diff --git a/.env.sample b/.env.sample index 1a40fcee..db5249a7 100644 --- a/.env.sample +++ b/.env.sample @@ -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_... diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index e7b70f32..2a541220 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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 { diff --git a/backend/go.mod b/backend/go.mod index 5d81e6a0..98e334bf 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 ) diff --git a/backend/go.sum b/backend/go.sum index 11e12c27..a74347da 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index b8f50508..7da69c53 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -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 + // 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"}) +} diff --git a/backend/internal/service/redis_service.go b/backend/internal/service/redis_service.go index 4a6b0ead..08e60a62 100644 --- a/backend/internal/service/redis_service.go +++ b/backend/internal/service/redis_service.go @@ -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() +} diff --git a/compose.infra.yaml b/compose.infra.yaml index 3b82cd84..47ab5d22 100644 --- a/compose.infra.yaml +++ b/compose.infra.yaml @@ -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: diff --git a/docker-compose.yaml b/docker-compose.yaml index 2ad152a8..849de395 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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. diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..5b420d61 --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/lib/core/services/audit_service.dart b/frontend/lib/core/services/audit_service.dart index 2080f7f8..b2ec6090 100644 --- a/frontend/lib/core/services/audit_service.dart +++ b/frontend/lib/core/services/audit_service.dart @@ -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 logEvent({ required String userId, diff --git a/frontend/lib/core/services/auth_proxy_service.dart b/frontend/lib/core/services/auth_proxy_service.dart index ec57a56c..858afd56 100644 --- a/frontend/lib/core/services/auth_proxy_service.dart +++ b/frontend/lib/core/services/auth_proxy_service.dart @@ -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> initEnchantedLink(String loginId) async { + static Future> 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) { diff --git a/frontend/lib/core/services/web_auth_integration_web.dart b/frontend/lib/core/services/web_auth_integration_web.dart index afa6cf40..2f0efbcc 100644 --- a/frontend/lib/core/services/web_auth_integration_web.dart +++ b/frontend/lib/core/services/web_auth_integration_web.dart @@ -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."); } } diff --git a/frontend/lib/features/auth/presentation/login_screen.dart b/frontend/lib/features/auth/presentation/login_screen.dart index 7487738b..fdc7ac50 100644 --- a/frontend/lib/features/auth/presentation/login_screen.dart +++ b/frontend/lib/features/auth/presentation/login_screen.dart @@ -23,6 +23,7 @@ class _LoginScreenState extends ConsumerState final TextEditingController _phoneController = TextEditingController(); final TextEditingController _smsCodeController = TextEditingController(); bool _smsSent = false; + String? _redirectUrl; @override void initState() { @@ -35,24 +36,27 @@ class _LoginScreenState extends ConsumerState if (uri.queryParameters.containsKey('t')) { _verifyToken(uri.queryParameters['t']!); } + if (uri.queryParameters.containsKey('redirect_url')) { + _redirectUrl = uri.queryParameters['redirect_url']; + } }); } Future _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 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 ); 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 _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 _handleSmsVerification() async { - final phone = _phoneController.text.trim(); - final code = _smsCodeController.text.trim(); - if (phone.isEmpty || code.isEmpty) return; + Future _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 _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 ), const SizedBox(height: 40), - // Tab Bar TabBar( controller: _tabController, tabs: const [ @@ -320,13 +321,12 @@ class _LoginScreenState extends ConsumerState ), 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 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 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 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 ), ); } -} +} \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 00000000..c9ef4dd9 --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/web/index.html b/frontend/web/index.html index 14e214a3..28898a2d 100644 --- a/frontend/web/index.html +++ b/frontend/web/index.html @@ -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`. --> - +