From 60df7ba9041c5fb86c5f3c3c9d2f14285533543e Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Fri, 30 Jan 2026 11:16:09 +0900 Subject: [PATCH] =?UTF-8?q?userfront=20=EC=9D=B4=EB=A0=A5=20session=20ID?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=9E=91=EC=97=85=20=EC=99=84=EB=A3=8C.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/domain/idp_models.go | 1 + backend/internal/domain/models.go | 2 + backend/internal/handler/auth_handler.go | 441 ++++++++++- .../internal/middleware/audit_middleware.go | 24 +- backend/internal/service/descope_service.go | 3 + backend/internal/service/dry_run_service.go | 18 + backend/internal/service/ory_service.go | 4 + userfront/lib/core/ui/layout_breakpoints.dart | 1 + .../presentation/dashboard_screen.dart | 288 +++++-- .../presentation/pages/edit_profile_page.dart | 251 ------ .../presentation/pages/profile_page.dart | 722 ++++++++++++++++-- userfront/lib/main.dart | 32 +- 12 files changed, 1389 insertions(+), 398 deletions(-) create mode 100644 backend/internal/service/dry_run_service.go create mode 100644 userfront/lib/core/ui/layout_breakpoints.dart delete mode 100644 userfront/lib/features/profile/presentation/pages/edit_profile_page.dart diff --git a/backend/internal/domain/idp_models.go b/backend/internal/domain/idp_models.go index e043977f..43aadec3 100644 --- a/backend/internal/domain/idp_models.go +++ b/backend/internal/domain/idp_models.go @@ -42,6 +42,7 @@ type PasswordPolicy struct { type Token struct { JWT string Expiration time.Time + SessionID string } // AuthInfo contains authentication information after a successful login. diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go index d87881bd..18244817 100644 --- a/backend/internal/domain/models.go +++ b/backend/internal/domain/models.go @@ -10,8 +10,10 @@ type AuditLog struct { EventID string `json:"event_id"` Timestamp time.Time `json:"timestamp"` UserID string `json:"user_id"` + SessionID string `json:"session_id,omitempty"` EventType string `json:"event_type"` // e.g., "login_success", "login_failed", "otp_sent" Status string `json:"status"` // e.g., "success", "failure" + AuthMethod string `json:"auth_method,omitempty"` IPAddress string `json:"ip_address"` UserAgent string `json:"user_agent"` DeviceID string `json:"device_id,omitempty"` diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 142f54f8..f931f01a 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -7,6 +7,7 @@ import ( "bytes" "context" crand "crypto/rand" + "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -41,6 +42,8 @@ const ( prefixLoginCodeQr = "login_code_qr:" prefixPollMeta = "poll_meta:" prefixQrRef = "qr_ref:" + prefixQrMeta = "qr_meta:" + prefixQrApproverSession = "qr_approver_session:" prefixQrPending = "qr_pending:" prefixSignupEmail = "signup:email:" prefixSignupPhone = "signup:phone:" @@ -605,6 +608,8 @@ func (h *AuthHandler) VerifySms(c *fiber.Ctx) error { if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) } + c.Locals("login_id", loginID) + setSessionIDLocal(c, authInfo.SessionToken) return c.JSON(fiber.Map{ "token": authInfo.SessionToken.JWT, @@ -855,6 +860,8 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) } sessionToken := authInfo.SessionToken.JWT + c.Locals("login_id", loginID) + setSessionIDLocal(c, authInfo.SessionToken) slog.Info("[Verify] Success! Updating Redis session", "pendingRef", pendingRef) sessionData, _ := json.Marshal(map[string]string{ @@ -912,6 +919,8 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) } + c.Locals("login_id", lookupLoginID) + setSessionIDLocal(c, authInfo.SessionToken) h.RedisService.Delete(prefixLoginCode + lookupLoginID) h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID) @@ -995,6 +1004,8 @@ func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error { if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) } + c.Locals("login_id", payload.LoginID) + setSessionIDLocal(c, authInfo.SessionToken) h.RedisService.Delete(prefixLoginCode + payload.LoginID) h.RedisService.Delete(prefixLoginCodeShort + shortCode) @@ -1080,6 +1091,7 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { ale.Status = fiber.StatusOK ale.LatencyMs = time.Since(startTime) ale.SessionJwt = authInfo.SessionToken.JWT + setSessionIDLocal(c, authInfo.SessionToken) ale.Log(slog.LevelInfo, "Login successful", slog.String("provider", h.IdpProvider.Name()), slog.String("subject", authInfo.Subject)) resp := fiber.Map{ @@ -1430,6 +1442,7 @@ func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error { // Redis에 초기 상태 저장 (5분 만료) h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), 5*time.Minute) h.RedisService.Set(prefixQrRef+qrRef, pendingRef, 5*time.Minute) + h.storeQrMeta(pendingRef, c) return c.JSON(fiber.Map{ "qrCode": qrPayload, // 프론트엔드에서 이 텍스트로 QR을 생성하거나, 이미지를 반환 @@ -1514,6 +1527,9 @@ func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error { slog.Warn("[QR] Cookie session invalid", "error", err) return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) } + if sessionID, err := h.getKratosSessionIDWithCookie(cookie); err == nil && sessionID != "" { + h.storeQrApproverSessionID(pendingRef, sessionID) + } loginID := pickLoginIDFromTraits(traits) if loginID == "" { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) @@ -1529,18 +1545,30 @@ func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error { } // 2. 모바일 토큰은 승인 검증용으로만 사용하고, 웹 전용 세션을 새로 발급 - if sessionToken, err := h.tryIssueDescopeQrSession(c, req.Token); err != nil { + if sessionToken, loginID, approvedSessionID, err := h.tryIssueDescopeQrSession(c, req.Token); err != nil { slog.Error("[QR] Issue web session failed", "error", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue web session"}) - } else if sessionToken != "" { + } else if sessionToken != nil && sessionToken.JWT != "" { + h.storeQrApproverSessionID(pendingRef, approvedSessionID) + h.writeQrAuditLog(loginID, pendingRef, sessionToken, approvedSessionID) sessionData, _ := json.Marshal(map[string]string{ "status": statusSuccess, - "jwt": sessionToken, + "jwt": sessionToken.JWT, }) h.RedisService.Set(prefixSession+pendingRef, string(sessionData), 5*time.Minute) return c.JSON(fiber.Map{"message": "QR Login Approved"}) } + approvedSessionID := "" + if req.Token != "" { + if sessionID, err := h.getKratosSessionID(req.Token); err == nil { + approvedSessionID = sessionID + } + } + if approvedSessionID != "" { + h.storeQrApproverSessionID(pendingRef, approvedSessionID) + } + loginID, err := h.resolveKratosLoginID(req.Token) if err != nil { slog.Warn("[QR] Invalid token", "error", err) @@ -1613,6 +1641,7 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { "jwt": authInfo.SessionToken.JWT, }) h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration) + h.writeQrAuditLog(loginID, pendingRef, authInfo.SessionToken, "") h.RedisService.Delete(prefixLoginCodeQrPending + loginID) h.RedisService.Delete(prefixLoginCode + loginID) h.RedisService.Delete(prefixLoginCodeQr + pendingRef) @@ -1640,6 +1669,7 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { "jwt": authInfo.SessionToken.JWT, }) h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration) + h.writeQrAuditLog(loginID, pendingRef, authInfo.SessionToken, "") h.RedisService.Delete(prefixQrPending + loginID) h.RedisService.Delete(prefixLoginCode + loginID) h.RedisService.Delete(prefixLoginCodeSmsTarget + loginID) @@ -2083,6 +2113,176 @@ func looksLikeJWT(token string) bool { return strings.Count(token, ".") == 2 } +func setSessionIDLocal(c *fiber.Ctx, token *domain.Token) { + if c == nil || token == nil { + return + } + if sessionID := extractSessionIDFromToken(token); sessionID != "" { + c.Locals("session_id", sessionID) + } +} + +func extractSessionIDFromToken(token *domain.Token) string { + if token == nil { + return "" + } + if token.SessionID != "" { + return token.SessionID + } + if token.JWT != "" { + return extractSessionIDFromJWT(token.JWT) + } + return "" +} + +func extractSessionIDFromJWT(token string) string { + if !looksLikeJWT(token) { + return "" + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "" + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + payload, err = base64.URLEncoding.DecodeString(parts[1]) + if err != nil { + return "" + } + } + var claims map[string]any + if err := json.Unmarshal(payload, &claims); err != nil { + return "" + } + for _, key := range []string{"sid", "session_id", "sessionId", "jti"} { + if raw, ok := claims[key]; ok { + switch value := raw.(type) { + case string: + if value != "" { + return value + } + default: + return fmt.Sprint(value) + } + } + } + return "" +} + +type qrMeta struct { + IPAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` +} + +func (h *AuthHandler) storeQrMeta(pendingRef string, c *fiber.Ctx) { + 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(prefixQrMeta+pendingRef, string(raw), 5*time.Minute) +} + +func (h *AuthHandler) loadQrMeta(pendingRef string) (qrMeta, bool) { + if h.RedisService == nil || pendingRef == "" { + return qrMeta{}, false + } + val, err := h.RedisService.Get(prefixQrMeta + pendingRef) + if err != nil || val == "" { + return qrMeta{}, false + } + var meta qrMeta + if err := json.Unmarshal([]byte(val), &meta); err != nil { + return qrMeta{}, false + } + return meta, true +} + +func (h *AuthHandler) storeQrApproverSessionID(pendingRef, sessionID string) { + if h.RedisService == nil || pendingRef == "" || sessionID == "" { + return + } + _ = h.RedisService.Set(prefixQrApproverSession+pendingRef, sessionID, loginCodeExpiration) +} + +func (h *AuthHandler) loadQrApproverSessionID(pendingRef string) string { + if h.RedisService == nil || pendingRef == "" { + return "" + } + val, err := h.RedisService.Get(prefixQrApproverSession + pendingRef) + if err != nil { + return "" + } + return strings.TrimSpace(val) +} + +func (h *AuthHandler) writeQrAuditLog(loginID, pendingRef string, sessionToken *domain.Token, approvedSessionID string) { + if h.AuditRepo == nil || pendingRef == "" { + return + } + meta, ok := h.loadQrMeta(pendingRef) + if !ok { + meta = qrMeta{ + IPAddress: "", + UserAgent: "", + } + } + if approvedSessionID == "" { + approvedSessionID = h.loadQrApproverSessionID(pendingRef) + } + sessionID := extractSessionIDFromToken(sessionToken) + details := map[string]any{ + "path": "/api/v1/auth/qr/approve", + "login_id": loginID, + "pending_ref": pendingRef, + } + if sessionID != "" { + details["session_id"] = sessionID + } + if approvedSessionID != "" { + details["approved_session_id"] = approvedSessionID + } + detailsJSON, _ := json.Marshal(details) + + log := &domain.AuditLog{ + EventID: GenerateSecureToken(16), + Timestamp: time.Now(), + UserID: "", + SessionID: sessionID, + EventType: "POST /api/v1/auth/qr/approve", + Status: "success", + IPAddress: meta.IPAddress, + UserAgent: meta.UserAgent, + Details: string(detailsJSON), + AuthMethod: "QR", + } + _ = h.AuditRepo.Create(log) +} + +func extractClientIPFromHeaders(c *fiber.Ctx) string { + if c == nil { + return "" + } + if forwarded := c.Get("X-Forwarded-For"); forwarded != "" { + parts := strings.Split(forwarded, ",") + if len(parts) > 0 { + if ip := strings.TrimSpace(parts[0]); ip != "" { + return ip + } + } + } + if realIP := strings.TrimSpace(c.Get("X-Real-IP")); realIP != "" { + return realIP + } + return c.IP() +} + func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { if h.AuditRepo == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Audit service unavailable"}) @@ -2126,6 +2326,13 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { 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 { break @@ -2182,6 +2389,141 @@ func isAuthEventType(eventType string) bool { return strings.Contains(normalized, " /api/v1/auth/") } +func extractAuditPath(log domain.AuditLog) string { + if log.Details != "" { + if payload, err := parseAuditDetails(log.Details); err == nil { + if path, ok := payload["path"].(string); ok && path != "" { + return path + } + } + } + parts := strings.SplitN(log.EventType, " ", 2) + if len(parts) == 2 { + return strings.TrimSpace(parts[1]) + } + return "" +} + +func parseAuditDetails(details string) (map[string]any, error) { + var payload map[string]any + if details == "" { + return nil, fmt.Errorf("empty details") + } + if err := json.Unmarshal([]byte(details), &payload); err != nil { + return nil, err + } + return payload, nil +} + +func extractRequestBody(details map[string]any) map[string]any { + if details == nil { + return nil + } + raw, ok := details["request_body"].(string) + if !ok || raw == "" { + return nil + } + var body map[string]any + if err := json.Unmarshal([]byte(raw), &body); err != nil { + return nil + } + return body +} + +func loginIDKind(loginID string) string { + normalized := strings.TrimSpace(loginID) + if normalized == "" { + return "" + } + if strings.Contains(normalized, "@") { + return "email" + } + return "phone" +} + +func deriveAuthMethod(log domain.AuditLog) string { + path := strings.ToLower(extractAuditPath(log)) + if path == "" { + return "" + } + + loginID := extractLoginIDFromAuditDetails(log.Details) + kind := loginIDKind(loginID) + details, _ := parseAuditDetails(log.Details) + requestBody := extractRequestBody(details) + + switch { + case strings.Contains(path, "/api/v1/auth/password/login"): + if kind == "email" { + return "비밀번호(Email)" + } + if kind == "phone" { + return "비밀번호(전화번호)" + } + return "비밀번호" + case strings.Contains(path, "/api/v1/auth/enchanted-link/init"): + if requestBody != nil { + if raw, ok := requestBody["codeOnly"]; ok { + if value, ok := raw.(bool); ok && value { + if kind == "phone" { + return "코드(SMS)" + } + if kind == "email" { + return "코드(Email)" + } + return "코드" + } + } + } + if requestBody != nil { + if raw, ok := requestBody["method"].(string); ok { + method := strings.ToLower(strings.TrimSpace(raw)) + if method == "sms" { + return "링크(SMS)" + } + if method == "email" { + return "링크(Email)" + } + } + } + if kind == "phone" { + return "링크(SMS)" + } + if kind == "email" { + return "링크(Email)" + } + return "링크" + case strings.Contains(path, "/api/v1/auth/magic-link/verify"): + if kind == "phone" { + return "링크(SMS)" + } + if kind == "email" { + return "링크(Email)" + } + return "링크" + case strings.Contains(path, "/api/v1/auth/login/code/verify"): + if kind == "phone" { + return "코드(SMS)" + } + if kind == "email" { + return "코드(Email)" + } + return "코드" + case strings.Contains(path, "/api/v1/auth/login/code/verify-short"): + return "코드(간편)" + case strings.Contains(path, "/api/v1/auth/verify-sms"): + return "코드(SMS)" + case strings.Contains(path, "/api/v1/auth/qr/approve"): + return "QR" + case strings.Contains(path, "/api/v1/auth/qr/init"): + return "QR" + case strings.Contains(path, "/api/v1/auth/qr/poll"): + return "QR" + default: + return "" + } +} + func buildLoginCandidates(profile *domain.UserProfileResponse) map[string]struct{} { candidates := make(map[string]struct{}) if profile == nil { @@ -2256,6 +2598,25 @@ func extractLoginIDFromAuditDetails(details string) string { return "" } +func extractSessionIDFromAuditDetails(details string) string { + if details == "" { + return "" + } + var payload map[string]any + if err := json.Unmarshal([]byte(details), &payload); err != nil { + return "" + } + if raw, ok := payload["session_id"]; ok { + switch value := raw.(type) { + case string: + return value + default: + return fmt.Sprint(value) + } + } + return "" +} + func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, error) { if looksLikeJWT(token) && h.DescopeClient != nil { authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) @@ -2267,26 +2628,26 @@ func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, err return id, err } -func (h *AuthHandler) tryIssueDescopeQrSession(c *fiber.Ctx, token string) (string, error) { +func (h *AuthHandler) tryIssueDescopeQrSession(c *fiber.Ctx, token string) (*domain.Token, string, string, error) { if !looksLikeJWT(token) || h.DescopeClient == nil { - return "", nil + return nil, "", "", nil } authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) if err != nil || !authorized { - return "", nil + return nil, "", "", nil } loginID, err := h.resolveDescopeLoginID(c.Context(), userToken) if err != nil { - return "", err + return nil, "", "", err } authInfo, err := h.IdpProvider.IssueSession(loginID) if err != nil { - return "", err + return nil, "", "", err } if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { - return "", fmt.Errorf("descope issue session returned empty token") + return nil, "", "", fmt.Errorf("descope issue session returned empty token") } - return authInfo.SessionToken.JWT, nil + return authInfo.SessionToken, loginID, userToken.ID, nil } func (h *AuthHandler) resolveKratosLoginID(token string) (string, error) { @@ -2544,6 +2905,36 @@ func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string return result.Identity.ID, result.Identity.Traits, nil } +func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) { + kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") + if kratosURL == "" { + kratosURL = "http://kratos:4433" + } + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil) + if err != nil { + return "", err + } + req.Header.Set("X-Session-Token", sessionToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body)) + } + + var result struct { + ID string `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + return result.ID, nil +} + func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string) (string, error) { if identityID == "" { return "", fmt.Errorf("kratos identity id is empty") @@ -2621,6 +3012,36 @@ func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[st return result.Identity.ID, result.Identity.Traits, nil } +func (h *AuthHandler) getKratosSessionIDWithCookie(cookie string) (string, error) { + kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") + if kratosURL == "" { + kratosURL = "http://kratos:4433" + } + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil) + if err != nil { + return "", err + } + req.Header.Set("Cookie", cookie) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body)) + } + + var result struct { + ID string `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + return result.ID, nil +} + func (h *AuthHandler) updateKratosIdentity(identityID string, traits map[string]interface{}) error { kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/") if kratosAdminURL == "" { diff --git a/backend/internal/middleware/audit_middleware.go b/backend/internal/middleware/audit_middleware.go index a010b8eb..a88982c0 100644 --- a/backend/internal/middleware/audit_middleware.go +++ b/backend/internal/middleware/audit_middleware.go @@ -7,6 +7,7 @@ import ( "fmt" "log/slog" "reflect" + "strings" "sync" "time" @@ -117,6 +118,8 @@ func AuditMiddleware(config AuditConfig) fiber.Handler { userID, _ := c.Locals("user_id").(string) loginID, _ := c.Locals("login_id").(string) tenantID, _ := c.Locals("tenant_id").(string) + sessionID, _ := c.Locals("session_id").(string) + clientIP := extractClientIP(c) // 6. Capture & Mask Body var maskedBody string @@ -141,6 +144,9 @@ func AuditMiddleware(config AuditConfig) fiber.Handler { "tenant_id": tenantID, "request_body": maskedBody, } + if sessionID != "" { + details["session_id"] = sessionID + } if err != nil { details["error"] = err.Error() } @@ -152,9 +158,10 @@ func AuditMiddleware(config AuditConfig) fiber.Handler { EventID: reqID, Timestamp: start, UserID: userID, + SessionID: sessionID, EventType: fmt.Sprintf("%s %s", c.Method(), c.Path()), Status: statusText, - IPAddress: c.IP(), + IPAddress: clientIP, UserAgent: c.Get("User-Agent"), Details: string(detailsJSON), } @@ -190,3 +197,18 @@ func AuditMiddleware(config AuditConfig) fiber.Handler { return err } } + +func extractClientIP(c *fiber.Ctx) string { + if forwarded := c.Get("X-Forwarded-For"); forwarded != "" { + parts := strings.Split(forwarded, ",") + if len(parts) > 0 { + if ip := strings.TrimSpace(parts[0]); ip != "" { + return ip + } + } + } + if realIP := strings.TrimSpace(c.Get("X-Real-IP")); realIP != "" { + return realIP + } + return c.IP() +} diff --git a/backend/internal/service/descope_service.go b/backend/internal/service/descope_service.go index 42d0bab5..b86b58bd 100644 --- a/backend/internal/service/descope_service.go +++ b/backend/internal/service/descope_service.go @@ -133,6 +133,7 @@ func (d *DescopeProvider) SignIn(loginID, password string) (*domain.AuthInfo, er SessionToken: &domain.Token{ JWT: authInfo.SessionToken.JWT, Expiration: time.Unix(authInfo.SessionToken.Expiration, 0), + SessionID: authInfo.SessionToken.ID, }, Subject: authInfo.User.UserID, } @@ -201,6 +202,7 @@ func (d *DescopeProvider) IssueSession(loginID string) (*domain.AuthInfo, error) SessionToken: &domain.Token{ JWT: authInfo.SessionToken.JWT, Expiration: time.Unix(authInfo.SessionToken.Expiration, 0), + SessionID: authInfo.SessionToken.ID, }, Subject: authInfo.User.UserID, } @@ -276,6 +278,7 @@ func (d *DescopeProvider) VerifyPasswordResetToken(token string) (*domain.AuthIn SessionToken: &domain.Token{ JWT: authInfo.SessionToken.JWT, Expiration: time.Unix(authInfo.SessionToken.Expiration, 0), + SessionID: authInfo.SessionToken.ID, }, } if authInfo.RefreshToken != nil { diff --git a/backend/internal/service/dry_run_service.go b/backend/internal/service/dry_run_service.go new file mode 100644 index 00000000..27d26290 --- /dev/null +++ b/backend/internal/service/dry_run_service.go @@ -0,0 +1,18 @@ +package service + +import ( + "os" + "strings" +) + +func IsProductionEnv() bool { + env := strings.ToLower(os.Getenv("APP_ENV")) + if env == "" { + env = strings.ToLower(os.Getenv("GO_ENV")) + } + return env == "prod" || env == "production" +} + +func IsDryRunAllowed() bool { + return !IsProductionEnv() +} diff --git a/backend/internal/service/ory_service.go b/backend/internal/service/ory_service.go index 9bc54e4f..ff04d678 100644 --- a/backend/internal/service/ory_service.go +++ b/backend/internal/service/ory_service.go @@ -182,6 +182,7 @@ func (o *OryProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) SessionToken string `json:"session_token"` SessionTokenExpiresAt time.Time `json:"session_token_expires_at"` Session struct { + ID string `json:"id"` Identity struct { ID string `json:"id"` } `json:"identity"` @@ -204,6 +205,7 @@ func (o *OryProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) SessionToken: &domain.Token{ JWT: result.SessionToken, Expiration: result.SessionTokenExpiresAt, + SessionID: result.Session.ID, }, Subject: result.Session.Identity.ID, }, nil @@ -626,6 +628,7 @@ func (o *OryProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.Aut SessionToken string `json:"session_token"` SessionTokenExpiresAt time.Time `json:"session_token_expires_at"` Session struct { + ID string `json:"id"` Identity struct { ID string `json:"id"` } `json:"identity"` @@ -648,6 +651,7 @@ func (o *OryProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.Aut SessionToken: &domain.Token{ JWT: result.SessionToken, Expiration: result.SessionTokenExpiresAt, + SessionID: result.Session.ID, }, Subject: result.Session.Identity.ID, }, nil diff --git a/userfront/lib/core/ui/layout_breakpoints.dart b/userfront/lib/core/ui/layout_breakpoints.dart new file mode 100644 index 00000000..b0411d93 --- /dev/null +++ b/userfront/lib/core/ui/layout_breakpoints.dart @@ -0,0 +1 @@ +const double sideMenuBreakpoint = 1400; diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index d7dd2886..d850e9a8 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:descope/descope.dart'; import 'package:go_router/go_router.dart'; @@ -8,6 +9,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/services/auth_token_store.dart'; import '../../../../core/services/http_client.dart'; +import '../../../../core/ui/layout_breakpoints.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; class AuditLogEntry { @@ -16,7 +18,10 @@ class AuditLogEntry { final String userId; final String eventType; final String status; + final String authMethod; final String ipAddress; + final String userAgent; + final String sessionId; final String details; AuditLogEntry({ @@ -25,7 +30,10 @@ class AuditLogEntry { required this.userId, required this.eventType, required this.status, + required this.authMethod, required this.ipAddress, + required this.userAgent, + required this.sessionId, required this.details, }); @@ -44,7 +52,10 @@ class AuditLogEntry { userId: json['user_id'] ?? '', eventType: json['event_type'] ?? '', status: json['status'] ?? '', + authMethod: json['auth_method'] ?? '', ipAddress: json['ip_address'] ?? '', + userAgent: json['user_agent'] ?? '', + sessionId: json['session_id'] ?? '', details: json['details'] ?? '', ); } @@ -105,6 +116,58 @@ class _DashboardScreenState extends ConsumerState { context.push('/scan'); } + Widget _buildSideMenu(BuildContext context, {required bool closeOnTap}) { + return SafeArea( + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 12), + children: [ + ListTile( + leading: const Icon(Icons.home_outlined), + title: const Text('대시보드'), + selected: true, + onTap: () { + if (closeOnTap) { + Navigator.of(context).pop(); + } + context.go('/'); + }, + ), + ListTile( + leading: const Icon(Icons.person_outline), + title: const Text('내 정보'), + onTap: () { + if (closeOnTap) { + Navigator.of(context).pop(); + } + context.push('/profile'); + }, + ), + ListTile( + leading: const Icon(Icons.qr_code_scanner), + title: const Text('QR 스캔'), + onTap: () { + if (closeOnTap) { + Navigator.of(context).pop(); + } + _onScanQR(); + }, + ), + const Divider(), + ListTile( + leading: const Icon(Icons.logout), + title: const Text('로그아웃'), + onTap: () async { + if (closeOnTap) { + Navigator.of(context).pop(); + } + await _logout(); + }, + ), + ], + ), + ); + } + Future _refreshAll() async { await ref.read(profileProvider.notifier).loadProfile(); setState(() { @@ -202,6 +265,95 @@ class _DashboardScreenState extends ConsumerState { return provider; } + String _deviceLabelFromUserAgent(String userAgent) { + if (userAgent.isEmpty) { + return '-'; + } + final ua = userAgent.toLowerCase(); + if (ua.contains('iphone') || ua.contains('ipad') || ua.contains('ipod')) { + return 'Mobile(iOS)'; + } + if (ua.contains('android')) { + return 'Mobile(Android)'; + } + if (ua.contains('windows')) { + return 'Desktop(Windows)'; + } + if (ua.contains('mac os x') || ua.contains('macintosh')) { + return 'Desktop(macOS)'; + } + if (ua.contains('linux')) { + return 'Desktop(Linux)'; + } + return 'Unknown'; + } + + Widget _buildAuthMethodCell(AuditLogEntry log, String authMethod) { + if (authMethod != 'QR') { + return Text(authMethod); + } + final approvedSessionId = log.detailMap['approved_session_id']?.toString() ?? ''; + final tooltip = approvedSessionId.isEmpty + ? '승인한 세션 ID 없음' + : '승인한 세션 ID: $approvedSessionId\n클릭하면 복사됩니다.'; + return InkWell( + onTap: approvedSessionId.isEmpty + ? null + : () async { + await Clipboard.setData(ClipboardData(text: approvedSessionId)); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('세션 ID가 복사되었습니다.')), + ); + } + }, + child: Tooltip( + message: tooltip, + child: Text( + 'QR', + style: TextStyle( + color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent, + decoration: + approvedSessionId.isEmpty ? null : TextDecoration.underline, + ), + ), + ), + ); + } + + Widget _buildAuthMethodLine(AuditLogEntry log, String authMethod) { + if (authMethod != 'QR') { + return Text('인증수단: $authMethod'); + } + final approvedSessionId = log.detailMap['approved_session_id']?.toString() ?? ''; + return InkWell( + onTap: approvedSessionId.isEmpty + ? null + : () async { + await Clipboard.setData(ClipboardData(text: approvedSessionId)); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('세션 ID가 복사되었습니다.')), + ); + } + }, + child: Tooltip( + message: approvedSessionId.isEmpty + ? '승인한 세션 ID 없음' + : '승인한 세션 ID: $approvedSessionId\n탭하면 복사됩니다.', + child: Text( + '인증수단: QR', + style: TextStyle( + color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent, + decoration: approvedSessionId.isEmpty + ? null + : TextDecoration.underline, + ), + ), + ), + ); + } + String _appLabelForPath(String path) { if (path.startsWith('/api/v1/auth')) { return 'Baron 통합로그인'; @@ -220,6 +372,7 @@ class _DashboardScreenState extends ConsumerState { @override Widget build(BuildContext context) { + final isWide = MediaQuery.of(context).size.width >= sideMenuBreakpoint; final profileState = ref.watch(profileProvider); final profile = profileState.value; final user = Descope.sessionManager.session?.user; @@ -261,70 +414,48 @@ class _DashboardScreenState extends ConsumerState { ), ], ), - drawer: Drawer( - child: SafeArea( - child: ListView( - padding: const EdgeInsets.symmetric(vertical: 12), - children: [ - ListTile( - leading: const Icon(Icons.person_outline), - title: const Text('내 정보'), - onTap: () { - Navigator.of(context).pop(); - context.push('/profile'); + drawer: isWide ? null : Drawer(child: _buildSideMenu(context, closeOnTap: true)), + body: Row( + children: [ + if (isWide) + SizedBox( + width: 240, + child: _buildSideMenu(context, closeOnTap: false), + ), + Expanded( + child: RefreshIndicator( + onRefresh: _refreshAll, + child: LayoutBuilder( + builder: (context, constraints) { + final timelineWide = constraints.maxWidth >= 900; + final isMobile = constraints.maxWidth < 600; + return SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isMobile) ...[ + _buildHeaderCard(userName, department, sessionIssuedAt), + const SizedBox(height: 28), + ], + _buildSectionTitle('활동상황', '현재 연결된 앱과 최근 인증 상태입니다.'), + const SizedBox(height: 12), + _buildActivityGrid(sessionIssuedAt, isMobile), + const SizedBox(height: 28), + _buildSectionTitle('접속이력', 'Baron 통합로그인 기준의 최근 접근 기록입니다.'), + const SizedBox(height: 12), + _buildAccessHistory(timelineWide), + ], + ), + ), + ); }, ), - ListTile( - leading: const Icon(Icons.qr_code_scanner), - title: const Text('QR 스캔'), - onTap: () { - Navigator.of(context).pop(); - _onScanQR(); - }, - ), - const Divider(), - ListTile( - leading: const Icon(Icons.logout), - title: const Text('로그아웃'), - onTap: () async { - Navigator.of(context).pop(); - await _logout(); - }, - ), - ], + ), ), - ), - ), - body: RefreshIndicator( - onRefresh: _refreshAll, - child: LayoutBuilder( - builder: (context, constraints) { - final isWide = constraints.maxWidth >= 900; - final isMobile = constraints.maxWidth < 600; - return SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isMobile) ...[ - _buildHeaderCard(userName, department, sessionIssuedAt), - const SizedBox(height: 28), - ], - _buildSectionTitle('활동상황', '현재 연결된 앱과 최근 인증 상태입니다.'), - const SizedBox(height: 12), - _buildActivityGrid(sessionIssuedAt, isMobile), - const SizedBox(height: 28), - _buildSectionTitle('접속이력', 'Baron 통합로그인 기준의 최근 접근 기록입니다.'), - const SizedBox(height: 12), - _buildAccessHistory(isWide), - ], - ), - ), - ); - }, - ), + ], ), ); } @@ -631,26 +762,30 @@ class _DashboardScreenState extends ConsumerState { columnSpacing: 16, horizontalMargin: 12, columns: const [ + DataColumn(label: Text('Session ID')), DataColumn(label: Text('접속일자')), - DataColumn(label: Text('어플리케이션')), - DataColumn(label: Text('접속 IP')), - DataColumn(label: Text('인증여부')), + DataColumn(label: Text('애플리케이션')), + DataColumn(label: Text('IP')), + DataColumn(label: Text('접속환경')), DataColumn(label: Text('인증수단')), + DataColumn(label: Text('인증결과')), DataColumn(label: Text('현황')), - DataColumn(label: Text('관리')), ], rows: logs.take(10).map((log) { final statusLabel = log.status == 'success' ? '성공' : '실패'; final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent; final appLabel = _appLabelForPath(log.path); + final authMethod = log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel(); + final deviceLabel = _deviceLabelFromUserAgent(log.userAgent); return DataRow(cells: [ + DataCell(Text(log.sessionId.isEmpty ? '-' : log.sessionId)), DataCell(Text(_formatDateTime(log.timestamp))), DataCell(Text(appLabel)), DataCell(Text(log.ipAddress.isEmpty ? '-' : log.ipAddress)), + DataCell(Text(deviceLabel)), + DataCell(_buildAuthMethodCell(log, authMethod)), DataCell(Text(statusLabel, style: TextStyle(color: statusColor, fontWeight: FontWeight.w600))), - DataCell(Text(_authMethodLabel())), - DataCell(Text(statusLabel == '성공' ? '활성' : '실패')), - const DataCell(Text('원격 로그아웃(준비중)', style: TextStyle(color: Colors.grey))), + const DataCell(Text('(준비중)', style: TextStyle(color: Colors.grey))), ]); }).toList(), ), @@ -668,6 +803,8 @@ class _DashboardScreenState extends ConsumerState { final statusLabel = log.status == 'success' ? '성공' : '실패'; final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent; final appLabel = _appLabelForPath(log.path); + final authMethod = log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel(); + final deviceLabel = _deviceLabelFromUserAgent(log.userAgent); return Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(12), @@ -694,18 +831,13 @@ class _DashboardScreenState extends ConsumerState { ], ), const SizedBox(height: 6), + Text('Session ID: ${log.sessionId.isEmpty ? '-' : log.sessionId}'), Text('접속일자: ${_formatDateTime(log.timestamp)}'), Text('접속 IP: ${log.ipAddress.isEmpty ? '-' : log.ipAddress}'), - Text('인증수단: ${_authMethodLabel()}'), - Text('관리: 원격 로그아웃(준비중)', style: TextStyle(color: Colors.grey[600])), - const SizedBox(height: 8), - Align( - alignment: Alignment.centerRight, - child: Text( - '원격 로그아웃 준비중', - style: TextStyle(color: Colors.grey[600], fontSize: 12), - ), - ), + Text('접속환경: $deviceLabel'), + _buildAuthMethodLine(log, authMethod), + Text('인증결과: $statusLabel'), + Text('현황: (준비중)', style: TextStyle(color: Colors.grey[600])), ], ), ); diff --git a/userfront/lib/features/profile/presentation/pages/edit_profile_page.dart b/userfront/lib/features/profile/presentation/pages/edit_profile_page.dart deleted file mode 100644 index ece0f193..00000000 --- a/userfront/lib/features/profile/presentation/pages/edit_profile_page.dart +++ /dev/null @@ -1,251 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import '../../domain/notifiers/profile_notifier.dart'; - -class EditProfilePage extends ConsumerStatefulWidget { - const EditProfilePage({super.key}); - - @override - ConsumerState createState() => _EditProfilePageState(); -} - -class _EditProfilePageState extends ConsumerState { - final _formKey = GlobalKey(); - late TextEditingController _nameController; - late TextEditingController _phoneController; - late TextEditingController _codeController; - late TextEditingController _departmentController; - - String? _initialPhone; - bool _isPhoneChanged = false; - bool _isPhoneVerified = false; - bool _isCodeSent = false; - bool _isVerifying = false; - - @override - void initState() { - super.initState(); - final profile = ref.read(profileProvider).value; - _initialPhone = profile?.phone ?? ''; - _nameController = TextEditingController(text: profile?.name ?? ''); - _phoneController = TextEditingController(text: _initialPhone); - _codeController = TextEditingController(); - _departmentController = TextEditingController(text: profile?.department ?? ''); - - _phoneController.addListener(() { - setState(() { - _isPhoneChanged = _phoneController.text != _initialPhone; - if (_isPhoneChanged) { - _isPhoneVerified = false; - } - }); - }); - } - - @override - void dispose() { - _nameController.dispose(); - _phoneController.dispose(); - _codeController.dispose(); - _departmentController.dispose(); - super.dispose(); - } - - Future _sendCode() async { - final phone = _phoneController.text; - if (phone.isEmpty) return; - - setState(() => _isVerifying = true); - try { - await ref.read(profileRepositoryProvider).sendUpdateCode(phone); - setState(() { - _isCodeSent = true; - _isVerifying = false; - }); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('인증번호가 전송되었습니다.')), - ); - } - } catch (e) { - setState(() => _isVerifying = false); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('전송 실패: $e')), - ); - } - } - } - - Future _verifyCode() async { - final phone = _phoneController.text; - final code = _codeController.text; - if (code.isEmpty) return; - - setState(() => _isVerifying = true); - try { - await ref.read(profileRepositoryProvider).verifyUpdateCode(phone, code); - setState(() { - _isPhoneVerified = true; - _isVerifying = false; - }); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('인증되었습니다.')), - ); - } - } catch (e) { - setState(() => _isVerifying = false); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('인증 실패: $e')), - ); - } - } - } - - Future _save() async { - if (!_formKey.currentState!.validate()) return; - if (_isPhoneChanged && !_isPhoneVerified) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('휴대폰 번호 인증이 필요합니다.')), - ); - return; - } - - try { - await ref.read(profileProvider.notifier).updateProfile( - name: _nameController.text, - phone: _phoneController.text, - department: _departmentController.text, - ); - if (mounted) { - context.pop(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('정보가 수정되었습니다.')), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('수정 실패: $e')), - ); - } - } - } - - @override - Widget build(BuildContext context) { - final profileState = ref.watch(profileProvider); - final isUpdating = profileState.isLoading; - - return Scaffold( - appBar: AppBar( - title: const Text('내 정보 수정'), - actions: [ - TextButton( - onPressed: (isUpdating || (_isPhoneChanged && !_isPhoneVerified)) ? null : _save, - child: const Text('저장'), - ), - ], - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Form( - key: _formKey, - child: ListView( - children: [ - TextFormField( - controller: _nameController, - decoration: const InputDecoration( - labelText: '이름', - prefixIcon: Icon(Icons.person_outline), - ), - validator: (value) => (value == null || value.isEmpty) ? '이름을 입력해주세요.' : null, - ), - const SizedBox(height: 24), - - // Phone Number Field - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: TextFormField( - controller: _phoneController, - decoration: InputDecoration( - labelText: '휴대폰 번호', - hintText: '01012345678', - prefixIcon: const Icon(Icons.phone_android), - suffixIcon: _isPhoneVerified - ? const Icon(Icons.check_circle, color: Colors.green) - : null, - ), - keyboardType: TextInputType.phone, - enabled: !_isPhoneVerified, - ), - ), - const SizedBox(width: 8), - if (_isPhoneChanged && !_isPhoneVerified) - ElevatedButton( - onPressed: _isVerifying ? null : _sendCode, - child: Text(_isCodeSent ? '재전송' : '인증요청'), - ), - ], - ), - - // OTP Code Field - if (_isCodeSent && !_isPhoneVerified) ...[ - const SizedBox(height: 16), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: TextFormField( - controller: _codeController, - decoration: const InputDecoration( - labelText: '인증번호', - hintText: '6자리 입력', - prefixIcon: Icon(Icons.security), - ), - keyboardType: TextInputType.number, - ), - ), - const SizedBox(width: 8), - ElevatedButton( - onPressed: _isVerifying ? null : _verifyCode, - style: ElevatedButton.styleFrom(backgroundColor: Colors.blue[700], foregroundColor: Colors.white), - child: const Text('확인'), - ), - ], - ), - ], - - if (_isPhoneChanged && !_isPhoneVerified) - const Padding( - padding: EdgeInsets.only(top: 8.0, left: 4.0), - child: Text( - '휴대폰 번호를 변경하려면 SMS 인증이 필요합니다.', - style: TextStyle(color: Colors.orange, fontSize: 12), - ), - ), - - const SizedBox(height: 24), - TextFormField( - controller: _departmentController, - decoration: const InputDecoration( - labelText: '소속 (부서)', - prefixIcon: Icon(Icons.business), - ), - ), - - const SizedBox(height: 40), - if (isUpdating || _isVerifying) - const Center(child: CircularProgressIndicator()), - ], - ), - ), - ), - ); - } -} diff --git a/userfront/lib/features/profile/presentation/pages/profile_page.dart b/userfront/lib/features/profile/presentation/pages/profile_page.dart index 145e06e2..92e1101d 100644 --- a/userfront/lib/features/profile/presentation/pages/profile_page.dart +++ b/userfront/lib/features/profile/presentation/pages/profile_page.dart @@ -1,71 +1,691 @@ +import 'package:descope/descope.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; +import '../../../../core/notifiers/auth_notifier.dart'; +import '../../../../core/services/auth_token_store.dart'; +import '../../../../core/ui/layout_breakpoints.dart'; +import '../../data/models/user_profile_model.dart'; import '../../domain/notifiers/profile_notifier.dart'; -import '../widgets/profile_info_row.dart'; -class ProfilePage extends ConsumerWidget { +class ProfilePage extends ConsumerStatefulWidget { const ProfilePage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - // profileState is AsyncValue - final profileState = ref.watch(profileProvider); + ConsumerState createState() => _ProfilePageState(); +} - return Scaffold( - appBar: AppBar( - title: const Text('내 정보'), - actions: [ - IconButton( - icon: const Icon(Icons.edit), - onPressed: () => context.push('/profile/edit'), +class _ProfilePageState extends ConsumerState { + static const _ink = Color(0xFF1A1F2C); + static const _surface = Colors.white; + static const _border = Color(0xFFE5E7EB); + static const _subtle = Color(0xFFF7F8FA); + + UserProfile? _cachedProfile; + String? _editingField; + TextEditingController? _nameController; + TextEditingController? _phoneController; + TextEditingController? _departmentController; + TextEditingController? _codeController; + + String _initialPhone = ''; + bool _isPhoneChanged = false; + bool _isPhoneVerified = false; + bool _isCodeSent = false; + bool _isVerifying = false; + + @override + void dispose() { + _nameController?.dispose(); + _phoneController?.dispose(); + _departmentController?.dispose(); + _codeController?.dispose(); + super.dispose(); + } + + Future _logout() async { + Descope.sessionManager.clearSession(); + AuthTokenStore.clear(); + AuthNotifier.instance.notify(); + } + + void _ensureControllers(UserProfile profile) { + _nameController ??= TextEditingController(text: profile.name); + _departmentController ??= TextEditingController(text: profile.department); + _codeController ??= TextEditingController(); + + if (_phoneController == null) { + _phoneController = TextEditingController(text: profile.phone); + _initialPhone = profile.phone; + _phoneController!.addListener(_onPhoneChanged); + } + + if (_editingField != 'name' && _nameController!.text != profile.name) { + _nameController!.text = profile.name; + } + if (_editingField != 'department' && _departmentController!.text != profile.department) { + _departmentController!.text = profile.department; + } + if (_editingField != 'phone' && _phoneController!.text != profile.phone) { + _phoneController!.text = profile.phone; + _initialPhone = profile.phone; + _resetPhoneState(); + } + } + + void _onPhoneChanged() { + if (_phoneController == null) return; + final changed = _phoneController!.text != _initialPhone; + if (changed != _isPhoneChanged) { + setState(() { + _isPhoneChanged = changed; + if (_isPhoneChanged) { + _isPhoneVerified = false; + _isCodeSent = false; + _codeController?.clear(); + } + }); + } + } + + void _resetPhoneState() { + _isPhoneChanged = false; + _isPhoneVerified = false; + _isCodeSent = false; + _isVerifying = false; + _codeController?.clear(); + } + + void _startEditing(String field, UserProfile profile) { + setState(() { + _editingField = field; + if (field == 'name') { + _nameController?.text = profile.name; + } else if (field == 'department') { + _departmentController?.text = profile.department; + } else if (field == 'phone') { + _phoneController?.text = profile.phone; + _initialPhone = profile.phone; + _resetPhoneState(); + } + }); + } + + void _cancelEditing(UserProfile profile) { + setState(() { + if (_editingField == 'name') { + _nameController?.text = profile.name; + } else if (_editingField == 'department') { + _departmentController?.text = profile.department; + } else if (_editingField == 'phone') { + _phoneController?.text = profile.phone; + _initialPhone = profile.phone; + _resetPhoneState(); + } + _editingField = null; + }); + } + + Future _sendCode() async { + final phone = _phoneController?.text ?? ''; + if (phone.isEmpty) return; + + setState(() => _isVerifying = true); + try { + await ref.read(profileRepositoryProvider).sendUpdateCode(phone); + setState(() { + _isCodeSent = true; + _isVerifying = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('인증번호가 전송되었습니다.')), + ); + } + } catch (e) { + setState(() => _isVerifying = false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('전송 실패: $e')), + ); + } + } + } + + Future _verifyCode() async { + final phone = _phoneController?.text ?? ''; + final code = _codeController?.text ?? ''; + if (code.isEmpty) return; + + setState(() => _isVerifying = true); + try { + await ref.read(profileRepositoryProvider).verifyUpdateCode(phone, code); + setState(() { + _isPhoneVerified = true; + _isVerifying = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('인증되었습니다.')), + ); + } + } catch (e) { + setState(() => _isVerifying = false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('인증 실패: $e')), + ); + } + } + } + + Future _saveField(UserProfile profile) async { + if (_editingField == null) return; + + final nextName = _editingField == 'name' + ? _nameController!.text.trim() + : profile.name; + final nextPhone = _editingField == 'phone' + ? _phoneController!.text.trim() + : profile.phone; + final nextDepartment = _editingField == 'department' + ? _departmentController!.text.trim() + : profile.department; + + if (_editingField == 'name' && nextName.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('이름을 입력해주세요.')), + ); + return; + } + if (_editingField == 'department' && nextDepartment.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('소속을 입력해주세요.')), + ); + return; + } + if (_editingField == 'phone') { + if (nextPhone.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('휴대폰 번호를 입력해주세요.')), + ); + return; + } + if (_isPhoneChanged && !_isPhoneVerified) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('휴대폰 번호 인증이 필요합니다.')), + ); + return; + } + } + + try { + await ref.read(profileProvider.notifier).updateProfile( + name: nextName, + phone: nextPhone, + department: nextDepartment, + ); + if (mounted) { + setState(() { + if (_editingField == 'phone') { + _initialPhone = nextPhone; + _resetPhoneState(); + } + _editingField = null; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('정보가 수정되었습니다.')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('수정 실패: $e')), + ); + } + } + } + + Widget _buildSideMenu(BuildContext context) { + return ListView( + padding: const EdgeInsets.symmetric(vertical: 12), + children: [ + ListTile( + leading: const Icon(Icons.home_outlined), + title: const Text('대시보드'), + onTap: () => context.go('/'), + ), + ListTile( + leading: const Icon(Icons.person_outline), + title: const Text('내 정보'), + selected: true, + onTap: () => context.go('/profile'), + ), + ListTile( + leading: const Icon(Icons.qr_code_scanner), + title: const Text('QR 스캔'), + onTap: () => context.go('/scan'), + ), + const Divider(), + ListTile( + leading: const Icon(Icons.logout), + title: const Text('로그아웃'), + onTap: _logout, + ), + ], + ); + } + + Widget _buildSectionTitle(String title, String subtitle) { + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: _ink), + ), + const SizedBox(width: 12), + Text( + subtitle, + style: TextStyle(fontSize: 13, color: Colors.grey[600]), + ), + ], + ); + } + + Widget _buildInfoChip(IconData icon, String label) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: _subtle, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: _border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: _ink), + const SizedBox(width: 6), + Text( + label, + style: const TextStyle(fontSize: 12, color: _ink, fontWeight: FontWeight.w600), ), ], ), - body: profileState.when( - data: (profile) { - if (profile == null) { - return const Center(child: Text('정보를 불러올 수 없습니다.')); - } - return RefreshIndicator( - onRefresh: () => ref.read(profileProvider.notifier).loadProfile(), - child: ListView( - padding: const EdgeInsets.all(16.0), + ); + } + + Widget _buildHeaderCard(UserProfile profile) { + final name = profile.name.isEmpty ? '이름 없음' : profile.name; + final email = profile.email.isEmpty ? '이메일 없음' : profile.email; + final department = profile.department.isEmpty ? '소속 정보 없음' : profile.department; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: _surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _border), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: Row( + children: [ + const CircleAvatar( + radius: 32, + child: Icon(Icons.person, size: 32), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Center( - child: CircleAvatar( - radius: 40, - child: Icon(Icons.person, size: 40), - ), + Text( + '안녕하세요, $name님', + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: _ink), + ), + const SizedBox(height: 6), + Text(email, style: TextStyle(color: Colors.grey[600], fontSize: 14)), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildInfoChip(Icons.badge_outlined, '프로필 관리'), + _buildInfoChip(Icons.apartment, department), + ], ), - const SizedBox(height: 24), - ProfileInfoRow(label: '이름', value: profile.name), - ProfileInfoRow(label: '이메일', value: profile.email), - ProfileInfoRow(label: '전화번호', value: profile.phone), - const Divider(height: 32), - ProfileInfoRow(label: '소속', value: profile.department), - ProfileInfoRow(label: '구분', value: profile.affiliationType), - if (profile.companyCode.isNotEmpty) - ProfileInfoRow(label: '회사코드', value: profile.companyCode), ], ), - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (err, stack) => Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('오류 발생: $err'), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () => ref.read(profileProvider.notifier).loadProfile(), - child: const Text('재시도'), - ), - ], ), - ), + ], ), ); } -} \ No newline at end of file + + Widget _buildCard(Widget child) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _border), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + child: child, + ); + } + + Widget _buildReadOnlyTile(String label, String value) { + final displayValue = value.isEmpty ? '-' : value; + return ListTile( + contentPadding: EdgeInsets.zero, + title: Text(label), + subtitle: Text(displayValue), + trailing: Text( + '읽기 전용', + style: TextStyle(color: Colors.grey[500], fontSize: 12), + ), + ); + } + + Widget _buildEditableTile({ + required String field, + required String label, + required String value, + required UserProfile profile, + required bool isUpdating, + required TextEditingController controller, + }) { + final isEditing = _editingField == field; + final displayValue = value.isEmpty ? '-' : value; + + if (!isEditing) { + return ListTile( + contentPadding: EdgeInsets.zero, + title: Text(label), + subtitle: Text(displayValue), + trailing: TextButton( + onPressed: isUpdating ? null : () => _startEditing(field, profile), + child: const Text('수정'), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + TextField( + controller: controller, + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: label, + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: isUpdating ? null : () => _cancelEditing(profile), + child: const Text('취소'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: isUpdating ? null : () => _saveField(profile), + child: const Text('확인'), + ), + ], + ), + ], + ); + } + + Widget _buildPhoneEditor(UserProfile profile, bool isUpdating) { + final isEditing = _editingField == 'phone'; + final displayValue = profile.phone.isEmpty ? '-' : profile.phone; + + if (!isEditing) { + return ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('전화번호'), + subtitle: Text(displayValue), + trailing: TextButton( + onPressed: isUpdating ? null : () => _startEditing('phone', profile), + child: const Text('수정'), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('전화번호', style: TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _phoneController, + keyboardType: TextInputType.phone, + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: '01012345678', + suffixIcon: _isPhoneVerified + ? const Icon(Icons.check_circle, color: Colors.green) + : null, + ), + enabled: !_isPhoneVerified, + ), + ), + const SizedBox(width: 8), + if (_isPhoneChanged && !_isPhoneVerified) + ElevatedButton( + onPressed: _isVerifying ? null : _sendCode, + child: Text(_isCodeSent ? '재전송' : '인증요청'), + ), + ], + ), + if (_isCodeSent && !_isPhoneVerified) ...[ + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _codeController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: '인증번호 6자리', + ), + ), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: _isVerifying ? null : _verifyCode, + child: const Text('확인'), + ), + ], + ), + ], + if (_isPhoneChanged && !_isPhoneVerified) + const Padding( + padding: EdgeInsets.only(top: 8.0), + child: Text( + '휴대폰 번호를 변경하려면 SMS 인증이 필요합니다.', + style: TextStyle(color: Colors.orange, fontSize: 12), + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: isUpdating ? null : () => _cancelEditing(profile), + child: const Text('취소'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: isUpdating ? null : () => _saveField(profile), + child: const Text('확인'), + ), + ], + ), + ], + ); + } + + Widget _buildContent(UserProfile profile, bool isUpdating) { + return RefreshIndicator( + onRefresh: () => ref.read(profileProvider.notifier).loadProfile(), + child: ListView( + padding: const EdgeInsets.all(24.0), + children: [ + _buildHeaderCard(profile), + const SizedBox(height: 28), + _buildSectionTitle('기본 정보', '계정 기본 정보를 관리합니다.'), + const SizedBox(height: 12), + _buildCard( + Column( + children: [ + _buildEditableTile( + field: 'name', + label: '이름', + value: profile.name, + profile: profile, + isUpdating: isUpdating, + controller: _nameController!, + ), + const Divider(height: 24), + _buildReadOnlyTile('이메일', profile.email), + const Divider(height: 24), + _buildPhoneEditor(profile, isUpdating), + ], + ), + ), + const SizedBox(height: 28), + _buildSectionTitle('조직 정보', '소속 및 구분 정보입니다.'), + const SizedBox(height: 12), + _buildCard( + Column( + children: [ + _buildEditableTile( + field: 'department', + label: '소속', + value: profile.department, + profile: profile, + isUpdating: isUpdating, + controller: _departmentController!, + ), + const Divider(height: 24), + _buildReadOnlyTile('구분', profile.affiliationType), + if (profile.companyCode.isNotEmpty) ...[ + const Divider(height: 24), + _buildReadOnlyTile('회사코드', profile.companyCode), + ], + ], + ), + ), + if (isUpdating || _isVerifying) ...[ + const SizedBox(height: 24), + const Center(child: CircularProgressIndicator()), + ], + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final profileState = ref.watch(profileProvider); + if (profileState.value != null) { + _cachedProfile = profileState.value; + } + + final profile = profileState.value ?? _cachedProfile; + if (profile == null) { + return Scaffold( + appBar: AppBar(title: const Text('내 정보')), + body: profileState.isLoading + ? const Center(child: CircularProgressIndicator()) + : Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('정보를 불러올 수 없습니다.'), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => ref.read(profileProvider.notifier).loadProfile(), + child: const Text('재시도'), + ), + ], + ), + ), + ); + } + + _ensureControllers(profile); + + final isWide = MediaQuery.of(context).size.width >= sideMenuBreakpoint; + final isUpdating = profileState.isLoading; + + return Scaffold( + backgroundColor: _subtle, + appBar: AppBar( + title: Text( + 'Baron 통합로그인', + style: GoogleFonts.outfit(fontWeight: FontWeight.bold), + ), + elevation: 0, + backgroundColor: _surface, + foregroundColor: Colors.black, + actions: [ + IconButton( + icon: const Icon(Icons.home_outlined), + tooltip: '대시보드', + onPressed: () => context.go('/'), + ), + IconButton( + icon: const Icon(Icons.qr_code_scanner), + tooltip: 'QR 스캔', + onPressed: () => context.push('/scan'), + ), + IconButton( + icon: const Icon(Icons.logout), + tooltip: '로그아웃', + onPressed: _logout, + ), + ], + ), + drawer: isWide ? null : Drawer(child: _buildSideMenu(context)), + body: Row( + children: [ + if (isWide) + SizedBox( + width: 240, + child: _buildSideMenu(context), + ), + Expanded(child: _buildContent(profile, isUpdating)), + ], + ), + ); + } +} diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index 06520c15..8dfec393 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -15,7 +15,6 @@ import 'features/auth/presentation/reset_password_screen.dart'; import 'features/dashboard/presentation/dashboard_screen.dart'; import 'features/admin/presentation/user_management_screen.dart'; import 'features/profile/presentation/pages/profile_page.dart'; -import 'features/profile/presentation/pages/edit_profile_page.dart'; import 'core/services/auth_proxy_service.dart'; import 'core/services/auth_token_store.dart'; import 'core/services/logger_service.dart'; @@ -88,12 +87,6 @@ final _router = GoRouter( GoRoute( path: '/profile', builder: (context, state) => const ProfilePage(), - routes: [ - GoRoute( - path: 'edit', - builder: (context, state) => const EditProfilePage(), - ), - ], ), GoRoute( path: '/signin', @@ -236,8 +229,33 @@ class BaronSSOApp extends StatelessWidget { ), useMaterial3: true, textTheme: GoogleFonts.interTextTheme(), + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: NoTransitionsBuilder(), + TargetPlatform.iOS: NoTransitionsBuilder(), + TargetPlatform.linux: NoTransitionsBuilder(), + TargetPlatform.macOS: NoTransitionsBuilder(), + TargetPlatform.windows: NoTransitionsBuilder(), + TargetPlatform.fuchsia: NoTransitionsBuilder(), + }, + ), ), routerConfig: _router, ); } } + +class NoTransitionsBuilder extends PageTransitionsBuilder { + const NoTransitionsBuilder(); + + @override + Widget buildTransitions( + PageRoute route, + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return child; + } +}