1
0
forked from baron/baron-sso

Merge commit '1c0a5ed2720348f0ef14eca36b214210a5390b44'

This commit is contained in:
2026-02-02 14:09:04 +09:00
35 changed files with 1655 additions and 435 deletions

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -231,7 +231,7 @@ func main() {
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, tenantService, userRepo)
adminHandler := handler.NewAdminHandler()
devHandler := handler.NewDevHandler()
tenantHandler := handler.NewTenantHandler(db, tenantService)
tenantHandler := handler.NewTenantHandler(db)
kratosAdminService := service.NewKratosAdminService()
oryAdminProvider := service.NewOryProvider()
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService)

View File

@@ -970,12 +970,18 @@ components:
properties:
token:
type: string
verifyOnly:
type: boolean
MagicLinkVerifyResponse:
type: object
properties:
token:
type: string
status:
type: string
pendingRef:
type: string
message:
type: string

View File

@@ -26,7 +26,8 @@ type EnchantedLinkPollResponse struct {
}
type MagicLinkVerifyRequest struct {
Token string `json:"token"`
Token string `json:"token"`
VerifyOnly bool `json:"verifyOnly,omitempty"`
}
type QRInitResponse struct {

View File

@@ -39,6 +39,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:"
@@ -54,6 +60,10 @@ const (
statusPending = "pending"
statusSuccess = "success"
// Login Flows
loginFlowCode = "code"
loginFlowLink = "link"
// Durations
defaultExpiration = 5 * time.Minute
signupStateExpiration = 10 * time.Minute
@@ -682,7 +692,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)
@@ -724,11 +743,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)
@@ -821,6 +849,96 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error {
})
}
if data["status"] == "approved" {
loginID := data["loginId"]
if loginID == "" {
loginID = data["login_id"]
}
if loginID == "" {
slog.Warn("[Poll] Approved but missing loginId", "pendingRef", req.PendingRef)
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"})
}
if h.IdpProvider == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
}
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"})
}
}
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)
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,
"jwt": authInfo.SessionToken.JWT,
}
if sessionID != "" {
sessionData["session_id"] = sessionID
}
sessionDataJSON, _ := json.Marshal(sessionData)
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,
"status": "ok",
})
}
return c.JSON(fiber.Map{
"error": "authorization_pending",
"interval": int(minPollInterval.Seconds()),
@@ -851,6 +969,29 @@ 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",
"loginId": loginID,
})
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), defaultExpiration)
return c.JSON(fiber.Map{
"status": "approved",
"pendingRef": pendingRef,
"message": "Login approved",
})
}
if h.IdpProvider == nil {
slog.Error("[Verify] IDP Provider is nil")
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
@@ -899,6 +1040,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)
@@ -925,6 +1067,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) {
@@ -977,6 +1156,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)
@@ -1001,6 +1181,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"})
}
@@ -1805,6 +2008,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)
}
@@ -2228,6 +2445,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
@@ -2246,6 +2496,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
@@ -2301,14 +2602,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{
@@ -2316,12 +2656,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)
}
@@ -2357,6 +2697,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"})
@@ -2371,38 +2721,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,
})
}
@@ -2627,6 +3015,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 == "" {
@@ -2638,6 +3074,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 == "" {
@@ -2648,6 +3109,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

@@ -12,11 +12,13 @@ import (
type DevHandler struct {
Hydra *service.HydraAdminService
Redis *service.RedisService
}
func NewDevHandler() *DevHandler {
func NewDevHandler(redis *service.RedisService) *DevHandler {
return &DevHandler{
Hydra: service.NewHydraAdminService(),
Redis: redis,
}
}
@@ -102,7 +104,7 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
items := make([]clientSummary, 0, len(clients))
for _, client := range clients {
items = append(items, mapClientSummary(client))
items = append(items, h.mapClientSummary(client))
}
return c.JSON(clientListResponse{
@@ -126,7 +128,7 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
summary := mapClientSummary(*client)
summary := h.mapClientSummary(*client)
return c.JSON(clientDetailResponse{
Client: summary,
Endpoints: clientEndpoints{
@@ -165,7 +167,7 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
summary := mapClientSummary(*updated)
summary := h.mapClientSummary(*updated)
return c.JSON(clientDetailResponse{
Client: summary,
Endpoints: clientEndpoints{
@@ -251,9 +253,14 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
}
created.Metadata["client_secret"] = created.ClientSecret
_, _ = h.Hydra.UpdateClient(c.Context(), created.ClientID, *created)
// Also store in Redis if available
if h.Redis != nil {
_ = h.Redis.Set("client_secret:"+created.ClientID, created.ClientSecret, 0)
}
}
summary := mapClientSummary(*created)
summary := h.mapClientSummary(*created)
return c.Status(fiber.StatusCreated).JSON(clientDetailResponse{
Client: summary,
Endpoints: clientEndpoints{
@@ -341,7 +348,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
summary := mapClientSummary(*updatedClient)
summary := h.mapClientSummary(*updatedClient)
return c.JSON(clientDetailResponse{
Client: summary,
Endpoints: clientEndpoints{
@@ -367,6 +374,11 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// Clean up Redis
if h.Redis != nil {
_ = h.Redis.Delete("client_secret:" + clientID)
}
return c.SendStatus(fiber.StatusNoContent)
}
@@ -416,7 +428,7 @@ func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
}
func mapClientSummary(client service.HydraClient) clientSummary {
func (h *DevHandler) mapClientSummary(client service.HydraClient) clientSummary {
status := "active"
if client.Metadata != nil {
if value, ok := client.Metadata["status"].(string); ok && strings.ToLower(value) == "inactive" {
@@ -436,6 +448,20 @@ func mapClientSummary(client service.HydraClient) clientSummary {
scopes := strings.Fields(client.Scope)
clientSecret := client.ClientSecret
// 1. Check Metadata (Legacy/Fallback)
if clientSecret == "" && client.Metadata != nil {
if val, ok := client.Metadata["client_secret"].(string); ok {
clientSecret = val
}
}
// 2. Check Redis (New)
if clientSecret == "" && h.Redis != nil {
if val, err := h.Redis.Get("client_secret:" + client.ClientID); err == nil && val != "" {
clientSecret = val
}
}
return clientSummary{
ID: client.ClientID,
Name: name,
@@ -443,7 +469,7 @@ func mapClientSummary(client service.HydraClient) clientSummary {
Status: status,
RedirectURIs: client.RedirectURIs,
Scopes: scopes,
ClientSecret: client.ClientSecret,
ClientSecret: clientSecret,
Metadata: client.Metadata,
}
}

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
}

View File

@@ -48,6 +48,24 @@ services:
networks:
- baron_net
gateway:
build:
context: ./gateway
dockerfile: Dockerfile
container_name: baron_gateway
restart: always
ports:
- "${USERFRONT_PORT:-5000}:5000"
networks:
- baron_net
- public_net
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
volumes:
postgres_data:
clickhouse_data:
@@ -58,3 +76,7 @@ networks:
name: baron_net
external: true
driver: bridge
public_net:
name: public_net
external: true

View File

@@ -160,6 +160,7 @@ services:
- "4457:4455" # Proxy
environment:
- APP_ENV=${APP_ENV:-development}
- LOG_LEVEL=debug
volumes:
- ./docker/ory/oathkeeper:/etc/config/oathkeeper
- ./docker/ory/oathkeeper/logs:/var/log/oathkeeper

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,6 @@ import ClientConsentsPage from "../features/clients/ClientConsentsPage";
import ClientDetailsPage from "../features/clients/ClientDetailsPage";
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
import ClientsPage from "../features/clients/ClientsPage";
import { ClientFederationPage } from "../features/clients/routes/ClientFederationPage";
export const router = createBrowserRouter(
[
@@ -18,7 +17,6 @@ export const router = createBrowserRouter(
{ path: "clients/:id", element: <ClientDetailsPage /> },
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
{ path: "clients/:id/federation", element: <ClientFederationPage /> },
],
},
],

View File

@@ -120,12 +120,6 @@ function ClientConsentsPage() {
>
Settings
</Link>
<Link
to={`/clients/${clientId}/federation`}
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
>
Federation
</Link>
</div>
</header>

View File

@@ -130,12 +130,6 @@ function ClientDetailsPage() {
>
Settings
</Link>
<Link
to={`/clients/${clientId}/federation`}
className="pb-3 text-sm font-bold text-muted-foreground hover:text-foreground"
>
Federation
</Link>
</div>
</div>

View File

@@ -154,7 +154,6 @@ function ClientGeneralPage() {
<Link to={`/clients/${clientId}`} className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground">Connection</Link>
<Link to={`/clients/${clientId}/consents`} className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground">Consent &amp; Users</Link>
<span className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary">Settings</span>
<Link to={`/clients/${clientId}/federation`} className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground">Federation</Link>
</>
)}
</div>

View File

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

View File

@@ -82,28 +82,6 @@ services:
networks:
- baron_net
gateway:
build:
context: ./gateway
dockerfile: Dockerfile
container_name: baron_gateway
restart: always
ports:
- "${USERFRONT_PORT:-5000}:5000"
networks:
- baron_net
- public_net
depends_on:
backend:
condition: service_healthy
userfront:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
userfront:
build:
context: ./userfront

View File

@@ -24,7 +24,12 @@ if [ ! -f "$RULES_FILE" ]; then
echo "[oathkeeper] rules file not found: $RULES_FILE"
exit 1
fi
cp "$RULES_FILE" "$RULES_ACTIVE"
# Remove existing active rules file to prevent overwrite issues (File exists/Permission denied)
if [ -f "$RULES_ACTIVE" ]; then
rm -f "$RULES_ACTIVE" || echo "[oathkeeper] Warning: Failed to remove existing rules.active.json"
fi
cp -f "$RULES_FILE" "$RULES_ACTIVE" || echo "[oathkeeper] Warning: Failed to copy rules file. Using existing if present."
LOG_DIR="/var/log/oathkeeper"
LOG_FILE="${LOG_DIR}/access.log"

0
docker/ory/oathkeeper/oathkeeper.yml Normal file → Executable file
View File

100
docker/ory/oathkeeper/rules.active.json Normal file → Executable file
View File

@@ -1,92 +1,114 @@
[
{
"id": "public-health",
"description": "공개 헬스체크",
"description": "공개 헬스체크 (STAGE 도메인)",
"match": {
"url": "http://<.*>/health",
"url": "<.*>://sso-test.hmac.kr/health",
"methods": ["GET"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "noop" }
],
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "public-preflight",
"description": "CORS preflight",
"description": "CORS preflight (STAGE 도메인)",
"match": {
"url": "http://<.*>/api/v1/<.*>",
"url": "<.*>://sso-test.hmac.kr/api/v1/<.*>",
"methods": ["OPTIONS"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "noop" }
],
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "public-auth",
"description": "인증/회원가입 등 공개 엔드포인트",
"description": "인증/회원가입 등 공개 엔드포인트 (STAGE 도메인)",
"match": {
"url": "http://<.*>/api/v1/auth/<.*>",
"url": "<.*>://sso-test.hmac.kr/api/v1/auth/<.*>",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "noop" }
],
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "backend-command",
"description": "Command 요청은 Backend로 전달 (Audit 강제)",
"match": {
"url": "http://<.*>/api/v1/<.*>",
"url": "<.*>://sso-test.hmac.kr/api/v1/<.*>",
"methods": ["POST", "PUT", "PATCH", "DELETE"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "cookie_session" }
],
"authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "remote_json" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "backend-query",
"description": "Backend Query (admin/dev 포함)",
"match": {
"url": "http://<.*>/api/v1/<.*>",
"url": "<.*>://sso-test.hmac.kr/api/v1/<.*>",
"methods": ["GET"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "cookie_session" }
],
"authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "remote_json" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-well-known",
"description": "Hydra OIDC Discovery & JWKS",
"match": {
"url": "<.*>://sso-test.hmac.kr/.well-known/<.*>",
"methods": ["GET", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-oauth2",
"description": "Hydra OAuth2 Endpoints",
"match": {
"url": "<.*>://sso-test.hmac.kr/oauth2/<.*>",
"methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-userinfo",
"description": "Hydra Userinfo",
"match": {
"url": "<.*>://sso-test.hmac.kr/userinfo",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
}
]
]

48
docker/ory/oathkeeper/rules.draft.json Normal file → Executable file
View File

@@ -9,13 +9,9 @@
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "noop" }
],
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "public-auth",
@@ -27,13 +23,9 @@
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "noop" }
],
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "backend-command",
@@ -45,13 +37,9 @@
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "cookie_session" }
],
"authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "remote_json" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "backend-query",
@@ -63,13 +51,9 @@
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "cookie_session" }
],
"authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "remote_json" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "kratos-public",
@@ -82,13 +66,9 @@
"url": "http://kratos:4433",
"strip_path": "/kratos"
},
"authenticators": [
{ "handler": "noop" }
],
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-public",
@@ -101,12 +81,8 @@
"url": "http://hydra:4444",
"strip_path": "/hydra"
},
"authenticators": [
{ "handler": "noop" }
],
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
}
]

92
docker/ory/oathkeeper/rules.json Normal file → Executable file
View File

@@ -3,90 +3,112 @@
"id": "public-health",
"description": "공개 헬스체크",
"match": {
"url": "http://<.*>/health",
"url": "<.*>://<.*>/health",
"methods": ["GET"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "noop" }
],
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "public-preflight",
"description": "CORS preflight",
"match": {
"url": "http://<.*>/api/v1/<.*>",
"url": "<.*>://<.*>/api/v1/<.*>",
"methods": ["OPTIONS"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "noop" }
],
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "public-auth",
"description": "인증/회원가입 등 공개 엔드포인트",
"match": {
"url": "http://<.*>/api/v1/auth/<.*>",
"url": "<.*>://<.*>/api/v1/auth/<.*>",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "noop" }
],
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "backend-command",
"description": "Command 요청은 Backend로 전달 (Audit 강제)",
"match": {
"url": "http://<.*>/api/v1/<.*>",
"url": "<.*>://<.*>/api/v1/<.*>",
"methods": ["POST", "PUT", "PATCH", "DELETE"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "cookie_session" }
],
"authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "remote_json" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "backend-query",
"description": "Backend Query (admin/dev 포함)",
"match": {
"url": "http://<.*>/api/v1/<.*>",
"url": "<.*>://<.*>/api/v1/<.*>",
"methods": ["GET"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "cookie_session" }
],
"authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "remote_json" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-well-known",
"description": "Hydra OIDC Discovery & JWKS",
"match": {
"url": "<.*>://<.*>/.well-known/<.*>",
"methods": ["GET", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-oauth2",
"description": "Hydra OAuth2 Endpoints",
"match": {
"url": "<.*>://<.*>/oauth2/<.*>",
"methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-userinfo",
"description": "Hydra Userinfo",
"match": {
"url": "<.*>://<.*>/userinfo",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
}
]

40
docker/ory/oathkeeper/rules.prod.json Normal file → Executable file
View File

@@ -9,13 +9,9 @@
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "noop" }
],
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "public-preflight",
@@ -27,13 +23,9 @@
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "noop" }
],
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "public-auth",
@@ -45,13 +37,9 @@
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "noop" }
],
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "backend-command",
@@ -63,13 +51,9 @@
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "cookie_session" }
],
"authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "remote_json" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "backend-query",
@@ -81,12 +65,8 @@
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "cookie_session" }
],
"authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "remote_json" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
}
]

