forked from baron/baron-sso
Merge remote-tracking branch 'origin/main' into dev/mypage
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user