forked from baron/baron-sso
접근 이력 스크롤 조회 기능 추가
This commit is contained in:
85
adminfront/playwright-report/index.html
Normal file
85
adminfront/playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
4
adminfront/test-results/.last-run.json
Normal file
4
adminfront/test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
85
devfront/playwright-report/index.html
Normal file
85
devfront/playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
4
devfront/test-results/.last-run.json
Normal file
4
devfront/test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -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`를 따른다.
|
||||
|
||||
@@ -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 대응(로그인 코드/링크 방식) 설계
|
||||
|
||||
35
docs/issue-146-remote-login.md
Normal file
35
docs/issue-146-remote-login.md
Normal 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
132
docs/test-plan.md
Normal 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 통과
|
||||
- 보안 관련 회귀 없음
|
||||
- 장애/모니터링 대시보드 정상
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user