94
docker/ory/oathkeeper/rules.stage.json Normal file → Executable file
View File

@@ -3,90 +3,112 @@
"id": "public-health",
"description": "공개 헬스체크 (STAGE 도메인)",
"match": {
"url": "https://sso.hmac.kr/health",
"url": "<.*>://sso-test.hmac.kr/health",
"methods": ["GET"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "noop" }
],
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "public-preflight",
"description": "CORS preflight (STAGE 도메인)",
"match": {
"url": "https://sso.hmac.kr/api/v1/<.*>",
"url": "<.*>://sso-test.hmac.kr/api/v1/<.*>",
"methods": ["OPTIONS"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "noop" }
],
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "public-auth",
"description": "인증/회원가입 등 공개 엔드포인트 (STAGE 도메인)",
"match": {
"url": "https://sso.hmac.kr/api/v1/auth/<.*>",
"url": "<.*>://sso-test.hmac.kr/api/v1/auth/<.*>",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "noop" }
],
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "backend-command",
"description": "Command 요청은 Backend로 전달 (Audit 강제)",
"match": {
"url": "https://sso.hmac.kr/api/v1/<.*>",
"url": "<.*>://sso-test.hmac.kr/api/v1/<.*>",
"methods": ["POST", "PUT", "PATCH", "DELETE"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "cookie_session" }
],
"authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "remote_json" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "backend-query",
"description": "Backend Query (admin/dev 포함)",
"match": {
"url": "https://sso.hmac.kr/api/v1/<.*>",
"url": "<.*>://sso-test.hmac.kr/api/v1/<.*>",
"methods": ["GET"]
},
"upstream": {
"url": "http://baron_backend:3000"
},
"authenticators": [
{ "handler": "cookie_session" }
],
"authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "remote_json" },
"mutators": [
{ "handler": "noop" }
]
"mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-well-known",
"description": "Hydra OIDC Discovery & JWKS",
"match": {
"url": "<.*>://sso-test.hmac.kr/.well-known/<.*>",
"methods": ["GET", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-oauth2",
"description": "Hydra OAuth2 Endpoints",
"match": {
"url": "<.*>://sso-test.hmac.kr/oauth2/<.*>",
"methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "hydra-userinfo",
"description": "Hydra Userinfo",
"match": {
"url": "<.*>://sso-test.hmac.kr/userinfo",
"methods": ["GET", "POST", "OPTIONS"]
},
"upstream": {
"url": "http://hydra:4444"
},
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
}
]
]

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

