diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 4434aad2..a75b8336 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -410,6 +410,7 @@ func main() { auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink) auth.Post("/magic-link/verify", authHandler.VerifyMagicLink) auth.Post("/login/code/verify", authHandler.VerifyLoginCode) + auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode) auth.Post("/password/login", authHandler.PasswordLogin) auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset) // [Changed] Use Interstitial Page for GET to prevent Scanner consumption diff --git a/backend/internal/domain/idp_models.go b/backend/internal/domain/idp_models.go index 47b4db8e..e043977f 100644 --- a/backend/internal/domain/idp_models.go +++ b/backend/internal/domain/idp_models.go @@ -58,6 +58,8 @@ type LinkLoginInit struct { ExpiresAt time.Time // Mode는 링크 로그인 완료 후 세션 처리 방식입니다. (예: "cookie") Mode string + // LoginID는 IDP에 실제 전달된 식별자입니다. + LoginID string } // IdentityProvider is the interface that all IDP adapters must implement. diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 745f263b..37825b27 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -31,6 +31,10 @@ const ( prefixSession = "enchanted_session:" prefixToken = "enchanted_token:" prefixLoginCode = "login_code_flow:" + prefixLoginCodePending = "login_code_pending:" + prefixLoginCodeSmsTarget = "login_code_sms_target:" + prefixLoginCodeSmsLookup = "login_code_sms_lookup:" + prefixLoginCodeShort = "login_code_short:" prefixPollMeta = "poll_meta:" prefixSignupEmail = "signup:email:" prefixSignupPhone = "signup:phone:" @@ -202,7 +206,7 @@ func (h *AuthHandler) SendSignupEmailCode(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"}) } - subject := "[Baron SSO] 회원가입 인증코드" + subject := "[Baron 통합로그인] 회원가입 인증코드" body := fmt.Sprintf(`

이메일 인증

