1
0
forked from baron/baron-sso

only JWT 발급

This commit is contained in:
2026-01-09 14:24:35 +09:00
parent 4813ec2f6d
commit b5aed4fedc
15 changed files with 564 additions and 470 deletions

View File

@@ -26,6 +26,7 @@ REDIS_ADDR=redis:6379
# --- Frontend Configuration ---
# Descope Project ID (Required for Auth)
DESCOPE_PROJECT_ID=P2t...your_descope_project_id
DESCOPE_MANAGEMENT_KEY=your_descope_management_key_here
# --- Naver Cloud Services ---
NAVER_CLOUD_ACCESS_KEY=ncp_iam_...

View File

@@ -51,6 +51,7 @@ func main() {
app.Use(cors.New(cors.Config{
AllowOrigins: "*", // Adjust in production
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
AllowMethods: "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS",
}))
app.Use(encryptcookie.New(encryptcookie.Config{
Key: getEnv("COOKIE_SECRET", "secret-key-must-be-32-bytes-long!"),
@@ -76,6 +77,12 @@ func main() {
auth.Post("/magic-link/verify", authHandler.VerifyMagicLink)
auth.Post("/sms", authHandler.SendSms)
auth.Post("/verify-sms", authHandler.VerifySms)
// Webhook for Descope Generic SMS Gateway
auth.Post("/webhooks/descope-sms", authHandler.HandleDescopeSmsRelay)
// Webhook for Descope Generic Email Gateway (Fake Email Strategy)
auth.Post("/webhooks/descope-email", authHandler.HandleDescopeEmailRelay)
// Client Logging Route (For Debugging)
api.Post("/client-log", func(c *fiber.Ctx) error {

View File

@@ -4,6 +4,8 @@ go 1.25.4
require (
github.com/ClickHouse/clickhouse-go/v2 v2.42.0
github.com/descope/go-sdk v1.6.23
github.com/go-redis/redis/v8 v8.11.5
github.com/gofiber/fiber/v2 v2.52.10
)
@@ -11,13 +13,19 @@ require (
github.com/ClickHouse/ch-go v0.69.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/go-redis/redis/v8 v8.11.5 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
@@ -32,5 +40,7 @@ require (
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e // indirect
golang.org/x/sys v0.39.0 // indirect
)

View File

@@ -9,19 +9,25 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/descope/go-sdk v1.6.23 h1:YO283ULq8O/6aCNLbqkG+QBaYnNMxf/mHSb4pmWe8u4=
github.com/descope/go-sdk v1.6.23/go.mod h1:lCwCgYOfrgjANMsR2BVe1yfX0Siwd2NjNAig0myWZqY=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY=
github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@@ -41,6 +47,18 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -49,6 +67,12 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
@@ -65,6 +89,7 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
@@ -93,6 +118,10 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e h1:Ctm9yurWsg7aWwIpH9Bnap/IdSVxixymIb3MhiMEQQA=
golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -100,6 +129,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -119,6 +150,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
@@ -132,6 +165,10 @@ google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -3,330 +3,331 @@ package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"os"
"strings"
"time"
"github.com/descope/go-sdk/descope"
"github.com/descope/go-sdk/descope/client"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v4"
)
type AuthHandler struct {
ProjectID string
SmsService domain.SmsService
RedisService *service.RedisService
DescopeClient *client.DescopeClient
}
func NewAuthHandler() *AuthHandler {
pid := os.Getenv("DESCOPE_PROJECT_ID")
if pid == "" {
// Fallback for dev if not set
pid = "P37DsGepBT6uDWb5TYYpb5RxUPuq"
}
redisService, err := service.NewRedisService()
if err != nil {
log.Fatalf("Failed to connect to Redis: %v", err)
}
projectID := os.Getenv("DESCOPE_PROJECT_ID")
managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY")
var descopeClient *client.DescopeClient
if projectID != "" {
descopeClient, err = client.NewWithConfig(&client.Config{
ProjectID: projectID,
ManagementKey: managementKey,
})
if err != nil {
log.Printf("Warning: Failed to initialize Descope Client: %v", err)
}
}
return &AuthHandler{
ProjectID: pid,
ProjectID: projectID,
SmsService: service.NewSmsService(),
RedisService: redisService,
DescopeClient: descopeClient,
}
}
// SendSms sends a verification code via SMS.
// SendSms sends a verification code via SMS. (Restored for completeness)
func (h *AuthHandler) SendSms(c *fiber.Ctx) error {
var req domain.SmsRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
log.Printf("[SMS 발송 시작] 요청된 번호: %s", req.PhoneNumber)
// Sanitize phone number: remove dashes
log.Printf("[SMS] Sending code to: %s", req.PhoneNumber)
sanitizedPhone := strings.ReplaceAll(req.PhoneNumber, "-", "")
log.Printf("[SMS 발송] 번호 정제 완료: %s", sanitizedPhone)
// Generate a 6-digit verification code
rand.Seed(time.Now().UnixNano())
code := fmt.Sprintf("%06d", rand.Intn(1000000))
content := fmt.Sprintf("[Baron SSO] Your verification code is %s", code)
log.Printf("[SMS 발송] 인증 코드 생성 완료: %s", code)
// Store the code in Redis before sending
if err := h.RedisService.StoreVerificationCode(sanitizedPhone, code); err != nil {
log.Printf("[SMS 발송 실패] Redis에 코드 저장 실패: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to process request"})
}
log.Printf("[SMS 발송] Redis에 인증 코드 저장 성공 (키: sms_verify:%s)", sanitizedPhone)
content := fmt.Sprintf("[Baron SSO] 인증번호: %s", code)
h.RedisService.StoreVerificationCode(sanitizedPhone, code)
if err := h.SmsService.SendSms(sanitizedPhone, content); err != nil {
log.Printf("[SMS 발송 실패] SENS API 호출 실패: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
}
log.Printf("[SMS 발송 성공] SENS API를 통해 SMS 발송 완료")
return c.JSON(fiber.Map{"message": "SMS sent successfully"})
}
// VerifySms verifies the provided SMS code.
// VerifySms verifies the provided SMS code. (Restored)
func (h *AuthHandler) VerifySms(c *fiber.Ctx) error {
var req domain.SmsVerifyRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
log.Printf("[SMS 검증 시작] 요청된 번호: %s, 코드: %s", req.PhoneNumber, req.Code)
sanitizedPhone := strings.ReplaceAll(req.PhoneNumber, "-", "")
log.Printf("[SMS 검증] 번호 정제 완료: %s", sanitizedPhone)
storedCode, err := h.RedisService.GetVerificationCode(sanitizedPhone)
if err != nil {
log.Printf("[SMS 검증 실패] Redis에서 코드 조회 실패: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Internal server error"})
}
log.Printf("[SMS 검증] Redis에서 코드 조회 완료. 저장된 코드: '%s'", storedCode)
storedCode, _ := h.RedisService.GetVerificationCode(sanitizedPhone)
if storedCode == "" || storedCode != req.Code {
log.Printf("[SMS 검증 실패] 코드가 일치하지 않거나 만료됨 (요청된 코드: %s, 저장된 코드: %s)", req.Code, storedCode)
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired code"})
}
log.Printf("[SMS 검증] 코드 일치 확인")
// Code is correct, delete it to prevent reuse
if err := h.RedisService.DeleteVerificationCode(sanitizedPhone); err != nil {
// Log the error but don't fail the request as the code was already verified
log.Printf("[SMS 검증] 경고: Redis에서 코드 삭제 실패 (하지만 검증은 성공으로 처리됨): %v", err)
} else {
log.Printf("[SMS 검증] Redis에서 사용된 코드 삭제 완료")
}
// Generate JWT token
claims := jwt.MapClaims{
"sub": sanitizedPhone, // Subject (user identifier)
"exp": time.Now().Add(time.Hour * 24).Unix(), // Expiration time (24 hours)
"iat": time.Now().Unix(), // Issued at
}
h.RedisService.DeleteVerificationCode(sanitizedPhone)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
log.Printf("[SMS 검증] JWT 클레임 생성 완료")
// Sign the token with the secret key
secretKey := os.Getenv("COOKIE_SECRET")
if secretKey == "" {
log.Println("Warning: COOKIE_SECRET is not set. Using a default, insecure key.")
secretKey = "default-insecure-secret-key-for-dev"
}
// Note: In a real scenario, you might want to generate a Descope JWT here too
// using the same logic as VerifyMagicLink, but for now returning a placeholder
// or you can call the Descope logic if needed.
token := "sms-verified-placeholder-token"
signedToken, err := token.SignedString([]byte(secretKey))
if err != nil {
log.Printf("[SMS 검증 실패] JWT 토큰 서명 실패: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate token"})
}
log.Printf("[SMS 검증 성공] JWT 토큰 발급 완료")
return c.JSON(fiber.Map{"token": signedToken})
return c.JSON(fiber.Map{"token": token})
}
// getBaseURL extracts the region code from Project ID if present (e.g., P37... -> api.37ds.descope.com)
// Default is api.descope.com
func (h *AuthHandler) getBaseURL() string {
if len(h.ProjectID) >= 32 {
// Heuristic: Descope project IDs usually start with 'P'
// If it's a region-specific project, the URL changes.
// For P37DsGepBT6uDWb5TYYpb5RxUPuq, the region is likely '37ds'.
// Actually, the safest bet is to use the standard API or check the logic.
// The error log showed 'api.37ds.descope.com'.
// Let's implement dynamic extraction or just use the standard one which redirects?
// No, standard is safer if region is unsure, but let's try to match the error URL.
// Region code is usually the first 4 chars after P? No.
// Let's rely on standard logic: https://api.descope.com usually works and routes.
// BUT the user specifically saw api.37ds.descope.com.
// Let's try the generic endpoint first.
return "https://api.descope.com"
}
return "https://api.descope.com"
}
// InitEnchantedLink proxies the sign-up/in request
// InitEnchantedLink - Custom Implementation (Restored)
func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
var req domain.EnchantedLinkInitRequest
if err := c.BodyParser(&req); err != nil {
fmt.Printf("[DEBUG] BodyParser failed: %v\n", err)
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
loginID := strings.ReplaceAll(req.LoginID, "-", "")
loginID = strings.ReplaceAll(loginID, " ", "")
fmt.Printf("[DEBUG] InitEnchantedLink - Received LoginID: '%s', URI: '%s'\n", req.LoginID, req.URI)
// Generate tokens
rand.Seed(time.Now().UnixNano())
token := fmt.Sprintf("tk_%d%d", time.Now().Unix(), rand.Intn(100000))
pendingRef := fmt.Sprintf("ref_%d%d", time.Now().Unix(), rand.Intn(100000))
// Prepare Descope Request
// Note: We are using the public API endpoint which expects Bearer <ProjectID>
// Store in Redis
h.RedisService.Set("enchanted_session:"+pendingRef, `{"status":"pending"}`, 5*time.Minute)
h.RedisService.Set("enchanted_token:"+token, fmt.Sprintf(`{"pendingRef":"%s","loginId":"%s"}`, pendingRef, loginID), 5*time.Minute)
// Send SMS
// Frontend URL should be dynamic or env based, but restoring hardcoded/env logic
// The frontend uses ssologin.hmac.kr
frontendURL := "http://ssologin.hmac.kr"
link := fmt.Sprintf("%s/?t=%s", frontendURL, token)
content := fmt.Sprintf("[Baron SSO] 로그인 링크: %s", link)
// Determine endpoint type (email vs sms)
// Default to Enchanted Link Email
apiPath := "enchantedlink/signup-in/email"
if req.Method == "sms" {
apiPath = "magiclink/signup-in/sms"
} else if len(req.LoginID) > 0 && req.LoginID[0] == '+' {
// Auto-detect if starts with +
apiPath = "magiclink/signup-in/sms"
log.Printf("[Enchanted] Sending link to %s", loginID)
if err := h.SmsService.SendSms(loginID, content); err != nil {
log.Printf("[Enchanted] SMS Failed: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
}
url := fmt.Sprintf("%s/v1/auth/%s", h.getBaseURL(), apiPath)
payload := map[string]string{
"loginId": req.LoginID,
// "redirectUrl": req.URI, // Let Descope use default from console configuration
}
body, _ := json.Marshal(payload)
r, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
return c.Status(fiber.StatusInternalServerError).SendString(err.Error())
}
r.Header.Set("Content-Type", "application/json")
r.Header.Set("Authorization", "Bearer "+h.ProjectID)
client := &http.Client{}
resp, err := client.Do(r)
if err != nil {
return c.Status(fiber.StatusBadGateway).SendString(err.Error())
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return c.Status(resp.StatusCode).Send(respBody)
}
return c.Send(respBody)
return c.JSON(fiber.Map{
"linkId": "SMS Sent",
"pendingRef": pendingRef,
"maskedEmail": loginID,
})
}
// PollEnchantedLink proxies the polling request
// PollEnchantedLink - Check status (Restored)
func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error {
var req domain.EnchantedLinkPollRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
url := fmt.Sprintf("%s/v1/auth/enchantedlink/pending-session", h.getBaseURL())
payload := map[string]string{
"pendingRef": req.PendingRef,
}
body, _ := json.Marshal(payload)
r, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
return c.Status(fiber.StatusInternalServerError).SendString(err.Error())
val, err := h.RedisService.Get("enchanted_session:" + req.PendingRef)
if err != nil || val == "" {
return c.JSON(fiber.Map{"status": "pending"})
}
r.Header.Set("Content-Type", "application/json")
r.Header.Set("Authorization", "Bearer "+h.ProjectID)
var data map[string]string
json.Unmarshal([]byte(val), &data)
client := &http.Client{}
resp, err := client.Do(r)
if err != nil {
return c.Status(fiber.StatusBadGateway).SendString(err.Error())
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return c.Status(resp.StatusCode).Send(respBody)
if data["status"] == "success" {
return c.JSON(fiber.Map{
"sessionJwt": data["jwt"],
"status": "ok",
})
}
return c.Send(respBody)
return c.JSON(fiber.Map{"status": "pending"})
}
// VerifyMagicLink verifies the token (t) from the email link
// VerifyMagicLink - Validate token and login (Restored)
func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
var req domain.MagicLinkVerifyRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
tokenKey := "enchanted_token:" + req.Token
val, err := h.RedisService.Get(tokenKey)
if err != nil || val == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired token"})
}
var tokenData map[string]string
json.Unmarshal([]byte(val), &tokenData)
pendingRef := tokenData["pendingRef"]
loginID := tokenData["loginId"]
// Use Magic Link Verify API
// 1. Generate Descope Session Directly (Management SDK)
if h.DescopeClient == nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Descope Client not configured"})
}
url := fmt.Sprintf("%s/v1/auth/magiclink/verify", h.getBaseURL())
// Use GenerateEmbeddedLink to get a session JWT directly for the user.
// This generates a JWT that mimics a successful login.
// In the Go SDK, GenerateEmbeddedLink usually returns the token string directly.
jwtToken, err := h.DescopeClient.Management.User().GenerateEmbeddedLink(context.Background(), loginID, nil, 0)
if err != nil {
// If user does not exist, create it and retry
if strings.Contains(err.Error(), "User not found") || strings.Contains(err.Error(), "E062108") {
log.Printf("User %s not found. Creating new user...", loginID)
// Format LoginID for Descope (E.164 for phones)
descopeLoginID := loginID
userObj := &descope.UserRequest{}
if strings.Contains(loginID, "@") {
userObj.Email = loginID
} else {
// LoginID is likely a phone number
// Convert 010-XXXX-XXXX (sanitized to 010XXXXXXXX) to +8210XXXXXXXX
if strings.HasPrefix(loginID, "010") {
descopeLoginID = "+82" + loginID[1:]
}
userObj.Phone = descopeLoginID
}
// Create user using the formatted LoginID
_, errCreate := h.DescopeClient.Management.User().Create(context.Background(), descopeLoginID, userObj)
if errCreate != nil {
log.Printf("Failed to create user: %v", errCreate)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create new user"})
}
// Retry generating token with the Descope LoginID
jwtToken, err = h.DescopeClient.Management.User().GenerateEmbeddedLink(context.Background(), descopeLoginID, nil, 0)
if err != nil {
log.Printf("Failed to generate Descope Session after creation: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate token for new user"})
}
} else {
log.Printf("Failed to generate Descope Session: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate upstream token"})
}
}
// Exchange the Embedded Token for a real Session JWT
// We pass nil for ResponseWriter as we don't need the SDK to set cookies here.
authInfo, err := h.DescopeClient.Auth.MagicLink().Verify(context.Background(), jwtToken, nil)
if err != nil {
log.Printf("Failed to verify embedded token: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to verify upstream token"})
}
realJwtToken := authInfo.SessionToken.JWT
// Update Session
sessionData, _ := json.Marshal(map[string]string{
"status": "success",
"jwt": realJwtToken,
})
h.RedisService.Set("enchanted_session:"+pendingRef, string(sessionData), 5*time.Minute)
payload := map[string]string{
"token": req.Token,
}
body, _ := json.Marshal(payload)
r, err := http.NewRequest("POST", url, bytes.NewBuffer(body))
if err != nil {
return c.Status(fiber.StatusInternalServerError).SendString(err.Error())
}
r.Header.Set("Content-Type", "application/json")
r.Header.Set("Authorization", "Bearer "+h.ProjectID)
client := &http.Client{}
resp, err := client.Do(r)
if err != nil {
return c.Status(fiber.StatusBadGateway).SendString(err.Error())
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return c.Status(resp.StatusCode).Send(respBody)
}
return c.Send(respBody)
return c.JSON(fiber.Map{
"token": realJwtToken,
"message": "Login successful",
})
}
// ProxyToDescope (Placeholder)
func (h *AuthHandler) ProxyToDescope(c *fiber.Ctx, path string, payload interface{}) error {
return c.Status(501).SendString("Descope Proxy Disabled")
}
// HandleDescopeSmsRelay
func (h *AuthHandler) HandleDescopeSmsRelay(c *fiber.Ctx) error {
var req struct {
Recipient string `json:"recipient"`
Body string `json:"body"`
}
if err := c.BodyParser(&req); err != nil {
log.Printf("[Webhook] Body parsing failed: %v", err)
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
if req.Recipient == "" || req.Body == "" {
log.Printf("[Webhook] Missing recipient or body")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Missing recipient or body"})
}
log.Printf("[Webhook] Received SMS request for %s", req.Recipient)
phone := req.Recipient
if strings.HasPrefix(phone, "+82") {
phone = "0" + phone[3:]
}
phone = strings.ReplaceAll(phone, "-", "")
phone = strings.ReplaceAll(phone, " ", "")
if err := h.SmsService.SendSms(phone, req.Body); err != nil {
log.Printf("[Webhook] Failed to forward SMS to Naver: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS via Naver"})
}
log.Printf("[Webhook] Successfully forwarded SMS to %s", phone)
return c.JSON(fiber.Map{"status": "ok"})
}
// HandleDescopeEmailRelay - Webhook for Descope Generic Email Gateway
// Used for "Fake Email Strategy" to support Polling with SMS.
func (h *AuthHandler) HandleDescopeEmailRelay(c *fiber.Ctx) error {
var req struct {
To string `json:"to"` // e.g., 01012345678@sms.baron
Subject string `json:"subject"`
Text string `json:"text"` // Body containing the link
}
if err := c.BodyParser(&req); err != nil {
log.Printf("[Email Webhook] Body parsing failed: %v", err)
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
log.Printf("[Email Webhook] Received email request for %s", req.To)
// Check if it's a Fake Email for SMS
if strings.HasSuffix(req.To, "@sms.baron") {
phone := strings.Split(req.To, "@")[0]
// Sanitize Phone (Descope might sanitize or not, but let's be safe)
if strings.HasPrefix(phone, "+82") {
phone = "0" + phone[3:]
}
// Send SMS with the text body (Descope template should be optimized for SMS)
if err := h.SmsService.SendSms(phone, req.Text); err != nil {
log.Printf("[Email Webhook] Failed to forward Email-as-SMS: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
}
log.Printf("[Email Webhook] Successfully converted Email to SMS for %s", phone)
return c.JSON(fiber.Map{"status": "ok"})
}
// Real Email Handling (Not implemented in this Relay)
// You would need an SMTP service here if you route ALL emails through this relay.
log.Printf("[Email Webhook] Real email skipped (Not implemented): %s", req.To)
return c.Status(501).JSON(fiber.Map{"error": "Real email sending not implemented"})
}

View File

@@ -18,7 +18,7 @@ type RedisService struct {
func NewRedisService() (*RedisService, error) {
redisAddr := os.Getenv("REDIS_ADDR")
if redisAddr == "" {
redisAddr = "localhost:6379" // Fallback for local dev without Docker
redisAddr = "localhost:6389" // Fallback for local dev without Docker
}
rdb := redis.NewClient(&redis.Options{
@@ -60,3 +60,22 @@ func (s *RedisService) DeleteVerificationCode(phone string) error {
key := "sms_verify:" + phone
return s.Client.Del(ctx, key).Err()
}
// Set stores a key-value pair with expiration
func (s *RedisService) Set(key string, value string, expiration time.Duration) error {
return s.Client.Set(ctx, key, value, expiration).Err()
}
// Get retrieves a value by key
func (s *RedisService) Get(key string) (string, error) {
val, err := s.Client.Get(ctx, key).Result()
if err == redis.Nil {
return "", nil
}
return val, err
}
// Delete removes a key
func (s *RedisService) Delete(key string) error {
return s.Client.Del(ctx, key).Err()
}

View File

@@ -29,8 +29,9 @@ services:
image: redis:7-alpine
container_name: baron_redis
restart: always
command: redis-server --port 6389
ports:
- "6379:6379"
- "6389:6389"
volumes:
- redis_data:/data
networks:

View File

@@ -11,6 +11,14 @@ services:
environment:
- APP_ENV=${APP_ENV:-development}
- COOKIE_SECRET=${COOKIE_SECRET}
- JWT_SECRET=${JWT_SECRET}
- DESCOPE_PROJECT_ID=${DESCOPE_PROJECT_ID}
- DESCOPE_MANAGEMENT_KEY=${DESCOPE_MANAGEMENT_KEY}
- NAVER_CLOUD_ACCESS_KEY=${NAVER_CLOUD_ACCESS_KEY}
- NAVER_CLOUD_SECRET_KEY=${NAVER_CLOUD_SECRET_KEY}
- NAVER_CLOUD_SERVICE_ID=${NAVER_CLOUD_SERVICE_ID}
- NAVER_SENDER_PHONE_NUMBER=${NAVER_SENDER_PHONE_NUMBER}
- FRONTEND_URL=${FRONTEND_URL}
- DB_HOST=postgres
- CLICKHOUSE_HOST=clickhouse
- CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000}
@@ -27,28 +35,18 @@ services:
command: ["go", "run", "./cmd/server/main.go"]
frontend:
image: ghcr.io/cirruslabs/flutter:stable # Use stable version for 2026 compatibility
build:
context: ./frontend
dockerfile: Dockerfile
container_name: baron_frontend
working_dir: /app
environment:
- DESCOPE_PROJECT_ID=${DESCOPE_PROJECT_ID}
ports:
- "${FRONTEND_PORT:-5000}:5000"
volumes:
- ./frontend:/app
command:
[
"flutter",
"run",
"-d",
"web-server",
"--web-port",
"5000",
"--web-hostname",
"0.0.0.0",
]
networks:
- baron_net
depends_on:
- backend
# Dummy service to wait for infra network if needed,
# but essentially we assume infra is running.

17
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
# Stage 1: Build Flutter
FROM ghcr.io/cirruslabs/flutter:stable AS build
WORKDIR /app
COPY . .
# Get dependencies and build for web
RUN flutter pub get
RUN flutter build web --release
# Stage 2: Serve with Nginx
FROM nginx:alpine
# Copy built assets
COPY --from=build /app/build/web /usr/share/nginx/html
# Copy custom Nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 5000
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -3,7 +3,7 @@ import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
class AuditService {
static final String _baseUrl = dotenv.env['BACKEND_URL'] ?? 'http://localhost:3000';
static const String _baseUrl = 'https://ssologin.hmac.kr';
static Future<void> logEvent({
required String userId,

View File

@@ -3,18 +3,25 @@ import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
class AuthProxyService {
static final String _baseUrl = dotenv.env['BACKEND_URL'] ?? 'http://localhost:3000';
// HARDCODED URL
static const String _baseUrl = 'https://ssologin.hmac.kr';
static Future<Map<String, dynamic>> initEnchantedLink(String loginId) async {
static Future<Map<String, dynamic>> initEnchantedLink(String loginId, {String? method}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init');
final frontendUrl = dotenv.env['FRONTEND_URL'] ?? 'http://ssologin.hmac.kr';
final body = {
'loginId': loginId,
'uri': frontendUrl,
};
if (method != null) {
body['method'] = method;
}
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'loginId': loginId,
'uri': 'http://localhost:5000', // Use 5000 as it's definitely allowed
}),
body: jsonEncode(body),
);
if (response.statusCode == 200) {

View File

@@ -1,34 +1,24 @@
import 'dart:html' as html;
import 'dart:async';
void implSendLoginSuccess(String token) {
final message = {'type': 'LOGIN_SUCCESS', 'token': token};
bool sent = false;
// 1. Try postMessage
if (html.window.opener != null) {
try {
html.window.opener!.postMessage(message, '*');
sent = true;
print("Sent login success message to opener");
} catch (e) {
print("Failed to postMessage: $e");
}
// 2. Fallback: Redirect opener directly (Force refresh with token)
try {
// Only redirect if it's localhost:8000 to be safe, or just do it.
// This will cause the parent window to reload, which is fine for login.
html.window.opener!.location.href = "http://localhost:8000?token=$token";
sent = true;
} catch (e) {
print("Failed to redirect opener: $e");
}
}
if (!sent) {
print("No opener found. Redirecting current window to target.");
// Fallback: Redirect THIS window to localhost:8000 with token
html.window.location.href = "http://localhost:8000?token=$token";
// Close the popup after a short delay to ensure message sending
Timer(const Duration(milliseconds: 500), () {
html.window.close();
});
} else {
// Should not happen given isPopup check, but as fallback:
print("No opener found during popup flow.");
}
}

View File

@@ -23,6 +23,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final TextEditingController _phoneController = TextEditingController();
final TextEditingController _smsCodeController = TextEditingController();
bool _smsSent = false;
String? _redirectUrl;
@override
void initState() {
@@ -35,24 +36,27 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (uri.queryParameters.containsKey('t')) {
_verifyToken(uri.queryParameters['t']!);
}
if (uri.queryParameters.containsKey('redirect_url')) {
_redirectUrl = uri.queryParameters['redirect_url'];
}
});
}
Future<void> _verifyToken(String token) async {
try {
// Use Proxy to verify token
// Use Backend to verify the token (Backend-Driven Flow)
// The backend will validate the local token, and then trigger Descope JWT generation.
// This approves the pending session for the Polling device.
await AuthProxyService.verifyMagicLink(token);
// Note: If this device (Mobile) also needs to login, we would need to
// parse the response from verifyMagicLink which contains the JWT.
// For now, we assume this action primarily approves the PC session.
if (mounted) {
_showSuccessDialog();
}
} catch (e) {
// Ignore "Missing session JWT" if it happens (though proxy might handle it differently)
if (e.toString().contains("Missing session JWT")) {
if (mounted) _showSuccessDialog();
return;
}
if (mounted) {
_showError("Verification failed: $e");
}
@@ -86,8 +90,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final password = _passwordController.text;
if (password.isNotEmpty) {
// Email + Password Flow (Keep SDK as is, assuming Password flow might work or fail same way.
// If password flow fails too, we need proxy for that as well. But let's focus on Enchanted Link first as requested.)
try {
final authResponse = await Descope.password.signIn(
loginId: email,
@@ -95,197 +97,197 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
);
final session = DescopeSession.fromAuthenticationResponse(authResponse);
Descope.sessionManager.manageSession(session);
await AuditService.logEvent(
userId: session.user?.userId ?? email,
eventType: 'login_success',
status: 'success',
details: 'Method: Email/Password',
);
if (mounted) {
final token = session.sessionToken.jwt;
if (WebAuthIntegration.isPopup()) {
WebAuthIntegration.sendLoginSuccess(token);
_showError("Login Successful! You can close this window.");
} else {
context.go('/dashboard');
}
}
if (mounted) _onLoginSuccess(session.sessionToken.jwt);
} catch (e) {
_showError("Email/Password Login Failed: $e");
}
} else {
// Enchanted Link Flow (via Proxy)
try {
// 1. Init via Proxy
final initData = await AuthProxyService.initEnchantedLink(email);
final linkId = initData['linkId'];
final pendingRef = initData['pendingRef'];
if (mounted) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text("Check your Email"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text("We sent an email to $email"),
const SizedBox(height: 16),
Text(
"Security Number: $linkId",
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.blue),
),
const SizedBox(height: 8),
const Text("Click the matching number in your email."),
const SizedBox(height: 16),
const LinearProgressIndicator(),
const SizedBox(height: 16),
],
),
),
);
// 2. Poll via Proxy (Loop until success or timeout)
String sessionToken = "";
int attempts = 0;
const maxAttempts = 60; // 2 minutes (assuming 2s delay)
while (attempts < maxAttempts && mounted) {
attempts++;
try {
final pollData = await AuthProxyService.pollEnchantedLink(pendingRef);
// Send log to backend
// AuthProxyService.logError("[DEBUG] Poll response keys: ${pollData.keys.toList()}");
// Descope API returns 'sessionJwt', not 'sessionToken'
var tokenObj = pollData['sessionJwt'] ?? pollData['sessionToken'];
if (tokenObj != null) {
if (tokenObj is Map) {
sessionToken = tokenObj['jwt'] ?? "";
} else if (tokenObj is String) {
sessionToken = tokenObj;
}
}
if (sessionToken.isNotEmpty) {
break; // Success!
}
} catch (e) {
// Check if it's the "pending" error. If so, continue.
// The error message from backend is likely a string in exception.
// A robust implementation would parse the error code.
// For PoC, we just assume any error means "not ready yet" unless it's a fatal one.
// Let's print debug but continue.
print("Polling attempt $attempts: Waiting... ($e)");
}
await Future.delayed(const Duration(seconds: 2));
}
if (sessionToken.isEmpty) {
throw Exception("Polling timed out or failed.");
}
// Note: pollData structure depends on what Descope API returns.
// Usually it returns full auth response.
// Let's assume we get the JWT string directly or extract it.
// The proxy just forwards the JSON. Descope /poll returns standard auth info.
// Manually handle session if needed or just use token.
// For PoC, we prioritize token handoff.
await AuditService.logEvent(
userId: email, // We might not have full user object yet
eventType: 'login_success',
status: 'success',
details: 'Method: Email/EnchantedLink/Proxy',
);
if (mounted) {
Navigator.of(context).pop(); // Close Dialog
if (WebAuthIntegration.isPopup()) {
WebAuthIntegration.sendLoginSuccess(sessionToken);
_showError("Login Successful! You can close this window.");
} else {
// For dashboard, we might need to properly init Descope session.
// Since we bypassed SDK, Descope.sessionManager.session is null.
// We can try to hydrate it if SDK allows, or just ignore for now if this is primarily a Launcher.
_showError("Login Successful (Standalone mode limited without SDK session)");
// context.go('/dashboard');
}
}
}
} catch (e) {
if (mounted && Navigator.canPop(context)) {
// Close dialog if open? logic is tricky without state, but let's assume error means stop.
Navigator.of(context).pop();
}
_showError("Enchanted Link Failed (Proxy): $e");
}
// Email Enchanted Link (Descope Standard)
_initiateDescopeLinkFlow(email, isSms: false);
}
}
Future<void> _handleSmsLogin() async {
final phone = _phoneController.text.trim();
if (phone.isEmpty) return;
final rawPhone = _phoneController.text.trim();
if (rawPhone.isEmpty) return;
print("[Frontend] SMS 코드 발송 시작. 번호: $phone");
// Sanitize phone number
String phone = rawPhone.replaceAll(RegExp(r'[-\s]'), '');
// Ensure 010 format if needed, but backend handles it too
try {
await AuthProxyService.sendSms(phone);
print("[Frontend] SMS 코드 발송 요청 성공.");
setState(() {
_smsSent = true;
});
if (mounted) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(child: CircularProgressIndicator()),
);
}
// 1. Init via Backend API (Not Descope SDK)
final initResponse = await AuthProxyService.initEnchantedLink(phone);
final pendingRef = initResponse['pendingRef'];
if (mounted) {
Navigator.of(context).pop(); // Close Loading
// Show Waiting Dialog
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text("SMS Sent"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text("Please check the link sent to your phone."),
const SizedBox(height: 16),
const LinearProgressIndicator(),
const SizedBox(height: 16),
TextButton(
onPressed: () {
Navigator.of(context).pop(); // Allow canceling
},
child: const Text("Cancel")
)
],
),
),
);
// 2. Poll Backend manually
_pollForSession(pendingRef);
}
} catch (e) {
print("[Frontend] SMS 코드 발송 요청 실패: $e");
if (mounted && Navigator.canPop(context)) Navigator.of(context).pop();
_showError("Failed to send SMS: $e");
}
}
Future<void> _handleSmsVerification() async {
final phone = _phoneController.text.trim();
final code = _smsCodeController.text.trim();
if (phone.isEmpty || code.isEmpty) return;
Future<void> _pollForSession(String pendingRef) async {
int attempts = 0;
const maxAttempts = 60; // 2 minutes
print("[Frontend] SMS 코드 검증 시작. 번호: $phone, 코드: $code");
while (attempts < maxAttempts && mounted) {
await Future.delayed(const Duration(seconds: 2));
attempts++;
try {
final result = await AuthProxyService.pollEnchantedLink(pendingRef);
if (result['status'] == 'ok') {
final jwt = result['sessionJwt'];
if (jwt != null) {
// Note: Manually constructing DescopeSession can be complex due to abstract classes.
// In a real production app, you should use the SDK's built-in exchange/verify methods.
// For this prototype, we will proceed with the login success callback.
// If session management is required immediately, we'd need to match the specific
// SDK version's Token implementation.
if (mounted) {
Navigator.of(context).pop(); // Close Polling Dialog
_onLoginSuccess(jwt);
}
return;
}
}
} catch (e) {
print("Polling error: $e");
// Continue polling even on temporary network error?
// Or break? Let's continue.
}
}
if (mounted) {
Navigator.of(context).pop(); // Close Polling Dialog
_showError("Login timed out.");
}
}
Future<void> _initiateDescopeLinkFlow(String loginId, {required bool isSms}) async {
try {
final result = await AuthProxyService.verifySmsCode(phone, code);
final token = result['token'];
print("[Frontend] SMS 코드 검증 성공. JWT 수신: $token");
// TODO: Handle the JWT token from the result, e.g., result['token']
_showSuccessDialog();
if (mounted) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(child: CircularProgressIndicator()),
);
}
// 1. Init via Descope SDK
final signUpOrInResponse = await Descope.enchantedLink.signUpOrIn(
loginId: loginId,
redirectUrl: "http://ssologin.hmac.kr/auth/callback",
);
if (mounted) {
Navigator.of(context).pop(); // Close Loading
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text(isSms ? "SMS Sent" : "Email Sent"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text("We sent a login link to ${isSms ? loginId.split('@')[0] : loginId}"),
const SizedBox(height: 16),
// For SMS, we might not get a Link ID in the message if the template doesn't include it.
// But Enchanted Link always has one.
Text(
"Security Number: ${signUpOrInResponse.linkId}",
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.blue),
),
const SizedBox(height: 8),
const Text("Click the matching number."),
const SizedBox(height: 16),
const LinearProgressIndicator(),
],
),
),
);
// 2. Poll via Descope SDK
final authResponse = await Descope.enchantedLink.pollForSession(pendingRef: signUpOrInResponse.pendingRef);
final session = DescopeSession.fromAuthenticationResponse(authResponse);
Descope.sessionManager.manageSession(session);
if (mounted) {
Navigator.of(context).pop(); // Close Dialog
_onLoginSuccess(session.sessionToken.jwt);
}
}
} catch (e) {
print("[Frontend] SMS 코드 검증 실패: $e");
_showError("Failed to verify code: $e");
if (mounted && Navigator.canPop(context)) Navigator.of(context).pop();
_showError("Login Failed: $e");
}
}
void _onLoginSuccess(String token) {
if (!mounted) return;
if (WebAuthIntegration.isPopup()) {
WebAuthIntegration.sendLoginSuccess(token);
_showError("Login Successful! You can close this window.");
} else if (_redirectUrl != null) {
final target = "$_redirectUrl?token=$token";
launchUrlString(target, webOnlyWindowName: '_self');
} else {
_showError("Login Successful (Token Received)");
}
}
void _showError(String message) {
if (!mounted) return;
// Show Snackbar
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.red),
);
// Send log to backend for Docker visibility
try {
// Use AuthProxyService base URL logic or dotenv, but for simplicity here use relative or direct.
// Since we are in the same network context as Proxy, we can assume localhost:3000 or relative path if deployed.
// But Flutter Web runs in browser, so we need the full URL reachable from browser.
// We'll use the same host logic as AuthProxyService (which uses dotenv BACKEND_URL).
// Since we can't easily import http here without clutter, we'll invoke a helper method if available,
// or just add the http call here. We already import AuthProxyService.
// Let's add a log method to AuthProxyService to keep it clean.
AuthProxyService.logError(message);
} catch (e) {
print("Failed to send log to backend: $e");
// ignore
}
}
@@ -310,7 +312,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
),
const SizedBox(height: 40),
// Tab Bar
TabBar(
controller: _tabController,
tabs: const [
@@ -320,13 +321,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
),
const SizedBox(height: 24),
// Tab View Content
SizedBox(
height: 300, // Slightly increased height for content
height: 300,
child: TabBarView(
controller: _tabController,
children: [
// Email/Password Form
// Email Form
Column(
children: [
TextField(
@@ -342,7 +342,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: "Password",
labelText: "Password (Optional)",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.lock_outline),
),
@@ -353,15 +353,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
child: const Text("Sign In"),
child: const Text("Sign In / Send Link"),
),
],
),
// Phone/SMS Form
// Phone Form
Column(
children: [
if (!_smsSent) ...[
TextField(
controller: _phoneController,
decoration: const InputDecoration(
@@ -377,26 +376,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
child: const Text("Send Verification Code"),
child: const Text("Send Login Link"),
),
] else ...[
TextField(
controller: _smsCodeController,
decoration: const InputDecoration(
labelText: "Verification Code",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.password),
),
const SizedBox(height: 16),
const Text(
"We will send a login link to your phone via SMS.",
style: TextStyle(color: Colors.grey, fontSize: 12),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton(
onPressed: _handleSmsVerification,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
child: const Text("Verify Code"),
),
],
],
),
],
@@ -408,4 +395,4 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
),
);
}
}
}

19
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,19 @@
server {
listen 5000;
# Backend API Proxy
location /api {
proxy_pass http://baron_backend:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Frontend Static Files
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
}

View File

@@ -14,7 +14,7 @@
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<base href="/">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">