forked from baron/baron-sso
Merge pull request 'dev/namecard2' (#13) from dev/namecard2 into main
Reviewed-on: ai-team/baron-sso#13
This commit is contained in:
@@ -21,7 +21,14 @@ DB_NAME=baron_sso
|
|||||||
# --- Backend Configuration ---
|
# --- Backend Configuration ---
|
||||||
# Must be 32 bytes. Generate with `openssl rand -hex 32`
|
# Must be 32 bytes. Generate with `openssl rand -hex 32`
|
||||||
COOKIE_SECRET=super-secret-key-must-be-32-bytes!
|
COOKIE_SECRET=super-secret-key-must-be-32-bytes!
|
||||||
|
REDIS_ADDR=redis:6379
|
||||||
|
|
||||||
# --- Frontend Configuration ---
|
# --- Frontend Configuration ---
|
||||||
# Descope Project ID (Required for Auth)
|
# Descope Project ID (Required for Auth)
|
||||||
DESCOPE_PROJECT_ID=P2t...your_descope_project_id
|
DESCOPE_PROJECT_ID=P2t...your_descope_project_id
|
||||||
|
|
||||||
|
# --- Naver Cloud Services ---
|
||||||
|
NAVER_CLOUD_ACCESS_KEY=ncp_iam_...
|
||||||
|
NAVER_CLOUD_SECRET_KEY=ncp_iam_...
|
||||||
|
NAVER_CLOUD_SERVICE_ID=ncp:sms:kr:...:...
|
||||||
|
NAVER_SENDER_PHONE_NUMBER=...
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ func main() {
|
|||||||
|
|
||||||
// 2. Initialize Handlers
|
// 2. Initialize Handlers
|
||||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||||
|
authHandler := handler.NewAuthHandler()
|
||||||
|
|
||||||
// 3. Initialize Fiber
|
// 3. Initialize Fiber
|
||||||
app := fiber.New(fiber.Config{
|
app := fiber.New(fiber.Config{
|
||||||
@@ -47,7 +48,10 @@ func main() {
|
|||||||
// Middleware
|
// Middleware
|
||||||
app.Use(logger.New())
|
app.Use(logger.New())
|
||||||
app.Use(recover.New())
|
app.Use(recover.New())
|
||||||
app.Use(cors.New()) // Allow Frontend Access
|
app.Use(cors.New(cors.Config{
|
||||||
|
AllowOrigins: "*", // Adjust in production
|
||||||
|
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
|
||||||
|
}))
|
||||||
app.Use(encryptcookie.New(encryptcookie.Config{
|
app.Use(encryptcookie.New(encryptcookie.Config{
|
||||||
Key: getEnv("COOKIE_SECRET", "secret-key-must-be-32-bytes-long!"),
|
Key: getEnv("COOKIE_SECRET", "secret-key-must-be-32-bytes-long!"),
|
||||||
}))
|
}))
|
||||||
@@ -65,6 +69,28 @@ func main() {
|
|||||||
api := app.Group("/api/v1")
|
api := app.Group("/api/v1")
|
||||||
api.Post("/audit", auditHandler.CreateLog)
|
api.Post("/audit", auditHandler.CreateLog)
|
||||||
|
|
||||||
|
// Auth Proxy Routes
|
||||||
|
auth := api.Group("/auth")
|
||||||
|
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
|
||||||
|
auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink)
|
||||||
|
auth.Post("/magic-link/verify", authHandler.VerifyMagicLink)
|
||||||
|
auth.Post("/sms", authHandler.SendSms)
|
||||||
|
auth.Post("/verify-sms", authHandler.VerifySms)
|
||||||
|
|
||||||
|
// Client Logging Route (For Debugging)
|
||||||
|
api.Post("/client-log", func(c *fiber.Ctx) error {
|
||||||
|
type LogReq struct {
|
||||||
|
Level string `json:"level"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
var req LogReq
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.SendStatus(fiber.StatusBadRequest)
|
||||||
|
}
|
||||||
|
log.Printf("[CLIENT-LOG] [%s] %s", req.Level, req.Message)
|
||||||
|
return c.SendStatus(fiber.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
// Start Server
|
// Start Server
|
||||||
port := getEnv("PORT", "3000")
|
port := getEnv("PORT", "3000")
|
||||||
log.Fatal(app.Listen(":" + port))
|
log.Fatal(app.Listen(":" + port))
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ require (
|
|||||||
github.com/ClickHouse/ch-go v0.69.0 // indirect
|
github.com/ClickHouse/ch-go v0.69.0 // indirect
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.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/city v1.0.1 // indirect
|
||||||
github.com/go-faster/errors v0.7.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/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
|||||||
@@ -9,13 +9,19 @@ 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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
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/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
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/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 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
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/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY=
|
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/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/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/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
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=
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
|||||||
27
backend/internal/domain/auth_models.go
Normal file
27
backend/internal/domain/auth_models.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
type EnchantedLinkInitRequest struct {
|
||||||
|
LoginID string `json:"loginId"`
|
||||||
|
URI string `json:"uri,omitempty"` // Redirect URI (optional for polling flow)
|
||||||
|
Method string `json:"method,omitempty"` // "email" or "sms"
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnchantedLinkInitResponse struct {
|
||||||
|
LinkID string `json:"linkId"`
|
||||||
|
PendingRef string `json:"pendingRef"`
|
||||||
|
MaskedEmail string `json:"maskedEmail"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnchantedLinkPollRequest struct {
|
||||||
|
PendingRef string `json:"pendingRef"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EnchantedLinkPollResponse struct {
|
||||||
|
SessionToken string `json:"sessionToken"` // JWT
|
||||||
|
RefreshToken string `json:"refreshToken"`
|
||||||
|
UserID string `json:"userId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MagicLinkVerifyRequest struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
41
backend/internal/domain/sms_models.go
Normal file
41
backend/internal/domain/sms_models.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
// SmsService defines the interface for sending SMS messages.
|
||||||
|
type SmsService interface {
|
||||||
|
SendSms(to, content string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NaverSmsRequest represents the request body for the Naver Cloud SMS API.
|
||||||
|
type NaverSmsRequest struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
CountryCode string `json:"countryCode"`
|
||||||
|
From string `json:"from"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Messages []SmsMessage `json:"messages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SmsMessage represents a single message to be sent.
|
||||||
|
type SmsMessage struct {
|
||||||
|
To string `json:"to"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NaverSmsResponse represents the response from the Naver Cloud SMS API.
|
||||||
|
type NaverSmsResponse struct {
|
||||||
|
RequestID string `json:"requestId"`
|
||||||
|
RequestTime string `json:"requestTime"`
|
||||||
|
StatusCode string `json:"statusCode"`
|
||||||
|
StatusName string `json:"statusName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SmsRequest represents the request body for sending an SMS.
|
||||||
|
type SmsRequest struct {
|
||||||
|
PhoneNumber string `json:"phoneNumber"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SmsVerifyRequest represents the request body for verifying an SMS code.
|
||||||
|
type SmsVerifyRequest struct {
|
||||||
|
PhoneNumber string `json:"phoneNumber"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
332
backend/internal/handler/auth_handler.go
Normal file
332
backend/internal/handler/auth_handler.go
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/service"
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/golang-jwt/jwt/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthHandler struct {
|
||||||
|
ProjectID string
|
||||||
|
SmsService domain.SmsService
|
||||||
|
RedisService *service.RedisService
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AuthHandler{
|
||||||
|
ProjectID: pid,
|
||||||
|
SmsService: service.NewSmsService(),
|
||||||
|
RedisService: redisService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendSms sends a verification code via SMS.
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
|
||||||
|
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.
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
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"})
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[DEBUG] InitEnchantedLink - Received LoginID: '%s', URI: '%s'\n", req.LoginID, req.URI)
|
||||||
|
|
||||||
|
// Prepare Descope Request
|
||||||
|
// Note: We are using the public API endpoint which expects Bearer <ProjectID>
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PollEnchantedLink proxies the polling request
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyMagicLink verifies the token (t) from the email link
|
||||||
|
|
||||||
|
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"})
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Use Magic Link Verify API
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/v1/auth/magiclink/verify", h.getBaseURL())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
62
backend/internal/service/redis_service.go
Normal file
62
backend/internal/service/redis_service.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-redis/redis/v8"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ctx = context.Background()
|
||||||
|
|
||||||
|
type RedisService struct {
|
||||||
|
Client *redis.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRedisService creates and returns a new RedisService
|
||||||
|
func NewRedisService() (*RedisService, error) {
|
||||||
|
redisAddr := os.Getenv("REDIS_ADDR")
|
||||||
|
if redisAddr == "" {
|
||||||
|
redisAddr = "localhost:6379" // Fallback for local dev without Docker
|
||||||
|
}
|
||||||
|
|
||||||
|
rdb := redis.NewClient(&redis.Options{
|
||||||
|
Addr: redisAddr,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ping the server to check the connection
|
||||||
|
if _, err := rdb.Ping(ctx).Result(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RedisService{Client: rdb}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoreVerificationCode saves the SMS verification code with a 3-minute expiration
|
||||||
|
func (s *RedisService) StoreVerificationCode(phone, code string) error {
|
||||||
|
// Key format: "sms_verify:01012345678"
|
||||||
|
key := "sms_verify:" + phone
|
||||||
|
expiration := 3 * time.Minute
|
||||||
|
err := s.Client.Set(ctx, key, code, expiration).Err()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVerificationCode retrieves the SMS verification code
|
||||||
|
func (s *RedisService) GetVerificationCode(phone string) (string, error) {
|
||||||
|
key := "sms_verify:" + phone
|
||||||
|
code, err := s.Client.Get(ctx, key).Result()
|
||||||
|
if err == redis.Nil {
|
||||||
|
// Key does not exist (expired or incorrect phone number)
|
||||||
|
return "", nil
|
||||||
|
} else if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return code, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteVerificationCode removes the verification code after successful verification
|
||||||
|
func (s *RedisService) DeleteVerificationCode(phone string) error {
|
||||||
|
key := "sms_verify:" + phone
|
||||||
|
return s.Client.Del(ctx, key).Err()
|
||||||
|
}
|
||||||
112
backend/internal/service/sms_service.go
Normal file
112
backend/internal/service/sms_service.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
|
)
|
||||||
|
type SmsServiceImpl struct {
|
||||||
|
accessKey string
|
||||||
|
secretKey string
|
||||||
|
serviceID string
|
||||||
|
senderPhone string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSmsService() domain.SmsService {
|
||||||
|
// Sanitize sender phone number right after reading from env
|
||||||
|
rawSenderPhone := os.Getenv("NAVER_SENDER_PHONE_NUMBER")
|
||||||
|
sanitizedSenderPhone := strings.ReplaceAll(rawSenderPhone, "-", "")
|
||||||
|
log.Printf("[서비스 초기화] 발신자 번호 처리: 원본='%s', 정제 후='%s'", rawSenderPhone, sanitizedSenderPhone)
|
||||||
|
|
||||||
|
return &SmsServiceImpl{
|
||||||
|
accessKey: os.Getenv("NAVER_CLOUD_ACCESS_KEY"),
|
||||||
|
secretKey: os.Getenv("NAVER_CLOUD_SECRET_KEY"),
|
||||||
|
serviceID: os.Getenv("NAVER_CLOUD_SERVICE_ID"),
|
||||||
|
senderPhone: sanitizedSenderPhone,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SmsServiceImpl) SendSms(to, content string) error {
|
||||||
|
timestamp := strconv.FormatInt(time.Now().UnixNano()/int64(time.Millisecond), 10)
|
||||||
|
apiURL := fmt.Sprintf("https://sens.apigw.ntruss.com/sms/v2/services/%s/messages", s.serviceID)
|
||||||
|
log.Printf("Requesting SENS API URL: %s", apiURL)
|
||||||
|
|
||||||
|
reqBody := domain.NaverSmsRequest{
|
||||||
|
Type: "SMS",
|
||||||
|
ContentType: "COMM",
|
||||||
|
CountryCode: "82",
|
||||||
|
From: s.senderPhone,
|
||||||
|
Content: content,
|
||||||
|
Messages: []domain.SmsMessage{
|
||||||
|
{
|
||||||
|
To: to,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBody, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error marshalling request body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signature, err := s.makeSignature("POST", fmt.Sprintf("/sms/v2/services/%s/messages", s.serviceID), timestamp)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error creating signature: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error creating request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("x-ncp-apigw-timestamp", timestamp)
|
||||||
|
req.Header.Set("x-ncp-iam-access-key", s.accessKey)
|
||||||
|
req.Header.Set("x-ncp-apigw-signature-v2", signature)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error sending request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error reading response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
log.Printf("error response from naver cloud sms api: %s", string(respBody))
|
||||||
|
return fmt.Errorf("error sending sms: status code %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("sms sent successfully: %s", string(respBody))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SmsServiceImpl) makeSignature(method, url, timestamp string) (string, error) {
|
||||||
|
space := " "
|
||||||
|
newLine := "\n"
|
||||||
|
message := method + space + url + newLine + timestamp + newLine + s.accessKey
|
||||||
|
|
||||||
|
h := hmac.New(sha256.New, []byte(s.secretKey))
|
||||||
|
_, err := h.Write([]byte(message))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64.StdEncoding.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
version: '3.8'
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -19,24 +19,29 @@ services:
|
|||||||
clickhouse:
|
clickhouse:
|
||||||
image: clickhouse/clickhouse-server:latest
|
image: clickhouse/clickhouse-server:latest
|
||||||
container_name: baron_clickhouse
|
container_name: baron_clickhouse
|
||||||
ports:
|
environment:
|
||||||
- "${CLICKHOUSE_PORT_HTTP:-8123}:8123"
|
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-baron}
|
||||||
- "${CLICKHOUSE_PORT_NATIVE:-9000}:9000"
|
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-password}
|
||||||
ulimits:
|
|
||||||
nofile:
|
|
||||||
soft: 262144
|
|
||||||
hard: 262144
|
|
||||||
volumes:
|
|
||||||
- clickhouse_data:/var/lib/clickhouse
|
|
||||||
networks:
|
networks:
|
||||||
- baron_net
|
- baron_net
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: baron_redis
|
||||||
restart: always
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
clickhouse_data:
|
||||||
|
redis_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
baron_net:
|
baron_net:
|
||||||
name: baron_network
|
name: baron_network
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
clickhouse_data:
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
version: '3.8'
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
@@ -6,12 +6,16 @@ services:
|
|||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: baron_backend
|
container_name: baron_backend
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- APP_ENV=${APP_ENV:-development}
|
- APP_ENV=${APP_ENV:-development}
|
||||||
- COOKIE_SECRET=${COOKIE_SECRET}
|
- COOKIE_SECRET=${COOKIE_SECRET}
|
||||||
- DB_HOST=postgres
|
- DB_HOST=postgres
|
||||||
- CLICKHOUSE_HOST=clickhouse
|
- CLICKHOUSE_HOST=clickhouse
|
||||||
- CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000}
|
- CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000}
|
||||||
|
- CLICKHOUSE_USER=${CLICKHOUSE_USER:-baron}
|
||||||
|
- CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-password}
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT:-3000}:3000"
|
- "${BACKEND_PORT:-3000}:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -20,9 +24,10 @@ services:
|
|||||||
- baron_net
|
- baron_net
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
|
command: ["go", "run", "./cmd/server/main.go"]
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: ghcr.io/cirruslabs/flutter:3.19.0 # Using a pre-built Flutter image for Dev
|
image: ghcr.io/cirruslabs/flutter:stable # Use stable version for 2026 compatibility
|
||||||
container_name: baron_frontend
|
container_name: baron_frontend
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
environment:
|
environment:
|
||||||
@@ -31,7 +36,17 @@ services:
|
|||||||
- "${FRONTEND_PORT:-5000}:5000"
|
- "${FRONTEND_PORT:-5000}:5000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
command: ["flutter", "run", "-d", "web-server", "--web-port", "5000", "--web-hostname", "0.0.0.0"]
|
command:
|
||||||
|
[
|
||||||
|
"flutter",
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"web-server",
|
||||||
|
"--web-port",
|
||||||
|
"5000",
|
||||||
|
"--web-hostname",
|
||||||
|
"0.0.0.0",
|
||||||
|
]
|
||||||
networks:
|
networks:
|
||||||
- baron_net
|
- baron_net
|
||||||
|
|
||||||
|
|||||||
34
frontend/.env.sample
Normal file
34
frontend/.env.sample
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# ==========================================
|
||||||
|
# Baron SSO - Unified Environment Configuration
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
# --- General System ---
|
||||||
|
APP_ENV=development
|
||||||
|
TZ=Asia/Seoul
|
||||||
|
|
||||||
|
# --- Infrastructure Ports ---
|
||||||
|
DB_PORT=5432
|
||||||
|
CLICKHOUSE_PORT_HTTP=8123
|
||||||
|
CLICKHOUSE_PORT_NATIVE=9000
|
||||||
|
BACKEND_PORT=3000
|
||||||
|
FRONTEND_PORT=5000
|
||||||
|
|
||||||
|
# --- Database Credentials (PostgreSQL) ---
|
||||||
|
DB_USER=baron
|
||||||
|
DB_PASSWORD=password
|
||||||
|
DB_NAME=baron_sso
|
||||||
|
|
||||||
|
# --- Backend Configuration ---
|
||||||
|
# Must be 32 bytes. Generate with `openssl rand -hex 32`
|
||||||
|
COOKIE_SECRET=super-secret-key-must-be-32-bytes!
|
||||||
|
REDIS_ADDR=redis:6379
|
||||||
|
|
||||||
|
# --- Frontend Configuration ---
|
||||||
|
# Descope Project ID (Required for Auth)
|
||||||
|
DESCOPE_PROJECT_ID=P2t...your_descope_project_id
|
||||||
|
|
||||||
|
# --- Naver Cloud Services ---
|
||||||
|
NAVER_CLOUD_ACCESS_KEY=ncp_iam_...
|
||||||
|
NAVER_CLOUD_SECRET_KEY=ncp_iam_...
|
||||||
|
NAVER_CLOUD_SERVICE_ID=ncp:sms:kr:...:...
|
||||||
|
NAVER_SENDER_PHONE_NUMBER=...
|
||||||
38
frontend/lib/core/services/audit_service.dart
Normal file
38
frontend/lib/core/services/audit_service.dart
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
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 Future<void> logEvent({
|
||||||
|
required String userId,
|
||||||
|
required String eventType,
|
||||||
|
required String status,
|
||||||
|
String? details,
|
||||||
|
}) async {
|
||||||
|
final url = Uri.parse('$_baseUrl/api/v1/audit');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'user_id': userId,
|
||||||
|
'event_type': eventType,
|
||||||
|
'status': status,
|
||||||
|
'details': details,
|
||||||
|
'timestamp': DateTime.now().toIso8601String(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
print("Audit log sent successfully");
|
||||||
|
} else {
|
||||||
|
print("Failed to send audit log: ${response.statusCode} ${response.body}");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print("Error sending audit log: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
111
frontend/lib/core/services/auth_proxy_service.dart
Normal file
111
frontend/lib/core/services/auth_proxy_service.dart
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
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';
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> initEnchantedLink(String loginId) async {
|
||||||
|
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init');
|
||||||
|
|
||||||
|
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
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return jsonDecode(response.body);
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to init login: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> pollEnchantedLink(String pendingRef) async {
|
||||||
|
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/poll');
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'pendingRef': pendingRef,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return jsonDecode(response.body);
|
||||||
|
} else {
|
||||||
|
throw Exception('Polling failed: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> verifyMagicLink(String token) async {
|
||||||
|
final url = Uri.parse('$_baseUrl/api/v1/auth/magic-link/verify');
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'token': token,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('Verification failed: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> sendSms(String phoneNumber) async {
|
||||||
|
final url = Uri.parse('$_baseUrl/api/v1/auth/sms');
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'phoneNumber': phoneNumber,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('Failed to send SMS: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> verifySmsCode(String phoneNumber, String code) async {
|
||||||
|
final url = Uri.parse('$_baseUrl/api/v1/auth/verify-sms');
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'phoneNumber': phoneNumber,
|
||||||
|
'code': code,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return jsonDecode(response.body);
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to verify code: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> logError(String message) async {
|
||||||
|
final url = Uri.parse('$_baseUrl/api/v1/client-log');
|
||||||
|
try {
|
||||||
|
await http.post(
|
||||||
|
url,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'level': 'ERROR',
|
||||||
|
'message': message,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore logging errors to prevent loops
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
frontend/lib/core/services/web_auth_integration.dart
Normal file
13
frontend/lib/core/services/web_auth_integration.dart
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import 'web_auth_integration_stub.dart'
|
||||||
|
if (dart.library.html) 'web_auth_integration_web.dart';
|
||||||
|
|
||||||
|
abstract class WebAuthIntegration {
|
||||||
|
static void sendLoginSuccess(String token) {
|
||||||
|
// Platform-specific implementation
|
||||||
|
implSendLoginSuccess(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isPopup() {
|
||||||
|
return implIsPopup();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
void implSendLoginSuccess(String token) {
|
||||||
|
// No-op on non-web platforms
|
||||||
|
print("Not on web: Login Success with token: $token");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool implIsPopup() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
37
frontend/lib/core/services/web_auth_integration_web.dart
Normal file
37
frontend/lib/core/services/web_auth_integration_web.dart
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import 'dart:html' as html;
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool implIsPopup() {
|
||||||
|
return html.window.opener != null;
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:descope/descope.dart';
|
import 'package:descope/descope.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
import '../../../core/services/audit_service.dart';
|
||||||
|
import '../../../core/services/web_auth_integration.dart';
|
||||||
|
import '../../../core/services/auth_proxy_service.dart';
|
||||||
|
|
||||||
class LoginScreen extends ConsumerStatefulWidget {
|
class LoginScreen extends ConsumerStatefulWidget {
|
||||||
const LoginScreen({super.key});
|
const LoginScreen({super.key});
|
||||||
@@ -17,11 +21,53 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final TextEditingController _emailController = TextEditingController();
|
final TextEditingController _emailController = TextEditingController();
|
||||||
final TextEditingController _passwordController = TextEditingController();
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
final TextEditingController _phoneController = TextEditingController();
|
final TextEditingController _phoneController = TextEditingController();
|
||||||
|
final TextEditingController _smsCodeController = TextEditingController();
|
||||||
|
bool _smsSent = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabController = TabController(length: 2, vsync: this);
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
|
|
||||||
|
// Check for 't' token in URL (Magic Link / Enchanted Link verification)
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final uri = Uri.base;
|
||||||
|
if (uri.queryParameters.containsKey('t')) {
|
||||||
|
_verifyToken(uri.queryParameters['t']!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _verifyToken(String token) async {
|
||||||
|
try {
|
||||||
|
// Use Proxy to verify token
|
||||||
|
await AuthProxyService.verifyMagicLink(token);
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSuccessDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => const AlertDialog(
|
||||||
|
title: Text("Authentication Successful"),
|
||||||
|
content: Text("You can close this tab and return to the application."),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -30,6 +76,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_emailController.dispose();
|
_emailController.dispose();
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
_phoneController.dispose();
|
_phoneController.dispose();
|
||||||
|
_smsCodeController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,35 +84,45 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final email = _emailController.text.trim();
|
final email = _emailController.text.trim();
|
||||||
if (email.isEmpty) return;
|
if (email.isEmpty) return;
|
||||||
|
|
||||||
// Determine if it's Password or Enchanted Link flow
|
|
||||||
// For this PoC, we'll try Enchanted Link as primary for 'Email' tab per requirements,
|
|
||||||
// but the UI has a password field. Let's support both based on input.
|
|
||||||
// However, PRD says Primary is Email/Password.
|
|
||||||
|
|
||||||
final password = _passwordController.text;
|
final password = _passwordController.text;
|
||||||
if (password.isNotEmpty) {
|
if (password.isNotEmpty) {
|
||||||
// Email + Password Flow
|
// 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 {
|
try {
|
||||||
final authResponse = await Descope.auth.password.signIn(
|
final authResponse = await Descope.password.signIn(
|
||||||
loginId: email,
|
loginId: email,
|
||||||
password: password,
|
password: password,
|
||||||
);
|
);
|
||||||
final session = DescopeSession.fromAuthenticationResponse(authResponse);
|
final session = DescopeSession.fromAuthenticationResponse(authResponse);
|
||||||
Descope.sessionManager.manageSession(session);
|
Descope.sessionManager.manageSession(session);
|
||||||
if (mounted) context.go('/dashboard');
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_showError("Email/Password Login Failed: $e");
|
_showError("Email/Password Login Failed: $e");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Enchanted Link Flow (Passwordless)
|
// Enchanted Link Flow (via Proxy)
|
||||||
try {
|
try {
|
||||||
// Start Enchanted Link
|
// 1. Init via Proxy
|
||||||
final response = await Descope.auth.enchantedLink.signUpOrIn(
|
final initData = await AuthProxyService.initEnchantedLink(email);
|
||||||
loginId: email,
|
final linkId = initData['linkId'];
|
||||||
uri: "baronsso://auth", // Deep link for the 'Clicked' device
|
final pendingRef = initData['pendingRef'];
|
||||||
);
|
|
||||||
|
|
||||||
// Show Polling Dialog
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -77,89 +134,159 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
children: [
|
children: [
|
||||||
Text("We sent an email to $email"),
|
Text("We sent an email to $email"),
|
||||||
const SizedBox(height: 16),
|
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 LinearProgressIndicator(),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text("Link: ${response.linkId}"), // Display for debug/PoC
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Poll for completion
|
// 2. Poll via Proxy (Loop until success or timeout)
|
||||||
final authResponse = await Descope.auth.enchantedLink.poll(
|
String sessionToken = "";
|
||||||
response.pendingRef,
|
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',
|
||||||
);
|
);
|
||||||
final session = DescopeSession.fromAuthenticationResponse(
|
|
||||||
authResponse,
|
|
||||||
);
|
|
||||||
Descope.sessionManager.manageSession(session);
|
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.of(context).pop(); // Close Dialog
|
Navigator.of(context).pop(); // Close Dialog
|
||||||
context.go('/dashboard');
|
|
||||||
|
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) {
|
} catch (e) {
|
||||||
if (mounted) Navigator.of(context).pop(); // Close dialog if open
|
if (mounted && Navigator.canPop(context)) {
|
||||||
_showError("Enchanted Link Failed: $e");
|
// 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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<void> _handleSmsLogin() async {
|
Future<void> _handleSmsLogin() async {
|
||||||
final phone = _phoneController.text.trim();
|
final phone = _phoneController.text.trim();
|
||||||
if (phone.isEmpty) return;
|
if (phone.isEmpty) return;
|
||||||
|
|
||||||
|
print("[Frontend] SMS 코드 발송 시작. 번호: $phone");
|
||||||
try {
|
try {
|
||||||
// Enchanted Link via SMS (Polling)
|
await AuthProxyService.sendSms(phone);
|
||||||
// Note: This assumes Descope project is configured to send SMS for this loginId
|
print("[Frontend] SMS 코드 발송 요청 성공.");
|
||||||
final response = await Descope.auth.enchantedLink.signUpOrIn(
|
setState(() {
|
||||||
loginId: phone,
|
_smsSent = true;
|
||||||
uri: "baronsso://auth", // Link for the device that receives SMS
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
barrierDismissible: false,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text("Check your Messages"),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text("We sent a message to $phone"),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const LinearProgressIndicator(),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
// Text("Link: ${response.linkId}"), // Debug
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Poll for completion
|
|
||||||
final authResponse = await Descope.auth.enchantedLink.poll(
|
|
||||||
response.pendingRef,
|
|
||||||
);
|
|
||||||
final session = DescopeSession.fromAuthenticationResponse(authResponse);
|
|
||||||
Descope.sessionManager.manageSession(session);
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
Navigator.of(context).pop(); // Close Dialog
|
|
||||||
context.go('/dashboard');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted && Navigator.canPop(context)) Navigator.of(context).pop();
|
print("[Frontend] SMS 코드 발송 요청 실패: $e");
|
||||||
_showError("SMS Enchanted Link Failed: $e");
|
_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;
|
||||||
|
|
||||||
|
print("[Frontend] SMS 코드 검증 시작. 번호: $phone, 코드: $code");
|
||||||
|
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();
|
||||||
|
} catch (e) {
|
||||||
|
print("[Frontend] SMS 코드 검증 실패: $e");
|
||||||
|
_showError("Failed to verify code: $e");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showError(String message) {
|
void _showError(String message) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// Show Snackbar
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text(message), backgroundColor: Colors.red),
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -234,23 +361,42 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
// Phone/SMS Form
|
// Phone/SMS Form
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
if (!_smsSent) ...[
|
||||||
controller: _phoneController,
|
TextField(
|
||||||
decoration: const InputDecoration(
|
controller: _phoneController,
|
||||||
labelText: "Phone Number",
|
decoration: const InputDecoration(
|
||||||
hintText: "+82 10-1234-5678",
|
labelText: "Phone Number",
|
||||||
border: OutlineInputBorder(),
|
hintText: "+82 10-1234-5678",
|
||||||
prefixIcon: Icon(Icons.phone_android),
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.phone_android),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 24),
|
||||||
const SizedBox(height: 24),
|
FilledButton(
|
||||||
FilledButton(
|
onPressed: _handleSmsLogin,
|
||||||
onPressed: _handleSmsLogin,
|
style: FilledButton.styleFrom(
|
||||||
style: FilledButton.styleFrom(
|
minimumSize: const Size.fromHeight(50),
|
||||||
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: 24),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _handleSmsVerification,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(50),
|
||||||
|
),
|
||||||
|
child: const Text("Verify Code"),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:descope/descope.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
|
class DashboardScreen extends StatelessWidget {
|
||||||
|
const DashboardScreen({super.key});
|
||||||
|
|
||||||
|
Future<void> _logout(BuildContext context) async {
|
||||||
|
Descope.sessionManager.clearSession();
|
||||||
|
if (context.mounted) context.go('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final user = Descope.sessionManager.session?.user;
|
||||||
|
final userName = user?.name ?? user?.email ?? user?.phone ?? 'User';
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text('Baron Launcher', style: GoogleFonts.outfit(fontWeight: FontWeight.bold)),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.logout),
|
||||||
|
onPressed: () => _logout(context),
|
||||||
|
tooltip: 'Sign Out',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Text('Dashboard Loaded Successfully', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text('Welcome, $userName'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import 'package:descope/descope.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'features/auth/presentation/login_screen.dart';
|
import 'features/auth/presentation/login_screen.dart';
|
||||||
|
import 'features/dashboard/presentation/dashboard_screen.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -21,7 +22,11 @@ void main() async {
|
|||||||
Descope.setup(projectId);
|
Descope.setup(projectId);
|
||||||
|
|
||||||
// Load saved session if any
|
// Load saved session if any
|
||||||
await Descope.sessionManager.loadSession();
|
try {
|
||||||
|
await Descope.sessionManager.loadSession();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Failed to load session: $e");
|
||||||
|
}
|
||||||
|
|
||||||
runApp(const ProviderScope(child: BaronSSOApp()));
|
runApp(const ProviderScope(child: BaronSSOApp()));
|
||||||
}
|
}
|
||||||
@@ -33,8 +38,7 @@ final _router = GoRouter(
|
|||||||
GoRoute(path: '/', builder: (context, state) => const LoginScreen()),
|
GoRoute(path: '/', builder: (context, state) => const LoginScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
builder: (context, state) =>
|
builder: (context, state) => const DashboardScreen(),
|
||||||
const Scaffold(body: Center(child: Text("Dashboard Placeholder"))),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
|
|||||||
@@ -65,9 +65,8 @@ flutter:
|
|||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
# To add assets to your application, add an assets section, like this:
|
# To add assets to your application, add an assets section, like this:
|
||||||
# assets:
|
assets:
|
||||||
# - images/a_dot_burr.jpeg
|
- .env
|
||||||
# - images/a_dot_ham.jpeg
|
|
||||||
|
|
||||||
# An image asset can refer to one or more resolution-specific "variants", see
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
# https://flutter.dev/to/resolution-aware-images
|
# https://flutter.dev/to/resolution-aware-images
|
||||||
|
|||||||
110
test/test_sms.py
Normal file
110
test/test_sms.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# python3 test/test_sms.py 01027774695
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
def get_env_variable(key, env_file):
|
||||||
|
"""Reads an environment variable from a given .env file."""
|
||||||
|
with open(env_file, 'r') as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith('#'):
|
||||||
|
k, v = line.split('=', 1)
|
||||||
|
if k == key:
|
||||||
|
return v.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python test/test_sms.py <recipient_phone_number>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
recipient_phone = sys.argv[1]
|
||||||
|
|
||||||
|
# Load environment variables from .env or .env.sample
|
||||||
|
env_path = os.path.join(os.getcwd(), '.env')
|
||||||
|
if not os.path.exists(env_path):
|
||||||
|
print("Info: .env file not found. Using .env.sample as a fallback.")
|
||||||
|
env_path = os.path.join(os.getcwd(), '.env.sample')
|
||||||
|
|
||||||
|
if not os.path.exists(env_path):
|
||||||
|
print("Error: No configuration file found (.env or .env.sample).")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
access_key = get_env_variable("NAVER_CLOUD_ACCESS_KEY", env_path)
|
||||||
|
secret_key = get_env_variable("NAVER_CLOUD_SECRET_KEY", env_path)
|
||||||
|
service_id = get_env_variable("NAVER_CLOUD_SERVICE_ID", env_path)
|
||||||
|
sender_phone = get_env_variable("NAVER_SENDER_PHONE_NUMBER", env_path)
|
||||||
|
|
||||||
|
if not all([access_key, secret_key, service_id, sender_phone]):
|
||||||
|
print(f"Error: One or more required environment variables are missing in {env_path}.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
timestamp = str(int(time.time() * 1000))
|
||||||
|
api_path = f"/sms/v2/services/{service_id}/messages"
|
||||||
|
api_url = f"https://sens.apigw.ntruss.com{api_path}"
|
||||||
|
|
||||||
|
# Create the signature for the API request
|
||||||
|
message = f"POST {api_path}\n{timestamp}\n{access_key}"
|
||||||
|
h = hmac.new(bytes(secret_key, 'UTF-8'), bytes(message, 'UTF-8'), hashlib.sha256)
|
||||||
|
signature = base64.b64encode(h.digest()).decode('UTF-8')
|
||||||
|
|
||||||
|
# Construct the JSON request body
|
||||||
|
json_body = {
|
||||||
|
"type": "SMS",
|
||||||
|
"contentType": "COMM",
|
||||||
|
"countryCode": "82",
|
||||||
|
"from": sender_phone,
|
||||||
|
"content": "[Baron SSO] Test message from Python script.",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"to": recipient_phone
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"x-ncp-apigw-timestamp": timestamp,
|
||||||
|
"x-ncp-iam-access-key": access_key,
|
||||||
|
"x-ncp-apigw-signature-v2": signature
|
||||||
|
}
|
||||||
|
|
||||||
|
print("========================================")
|
||||||
|
print(" Attempting to send SMS via SENS API (Python)")
|
||||||
|
print("========================================")
|
||||||
|
print(f" Recipient: {recipient_phone}")
|
||||||
|
print(f" Timestamp: {timestamp}")
|
||||||
|
print(f" Service ID: {service_id}")
|
||||||
|
print("========================================")
|
||||||
|
print()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(api_url, headers=headers, json=json_body)
|
||||||
|
response.raise_for_status() # Raise an exception for HTTP errors
|
||||||
|
print("API Response:")
|
||||||
|
print(json.dumps(response.json(), indent=2, ensure_ascii=False))
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"Request failed: {e}")
|
||||||
|
if hasattr(e, 'response') and e.response is not None:
|
||||||
|
print("API Error Response:")
|
||||||
|
try:
|
||||||
|
print(json.dumps(e.response.json(), indent=2, ensure_ascii=False))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print(e.response.text)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"An unexpected error occurred: {e}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("========================================")
|
||||||
|
print(" Request complete.")
|
||||||
|
print("========================================")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user