31
gateway/entrypoint.sh Normal file
View File

@@ -0,0 +1,31 @@
#!/bin/sh
# 대상 호스트와 포트가 준비될 때까지 대기하는 함수
wait_for_host() {
host=$1
port=$2
name=$3
echo "Waiting for $name ($host:$port)..."
# 최대 30초 동안 대기
count=0
until nc -z $host $port || [ $count -eq 30 ]; do
sleep 1
count=$((count + 1))
done
if [ $count -eq 30 ]; then
echo "Timeout waiting for $name"
else
echo "$name is ready!"
fi
}
# 백엔드와 유저프론트 대기 (Oathkeeper는 인프라 레벨이므로 함께 뜰 가능성이 높지만 안전을 위해 포함)
wait_for_host "baron_backend" 3000 "Backend"
wait_for_host "baron_userfront" 5000 "UserFront"
wait_for_host "oathkeeper" 4455 "Oathkeeper"
echo "All dependencies are up. Starting Nginx..."
exec nginx -g 'daemon off;'

View File

@@ -56,6 +56,7 @@ server {
# Hydra Public API
location /oidc {
rewrite ^/oidc/(.*)$ /$1 break;
proxy_pass $oathkeeper_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

View File

@@ -125,7 +125,7 @@ class AuthProxyService {
throw Exception('Polling failed: ${response.body}');
}
static Future<Map<String, dynamic>> verifyMagicLink(String token) async {
static Future<Map<String, dynamic>> verifyMagicLink(String token, {bool verifyOnly = false}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/magic-link/verify');
final response = await http.post(
@@ -133,6 +133,7 @@ class AuthProxyService {
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'token': token,
'verifyOnly': verifyOnly,
}),
);
@@ -143,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;
@@ -167,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(
@@ -175,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),
),
],
@@ -430,31 +440,46 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
debugPrint("[Auth] Starting verification for token: $token");
try {
// Use Backend to verify the token (Backend-Driven Flow)
final res = await AuthProxyService.verifyMagicLink(token);
final res = await AuthProxyService.verifyMagicLink(
token,
verifyOnly: _verificationOnly,
);
debugPrint("[Auth] Verification successful for token: $token");
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(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
actionPath: actionPath,
);
}
return;
}
if (jwt is String && jwt.isNotEmpty) {
if (hasLocalSession) {
_markVerificationApproved(
"승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다",
actionPath: actionPath,
);
return;
}
_markVerificationApproved(
"링크로 로그인 되었습니다. 잠시 후 로그인 화면으로 이동합니다.",
title: '링크 로그인 완료',
pageTitle: '링크 로그인',
actionLabel: '로그인 화면으로 이동',
actionPath: '/signin',
autoRedirect: true,
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
actionPath: actionPath,
);
return;
}
if (mounted) {
_markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
_markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
actionPath: actionPath,
);
}
} catch (e) {
debugPrint("[Auth] Verification FAILED for token: $token. Error: $e");
@@ -472,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;
}
@@ -489,11 +519,18 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (hasLocalSession) {
_markVerificationApproved(
"승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다",
actionPath: actionPath,
);
return;
}
_markVerificationApproved(
"링크로 로그인 되었습니다. 잠시 후 로그인 화면으로 이동합니다.",
if (_verificationOnly) {
_markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
actionPath: actionPath,
);
return;
}
_markVerificationApproved("링크로 로그인 되었습니다. 잠시 후 로그인 화면으로 이동합니다.",
title: '링크 로그인 완료',
pageTitle: '링크 로그인',
actionLabel: '로그인 화면으로 이동',
@@ -504,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");
@@ -519,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;
}
@@ -536,6 +583,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (hasLocalSession) {
_markVerificationApproved(
"승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다",
actionPath: actionPath,
);
return;
}
if (_verificationOnly) {
_markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
actionPath: actionPath,
);
return;
}
@@ -544,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

@@ -26,7 +26,16 @@ server {
error_log /dev/stderr warn;
access_log /var/log/nginx/access.log json_combined;
# --- UserFront 정적 파일 ---
# --- Backend API Proxy ---
location /api {
proxy_pass http://baron_backend:3000;
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;