forked from baron/baron-sso
Merge pull request 'feature/headless-url' (#556) from feature/headless-url into dev
Reviewed-on: baron/baron-sso#556
This commit is contained in:
@@ -109,6 +109,7 @@ type signupState struct {
|
|||||||
|
|
||||||
type headlessLinkState struct {
|
type headlessLinkState struct {
|
||||||
ClientID string `json:"clientId"`
|
ClientID string `json:"clientId"`
|
||||||
|
ClientName string `json:"clientName,omitempty"`
|
||||||
LoginChallenge string `json:"loginChallenge"`
|
LoginChallenge string `json:"loginChallenge"`
|
||||||
LoginID string `json:"loginId"`
|
LoginID string `json:"loginId"`
|
||||||
RedirectTo string `json:"redirectTo,omitempty"`
|
RedirectTo string `json:"redirectTo,omitempty"`
|
||||||
@@ -2520,6 +2521,8 @@ func (h *AuthHandler) HeadlessPasswordLogin(c *fiber.Ctx) error {
|
|||||||
c.Locals("user_id", authInfo.Subject)
|
c.Locals("user_id", authInfo.Subject)
|
||||||
c.Locals("login_id", loginID)
|
c.Locals("login_id", loginID)
|
||||||
setSessionIDLocal(c, authInfo.SessionToken)
|
setSessionIDLocal(c, authInfo.SessionToken)
|
||||||
|
attachAuditClientDetails(c, loginReq.Client)
|
||||||
|
appendAuditDetail(c, "login_challenge", loginChallenge)
|
||||||
|
|
||||||
acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), loginChallenge, authInfo.Subject)
|
acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), loginChallenge, authInfo.Subject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2673,6 +2676,7 @@ func (h *AuthHandler) HeadlessLinkInit(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
h.storeHeadlessLinkState(pendingRef, headlessLinkState{
|
h.storeHeadlessLinkState(pendingRef, headlessLinkState{
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
|
ClientName: strings.TrimSpace(loginReq.Client.ClientName),
|
||||||
LoginChallenge: loginChallenge,
|
LoginChallenge: loginChallenge,
|
||||||
LoginID: resolvedLoginID,
|
LoginID: resolvedLoginID,
|
||||||
}, ttl)
|
}, ttl)
|
||||||
@@ -2903,6 +2907,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를 보냅니다.
|
// InitiatePasswordReset - 사용자가 비밀번호 재설정을 시작하면, loginID 유형에 따라 이메일 또는 SMS를 보냅니다.
|
||||||
func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
|
func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
@@ -4104,6 +4121,21 @@ func (h *AuthHandler) writeLinkAuditLog(loginID, pendingRef string, sessionToken
|
|||||||
if rawLoginID != "" && rawLoginID != loginID {
|
if rawLoginID != "" && rawLoginID != loginID {
|
||||||
details["login_id_effective"] = loginID
|
details["login_id_effective"] = loginID
|
||||||
}
|
}
|
||||||
|
if state, ok := h.loadHeadlessLinkState(pendingRef); ok {
|
||||||
|
if strings.TrimSpace(state.ClientID) != "" {
|
||||||
|
details["client_id"] = strings.TrimSpace(state.ClientID)
|
||||||
|
}
|
||||||
|
clientName := strings.TrimSpace(state.ClientName)
|
||||||
|
if clientName == "" && strings.TrimSpace(state.ClientID) != "" {
|
||||||
|
clientName = strings.TrimSpace(state.ClientID)
|
||||||
|
}
|
||||||
|
if clientName != "" {
|
||||||
|
details["client_name"] = clientName
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(state.LoginChallenge) != "" {
|
||||||
|
details["login_challenge"] = strings.TrimSpace(state.LoginChallenge)
|
||||||
|
}
|
||||||
|
}
|
||||||
if approverMeta, ok := h.loadLoginApproverMeta(pendingRef); ok {
|
if approverMeta, ok := h.loadLoginApproverMeta(pendingRef); ok {
|
||||||
if approverMeta.IPAddress != "" {
|
if approverMeta.IPAddress != "" {
|
||||||
details["approved_ip"] = approverMeta.IPAddress
|
details["approved_ip"] = approverMeta.IPAddress
|
||||||
@@ -4423,7 +4455,8 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
|
|||||||
path := strings.ToLower(extractAuditPath(log))
|
path := strings.ToLower(extractAuditPath(log))
|
||||||
|
|
||||||
isOidcAccept := strings.Contains(path, "/api/v1/auth/oidc/login/accept")
|
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 정보를 사용
|
// 우선 audit details의 client 정보를 사용
|
||||||
if details, err := utils.ParseAuditDetails(log.Details); err == nil && details != nil {
|
if details, err := utils.ParseAuditDetails(log.Details); err == nil && details != nil {
|
||||||
@@ -5696,7 +5729,8 @@ func deriveAuthMethod(log domain.AuditLog) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
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" {
|
if kind == "email" {
|
||||||
return "비밀번호(Email)"
|
return "비밀번호(Email)"
|
||||||
}
|
}
|
||||||
@@ -7363,6 +7397,7 @@ func (h *AuthHandler) loadSessionAuditHints(ctx context.Context, userID string)
|
|||||||
"consent.granted",
|
"consent.granted",
|
||||||
"POST /api/v1/auth/oidc/login/accept",
|
"POST /api/v1/auth/oidc/login/accept",
|
||||||
"POST /api/v1/auth/password/login",
|
"POST /api/v1/auth/password/login",
|
||||||
|
"POST /api/v1/auth/headless/password/login",
|
||||||
"POST /api/v1/auth/magic-link/verify",
|
"POST /api/v1/auth/magic-link/verify",
|
||||||
"POST /api/v1/auth/login/code/verify",
|
"POST /api/v1/auth/login/code/verify",
|
||||||
"POST /api/v1/auth/qr/approve",
|
"POST /api/v1/auth/qr/approve",
|
||||||
@@ -7476,7 +7511,8 @@ func deriveSessionClientInfo(log domain.AuditLog) (string, string) {
|
|||||||
appName = "코드 로그인"
|
appName = "코드 로그인"
|
||||||
case strings.Contains(path, "/api/v1/auth/magic-link/verify"):
|
case strings.Contains(path, "/api/v1/auth/magic-link/verify"):
|
||||||
appName = "링크 로그인"
|
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 = "비밀번호 로그인"
|
appName = "비밀번호 로그인"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7547,6 +7583,7 @@ func (h *AuthHandler) loadSessionClientBindings(ctx context.Context, userID stri
|
|||||||
"consent.granted",
|
"consent.granted",
|
||||||
"POST /api/v1/auth/oidc/login/accept",
|
"POST /api/v1/auth/oidc/login/accept",
|
||||||
"POST /api/v1/auth/password/login",
|
"POST /api/v1/auth/password/login",
|
||||||
|
"POST /api/v1/auth/headless/password/login",
|
||||||
"password_login_success",
|
"password_login_success",
|
||||||
"login_success",
|
"login_success",
|
||||||
}, 200)
|
}, 200)
|
||||||
|
|||||||
@@ -243,11 +243,6 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
|
|||||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||||
jwksBody, _ := json.Marshal(jwks)
|
jwksBody, _ := json.Marshal(jwks)
|
||||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write(jwksBody)
|
|
||||||
}))
|
|
||||||
defer jwksServer.Close()
|
|
||||||
|
|
||||||
idp := &mockIdpProvider{
|
idp := &mockIdpProvider{
|
||||||
userExists: true,
|
userExists: true,
|
||||||
@@ -261,12 +256,13 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
|
|||||||
Challenge: "challenge-123",
|
Challenge: "challenge-123",
|
||||||
Client: domain.HydraClient{
|
Client: domain.HydraClient{
|
||||||
ClientID: "headless-login-client",
|
ClientID: "headless-login-client",
|
||||||
|
ClientName: "local-demo-rp",
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]interface{}{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"headless_login_enabled": true,
|
"headless_login_enabled": true,
|
||||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
"headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -280,12 +276,21 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
|
|||||||
|
|
||||||
mockKratos := new(MockKratosAdminService)
|
mockKratos := new(MockKratosAdminService)
|
||||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "+821012345678").Return("kratos-identity-id", nil)
|
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "+821012345678").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{
|
h := &AuthHandler{
|
||||||
RedisService: redis,
|
RedisService: redis,
|
||||||
IdpProvider: idp,
|
IdpProvider: idp,
|
||||||
SmsService: &mockSmsService{},
|
SmsService: &mockSmsService{},
|
||||||
KratosAdmin: mockKratos,
|
KratosAdmin: mockKratos,
|
||||||
|
AuditRepo: auditRepo,
|
||||||
|
HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, headlessClient),
|
||||||
Hydra: &service.HydraAdminService{
|
Hydra: &service.HydraAdminService{
|
||||||
AdminURL: "http://hydra.test",
|
AdminURL: "http://hydra.test",
|
||||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||||
@@ -343,4 +348,14 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
|
|||||||
_ = json.NewDecoder(resp.Body).Decode(&pollResp)
|
_ = json.NewDecoder(resp.Body).Decode(&pollResp)
|
||||||
assert.Equal(t, "http://rp/cb", pollResp["redirectTo"])
|
assert.Equal(t, "http://rp/cb", pollResp["redirectTo"])
|
||||||
assert.Equal(t, "ok", pollResp["status"])
|
assert.Equal(t, "ok", pollResp["status"])
|
||||||
|
if assert.Len(t, auditRepo.logs, 1) {
|
||||||
|
assert.Contains(t, auditRepo.logs[0].EventType, "/api/v1/auth/")
|
||||||
|
details, err := parseAuditDetails(auditRepo.logs[0].Details)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse audit details: %v", err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, "headless-login-client", details["client_id"])
|
||||||
|
assert.Equal(t, "local-demo-rp", details["client_name"])
|
||||||
|
assert.Equal(t, "challenge-123", details["login_challenge"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(t *testing.T) {
|
||||||
mockIdp := new(MockIdentityProvider)
|
mockIdp := new(MockIdentityProvider)
|
||||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||||
|
|||||||
@@ -683,3 +683,158 @@ func TestGetAuthTimeline_FillsSessionIDFromOathkeeperRaw(t *testing.T) {
|
|||||||
assert.Equal(t, "oathkeeper", body.Items[0].Source)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import 'package:userfront/features/dashboard/domain/models.dart';
|
||||||
|
|
||||||
|
const headlessServerUserAgentSentinel = '__headless_server__';
|
||||||
|
|
||||||
|
bool looksLikeInternalAuditUserAgent(String userAgent) {
|
||||||
|
final lower = userAgent.trim().toLowerCase();
|
||||||
|
return lower.startsWith('go-http-client/') ||
|
||||||
|
lower.startsWith('fasthttp') ||
|
||||||
|
lower.startsWith('fiber') ||
|
||||||
|
lower.startsWith('undici') ||
|
||||||
|
lower.startsWith('node');
|
||||||
|
}
|
||||||
|
|
||||||
|
String preferredAuditLogUserAgent(AuditLogEntry log) {
|
||||||
|
final userAgent = log.userAgent.trim();
|
||||||
|
final path = log.path.toLowerCase();
|
||||||
|
|
||||||
|
final isHeadlessLinkLog =
|
||||||
|
path.contains('/api/v1/auth/magic-link/verify') ||
|
||||||
|
path.contains('/api/v1/auth/login/code/verify');
|
||||||
|
final isHeadlessPasswordLog = path.contains(
|
||||||
|
'/api/v1/auth/headless/password/login',
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((isHeadlessLinkLog || isHeadlessPasswordLog) &&
|
||||||
|
looksLikeInternalAuditUserAgent(userAgent)) {
|
||||||
|
return headlessServerUserAgentSentinel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return userAgent;
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import '../../../../core/ui/toast_service.dart';
|
|||||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||||
import '../domain/dashboard_providers.dart';
|
import '../domain/dashboard_providers.dart';
|
||||||
import '../domain/models.dart' hide LinkedRp;
|
import '../domain/models.dart' hide LinkedRp;
|
||||||
|
import 'audit_device_utils.dart';
|
||||||
import 'package:userfront/i18n.dart';
|
import 'package:userfront/i18n.dart';
|
||||||
|
|
||||||
class DashboardScreen extends ConsumerStatefulWidget {
|
class DashboardScreen extends ConsumerStatefulWidget {
|
||||||
@@ -690,6 +691,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
if (userAgent.isEmpty) {
|
if (userAgent.isEmpty) {
|
||||||
return tr('ui.common.hyphen', fallback: '-');
|
return tr('ui.common.hyphen', fallback: '-');
|
||||||
}
|
}
|
||||||
|
if (userAgent == headlessServerUserAgentSentinel) {
|
||||||
|
return 'Headless(Server)';
|
||||||
|
}
|
||||||
final ua = userAgent.toLowerCase();
|
final ua = userAgent.toLowerCase();
|
||||||
if (ua.contains('iphone') || ua.contains('ipad') || ua.contains('ipod')) {
|
if (ua.contains('iphone') || ua.contains('ipad') || ua.contains('ipod')) {
|
||||||
return tr('ui.userfront.device.ios', fallback: 'Mobile(iOS)');
|
return tr('ui.userfront.device.ios', fallback: 'Mobile(iOS)');
|
||||||
@@ -1234,6 +1238,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _sessionBrowserLabel(String userAgent) {
|
String _sessionBrowserLabel(String userAgent) {
|
||||||
|
if (userAgent == headlessServerUserAgentSentinel) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
final lower = userAgent.toLowerCase();
|
final lower = userAgent.toLowerCase();
|
||||||
if (lower.isEmpty || _looksLikeInternalUserAgent(lower)) {
|
if (lower.isEmpty || _looksLikeInternalUserAgent(lower)) {
|
||||||
return '';
|
return '';
|
||||||
@@ -2164,10 +2171,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
final authMethod = log.authMethod.isNotEmpty
|
final authMethod = log.authMethod.isNotEmpty
|
||||||
? log.authMethod
|
? log.authMethod
|
||||||
: _authMethodLabel();
|
: _authMethodLabel();
|
||||||
final deviceLabel = _deviceLabelFromUserAgent(
|
final preferredUserAgent = preferredAuditLogUserAgent(
|
||||||
log.userAgent,
|
log,
|
||||||
|
);
|
||||||
|
final deviceLabel = _deviceLabelFromUserAgent(
|
||||||
|
preferredUserAgent,
|
||||||
|
);
|
||||||
|
final browserLabel = _sessionBrowserLabel(
|
||||||
|
preferredUserAgent,
|
||||||
);
|
);
|
||||||
final browserLabel = _sessionBrowserLabel(log.userAgent);
|
|
||||||
return DataRow(
|
return DataRow(
|
||||||
cells: [
|
cells: [
|
||||||
DataCell(
|
DataCell(
|
||||||
@@ -2387,7 +2399,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
tr(
|
tr(
|
||||||
'msg.userfront.audit.device',
|
'msg.userfront.audit.device',
|
||||||
params: {
|
params: {
|
||||||
'value': _deviceLabelFromUserAgent(log.userAgent),
|
'value': _deviceLabelFromUserAgent(
|
||||||
|
preferredAuditLogUserAgent(log),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -2395,9 +2409,14 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
tr(
|
tr(
|
||||||
'msg.userfront.audit.browser',
|
'msg.userfront.audit.browser',
|
||||||
params: {
|
params: {
|
||||||
'value': _sessionBrowserLabel(log.userAgent).isEmpty
|
'value':
|
||||||
|
_sessionBrowserLabel(
|
||||||
|
preferredAuditLogUserAgent(log),
|
||||||
|
).isEmpty
|
||||||
? tr('ui.common.hyphen', fallback: '-')
|
? tr('ui.common.hyphen', fallback: '-')
|
||||||
: _sessionBrowserLabel(log.userAgent),
|
: _sessionBrowserLabel(
|
||||||
|
preferredAuditLogUserAgent(log),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
59
userfront/test/audit_device_utils_test.dart
Normal file
59
userfront/test/audit_device_utils_test.dart
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:userfront/features/dashboard/domain/models.dart';
|
||||||
|
import 'package:userfront/features/dashboard/presentation/audit_device_utils.dart';
|
||||||
|
|
||||||
|
AuditLogEntry _log({
|
||||||
|
required String eventType,
|
||||||
|
String userAgent = '',
|
||||||
|
Map<String, dynamic>? details,
|
||||||
|
}) {
|
||||||
|
return AuditLogEntry.fromJson({
|
||||||
|
'event_id': 'audit-1',
|
||||||
|
'timestamp': '2026-04-14T00:00:00Z',
|
||||||
|
'user_id': 'user-123',
|
||||||
|
'event_type': eventType,
|
||||||
|
'status': 'success',
|
||||||
|
'user_agent': userAgent,
|
||||||
|
'details': details == null ? '' : details.toString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('headless link login maps internal client user agent to sentinel', () {
|
||||||
|
final log = AuditLogEntry.fromJson({
|
||||||
|
'event_id': 'audit-1',
|
||||||
|
'timestamp': '2026-04-14T00:00:00Z',
|
||||||
|
'user_id': 'user-123',
|
||||||
|
'event_type': 'POST /api/v1/auth/login/code/verify',
|
||||||
|
'status': 'success',
|
||||||
|
'user_agent': 'undici',
|
||||||
|
'details':
|
||||||
|
'{"approved_user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/146.0.0.0 Safari/537.36"}',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(preferredAuditLogUserAgent(log), headlessServerUserAgentSentinel);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'headless password login maps internal client user agent to sentinel',
|
||||||
|
() {
|
||||||
|
final log = _log(
|
||||||
|
eventType: 'POST /api/v1/auth/headless/password/login',
|
||||||
|
userAgent: 'undici',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(preferredAuditLogUserAgent(log), headlessServerUserAgentSentinel);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('non-headless login preserves original browser user agent', () {
|
||||||
|
const browserUa =
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/146.0.0.0 Safari/537.36';
|
||||||
|
final log = _log(
|
||||||
|
eventType: 'POST /api/v1/auth/password/login',
|
||||||
|
userAgent: browserUa,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(preferredAuditLogUserAgent(log), browserUa);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user