From 92f8e9a61a91089562c429a7e57beee99beadd5c Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 14 Apr 2026 10:49:11 +0900 Subject: [PATCH] =?UTF-8?q?headless=20password=20login=20=EC=A0=91?= =?UTF-8?q?=EC=86=8D=20=EC=9D=B4=EB=A0=A5=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth_handler.go | 26 ++- .../handler/auth_handler_login_test.go | 123 ++++++++++++++ .../handler/auth_handler_sessions_test.go | 155 ++++++++++++++++++ 3 files changed, 301 insertions(+), 3 deletions(-) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 0622bf74..5b3f415e 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -2520,6 +2520,8 @@ func (h *AuthHandler) HeadlessPasswordLogin(c *fiber.Ctx) error { c.Locals("user_id", authInfo.Subject) c.Locals("login_id", loginID) setSessionIDLocal(c, authInfo.SessionToken) + attachAuditClientDetails(c, loginReq.Client) + appendAuditDetail(c, "login_challenge", loginChallenge) acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), loginChallenge, authInfo.Subject) if err != nil { @@ -2903,6 +2905,19 @@ func attachAuditClientDetails(c *fiber.Ctx, client domain.HydraClient) { }) } +func appendAuditDetail(c *fiber.Ctx, key string, value any) { + if c == nil || strings.TrimSpace(key) == "" || value == nil { + return + } + + extra, _ := c.Locals("audit_details_extra").(map[string]any) + if extra == nil { + extra = make(map[string]any) + } + extra[key] = value + c.Locals("audit_details_extra", extra) +} + // InitiatePasswordReset - 사용자가 비밀번호 재설정을 시작하면, loginID 유형에 따라 이메일 또는 SMS를 보냅니다. func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { startTime := time.Now() @@ -4423,7 +4438,8 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { path := strings.ToLower(extractAuditPath(log)) isOidcAccept := strings.Contains(path, "/api/v1/auth/oidc/login/accept") - isPasswordLogin := strings.Contains(path, "/api/v1/auth/password/login") + isPasswordLogin := strings.Contains(path, "/api/v1/auth/password/login") || + strings.Contains(path, "/api/v1/auth/headless/password/login") // 우선 audit details의 client 정보를 사용 if details, err := utils.ParseAuditDetails(log.Details); err == nil && details != nil { @@ -5696,7 +5712,8 @@ func deriveAuthMethod(log domain.AuditLog) string { } switch { - case strings.Contains(path, "/api/v1/auth/password/login"): + case strings.Contains(path, "/api/v1/auth/password/login"), + strings.Contains(path, "/api/v1/auth/headless/password/login"): if kind == "email" { return "비밀번호(Email)" } @@ -7363,6 +7380,7 @@ func (h *AuthHandler) loadSessionAuditHints(ctx context.Context, userID string) "consent.granted", "POST /api/v1/auth/oidc/login/accept", "POST /api/v1/auth/password/login", + "POST /api/v1/auth/headless/password/login", "POST /api/v1/auth/magic-link/verify", "POST /api/v1/auth/login/code/verify", "POST /api/v1/auth/qr/approve", @@ -7476,7 +7494,8 @@ func deriveSessionClientInfo(log domain.AuditLog) (string, string) { appName = "코드 로그인" case strings.Contains(path, "/api/v1/auth/magic-link/verify"): appName = "링크 로그인" - case strings.Contains(path, "/api/v1/auth/password/login"): + case strings.Contains(path, "/api/v1/auth/password/login"), + strings.Contains(path, "/api/v1/auth/headless/password/login"): appName = "비밀번호 로그인" } } @@ -7547,6 +7566,7 @@ func (h *AuthHandler) loadSessionClientBindings(ctx context.Context, userID stri "consent.granted", "POST /api/v1/auth/oidc/login/accept", "POST /api/v1/auth/password/login", + "POST /api/v1/auth/headless/password/login", "password_login_success", "login_success", }, 200) diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go index d523c42b..08d06e2b 100644 --- a/backend/internal/handler/auth_handler_login_test.go +++ b/backend/internal/handler/auth_handler_login_test.go @@ -879,6 +879,129 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) { } } +func TestHeadlessPasswordLogin_AuditIncludesClientMetadata(t *testing.T) { + mockIdp := new(MockIdentityProvider) + mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ + SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "session-123"}, + Subject: "kratos-identity-id", + }, nil) + + privateKey, jwks := mustHeadlessRSAJWK(t) + jwksBody, _ := json.Marshal(jwks) + + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet: + json.NewEncoder(w).Encode(domain.HydraLoginRequest{ + Challenge: "challenge-123", + Client: domain.HydraClient{ + ClientID: "headless-login-client", + ClientName: "Headless Login Portal", + TokenEndpointAuthMethod: "none", + Metadata: map[string]interface{}{ + "status": "active", + "headless_login_enabled": true, + "headless_token_endpoint_auth_method": "private_key_jwt", + "headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json", + }, + }, + }) + case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut: + json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"}) + default: + http.NotFound(w, r) + } + }) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-identity-id", nil) + + auditRepo := &mockAuditRepo{} + headlessClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Host == "rp.example.com" && r.URL.Path == "/.well-known/jwks.json" { + return httpResponse(r, http.StatusOK, string(jwksBody)), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })} + h := &AuthHandler{ + IdpProvider: mockIdp, + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + HeadlessJWKS: service.NewHeadlessJWKSCacheService( + nil, + headlessClient, + ), + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, + }, + } + + app := fiber.New() + app.Use(middleware.AuditMiddleware(middleware.AuditConfig{ + Repo: auditRepo, + BodyDump: true, + })) + app.Post("/api/v1/auth/headless/password/login", h.HeadlessPasswordLogin) + + clientAssertion := mustHeadlessClientAssertion( + t, + privateKey, + "headless-login-client", + "http://example.com/api/v1/auth/headless/password/login", + ) + body, _ := json.Marshal(map[string]string{ + "client_id": "headless-login-client", + "client_assertion": clientAssertion, + "loginId": "employee001", + "password": "password", + "login_challenge": "challenge-123", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/password/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/146.0.0.0 Safari/537.36") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes)) + } + + if len(auditRepo.logs) != 1 { + t.Fatalf("expected 1 audit log, got %d", len(auditRepo.logs)) + } + + log := auditRepo.logs[0] + if log.EventType != "POST /api/v1/auth/headless/password/login" { + t.Fatalf("expected headless password login audit event, got %q", log.EventType) + } + if log.UserID != "kratos-identity-id" { + t.Fatalf("expected audit user_id kratos-identity-id, got %q", log.UserID) + } + if log.SessionID != "session-123" { + t.Fatalf("expected audit session_id session-123, got %q", log.SessionID) + } + + details, err := parseAuditDetails(log.Details) + if err != nil { + t.Fatalf("failed to parse audit details: %v", err) + } + if got, _ := details["client_id"].(string); got != "headless-login-client" { + t.Fatalf("expected client_id headless-login-client, got %v", details["client_id"]) + } + if got, _ := details["client_name"].(string); got != "Headless Login Portal" { + t.Fatalf("expected client_name Headless Login Portal, got %v", details["client_name"]) + } + if got, _ := details["login_challenge"].(string); got != "challenge-123" { + t.Fatalf("expected login_challenge challenge-123, got %v", details["login_challenge"]) + } +} + func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(t *testing.T) { mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ diff --git a/backend/internal/handler/auth_handler_sessions_test.go b/backend/internal/handler/auth_handler_sessions_test.go index 817daf86..8a12de2c 100644 --- a/backend/internal/handler/auth_handler_sessions_test.go +++ b/backend/internal/handler/auth_handler_sessions_test.go @@ -683,3 +683,158 @@ func TestGetAuthTimeline_FillsSessionIDFromOathkeeperRaw(t *testing.T) { assert.Equal(t, "oathkeeper", body.Items[0].Source) } } + +func TestGetAuthTimeline_IncludesHeadlessPasswordLogin(t *testing.T) { + now := time.Date(2026, 4, 7, 5, 10, 0, 0, time.UTC) + setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": now.Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })) + + h := &AuthHandler{ + AuditRepo: &mockAuditRepo{ + logs: []domain.AuditLog{ + { + EventID: "audit-1", + Timestamp: now, + UserID: "user-123", + SessionID: "headless-session-1", + EventType: "POST /api/v1/auth/headless/password/login", + Status: "success", + IPAddress: "203.0.113.20", + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/146.0.0.0 Safari/537.36", + Details: `{"client_id":"headless-login-client","client_name":"Headless Login Portal","session_id":"headless-session-1","login_id":"user@example.com","login_challenge":"challenge-123"}`, + }, + }, + }, + } + + app := fiber.New() + app.Get("/api/v1/audit/auth/timeline", h.GetAuthTimeline) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/audit/auth/timeline", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body struct { + Items []struct { + SessionID string `json:"session_id"` + ClientID string `json:"client_id"` + AppName string `json:"app_name"` + AuthMethod string `json:"auth_method"` + EventType string `json:"event_type"` + } `json:"items"` + } + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + if assert.Len(t, body.Items, 1) { + assert.Equal(t, "headless-session-1", body.Items[0].SessionID) + assert.Equal(t, "headless-login-client", body.Items[0].ClientID) + assert.Equal(t, "Headless Login Portal", body.Items[0].AppName) + assert.Equal(t, "비밀번호(Email)", body.Items[0].AuthMethod) + assert.Equal(t, "POST /api/v1/auth/headless/password/login", body.Items[0].EventType) + } +} + +func TestListMySessions_UsesHeadlessPasswordLoginForClientBinding(t *testing.T) { + now := time.Date(2026, 4, 7, 5, 35, 0, 0, time.UTC) + setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": now.Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ + { + ID: "current-sid", + Active: true, + AuthenticatedAt: now, + ExpiresAt: now.Add(24 * time.Hour), + }, + { + ID: "headless-session-1", + Active: true, + AuthenticatedAt: now.Add(-10 * time.Minute), + ExpiresAt: now.Add(23*time.Hour + 50*time.Minute), + }, + }, nil).Once() + + auditRepo := &mockAuditRepo{ + logs: []domain.AuditLog{ + { + UserID: "user-123", + EventType: "POST /api/v1/auth/headless/password/login", + SessionID: "headless-session-1", + Timestamp: now, + IPAddress: "203.0.113.20", + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/146.0.0.0 Safari/537.36", + Details: `{"client_id":"headless-login-client","client_name":"Headless Login Portal","session_id":"headless-session-1"}`, + }, + }, + } + + h := &AuthHandler{ + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + } + + app := fiber.New() + app.Get("/api/v1/user/sessions", h.ListMySessions) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body struct { + Items []struct { + SessionID string `json:"session_id"` + AppName string `json:"app_name"` + ClientID string `json:"client_id"` + IPAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + } `json:"items"` + } + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + if assert.Len(t, body.Items, 2) { + assert.Equal(t, "headless-session-1", body.Items[1].SessionID) + assert.Equal(t, "Headless Login Portal", body.Items[1].AppName) + assert.Equal(t, "headless-login-client", body.Items[1].ClientID) + assert.Equal(t, "203.0.113.20", body.Items[1].IPAddress) + assert.Equal(t, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/146.0.0.0 Safari/537.36", body.Items[1].UserAgent) + } + + mockKratos.AssertExpectations(t) +}