1
0
forked from baron/baron-sso

접근 이력 스크롤 조회 기능 추가

This commit is contained in:
Lectom C Han
2026-02-02 14:03:54 +09:00
parent 7e662c9878
commit 1c0a5ed272
15 changed files with 1265 additions and 231 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}

View File

@@ -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"):

View File

@@ -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
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}

View File

@@ -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`를 따른다.

View File

@@ -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 대응(로그인 코드/링크 방식) 설계

View File

@@ -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) 정확성 확인
- 코드/링크 만료/재사용 시나리오 점검

132
docs/test-plan.md Normal file
View File

@@ -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 통과
- 보안 관련 회귀 없음
- 장애/모니터링 대시보드 정상

View File

@@ -144,12 +144,18 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> verifyLoginCode(String loginId, String code, {String? pendingRef}) async {
static Future<Map<String, dynamic>> verifyLoginCode(
String loginId,
String code, {
String? pendingRef,
bool verifyOnly = false,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/login/code/verify');
final payload = {
'loginId': loginId,
'code': code,
'verifyOnly': verifyOnly,
};
if (pendingRef != null && pendingRef.isNotEmpty) {
payload['pendingRef'] = pendingRef;
@@ -168,7 +174,10 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> verifyLoginShortCode(String shortCode) async {
static Future<Map<String, dynamic>> verifyLoginShortCode(
String shortCode, {
bool verifyOnly = false,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/login/code/verify-short');
final response = await http.post(
@@ -176,6 +185,7 @@ class AuthProxyService {
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'shortCode': shortCode,
'verifyOnly': verifyOnly,
}),
);

View File

@@ -417,7 +417,17 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
),
const SizedBox(height: 24),
FilledButton(
onPressed: () => context.go(_verificationActionPath),
onPressed: () {
final hasLocalSession = AuthTokenStore.getToken() != null || AuthTokenStore.usesCookie();
final target = hasLocalSession ? '/' : '/signin';
if (mounted) {
setState(() {
_verificationOnly = false;
_verificationApproved = false;
});
}
context.go(target);
},
child: Text(_verificationActionLabel),
),
],
@@ -438,10 +448,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final jwt = res['token'] ?? res['sessionJwt'];
final status = res['status']?.toString();
final hasLocalSession = await _hasValidLocalSession();
final actionPath = hasLocalSession ? '/' : '/signin';
if (status == 'approved' || (jwt == null && _verificationOnly)) {
if (mounted) {
_markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
_markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
actionPath: actionPath,
);
}
return;
}
@@ -450,15 +464,22 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (hasLocalSession) {
_markVerificationApproved(
"승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다",
actionPath: actionPath,
);
return;
}
_markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
_markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
actionPath: actionPath,
);
return;
}
if (mounted) {
_markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
_markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
actionPath: actionPath,
);
}
} catch (e) {
debugPrint("[Auth] Verification FAILED for token: $token. Error: $e");
@@ -476,15 +497,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
sanitizedLoginId,
code,
pendingRef: pendingRef,
verifyOnly: _verificationOnly,
);
final jwt = res['sessionJwt'] ?? res['token'];
final status = res['status']?.toString();
debugPrint("[Auth] Code verification successful for loginId: $sanitizedLoginId");
final hasLocalSession = await _hasValidLocalSession();
final actionPath = hasLocalSession ? '/' : '/signin';
if (jwt == null && status == 'approved') {
if (mounted) {
_markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
_markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
actionPath: actionPath,
);
}
return;
}
@@ -493,11 +519,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (hasLocalSession) {
_markVerificationApproved(
"승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다",
actionPath: actionPath,
);
return;
}
if (_verificationOnly) {
_markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
_markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
actionPath: actionPath,
);
return;
}
_markVerificationApproved("링크로 로그인 되었습니다. 잠시 후 로그인 화면으로 이동합니다.",
@@ -511,7 +541,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
if (_verificationOnly && mounted) {
_markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
_markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
actionPath: actionPath,
);
}
} catch (e) {
debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e");
@@ -526,15 +559,22 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (sanitized.isEmpty) return;
debugPrint("[Auth] Starting short code verification for code: $sanitized");
try {
final res = await AuthProxyService.verifyLoginShortCode(sanitized);
final res = await AuthProxyService.verifyLoginShortCode(
sanitized,
verifyOnly: _verificationOnly,
);
final jwt = res['sessionJwt'] ?? res['token'];
final status = res['status']?.toString();
debugPrint("[Auth] Short code verification successful");
final hasLocalSession = await _hasValidLocalSession();
final actionPath = hasLocalSession ? '/' : '/signin';
if (jwt == null && status == 'approved') {
if (mounted) {
_markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
_markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
actionPath: actionPath,
);
}
return;
}
@@ -543,11 +583,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (hasLocalSession) {
_markVerificationApproved(
"승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다",
actionPath: actionPath,
);
return;
}
if (_verificationOnly) {
_markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
_markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
actionPath: actionPath,
);
return;
}
_completeLoginFromToken(jwt, provider: res['provider'] as String?);
@@ -555,7 +599,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
if (_verificationOnly && mounted) {
_markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
_markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
actionPath: actionPath,
);
}
} catch (e) {
debugPrint("[Auth] Short code verification FAILED. Error: $e");

View File

@@ -82,6 +82,13 @@ class AuditLogEntry {
}
}
class _AuditPage {
final List<AuditLogEntry> items;
final String? nextCursor;
const _AuditPage({required this.items, this.nextCursor});
}
class LinkedRp {
final String id;
final String name;
@@ -134,17 +141,30 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
static const _border = Color(0xFFE5E7EB);
static const _subtle = Color(0xFFF7F8FA);
Future<List<AuditLogEntry>>? _auditFuture;
final ScrollController _pageScrollController = ScrollController();
final List<AuditLogEntry> _auditLogs = [];
String? _auditNextCursor;
bool _auditLoading = false;
bool _auditLoadingMore = false;
String? _auditError;
Future<List<LinkedRp>>? _linkedRpsFuture;
bool _showAllActivities = false;
@override
void initState() {
super.initState();
_auditFuture = _fetchAuditLogs();
_pageScrollController.addListener(_onPageScroll);
_loadAuditLogs(reset: true);
_linkedRpsFuture = _fetchLinkedRps();
}
@override
void dispose() {
_pageScrollController.dispose();
super.dispose();
}
Future<void> _logout() async {
AuthTokenStore.clear();
AuthNotifier.instance.notify();
@@ -154,6 +174,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
context.push('/scan');
}
void _onPageScroll() {
if (!_pageScrollController.hasClients) {
return;
}
if (_pageScrollController.position.extentAfter < 240) {
_loadAuditLogs();
}
}
Widget _buildSideMenu(BuildContext context, {required bool closeOnTap}) {
return SafeArea(
child: ListView(
@@ -208,13 +237,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Future<void> _refreshAll() async {
await ref.read(profileProvider.notifier).loadProfile();
await _loadAuditLogs(reset: true);
setState(() {
_auditFuture = _fetchAuditLogs();
_linkedRpsFuture = _fetchLinkedRps();
});
if (_auditFuture != null) {
await _auditFuture;
}
if (_linkedRpsFuture != null) {
await _linkedRpsFuture;
}
@@ -227,9 +253,16 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
return dotenv.env[key] ?? fallback;
}
Future<List<AuditLogEntry>> _fetchAuditLogs() async {
Future<_AuditPage> _fetchAuditLogs({String? cursor}) async {
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
final url = Uri.parse('$baseUrl/api/v1/audit/auth/timeline?limit=20');
final queryParameters = <String, String>{
'limit': '20',
};
if (cursor != null && cursor.isNotEmpty) {
queryParameters['cursor'] = cursor;
}
final url = Uri.parse('$baseUrl/api/v1/audit/auth/timeline')
.replace(queryParameters: queryParameters);
final useCookie = AuthTokenStore.usesCookie();
final token = AuthTokenStore.getToken();
@@ -250,12 +283,53 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final body = jsonDecode(response.body) as Map<String, dynamic>;
final items = (body['items'] as List?) ?? [];
final nextCursor = body['next_cursor']?.toString();
final logs = items
.whereType<Map<String, dynamic>>()
.map(AuditLogEntry.fromJson)
.toList();
return logs;
return _AuditPage(items: logs, nextCursor: nextCursor);
}
Future<void> _loadAuditLogs({bool reset = false}) async {
if (_auditLoading || _auditLoadingMore) {
return;
}
if (!reset && (_auditNextCursor == null || _auditNextCursor!.isEmpty)) {
return;
}
if (reset) {
setState(() {
_auditLogs.clear();
_auditNextCursor = null;
_auditError = null;
_auditLoading = true;
});
} else {
setState(() {
_auditLoadingMore = true;
});
}
try {
final page = await _fetchAuditLogs(cursor: _auditNextCursor);
setState(() {
_auditLogs.addAll(page.items);
_auditNextCursor = page.nextCursor;
_auditError = null;
});
} catch (_) {
setState(() {
_auditError = '접속이력을 불러오지 못했습니다.';
});
} finally {
setState(() {
_auditLoading = false;
_auditLoadingMore = false;
});
}
}
Future<List<LinkedRp>> _fetchLinkedRps() async {
@@ -320,6 +394,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
return '$yyyy.$mm.$dd $hh:$min';
}
Widget _selectableText(String text, {TextStyle? style}) {
return SelectableText(text, style: style);
}
String _authMethodLabel() {
if (AuthTokenStore.usesCookie()) {
return 'Ory 세션';
@@ -360,7 +438,27 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Widget _buildAuthMethodCell(AuditLogEntry log, String authMethod) {
if (authMethod != 'QR') {
return Text(authMethod);
final approvedUserAgent = log.detailMap['approved_user_agent']?.toString() ?? '';
final approvedIp = log.detailMap['approved_ip']?.toString() ?? '';
final hasApproverMeta = approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty;
if (!authMethod.startsWith('링크') || !hasApproverMeta) {
return _selectableText(authMethod);
}
final deviceLabel = _deviceLabelFromUserAgent(approvedUserAgent);
final tooltip = [
'승인 기기: $deviceLabel',
'승인 IP: ${approvedIp.isEmpty ? '-' : approvedIp}',
].join('\n');
return Tooltip(
message: tooltip,
child: _selectableText(
authMethod,
style: const TextStyle(
color: Colors.blueAccent,
decoration: TextDecoration.underline,
),
),
);
}
final approvedSessionId = log.detailMap['approved_session_id']?.toString() ?? '';
final tooltip = approvedSessionId.isEmpty
@@ -393,7 +491,27 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Widget _buildAuthMethodLine(AuditLogEntry log, String authMethod) {
if (authMethod != 'QR') {
return Text('인증수단: $authMethod');
final approvedUserAgent = log.detailMap['approved_user_agent']?.toString() ?? '';
final approvedIp = log.detailMap['approved_ip']?.toString() ?? '';
final hasApproverMeta = approvedUserAgent.isNotEmpty || approvedIp.isNotEmpty;
if (!authMethod.startsWith('링크') || !hasApproverMeta) {
return _selectableText('인증수단: $authMethod');
}
final deviceLabel = _deviceLabelFromUserAgent(approvedUserAgent);
final tooltip = [
'승인 기기: $deviceLabel',
'승인 IP: ${approvedIp.isEmpty ? '-' : approvedIp}',
].join('\n');
return Tooltip(
message: tooltip,
child: _selectableText(
'인증수단: $authMethod',
style: const TextStyle(
color: Colors.blueAccent,
decoration: TextDecoration.underline,
),
),
);
}
final approvedSessionId = log.detailMap['approved_session_id']?.toString() ?? '';
return InkWell(
@@ -496,6 +614,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
final timelineWide = constraints.maxWidth >= 900;
final isMobile = constraints.maxWidth < 600;
return SingleChildScrollView(
controller: _pageScrollController,
physics: const AlwaysScrollableScrollPhysics(),
child: Padding(
padding: const EdgeInsets.all(24),
@@ -793,55 +912,45 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
}
Widget _buildAccessHistory(bool isWide) {
return FutureBuilder<List<AuditLogEntry>>(
future: _auditFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return _buildHistoryContainer(
child: const Center(child: CircularProgressIndicator()),
);
}
if (_auditLoading && _auditLogs.isEmpty) {
return _buildHistoryContainer(
child: const Center(child: CircularProgressIndicator()),
);
}
if (snapshot.hasError) {
return _buildHistoryContainer(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('접속이력을 불러오지 못했습니다.'),
const SizedBox(height: 8),
TextButton(
onPressed: () {
setState(() {
_auditFuture = _fetchAuditLogs();
});
},
child: const Text('다시 시도'),
),
],
if (_auditError != null && _auditLogs.isEmpty) {
return _buildHistoryContainer(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('접속이력을 불러오지 못했습니다.'),
const SizedBox(height: 8),
TextButton(
onPressed: () => _loadAuditLogs(reset: true),
child: const Text('다시 시도'),
),
),
);
}
],
),
),
);
}
final logs = snapshot.data ?? [];
if (logs.isEmpty) {
return _buildHistoryContainer(
child: Center(
child: Text(
'최근 접속 이력이 없습니다.',
style: TextStyle(color: Colors.grey[600]),
),
),
);
}
if (_auditLogs.isEmpty) {
return _buildHistoryContainer(
child: Center(
child: Text(
'최근 접속 이력이 없습니다.',
style: TextStyle(color: Colors.grey[600]),
),
),
);
}
if (isWide) {
return _buildHistoryTable(logs);
}
return _buildHistoryList(logs);
},
);
if (isWide) {
return _buildHistoryTable(_auditLogs);
}
return _buildHistoryList(_auditLogs);
}
Widget _buildHistoryContainer({required Widget child}) {
@@ -859,46 +968,51 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Widget _buildHistoryTable(List<AuditLogEntry> logs) {
return _buildHistoryContainer(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: DataTable(
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('인증결과')),
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))),
const DataCell(Text('(준비중)', style: TextStyle(color: Colors.grey))),
]);
}).toList(),
),
),
);
},
child: Column(
children: [
LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: DataTable(
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('인증결과')),
DataColumn(label: Text('현황')),
],
rows: logs.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(_selectableText(log.sessionId.isEmpty ? '-' : log.sessionId)),
DataCell(_selectableText(_formatDateTime(log.timestamp))),
DataCell(_selectableText(appLabel)),
DataCell(_selectableText(log.ipAddress.isEmpty ? '-' : log.ipAddress)),
DataCell(_selectableText(deviceLabel)),
DataCell(_buildAuthMethodCell(log, authMethod)),
DataCell(_selectableText(statusLabel, style: TextStyle(color: statusColor, fontWeight: FontWeight.w600))),
DataCell(_selectableText('(준비중)', style: const TextStyle(color: Colors.grey))),
]);
}).toList(),
),
),
);
},
),
_buildHistoryFooter(),
],
),
);
}
@@ -906,52 +1020,86 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Widget _buildHistoryList(List<AuditLogEntry> logs) {
return _buildHistoryContainer(
child: Column(
children: 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 Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _subtle,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
appLabel,
style: const TextStyle(fontWeight: FontWeight.w600, color: _ink),
children: [
for (final log in logs)
Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _subtle,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: _selectableText(
_appLabelForPath(log.path),
style: const TextStyle(fontWeight: FontWeight.w600, color: _ink),
),
),
),
Text(
statusLabel,
style: TextStyle(color: statusColor, fontWeight: FontWeight.w600),
),
],
),
const SizedBox(height: 6),
Text('Session ID: ${log.sessionId.isEmpty ? '-' : log.sessionId}'),
Text('접속일자: ${_formatDateTime(log.timestamp)}'),
Text('접속 IP: ${log.ipAddress.isEmpty ? '-' : log.ipAddress}'),
Text('접속환경: $deviceLabel'),
_buildAuthMethodLine(log, authMethod),
Text('인증결과: $statusLabel'),
Text('현황: (준비중)', style: TextStyle(color: Colors.grey[600])),
],
_selectableText(
log.status == 'success' ? '성공' : '실패',
style: TextStyle(
color: log.status == 'success' ? Colors.green : Colors.redAccent,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 6),
_selectableText('Session ID: ${log.sessionId.isEmpty ? '-' : log.sessionId}'),
_selectableText('접속일자: ${_formatDateTime(log.timestamp)}'),
_selectableText('접속 IP: ${log.ipAddress.isEmpty ? '-' : log.ipAddress}'),
_selectableText('접속환경: ${_deviceLabelFromUserAgent(log.userAgent)}'),
_buildAuthMethodLine(log, log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel()),
_selectableText('인증결과: ${log.status == 'success' ? '성공' : '실패'}'),
_selectableText('현황: (준비중)', style: TextStyle(color: Colors.grey[600])),
],
),
),
);
}).toList(),
_buildHistoryFooter(),
],
),
);
}
Widget _buildHistoryFooter() {
if (_auditLoadingMore) {
return const Padding(
padding: EdgeInsets.only(top: 8),
child: Center(child: CircularProgressIndicator()),
);
}
if (_auditError != null) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('더 불러오지 못했습니다.'),
TextButton(
onPressed: () => _loadAuditLogs(),
child: const Text('재시도'),
),
],
),
);
}
if (_auditNextCursor == null || _auditNextCursor!.isEmpty) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'더 이상 항목이 없습니다.',
style: TextStyle(color: Colors.grey[600], fontSize: 12),
),
);
}
return const SizedBox.shrink();
}
}
class _ActivityItem {

View File

@@ -91,7 +91,7 @@ final _router = GoRouter(
path: '/signin',
builder: (context, state) {
_routerLogger.info("Navigating to /signin");
return const LoginScreen();
return LoginScreen(key: state.pageKey);
}
),
GoRoute(
@@ -105,7 +105,7 @@ final _router = GoRouter(
path: '/verify',
builder: (context, state) {
_routerLogger.info("Navigating to /verify (query)");
return const LoginScreen();
return LoginScreen(key: state.pageKey);
},
),
GoRoute(
@@ -113,7 +113,7 @@ final _router = GoRouter(
builder: (context, state) {
final token = state.pathParameters['token'];
_routerLogger.info("Navigating to /verify with token: $token");
return LoginScreen(verificationToken: token);
return LoginScreen(key: state.pageKey, verificationToken: token);
},
),
GoRoute(
@@ -121,7 +121,7 @@ final _router = GoRouter(
builder: (context, state) {
final shortCode = state.pathParameters['shortCode'];
_routerLogger.info("Navigating to /l with code: $shortCode");
return const LoginScreen();
return LoginScreen(key: state.pageKey);
},
),
GoRoute(

View File

@@ -35,30 +35,10 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
# --- Ory Stack Proxy (via Oathkeeper) ---
# Kratos Public API
location /auth {
proxy_pass http://oathkeeper:4455;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Hydra Public API (Rewrite /oidc/... to /...)
location /oidc {
rewrite ^/oidc/(.*)$ /$1 break;
proxy_pass http://oathkeeper:4455;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# --- UserFront Static Files ---
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
}
}