@@ -252,7 +256,7 @@ func (h *AuthHandler) SendSignupSmsCode(c *fiber.Ctx) error { h.saveSignupState(key, newState, signupStateExpiration) // 4. Send SMS - content := fmt.Sprintf("[Baron SSO] 인증번호 [%s]를 입력해주세요.", code) + content := fmt.Sprintf("[Baron 통합로그인] 인증번호 [%s]를 입력해주세요.", code) go h.SmsService.SendSms(phone, content) return c.JSON(fiber.Map{"message": "Verification code sent"}) @@ -535,7 +539,7 @@ func (h *AuthHandler) SendSms(c *fiber.Ctx) error { sanitizedPhone := strings.ReplaceAll(req.PhoneNumber, "-", "") rand.Seed(time.Now().UnixNano()) code := fmt.Sprintf("%06d", rand.Intn(1000000)) - content := fmt.Sprintf("[Baron SSO] 인증번호: %s", code) + content := fmt.Sprintf("[Baron 통합로그인] 인증번호: %s", code) h.RedisService.StoreVerificationCode(sanitizedPhone, code) if err := h.SmsService.SendSms(sanitizedPhone, content); err != nil { @@ -621,8 +625,19 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { } if init, err := h.IdpProvider.InitiateLinkLogin(lookupLoginID, userfrontURL); err == nil && init != nil && init.Mode != "" { + keyLoginID := lookupLoginID + if init.LoginID != "" { + keyLoginID = init.LoginID + } if init.FlowID != "" { - _ = h.RedisService.Set(prefixLoginCode+lookupLoginID, init.FlowID, loginCodeExpiration) + _ = h.RedisService.Set(prefixLoginCode+keyLoginID, init.FlowID, loginCodeExpiration) + } + pendingRef := GenerateSecureToken(3) + h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), loginCodeExpiration) + _ = h.RedisService.Set(prefixLoginCodePending+keyLoginID, pendingRef, loginCodeExpiration) + if !strings.Contains(loginID, "@") && keyLoginID != lookupLoginID { + _ = h.RedisService.Set(prefixLoginCodeSmsTarget+keyLoginID, lookupLoginID, loginCodeExpiration) + _ = h.RedisService.Set(prefixLoginCodeSmsLookup+lookupLoginID, keyLoginID, loginCodeExpiration) } expiresIn := 0 if !init.ExpiresAt.IsZero() { @@ -630,7 +645,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { } return c.JSON(fiber.Map{ "linkId": "Sent", - "pendingRef": init.FlowID, + "pendingRef": pendingRef, "maskedEmail": loginID, "mode": init.Mode, "provider": h.IdpProvider.Name(), @@ -665,7 +680,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"}) } - subject := "[Baron SSO] 로그인 링크" + subject := "[Baron 통합로그인] 링크" body := fmt.Sprintf(`

Baron SSO 로그인

@@ -686,7 +701,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { } } else { // Send SMS - content := fmt.Sprintf("[Baron SSO] 로그인 링크: %s | 코드: %s", link, userCode) + content := fmt.Sprintf("[Baron 통합로그인] 로그인 링크: %s | 코드: %s", link, userCode) slog.Info("[Enchanted] Sending SMS via Naver Cloud", "loginID", loginID) if err := h.SmsService.SendSms(loginID, content); err != nil { @@ -714,7 +729,7 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { pollKey := prefixPollMeta + "enchanted:" + req.PendingRef if slowDown, interval := checkPollInterval(h.RedisService, pollKey, minPollInterval); slowDown { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + return c.JSON(fiber.Map{ "error": "slow_down", "interval": interval, }) @@ -722,7 +737,7 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { val, err := h.RedisService.Get(prefixSession + req.PendingRef) if err != nil || val == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "expired_token"}) + return c.JSON(fiber.Map{"error": "expired_token"}) } var data map[string]string @@ -736,7 +751,7 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { }) } - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + return c.JSON(fiber.Map{ "error": "authorization_pending", "interval": int(minPollInterval.Seconds()), }) @@ -802,8 +817,9 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { // VerifyLoginCode - Verify Kratos login code and issue session. func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { var req struct { - LoginID string `json:"loginId"` - Code string `json:"code"` + LoginID string `json:"loginId"` + Code string `json:"code"` + PendingRef string `json:"pendingRef"` } if err := c.BodyParser(&req); err != nil { slog.Error("[LoginCode] Body parse error", "error", err) @@ -843,6 +859,110 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { } h.RedisService.Delete(prefixLoginCode + lookupLoginID) + h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID) + + pendingRef := strings.TrimSpace(req.PendingRef) + if pendingRef == "" { + storedRef, _ := h.RedisService.Get(prefixLoginCodePending + lookupLoginID) + pendingRef = storedRef + } + if pendingRef != "" { + sessionData, _ := json.Marshal(map[string]string{ + "status": statusSuccess, + "jwt": authInfo.SessionToken.JWT, + }) + h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration) + h.RedisService.Delete(prefixLoginCodePending + lookupLoginID) + h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID) + return c.JSON(fiber.Map{ + "status": "approved", + "pendingRef": pendingRef, + "provider": h.IdpProvider.Name(), + "subject": authInfo.Subject, + "message": "Login approved", + }) + } + + return c.JSON(fiber.Map{ + "token": authInfo.SessionToken.JWT, + "sessionJwt": authInfo.SessionToken.JWT, + "provider": h.IdpProvider.Name(), + "subject": authInfo.Subject, + "message": "Login successful", + }) +} + +// VerifyLoginShortCode - Verify short code (2 letters + 6 digits) and issue/approve session. +func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error { + var req struct { + ShortCode string `json:"shortCode"` + } + if err := c.BodyParser(&req); err != nil { + slog.Error("[LoginShortCode] Body parse error", "error", err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + + shortCode := strings.ToUpper(strings.TrimSpace(req.ShortCode)) + if shortCode == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "shortCode is required"}) + } + + val, _ := h.RedisService.Get(prefixLoginCodeShort + shortCode) + if val == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired code"}) + } + + var payload shortLoginCodePayload + if err := json.Unmarshal([]byte(val), &payload); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Invalid code payload"}) + } + if payload.LoginID == "" || payload.Code == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired code"}) + } + + if h.IdpProvider == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) + } + + flowID, err := h.RedisService.Get(prefixLoginCode + payload.LoginID) + if err != nil || flowID == "" { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Login flow expired"}) + } + + authInfo, err := h.IdpProvider.VerifyLoginCode(payload.LoginID, flowID, payload.Code) + if err != nil { + if errors.Is(err, domain.ErrNotSupported) { + return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"}) + } + slog.Error("[LoginShortCode] Verify failed", "loginID", payload.LoginID, "error", err) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid code"}) + } + if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) + } + + h.RedisService.Delete(prefixLoginCode + payload.LoginID) + h.RedisService.Delete(prefixLoginCodeShort + shortCode) + h.RedisService.Delete(prefixLoginCodeSmsTarget + payload.LoginID) + h.RedisService.Delete(prefixLoginCodeSmsLookup + payload.LoginID) + + if payload.PendingRef != "" { + sessionData, _ := json.Marshal(map[string]string{ + "status": statusSuccess, + "jwt": authInfo.SessionToken.JWT, + }) + h.RedisService.Set(prefixSession+payload.PendingRef, string(sessionData), loginCodeExpiration) + h.RedisService.Delete(prefixLoginCodePending + payload.LoginID) + return c.JSON(fiber.Map{ + "status": "approved", + "pendingRef": payload.PendingRef, + "token": authInfo.SessionToken.JWT, + "sessionJwt": authInfo.SessionToken.JWT, + "provider": h.IdpProvider.Name(), + "subject": authInfo.Subject, + "message": "Login approved", + }) + } return c.JSON(fiber.Map{ "token": authInfo.SessionToken.JWT, @@ -998,7 +1118,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { ale.Log(slog.LevelError, "Email service not configured") return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"}) } - subject := "[Baron SSO] 비밀번호 재설정" + subject := "[Baron 통합로그인] 비밀번호 재설정" body := fmt.Sprintf(`

Baron SSO 비밀번호 재설정

@@ -1017,7 +1137,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { 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 { + if err := h.SmsService.SendSms(loginID, fmt.Sprintf("[Baron 통합로그인] 비밀번호 재설정 링크: %s", resetLink)); err != nil { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.DescopeError = err.Error() @@ -1350,6 +1470,22 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Empty message"}) } + if strings.Contains(req.Recipient, "@") { + if target, _ := h.RedisService.Get(prefixLoginCodeSmsTarget + req.Recipient); target != "" { + phone := sanitizePhoneForSms(target) + smsBody := h.buildKratosShortSmsBody(&req, req.Recipient, phone) + if smsBody == "" { + smsBody = body + } + if err := h.SmsService.SendSms(phone, smsBody); err != nil { + slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) + } + slog.Info("[Kratos Courier] SMS sent (email relay)", "to", phone, "template", req.TemplateType) + return c.JSON(fiber.Map{"status": "ok"}) + } + } + if strings.Contains(req.Recipient, "@") { if h.EmailService == nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"}) @@ -1366,7 +1502,20 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "SMS service not configured"}) } phone := sanitizePhoneForSms(req.Recipient) - if err := h.SmsService.SendSms(phone, body); err != nil { + loginID := req.Recipient + if !strings.Contains(loginID, "@") { + lookup := normalizePhoneForLoginID(loginID) + if email, _ := h.RedisService.Get(prefixLoginCodeSmsLookup + lookup); email != "" { + loginID = email + } else { + loginID = lookup + } + } + smsBody := h.buildKratosShortSmsBody(&req, loginID, phone) + if smsBody == "" { + smsBody = body + } + if err := h.SmsService.SendSms(phone, smsBody); err != nil { slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) } @@ -1379,7 +1528,7 @@ func (h *AuthHandler) buildKratosCourierMessage(req *kratosCourierRequest) (stri body := strings.TrimSpace(req.Body) if body != "" || subject != "" { if subject == "" { - subject = "[Baron SSO] 알림" + subject = "[Baron 통합로그인] 알림" } return subject, body } @@ -1403,23 +1552,38 @@ func (h *AuthHandler) buildKratosCourierMessage(req *kratosCourierRequest) (stri if subject == "" { if label == "알림" { - subject = "[Baron SSO] 알림" + subject = "[Baron 통합로그인] 알림" } else { - subject = fmt.Sprintf("[Baron SSO] %s 코드", label) + subject = fmt.Sprintf("[Baron 통합로그인] %s 코드", label) } } if code == "" { - return subject, fmt.Sprintf("[Baron SSO] %s 요청이 도착했습니다", label) + return subject, fmt.Sprintf("[Baron 통합로그인] %s 요청이 도착했습니다", label) } - message := fmt.Sprintf("[Baron SSO] %s 코드: %s", label, code) + message := fmt.Sprintf("[Baron 통합로그인] %s 코드: %s", label, code) if label == "로그인" { baseURL := os.Getenv("USERFRONT_URL") if baseURL == "" { baseURL = "http://localhost:5000" } baseURL = strings.TrimRight(baseURL, "/") + loginID := req.Recipient + if !strings.Contains(loginID, "@") { + loginID = normalizePhoneForLoginID(loginID) + } + pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID) + if pendingRef != "" { + message = fmt.Sprintf("%s | 링크: %s/verify?loginId=%s&code=%s&pendingRef=%s", + message, + baseURL, + url.QueryEscape(req.Recipient), + url.QueryEscape(code), + url.QueryEscape(pendingRef), + ) + return subject, message + } link := fmt.Sprintf("%s/verify?loginId=%s&code=%s", baseURL, url.QueryEscape(req.Recipient), @@ -1431,6 +1595,78 @@ func (h *AuthHandler) buildKratosCourierMessage(req *kratosCourierRequest) (stri return subject, message } +type shortLoginCodePayload struct { + LoginID string `json:"loginId"` + Code string `json:"code"` + PendingRef string `json:"pendingRef"` +} + +func (h *AuthHandler) buildKratosShortSmsBody(req *kratosCourierRequest, loginID, phone string) string { + if req == nil || loginID == "" { + return "" + } + code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code")) + if code == "" { + return "" + } + shortCode := h.generateShortCode(code) + if shortCode == "" { + return "" + } + + pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID) + payload := shortLoginCodePayload{ + LoginID: loginID, + Code: code, + PendingRef: pendingRef, + } + raw, _ := json.Marshal(payload) + _ = h.RedisService.Set(prefixLoginCodeShort+shortCode, string(raw), loginCodeExpiration) + + baseURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/") + if baseURL == "" { + baseURL = "http://localhost:5000" + } + + link := fmt.Sprintf("%s/l/%s", baseURL, shortCode) + return fmt.Sprintf("[Baron 통합로그인] %s", link) +} + +func (h *AuthHandler) generateShortCode(code string) string { + const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + for i := 0; i < 10; i++ { + b := make([]byte, 2) + if _, err := crand.Read(b); err != nil { + break + } + prefix := string(letters[int(b[0])%len(letters)]) + string(letters[int(b[1])%len(letters)]) + shortCode := prefix + code + if val, _ := h.RedisService.Get(prefixLoginCodeShort + shortCode); val == "" { + return shortCode + } + } + return "" +} + +func normalizeLoginCode(code string) string { + if code == "" { + return "" + } + digits := make([]rune, 0, len(code)) + for _, ch := range code { + if ch >= '0' && ch <= '9' { + digits = append(digits, ch) + } + } + if len(digits) < 6 { + return "" + } + if len(digits) > 6 { + digits = digits[:6] + } + return string(digits) +} + func firstNonEmpty(values ...string) string { for _, value := range values { if value != "" { @@ -1941,7 +2177,7 @@ func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error { h.RedisService.Set(key, code, 5*time.Minute) // Send SMS - content := fmt.Sprintf("[Baron SSO] 정보 수정 인증번호: [%s]", code) + content := fmt.Sprintf("[Baron 통합로그인] 정보 수정 인증번호: [%s]", code) go h.SmsService.SendSms(phone, content) return c.JSON(fiber.Map{"message": "인증번호가 전송되었습니다."}) diff --git a/backend/internal/service/ory_service.go b/backend/internal/service/ory_service.go index 655455a2..9bc54e4f 100644 --- a/backend/internal/service/ory_service.go +++ b/backend/internal/service/ory_service.go @@ -232,22 +232,64 @@ func (o *OryProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkL return nil, fmt.Errorf("ory provider: loginID is required") } - init, err := o.submitLoginCodeInit(loginID, returnTo) + effectiveLoginID, err := o.resolveEffectiveLoginID(loginID) + if err != nil { + return nil, err + } + + if err := o.ensureCodeLoginIdentifier(effectiveLoginID); err != nil { + return nil, err + } + + init, err := o.submitLoginCodeInit(effectiveLoginID, returnTo) if err == nil { + init.LoginID = effectiveLoginID return init, nil } if shouldBootstrapCodeLogin(err) { - if ensureErr := o.ensureCodeLoginIdentifier(loginID); ensureErr == nil { - return o.submitLoginCodeInit(loginID, returnTo) + if ensureErr := o.ensureCodeLoginIdentifier(effectiveLoginID); ensureErr == nil { + init, initErr := o.submitLoginCodeInit(effectiveLoginID, returnTo) + if initErr == nil { + init.LoginID = effectiveLoginID + } + return init, initErr } else { - slog.Warn("Ory code login bootstrap failed", "loginID", loginID, "error", ensureErr) + slog.Warn("Ory code login bootstrap failed", "loginID", effectiveLoginID, "error", ensureErr) } } return nil, err } +func (o *OryProvider) resolveEffectiveLoginID(loginID string) (string, error) { + if strings.Contains(loginID, "@") { + return loginID, nil + } + + identityID, err := o.findIdentityID(loginID) + if err != nil { + return "", err + } + if identityID == "" { + return "", fmt.Errorf("ory provider: identity not found for loginID=%s", loginID) + } + + fullIdentity, err := o.fetchIdentityFull(identityID) + if err != nil { + return "", err + } + if fullIdentity != nil { + if emailRaw, ok := fullIdentity.Traits["email"]; ok { + if email, ok := emailRaw.(string); ok && email != "" { + return email, nil + } + } + } + + return "", fmt.Errorf("ory provider: email trait missing for loginID=%s", loginID) +} + func (o *OryProvider) submitLoginCodeInit(loginID, returnTo string) (*domain.LinkLoginInit, error) { flowID, err := o.startLoginFlow(returnTo) if err != nil { @@ -404,13 +446,83 @@ func (o *OryProvider) ensureCodeLoginIdentifier(loginID string) error { return nil } - return o.patchIdentity(identityID, ops) + if err := o.patchIdentity(identityID, ops); err != nil { + slog.Warn("Ory identity patch failed, trying full update", "identity_id", identityID, "error", err) + } + + fullIdentity, err := o.fetchIdentityFull(identityID) + if err != nil { + return err + } + + addresses = make([]kratosVerifiableAddress, 0, len(fullIdentity.VerifiableAddresses)+1) + found := false + for _, addr := range fullIdentity.VerifiableAddresses { + addresses = append(addresses, kratosVerifiableAddress{ + Value: addr.Value, + Via: addr.Via, + Verified: addr.Verified, + Status: addr.Status, + }) + if addr.Value == loginID && addr.Via == via { + found = true + } + } + if !found { + addresses = append(addresses, kratosVerifiableAddress{ + Value: loginID, + Via: via, + Verified: true, + Status: "completed", + }) + } + + payload := map[string]interface{}{ + "schema_id": fullIdentity.SchemaID, + "traits": fullIdentity.Traits, + "verifiable_addresses": addresses, + } + if len(fullIdentity.RecoveryAddresses) > 0 { + payload["recovery_addresses"] = fullIdentity.RecoveryAddresses + } + + body, _ := json.Marshal(payload) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("ory provider: build identity update failed: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := o.httpClient().Do(req) + if err != nil { + return fmt.Errorf("ory provider: identity update failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return fmt.Errorf("ory provider: identity update failed status=%d body=%s", resp.StatusCode, string(respBody)) + } + + slog.Info("Ory identity updated with verifiable address", "identity_id", identityID, "loginID", loginID, "via", via) + return nil } type kratosIdentity struct { VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"` } +type kratosRecoveryAddress struct { + Value string `json:"value"` + Via string `json:"via"` +} + +type kratosIdentityFull struct { + SchemaID string `json:"schema_id"` + Traits map[string]interface{} `json:"traits"` + VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"` + RecoveryAddresses []kratosRecoveryAddress `json:"recovery_addresses"` +} + func (o *OryProvider) patchIdentity(identityID string, ops []map[string]interface{}) error { body, _ := json.Marshal(ops) req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body)) @@ -457,6 +569,30 @@ func (o *OryProvider) fetchIdentity(identityID string) (*kratosIdentity, error) return &identity, nil } +func (o *OryProvider) fetchIdentityFull(identityID string) (*kratosIdentityFull, error) { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), nil) + if err != nil { + return nil, fmt.Errorf("ory provider: build identity get failed: %w", err) + } + + resp, err := o.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("ory provider: identity get failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("ory provider: identity get failed status=%d body=%s", resp.StatusCode, string(body)) + } + + var identity kratosIdentityFull + if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil { + return nil, fmt.Errorf("ory provider: decode identity failed: %w", err) + } + return &identity, nil +} + // VerifyLoginCode는 Kratos 로그인 코드 제출로 세션을 발급합니다. func (o *OryProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) { if loginID == "" || flowID == "" || code == "" { diff --git a/compose.infra.yaml b/compose.infra.yaml index f53bfe4e..2baa1405 100644 --- a/compose.infra.yaml +++ b/compose.infra.yaml @@ -27,6 +27,7 @@ services: clickhouse: image: clickhouse/clickhouse-server:latest container_name: baron_clickhouse + restart: always environment: CLICKHOUSE_USER: ${CLICKHOUSE_USER:-baron} CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-password} diff --git a/docker/ory/kratos/courier-templates/login_code/valid/sms.body.gotmpl b/docker/ory/kratos/courier-templates/login_code/valid/sms.body.gotmpl index f3f6fdf4..cacba938 100644 --- a/docker/ory/kratos/courier-templates/login_code/valid/sms.body.gotmpl +++ b/docker/ory/kratos/courier-templates/login_code/valid/sms.body.gotmpl @@ -1,4 +1,4 @@ -[Baron SSO] 로그인 링크 +[Baron 통합로그인] 로그인 링크 # 운영 링크: https://app.brsw.kr/verify?loginId={{ .To }}&code={{ .LoginCode }} http://localhost:5000/verify?loginId={{ .To }}&code={{ .LoginCode }} 코드: {{ .LoginCode }} diff --git a/test/test_sms.py b/test/test_sms.py index 3ce0c0bb..a8cc4520 100644 --- a/test/test_sms.py +++ b/test/test_sms.py @@ -61,7 +61,7 @@ def main(): "contentType": "COMM", "countryCode": "82", "from": sender_phone, - "content": "[Baron SSO] Test message from Python script.", + "content": "[Baron 통합로그인] Test message from Python script.", "messages": [ { "to": recipient_phone diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 0cc15c0e..43bd815d 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -104,15 +104,38 @@ class AuthProxyService { } } - static Future> verifyLoginCode(String loginId, String code) async { + static Future> verifyLoginCode(String loginId, String code, {String? pendingRef}) async { final url = Uri.parse('$_baseUrl/api/v1/auth/login/code/verify'); + final payload = { + 'loginId': loginId, + 'code': code, + }; + if (pendingRef != null && pendingRef.isNotEmpty) { + payload['pendingRef'] = pendingRef; + } + + final response = await http.post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(payload), + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + throw Exception('Verification failed: ${response.body}'); + } + } + + static Future> verifyLoginShortCode(String shortCode) async { + final url = Uri.parse('$_baseUrl/api/v1/auth/login/code/verify-short'); + final response = await http.post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode({ - 'loginId': loginId, - 'code': code, + 'shortCode': shortCode, }), ); diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 9850654f..dd433c49 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -39,6 +39,9 @@ class _LoginScreenState extends ConsumerState int _qrRemainingSeconds = 0; Timer? _qrCountdownTimer; int _qrPollIntervalMs = 2000; + final TextEditingController _shortCodePrefixController = TextEditingController(); + final TextEditingController _shortCodeDigitsController = TextEditingController(); + String? _linkPendingRef; @override void initState() { @@ -52,8 +55,13 @@ class _LoginScreenState extends ConsumerState final uri = Uri.base; final loginIdParam = uri.queryParameters['loginId']; final codeParam = uri.queryParameters['code']; + final pendingRefParam = uri.queryParameters['pendingRef']; + if (uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l') { + final shortCode = uri.pathSegments[1]; + _verifyShortCode(shortCode); + } if (loginIdParam != null && codeParam != null) { - _verifyLoginCode(loginIdParam, codeParam); + _verifyLoginCode(loginIdParam, codeParam, pendingRef: pendingRefParam); } else if (widget.verificationToken != null) { _verifyToken(widget.verificationToken!); } else if (uri.queryParameters.containsKey('t')) { @@ -101,6 +109,12 @@ class _LoginScreenState extends ConsumerState } } + void _resetLinkLoginState() { + _linkPendingRef = null; + _shortCodePrefixController.clear(); + _shortCodeDigitsController.clear(); + } + // Helper to decode JWT and get loginId String _getLoginIdFromJwt(String jwt) { try { @@ -306,14 +320,26 @@ class _LoginScreenState extends ConsumerState } } - Future _verifyLoginCode(String loginId, String code) async { + Future _verifyLoginCode(String loginId, String code, {String? pendingRef}) async { final sanitizedLoginId = loginId.replaceAll(' ', '+'); debugPrint("[Auth] Starting code verification for loginId: $sanitizedLoginId"); try { - final res = await AuthProxyService.verifyLoginCode(sanitizedLoginId, code); + final res = await AuthProxyService.verifyLoginCode( + sanitizedLoginId, + code, + pendingRef: pendingRef, + ); final jwt = res['sessionJwt'] ?? res['token']; + final status = res['status']?.toString(); debugPrint("[Auth] Code verification successful for loginId: $sanitizedLoginId"); + if (jwt == null && status == 'approved') { + if (mounted) { + _showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); + } + return; + } + if (jwt != null && mounted) { final isJwt = (jwt as String).split('.').length == 3; if (isJwt) { @@ -334,6 +360,43 @@ class _LoginScreenState extends ConsumerState } } + Future _verifyShortCode(String shortCode) async { + final sanitized = shortCode.trim().toUpperCase(); + if (sanitized.isEmpty) return; + debugPrint("[Auth] Starting short code verification for code: $sanitized"); + try { + final res = await AuthProxyService.verifyLoginShortCode(sanitized); + final jwt = res['sessionJwt'] ?? res['token']; + final status = res['status']?.toString(); + debugPrint("[Auth] Short code verification successful"); + + if (jwt == null && status == 'approved') { + if (mounted) { + _showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); + } + return; + } + + if (jwt != null && mounted) { + final isJwt = (jwt as String).split('.').length == 3; + if (isJwt) { + 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, provider: res['provider'] as String?); + } + } catch (e) { + debugPrint("[Auth] Short code verification FAILED. Error: $e"); + if (mounted) { + _showError("Verification failed: $e"); + } + } + } + @override void dispose() { _stopQrPolling(); @@ -341,6 +404,8 @@ class _LoginScreenState extends ConsumerState _linkIdController.dispose(); _passwordLoginIdController.dispose(); _passwordController.dispose(); + _shortCodePrefixController.dispose(); + _shortCodeDigitsController.dispose(); super.dispose(); } @@ -434,61 +499,14 @@ class _LoginScreenState extends ConsumerState debugPrint("[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider"); if (mounted) { + setState(() { + _linkPendingRef = pendingRef?.toString(); + }); Navigator.of(context).pop(); // Close Loading - if (mode == 'link' || provider.toLowerCase().contains('ory')) { - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => AlertDialog( - title: Text(isEmail ? "이메일 전송됨" : "SMS 전송됨"), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(isEmail - ? "입력하신 이메일로 로그인 링크를 보냈습니다." - : "입력하신 번호로 로그인 링크를 보냈습니다."), - const SizedBox(height: 12), - const Text("메일/문자 링크를 열면 이 탭에서 자동으로 로그인됩니다."), - const SizedBox(height: 16), - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text("닫기"), - ) - ], - ), - ), - ); - return; - } - - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => AlertDialog( - title: Text(isEmail ? "이메일 전송됨" : "SMS 전송됨"), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(isEmail - ? "입력하신 이메일로 로그인 링크를 보냈습니다." - : "입력하신 번호로 로그인 링크를 보냈습니다."), - const SizedBox(height: 16), - const LinearProgressIndicator(), - const SizedBox(height: 16), - TextButton( - onPressed: () { - debugPrint("[Auth] Polling canceled by user"); - Navigator.of(context).pop(); - }, - child: const Text("취소"), - ) - ], - ), - ), - ); + _showInfo(isEmail + ? "입력하신 이메일로 로그인 링크를 보냈습니다." + : "입력하신 번호로 로그인 링크를 보냈습니다."); // 2. Poll Backend manually final initialInterval = (interval is int && interval > 0) @@ -499,6 +517,11 @@ class _LoginScreenState extends ConsumerState } catch (e) { debugPrint("[Auth] Initialization failed: $e"); if (mounted && Navigator.canPop(context)) Navigator.of(context).pop(); + if (mounted) { + setState(() { + _linkPendingRef = null; + }); + } if (e.toString().contains("User not registered")) { _showUnregisteredDialog(); } else { @@ -584,6 +607,13 @@ class _LoginScreenState extends ConsumerState } } + void _showInfo(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), backgroundColor: Colors.green), + ); + } + void _logTokenDetails(String jwt) { try { final parts = jwt.split('.'); @@ -682,6 +712,7 @@ class _LoginScreenState extends ConsumerState FilledButton( onPressed: () { Navigator.pop(context); + _resetLinkLoginState(); context.push('/signup'); }, child: const Text("회원가입 하기"), @@ -769,35 +800,90 @@ class _LoginScreenState extends ConsumerState ), ), - // 2. 로그인 링크 전송 폼 + // 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), + if (_linkPendingRef == null) ...[ + TextField( + controller: _linkIdController, + decoration: const InputDecoration( + labelText: "이메일 또는 휴대폰 번호", + hintText: "", + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + ), + onSubmitted: (_) => _handleLinkLogin(), ), - onSubmitted: (_) => _handleLinkLogin(), - ), - const SizedBox(height: 24), - FilledButton( - onPressed: _handleLinkLogin, - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(50), + const SizedBox(height: 24), + FilledButton( + onPressed: _handleLinkLogin, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(50), + ), + child: const Text("로그인 링크 전송"), ), - child: const Text("로그인 링크 전송"), - ), - const SizedBox(height: 24), - const Text( - "입력하신 정보로 로그인 링크를 전송합니다.", - style: TextStyle(color: Colors.grey, fontSize: 12), - textAlign: TextAlign.center, - ), + const SizedBox(height: 24), + const Text( + "입력하신 정보로 로그인 링크를 전송합니다.", + style: TextStyle(color: Colors.grey, fontSize: 12), + textAlign: TextAlign.center, + ), + ], + if (_linkPendingRef != null) ...[ + const Text( + "링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.", + style: TextStyle(color: Colors.grey, fontSize: 12), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + flex: 2, + child: TextField( + controller: _shortCodePrefixController, + textCapitalization: TextCapitalization.characters, + decoration: const InputDecoration( + labelText: "AA", + border: OutlineInputBorder(), + ), + maxLength: 2, + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 4, + child: TextField( + controller: _shortCodeDigitsController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: "000000", + border: OutlineInputBorder(), + ), + maxLength: 6, + ), + ), + ], + ), + const SizedBox(height: 12), + FilledButton( + onPressed: () { + final prefix = _shortCodePrefixController.text.trim().toUpperCase(); + final digits = _shortCodeDigitsController.text.trim(); + if (prefix.length != 2 || digits.length != 6) { + _showError("문자 2개와 숫자 6자리를 입력해 주세요."); + return; + } + _verifyShortCode(prefix + digits); + }, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(45), + ), + child: const Text("코드로 로그인"), + ), + ], ], ), ), diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index 5a4b23e7..1f13a44e 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -124,6 +124,14 @@ final _router = GoRouter( return LoginScreen(verificationToken: token); }, ), + GoRoute( + path: '/l/:shortCode', + builder: (context, state) { + final shortCode = state.pathParameters['shortCode']; + _routerLogger.info("Navigating to /l with code: $shortCode"); + return const LoginScreen(); + }, + ), GoRoute( path: '/forgot-password', builder: (context, state) {