forked from baron/baron-sso
only JWT 발급
This commit is contained in:
@@ -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_...
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
17
frontend/Dockerfile
Normal 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;"]
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
19
frontend/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user