diff --git a/adminfront/playwright-report/index.html b/adminfront/playwright-report/index.html
new file mode 100644
index 00000000..8371ce48
--- /dev/null
+++ b/adminfront/playwright-report/index.html
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+ Playwright Test Report
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/adminfront/test-results/.last-run.json b/adminfront/test-results/.last-run.json
new file mode 100644
index 00000000..cbcc1fba
--- /dev/null
+++ b/adminfront/test-results/.last-run.json
@@ -0,0 +1,4 @@
+{
+ "status": "passed",
+ "failedTests": []
+}
\ No newline at end of file
diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go
index d8ddd928..7ba07c90 100644
--- a/backend/internal/handler/auth_handler.go
+++ b/backend/internal/handler/auth_handler.go
@@ -38,6 +38,12 @@ const (
prefixLoginCodeSmsTarget = "login_code_sms_target:"
prefixLoginCodeSmsLookup = "login_code_sms_lookup:"
prefixLoginCodeShort = "login_code_short:"
+ prefixLoginCodeValue = "login_code_value:"
+ prefixLoginIDRaw = "login_id_raw:"
+ prefixLoginMethod = "login_method:"
+ prefixLoginFlow = "login_flow:"
+ prefixLoginStrategy = "login_strategy:"
+ prefixLoginApproverMeta = "login_approver_meta:"
prefixLoginCodeSmsOnly = "login_code_sms_only:"
prefixLoginCodeQrPending = "login_code_qr_pending:"
prefixLoginCodeQr = "login_code_qr:"
@@ -53,6 +59,10 @@ const (
statusPending = "pending"
statusSuccess = "success"
+ // Login Flows
+ loginFlowCode = "code"
+ loginFlowLink = "link"
+
// Durations
defaultExpiration = 5 * time.Minute
signupStateExpiration = 10 * time.Minute
@@ -635,7 +645,16 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
_ = h.RedisService.Set(prefixLoginCode+keyLoginID, init.FlowID, loginCodeExpiration)
}
pendingRef := GenerateSecureToken(3)
- h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), loginCodeExpiration)
+ sessionData, _ := json.Marshal(map[string]string{
+ "status": statusPending,
+ "loginId": keyLoginID,
+ })
+ h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
+ intent := loginFlowLink
+ if req.CodeOnly {
+ intent = loginFlowCode
+ }
+ h.storeLoginMeta(pendingRef, loginID, req.Method, intent, loginFlowCode, loginCodeExpiration)
_ = h.RedisService.Set(prefixLoginCodePending+keyLoginID, pendingRef, loginCodeExpiration)
if drySend {
_ = h.RedisService.Set(prefixDrySend+keyLoginID, pendingRef, loginCodeExpiration)
@@ -677,11 +696,20 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
slog.Info("[Enchanted] Initiating enchanted link", "loginID", loginID, "token", token, "pendingRef", pendingRef)
// Store in Redis
- h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), defaultExpiration)
+ sessionData, _ := json.Marshal(map[string]string{
+ "status": statusPending,
+ "loginId": lookupLoginID,
+ })
+ h.RedisService.Set(prefixSession+pendingRef, string(sessionData), defaultExpiration)
h.RedisService.Set(prefixToken+token, fmt.Sprintf(`{"pendingRef":"%s","loginId":"%s"}`, pendingRef, lookupLoginID), defaultExpiration)
if drySend {
_ = h.RedisService.Set(prefixDrySend+lookupLoginID, pendingRef, defaultExpiration)
}
+ intent := loginFlowLink
+ if req.CodeOnly {
+ intent = loginFlowCode
+ }
+ h.storeLoginMeta(pendingRef, loginID, req.Method, intent, loginFlowLink, defaultExpiration)
// Generate Link
slog.Info("[Enchanted] Read USERFRONT_URL", "url", userfrontURL)
@@ -787,13 +815,41 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
}
- authInfo, err := h.IdpProvider.IssueSession(loginID)
- if err != nil {
- if errors.Is(err, domain.ErrNotSupported) {
- return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"})
+ loginStrategy := h.loadLoginStrategy(req.PendingRef)
+ if loginStrategy == "" {
+ loginStrategy = loginFlowLink
+ }
+
+ var authInfo *domain.AuthInfo
+ var err error
+ if loginStrategy == loginFlowCode {
+ code, _ := h.RedisService.Get(prefixLoginCodeValue + req.PendingRef)
+ code = normalizeLoginCode(code)
+ if code == "" {
+ slog.Warn("[Poll] Missing login code for approved flow", "pendingRef", req.PendingRef)
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Login code expired"})
+ }
+ flowID, _ := h.RedisService.Get(prefixLoginCode + loginID)
+ if flowID == "" {
+ return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Login flow expired"})
+ }
+ authInfo, err = h.IdpProvider.VerifyLoginCode(loginID, flowID, 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("[Poll] IDP code verify failed", "error", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to verify login code"})
+ }
+ } else {
+ authInfo, err = h.IdpProvider.IssueSession(loginID)
+ if err != nil {
+ if errors.Is(err, domain.ErrNotSupported) {
+ return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"})
+ }
+ slog.Error("[Poll] IDP session issue failed", "error", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
}
- slog.Error("[Poll] IDP session issue failed", "error", err)
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
}
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
@@ -802,6 +858,13 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error {
c.Locals("login_id", loginID)
setSessionIDLocal(c, authInfo.SessionToken)
sessionID := extractSessionIDFromToken(authInfo.SessionToken)
+ if sessionID == "" && authInfo.SessionToken != nil && authInfo.SessionToken.JWT != "" {
+ if resolved, err := h.getKratosSessionID(authInfo.SessionToken.JWT); err == nil && resolved != "" {
+ sessionID = resolved
+ authInfo.SessionToken.SessionID = resolved
+ setSessionIDLocal(c, authInfo.SessionToken)
+ }
+ }
sessionData := map[string]string{
"status": statusSuccess,
@@ -814,6 +877,14 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error {
h.RedisService.Set(prefixSession+req.PendingRef, string(sessionDataJSON), defaultExpiration)
h.writeLinkAuditLog(loginID, req.PendingRef, authInfo.SessionToken, c)
+ h.clearLoginMeta(req.PendingRef)
+ if loginStrategy == loginFlowCode {
+ h.RedisService.Delete(prefixLoginCode + loginID)
+ h.RedisService.Delete(prefixLoginCodePending + loginID)
+ h.RedisService.Delete(prefixLoginCodeSmsTarget + loginID)
+ h.RedisService.Delete(prefixLoginCodeSmsLookup + loginID)
+ h.RedisService.Delete(prefixLoginCodeValue + req.PendingRef)
+ }
return c.JSON(fiber.Map{
"sessionJwt": authInfo.SessionToken.JWT,
@@ -852,11 +923,14 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
slog.Info("[Verify] Token valid", "loginID", loginID, "pendingRef", pendingRef)
if req.VerifyOnly {
+ c.Locals("auth_timeline_skip", true)
if pendingRef == "" || loginID == "" {
slog.Warn("[Verify] Missing pendingRef/loginID for verify-only", "token", req.Token)
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"})
}
+ h.storeLoginApproverMeta(pendingRef, c, defaultExpiration)
+
// 승인 전용: 세션 발급 없이 승인 상태만 기록
sessionData, _ := json.Marshal(map[string]string{
"status": "approved",
@@ -919,6 +993,7 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
LoginID string `json:"loginId"`
Code string `json:"code"`
PendingRef string `json:"pendingRef"`
+ VerifyOnly bool `json:"verifyOnly,omitempty"`
}
if err := c.BodyParser(&req); err != nil {
slog.Error("[LoginCode] Body parse error", "error", err)
@@ -945,6 +1020,43 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Login flow expired"})
}
+ if req.VerifyOnly {
+ c.Locals("auth_timeline_skip", true)
+ effectiveLoginID := lookupLoginID
+ if !strings.Contains(loginID, "@") {
+ if mapped, _ := h.RedisService.Get(prefixLoginCodeSmsLookup + lookupLoginID); mapped != "" {
+ effectiveLoginID = mapped
+ }
+ }
+ pendingRef := strings.TrimSpace(req.PendingRef)
+ storedRef, _ := h.RedisService.Get(prefixLoginCodePending + lookupLoginID)
+ if pendingRef == "" {
+ pendingRef = storedRef
+ } else if storedRef != "" && pendingRef != storedRef {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"})
+ }
+ if pendingRef == "" {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"})
+ }
+ expectedCode, _ := h.RedisService.Get(prefixLoginCodeValue + pendingRef)
+ expectedCode = normalizeLoginCode(expectedCode)
+ inputCode := normalizeLoginCode(req.Code)
+ if expectedCode == "" || inputCode == "" || inputCode != expectedCode {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid code"})
+ }
+ h.storeLoginApproverMeta(pendingRef, c, loginCodeExpiration)
+ sessionData, _ := json.Marshal(map[string]string{
+ "status": "approved",
+ "loginId": effectiveLoginID,
+ })
+ h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
+ return c.JSON(fiber.Map{
+ "status": "approved",
+ "pendingRef": pendingRef,
+ "message": "Login approved",
+ })
+ }
+
authInfo, err := h.IdpProvider.VerifyLoginCode(lookupLoginID, flowID, req.Code)
if err != nil {
if errors.Is(err, domain.ErrNotSupported) {
@@ -997,6 +1109,7 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error {
var req struct {
ShortCode string `json:"shortCode"`
+ VerifyOnly bool `json:"verifyOnly,omitempty"`
}
if err := c.BodyParser(&req); err != nil {
slog.Error("[LoginShortCode] Body parse error", "error", err)
@@ -1021,6 +1134,29 @@ func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired code"})
}
+ if req.VerifyOnly {
+ c.Locals("auth_timeline_skip", true)
+ if payload.PendingRef == "" {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"})
+ }
+ normalizedCode := normalizeLoginCode(payload.Code)
+ if normalizedCode != "" {
+ h.RedisService.Set(prefixLoginCodeValue+payload.PendingRef, normalizedCode, loginCodeExpiration)
+ }
+ h.storeLoginApproverMeta(payload.PendingRef, c, loginCodeExpiration)
+ sessionData, _ := json.Marshal(map[string]string{
+ "status": "approved",
+ "loginId": payload.LoginID,
+ })
+ h.RedisService.Set(prefixSession+payload.PendingRef, string(sessionData), loginCodeExpiration)
+ h.RedisService.Delete(prefixLoginCodeShort + shortCode)
+ return c.JSON(fiber.Map{
+ "status": "approved",
+ "pendingRef": payload.PendingRef,
+ "message": "Login approved",
+ })
+ }
+
if h.IdpProvider == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
}
@@ -1825,6 +1961,20 @@ func (h *AuthHandler) buildKratosCourierMessage(req *kratosCourierRequest) (stri
}
}
+ if loginCode != "" && label == "로그인" {
+ loginID := req.Recipient
+ if !strings.Contains(loginID, "@") {
+ loginID = normalizePhoneForLoginID(loginID)
+ }
+ pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID)
+ if pendingRef != "" {
+ normalizedCode := normalizeLoginCode(loginCode)
+ if normalizedCode != "" {
+ _ = h.RedisService.Set(prefixLoginCodeValue+pendingRef, normalizedCode, loginCodeExpiration)
+ }
+ }
+ }
+
if code == "" {
return subject, fmt.Sprintf("[Baron 통합로그인] %s 요청이 도착했습니다", label)
}
@@ -2241,6 +2391,39 @@ func (h *AuthHandler) loadQrMeta(pendingRef string) (qrMeta, bool) {
return meta, true
}
+func (h *AuthHandler) storeLoginApproverMeta(pendingRef string, c *fiber.Ctx, ttl time.Duration) {
+ if h.RedisService == nil || pendingRef == "" || c == nil {
+ return
+ }
+ meta := qrMeta{
+ IPAddress: extractClientIPFromHeaders(c),
+ UserAgent: c.Get("User-Agent"),
+ }
+ raw, err := json.Marshal(meta)
+ if err != nil {
+ return
+ }
+ _ = h.RedisService.Set(prefixLoginApproverMeta+pendingRef, string(raw), ttl)
+}
+
+func (h *AuthHandler) loadLoginApproverMeta(pendingRef string) (qrMeta, bool) {
+ if h.RedisService == nil || pendingRef == "" {
+ return qrMeta{}, false
+ }
+ val, err := h.RedisService.Get(prefixLoginApproverMeta + pendingRef)
+ if err != nil || val == "" {
+ return qrMeta{}, false
+ }
+ var meta qrMeta
+ if err := json.Unmarshal([]byte(val), &meta); err != nil {
+ return qrMeta{}, false
+ }
+ if meta.IPAddress == "" && meta.UserAgent == "" {
+ return qrMeta{}, false
+ }
+ return meta, true
+}
+
func (h *AuthHandler) storeQrApproverSessionID(pendingRef, sessionID string) {
if h.RedisService == nil || pendingRef == "" || sessionID == "" {
return
@@ -2259,6 +2442,57 @@ func (h *AuthHandler) loadQrApproverSessionID(pendingRef string) string {
return strings.TrimSpace(val)
}
+func (h *AuthHandler) storeLoginMeta(pendingRef, loginID, rawMethod, flow, strategy string, ttl time.Duration) {
+ if h.RedisService == nil || pendingRef == "" {
+ return
+ }
+ method := resolveLoginMethod(rawMethod, loginID)
+ if method != "" {
+ _ = h.RedisService.Set(prefixLoginMethod+pendingRef, method, ttl)
+ }
+ if flow != "" {
+ _ = h.RedisService.Set(prefixLoginFlow+pendingRef, flow, ttl)
+ }
+ if strategy != "" {
+ _ = h.RedisService.Set(prefixLoginStrategy+pendingRef, strategy, ttl)
+ }
+ if strings.TrimSpace(loginID) != "" {
+ _ = h.RedisService.Set(prefixLoginIDRaw+pendingRef, loginID, ttl)
+ }
+}
+
+func (h *AuthHandler) loadLoginMeta(pendingRef string) (string, string, string, string) {
+ if h.RedisService == nil || pendingRef == "" {
+ return "", "", "", ""
+ }
+ method, _ := h.RedisService.Get(prefixLoginMethod + pendingRef)
+ flow, _ := h.RedisService.Get(prefixLoginFlow + pendingRef)
+ strategy, _ := h.RedisService.Get(prefixLoginStrategy + pendingRef)
+ rawLoginID, _ := h.RedisService.Get(prefixLoginIDRaw + pendingRef)
+ return strings.TrimSpace(method), strings.TrimSpace(flow), strings.TrimSpace(strategy), strings.TrimSpace(rawLoginID)
+}
+
+func (h *AuthHandler) loadLoginFlow(pendingRef string) string {
+ _, flow, _, _ := h.loadLoginMeta(pendingRef)
+ return flow
+}
+
+func (h *AuthHandler) loadLoginStrategy(pendingRef string) string {
+ _, _, strategy, _ := h.loadLoginMeta(pendingRef)
+ return strategy
+}
+
+func (h *AuthHandler) clearLoginMeta(pendingRef string) {
+ if h.RedisService == nil || pendingRef == "" {
+ return
+ }
+ _ = h.RedisService.Delete(prefixLoginMethod + pendingRef)
+ _ = h.RedisService.Delete(prefixLoginFlow + pendingRef)
+ _ = h.RedisService.Delete(prefixLoginStrategy + pendingRef)
+ _ = h.RedisService.Delete(prefixLoginIDRaw + pendingRef)
+ _ = h.RedisService.Delete(prefixLoginApproverMeta + pendingRef)
+}
+
func (h *AuthHandler) writeQrAuditLog(loginID, pendingRef string, sessionToken *domain.Token, approvedSessionID string) {
if h.AuditRepo == nil || pendingRef == "" {
return
@@ -2314,14 +2548,53 @@ func (h *AuthHandler) writeLinkAuditLog(loginID, pendingRef string, sessionToken
meta.UserAgent = c.Get("User-Agent")
}
sessionID := extractSessionIDFromToken(sessionToken)
+ loginMethod, loginFlow, loginStrategy, rawLoginID := h.loadLoginMeta(pendingRef)
+ path := "/api/v1/auth/magic-link/verify"
+ authLabel := "링크"
+ if loginStrategy == loginFlowCode {
+ path = "/api/v1/auth/login/code/verify"
+ }
+ displayFlow := loginFlow
+ if displayFlow == "" {
+ displayFlow = loginStrategy
+ }
+ if displayFlow == loginFlowCode {
+ authLabel = "코드"
+ } else if displayFlow == loginFlowLink {
+ authLabel = "링크"
+ }
+ logLoginID := loginID
+ if rawLoginID != "" {
+ logLoginID = rawLoginID
+ }
details := map[string]any{
- "path": "/api/v1/auth/magic-link/verify",
- "login_id": loginID,
+ "path": path,
+ "login_id": logLoginID,
"pending_ref": pendingRef,
}
if sessionID != "" {
details["session_id"] = sessionID
}
+ if loginMethod != "" {
+ details["login_method"] = loginMethod
+ }
+ if loginFlow != "" {
+ details["login_flow"] = loginFlow
+ }
+ if loginStrategy != "" {
+ details["login_strategy"] = loginStrategy
+ }
+ if rawLoginID != "" && rawLoginID != loginID {
+ details["login_id_effective"] = loginID
+ }
+ if approverMeta, ok := h.loadLoginApproverMeta(pendingRef); ok {
+ if approverMeta.IPAddress != "" {
+ details["approved_ip"] = approverMeta.IPAddress
+ }
+ if approverMeta.UserAgent != "" {
+ details["approved_user_agent"] = approverMeta.UserAgent
+ }
+ }
detailsJSON, _ := json.Marshal(details)
log := &domain.AuditLog{
@@ -2329,12 +2602,12 @@ func (h *AuthHandler) writeLinkAuditLog(loginID, pendingRef string, sessionToken
Timestamp: time.Now(),
UserID: "",
SessionID: sessionID,
- EventType: "POST /api/v1/auth/magic-link/verify",
+ EventType: fmt.Sprintf("POST %s", path),
Status: "success",
IPAddress: meta.IPAddress,
UserAgent: meta.UserAgent,
Details: string(detailsJSON),
- AuthMethod: "링크",
+ AuthMethod: authLabel,
}
_ = h.AuditRepo.Create(log)
}
@@ -2370,6 +2643,16 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
limit = 100
}
+ cursorRaw := strings.TrimSpace(c.Query("cursor"))
+ var cursor *domain.AuditCursor
+ if cursorRaw != "" {
+ var err error
+ cursor, err = parseAuditCursor(cursorRaw)
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid cursor"})
+ }
+ }
+
profile, err := h.resolveCurrentProfile(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
@@ -2384,38 +2667,76 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
fetchLimit = 500
}
- logs, err := h.AuditRepo.FindPage(c.Context(), fetchLimit, nil)
- if err != nil {
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to retrieve audit logs"})
- }
-
items := make([]domain.AuditLog, 0, limit)
- for _, log := range logs {
- if !isAuthEventType(log.EventType) {
- continue
+ nextCursor := ""
+ currentCursor := cursor
+ const maxBatches = 10
+ for batch := 0; batch < maxBatches && len(items) < limit; batch++ {
+ logs, err := h.AuditRepo.FindPage(c.Context(), fetchLimit, currentCursor)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to retrieve audit logs"})
}
- if !matchesAuthTimelineUser(log, profile, candidates) {
- continue
+ if len(logs) == 0 {
+ nextCursor = ""
+ break
}
- if log.UserID == "" {
- log.UserID = profile.ID
+
+ var lastScanned *domain.AuditLog
+ for i := range logs {
+ log := logs[i]
+ lastScanned = &log
+ if !isAuthEventType(log.EventType) {
+ continue
+ }
+ if !matchesAuthTimelineUser(log, profile, candidates) {
+ continue
+ }
+ if shouldSkipAuthTimeline(log) {
+ continue
+ }
+ if log.UserID == "" {
+ log.UserID = profile.ID
+ }
+ log.AuthMethod = deriveAuthMethod(log)
+ if log.AuthMethod == "" {
+ continue
+ }
+ if log.SessionID == "" {
+ log.SessionID = extractSessionIDFromAuditDetails(log.Details)
+ }
+ items = append(items, log)
+ if len(items) >= limit {
+ nextCursor = encodeAuditCursor(log)
+ break
+ }
}
- log.AuthMethod = deriveAuthMethod(log)
- if log.AuthMethod == "" {
- continue
- }
- if log.SessionID == "" {
- log.SessionID = extractSessionIDFromAuditDetails(log.Details)
- }
- items = append(items, log)
+
if len(items) >= limit {
break
}
+
+ if len(logs) < fetchLimit {
+ nextCursor = ""
+ break
+ }
+
+ if lastScanned == nil {
+ nextCursor = ""
+ break
+ }
+
+ currentCursor = &domain.AuditCursor{
+ Timestamp: lastScanned.Timestamp,
+ EventID: lastScanned.EventID,
+ }
+ nextCursor = encodeAuditCursor(*lastScanned)
}
return c.JSON(fiber.Map{
- "items": items,
- "limit": limit,
+ "items": items,
+ "limit": limit,
+ "cursor": cursorRaw,
+ "next_cursor": nextCursor,
})
}
@@ -2640,6 +2961,54 @@ func extractRequestBody(details map[string]any) map[string]any {
return body
}
+func shouldSkipAuthTimeline(log domain.AuditLog) bool {
+ details, _ := parseAuditDetails(log.Details)
+ path := strings.ToLower(extractAuditPath(log))
+ if path != "" && strings.Contains(path, "/api/v1/auth/enchanted-link/init") {
+ return true
+ }
+ if path != "" && (strings.Contains(path, "/api/v1/auth/magic-link/verify") ||
+ strings.Contains(path, "/api/v1/auth/login/code/verify")) {
+ sessionID := log.SessionID
+ if sessionID == "" {
+ sessionID = extractSessionIDFromAuditDetails(log.Details)
+ }
+ if sessionID == "" {
+ return true
+ }
+ }
+ if details != nil {
+ if raw, ok := details["auth_timeline_skip"]; ok {
+ switch value := raw.(type) {
+ case bool:
+ if value {
+ return true
+ }
+ case string:
+ if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" {
+ return true
+ }
+ }
+ }
+ }
+ requestBody := extractRequestBody(details)
+ if requestBody != nil {
+ if raw, ok := requestBody["verifyOnly"]; ok {
+ switch value := raw.(type) {
+ case bool:
+ if value {
+ return true
+ }
+ case string:
+ if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" {
+ return true
+ }
+ }
+ }
+ }
+ return false
+}
+
func loginIDKind(loginID string) string {
normalized := strings.TrimSpace(loginID)
if normalized == "" {
@@ -2651,6 +3020,31 @@ func loginIDKind(loginID string) string {
return "phone"
}
+func resolveLoginMethod(rawMethod, loginID string) string {
+ method := strings.ToLower(strings.TrimSpace(rawMethod))
+ if method == "sms" || method == "email" {
+ return method
+ }
+ if strings.TrimSpace(loginID) == "" {
+ return ""
+ }
+ if strings.Contains(loginID, "@") {
+ return "email"
+ }
+ return "sms"
+}
+
+func loginMethodLabel(method string) string {
+ switch strings.ToLower(strings.TrimSpace(method)) {
+ case "sms":
+ return "SMS"
+ case "email":
+ return "Email"
+ default:
+ return ""
+ }
+}
+
func deriveAuthMethod(log domain.AuditLog) string {
path := strings.ToLower(extractAuditPath(log))
if path == "" {
@@ -2661,6 +3055,57 @@ func deriveAuthMethod(log domain.AuditLog) string {
kind := loginIDKind(loginID)
details, _ := parseAuditDetails(log.Details)
requestBody := extractRequestBody(details)
+ if details != nil {
+ if raw, ok := details["auth_timeline_skip"]; ok {
+ switch value := raw.(type) {
+ case bool:
+ if value {
+ return ""
+ }
+ case string:
+ if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" {
+ return ""
+ }
+ }
+ }
+ }
+ if requestBody != nil {
+ if raw, ok := requestBody["verifyOnly"]; ok {
+ switch value := raw.(type) {
+ case bool:
+ if value {
+ return ""
+ }
+ case string:
+ if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" {
+ return ""
+ }
+ }
+ }
+ }
+ if path != "" && (strings.Contains(path, "/api/v1/auth/qr/init") ||
+ strings.Contains(path, "/api/v1/auth/qr/poll") ||
+ strings.Contains(path, "/api/v1/auth/qr/approve")) {
+ return "QR"
+ }
+ if details != nil {
+ rawFlow, _ := details["login_flow"].(string)
+ rawMethod, _ := details["login_method"].(string)
+ flow := strings.ToLower(strings.TrimSpace(rawFlow))
+ methodLabel := loginMethodLabel(rawMethod)
+ switch flow {
+ case loginFlowCode:
+ if methodLabel != "" {
+ return fmt.Sprintf("코드(%s)", methodLabel)
+ }
+ return "코드"
+ case loginFlowLink:
+ if methodLabel != "" {
+ return fmt.Sprintf("링크(%s)", methodLabel)
+ }
+ return "링크"
+ }
+ }
switch {
case strings.Contains(path, "/api/v1/auth/password/login"):
diff --git a/backend/internal/middleware/audit_middleware.go b/backend/internal/middleware/audit_middleware.go
index a88982c0..b42e5756 100644
--- a/backend/internal/middleware/audit_middleware.go
+++ b/backend/internal/middleware/audit_middleware.go
@@ -144,6 +144,9 @@ func AuditMiddleware(config AuditConfig) fiber.Handler {
"tenant_id": tenantID,
"request_body": maskedBody,
}
+ if skipTimeline, ok := c.Locals("auth_timeline_skip").(bool); ok && skipTimeline {
+ details["auth_timeline_skip"] = true
+ }
if sessionID != "" {
details["session_id"] = sessionID
}
diff --git a/devfront/playwright-report/index.html b/devfront/playwright-report/index.html
new file mode 100644
index 00000000..c98a4a9a
--- /dev/null
+++ b/devfront/playwright-report/index.html
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+ Playwright Test Report
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/devfront/test-results/.last-run.json b/devfront/test-results/.last-run.json
new file mode 100644
index 00000000..cbcc1fba
--- /dev/null
+++ b/devfront/test-results/.last-run.json
@@ -0,0 +1,4 @@
+{
+ "status": "passed",
+ "failedTests": []
+}
\ No newline at end of file
diff --git a/docs/AGENTS.md b/docs/AGENTS.md
index f621b3cb..227b1214 100644
--- a/docs/AGENTS.md
+++ b/docs/AGENTS.md
@@ -1,22 +1,49 @@
# AGENTS 가이드 (Baron SSO)
## 목적
-- Inbound Auth/Launcher와 관리(Admin) 기능을 하나의 백엔드에서 운영하되, 네임스페이스·도메인·권한으로 강하게 분리한다.
-- 사용자 플로우(가입/로그인)와 관리 플로우(Descope Management Key 사용)를 명확히 구분해 보안 사고면을 축소한다.
+- 인증/인가 허브로서 **Backend + Ory Stack** 중심 아키텍처를 유지
+- 사용자 플로우(UserFront)와 관리 플로우(Admin/DevFront)를 명확히 분리
+- 네트워크/보안 경계를 문서화해 회귀/설정 오류를 방지
-## 현재 원칙
-- **경계 분리**: `/admin/*` + admin 서브도메인에서만 관리 기능 노출. 일반 사용자 번들과 관리자 번들(또는 라우트)을 분리.
-- **관리 키 취급**: Descope Management Key는 서버 내부에서만 사용, 비동기 잡/관리 API에서 래핑. 모든 관리 액션을 감사 로그/알람/레이트리밋으로 보호.
-- **권한/가드**: role/permission 기반 접근 제어. 관리자 세션 TTL은 짧게, step-up MFA 고려.
+## 시스템 요약
+- **Backend**: Command 단일 진입점, 감사 로그를 ClickHouse에 적재
+- **Ory Stack**: Kratos/Hydra/Keto/Oathkeeper (인증/토큰/정책)
+- **Front**: UserFront(Flutter)-사용자 접점, AdminFront/DevFront(React)-내부 관리도구
+- **원칙**: Front는 Backend API를 통해서만 IDP 기능을 호출
-## 인증 플로우 핵심
-- **최초 회원가입**: SMS 인증(Enchanted Link/OTP) 필수 → 인증 성공 후 계정 생성 및 초기 세션 발급.
-- **재로그인 분기 (앱 세션 보유 + 사용자 선택)**:
- - 앱 로그인 상태 + 사용자가 “앱 승인” 선택: 앱을 MFA/IDPW 대체 수단으로 사용(푸시/딥링크 승인) → 승인 시 웹 세션 발급.
- - 앱 세션이 없거나, 사용자가 이번 로그인에서 앱을 사용하지 않기로 선택: SMS 또는 이메일/비밀번호 경로로 진행.
-- **세션 TTL**: 앱 기반 세션 유지시간을 `APP_SESSION_TTL_MINUTES` 환경 변수로 관리(기본 예: 30분).
+## 네트워크/보안 경계
+- `ory-net`: Ory 내부 통신 전용 네트워크
+- `baron_net`: App(backend/userfront/adminfront/devfront) 네트워크
+- `public_net`: Oathkeeper, userfront 외부 공개. Gateway를 이용해 Proxy 분기
-## 작업 시 체크리스트
-- 관리 기능 개발 시 admin 네임스페이스, 권한 체크, 감사 로깅, 레이트리밋을 기본 포함.
-- 인증/로그인 변경 시 “폴백은 사용자 선택일 때만” 규칙을 준수하고, UI에도 선택 흐름을 노출.
-- 새 설정/비밀값은 .env.sample에 반영하고 서버에서만 소비하게 설계한다.
+핵심 규칙:
+- **Ory Admin 포트는 외부 노출 금지** (Backend만 `ory-net`을 통해 접근)
+- **UserFront는 Oathkeeper 뒤에 있지 않음**
+- **모든 Front(User/Admin/Dev)는 Ory Admin 엔드포인트에 직접 접근하지 않음**
+
+## 인증/세션 핵심
+- `IDP_PROVIDER` Ory 전용 저장 구조지만 향후 마이그레이션으로 추가 스택 지원할 수 있음
+- `sessionJwt`는 **JWT가 아닐 수 있음** (Kratos session token은 opaque 가능)
+- OIDC Consent 플로우는 UserFront의 `/consent` 경로에서 처리
+- 토큰/쿠키 전달 방식 변경 시 `docs/auth-flow.md`를 반드시 갱신
+
+## 작업 체크리스트
+- 인증/로그인 변경 시
+ - `docs/auth-flow.md` 업데이트
+ - 세션/쿠키/Authorization 전달 방식 영향도 점검
+ - UserFront가 Ory/Oathkeeper 직접 호출하지 않도록 확인
+
+- Ory 설정 변경 시
+ - `compose.ory.yaml`, `docker/ory/*` 변경 범위 명시
+ - `ory-net`/`public_net` 경계 유지 여부 확인
+
+- 환경 변수 추가/변경 시
+ - `.env.sample` 반영
+ - 문서/가이드 갱신
+
+- 배포/운영 변경 시
+ - `Makefile`/compose 실행 절차 영향 확인
+ - 최소 Smoke 테스트 수행
+
+## 테스트 참고
+- 테스트 계획 및 수동 실행 기준은 `docs/test-plan.md`를 따른다.
diff --git a/docs/auth-flow.md b/docs/auth-flow.md
index 45ba01e0..d5bab1de 100644
--- a/docs/auth-flow.md
+++ b/docs/auth-flow.md
@@ -11,8 +11,8 @@
| 방식 | Backend 엔드포인트 | 세션 토큰 반환 | 비고 |
|---|---|---|---|
| ID/Password | `POST /api/v1/auth/password/login` | `sessionJwt` | IDP 추상화 사용 (Ory/Descope) |
-| Enchanted Link (Email/SMS) | `POST /api/v1/auth/enchanted-link/init` → `POST /api/v1/auth/enchanted-link/poll` | `sessionJwt` | 링크 클릭 시 `POST /api/v1/auth/magic-link/verify` 호출 |
-| Magic Link Verify | `POST /api/v1/auth/magic-link/verify` | `token` | Polling 세션 갱신용 |
+| Enchanted Link (Email/SMS) | `POST /api/v1/auth/enchanted-link/init` → `POST /api/v1/auth/enchanted-link/poll` | `sessionJwt` | Ory는 `/api/v1/auth/login/code/verify`/`verify-short`(verifyOnly) 사용 |
+| Magic Link Verify | `POST /api/v1/auth/magic-link/verify` | `token` | 비-Ory 경로(verifyOnly 가능) |
| SMS 코드 | `POST /api/v1/auth/sms` → `POST /api/v1/auth/verify-sms` | `token` | 현재는 내부 토큰(placeholder). Kratos 세션 교환 필요 |
| QR 로그인 | `POST /api/v1/auth/qr/init` → `POST /api/v1/auth/qr/poll` | `sessionJwt` | 모바일 승인: `POST /api/v1/auth/qr/approve` |
@@ -27,8 +27,10 @@
### 2.2 Enchanted Link (Email/SMS)
1. `POST /api/v1/auth/enchanted-link/init` → `pendingRef` 수신
2. `POST /api/v1/auth/enchanted-link/poll`로 폴링
-3. 사용자가 링크 클릭하면 UserFront가 `POST /api/v1/auth/magic-link/verify` 호출
-4. Polling 응답에서 `sessionJwt` 수신
+3. 사용자가 링크 클릭하면 UserFront가 아래 중 하나 호출
+ - Ory: `POST /api/v1/auth/login/code/verify` 또는 `/api/v1/auth/login/code/verify-short` (verifyOnly=1)
+ - 비-Ory: `POST /api/v1/auth/magic-link/verify` (verifyOnly=1)
+4. Polling 응답에서 `sessionJwt` 수신 (승인 후 Backend에서 세션 발급)
### 2.3 QR 로그인
1. `POST /api/v1/auth/qr/init` → `qrCode`, `pendingRef` 수신
@@ -75,14 +77,41 @@
---
-## 5) UserFront 주의사항
+## 5) 링크 로그인 ↔ QR 로그인 공유/분리 로직
+
+### 5.1 공유되는 로직 (공통 기반)
+- **IDP 코드 검증 로직 공유**: Ory 기준으로 링크 로그인과 QR 로그인 모두 `VerifyLoginCode`(코드 기반 로그인 검증)를 사용합니다.
+- **Kratos courier relay 공유**: Kratos에서 발송되는 `login_code`를 `HandleKratosCourierRelay`에서 처리하며, 링크/QR 모두 이 경로를 거칩니다.
+- **코드/플로우 상태 저장**: 코드 로그인 플로우의 `flow_id`는 공통 키(`prefixLoginCode`)에 저장됩니다.
+
+### 5.2 분리되는 로직 (pendingRef/승인 경로)
+- **pendingRef 네임스페이스 분리**:
+ - 링크 로그인: `prefixSession`, `prefixLoginCodePending`, `prefixLoginMethod`, `prefixLoginFlow`
+ - QR 로그인: `prefixLoginCodeQrPending`, `prefixLoginCodeQr`, `prefixQrPending`, `prefixQrMeta`, `prefixQrApproverSession`
+- **승인 엔드포인트 분리**:
+ - 링크 로그인: `/api/v1/auth/magic-link/verify` 또는 `/api/v1/auth/login/code/verify*` (verify-only)
+ - QR 로그인: `/api/v1/auth/qr/approve`
+- **세션 발급 주체 분리**:
+ - 링크 로그인: Polling(요청 기기 A)에서 세션 발급
+ - QR 로그인: Polling(웹)에서 세션 발급, 모바일은 승인만 수행
+- **audit 기록 경로 분리**:
+ - 링크 로그인: `writeLinkAuditLog`
+ - QR 로그인: `writeQrAuditLog`
+
+### 5.3 verify-only 적용 범위
+- 링크 로그인/코드 로그인 경로에만 적용됩니다.
+- QR 로그인은 approve/poll 구조이므로 verify-only를 사용하지 않습니다.
+
+---
+
+## 6) UserFront 주의사항
- `sessionJwt`가 **JWT 형식이 아닐 수 있음** (Kratos session token은 opaque 가능)
- 현재 UserFront는 Descope SDK 기반 세션 처리 로직이 포함되어 있어, Ory 사용 시 이 부분은 분리/대체가 필요함
---
-## 6) 다음 액션 제안
+## 7) 다음 액션 제안
1. **Kratos 세션 쿠키 전달 방식(A) 구현**
2. Enchanted/Magic Link의 Ory 대응(로그인 코드/링크 방식) 설계
diff --git a/docs/issue-146-remote-login.md b/docs/issue-146-remote-login.md
new file mode 100644
index 00000000..5f34bbdc
--- /dev/null
+++ b/docs/issue-146-remote-login.md
@@ -0,0 +1,35 @@
+# #146 원격 링크 로그인 세션/이력 불일치 대응
+
+## 요약
+- Ory 링크 로그인은 실제로 `/api/v1/auth/login/code/verify` 또는 `/api/v1/auth/login/code/verify-short` 경로를 사용합니다.
+- 기존에는 `verifyOnly`가 `/api/v1/auth/magic-link/verify`에만 적용되어, 링크를 클릭한 기기에서 세션이 발급되는 문제가 있었습니다.
+- 인증수단 표기는 loginId 기반 추론에 의존해 SMS 요청이 Email로 표시되는 문제가 있었습니다.
+
+## 원인
+- verify-only 적용 범위가 magic link에 한정되어 있었고, Ory 코드 기반 경로는 세션을 즉시 발급했습니다.
+- audit 로그의 인증수단 표기는 request_body/loginId 기반 추론만 사용했습니다.
+
+## 변경 사항
+### 1) verify-only 범위 확장
+- `/api/v1/auth/login/code/verify`, `/api/v1/auth/login/code/verify-short`에 `verifyOnly` 지원 추가
+- verify-only일 때는 승인 상태만 저장하고 세션 발급은 Polling(Desktop)에서 수행
+
+### 2) Polling 시 세션 발급 주체 정리
+- 승인 상태(`status=approved`)는 **요청한 기기(A)**에서만 세션 발급
+- Ory 코드 플로우는 Polling 시점에 `VerifyLoginCode`를 수행해 세션 생성
+
+### 3) 인증수단 표기 개선
+- `pendingRef` 기준으로 `login_method`(sms/email), `login_flow`(code/link) 저장
+- audit 로그에 해당 메타를 기록하여 SMS/Email, 코드/링크 구분을 명확히 표시
+- verify-only 요청 로그는 타임라인에서 제외
+
+## 영향 범위
+- Backend: 링크 로그인 승인/세션 발급 경로 변경
+- Front: verify-only 플래그 전달 확장
+- 문서: auth-flow/test-plan 업데이트
+
+## 테스트 계획 (요약)
+- Desktop에서 링크 요청 → Mobile에서 링크 클릭(verifyOnly) → Desktop Polling으로 세션 발급
+- Mobile 단말에서 세션/로그인 이력 미생성 확인
+- 인증수단 표기(SMS/Email) 정확성 확인
+- 코드/링크 만료/재사용 시나리오 점검
diff --git a/docs/test-plan.md b/docs/test-plan.md
new file mode 100644
index 00000000..f9546356
--- /dev/null
+++ b/docs/test-plan.md
@@ -0,0 +1,132 @@
+# 테스트 계획 및 원칙 (Baron SSO)
+
+## 1) 목적
+- 인증/인가 핵심 플로우의 안정성과 회귀 방지
+- 멀티 서비스(Backend/Ory Stack/Front) 연동 품질 확보
+- 릴리즈 기준과 장애 분석 기준의 표준화
+
+## 2) 범위
+### 포함
+- Backend (Go Fiber)
+- UserFront (Flutter Web/App)
+- AdminFront / DevFront (React)
+- Ory Stack (Kratos/Hydra/Keto/Oathkeeper)
+- Gateway/네트워크 구성 (baron_net, ory-net, public_net)
+- DB (PostgreSQL, ClickHouse, Redis)
+
+### 제외(별도 계획)
+- 외부 IDP 벤더의 장애 대응 (Descope 등)
+- 프로덕션 데이터 복구 시나리오(백업/DR)
+
+## 3) 원칙
+- **Shift-left**: 개발 단계에서 최대한 조기 검증
+- **단계적 신뢰**: Unit → Integration → E2E 순으로 신뢰도 상승
+- **환경 분리**: 로컬/스테이징/프로덕션 구성 차이를 문서로 명시
+- **결정적 테스트**: 시간/랜덤/외부 의존성 최소화
+- **Idempotent**: 반복 실행 시 동일 결과 보장
+- **보안 우선**: 민감정보(PII/Token)는 테스트 로그에 노출 금지
+- **실패 우선 기록**: 실패 로그/재현 절차를 우선 확보
+
+## 4) 테스트 레이어 및 목표
+### 4.1 Unit Test
+- Backend: 비즈니스 로직, 유효성 검증, Mapper/Adapter
+- Frontend: 유틸/상태관리/컴포넌트 로직
+- 목표: 빠른 피드백(수초~수분)
+
+### 4.2 Integration Test
+- Backend + DB(Postgres/ClickHouse/Redis)
+- Backend + Ory Admin API (Kratos/Hydra/Keto)
+- 목표: 네트워크/스토리지 연동 검증
+
+### 4.3 Contract Test
+- Backend ↔ Frontend API 스키마/응답 계약 검증
+- OIDC/OpenID Connect 표준 응답 형식 검증
+
+### 4.4 E2E Test (Happy/Edge Path)
+- 로그인 플로우(Password / Magic Link / SMS / QR)
+- Consent 플로우 (Hydra login/consent)
+- 토큰 발급/재발급/로그아웃/세션 만료
+- 목표: 핵심 사용자 여정의 회귀 방지
+
+### 4.5 Smoke Test
+- 배포 직후 필수 엔드포인트 헬스체크
+- `GET /health`, Ory readiness, UserFront 정적 리소스
+
+### 4.6 Regression / Non-functional
+- 성능: 로그인/토큰 발급 지연, 대량 감사 로그 적재
+- 보안: 인증 우회, 권한 상승, 세션 고정 공격
+- 관측성: 핵심 로그/메트릭 누락 여부
+
+## 5) 환경 전략
+- 로컬: `make up-all` 또는 `docker compose -f compose.infra.yaml -f compose.ory.yaml -f docker-compose.yaml up -d`
+- 스테이징: 프로덕션과 동일한 네트워크/도메인 구성
+- 프로덕션: 최소한의 smoke/관측성 점검
+
+## 6) 테스트 데이터 정책
+- 표준 시드 사용자/테넌트/클라이언트 세트 정의
+- PII 마스킹 규칙(이메일/전화번호/토큰)
+- 재현용 고정 데이터와 랜덤 데이터 분리
+- 테스트 종료 후 클린업 규칙 정의
+
+## 7) 자동화 및 CI/CD 기준 (현행)
+- **현재 상태**: 레포에 CI/CD 워크플로우 정의가 없음. 테스트는 로컬/수동 실행 기준으로 운영.
+- **CI 변수 활용**: AdminFront/DevFront Playwright 설정은 `CI` 환경 변수에 따라 재시도/워커 수를 조정함.
+- **수동 실행 기준**:
+ - Backend: `go test ./...` (위치: `backend/`)
+ - UserFront: `flutter test` (위치: `userfront/`)
+ - AdminFront: `npm test` (Playwright, 위치: `adminfront/`, baseURL `http://localhost:5173`)
+ - DevFront: `npm test` (Playwright, 위치: `devfront/`, baseURL `http://localhost:5174`)
+
+### 7.1 수동 게이트 제안(현행 기준)
+- PR/머지 전 최소 기준: Backend Unit + 해당 Front 테스트(변경 범위)
+- 배포 전 최소 기준: Smoke + 핵심 E2E(로그인/Consent)
+
+## 8) 핵심 플로우 테스트 시나리오
+### 인증/세션
+- Password 로그인 성공/실패/락/재시도
+- Magic Link 발송/검증/만료
+- SMS 코드 발송/검증/재시도 제한
+- QR 승인/거절/타임아웃
+- 로그아웃 시 세션/쿠키/토큰 무효화
+
+### 원격 링크 로그인(verify-only)
+- Desktop에서 링크 요청 → Mobile에서 링크 클릭(verifyOnly) → Desktop Poll로 세션 발급
+- Mobile 단말에 세션 생성/로그인이 발생하지 않는지 확인
+- Audit/로그인 이력에 Desktop 세션 ID만 기록되는지 확인
+- 인증수단 표기(SMS/Email)가 요청 수단과 일치하는지 확인
+- 코드/링크 만료 시 승인 실패 및 재요청 안내
+
+### OIDC/Hydra
+- Login Challenge 처리
+- Consent 승인/거절
+- Token/Refresh Token 발급
+- Redirect URI 검증
+
+### 권한/정책(Keto)
+- 권한 부여/회수 시 접근 제어 확인
+- 관리자/일반 사용자 분리
+
+### 네트워크/프록시
+- `baron_net`와 `ory-net` 경계 준수
+- Frontend에서 Ory 내부 Admin 포트 접근 불가
+
+## 9) 관측성/장애 대응 테스트
+- 에러 로그 구조(필수 필드 포함) 확인
+- Audit Log 누락/중복 체크
+- 실패 시 재시도 정책 검증
+
+## 10) 책임 및 운영 프로세스
+- 각 영역별 오너 지정(Backend/Front/Ory)
+- 실패 시 triage 기준: 재현 가능 여부 → 영향도 → 우선순위
+- 테스트 케이스/기대 결과는 이슈/PR에 링크
+
+## 11) 유지보수 원칙
+- 신규 기능은 반드시 관련 테스트 추가
+- 회귀 버그 발생 시 재현 테스트를 우선 추가
+- 불안정 테스트는 원인 분석 후 격리 또는 개선
+
+## 12) 체크리스트 (릴리즈 전)
+- Smoke 통과
+- 핵심 E2E 통과
+- 보안 관련 회귀 없음
+- 장애/모니터링 대시보드 정상
diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart
index c566d7f9..8389815f 100644
--- a/userfront/lib/core/services/auth_proxy_service.dart
+++ b/userfront/lib/core/services/auth_proxy_service.dart
@@ -144,12 +144,18 @@ class AuthProxyService {
}
}
- static Future