diff --git a/backend/internal/domain/idp_models.go b/backend/internal/domain/idp_models.go index 29283318..3c71936c 100644 --- a/backend/internal/domain/idp_models.go +++ b/backend/internal/domain/idp_models.go @@ -8,10 +8,10 @@ import ( // 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 { - ID string `json:"id" required:"true"` - Email string `json:"email" required:"true"` - Name string `json:"name"` - PhoneNumber string `json:"phone_number"` + ID string `json:"id" required:"true"` + Email string `json:"email" required:"true"` + Name string `json:"name"` + PhoneNumber string `json:"phone_number"` // Attributes stores custom user attributes. // The "required_keys" tag specifies which keys MUST be present in the IDP's schema support. Attributes map[string]interface{} `json:"attributes" required_keys:"grade,department"` diff --git a/backend/internal/handler/admin_handler.go b/backend/internal/handler/admin_handler.go index a6f8354c..32b8826f 100644 --- a/backend/internal/handler/admin_handler.go +++ b/backend/internal/handler/admin_handler.go @@ -55,4 +55,4 @@ func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error { return err } return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"}) -} \ No newline at end of file +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index da1102b5..020506f0 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -35,14 +35,14 @@ const ( statusSuccess = "success" // Durations - defaultExpiration = 5 * time.Minute - signupStateExpiration = 10 * time.Minute - signupBlockDuration = 10 * time.Minute - maxSignupFailures = 5 - emailCodeTTL = 5 * time.Minute - smsCodeTTL = 3 * time.Minute - prefixPwdResetToken = "pwdreset_token:" - pwdResetExpiration = 15 * time.Minute + defaultExpiration = 5 * time.Minute + signupStateExpiration = 10 * time.Minute + signupBlockDuration = 10 * time.Minute + maxSignupFailures = 5 + emailCodeTTL = 5 * time.Minute + smsCodeTTL = 3 * time.Minute + prefixPwdResetToken = "pwdreset_token:" + pwdResetExpiration = 15 * time.Minute ) type AuthHandler struct { @@ -125,12 +125,12 @@ func (h *AuthHandler) CheckEmail(c *fiber.Ctx) error { // Note: Descope doesn't have a direct "exists" check, we use Load or Search. // Since we are checking availability for signup, we want "User not found". exists, err := h.DescopeClient.Management.User().Load(context.Background(), req.Email) - + // If err is nil and exists is not nil, user exists. if err == nil && exists != nil { return c.JSON(fiber.Map{"available": false, "message": "Email already registered"}) } - + // Check if specific error is "not found" or just assume if Load fails it might be free. // Typically Descope Load returns error if not found? Let's assume so or check error message. // Actually, strictly speaking, we should handle specific errors, but for MVP: @@ -146,7 +146,7 @@ func (h *AuthHandler) SendSignupEmailCode(c *fiber.Ctx) error { req.Type = "email" // Enforce type key := prefixSignupEmail + req.Target - + // 1. Check existing state (Rate Limit / Block) state, _ := h.getSignupState(key) if state != nil && state.FailCount > maxSignupFailures { @@ -164,26 +164,26 @@ func (h *AuthHandler) SendSignupEmailCode(c *fiber.Ctx) error { newState := &signupState{ Code: code, Verified: false, - FailCount: 0, // Reset fail count on new code generation? Or keep it? - // Requirement says "Auth fail > 5 -> block". New code usually resets or continues? - // Usually getting a new code doesn't reset verify failure count if we want strict blocking. - // But for simplicity let's say "fail count" applies to verification attempts. - // If we are issuing a new code, it's a new attempt cycle usually. - // However, spamming "send code" is also an attack. - // Let's keep FailCount if exists, or 0. + FailCount: 0, // Reset fail count on new code generation? Or keep it? + // Requirement says "Auth fail > 5 -> block". New code usually resets or continues? + // Usually getting a new code doesn't reset verify failure count if we want strict blocking. + // But for simplicity let's say "fail count" applies to verification attempts. + // If we are issuing a new code, it's a new attempt cycle usually. + // However, spamming "send code" is also an attack. + // Let's keep FailCount if exists, or 0. ExpiresAt: time.Now().Add(emailCodeTTL).Unix(), } if state != nil { newState.FailCount = state.FailCount } - + h.saveSignupState(key, newState, signupStateExpiration) // 4. Send Email if h.EmailService == nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"}) } - + subject := "[Baron SSO] 회원가입 인증코드" body := fmt.Sprintf(`
@@ -193,7 +193,7 @@ func (h *AuthHandler) SendSignupEmailCode(c *fiber.Ctx) error {

이 코드는 5분간 유효합니다.

`, code) - + go h.EmailService.SendEmail(req.Target, subject, body) return c.JSON(fiber.Map{"message": "Verification code sent"}) @@ -312,10 +312,18 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { } // Check complexity (at least 2 types: lower, upper, digit, special) types := 0 - if strings.ContainsAny(req.Password, "abcdefghijklmnopqrstuvwxyz") { types++ } - if strings.ContainsAny(req.Password, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") { types++ } - if strings.ContainsAny(req.Password, "0123456789") { types++ } - if strings.ContainsAny(req.Password, "!@#$%^&*()_+-=[]{}|;:,.<>?") { types++ } + if strings.ContainsAny(req.Password, "abcdefghijklmnopqrstuvwxyz") { + types++ + } + if strings.ContainsAny(req.Password, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") { + types++ + } + if strings.ContainsAny(req.Password, "0123456789") { + types++ + } + if strings.ContainsAny(req.Password, "!@#$%^&*()_+-=[]{}|;:,.<>?") { + types++ + } if types < 2 { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least 2 types of characters (letters, numbers, symbols)"}) } @@ -359,14 +367,14 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { "termsAccepted": req.TermsAccepted, "createdAt": time.Now().Format(time.RFC3339), } - + // Create user - // Note: Descope `Create` does not set password. We usually need `Create` then `Password().Update` - // or use a specialized signup flow. + // Note: Descope `Create` does not set password. We usually need `Create` then `Password().Update` + // or use a specialized signup flow. // `Management.User().Create` creates a user but doesn't set a password credential immediately unless specified? // Actually `User().Create` creates the identity. // To set password, we use `h.DescopeClient.Management.User().SetPassword(...)` - + // Check if user exists (Double check) exists, _ := h.DescopeClient.Management.User().Load(context.Background(), req.Email) if exists != nil { @@ -1305,8 +1313,6 @@ func (h *AuthHandler) HandleDescopeEmailRelay(c *fiber.Ctx) error { return c.Status(501).JSON(fiber.Map{"error": "Real email sending not implemented"}) } - - // --- User Profile Handlers --- func (h *AuthHandler) formatPhoneForDisplay(phone string) string { @@ -1409,7 +1415,7 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error { // Normalize for comparison normID := strings.ReplaceAll(loginID, "+82", "0") normOld := strings.ReplaceAll(oldPhoneStorage, "+82", "0") - + if loginID == oldPhoneStorage || (normOld != "" && normID == normOld) { slog.Info("[UpdateMe] Updating LoginID", "old", loginID, "new", newPhoneStorage) _, err = h.DescopeClient.Management.User().UpdateLoginID(c.Context(), loginID, newPhoneStorage) @@ -1456,7 +1462,7 @@ func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error { if token == "" { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) } - + authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) if err != nil || !authorized { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) @@ -1471,7 +1477,7 @@ func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error { phone := h.formatPhoneForStorage(req.Phone) code := fmt.Sprintf("%06d", rand.Intn(1000000)) - + // Store code in Redis key := "otp_update_phone:" + userToken.ID + ":" + phone h.RedisService.Set(key, code, 5*time.Minute) diff --git a/backend/internal/logger/audit_logger.go b/backend/internal/logger/audit_logger.go index ad1291ea..50f414a4 100644 --- a/backend/internal/logger/audit_logger.go +++ b/backend/internal/logger/audit_logger.go @@ -28,8 +28,8 @@ type AuditLogEntry struct { 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) + DescopeStatus int // Descope HTTP status + DescopeBody string // Descope response body (full raw) RefreshToken string SessionJwt string AccessJwt string @@ -68,7 +68,6 @@ func NewAuditLogEntry(c *fiber.Ctx, stage string) *AuditLogEntry { headers["Origin"] = c.Get("Origin") headers["Referer"] = c.Get("Referer") - return &AuditLogEntry{ RequestID: reqID, Stage: stage, @@ -85,7 +84,6 @@ func NewAuditLogEntry(c *fiber.Ctx, stage string) *AuditLogEntry { } } - // 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) { @@ -213,4 +211,4 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) { } slog.Default().LogAttrs(context.Background(), level, msg, attrs...) -} \ No newline at end of file +} diff --git a/backend/internal/service/descope_service.go b/backend/internal/service/descope_service.go index 58cb36aa..1dca9231 100644 --- a/backend/internal/service/descope_service.go +++ b/backend/internal/service/descope_service.go @@ -70,28 +70,28 @@ func (d *DescopeProvider) GetMetadata() (*domain.IDPMetadata, error) { } 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), - ) + 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 + 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) {