forked from baron/baron-sso
Merge remote-tracking branch 'origin/main' into dev/mypage
This commit is contained in:
@@ -21,11 +21,13 @@ 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
|
||||
REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트(컨테이너 내부 기준)
|
||||
|
||||
# Descope Project ID (Required for Auth)
|
||||
DESCOPE_PROJECT_ID=P2t...your_descope_project_id
|
||||
DESCOPE_MANAGEMENT_KEY=your_descope_management_key_here
|
||||
DESCOPE_TEST_ACCOUNT=dyddus1210@gmail.com # 테스트 자동화용 계정(loginId). 없으면 생성 후 비밀번호 변경 시나리오 실행
|
||||
DESCOPE_TEST_ACCOUNT=tester@baroncs.co.kr
|
||||
|
||||
# --- Naver Cloud Services ---
|
||||
NAVER_CLOUD_ACCESS_KEY=ncp_iam_...
|
||||
@@ -44,4 +46,6 @@ ADMIN_PASSWORD=admin
|
||||
|
||||
# --- URLs for Proxy/Handoff ---
|
||||
FRONTEND_URL=https://sso.hmac.kr # 프론트엔드 접속 주소 (이메일/SMS 링크 생성 시 사용)
|
||||
BACKEND_URL=https://sso.hmac.kr # 프론트엔드에서 참조할 백엔드 API 주소
|
||||
BACKEND_URL=https://sso.hmac.kr # 프론트엔드에서 참조할 백엔드 API 주소
|
||||
|
||||
IDP_PROVIDER=descopse, hydra ...
|
||||
|
||||
5
.gemini/settings.json
Normal file
5
.gemini/settings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"general": {
|
||||
"previewFeatures": true
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/handler"
|
||||
"baron-sso-backend/internal/idp"
|
||||
"baron-sso-backend/internal/logger"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"baron-sso-backend/internal/service"
|
||||
@@ -73,14 +74,12 @@ func main() {
|
||||
)
|
||||
|
||||
// --- Fail-Fast Schema Validation ---
|
||||
// Initialize the IDP Provider (Descope)
|
||||
descopeProjectID := getEnv("DESCOPE_PROJECT_ID", "")
|
||||
descopeManagementKey := getEnv("DESCOPE_MANAGEMENT_KEY", "")
|
||||
|
||||
// We create a provider instance to check schema compatibility.
|
||||
// This ensures that our BrokerUser model requirements (e.g. custom attributes)
|
||||
// are supported by the configured IDP.
|
||||
idpProvider := service.NewDescopeProvider(descopeProjectID, descopeManagementKey)
|
||||
// 팩토리를 사용하여 IDP 공급자를 초기화합니다.
|
||||
idpProvider, err := idp.InitializeProvider()
|
||||
if err != nil {
|
||||
slog.Error("❌ [CRITICAL] Failed to initialize IDP Provider", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := validator.ValidateIDPCompatibility(domain.BrokerUser{}, idpProvider); err != nil {
|
||||
slog.Error("❌ [CRITICAL] Broker Schema Mismatch",
|
||||
@@ -167,8 +166,21 @@ func main() {
|
||||
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
|
||||
AllowMethods: "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS",
|
||||
}))
|
||||
|
||||
// Ensure COOKIE_SECRET is exactly 32 bytes for AES-256
|
||||
cookieSecret := getEnv("COOKIE_SECRET", "secret-key-must-be-32-bytes-long!")
|
||||
if len(cookieSecret) != 32 {
|
||||
slog.Warn("COOKIE_SECRET length is not 32 bytes. Adjusting...", "original_length", len(cookieSecret))
|
||||
if len(cookieSecret) > 32 {
|
||||
cookieSecret = cookieSecret[:32]
|
||||
} else {
|
||||
// Pad with '0' if too short
|
||||
cookieSecret = fmt.Sprintf("%-32s", cookieSecret)
|
||||
}
|
||||
}
|
||||
|
||||
app.Use(encryptcookie.New(encryptcookie.Config{
|
||||
Key: getEnv("COOKIE_SECRET", "secret-key-must-be-32-bytes-long!"),
|
||||
Key: cookieSecret,
|
||||
}))
|
||||
|
||||
// Routes
|
||||
@@ -228,6 +240,14 @@ func main() {
|
||||
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
|
||||
auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink)
|
||||
auth.Post("/magic-link/verify", authHandler.VerifyMagicLink)
|
||||
auth.Post("/password/login", authHandler.PasswordLogin)
|
||||
auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset)
|
||||
// [Changed] Use Interstitial Page for GET to prevent Scanner consumption
|
||||
auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage)
|
||||
// [Added] Use POST for actual verification triggered by the user
|
||||
auth.Post("/password/reset/verify", authHandler.ProcessPasswordResetToken)
|
||||
auth.Post("/password/reset/complete", authHandler.CompletePasswordReset)
|
||||
auth.Get("/password/policy", authHandler.GetPasswordPolicy)
|
||||
auth.Post("/sms", authHandler.SendSms)
|
||||
auth.Post("/verify-sms", authHandler.VerifySms)
|
||||
auth.Post("/qr/init", authHandler.InitQRLogin)
|
||||
|
||||
@@ -4,30 +4,31 @@ go 1.25.4
|
||||
|
||||
require (
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.42.0
|
||||
github.com/descope/go-sdk v1.6.23
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.7
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.7
|
||||
github.com/aws/aws-sdk-go-v2/service/ses v1.34.18
|
||||
github.com/bwmarrin/snowflake v0.3.0
|
||||
github.com/descope/go-sdk v1.7.0
|
||||
github.com/go-redis/redis/v8 v8.11.5
|
||||
github.com/gofiber/fiber/v2 v2.52.10
|
||||
github.com/google/uuid v1.6.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/ClickHouse/ch-go v0.69.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ses v1.34.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
|
||||
github.com/aws/smithy-go v1.24.0 // indirect
|
||||
github.com/bwmarrin/snowflake v0.3.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
|
||||
|
||||
@@ -43,8 +43,8 @@ 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/descope/go-sdk v1.7.0 h1:DIRmnA4Q8TDtWdGJ9z0I11+AWMrzyNiiozFH557LrgQ=
|
||||
github.com/descope/go-sdk v1.7.0/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=
|
||||
|
||||
@@ -78,3 +78,14 @@ type UpdateUserRequest struct {
|
||||
Department string `json:"department"`
|
||||
VerificationCode string `json:"verificationCode,omitempty"` // For phone change
|
||||
}
|
||||
|
||||
// PasswordResetInitiateRequest is the request body for initiating a password reset.
|
||||
type PasswordResetInitiateRequest struct {
|
||||
LoginID string `json:"loginId"`
|
||||
}
|
||||
|
||||
// PasswordResetCompleteRequest is the request body for completing a password reset.
|
||||
type PasswordResetCompleteRequest struct {
|
||||
LoginID string `json:"loginId"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BrokerUser is the standard user model used within Baron SSO business logic.
|
||||
// It defines the canonical set of fields that must be supported by any underlying IDP.
|
||||
type BrokerUser struct {
|
||||
@@ -19,10 +24,25 @@ type IDPMetadata struct {
|
||||
SupportedFields []string
|
||||
}
|
||||
|
||||
// Token represents a session or refresh token.
|
||||
type Token struct {
|
||||
JWT string
|
||||
Expiration time.Time
|
||||
}
|
||||
|
||||
// AuthInfo contains authentication information after a successful login.
|
||||
type AuthInfo struct {
|
||||
SessionToken *Token
|
||||
RefreshToken *Token
|
||||
}
|
||||
|
||||
// IdentityProvider is the interface that all IDP adapters must implement.
|
||||
type IdentityProvider interface {
|
||||
Name() string
|
||||
// GetMetadata returns the schema support information for this IDP.
|
||||
// This is used for startup-time validation.
|
||||
GetMetadata() (*IDPMetadata, error)
|
||||
InitiatePasswordReset(loginID, redirectUrl string) error
|
||||
VerifyPasswordResetToken(token string) (*AuthInfo, error)
|
||||
UpdateUserPassword(loginID, newPassword string, r *http.Request) error
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/idp"
|
||||
"baron-sso-backend/internal/logger"
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
@@ -11,6 +13,8 @@ import (
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -37,6 +41,8 @@ const (
|
||||
maxSignupFailures = 5
|
||||
emailCodeTTL = 5 * time.Minute
|
||||
smsCodeTTL = 3 * time.Minute
|
||||
prefixPwdResetToken = "pwdreset_token:"
|
||||
pwdResetExpiration = 15 * time.Minute
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
@@ -45,6 +51,7 @@ type AuthHandler struct {
|
||||
EmailService domain.EmailService
|
||||
RedisService *service.RedisService
|
||||
DescopeClient *client.DescopeClient
|
||||
IdpProvider domain.IdentityProvider
|
||||
}
|
||||
|
||||
type signupState struct {
|
||||
@@ -79,12 +86,20 @@ func NewAuthHandler(redisService *service.RedisService) *AuthHandler {
|
||||
}
|
||||
}
|
||||
|
||||
idpProvider, err := idp.InitializeProvider()
|
||||
if err != nil {
|
||||
slog.Error("Failed to initialize IDP Provider", "error", err)
|
||||
// Depending on the application's needs, you might want to panic here
|
||||
// if the IDP provider is essential for the application to run.
|
||||
}
|
||||
|
||||
return &AuthHandler{
|
||||
ProjectID: projectID,
|
||||
SmsService: service.NewSmsService(),
|
||||
EmailService: service.NewEmailService(),
|
||||
RedisService: redisService,
|
||||
DescopeClient: descopeClient,
|
||||
IdpProvider: idpProvider,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,6 +432,26 @@ func (h *AuthHandler) saveSignupState(key string, state *signupState, ttl time.D
|
||||
return h.RedisService.Set(key, string(data), ttl)
|
||||
}
|
||||
|
||||
// GetPasswordPolicy exposes the current Descope password policy to the frontend for dynamic validation.
|
||||
func (h *AuthHandler) GetPasswordPolicy(c *fiber.Ctx) error {
|
||||
if h.DescopeClient == nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Descope client not configured"})
|
||||
}
|
||||
|
||||
policy, err := h.DescopeClient.Auth.Password().GetPasswordPolicy(context.Background())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"minLength": policy.MinLength,
|
||||
"lowercase": policy.Lowercase,
|
||||
"uppercase": policy.Uppercase,
|
||||
"number": policy.Number,
|
||||
"nonAlphanumeric": policy.NonAlphanumeric,
|
||||
})
|
||||
}
|
||||
|
||||
// SendSms sends a verification code via SMS. (Restored for completeness)
|
||||
func (h *AuthHandler) SendSms(c *fiber.Ctx) error {
|
||||
var req domain.SmsRequest
|
||||
@@ -474,8 +509,8 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
|
||||
loginID = strings.ReplaceAll(loginID, " ", "")
|
||||
|
||||
// Generate secure tokens
|
||||
token := GenerateSecureToken(3)
|
||||
pendingRef := GenerateSecureToken(3)
|
||||
token := GenerateSecureToken(32)
|
||||
pendingRef := GenerateSecureToken(16)
|
||||
|
||||
slog.Info("[Enchanted] Initiating enchanted link", "loginID", loginID, "token", token, "pendingRef", pendingRef)
|
||||
|
||||
@@ -485,6 +520,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
|
||||
|
||||
// Generate Link
|
||||
frontendURL := os.Getenv("FRONTEND_URL")
|
||||
slog.Info("[Enchanted] Read FRONTEND_URL", "url", frontendURL)
|
||||
if frontendURL == "" {
|
||||
frontendURL = "http://sso.hmac.kr"
|
||||
}
|
||||
@@ -679,6 +715,433 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// PasswordLogin - Authenticate a user with login ID and password.
|
||||
func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
|
||||
startTime := time.Now()
|
||||
ale := logger.NewAuditLogEntry(c, "login")
|
||||
ale.Operation = "Auth.Password().SignIn"
|
||||
|
||||
var req struct {
|
||||
LoginID string `json:"loginId"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
ale.Status = fiber.StatusBadRequest
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = err.Error()
|
||||
ale.Log(slog.LevelError, "Body parse error")
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
loginID := strings.TrimSpace(req.LoginID)
|
||||
ale.LoginIDs["loginId"] = req.LoginID // 원문
|
||||
ale.LoginIDs["loginId_normalized"] = loginID
|
||||
ale.NewPassword = req.Password // For test only, logging password (sensitive)
|
||||
|
||||
ale.Log(slog.LevelInfo, "Attempting to login")
|
||||
|
||||
// Validate password complexity before sending to Descope
|
||||
password := req.Password
|
||||
if len(password) < 8 {
|
||||
ale.Status = fiber.StatusBadRequest
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = "Password must be at least 8 characters long"
|
||||
ale.Log(slog.LevelWarn, "Validation failed: password too short")
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must be at least 8 characters long"})
|
||||
}
|
||||
if ok, _ := regexp.MatchString(`[a-z]`, password); !ok {
|
||||
ale.Status = fiber.StatusBadRequest
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = "Password must contain at least one lowercase letter"
|
||||
ale.Log(slog.LevelWarn, "Validation failed: no lowercase letter")
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one lowercase letter"})
|
||||
}
|
||||
if ok, _ := regexp.MatchString(`[A-Z]`, password); !ok {
|
||||
ale.Status = fiber.StatusBadRequest
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = "Password must contain at least one uppercase letter"
|
||||
ale.Log(slog.LevelWarn, "Validation failed: no uppercase letter")
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one uppercase letter"})
|
||||
}
|
||||
if ok, _ := regexp.MatchString(`[0-9]`, password); !ok {
|
||||
ale.Status = fiber.StatusBadRequest
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = "Password must contain at least one number"
|
||||
ale.Log(slog.LevelWarn, "Validation failed: no number")
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one number"})
|
||||
}
|
||||
if ok, _ := regexp.MatchString(`[\W_]`, password); !ok {
|
||||
ale.Status = fiber.StatusBadRequest
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = "Password must contain at least one special character"
|
||||
ale.Log(slog.LevelWarn, "Validation failed: no special character")
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one special character"})
|
||||
}
|
||||
|
||||
if h.DescopeClient == nil {
|
||||
ale.Status = fiber.StatusInternalServerError
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = "Descope Client is nil!"
|
||||
ale.Log(slog.LevelError, "Descope Client is nil")
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
|
||||
}
|
||||
|
||||
// Sign in using Descope
|
||||
authInfo, err := h.DescopeClient.Auth.Password().SignIn(context.Background(), req.LoginID, req.Password, nil)
|
||||
if err != nil {
|
||||
ale.Status = fiber.StatusUnauthorized
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = err.Error()
|
||||
ale.Log(slog.LevelWarn, "Descope sign-in failed")
|
||||
// It's good practice to return a generic error message for security.
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
|
||||
}
|
||||
|
||||
ale.Status = fiber.StatusOK
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.SessionJwt = authInfo.SessionToken.JWT
|
||||
ale.Log(slog.LevelInfo, "Login successful")
|
||||
return c.JSON(fiber.Map{
|
||||
"sessionJwt": authInfo.SessionToken.JWT,
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
// InitiatePasswordReset - 사용자가 비밀번호 재설정을 시작하면, loginID 유형에 따라 Descope를 통해 이메일 또는 SMS를 보냅니다.
|
||||
func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
|
||||
startTime := time.Now()
|
||||
ale := logger.NewAuditLogEntry(c, "initiate")
|
||||
|
||||
var req domain.PasswordResetInitiateRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
ale.Status = fiber.StatusBadRequest
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = err.Error()
|
||||
ale.Log(slog.LevelError, "Body parse error")
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
loginID := strings.TrimSpace(req.LoginID)
|
||||
ale.LoginIDs["loginId"] = req.LoginID // 원문
|
||||
ale.LoginIDs["loginId_normalized"] = loginID
|
||||
|
||||
if loginID == "" {
|
||||
ale.Status = fiber.StatusBadRequest
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = "Login ID is required"
|
||||
ale.Log(slog.LevelWarn, "Login ID missing")
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Login ID is required"})
|
||||
}
|
||||
|
||||
if h.IdpProvider == nil {
|
||||
ale.Status = fiber.StatusInternalServerError
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = "IDP Provider is not initialized"
|
||||
ale.Log(slog.LevelError, "IDP Provider is not initialized")
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
|
||||
}
|
||||
|
||||
frontendURL := os.Getenv("FRONTEND_URL")
|
||||
if frontendURL == "" {
|
||||
ale.Status = fiber.StatusInternalServerError
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = "FRONTEND_URL is not set"
|
||||
ale.Log(slog.LevelError, "FRONTEND_URL is not set")
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "FRONTEND_URL environment variable is not set"})
|
||||
}
|
||||
// [Changed] Point to Backend API for verification (which then redirects to Frontend)
|
||||
redirectURL := fmt.Sprintf("%s/api/v1/auth/password/reset/verify", frontendURL)
|
||||
ale.RedirectTo = redirectURL
|
||||
|
||||
// 내부 토큰 발급 + 우리 채널로 전송
|
||||
resetToken := GenerateSecureToken(32)
|
||||
if resetToken == "" {
|
||||
ale.Status = fiber.StatusInternalServerError
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = "Failed to generate reset token"
|
||||
ale.Log(slog.LevelError, "Failed to generate reset token")
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate reset token"})
|
||||
}
|
||||
|
||||
if err := h.RedisService.Set(prefixPwdResetToken+resetToken, loginID, pwdResetExpiration); err != nil {
|
||||
ale.Status = fiber.StatusInternalServerError
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = err.Error()
|
||||
ale.Log(slog.LevelError, "Failed to store reset token in Redis")
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to store reset token"})
|
||||
}
|
||||
|
||||
resetLink := fmt.Sprintf("%s/reset-password?token=%s", frontendURL, resetToken)
|
||||
ale.RedirectTo = resetLink
|
||||
ale.Operation = "SendPasswordReset"
|
||||
ale.Log(slog.LevelInfo, "Initiating password reset via internal token")
|
||||
|
||||
if strings.Contains(loginID, "@") {
|
||||
if h.EmailService == nil {
|
||||
ale.Status = fiber.StatusInternalServerError
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = "Email service not configured"
|
||||
ale.Log(slog.LevelError, "Email service not configured")
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"})
|
||||
}
|
||||
subject := "[Baron SSO] 비밀번호 재설정"
|
||||
body := fmt.Sprintf(`
|
||||
<div style="font-family: sans-serif; padding: 20px; border: 1px solid #eee; border-radius: 10px; max-width: 500px;">
|
||||
<h2 style="color: #1A1F2C;">Baron SSO 비밀번호 재설정</h2>
|
||||
<p>아래 버튼을 클릭해 비밀번호 재설정을 진행해 주세요. 링크는 15분간 유효합니다.</p>
|
||||
<div style="margin: 30px 0;">
|
||||
<a href="%s" style="background-color: #1A1F2C; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;">비밀번호 재설정</a>
|
||||
</div>
|
||||
<p style="font-size: 12px; color: #888;">요청하지 않았다면 이 메일을 무시하세요.</p>
|
||||
</div>
|
||||
`, resetLink)
|
||||
if err := h.EmailService.SendEmail(loginID, subject, body); err != nil {
|
||||
ale.Status = fiber.StatusInternalServerError
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = err.Error()
|
||||
ale.Log(slog.LevelError, "Failed to send reset email", slog.String("loginId", loginID))
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send reset email"})
|
||||
}
|
||||
} else {
|
||||
if err := h.SmsService.SendSms(loginID, fmt.Sprintf("[Baron SSO] 비밀번호 재설정 링크: %s", resetLink)); err != nil {
|
||||
ale.Status = fiber.StatusInternalServerError
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = err.Error()
|
||||
ale.Log(slog.LevelError, "Failed to send reset SMS", slog.String("loginId", loginID))
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send reset SMS"})
|
||||
}
|
||||
}
|
||||
|
||||
ale.Status = fiber.StatusOK
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.Log(slog.LevelInfo, "Password reset link sent successfully (internal token)")
|
||||
return c.JSON(fiber.Map{"message": "If an account with that login ID exists, a reset link has been sent."})
|
||||
}
|
||||
|
||||
// VerifyPasswordResetPage - Serves an interstitial page to prevent link scanners from consuming the token.
|
||||
func (h *AuthHandler) VerifyPasswordResetPage(c *fiber.Ctx) error {
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
token = c.Query("t")
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return c.Status(fiber.StatusBadRequest).SendString("Missing token")
|
||||
}
|
||||
|
||||
// Simple HTML page with a form to trigger the POST request
|
||||
html := fmt.Sprintf(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Baron SSO - 비밀번호 재설정</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #f5f5f5; }
|
||||
.container { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); text-align: center; max-width: 400px; width: 100%%; }
|
||||
h2 { color: #1A1F2C; margin-bottom: 1rem; }
|
||||
p { color: #666; margin-bottom: 2rem; }
|
||||
button { background-color: #1A1F2C; color: white; padding: 12px 24px; border: none; border-radius: 4px; font-size: 16px; cursor: pointer; width: 100%%; transition: background 0.2s; }
|
||||
button:hover { background-color: #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>비밀번호 재설정</h2>
|
||||
<p>아래 버튼을 클릭하여 비밀번호 재설정을 계속해 주세요.</p>
|
||||
<form action="/api/v1/auth/password/reset/verify" method="POST">
|
||||
<input type="hidden" name="token" value="%s">
|
||||
<button type="submit">계속하기</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`, token)
|
||||
|
||||
c.Set("Content-Type", "text/html; charset=utf-8")
|
||||
return c.SendString(html)
|
||||
}
|
||||
|
||||
// ProcessPasswordResetToken - Handles the POST request from the interstitial page.
|
||||
// Verifies the token, sets the refresh token cookie, and redirects to the frontend.
|
||||
func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error {
|
||||
startTime := time.Now()
|
||||
ale := logger.NewAuditLogEntry(c, "verify")
|
||||
ale.Operation = "Verify"
|
||||
|
||||
// Token comes from Form Body in POST or query
|
||||
token := c.FormValue("token")
|
||||
if token == "" {
|
||||
token = c.Query("token")
|
||||
if token == "" {
|
||||
token = c.Query("t")
|
||||
}
|
||||
}
|
||||
ale.Token = token
|
||||
|
||||
if token == "" {
|
||||
ale.Status = fiber.StatusBadRequest
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = "Missing token"
|
||||
ale.Log(slog.LevelWarn, "Missing token in request")
|
||||
return c.Status(fiber.StatusBadRequest).SendString("Missing token")
|
||||
}
|
||||
|
||||
loginID, err := h.RedisService.Get(prefixPwdResetToken + token)
|
||||
if err != nil || loginID == "" {
|
||||
ale.Status = fiber.StatusUnauthorized
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = "Invalid or expired reset token"
|
||||
ale.Log(slog.LevelWarn, "Reset token invalid or expired")
|
||||
return c.Status(fiber.StatusUnauthorized).SendString("Invalid or expired token")
|
||||
}
|
||||
|
||||
ale.LoginIDs["loginId"] = loginID
|
||||
ale.LoginIDs["loginId_normalized"] = loginID
|
||||
|
||||
redirectURL := fmt.Sprintf("%s/reset-password?loginId=%s&token=%s",
|
||||
os.Getenv("FRONTEND_URL"),
|
||||
loginID,
|
||||
token,
|
||||
)
|
||||
|
||||
ale.RedirectTo = redirectURL
|
||||
ale.Status = fiber.StatusFound
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.Log(slog.LevelInfo, "Token verified, redirecting to frontend")
|
||||
|
||||
return c.Redirect(redirectURL)
|
||||
}
|
||||
|
||||
// CompletePasswordReset - 제공된 loginID와 새 비밀번호로 Descope에 비밀번호를 업데이트합니다.
|
||||
// 리프레시 토큰은 요청 쿠키에 포함되어 있어야 합니다.
|
||||
func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
|
||||
startTime := time.Now()
|
||||
ale := logger.NewAuditLogEntry(c, "complete")
|
||||
ale.Operation = "UpdateUserPassword"
|
||||
|
||||
var req struct {
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
ale.Status = fiber.StatusBadRequest
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = err.Error()
|
||||
ale.Log(slog.LevelError, "Body parse error")
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
// loginID는 URL 쿼리 파라미터 또는 토큰 조회로 받습니다.
|
||||
loginID := c.Query("loginId")
|
||||
resetToken := c.Query("token")
|
||||
if loginID == "" && resetToken != "" {
|
||||
if val, err := h.RedisService.Get(prefixPwdResetToken + resetToken); err == nil && val != "" {
|
||||
loginID = val
|
||||
ale.Token = resetToken
|
||||
}
|
||||
}
|
||||
|
||||
ale.LoginIDs["loginId"] = loginID
|
||||
ale.RequestBody = fmt.Sprintf("{\"newPassword\": \"%s\"}", req.NewPassword) // Log request body (for test only)
|
||||
ale.NewPassword = req.NewPassword // Log new password (for test only)
|
||||
|
||||
// Request cookie logging (minimal)
|
||||
if cookieHeader := c.Get(fiber.HeaderCookie); cookieHeader != "" {
|
||||
ale.Headers["Request-Cookie-Header"] = cookieHeader
|
||||
if dsrfCookie := c.Cookies("DSRF"); dsrfCookie != "" {
|
||||
ale.ParsedCookieDSRF = dsrfCookie
|
||||
ale.HasCookieDSRF = true
|
||||
} else {
|
||||
ale.HasCookieDSRF = false
|
||||
}
|
||||
}
|
||||
|
||||
if loginID == "" || req.NewPassword == "" {
|
||||
ale.Status = fiber.StatusBadRequest
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = "Login ID and new password are required"
|
||||
ale.Log(slog.LevelWarn, "Login ID or new password missing")
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Login ID and new password are required"})
|
||||
}
|
||||
|
||||
// 디버깅을 위해 요청된 새 비밀번호를 로그로 출력
|
||||
ale.Log(slog.LevelInfo, "Received new password for reset")
|
||||
|
||||
// Validate password complexity
|
||||
if len(req.NewPassword) < 8 {
|
||||
ale.Status = fiber.StatusBadRequest
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = "Password must be at least 8 characters long"
|
||||
ale.Log(slog.LevelWarn, "Validation failed: password too short")
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must be at least 8 characters long"})
|
||||
}
|
||||
if ok, _ := regexp.MatchString(`[a-z]`, req.NewPassword); !ok {
|
||||
ale.Status = fiber.StatusBadRequest
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = "Password must contain at least one lowercase letter"
|
||||
ale.Log(slog.LevelWarn, "Validation failed: no lowercase letter")
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one lowercase letter"})
|
||||
}
|
||||
if ok, _ := regexp.MatchString(`[A-Z]`, req.NewPassword); !ok {
|
||||
ale.Status = fiber.StatusBadRequest
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = "Password must contain at least one uppercase letter"
|
||||
ale.Log(slog.LevelWarn, "Validation failed: no uppercase letter")
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one uppercase letter"})
|
||||
}
|
||||
if ok, _ := regexp.MatchString(`[0-9]`, req.NewPassword); !ok {
|
||||
ale.Status = fiber.StatusBadRequest
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = "Password must contain at least one number"
|
||||
ale.Log(slog.LevelWarn, "Validation failed: no number")
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one number"})
|
||||
}
|
||||
if ok, _ := regexp.MatchString(`[\W_]`, req.NewPassword); !ok {
|
||||
ale.Status = fiber.StatusBadRequest
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = "Password must contain at least one special character"
|
||||
ale.Log(slog.LevelWarn, "Validation failed: no special character")
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one special character"})
|
||||
}
|
||||
|
||||
ale.Log(slog.LevelInfo, "Attempting to update password via Descope Auth API")
|
||||
|
||||
// Descope Management API를 통해 비밀번호 업데이트
|
||||
if h.DescopeClient == nil {
|
||||
ale.Status = fiber.StatusInternalServerError
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = "Descope Client is nil!"
|
||||
ale.Log(slog.LevelError, "Descope Client is nil")
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
|
||||
}
|
||||
|
||||
if err := h.DescopeClient.Management.User().SetActivePassword(context.Background(), loginID, req.NewPassword); err != nil {
|
||||
// Descope 에러 상세를 감사 로그에 포함
|
||||
if de, ok := err.(*descope.Error); ok {
|
||||
if statusRaw, ok := de.Info[descope.ErrorInfoKeys.HTTPResponseStatusCode]; ok {
|
||||
if statusInt, convErr := strconv.Atoi(fmt.Sprintf("%v", statusRaw)); convErr == nil {
|
||||
ale.DescopeStatus = statusInt
|
||||
}
|
||||
}
|
||||
ale.DescopeBody = de.Message
|
||||
}
|
||||
ale.Status = fiber.StatusInternalServerError
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = err.Error()
|
||||
ale.Log(slog.LevelError, "Failed to update password via IDP")
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update password"})
|
||||
}
|
||||
|
||||
ale.Status = fiber.StatusOK
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.Log(slog.LevelInfo, "Password updated successfully", slog.String("login_id", loginID))
|
||||
if resetToken != "" {
|
||||
_ = h.RedisService.Delete(prefixPwdResetToken + resetToken)
|
||||
}
|
||||
return c.JSON(fiber.Map{"message": "Password has been reset successfully."})
|
||||
}
|
||||
|
||||
// InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다.
|
||||
func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error {
|
||||
pendingRef := GenerateSecureToken(16)
|
||||
|
||||
108
backend/internal/handler/auth_handler_test.go
Normal file
108
backend/internal/handler/auth_handler_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// helper to build a Fiber app with the handler route mounted.
|
||||
func newTestApp(h *AuthHandler) *fiber.App {
|
||||
app := fiber.New()
|
||||
app.Post("/api/v1/auth/password/reset/complete", h.CompletePasswordReset)
|
||||
return app
|
||||
}
|
||||
|
||||
func TestCompletePasswordReset_MissingLoginID(t *testing.T) {
|
||||
h := &AuthHandler{}
|
||||
app := newTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"newPassword": "Password1!",
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/complete", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for missing loginId, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var got map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if got["error"] != "Login ID and new password are required" {
|
||||
t.Fatalf("unexpected error message: %v", got["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletePasswordReset_InvalidPasswordPolicy(t *testing.T) {
|
||||
h := &AuthHandler{}
|
||||
app := newTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"newPassword": "short", // too short + missing complexity
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/complete?loginId=user@example.com", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for weak password, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var got map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if got["error"] != "Password must be at least 8 characters long" {
|
||||
t.Fatalf("unexpected error message: %v", got["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletePasswordReset_NilDescopeClient(t *testing.T) {
|
||||
h := &AuthHandler{} // DescopeClient intentionally nil to hit the configuration error branch
|
||||
app := newTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"newPassword": "StrongPass1!",
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/reset/complete?loginId=user@example.com", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500 when Descope client is nil, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var got map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if got["error"] != "Authentication service not configured" {
|
||||
t.Fatalf("unexpected error message: %v", got["error"])
|
||||
}
|
||||
}
|
||||
232
backend/internal/handler/password_policy_test.go
Normal file
232
backend/internal/handler/password_policy_test.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
"unicode"
|
||||
|
||||
"github.com/descope/go-sdk/descope"
|
||||
"github.com/descope/go-sdk/descope/client"
|
||||
mocksauth "github.com/descope/go-sdk/descope/tests/mocks/auth"
|
||||
)
|
||||
|
||||
// 정책을 받아 필수 요구사항을 모두 포함하는 비밀번호를 생성한다.
|
||||
func generatePasswordFromPolicy(policy *descope.PasswordPolicy) string {
|
||||
minLen := int(policy.MinLength)
|
||||
if minLen < 8 {
|
||||
minLen = 12 // 안전한 기본값
|
||||
}
|
||||
|
||||
pwd := make([]rune, 0, minLen)
|
||||
|
||||
if policy.Lowercase {
|
||||
pwd = append(pwd, 'a')
|
||||
}
|
||||
if policy.Uppercase {
|
||||
pwd = append(pwd, 'B')
|
||||
}
|
||||
if policy.Number {
|
||||
pwd = append(pwd, '3')
|
||||
}
|
||||
if policy.NonAlphanumeric {
|
||||
pwd = append(pwd, '!')
|
||||
}
|
||||
|
||||
charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*"
|
||||
for len(pwd) < minLen {
|
||||
pwd = append(pwd, rune(charset[randomInt(len(charset))]))
|
||||
}
|
||||
|
||||
// 섞어서 예측 가능성을 낮춘다.
|
||||
for i := range pwd {
|
||||
j := randomInt(len(pwd))
|
||||
pwd[i], pwd[j] = pwd[j], pwd[i]
|
||||
}
|
||||
return string(pwd)
|
||||
}
|
||||
|
||||
func randomInt(n int) int {
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
var b [8]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return 0
|
||||
}
|
||||
return int(binary.BigEndian.Uint64(b[:]) % uint64(n))
|
||||
}
|
||||
|
||||
func TestGeneratePasswordUsesNonAlphanumericRequirement(t *testing.T) {
|
||||
mockAuth := &mocksauth.MockAuthentication{
|
||||
MockPassword: &mocksauth.MockPassword{
|
||||
PolicyResponse: &descope.PasswordPolicy{
|
||||
MinLength: 8,
|
||||
Lowercase: true,
|
||||
Uppercase: true,
|
||||
Number: true,
|
||||
NonAlphanumeric: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
policy, err := mockAuth.Password().GetPasswordPolicy(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("정책 조회 실패: %v", err)
|
||||
}
|
||||
if !policy.NonAlphanumeric {
|
||||
t.Fatalf("정책에 비영문자 요구사항이 표시되지 않음")
|
||||
}
|
||||
|
||||
pwd := generatePasswordFromPolicy(policy)
|
||||
|
||||
if len(pwd) < int(policy.MinLength) {
|
||||
t.Fatalf("비밀번호 길이가 정책 최소 길이 미만: got %d, want >= %d", len(pwd), policy.MinLength)
|
||||
}
|
||||
|
||||
var hasLower, hasUpper, hasNumber, hasSymbol bool
|
||||
for _, r := range pwd {
|
||||
switch {
|
||||
case unicode.IsLower(r):
|
||||
hasLower = true
|
||||
case unicode.IsUpper(r):
|
||||
hasUpper = true
|
||||
case unicode.IsNumber(r):
|
||||
hasNumber = true
|
||||
case !unicode.IsLetter(r) && !unicode.IsNumber(r):
|
||||
hasSymbol = true
|
||||
}
|
||||
}
|
||||
|
||||
if policy.Lowercase && !hasLower {
|
||||
t.Fatalf("소문자 요구사항 미충족: %q", pwd)
|
||||
}
|
||||
if policy.Uppercase && !hasUpper {
|
||||
t.Fatalf("대문자 요구사항 미충족: %q", pwd)
|
||||
}
|
||||
if policy.Number && !hasNumber {
|
||||
t.Fatalf("숫자 요구사항 미충족: %q", pwd)
|
||||
}
|
||||
if policy.NonAlphanumeric && !hasSymbol {
|
||||
t.Fatalf("비영문자 요구사항 미충족: %q", pwd)
|
||||
}
|
||||
}
|
||||
|
||||
// 통합 테스트: 실제 Descope 정책으로 비밀번호를 생성하고 교체 플로우를 검증한다.
|
||||
// 필요 env:
|
||||
// DESCOPE_PROJECT_ID, DESCOPE_MANAGEMENT_KEY, TEST_DESCOPE_LOGIN_ID, TEST_DESCOPE_CURRENT_PASSWORD
|
||||
func TestDescopePasswordPolicyAndChange(t *testing.T) {
|
||||
projectID := os.Getenv("DESCOPE_PROJECT_ID")
|
||||
managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY")
|
||||
loginID := os.Getenv("DESCOPE_TEST_ACCOUNT")
|
||||
|
||||
if projectID == "" || managementKey == "" || loginID == "" {
|
||||
t.Skip("환경변수(DESCOPE_PROJECT_ID, DESCOPE_MANAGEMENT_KEY, DESCOPE_TEST_ACCOUNT) 미설정으로 통합 테스트 건너뜀")
|
||||
}
|
||||
|
||||
logf := func(format string, args ...any) {
|
||||
t.Logf(format, args...)
|
||||
fmt.Printf(format+"\n", args...)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
cl, err := client.NewWithConfig(&client.Config{
|
||||
ProjectID: projectID,
|
||||
ManagementKey: managementKey,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Descope 클라이언트 초기화 실패: %v", err)
|
||||
}
|
||||
|
||||
policy, err := cl.Auth.Password().GetPasswordPolicy(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("비밀번호 정책 조회 실패: %v", err)
|
||||
}
|
||||
logf("정책: min=%d lower=%v upper=%v number=%v nonAlpha=%v", policy.MinLength, policy.Lowercase, policy.Uppercase, policy.Number, policy.NonAlphanumeric)
|
||||
|
||||
// 테스트 계정이 없으면 생성
|
||||
users, _, err := cl.Management.User().SearchAll(ctx, &descope.UserSearchOptions{
|
||||
LoginIDs: []string{loginID},
|
||||
Limit: 1,
|
||||
Page: 0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("테스트 계정 검색 실패: %v", err)
|
||||
}
|
||||
if len(users) == 0 {
|
||||
logf("테스트 계정 미존재, 생성 시도: %s", loginID)
|
||||
if _, err := cl.Management.User().CreateTestUser(ctx, loginID, &descope.UserRequest{
|
||||
User: descope.User{
|
||||
Email: loginID,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("테스트 계정 생성 실패: %v", err)
|
||||
}
|
||||
} else {
|
||||
logf("테스트 계정 존재 확인: %s", loginID)
|
||||
}
|
||||
|
||||
// 1) 기초 비밀번호 설정 (알려진 값으로 초기화)
|
||||
basePassword := generatePasswordFromPolicy(policy)
|
||||
if err := cl.Management.User().SetActivePassword(ctx, loginID, basePassword); err != nil {
|
||||
logf("초기 비밀번호 설정 실패: status=%d err=%v", statusFromError(err), err)
|
||||
t.Fatalf("초기 비밀번호 설정 실패: %v", err)
|
||||
}
|
||||
logf("초기 비밀번호 설정 완료: %s", basePassword)
|
||||
|
||||
// 2) 초기 비밀번호 로그인 검증
|
||||
wOld := httptest.NewRecorder()
|
||||
_, err = cl.Auth.Password().SignIn(ctx, loginID, basePassword, wOld)
|
||||
logf("기초 비밀번호 로그인: status=%d err=%v", statusFromError(err), err)
|
||||
if err != nil {
|
||||
t.Fatalf("기초 비밀번호 로그인 실패: %v", err)
|
||||
}
|
||||
|
||||
// 3) 새 비밀번호 생성 및 변경
|
||||
newPassword := generatePasswordFromPolicy(policy)
|
||||
if newPassword == basePassword {
|
||||
newPassword = newPassword + "Z9!"
|
||||
}
|
||||
logf("새 비밀번호 생성: %s", newPassword)
|
||||
|
||||
if err := cl.Management.User().SetActivePassword(ctx, loginID, newPassword); err != nil {
|
||||
logf("비밀번호 변경 실패: status=%d err=%v", statusFromError(err), err)
|
||||
t.Fatalf("비밀번호 변경 실패: %v", err)
|
||||
}
|
||||
logf("비밀번호 변경 성공(status=200)")
|
||||
|
||||
// 4) 새 비밀번호로 로그인 확인
|
||||
wNew := httptest.NewRecorder()
|
||||
_, err = cl.Auth.Password().SignIn(ctx, loginID, newPassword, wNew)
|
||||
logf("새 비밀번호 로그인: status=%d err=%v", statusFromError(err), err)
|
||||
if err != nil {
|
||||
t.Fatalf("새 비밀번호 로그인 실패: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func statusFromError(err error) int {
|
||||
if err == nil {
|
||||
return http.StatusOK
|
||||
}
|
||||
var de *descope.Error
|
||||
if errors.As(err, &de) {
|
||||
if statusRaw, ok := de.Info[descope.ErrorInfoKeys.HTTPResponseStatusCode]; ok {
|
||||
switch v := statusRaw.(type) {
|
||||
case int:
|
||||
return v
|
||||
case string:
|
||||
if n, convErr := strconv.Atoi(v); convErr == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
63
backend/internal/idp/factory.go
Normal file
63
backend/internal/idp/factory.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package idp
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// getEnv는 환경 변수를 읽거나 대체 값을 반환하는 헬퍼 함수입니다.
|
||||
func getEnv(key, fallback string) string {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// InitializeProvider는 환경 설정을 기반으로 IDP 공급자를 생성하고 반환합니다.
|
||||
// 이것은 IdentityProvider 인터페이스의 팩토리 역할을 합니다.
|
||||
func InitializeProvider() (domain.IdentityProvider, error) {
|
||||
rawProviders := getEnv("IDP_PROVIDER", "descope") // 기본값은 descope입니다.
|
||||
providers := strings.Split(rawProviders, ",")
|
||||
slog.Info("Initializing IDP", "providers", rawProviders)
|
||||
|
||||
for _, p := range providers {
|
||||
providerName := strings.TrimSpace(strings.ToLower(p))
|
||||
switch providerName {
|
||||
case "descope":
|
||||
descopeProjectID := getEnv("DESCOPE_PROJECT_ID", "")
|
||||
descopeManagementKey := getEnv("DESCOPE_MANAGEMENT_KEY", "")
|
||||
// 선택된 공급자에 대한 키가 설정되었는지 확인하기 위한 기본 유효성 검사
|
||||
if descopeProjectID == "" || descopeManagementKey == "" {
|
||||
return nil, fmt.Errorf("DESCOPE_PROJECT_ID and DESCOPE_MANAGEMENT_KEY must be set for the 'descope' provider")
|
||||
}
|
||||
return service.NewDescopeProvider(descopeProjectID, descopeManagementKey), nil
|
||||
|
||||
// --- 향후 공급자 구현 ---
|
||||
// case "ory":
|
||||
// // oryURL := getEnv("ORY_URL", "")
|
||||
// // if oryURL == "" {
|
||||
// // return nil, fmt.Errorf("ORY_URL must be set for the 'ory' provider")
|
||||
// // }
|
||||
// // return service.NewOryProvider(oryURL), nil
|
||||
// // return nil, fmt.Errorf(\"'ory' provider is not yet implemented\")
|
||||
|
||||
// case "keycloak":
|
||||
// // keycloakURL := getEnv("KEYCLOAK_URL", "")
|
||||
// // keycloakRealm := getEnv("KEYCLOAK_REALM", "")
|
||||
// // if keycloakURL == "" || keycloakRealm == "" {
|
||||
// // return nil, fmt.Errorf("KEYCLOAK_URL and KEYCLOAK_REALM must be set for the 'keycloak' provider")
|
||||
// // }
|
||||
// // return service.NewKeycloakProvider(keycloakURL, keycloakRealm), nil
|
||||
// // return nil, fmt.Errorf(\"'keycloak' provider is not yet implemented\")
|
||||
default:
|
||||
// 알 수 없는 공급자는 건너뛰고 다음 후보를 시도
|
||||
slog.Warn("Skipping unsupported IDP provider entry", "provider", providerName)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unsupported or unknown IDP_PROVIDER specified: %s", rawProviders)
|
||||
}
|
||||
216
backend/internal/logger/audit_logger.go
Normal file
216
backend/internal/logger/audit_logger.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AuditLogEntry holds common audit logging fields.
|
||||
type AuditLogEntry struct {
|
||||
RequestID string
|
||||
Stage string
|
||||
Operation string // e.g., "SendPasswordReset", "Verify"
|
||||
Method string
|
||||
Path string
|
||||
Status int
|
||||
LatencyMs time.Duration
|
||||
IP string
|
||||
UserAgent string
|
||||
Origin string
|
||||
Referer string
|
||||
Query map[string]string
|
||||
Headers map[string]string // Core headers like Host, Cookie, Set-Cookie
|
||||
LoginIDs map[string]string // loginId and loginId_normalized
|
||||
Token string // For reset tokens, magic link tokens
|
||||
DescopeError string
|
||||
DescopeStatus int // Descope HTTP status
|
||||
DescopeBody string // Descope response body (full raw)
|
||||
RefreshToken string
|
||||
SessionJwt string
|
||||
AccessJwt string
|
||||
UserLoginId string
|
||||
UserID string
|
||||
Email string
|
||||
Phone string
|
||||
SetCookieName string
|
||||
SetCookieValue string
|
||||
SetCookieAttrs map[string]string
|
||||
RedirectTo string
|
||||
HasCookieDSRF bool
|
||||
ParsedCookieDSRF string
|
||||
RequestBody string // For complete stage
|
||||
NewPassword string // For complete stage (test only, sensitive)
|
||||
// ... potentially more fields specific to different stages
|
||||
}
|
||||
|
||||
// NewAuditLogEntry creates a new AuditLogEntry with a generated RequestID and initial common fields.
|
||||
func NewAuditLogEntry(c *fiber.Ctx, stage string) *AuditLogEntry {
|
||||
reqID := uuid.New().String()
|
||||
|
||||
// Extract query parameters
|
||||
queryParams := make(map[string]string)
|
||||
c.Context().QueryArgs().VisitAll(func(key, value []byte) {
|
||||
queryParams[string(key)] = string(value)
|
||||
})
|
||||
|
||||
// Extract relevant headers
|
||||
headers := make(map[string]string)
|
||||
headers["Host"] = c.Get("Host")
|
||||
headers["User-Agent"] = c.Get("User-Agent")
|
||||
if cookie := c.Get("Cookie"); cookie != "" {
|
||||
headers["Cookie"] = cookie
|
||||
}
|
||||
headers["Origin"] = c.Get("Origin")
|
||||
headers["Referer"] = c.Get("Referer")
|
||||
|
||||
|
||||
return &AuditLogEntry{
|
||||
RequestID: reqID,
|
||||
Stage: stage,
|
||||
Method: c.Method(),
|
||||
Path: c.Path(),
|
||||
IP: c.IP(),
|
||||
UserAgent: c.Get("User-Agent"),
|
||||
Origin: c.Get("Origin"),
|
||||
Referer: c.Get("Referer"),
|
||||
Query: queryParams,
|
||||
Headers: headers,
|
||||
LoginIDs: make(map[string]string),
|
||||
SetCookieAttrs: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Log emits an audit log entry using slog.
|
||||
// It includes common fields and allows for additional custom fields.
|
||||
func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) {
|
||||
attrs := []slog.Attr{
|
||||
slog.String("req_id", ale.RequestID),
|
||||
slog.String("stage", ale.Stage),
|
||||
}
|
||||
|
||||
if ale.Operation != "" {
|
||||
attrs = append(attrs, slog.String("op", ale.Operation))
|
||||
}
|
||||
if ale.Method != "" {
|
||||
attrs = append(attrs, slog.String("method", ale.Method))
|
||||
}
|
||||
if ale.Path != "" {
|
||||
attrs = append(attrs, slog.String("path", ale.Path))
|
||||
}
|
||||
if ale.Status != 0 {
|
||||
attrs = append(attrs, slog.Int("status", ale.Status))
|
||||
}
|
||||
if ale.LatencyMs != 0 {
|
||||
attrs = append(attrs, slog.Duration("latency_ms", ale.LatencyMs))
|
||||
}
|
||||
if ale.IP != "" {
|
||||
attrs = append(attrs, slog.String("ip", ale.IP))
|
||||
}
|
||||
if ale.UserAgent != "" {
|
||||
attrs = append(attrs, slog.String("user_agent", ale.UserAgent))
|
||||
}
|
||||
if ale.Origin != "" {
|
||||
attrs = append(attrs, slog.String("origin", ale.Origin))
|
||||
}
|
||||
if ale.Referer != "" {
|
||||
attrs = append(attrs, slog.String("referer", ale.Referer))
|
||||
}
|
||||
if len(ale.Query) > 0 {
|
||||
queryGroupArgs := make([]any, 0, len(ale.Query))
|
||||
for k, v := range ale.Query {
|
||||
queryGroupArgs = append(queryGroupArgs, slog.String(k, v))
|
||||
}
|
||||
attrs = append(attrs, slog.Group("query", queryGroupArgs...))
|
||||
}
|
||||
if len(ale.Headers) > 0 {
|
||||
headersGroupArgs := make([]any, 0, len(ale.Headers))
|
||||
for k, v := range ale.Headers {
|
||||
headersGroupArgs = append(headersGroupArgs, slog.String(k, v))
|
||||
}
|
||||
attrs = append(attrs, slog.Group("headers", headersGroupArgs...))
|
||||
}
|
||||
if len(ale.LoginIDs) > 0 {
|
||||
loginIDGroupArgs := make([]any, 0, len(ale.LoginIDs))
|
||||
for k, v := range ale.LoginIDs {
|
||||
loginIDGroupArgs = append(loginIDGroupArgs, slog.String(k, v))
|
||||
}
|
||||
attrs = append(attrs, slog.Group("login_ids", loginIDGroupArgs...))
|
||||
}
|
||||
if ale.Token != "" {
|
||||
attrs = append(attrs, slog.String("token", ale.Token))
|
||||
}
|
||||
if ale.DescopeError != "" {
|
||||
attrs = append(attrs, slog.String("descope_error", ale.DescopeError))
|
||||
}
|
||||
if ale.DescopeStatus != 0 {
|
||||
attrs = append(attrs, slog.Int("descope_http_status", ale.DescopeStatus))
|
||||
}
|
||||
if ale.DescopeBody != "" {
|
||||
attrs = append(attrs, slog.String("descope_response_body", ale.DescopeBody))
|
||||
}
|
||||
if ale.RefreshToken != "" {
|
||||
attrs = append(attrs, slog.String("refresh_token", ale.RefreshToken))
|
||||
}
|
||||
if ale.SessionJwt != "" {
|
||||
attrs = append(attrs, slog.String("session_jwt", ale.SessionJwt))
|
||||
}
|
||||
if ale.AccessJwt != "" {
|
||||
attrs = append(attrs, slog.String("access_jwt", ale.AccessJwt))
|
||||
}
|
||||
if ale.UserLoginId != "" {
|
||||
attrs = append(attrs, slog.String("user_login_id", ale.UserLoginId))
|
||||
}
|
||||
if ale.UserID != "" {
|
||||
attrs = append(attrs, slog.String("user_id", ale.UserID))
|
||||
}
|
||||
if ale.Email != "" {
|
||||
attrs = append(attrs, slog.String("email", ale.Email))
|
||||
}
|
||||
if ale.Phone != "" {
|
||||
attrs = append(attrs, slog.String("phone", ale.Phone))
|
||||
}
|
||||
if ale.SetCookieName != "" {
|
||||
attrs = append(attrs, slog.String("set_cookie_name", ale.SetCookieName))
|
||||
attrs = append(attrs, slog.String("set_cookie_value", ale.SetCookieValue))
|
||||
if len(ale.SetCookieAttrs) > 0 {
|
||||
cookieAttrsGroupArgs := make([]any, 0, len(ale.SetCookieAttrs))
|
||||
for k, v := range ale.SetCookieAttrs {
|
||||
cookieAttrsGroupArgs = append(cookieAttrsGroupArgs, slog.String(k, v))
|
||||
}
|
||||
attrs = append(attrs, slog.Group("set_cookie_attrs", cookieAttrsGroupArgs...))
|
||||
}
|
||||
}
|
||||
if ale.RedirectTo != "" {
|
||||
attrs = append(attrs, slog.String("redirect_to", ale.RedirectTo))
|
||||
}
|
||||
if ale.HasCookieDSRF {
|
||||
attrs = append(attrs, slog.Bool("has_cookie_DSRF", ale.HasCookieDSRF))
|
||||
}
|
||||
if ale.ParsedCookieDSRF != "" {
|
||||
attrs = append(attrs, slog.String("parsed_cookie_DSRF", ale.ParsedCookieDSRF))
|
||||
}
|
||||
if ale.RequestBody != "" {
|
||||
attrs = append(attrs, slog.String("request_body", ale.RequestBody))
|
||||
}
|
||||
if ale.NewPassword != "" { // FOR TEST ONLY - DO NOT LOG IN PRODUCTION
|
||||
attrs = append(attrs, slog.String("new_password", ale.NewPassword))
|
||||
}
|
||||
|
||||
// Convert variadic args to slog.Attr before appending
|
||||
for i := 0; i < len(args); i += 2 {
|
||||
if i+1 < len(args) {
|
||||
attrs = append(attrs, slog.Any(fmt.Sprintf("%v", args[i]), args[i+1]))
|
||||
} else {
|
||||
// Handle odd number of arguments - log the last one with a generic key
|
||||
attrs = append(attrs, slog.Any(fmt.Sprintf("extra_arg_%d", i), args[i]))
|
||||
}
|
||||
}
|
||||
|
||||
slog.Default().LogAttrs(context.Background(), level, msg, attrs...)
|
||||
}
|
||||
@@ -2,13 +2,20 @@ package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/descope/go-sdk/descope"
|
||||
"github.com/descope/go-sdk/descope/client"
|
||||
)
|
||||
|
||||
type DescopeProvider struct {
|
||||
Client *client.DescopeClient
|
||||
FrontendURL string
|
||||
fieldMapping map[string]string // Key: Broker Field Name, Value: Descope Attribute Key
|
||||
}
|
||||
|
||||
@@ -36,6 +43,7 @@ func NewDescopeProvider(projectID, managementKey string) *DescopeProvider {
|
||||
|
||||
return &DescopeProvider{
|
||||
Client: descopeClient,
|
||||
FrontendURL: os.Getenv("FRONTEND_URL"),
|
||||
fieldMapping: mapping,
|
||||
}
|
||||
}
|
||||
@@ -60,3 +68,56 @@ func (d *DescopeProvider) GetMetadata() (*domain.IDPMetadata, error) {
|
||||
SupportedFields: supported,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *DescopeProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
|
||||
ctx := context.Background()
|
||||
err := d.Client.Auth.Password().SendPasswordReset(ctx, loginID, redirectUrl, nil)
|
||||
if err != nil {
|
||||
slog.Error("Descope SendPasswordReset failed (raw)",
|
||||
"loginID", loginID,
|
||||
"redirectUrl", redirectUrl,
|
||||
"err", err,
|
||||
"err_type", fmt.Sprintf("%T", err),
|
||||
)
|
||||
|
||||
if de, ok := err.(*descope.Error); ok {
|
||||
status := de.Info[descope.ErrorInfoKeys.HTTPResponseStatusCode] // "Status-Code"
|
||||
slog.Error("Descope error details",
|
||||
"code", de.Code,
|
||||
"description", de.Description,
|
||||
"message", de.Message,
|
||||
"status_code", status,
|
||||
"info", de.Info,
|
||||
)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DescopeProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
|
||||
ctx := context.Background()
|
||||
authInfo, err := d.Client.Auth.MagicLink().Verify(ctx, token, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := &domain.AuthInfo{
|
||||
SessionToken: &domain.Token{
|
||||
JWT: authInfo.SessionToken.JWT,
|
||||
Expiration: time.Unix(authInfo.SessionToken.Expiration, 0),
|
||||
},
|
||||
}
|
||||
if authInfo.RefreshToken != nil {
|
||||
res.RefreshToken = &domain.Token{
|
||||
JWT: authInfo.RefreshToken.JWT,
|
||||
Expiration: time.Unix(authInfo.RefreshToken.Expiration, 0),
|
||||
}
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (d *DescopeProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
|
||||
ctx := context.Background()
|
||||
return d.Client.Auth.Password().UpdateUserPassword(ctx, loginID, newPassword, r)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package validator
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -20,6 +21,19 @@ func (m *MockProvider) GetMetadata() (*domain.IDPMetadata, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Stub implementations to satisfy the IdentityProvider interface for this unit test.
|
||||
func (m *MockProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
|
||||
return &domain.AuthInfo{}, nil
|
||||
}
|
||||
|
||||
func (m *MockProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestValidateIDPCompatibility(t *testing.T) {
|
||||
// BrokerUser 모델은 다음과 같이 정의되어 있다고 가정합니다 (idp_models.go 참조):
|
||||
// ID (required), Email (required), Name, PhoneNumber
|
||||
|
||||
@@ -5,6 +5,16 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
class AuthProxyService {
|
||||
static String get _baseUrl => dotenv.env['BACKEND_URL'] ?? 'https://sso.hmac.kr';
|
||||
|
||||
static Future<Map<String, dynamic>> fetchPasswordPolicy() async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/auth/password/policy');
|
||||
final response = await http.get(url);
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
} else {
|
||||
throw Exception('Failed to fetch password policy');
|
||||
}
|
||||
}
|
||||
|
||||
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://sso.hmac.kr';
|
||||
@@ -66,6 +76,69 @@ class AuthProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> loginWithPassword(String loginId, String password) async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/auth/password/login');
|
||||
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'loginId': loginId,
|
||||
'password': password,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
} else {
|
||||
final errorBody = jsonDecode(response.body);
|
||||
throw Exception(errorBody['error'] ?? 'Failed to login');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> initiatePasswordReset(String loginId) async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/initiate');
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'loginId': loginId}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
} else {
|
||||
final errorBody = jsonDecode(response.body);
|
||||
throw Exception(errorBody['error'] ?? 'Failed to initiate password reset');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> completePasswordReset({
|
||||
String? loginId,
|
||||
String? token,
|
||||
required String newPassword,
|
||||
}) async {
|
||||
final query = <String, String>{};
|
||||
if (loginId != null && loginId.isNotEmpty) {
|
||||
query['loginId'] = loginId;
|
||||
}
|
||||
if (token != null && token.isNotEmpty) {
|
||||
query['token'] = token;
|
||||
}
|
||||
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/complete').replace(queryParameters: query);
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'newPassword': newPassword}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
} else {
|
||||
final errorBody = jsonDecode(response.body);
|
||||
throw Exception(errorBody['error'] ?? 'Failed to complete password reset');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> sendSms(String phoneNumber) async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/auth/sms');
|
||||
|
||||
|
||||
@@ -108,12 +108,28 @@ class _CreateUserScreenState extends State<CreateUserScreen> {
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
String loginId = _loginIdController.text.trim();
|
||||
if (!loginId.contains('@')) {
|
||||
loginId = loginId.replaceAll(RegExp(r'[-\s]'), '');
|
||||
if (loginId.startsWith('010')) {
|
||||
loginId = '+82${loginId.substring(1)}';
|
||||
}
|
||||
}
|
||||
|
||||
String? phone = _phoneController.text.trim().isEmpty ? null : _phoneController.text.trim();
|
||||
if (phone != null && !phone.contains('@')) {
|
||||
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
|
||||
if (phone.startsWith('010')) {
|
||||
phone = '+82${phone.substring(1)}';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await AuthProxyService.createUser(
|
||||
loginId: _loginIdController.text.trim(),
|
||||
loginId: loginId,
|
||||
adminPassword: _verifiedAdminPassword!,
|
||||
email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(),
|
||||
phone: _phoneController.text.trim().isEmpty ? null : _phoneController.text.trim(),
|
||||
phone: phone,
|
||||
displayName: _nameController.text.trim().isEmpty ? null : _nameController.text.trim(),
|
||||
);
|
||||
|
||||
|
||||
@@ -205,13 +205,22 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
||||
if (confirm != true) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
String? phone = phoneController.text.trim().isEmpty ? null : phoneController.text.trim();
|
||||
if (phone != null && !phone.contains('@')) {
|
||||
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
|
||||
if (phone.startsWith('010')) {
|
||||
phone = '+82${phone.substring(1)}';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await AuthProxyService.updateUserDetails(
|
||||
adminPassword: _verifiedAdminPassword!,
|
||||
loginId: loginId,
|
||||
displayName: nameController.text.trim(),
|
||||
email: emailController.text.trim(),
|
||||
phone: phoneController.text.trim(),
|
||||
phone: phone,
|
||||
);
|
||||
_showSuccess("User updated successfully");
|
||||
_loadUsers(query: _searchController.text);
|
||||
@@ -228,12 +237,29 @@ class _UserManagementScreenState extends State<UserManagementScreen> with Single
|
||||
if (_verifiedAdminPassword == null) return;
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
String loginId = _createLoginIdController.text.trim();
|
||||
if (!loginId.contains('@')) {
|
||||
loginId = loginId.replaceAll(RegExp(r'[-\s]'), '');
|
||||
if (loginId.startsWith('010')) {
|
||||
loginId = '+82${loginId.substring(1)}';
|
||||
}
|
||||
}
|
||||
|
||||
String? phone = _createPhoneController.text.trim().isEmpty ? null : _createPhoneController.text.trim();
|
||||
if (phone != null && !phone.contains('@')) {
|
||||
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
|
||||
if (phone.startsWith('010')) {
|
||||
phone = '+82${phone.substring(1)}';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await AuthProxyService.createUser(
|
||||
loginId: _createLoginIdController.text.trim(),
|
||||
loginId: loginId,
|
||||
adminPassword: _verifiedAdminPassword!,
|
||||
email: _createEmailController.text.trim().isEmpty ? null : _createEmailController.text.trim(),
|
||||
phone: _createPhoneController.text.trim().isEmpty ? null : _createPhoneController.text.trim(),
|
||||
phone: phone,
|
||||
displayName: _createNameController.text.trim().isEmpty ? null : _createNameController.text.trim(),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
|
||||
class ForgotPasswordScreen extends StatefulWidget {
|
||||
const ForgotPasswordScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ForgotPasswordScreen> createState() => _ForgotPasswordScreenState();
|
||||
}
|
||||
|
||||
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||
final TextEditingController _loginIdController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
|
||||
Future<void> _handlePasswordReset() async {
|
||||
final input = _loginIdController.text.trim();
|
||||
if (input.isEmpty) {
|
||||
_showError("이메일 또는 휴대폰 번호를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
String loginId = input;
|
||||
if (!input.contains('@')) {
|
||||
// Format phone number if it's not an email
|
||||
loginId = input.replaceAll(RegExp(r'[-\s]'), '');
|
||||
if (loginId.startsWith('010')) {
|
||||
loginId = '+82${loginId.substring(1)}';
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
await AuthProxyService.initiatePasswordReset(loginId);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요."),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showError("전송에 실패했습니다: ${e.toString()}");
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("비밀번호 재설정"),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
"비밀번호를 잊으셨나요?",
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
"계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
TextField(
|
||||
controller: _loginIdController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "이메일 또는 휴대폰 번호",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
),
|
||||
onSubmitted: (_) => _handlePasswordReset(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _isLoading ? null : _handlePasswordReset,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Text("재설정 링크 전송"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import '../../../core/services/audit_service.dart';
|
||||
import '../../../core/services/web_auth_integration.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../core/notifiers/auth_notifier.dart';
|
||||
import './forgot_password_screen.dart';
|
||||
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
final String? verificationToken;
|
||||
@@ -24,9 +25,9 @@ class LoginScreen extends ConsumerStatefulWidget {
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
final TextEditingController _idController = TextEditingController();
|
||||
final TextEditingController _smsCodeController = TextEditingController(); // Keep if needed for verification inputs later? Actually not used in link flow.
|
||||
bool _smsSent = false;
|
||||
final TextEditingController _linkIdController = TextEditingController();
|
||||
final TextEditingController _passwordLoginIdController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
String? _redirectUrl;
|
||||
|
||||
// QR Login Variables
|
||||
@@ -40,7 +41,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
// 탭 컨트롤러: 3개 탭, 기본 선택은 두 번째 탭("로그인 링크")
|
||||
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
|
||||
_tabController.addListener(_handleTabSelection);
|
||||
|
||||
// Check for tokens (Path Parameter or Legacy Query Parameter)
|
||||
@@ -92,9 +94,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
}
|
||||
|
||||
void _handleTabSelection() {
|
||||
if (_tabController.index == 1 && _qrPendingRef == null) {
|
||||
// QR 탭 (세 번째 탭, index 2)이 선택되었을 때 QR 플로우 시작
|
||||
if (_tabController.index == 2 && _qrPendingRef == null) {
|
||||
_startQrFlow();
|
||||
} else if (_tabController.index != 1) {
|
||||
} else if (_tabController.index != 2) {
|
||||
_stopQrPolling();
|
||||
}
|
||||
}
|
||||
@@ -230,28 +233,65 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
void dispose() {
|
||||
_stopQrPolling();
|
||||
_tabController.dispose();
|
||||
_idController.dispose();
|
||||
_smsCodeController.dispose();
|
||||
_linkIdController.dispose();
|
||||
_passwordLoginIdController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleLogin() async {
|
||||
final input = _idController.text.trim();
|
||||
// 이메일/비밀번호 로그인 처리
|
||||
Future<void> _handlePasswordLogin() async {
|
||||
final input = _passwordLoginIdController.text.trim();
|
||||
final password = _passwordController.text.trim();
|
||||
if (input.isEmpty || password.isEmpty) {
|
||||
_showError("이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
String loginId = input;
|
||||
if (!input.contains('@')) {
|
||||
// Format phone number if it's not an email
|
||||
loginId = input.replaceAll(RegExp(r'[-\s]'), '');
|
||||
if (loginId.startsWith('010')) {
|
||||
loginId = '+82${loginId.substring(1)}';
|
||||
}
|
||||
}
|
||||
|
||||
// 로딩 인디케이터 표시
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
|
||||
try {
|
||||
final res = await AuthProxyService.loginWithPassword(loginId, password);
|
||||
final jwt = res['sessionJwt'];
|
||||
if (jwt != null && mounted) {
|
||||
Navigator.of(context).pop(); // 로딩 닫기
|
||||
|
||||
final displayName = _getLoginIdFromJwt(jwt);
|
||||
final dummyUser = DescopeUser(
|
||||
'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [],
|
||||
);
|
||||
final session = DescopeSession.fromJwt(jwt, jwt, dummyUser);
|
||||
Descope.sessionManager.manageSession(session);
|
||||
|
||||
_onLoginSuccess(jwt);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) Navigator.of(context).pop(); // 로딩 닫기
|
||||
_showError("로그인 실패: ${e.toString().replaceFirst("Exception: ", "")}");
|
||||
}
|
||||
}
|
||||
|
||||
// 로그인 링크 전송 처리
|
||||
Future<void> _handleLinkLogin() async {
|
||||
final input = _linkIdController.text.trim();
|
||||
if (input.isEmpty) return;
|
||||
|
||||
String loginId = input;
|
||||
@@ -340,26 +380,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
final displayName = _getLoginIdFromJwt(jwt);
|
||||
// Descope SDK 세션 강제 주입
|
||||
// Note: DescopeUser in 0.9.11 requires 18 positional arguments.
|
||||
final dummyUser = DescopeUser(
|
||||
'unknown', // userId
|
||||
[], // loginIds
|
||||
0, // createdAt
|
||||
displayName, // name
|
||||
null, // picture (Uri?)
|
||||
'', // email
|
||||
false, // isVerifiedEmail
|
||||
'', // phone
|
||||
false, // isVerifiedPhone
|
||||
{}, // customAttributes
|
||||
'', // givenName
|
||||
'', // middleName
|
||||
'', // familyName
|
||||
false, // hasPassword
|
||||
'enabled', // status
|
||||
[], // roleNames
|
||||
[], // ssoAppIds
|
||||
[], // oauthProviders (List<String>)
|
||||
'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [],
|
||||
);
|
||||
final session = DescopeSession.fromJwt(jwt, jwt, dummyUser);
|
||||
Descope.sessionManager.manageSession(session);
|
||||
@@ -397,38 +419,25 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
void _logTokenDetails(String jwt) {
|
||||
try {
|
||||
// JWT는 세 부분(Header, Payload, Signature)이 '.'으로 구분된 문자열입니다. 이를 분리합니다.
|
||||
final parts = jwt.split('.');
|
||||
// 세 부분으로 정확히 나뉘지 않았다면 유효한 JWT가 아니므로 중단합니다.
|
||||
if (parts.length != 3) return;
|
||||
|
||||
// JWT의 두 번째 부분(Payload)은 Base64Url로 인코딩된 JSON 데이터입니다.
|
||||
// 1. Base64Url 문자열을 디코딩하여 바이트 배열로 변환합니다.
|
||||
// normalize()는 Base64 패딩(=) 문제를 처리해줍니다.
|
||||
final decodedPayload = base64Url.decode(base64Url.normalize(parts[1]));
|
||||
// 2. 바이트 배열을 UTF-8 형식의 일반 문자열(JSON)로 변환합니다.
|
||||
final payloadJson = utf8.decode(decodedPayload);
|
||||
// 3. JSON 문자열을 Dart에서 사용할 수 있는 Map 객체로 변환합니다.
|
||||
final data = json.decode(payloadJson) as Map<String, dynamic>;
|
||||
|
||||
// [FIX] 'exp'는 int 또는 double일 수 있으므로, 안전하게 num으로 처리합니다.
|
||||
final accessExpValue = data['exp'] as num?;
|
||||
// 'exp' (Expiration Time) 필드는 Access Token의 만료 시간을 나타냅니다. Unix 타임스탬프(초 단위) 값입니다.
|
||||
// 이 값을 Dart의 DateTime 객체로 변환합니다. (1000을 곱해 밀리초 단위로 만듦)
|
||||
final accessExp = accessExpValue != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(accessExpValue.toInt() * 1000)
|
||||
: 'N/A';
|
||||
// 'rexp' (Refresh Expiration) 필드는 Descope가 사용하는 커스텀 필드로, Refresh Token의 만료 시간을 ISO 8601 형식의 문자열로 나타냅니다.
|
||||
final refreshExp = data['rexp'] ?? 'N/A';
|
||||
|
||||
// 확인된 만료 시간 정보들을 디버그 콘솔에 출력합니다.
|
||||
debugPrint("""
|
||||
[Auth] Session Token Details ---
|
||||
- Access Token Expires: $accessExp
|
||||
- Refresh Token Expires: $refreshExp
|
||||
""");
|
||||
} catch (e) {
|
||||
// JWT를 해석하는 과정에서 오류가 발생하면 콘솔에 에러를 출력합니다.
|
||||
debugPrint("[Auth] Failed to decode or log token details: $e");
|
||||
}
|
||||
}
|
||||
@@ -438,7 +447,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
_logTokenDetails(token);
|
||||
|
||||
// [FIX] 감사 로그에 실제 사용자 ID를 전송하기 위해 토큰에서 ID를 추출합니다.
|
||||
final userId = _getUserIdFromJwt(token);
|
||||
|
||||
// Record Audit Log
|
||||
@@ -449,16 +457,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
details: "User logged in via Baron SSO",
|
||||
);
|
||||
|
||||
// 1. Handle Popup Flow (Highest Priority for child windows)
|
||||
// If opened as a popup (has opener), we notify and try to close.
|
||||
// 1. Handle Popup Flow
|
||||
if (WebAuthIntegration.isPopup()) {
|
||||
debugPrint("[Auth] Popup detected. Notifying opener and attempting to close.");
|
||||
WebAuthIntegration.sendLoginSuccess(token);
|
||||
|
||||
// We don't 'return' here to allow a fallback if window.close() is blocked,
|
||||
// but in most cases WebAuthIntegration.sendLoginSuccess will close the window.
|
||||
} else {
|
||||
// 2. Handle Redirect Flow (Only if NOT a popup)
|
||||
// 2. Handle Redirect Flow
|
||||
if (_redirectUrl != null && _redirectUrl!.isNotEmpty) {
|
||||
debugPrint("[Auth] Redirecting standalone window to: $_redirectUrl");
|
||||
final target = "$_redirectUrl?token=$token";
|
||||
@@ -468,7 +472,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
}
|
||||
|
||||
// 3. Standalone mode / Fallback
|
||||
// If it's a standard login, or if a popup's window.close() was blocked by the browser.
|
||||
debugPrint("[Auth] Login success. Navigating to root.");
|
||||
AuthNotifier.instance.notify();
|
||||
if (mounted) {
|
||||
@@ -505,7 +508,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
tabs: const [
|
||||
Tab(text: "로그인"),
|
||||
Tab(text: "비밀번호"),
|
||||
Tab(text: "로그인 링크"),
|
||||
Tab(text: "QR 코드"),
|
||||
],
|
||||
),
|
||||
@@ -516,24 +520,72 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
// Unified Login Form
|
||||
// 1. 이메일/비밀번호 로그인 폼
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _idController,
|
||||
controller: _passwordLoginIdController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "이메일 또는 휴대폰 번호",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
),
|
||||
onSubmitted: (_) => _handlePasswordLogin(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "비밀번호",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.lock_outline),
|
||||
),
|
||||
onSubmitted: (_) => _handlePasswordLogin(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _handlePasswordLogin,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
child: const Text("로그인"),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const ForgotPasswordScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text("비밀번호를 잊으셨나요?"),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 2. 로그인 링크 전송 폼
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _linkIdController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: "이메일 또는 휴대폰 번호",
|
||||
hintText: "",
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
),
|
||||
onSubmitted: (_) => _handleLogin(),
|
||||
onSubmitted: (_) => _handleLinkLogin(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _handleLogin,
|
||||
onPressed: _handleLinkLogin,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
@@ -560,7 +612,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
),
|
||||
),
|
||||
|
||||
// QR Login View
|
||||
// 3. QR 로그인 뷰
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
|
||||
class ResetPasswordScreen extends StatefulWidget {
|
||||
final String? loginId; // Now receiving loginId
|
||||
const ResetPasswordScreen({super.key, this.loginId});
|
||||
|
||||
@override
|
||||
State<ResetPasswordScreen> createState() => _ResetPasswordScreenState();
|
||||
}
|
||||
|
||||
class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
final TextEditingController _confirmPasswordController = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isLoading = false;
|
||||
String? _loginId;
|
||||
String? _token;
|
||||
bool _isPasswordObscured = true;
|
||||
bool _isConfirmPasswordObscured = true;
|
||||
Map<String, dynamic>? _policy;
|
||||
bool _isPolicyLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 1. Get loginId from GoRouter state if available
|
||||
_loginId = widget.loginId;
|
||||
|
||||
// 2. Fallback to URI query parameter if not available via router
|
||||
if (_loginId == null || _loginId!.isEmpty) {
|
||||
final uri = Uri.base;
|
||||
_loginId = uri.queryParameters['loginId'];
|
||||
}
|
||||
|
||||
// 토큰도 함께 읽어놓는다.
|
||||
final uri = Uri.base;
|
||||
_token = uri.queryParameters['token'];
|
||||
|
||||
_loadPolicy();
|
||||
}
|
||||
|
||||
Future<void> _loadPolicy() async {
|
||||
setState(() {
|
||||
_isPolicyLoading = true;
|
||||
});
|
||||
try {
|
||||
final policy = await AuthProxyService.fetchPasswordPolicy();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_policy = policy;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// 실패해도 기본 검증 로직 사용
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isPolicyLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePasswordReset() async {
|
||||
if (_formKey.currentState?.validate() != true) return;
|
||||
if ((_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)) {
|
||||
_showError("유효하지 않은 재설정 링크입니다. (loginId/token 누락)");
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
await AuthProxyService.completePasswordReset(
|
||||
loginId: _loginId,
|
||||
token: _token,
|
||||
newPassword: _passwordController.text,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요."),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
context.go('/login');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showError("비밀번호 변경에 실패했습니다: ${e.toString()}");
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
|
||||
String _buildPolicyDescription() {
|
||||
if (_isPolicyLoading) {
|
||||
return "비밀번호 정책을 불러오는 중입니다...";
|
||||
}
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 8;
|
||||
final requiresLower = _policy?['lowercase'] ?? true;
|
||||
final requiresUpper = _policy?['uppercase'] ?? true;
|
||||
final requiresNumber = _policy?['number'] ?? true;
|
||||
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
|
||||
|
||||
final parts = <String>["최소 ${minLength}자 이상"];
|
||||
if (requiresLower) parts.add("소문자 1개 이상");
|
||||
if (requiresUpper) parts.add("대문자 1개 이상");
|
||||
if (requiresNumber) parts.add("숫자 1개 이상");
|
||||
if (requiresSymbol) parts.add("특수문자 1개 이상");
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("새 비밀번호 설정"),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: (_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)
|
||||
? _buildInvalidTokenView()
|
||||
: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
"새로운 비밀번호 설정",
|
||||
style: GoogleFonts.outfit(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_buildPolicyDescription(),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _isPasswordObscured,
|
||||
decoration: InputDecoration(
|
||||
labelText: "새 비밀번호",
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_isPasswordObscured ? Icons.visibility_off : Icons.visibility,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isPasswordObscured = !_isPasswordObscured;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
final val = value ?? "";
|
||||
if (val.isEmpty) {
|
||||
return '비밀번호를 입력해주세요.';
|
||||
}
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 8;
|
||||
if (val.length < minLength) {
|
||||
return '비밀번호는 최소 $minLength자 이상이어야 합니다.';
|
||||
}
|
||||
if ((_policy?['lowercase'] ?? true) && !RegExp(r'(?=.*[a-z])').hasMatch(val)) {
|
||||
return '최소 1개 이상의 소문자를 포함해야 합니다.';
|
||||
}
|
||||
if ((_policy?['uppercase'] ?? true) && !RegExp(r'(?=.*[A-Z])').hasMatch(val)) {
|
||||
return '최소 1개 이상의 대문자를 포함해야 합니다.';
|
||||
}
|
||||
if ((_policy?['number'] ?? true) && !RegExp(r'(?=.*\d)').hasMatch(val)) {
|
||||
return '최소 1개 이상의 숫자를 포함해야 합니다.';
|
||||
}
|
||||
if ((_policy?['nonAlphanumeric'] ?? true) && !RegExp(r'(?=.*[\W_])').hasMatch(val)) {
|
||||
return '최소 1개 이상의 특수문자를 포함해야 합니다.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _confirmPasswordController,
|
||||
obscureText: _isConfirmPasswordObscured,
|
||||
decoration: InputDecoration(
|
||||
labelText: "새 비밀번호 확인",
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_isConfirmPasswordObscured ? Icons.visibility_off : Icons.visibility,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isConfirmPasswordObscured = !_isConfirmPasswordObscured;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value != _passwordController.text) {
|
||||
return '비밀번호가 일치하지 않습니다.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _isLoading ? null : _handlePasswordReset,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
)
|
||||
: const Text("비밀번호 변경"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInvalidTokenView() {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: Colors.red, size: 60),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
"유효하지 않은 링크입니다.",
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
"비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요.",
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import 'features/auth/presentation/login_screen.dart';
|
||||
import 'features/auth/presentation/signup_screen.dart';
|
||||
import 'features/auth/presentation/approve_qr_screen.dart';
|
||||
import 'features/auth/presentation/qr_scan_screen.dart';
|
||||
import 'features/auth/presentation/forgot_password_screen.dart';
|
||||
import 'features/auth/presentation/reset_password_screen.dart';
|
||||
import 'features/dashboard/presentation/dashboard_screen.dart';
|
||||
import 'features/admin/presentation/user_management_screen.dart';
|
||||
import 'features/profile/presentation/pages/profile_page.dart';
|
||||
@@ -111,6 +113,23 @@ final _router = GoRouter(
|
||||
return LoginScreen(verificationToken: token);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/forgot-password',
|
||||
builder: (context, state) {
|
||||
_routerLogger.info("Navigating to /forgot-password");
|
||||
return const ForgotPasswordScreen();
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
// Supports both /reset-password and /reset-password?token=...
|
||||
path: '/reset-password',
|
||||
builder: (context, state) {
|
||||
// For deep linking, you might pass the token in the path, e.g., /reset-password/:token
|
||||
// final token = state.pathParameters['token'];
|
||||
_routerLogger.info("Navigating to /reset-password");
|
||||
return const ResetPasswordScreen();
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/approve',
|
||||
builder: (context, state) {
|
||||
@@ -143,26 +162,29 @@ final _router = GoRouter(
|
||||
final isPublicPath = path == '/login' ||
|
||||
path == '/signup' ||
|
||||
path.startsWith('/verify/') ||
|
||||
path == '/approve';
|
||||
path == '/approve' ||
|
||||
path == '/forgot-password' ||
|
||||
path == '/reset-password';
|
||||
|
||||
_routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn");
|
||||
|
||||
// 0. ALWAYS allow /verify/ to proceed so it can signal the backend
|
||||
if (path.startsWith('/verify/')) {
|
||||
// 0. ALWAYS allow public paths to proceed so they can function
|
||||
if (isPublicPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If not logged in and trying to access a protected page, redirect to /login
|
||||
if (!isLoggedIn && !isPublicPath) {
|
||||
if (!isLoggedIn) {
|
||||
_routerLogger.info("Not logged in, redirecting to /login");
|
||||
return '/login';
|
||||
}
|
||||
|
||||
// If logged in and trying to access login page, redirect to root (dashboard)
|
||||
if (isLoggedIn && path == '/login') {
|
||||
_routerLogger.info("Logged in, redirecting to /");
|
||||
return '/';
|
||||
}
|
||||
// This is now implicitly handled by the isPublicPath check, but kept for clarity.
|
||||
// if (isLoggedIn && path == '/login') {
|
||||
// _routerLogger.info("Logged in, redirecting to /");
|
||||
// return '/';
|
||||
// }
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user