forked from baron/baron-sso
Refactor password reset flow
This commit is contained in:
@@ -7,7 +7,6 @@ import (
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -15,6 +14,7 @@ import (
|
||||
"math/rand"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -42,7 +42,7 @@ const (
|
||||
emailCodeTTL = 5 * time.Minute
|
||||
smsCodeTTL = 3 * time.Minute
|
||||
prefixPwdResetToken = "pwdreset_token:"
|
||||
pwdResetExpiration = 15 * time.Minute
|
||||
pwdResetExpiration = 15 * time.Minute
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
@@ -420,6 +420,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
|
||||
@@ -683,7 +703,6 @@ 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()
|
||||
@@ -748,7 +767,6 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
|
||||
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)
|
||||
@@ -824,22 +842,69 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
|
||||
redirectURL := fmt.Sprintf("%s/api/v1/auth/password/reset/verify", frontendURL)
|
||||
ale.RedirectTo = redirectURL
|
||||
|
||||
ale.Operation = "SendPasswordReset"
|
||||
ale.Log(slog.LevelInfo, "Initiating password reset via Descope")
|
||||
// 내부 토큰 발급 + 우리 채널로 전송
|
||||
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"})
|
||||
}
|
||||
|
||||
err := h.IdpProvider.InitiatePasswordReset(loginID, redirectURL)
|
||||
if err != nil {
|
||||
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 initiate password reset via Descope")
|
||||
return c.JSON(fiber.Map{"message": "If an account with that login ID exists, a reset link has been sent."})
|
||||
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")
|
||||
return c.JSON(fiber.Map{"message": "Password reset link sent successfully."})
|
||||
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.
|
||||
@@ -893,10 +958,9 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error {
|
||||
ale := logger.NewAuditLogEntry(c, "verify")
|
||||
ale.Operation = "Verify"
|
||||
|
||||
// Token comes from Form Body in POST
|
||||
// Token comes from Form Body in POST or query
|
||||
token := c.FormValue("token")
|
||||
if token == "" {
|
||||
// Fallback to query param or body json if needed, but form is primary
|
||||
token = c.Query("token")
|
||||
if token == "" {
|
||||
token = c.Query("t")
|
||||
@@ -912,93 +976,29 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).SendString("Missing token")
|
||||
}
|
||||
|
||||
ale.Log(slog.LevelInfo, "Attempting to verify token via POST")
|
||||
|
||||
authInfo, err := h.IdpProvider.VerifyPasswordResetToken(token)
|
||||
if err != nil {
|
||||
loginID, err := h.RedisService.Get(prefixPwdResetToken + token)
|
||||
if err != nil || loginID == "" {
|
||||
ale.Status = fiber.StatusUnauthorized
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = err.Error()
|
||||
ale.Log(slog.LevelError, "Failed to verify token with Descope")
|
||||
|
||||
// Redirect to login with error
|
||||
return c.Status(fiber.StatusUnauthorized).Redirect(h.IdpProvider.(*service.DescopeProvider).FrontendURL + "/login?error=invalid_token")
|
||||
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")
|
||||
}
|
||||
|
||||
if authInfo.RefreshToken == nil || authInfo.RefreshToken.JWT == "" {
|
||||
ale.Status = fiber.StatusInternalServerError
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.DescopeError = "Descope did not return a refresh token"
|
||||
ale.Log(slog.LevelError, "Descope did not return a refresh token")
|
||||
return c.Status(fiber.StatusInternalServerError).Redirect(h.IdpProvider.(*service.DescopeProvider).FrontendURL + "/login?error=no_refresh_token")
|
||||
}
|
||||
ale.LoginIDs["loginId"] = loginID
|
||||
ale.LoginIDs["loginId_normalized"] = loginID
|
||||
|
||||
// Populate authInfo related fields
|
||||
ale.RefreshToken = authInfo.RefreshToken.JWT
|
||||
if authInfo.SessionToken != nil {
|
||||
ale.SessionJwt = authInfo.SessionToken.JWT
|
||||
}
|
||||
|
||||
// Set Refresh Token Cookie
|
||||
cookie := &fiber.Cookie{
|
||||
Name: "DSRF",
|
||||
Value: authInfo.RefreshToken.JWT,
|
||||
Expires: authInfo.RefreshToken.Expiration,
|
||||
HTTPOnly: true,
|
||||
Secure: true,
|
||||
SameSite: "Lax",
|
||||
}
|
||||
c.Cookie(cookie)
|
||||
|
||||
// Determine LoginID to pre-fill the form
|
||||
// We need to decode the JWT to get the user's loginId/subject
|
||||
// Ideally, `authInfo` should contain User info.
|
||||
// Descope `MagicLink().Verify` returns `AuthenticationInfo` which has `User`.
|
||||
// Our `IdpProvider` interface returns `*domain.AuthInfo`. We might need to extend it.
|
||||
// For now, we redirect to /reset-password. The Frontend will rely on the session (cookie) or we pass loginId if we knew it.
|
||||
// Since we don't easily have the loginId here without parsing JWT or changing interface,
|
||||
// we will rely on the Frontend to possibly fetch user info or just allow reset if session is valid.
|
||||
// *Correction*: The Frontend `ResetPasswordScreen` expects `loginId` param.
|
||||
// If we don't pass it, the screen shows "Invalid Link".
|
||||
// We MUST extract the loginId from the verified session.
|
||||
|
||||
// Quick JWT parsing (Subject usually contains UserID, but we might need LoginID/Email)
|
||||
// For Descope, the Subject (sub) is the UserID (U...). LoginID is usually in custom claims or we need to fetch user.
|
||||
// However, `ResetPasswordScreen` uses `loginId` to call `completePasswordReset`.
|
||||
// `completePasswordReset` calls `User().SetPassword(loginId...)`.
|
||||
// In Descope Management API, `loginId` is required.
|
||||
|
||||
// Let's parse the JWT to get the LoginID (email/phone) if possible, or UserID.
|
||||
// Descope JWTs usually have `email` claim if it's an email user.
|
||||
// We'll do a best-effort extraction or rely on the UserID.
|
||||
|
||||
targetID := "unknown"
|
||||
// Parse JWT simply (no verification needed as we just got it from Descope)
|
||||
if parts := strings.Split(authInfo.SessionToken.JWT, "."); len(parts) == 3 {
|
||||
payload, _ := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
var claims map[string]interface{}
|
||||
json.Unmarshal(payload, &claims)
|
||||
if sub, ok := claims["sub"].(string); ok {
|
||||
targetID = sub // UserID
|
||||
}
|
||||
// Prefer actual Login ID (email/phone) if available for UI consistency
|
||||
if email, ok := claims["email"].(string); ok && email != "" {
|
||||
targetID = email
|
||||
} else if phone, ok := claims["phone"].(string); ok && phone != "" {
|
||||
targetID = phone
|
||||
}
|
||||
}
|
||||
|
||||
redirectURL := fmt.Sprintf("%s/reset-password?loginId=%s",
|
||||
h.IdpProvider.(*service.DescopeProvider).FrontendURL,
|
||||
targetID,
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1020,10 +1020,19 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
loginID := c.Query("loginId") // loginID는 URL 쿼리 파라미터로 받습니다.
|
||||
// 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)
|
||||
ale.NewPassword = req.NewPassword // Log new password (for test only)
|
||||
|
||||
// Request cookie logging (minimal)
|
||||
if cookieHeader := c.Get(fiber.HeaderCookie); cookieHeader != "" {
|
||||
@@ -1095,7 +1104,16 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
|
||||
}
|
||||
|
||||
if err := h.DescopeClient.Management.User().SetPassword(context.Background(), loginID, req.NewPassword); err != nil {
|
||||
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()
|
||||
@@ -1105,11 +1123,13 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
|
||||
|
||||
ale.Status = fiber.StatusOK
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.Log(slog.LevelInfo, "Password updated successfully")
|
||||
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)
|
||||
@@ -1272,4 +1292,3 @@ func (h *AuthHandler) HandleDescopeEmailRelay(c *fiber.Ctx) error {
|
||||
slog.Warn("[Email Webhook] Real email skipped (Not implemented)", "to", req.To)
|
||||
return c.Status(501).JSON(fiber.Map{"error": "Real email sending not implemented"})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user