1
0
forked from baron/baron-sso

userfront 연동이력 맞춤

This commit is contained in:
Lectom C Han
2026-02-03 13:37:24 +09:00
parent e20b61189c
commit 4f3d0759c3
24 changed files with 4092 additions and 175 deletions

View File

@@ -476,6 +476,7 @@ func main() {
auth.Post("/password/login", authHandler.PasswordLogin) auth.Post("/password/login", authHandler.PasswordLogin)
auth.Get("/consent", authHandler.GetConsentRequest) auth.Get("/consent", authHandler.GetConsentRequest)
auth.Post("/consent/accept", authHandler.AcceptConsentRequest) auth.Post("/consent/accept", authHandler.AcceptConsentRequest)
auth.Post("/oidc/login/accept", authHandler.AcceptOidcLoginRequest)
auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset) auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset)
// [Changed] Use Interstitial Page for GET to prevent Scanner consumption // [Changed] Use Interstitial Page for GET to prevent Scanner consumption

View File

@@ -0,0 +1,30 @@
package domain
import (
"context"
"time"
)
type OathkeeperAccessLog struct {
Timestamp time.Time
RequestID string
Method string
Path string
Status int
LatencyMs int
RP string
Action string
Target string
Subject string
ClientIP string
UserAgent string
Decision string
TraceID string
SpanID string
Raw string
}
type OathkeeperLogRepository interface {
FindPageBySubject(ctx context.Context, subject string, limit int, cursor *AuditCursor) ([]OathkeeperAccessLog, error)
Ping(ctx context.Context) error
}

View File

@@ -521,6 +521,228 @@ func normalizePhoneForLoginID(phone string) string {
return normalized return normalized
} }
func buildOidcClaimsFromTraits(traits map[string]any, scopes []string) map[string]any {
claims := map[string]any{}
if traits == nil {
return claims
}
scopeSet := map[string]struct{}{}
for _, scope := range scopes {
scope = strings.TrimSpace(scope)
if scope == "" {
continue
}
scopeSet[scope] = struct{}{}
}
getString := func(key string) string {
raw, ok := traits[key]
if !ok || raw == nil {
return ""
}
switch value := raw.(type) {
case string:
return strings.TrimSpace(value)
default:
return strings.TrimSpace(fmt.Sprint(value))
}
}
displayName := getString("displayname")
if displayName == "" {
displayName = getString("name")
}
if displayName != "" {
claims["name"] = displayName
}
primaryEmail := getString("primary_email")
if primaryEmail == "" {
primaryEmail = getString("email")
}
if primaryEmail != "" {
claims["email"] = primaryEmail
}
if _, ok := scopeSet["profile"]; ok {
profile := map[string]any{}
names := map[string]any{}
for _, key := range []string{
"name",
"displayname",
"preferred_username",
"given_name",
"family_name",
"middle_name",
"nickname",
} {
if value := getString(key); value != "" {
names[key] = value
}
}
if len(names) > 0 {
profile["names"] = names
}
emails := collectEmailList(traits, primaryEmail)
if len(emails) > 0 {
profile["emails"] = emails
}
if len(profile) > 0 {
claims["profile"] = profile
}
for _, key := range []string{
"department",
"affiliationType",
"companyCode",
"displayname",
"team",
"grade",
"familyCompany",
"taxCode",
"familyUniqueKey",
"personal",
} {
if raw, ok := traits[key]; ok && raw != nil {
switch value := raw.(type) {
case string:
if strings.TrimSpace(value) != "" {
claims[key] = strings.TrimSpace(value)
}
default:
claims[key] = value
}
}
}
}
if _, ok := scopeSet["phone"]; ok {
if phone := getString("phone_number"); phone != "" {
claims["phone_number"] = phone
}
}
return claims
}
func collectEmailList(traits map[string]any, primaryEmail string) []string {
emails := make([]string, 0)
seen := make(map[string]struct{})
add := func(value string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
if _, ok := seen[value]; ok {
return
}
seen[value] = struct{}{}
emails = append(emails, value)
}
add(primaryEmail)
for _, key := range []string{"email", "primary_email"} {
if raw, ok := traits[key]; ok {
if value, ok := raw.(string); ok {
add(value)
}
}
}
if raw, ok := traits["emails"]; ok {
switch value := raw.(type) {
case []string:
for _, email := range value {
add(email)
}
case []any:
for _, email := range value {
add(fmt.Sprint(email))
}
}
}
if raw, ok := traits["secondary_emails"]; ok {
switch value := raw.(type) {
case []string:
for _, email := range value {
add(email)
}
case []any:
for _, email := range value {
add(fmt.Sprint(email))
}
}
}
if raw, ok := traits["additional_emails"]; ok {
switch value := raw.(type) {
case []string:
for _, email := range value {
add(email)
}
case []any:
for _, email := range value {
add(fmt.Sprint(email))
}
}
}
return emails
}
func buildIdentityLookupCandidates(loginID string) []string {
seen := make(map[string]struct{})
add := func(value string) {
candidate := strings.TrimSpace(value)
if candidate == "" {
return
}
if _, ok := seen[candidate]; ok {
return
}
seen[candidate] = struct{}{}
}
normalized := strings.TrimSpace(loginID)
add(normalized)
if normalized != "" {
add(strings.ToLower(normalized))
}
if normalized != "" && !strings.Contains(normalized, "@") {
add(normalizePhoneForLoginID(normalized))
}
candidates := make([]string, 0, len(seen))
for candidate := range seen {
candidates = append(candidates, candidate)
}
return candidates
}
func (h *AuthHandler) resolveKratosIdentityID(ctx context.Context, identifiers ...string) (string, error) {
if h.KratosAdmin == nil {
return "", fmt.Errorf("kratos admin unavailable")
}
for _, identifier := range identifiers {
candidate := strings.TrimSpace(identifier)
if candidate == "" {
continue
}
identityID, err := h.KratosAdmin.FindIdentityIDByIdentifier(ctx, candidate)
if err == nil && identityID != "" {
return identityID, nil
}
}
return "", fmt.Errorf("kratos identity not found")
}
func (h *AuthHandler) resolveKratosIdentityIDFromLoginID(ctx context.Context, loginID string) (string, error) {
candidates := buildIdentityLookupCandidates(loginID)
return h.resolveKratosIdentityID(ctx, candidates...)
}
func (h *AuthHandler) getSignupState(key string) (*signupState, error) { func (h *AuthHandler) getSignupState(key string) (*signupState, error) {
val, err := h.RedisService.Get(key) val, err := h.RedisService.Get(key)
if err != nil || val == "" { if err != nil || val == "" {
@@ -1117,6 +1339,12 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
} }
subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(c.Context(), lookupLoginID)
if resolveErr != nil || subject == "" {
slog.Error("[LoginCode] Failed to resolve kratos identity", "loginID", lookupLoginID, "error", resolveErr)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to resolve user identity"})
}
authInfo.Subject = subject
c.Locals("login_id", lookupLoginID) c.Locals("login_id", lookupLoginID)
setSessionIDLocal(c, authInfo.SessionToken) setSessionIDLocal(c, authInfo.SessionToken)
@@ -1140,7 +1368,7 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
"status": "approved", "status": "approved",
"pendingRef": pendingRef, "pendingRef": pendingRef,
"provider": h.IdpProvider.Name(), "provider": h.IdpProvider.Name(),
"subject": authInfo.Subject, "subject": subject,
"message": "Login approved", "message": "Login approved",
}) })
} }
@@ -1149,7 +1377,7 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
"token": authInfo.SessionToken.JWT, "token": authInfo.SessionToken.JWT,
"sessionJwt": authInfo.SessionToken.JWT, "sessionJwt": authInfo.SessionToken.JWT,
"provider": h.IdpProvider.Name(), "provider": h.IdpProvider.Name(),
"subject": authInfo.Subject, "subject": subject,
"message": "Login successful", "message": "Login successful",
}) })
} }
@@ -1226,6 +1454,12 @@ func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error {
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
} }
subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(c.Context(), payload.LoginID)
if resolveErr != nil || subject == "" {
slog.Error("[LoginShortCode] Failed to resolve kratos identity", "loginID", payload.LoginID, "error", resolveErr)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to resolve user identity"})
}
authInfo.Subject = subject
c.Locals("login_id", payload.LoginID) c.Locals("login_id", payload.LoginID)
setSessionIDLocal(c, authInfo.SessionToken) setSessionIDLocal(c, authInfo.SessionToken)
@@ -1247,7 +1481,7 @@ func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error {
"token": authInfo.SessionToken.JWT, "token": authInfo.SessionToken.JWT,
"sessionJwt": authInfo.SessionToken.JWT, "sessionJwt": authInfo.SessionToken.JWT,
"provider": h.IdpProvider.Name(), "provider": h.IdpProvider.Name(),
"subject": authInfo.Subject, "subject": subject,
"message": "Login approved", "message": "Login approved",
}) })
} }
@@ -1256,7 +1490,7 @@ func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error {
"token": authInfo.SessionToken.JWT, "token": authInfo.SessionToken.JWT,
"sessionJwt": authInfo.SessionToken.JWT, "sessionJwt": authInfo.SessionToken.JWT,
"provider": h.IdpProvider.Name(), "provider": h.IdpProvider.Name(),
"subject": authInfo.Subject, "subject": subject,
"message": "Login successful", "message": "Login successful",
}) })
} }
@@ -1311,6 +1545,13 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"}) return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
} }
subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(c.Context(), loginID)
if resolveErr != nil || subject == "" {
slog.Error("Failed to resolve kratos identity after login", "loginID", loginID, "error", resolveErr)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to resolve user identity")
}
authInfo.Subject = subject
ale.Status = fiber.StatusOK ale.Status = fiber.StatusOK
ale.LatencyMs = time.Since(startTime) ale.LatencyMs = time.Since(startTime)
ale.SessionJwt = authInfo.SessionToken.JWT ale.SessionJwt = authInfo.SessionToken.JWT
@@ -1320,7 +1561,7 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
// --- OIDC 로그인 흐름 처리 --- // --- OIDC 로그인 흐름 처리 ---
if req.LoginChallenge != "" { if req.LoginChallenge != "" {
slog.Info("OIDC login flow detected", "challenge", req.LoginChallenge) slog.Info("OIDC login flow detected", "challenge", req.LoginChallenge)
acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, authInfo.Subject) acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, subject)
if err != nil { if err != nil {
slog.Error("failed to accept hydra login request", "error", err) slog.Error("failed to accept hydra login request", "error", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request") return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
@@ -2326,12 +2567,21 @@ func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load user profile"}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load user profile"})
} }
identityID, resolveErr := h.resolveKratosIdentityID(
c.Context(),
userResponse.Email,
normalizePhoneForLoginID(userResponse.Phone),
)
if resolveErr != nil || identityID == "" {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to resolve user identity"})
}
dept, _ := userResponse.CustomAttributes["department"].(string) dept, _ := userResponse.CustomAttributes["department"].(string)
affType, _ := userResponse.CustomAttributes["affiliationType"].(string) affType, _ := userResponse.CustomAttributes["affiliationType"].(string)
compCode, _ := userResponse.CustomAttributes["companyCode"].(string) compCode, _ := userResponse.CustomAttributes["companyCode"].(string)
resp := domain.UserProfileResponse{ resp := domain.UserProfileResponse{
ID: userResponse.UserID, ID: identityID,
Email: userResponse.Email, Email: userResponse.Email,
Name: userResponse.Name, Name: userResponse.Name,
Phone: h.formatPhoneForDisplay(userResponse.Phone), Phone: h.formatPhoneForDisplay(userResponse.Phone),
@@ -3033,24 +3283,43 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "hydra admin unavailable"}) return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "hydra admin unavailable"})
} }
subject, err := h.resolveConsentSubject(c) subjects, err := h.resolveConsentSubjects(c)
if err != nil || subject == "" { if err != nil || len(subjects) == 0 {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
} }
sessions, err := h.Hydra.ListConsentSessions(c.Context(), subject, "") var sessions []service.HydraConsentSession
if err != nil { var lastErr error
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) hasSuccess := false
for _, subject := range subjects {
subject = strings.TrimSpace(subject)
if subject == "" {
continue
}
linked, listErr := h.Hydra.ListConsentSessions(c.Context(), subject, "")
if listErr != nil {
lastErr = listErr
continue
}
hasSuccess = true
sessions = append(sessions, linked...)
}
if !hasSuccess && lastErr != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": lastErr.Error()})
} }
records := make(map[string]*linkedRpRecord) records := make(map[string]*linkedRpRecord)
for _, session := range sessions { for _, session := range sessions {
clientID := strings.TrimSpace(session.Client.ClientID) client := session.Client
if client.ClientID == "" && session.ConsentRequest != nil {
client = session.ConsentRequest.Client
}
clientID := strings.TrimSpace(client.ClientID)
if clientID == "" { if clientID == "" {
continue continue
} }
name := strings.TrimSpace(session.Client.ClientName) name := strings.TrimSpace(client.ClientName)
if name == "" { if name == "" {
name = clientID name = clientID
} }
@@ -3060,11 +3329,13 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
lastAuth = *session.AuthenticatedAt lastAuth = *session.AuthenticatedAt
} else if session.RequestedAt != nil { } else if session.RequestedAt != nil {
lastAuth = *session.RequestedAt lastAuth = *session.RequestedAt
} else if session.HandledAt != nil {
lastAuth = *session.HandledAt
} }
scopes := session.GrantedScope scopes := session.GrantedScope
if len(scopes) == 0 && strings.TrimSpace(session.Client.Scope) != "" { if len(scopes) == 0 && strings.TrimSpace(client.Scope) != "" {
scopes = strings.Fields(session.Client.Scope) scopes = strings.Fields(client.Scope)
} }
existing := records[clientID] existing := records[clientID]
@@ -3073,8 +3344,8 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
linkedRpSummary: linkedRpSummary{ linkedRpSummary: linkedRpSummary{
ID: clientID, ID: clientID,
Name: name, Name: name,
Logo: extractHydraClientLogo(session.Client.Metadata), Logo: extractHydraClientLogo(client.Metadata),
Status: hydraClientStatus(session.Client.Metadata), Status: hydraClientStatus(client.Metadata),
Scopes: scopes, Scopes: scopes,
}, },
lastAuth: lastAuth, lastAuth: lastAuth,
@@ -3086,7 +3357,7 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
existing.Name = name existing.Name = name
} }
if existing.Logo == "" { if existing.Logo == "" {
existing.Logo = extractHydraClientLogo(session.Client.Metadata) existing.Logo = extractHydraClientLogo(client.Metadata)
} }
existing.Scopes = mergeScopes(existing.Scopes, scopes) existing.Scopes = mergeScopes(existing.Scopes, scopes)
if lastAuth.After(existing.lastAuth) { if lastAuth.After(existing.lastAuth) {
@@ -3145,7 +3416,19 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information") return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information")
} }
acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), req.ConsentChallenge, consentRequest) if consentRequest.Subject == "" {
return fiber.NewError(fiber.StatusInternalServerError, "Consent subject missing")
}
if h.KratosAdmin == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Kratos admin unavailable")
}
identity, err := h.KratosAdmin.GetIdentity(c.Context(), consentRequest.Subject)
if err != nil || identity == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to load identity")
}
sessionClaims := buildOidcClaimsFromTraits(identity.Traits, consentRequest.RequestedScope)
acceptResp, err := h.Hydra.AcceptConsentRequest(c.Context(), req.ConsentChallenge, consentRequest, sessionClaims)
if err != nil { if err != nil {
slog.Error("failed to accept hydra consent request", "error", err) slog.Error("failed to accept hydra consent request", "error", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept consent request") return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept consent request")
@@ -3154,6 +3437,30 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
return c.JSON(acceptResp) return c.JSON(acceptResp)
} }
func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error {
var req struct {
LoginChallenge string `json:"login_challenge"`
}
if err := c.BodyParser(&req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
}
if req.LoginChallenge == "" {
return fiber.NewError(fiber.StatusBadRequest, "login_challenge is required")
}
subject, err := h.resolveConsentSubject(c)
if err != nil || subject == "" {
return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
}
acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, subject)
if err != nil {
slog.Error("failed to accept hydra login request", "error", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
}
return c.JSON(acceptResp)
}
func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) { func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
token := h.getBearerToken(c) token := h.getBearerToken(c)
@@ -3165,11 +3472,19 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
if err != nil { if err != nil {
return nil, err return nil, err
} }
identityID, resolveErr := h.resolveKratosIdentityID(
c.Context(),
userResponse.Email,
normalizePhoneForLoginID(userResponse.Phone),
)
if resolveErr != nil || identityID == "" {
return nil, fmt.Errorf("failed to resolve kratos identity for profile")
}
dept, _ := userResponse.CustomAttributes["department"].(string) dept, _ := userResponse.CustomAttributes["department"].(string)
affType, _ := userResponse.CustomAttributes["affiliationType"].(string) affType, _ := userResponse.CustomAttributes["affiliationType"].(string)
compCode, _ := userResponse.CustomAttributes["companyCode"].(string) compCode, _ := userResponse.CustomAttributes["companyCode"].(string)
return &domain.UserProfileResponse{ return &domain.UserProfileResponse{
ID: userResponse.UserID, ID: identityID,
Email: userResponse.Email, Email: userResponse.Email,
Name: userResponse.Name, Name: userResponse.Name,
Phone: h.formatPhoneForDisplay(userResponse.Phone), Phone: h.formatPhoneForDisplay(userResponse.Phone),
@@ -3197,27 +3512,34 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
func (h *AuthHandler) resolveConsentSubject(c *fiber.Ctx) (string, error) { func (h *AuthHandler) resolveConsentSubject(c *fiber.Ctx) (string, error) {
token := h.getBearerToken(c) token := h.getBearerToken(c)
if token != "" { if token != "" {
if looksLikeJWT(token) && h.DescopeClient != nil && h.KratosAdmin != nil { if looksLikeJWT(token) && h.DescopeClient != nil {
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
if err == nil && authorized { if err == nil && authorized {
userResponse, loadErr := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID) userResponse, loadErr := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID)
if loadErr == nil { if loadErr == nil {
if email := strings.TrimSpace(userResponse.Email); email != "" { identityID, resolveErr := h.resolveKratosIdentityID(
if identityID, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), email); err == nil && identityID != "" { c.Context(),
return identityID, nil userResponse.Email,
} normalizePhoneForLoginID(userResponse.Phone),
} )
if phone := strings.TrimSpace(userResponse.Phone); phone != "" { if resolveErr == nil {
normalized := normalizePhoneForLoginID(phone) return identityID, nil
if identityID, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), normalized); err == nil && identityID != "" {
return identityID, nil
}
} }
} }
return userToken.ID, nil return "", fmt.Errorf("failed to resolve kratos identity for consent subject")
} }
} }
return h.resolveIdentityID(c, token) identityID, resolveErr := h.resolveIdentityID(c, token)
if resolveErr == nil && identityID != "" {
return identityID, nil
}
if cookie := c.Get("Cookie"); cookie != "" {
cookieID, _, cookieErr := h.getKratosIdentityWithCookie(cookie)
if cookieErr == nil && cookieID != "" {
return cookieID, nil
}
}
return "", resolveErr
} }
cookie := c.Get("Cookie") cookie := c.Get("Cookie")
if cookie == "" { if cookie == "" {
@@ -3227,6 +3549,114 @@ func (h *AuthHandler) resolveConsentSubject(c *fiber.Ctx) (string, error) {
return identityID, err return identityID, err
} }
func (h *AuthHandler) resolveConsentSubjects(c *fiber.Ctx) ([]string, error) {
token := h.getBearerToken(c)
if token != "" && looksLikeJWT(token) && h.DescopeClient != nil {
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
if err == nil && authorized {
subjects := make([]string, 0, 2)
userResponse, loadErr := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID)
if loadErr == nil {
subjects = appendLoginIDsFromValues(subjects, userResponse.Email, userResponse.Phone)
identityID, resolveErr := h.resolveKratosIdentityID(
c.Context(),
userResponse.Email,
normalizePhoneForLoginID(userResponse.Phone),
)
if resolveErr == nil && identityID != "" {
subjects = append([]string{identityID}, subjects...)
}
}
return uniqueStrings(subjects), nil
}
}
if token != "" {
identityID, traits, err := h.getKratosIdentity(token)
if err == nil && identityID != "" {
subjects := []string{identityID}
subjects = appendLoginIDsFromTraits(subjects, traits)
return uniqueStrings(subjects), nil
}
}
cookie := c.Get("Cookie")
if cookie == "" {
return nil, fmt.Errorf("missing authorization token")
}
identityID, traits, err := h.getKratosIdentityWithCookie(cookie)
if err != nil {
return nil, err
}
subjects := []string{identityID}
subjects = appendLoginIDsFromTraits(subjects, traits)
return uniqueStrings(subjects), nil
}
func uniqueStrings(items []string) []string {
seen := make(map[string]struct{}, len(items))
result := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
result = append(result, item)
}
return result
}
func appendLoginIDsFromValues(subjects []string, email string, phone string) []string {
if strings.TrimSpace(email) != "" {
subjects = append(subjects, strings.TrimSpace(email))
}
if strings.TrimSpace(phone) != "" {
subjects = append(subjects, normalizePhoneForLoginID(phone))
}
return subjects
}
func appendLoginIDsFromTraits(subjects []string, traits map[string]interface{}) []string {
if traits == nil {
return subjects
}
if raw, ok := traits["email"]; ok {
if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" {
subjects = append(subjects, strings.TrimSpace(value))
}
}
if raw, ok := traits["phone"]; ok {
if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" {
subjects = append(subjects, normalizePhoneForLoginID(value))
}
}
if raw, ok := traits["phone_number"]; ok {
if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" {
subjects = append(subjects, normalizePhoneForLoginID(value))
}
}
if raw, ok := traits["phoneNumber"]; ok {
if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" {
subjects = append(subjects, normalizePhoneForLoginID(value))
}
}
if raw, ok := traits["mobile"]; ok {
if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" {
subjects = append(subjects, normalizePhoneForLoginID(value))
}
}
if raw, ok := traits["mobile_number"]; ok {
if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" {
subjects = append(subjects, normalizePhoneForLoginID(value))
}
}
return subjects
}
func isAuthEventType(eventType string) bool { func isAuthEventType(eventType string) bool {
normalized := strings.ToLower(eventType) normalized := strings.ToLower(eventType)
return strings.Contains(normalized, " /api/v1/auth/") return strings.Contains(normalized, " /api/v1/auth/")
@@ -3662,7 +4092,22 @@ func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, err
if looksLikeJWT(token) && h.DescopeClient != nil { if looksLikeJWT(token) && h.DescopeClient != nil {
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
if err == nil && authorized { if err == nil && authorized {
return userToken.ID, nil if h.KratosAdmin == nil {
return "", fmt.Errorf("kratos admin unavailable")
}
userResponse, loadErr := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID)
if loadErr != nil {
return "", loadErr
}
identityID, resolveErr := h.resolveKratosIdentityID(
c.Context(),
userResponse.Email,
normalizePhoneForLoginID(userResponse.Phone),
)
if resolveErr != nil || identityID == "" {
return "", fmt.Errorf("failed to resolve kratos identity for token")
}
return identityID, nil
} }
} }
id, _, err := h.getKratosIdentity(token) id, _, err := h.getKratosIdentity(token)
@@ -3688,7 +4133,7 @@ func (h *AuthHandler) tryIssueDescopeQrSession(c *fiber.Ctx, token string) (*dom
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
return nil, "", "", fmt.Errorf("descope issue session returned empty token") return nil, "", "", fmt.Errorf("descope issue session returned empty token")
} }
return authInfo.SessionToken, loginID, userToken.ID, nil return authInfo.SessionToken, loginID, "", nil
} }
func (h *AuthHandler) resolveKratosLoginID(token string) (string, error) { func (h *AuthHandler) resolveKratosLoginID(token string) (string, error) {
@@ -4204,16 +4649,24 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load current user"}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load current user"})
} }
identityID, resolveErr := h.resolveKratosIdentityID(
c.Context(),
currentUser.Email,
normalizePhoneForLoginID(currentUser.Phone),
)
if resolveErr != nil || identityID == "" {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to resolve user identity"})
}
newPhoneStorage := h.formatPhoneForStorage(req.Phone) newPhoneStorage := h.formatPhoneForStorage(req.Phone)
oldPhoneStorage := currentUser.Phone oldPhoneStorage := currentUser.Phone
slog.Info("[UpdateMe] Checking changes", "userID", userToken.ID, "oldPhone", oldPhoneStorage, "newPhone", newPhoneStorage, "newName", req.Name) slog.Info("[UpdateMe] Checking changes", "userID", identityID, "oldPhone", oldPhoneStorage, "newPhone", newPhoneStorage, "newName", req.Name)
// 2. Handle Phone Number Change // 2. Handle Phone Number Change
if newPhoneStorage != "" && newPhoneStorage != oldPhoneStorage { if newPhoneStorage != "" && newPhoneStorage != oldPhoneStorage {
// Check verification status in Redis // Check verification status in Redis
verifyKey := "verify_update_phone:" + userToken.ID + ":" + newPhoneStorage verifyKey := "verify_update_phone:" + identityID + ":" + newPhoneStorage
val, _ := h.RedisService.Get(verifyKey) val, _ := h.RedisService.Get(verifyKey)
if val != "verified" { if val != "verified" {
slog.Warn("[UpdateMe] Phone verification missing", "key", verifyKey) slog.Warn("[UpdateMe] Phone verification missing", "key", verifyKey)
@@ -4221,7 +4674,7 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
} }
// Update Phone in Descope and mark as verified // Update Phone in Descope and mark as verified
slog.Info("[UpdateMe] Updating phone number", "userID", userToken.ID, "newPhone", newPhoneStorage) slog.Info("[UpdateMe] Updating phone number", "userID", identityID, "newPhone", newPhoneStorage)
_, err = h.DescopeClient.Management.User().UpdatePhone(c.Context(), userToken.ID, newPhoneStorage, true, false) _, err = h.DescopeClient.Management.User().UpdatePhone(c.Context(), userToken.ID, newPhoneStorage, true, false)
if err != nil { if err != nil {
slog.Error("Failed to update phone in Descope", "error", err) slog.Error("Failed to update phone in Descope", "error", err)
@@ -4250,7 +4703,7 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
// 3. Update Name if changed // 3. Update Name if changed
if req.Name != "" && req.Name != currentUser.Name { if req.Name != "" && req.Name != currentUser.Name {
slog.Info("[UpdateMe] Updating display name", "userID", userToken.ID, "newName", req.Name) slog.Info("[UpdateMe] Updating display name", "userID", identityID, "newName", req.Name)
_, err = h.DescopeClient.Management.User().UpdateDisplayName(c.Context(), userToken.ID, req.Name) _, err = h.DescopeClient.Management.User().UpdateDisplayName(c.Context(), userToken.ID, req.Name)
if err != nil { if err != nil {
slog.Error("Failed to update user name", "error", err) slog.Error("Failed to update user name", "error", err)
@@ -4260,13 +4713,13 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
// 4. Update Custom Attributes (Department) // 4. Update Custom Attributes (Department)
if req.Department != "" { if req.Department != "" {
slog.Info("[UpdateMe] Updating department", "userID", userToken.ID, "dept", req.Department) slog.Info("[UpdateMe] Updating department", "userID", identityID, "dept", req.Department)
if _, err := h.DescopeClient.Management.User().UpdateCustomAttribute(c.Context(), userToken.ID, "department", req.Department); err != nil { if _, err := h.DescopeClient.Management.User().UpdateCustomAttribute(c.Context(), userToken.ID, "department", req.Department); err != nil {
slog.Error("Failed to update department", "error", err) slog.Error("Failed to update department", "error", err)
} }
} }
slog.Info("[UpdateMe] Profile update completed successfully", "userID", userToken.ID) slog.Info("[UpdateMe] Profile update completed successfully", "userID", identityID)
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
"status": "success", "status": "success",

View File

@@ -0,0 +1,201 @@
package handler
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"baron-sso-backend/internal/service"
)
func newOidcLoginTestApp(h *AuthHandler) *fiber.App {
app := fiber.New()
app.Post("/api/v1/auth/oidc/login/accept", h.AcceptOidcLoginRequest)
return app
}
func TestAcceptOidcLoginRequest_CookieOnly(t *testing.T) {
var gotSubject string
var gotChallenge string
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch r.URL.Host {
case "kratos.test":
if r.URL.Path != "/sessions/whoami" {
return httpResponse(r, http.StatusNotFound, "not found"), nil
}
if r.Header.Get("X-Session-Token") != "" {
return httpResponse(r, http.StatusUnauthorized, "invalid token"), nil
}
if r.Header.Get("Cookie") == "" {
return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil
}
return httpJSON(r, http.StatusOK, map[string]interface{}{
"identity": map[string]interface{}{
"id": "kratos-123",
"traits": map[string]interface{}{},
},
}), nil
case "hydra.test":
if r.URL.Path != "/oauth2/auth/requests/login/accept" {
return httpResponse(r, http.StatusNotFound, "not found"), nil
}
gotChallenge = r.URL.Query().Get("login_challenge")
body, _ := io.ReadAll(r.Body)
var payload map[string]interface{}
_ = json.Unmarshal(body, &payload)
if subject, ok := payload["subject"].(string); ok {
gotSubject = subject
}
return httpResponse(r, http.StatusOK, `{"redirect_to":"http://rp/cb"}`), nil
default:
return httpResponse(r, http.StatusNotFound, "not found"), nil
}
})
client := &http.Client{Transport: transport}
origDefault := http.DefaultClient
http.DefaultClient = client
defer func() {
http.DefaultClient = origDefault
}()
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
}
app := newOidcLoginTestApp(h)
body, _ := json.Marshal(map[string]string{
"login_challenge": "challenge-123",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/login/accept", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Cookie", "ory_kratos_session=abc123")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var got map[string]string
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if got["redirectTo"] != "http://rp/cb" {
t.Fatalf("unexpected redirectTo: %v", got["redirectTo"])
}
if gotSubject != "kratos-123" {
t.Fatalf("unexpected subject: %v", gotSubject)
}
if gotChallenge != "challenge-123" {
t.Fatalf("unexpected login_challenge: %v", gotChallenge)
}
}
func TestAcceptOidcLoginRequest_TokenFallbackToCookie(t *testing.T) {
var gotSubject string
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch r.URL.Host {
case "kratos.test":
if r.URL.Path != "/sessions/whoami" {
return httpResponse(r, http.StatusNotFound, "not found"), nil
}
if r.Header.Get("X-Session-Token") != "" {
return httpResponse(r, http.StatusUnauthorized, "invalid token"), nil
}
if r.Header.Get("Cookie") == "" {
return httpResponse(r, http.StatusUnauthorized, "missing cookie"), nil
}
return httpJSON(r, http.StatusOK, map[string]interface{}{
"identity": map[string]interface{}{
"id": "kratos-456",
"traits": map[string]interface{}{},
},
}), nil
case "hydra.test":
if r.URL.Path != "/oauth2/auth/requests/login/accept" {
return httpResponse(r, http.StatusNotFound, "not found"), nil
}
body, _ := io.ReadAll(r.Body)
var payload map[string]interface{}
_ = json.Unmarshal(body, &payload)
if subject, ok := payload["subject"].(string); ok {
gotSubject = subject
}
return httpResponse(r, http.StatusOK, `{"redirect_to":"http://rp/cb"}`), nil
default:
return httpResponse(r, http.StatusNotFound, "not found"), nil
}
})
client := &http.Client{Transport: transport}
origDefault := http.DefaultClient
http.DefaultClient = client
defer func() {
http.DefaultClient = origDefault
}()
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
}
app := newOidcLoginTestApp(h)
body, _ := json.Marshal(map[string]string{
"login_challenge": "challenge-456",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oidc/login/accept", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer invalid-token")
req.Header.Set("Cookie", "ory_kratos_session=def456")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if gotSubject != "kratos-456" {
t.Fatalf("unexpected subject: %v", gotSubject)
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func httpResponse(req *http.Request, status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewBufferString(body)),
Request: req,
}
}
func httpJSON(req *http.Request, status int, payload map[string]interface{}) *http.Response {
data, _ := json.Marshal(payload)
resp := httpResponse(req, status, string(data))
resp.Header.Set("Content-Type", "application/json")
return resp
}

View File

@@ -396,16 +396,26 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
items := make([]consentSummary, 0, len(sessions)) items := make([]consentSummary, 0, len(sessions))
for _, session := range sessions { for _, session := range sessions {
client := session.Client
if client.ClientID == "" && session.ConsentRequest != nil {
client = session.ConsentRequest.Client
}
subject := session.Subject
if subject == "" && session.ConsentRequest != nil {
subject = session.ConsentRequest.Subject
}
authAt := "" authAt := ""
if session.AuthenticatedAt != nil { if session.AuthenticatedAt != nil {
authAt = session.AuthenticatedAt.Format(time.RFC3339) authAt = session.AuthenticatedAt.Format(time.RFC3339)
} else if session.RequestedAt != nil { } else if session.RequestedAt != nil {
authAt = session.RequestedAt.Format(time.RFC3339) authAt = session.RequestedAt.Format(time.RFC3339)
} else if session.HandledAt != nil {
authAt = session.HandledAt.Format(time.RFC3339)
} }
items = append(items, consentSummary{ items = append(items, consentSummary{
Subject: session.Subject, Subject: subject,
ClientID: session.Client.ClientID, ClientID: client.ClientID,
ClientName: session.Client.ClientName, ClientName: client.ClientName,
GrantedScopes: session.GrantedScope, GrantedScopes: session.GrantedScope,
AuthenticatedAt: authAt, AuthenticatedAt: authAt,
}) })

View File

@@ -0,0 +1,106 @@
package repository
import (
"baron-sso-backend/internal/domain"
"context"
"fmt"
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
)
type OathkeeperClickHouseRepository struct {
conn driver.Conn
}
func NewOathkeeperClickHouseRepository(host string, port int, user, password, db string) (*OathkeeperClickHouseRepository, error) {
conn, err := clickhouse.Open(&clickhouse.Options{
Addr: []string{fmt.Sprintf("%s:%d", host, port)},
Auth: clickhouse.Auth{
Database: db,
Username: user,
Password: password,
},
Debug: false,
})
if err != nil {
return nil, fmt.Errorf("failed to open ory clickhouse connection: %w", err)
}
if err := conn.Ping(context.Background()); err != nil {
return nil, fmt.Errorf("failed to ping ory clickhouse: %w", err)
}
return &OathkeeperClickHouseRepository{conn: conn}, nil
}
func (r *OathkeeperClickHouseRepository) FindPageBySubject(ctx context.Context, subject string, limit int, cursor *domain.AuditCursor) ([]domain.OathkeeperAccessLog, error) {
if limit <= 0 {
limit = 50
}
query := `
SELECT timestamp, request_id, method, path, status, latency_ms, rp, action, target, subject, client_ip, user_agent, decision, trace_id, span_id, raw
FROM oathkeeper_access_logs
`
args := make([]any, 0, 5)
if subject != "" {
query += `
WHERE subject = ?
`
args = append(args, subject)
if cursor != nil {
query += `
AND ((timestamp < ?) OR (timestamp = ? AND request_id < ?))
`
args = append(args, cursor.Timestamp, cursor.Timestamp, cursor.EventID)
}
} else if cursor != nil {
query += `
WHERE (timestamp < ?) OR (timestamp = ? AND request_id < ?)
`
args = append(args, cursor.Timestamp, cursor.Timestamp, cursor.EventID)
}
query += `
ORDER BY timestamp DESC, request_id DESC
LIMIT ?
`
args = append(args, limit)
rows, err := r.conn.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query oathkeeper logs: %w", err)
}
defer rows.Close()
var logs []domain.OathkeeperAccessLog
for rows.Next() {
var log domain.OathkeeperAccessLog
if err := rows.Scan(
&log.Timestamp,
&log.RequestID,
&log.Method,
&log.Path,
&log.Status,
&log.LatencyMs,
&log.RP,
&log.Action,
&log.Target,
&log.Subject,
&log.ClientIP,
&log.UserAgent,
&log.Decision,
&log.TraceID,
&log.SpanID,
&log.Raw,
); err != nil {
return nil, fmt.Errorf("failed to scan oathkeeper log: %w", err)
}
logs = append(logs, log)
}
return logs, nil
}
func (r *OathkeeperClickHouseRepository) Ping(ctx context.Context) error {
if r == nil || r.conn == nil {
return fmt.Errorf("ory clickhouse connection is nil")
}
return r.conn.Ping(ctx)
}

View File

@@ -135,7 +135,8 @@ func (d *DescopeProvider) SignIn(loginID, password string) (*domain.AuthInfo, er
Expiration: time.Unix(authInfo.SessionToken.Expiration, 0), Expiration: time.Unix(authInfo.SessionToken.Expiration, 0),
SessionID: authInfo.SessionToken.ID, SessionID: authInfo.SessionToken.ID,
}, },
Subject: authInfo.User.UserID, // 내부 식별자는 Kratos identity ID로 통일합니다.
Subject: "",
} }
if authInfo.RefreshToken != nil { if authInfo.RefreshToken != nil {
res.RefreshToken = &domain.Token{ res.RefreshToken = &domain.Token{
@@ -204,7 +205,8 @@ func (d *DescopeProvider) IssueSession(loginID string) (*domain.AuthInfo, error)
Expiration: time.Unix(authInfo.SessionToken.Expiration, 0), Expiration: time.Unix(authInfo.SessionToken.Expiration, 0),
SessionID: authInfo.SessionToken.ID, SessionID: authInfo.SessionToken.ID,
}, },
Subject: authInfo.User.UserID, // 내부 식별자는 Kratos identity ID로 통일합니다.
Subject: "",
} }
if authInfo.RefreshToken != nil { if authInfo.RefreshToken != nil {
res.RefreshToken = &domain.Token{ res.RefreshToken = &domain.Token{

View File

@@ -46,13 +46,17 @@ type HydraConsentRequest struct {
} }
type HydraConsentSession struct { type HydraConsentSession struct {
Subject string `json:"subject"` ConsentRequestID string `json:"consent_request_id,omitempty"`
GrantedScope []string `json:"granted_scope"` Subject string `json:"subject,omitempty"`
GrantedAudience []string `json:"granted_audience,omitempty"` GrantedScope []string `json:"grant_scope,omitempty"`
Remember bool `json:"remember"` GrantedAudience []string `json:"grant_access_token_audience,omitempty"`
AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"` Remember bool `json:"remember"`
RequestedAt *time.Time `json:"requested_at,omitempty"` RememberFor int `json:"remember_for,omitempty"`
Client HydraClient `json:"client"` AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"`
RequestedAt *time.Time `json:"requested_at,omitempty"`
HandledAt *time.Time `json:"handled_at,omitempty"`
Client HydraClient `json:"client,omitempty"`
ConsentRequest *HydraConsentRequest `json:"consent_request,omitempty"`
} }
func NewHydraAdminService() *HydraAdminService { func NewHydraAdminService() *HydraAdminService {
@@ -267,13 +271,13 @@ func (s *HydraAdminService) ListConsentSessions(ctx context.Context, subject, cl
} }
defer resp.Body.Close() defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode >= 300 { if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("hydra admin: list consent sessions failed status=%d body=%s", resp.StatusCode, string(body)) return nil, fmt.Errorf("hydra admin: list consent sessions failed status=%d body=%s", resp.StatusCode, string(body))
} }
var sessions []HydraConsentSession var sessions []HydraConsentSession
if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil { if err := json.Unmarshal(body, &sessions); err != nil {
return nil, fmt.Errorf("hydra admin: decode consent sessions failed: %w", err) return nil, fmt.Errorf("hydra admin: decode consent sessions failed: %w", err)
} }
return sessions, nil return sessions, nil
@@ -398,7 +402,7 @@ func (s *HydraAdminService) GetConsentRequest(ctx context.Context, challenge str
return &consentReq, nil return &consentReq, nil
} }
func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge string, grantInfo *HydraConsentRequest) (*AcceptConsentRequestResponse, error) { func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge string, grantInfo *HydraConsentRequest, sessionClaims map[string]any) (*AcceptConsentRequestResponse, error) {
params := map[string]string{ params := map[string]string{
"consent_challenge": challenge, "consent_challenge": challenge,
} }
@@ -413,6 +417,12 @@ func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge
"remember": true, "remember": true,
"remember_for": 3600, "remember_for": 3600,
} }
if len(sessionClaims) > 0 {
payload["session"] = map[string]any{
"id_token": sessionClaims,
"access_token": sessionClaims,
}
}
body, _ := json.Marshal(payload) body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, "PUT", endpoint, bytes.NewReader(body)) req, err := http.NewRequestWithContext(ctx, "PUT", endpoint, bytes.NewReader(body))
@@ -443,7 +453,6 @@ func (s *HydraAdminService) AcceptConsentRequest(ctx context.Context, challenge
return &AcceptConsentRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil return &AcceptConsentRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil
} }
func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge string, subject string) (*AcceptLoginRequestResponse, error) { func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge string, subject string) (*AcceptLoginRequestResponse, error) {
params := map[string]string{ params := map[string]string{
"login_challenge": challenge, "login_challenge": challenge,

View File

@@ -144,6 +144,8 @@ services:
environment: environment:
- APP_ENV=${APP_ENV:-development} - APP_ENV=${APP_ENV:-development}
- LOG_LEVEL=debug - LOG_LEVEL=debug
- OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}
- OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}
volumes: volumes:
- ./docker/ory/oathkeeper:/etc/config/oathkeeper - ./docker/ory/oathkeeper:/etc/config/oathkeeper
- ./docker/ory/oathkeeper/logs:/var/log/oathkeeper - ./docker/ory/oathkeeper/logs:/var/log/oathkeeper
@@ -201,6 +203,8 @@ services:
image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} image: oryd/hydra:${HYDRA_VERSION:-v25.4.0}
environment: environment:
- HYDRA_ADMIN_URL=http://hydra:4445 - HYDRA_ADMIN_URL=http://hydra:4445
- OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}
- OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}
command: | command: |
hydra clients create \ hydra clients create \
--endpoint http://hydra:4445 \ --endpoint http://hydra:4445 \
@@ -220,6 +224,14 @@ services:
--token-endpoint-auth-method none \ --token-endpoint-auth-method none \
--response-types code \ --response-types code \
--callbacks http://localhost:5174/callback; --callbacks http://localhost:5174/callback;
hydra clients create \
--endpoint http://hydra:4445 \
--id "$OATHKEEPER_INTROSPECT_CLIENT_ID" \
--secret "$OATHKEEPER_INTROSPECT_CLIENT_SECRET" \
--grant-types client_credentials \
--response-types token \
--scope openid,offline_access,profile,email;
depends_on: depends_on:
ory_stack_check: ory_stack_check:
condition: service_completed_successfully condition: service_completed_successfully

View File

@@ -26,6 +26,23 @@ authenticators:
preserve_path: true preserve_path: true
extra_from: "@this" extra_from: "@this"
subject_from: "identity.id" subject_from: "identity.id"
oauth2_introspection:
enabled: true
config:
introspection_url: http://hydra:4444/oauth2/introspect
pre_authorization:
enabled: true
client_id: ${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}
client_secret: ${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}
token_url: http://hydra:4444/oauth2/token
jwt:
enabled: true
config:
jwks_urls:
- http://hydra:4444/.well-known/jwks.json
trusted_issuers:
- http://hydra:4444/
scope_strategy: none
authorizers: authorizers:
allow: allow:

View File

@@ -86,30 +86,20 @@
"mutators": [{ "handler": "noop" }] "mutators": [{ "handler": "noop" }]
}, },
{ {
"id": "rp-template-browser", "id": "rp-host-template",
"description": "RP proxy (browser session). TODO: match.url/upstream.url을 실제 RP로 좁혀야 함.", "description": "RP 호스트 기반 템플릿. redirect_uri의 host를 기준으로 매칭합니다.",
"match": { "match": {
"url": "http://<.*>/rp/<.*>", "url": "<.*>://rp.example.com/<.*>",
"methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] "methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
}, },
"upstream": { "upstream": {
"url": "http://rp_upstream:8080" "url": "http://rp_upstream:8080"
}, },
"authenticators": [{ "handler": "cookie_session" }], "authenticators": [
"authorizer": { "handler": "allow" }, { "handler": "cookie_session" },
"mutators": [{ "handler": "noop" }] { "handler": "oauth2_introspection" },
}, { "handler": "jwt" }
{ ],
"id": "rp-template-bearer",
"description": "RP proxy (bearer). TODO: oauth2_introspection 또는 jwt 활성화 필요.",
"match": {
"url": "http://<.*>/rp-api/<.*>",
"methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
},
"upstream": {
"url": "http://rp_upstream:8080"
},
"authenticators": [{ "handler": "oauth2_introspection" }],
"authorizer": { "handler": "allow" }, "authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }] "mutators": [{ "handler": "noop" }]
} }

View File

@@ -46,7 +46,10 @@
.action = parsed.action ?? "" .action = parsed.action ?? ""
.target = parsed.target ?? "" .target = parsed.target ?? ""
.rule_id = parsed.rule_id ?? get(parsed, ["rule", "id"]) ?? "" .rule_id = parsed.rule_id ?? get(parsed, ["rule", "id"]) ?? ""
.client_id = parsed.client_id ?? get(parsed, ["client", "id"]) ?? "" parsed_url = {}
if request_url != "" { parsed_url = parse_url(request_url) ?? {} }
query_params = get(parsed_url, ["query"]) ?? {}
.client_id = parsed.client_id ?? get(parsed, ["client", "id"]) ?? get(query_params, ["client_id"]) ?? get(query_params, ["clientId"]) ?? ""
.parent_session_id = parsed.parent_session_id ?? get(parsed, ["extra", "parent_session_id"]) ?? "" .parent_session_id = parsed.parent_session_id ?? get(parsed, ["extra", "parent_session_id"]) ?? ""
.host = parsed.host ?? request_host ?? "" .host = parsed.host ?? request_host ?? ""
.scheme = parsed.scheme ?? request_scheme ?? "" .scheme = parsed.scheme ?? request_scheme ?? ""

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
const Map<String, String> errorWhitelistMessages = {
'settings_disabled': '현재 계정 설정 화면은 준비 중입니다.',
'invalid_session': '세션이 만료되었습니다. 다시 로그인해 주세요.',
'verification_required': '추가 인증이 필요합니다. 안내에 따라 진행해 주세요.',
'recovery_expired': '재설정 링크가 만료되었습니다. 다시 요청해 주세요.',
'recovery_invalid': '재설정 링크가 유효하지 않습니다.',
'consent_required': '앱 접근 동의가 필요합니다.',
'rate_limited': '요청이 많습니다. 잠시 후 다시 시도해 주세요.',
'not_found': '요청한 페이지를 찾을 수 없습니다.',
'bad_request': '입력값을 확인해 주세요.',
};

View File

@@ -2,7 +2,7 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'http_client.dart'; import 'http_client.dart';
import 'dart:html' as html; import 'web_window.dart';
class AuthProxyService { class AuthProxyService {
static String _envOrDefault(String key, String fallback) { static String _envOrDefault(String key, String fallback) {
@@ -215,7 +215,7 @@ class AuthProxyService {
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = jsonDecode(response.body); final data = jsonDecode(response.body);
if (data['redirectTo'] != null && data['redirectTo'].isNotEmpty) { if (data['redirectTo'] != null && data['redirectTo'].isNotEmpty) {
html.window.location.href = data['redirectTo']; webWindow.redirectTo(data['redirectTo']);
} }
return data; return data;
} else { } else {
@@ -254,6 +254,36 @@ class AuthProxyService {
} }
} }
static Future<Map<String, dynamic>> acceptOidcLogin(
String loginChallenge, {
String? token,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/oidc/login/accept');
final headers = <String, String>{
'Content-Type': 'application/json',
};
if (token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
final client = createHttpClient(withCredentials: true);
try {
final response = await client.post(
url,
headers: headers,
body: jsonEncode({'login_challenge': loginChallenge}),
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
final errorBody = jsonDecode(response.body);
throw Exception(errorBody['error'] ?? 'Failed to accept OIDC login');
}
} finally {
client.close();
}
}
static Future<Map<String, dynamic>> initiatePasswordReset(String loginId, {bool? drySend}) async { static Future<Map<String, dynamic>> initiatePasswordReset(String loginId, {bool? drySend}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/initiate'); final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/initiate');
final response = await http.post( final response = await http.post(

View File

@@ -0,0 +1 @@
export 'web_window_stub.dart' if (dart.library.html) 'web_window_web.dart';

View File

@@ -0,0 +1,17 @@
class WebWindow {
void redirectTo(String url) {}
void alert(String message) {}
void close() {}
bool hasOpener() {
return false;
}
bool redirectOpenerTo(String url) {
return false;
}
}
final webWindow = WebWindow();

View File

@@ -0,0 +1,34 @@
import 'dart:html' as html;
class WebWindow {
void redirectTo(String url) {
html.window.location.href = url;
}
void alert(String message) {
html.window.alert(message);
}
void close() {
html.window.close();
}
bool hasOpener() {
return html.window.opener != null;
}
bool redirectOpenerTo(String url) {
final opener = html.window.opener;
if (opener == null) {
return false;
}
try {
opener.location.href = url;
return true;
} catch (_) {
return false;
}
}
}
final webWindow = WebWindow();

View File

@@ -1,7 +1,6 @@
import 'dart:html' as html;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:userfront/core/services/auth_proxy_service.dart'; import 'package:userfront/core/services/auth_proxy_service.dart';
import 'package:userfront/core/services/web_window.dart';
class ConsentScreen extends StatefulWidget { class ConsentScreen extends StatefulWidget {
final String consentChallenge; final String consentChallenge;
@@ -13,8 +12,15 @@ class ConsentScreen extends StatefulWidget {
} }
class _ConsentScreenState extends State<ConsentScreen> { class _ConsentScreenState extends State<ConsentScreen> {
static const _ink = Color(0xFF1A1F2C);
static const _surface = Colors.white;
static const _border = Color(0xFFE5E7EB);
static const _subtle = Color(0xFFF7F8FA);
static const _accent = Color(0xFF2563EB);
Map<String, dynamic>? _consentInfo; Map<String, dynamic>? _consentInfo;
bool _isLoading = true; bool _isLoading = true;
bool _isSubmitting = false;
String? _error; String? _error;
@override @override
@@ -32,7 +38,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
}); });
} catch (e) { } catch (e) {
setState(() { setState(() {
_error = 'Failed to load consent information: $e'; _error = '권한 정보를 불러오지 못했습니다: $e';
_isLoading = false; _isLoading = false;
}); });
} }
@@ -40,83 +46,498 @@ class _ConsentScreenState extends State<ConsentScreen> {
Future<void> _acceptConsent() async { Future<void> _acceptConsent() async {
setState(() { setState(() {
_isLoading = true; _isSubmitting = true;
_error = null; _error = null;
}); });
try { try {
final result = final result =
await AuthProxyService.acceptConsent(widget.consentChallenge); await AuthProxyService.acceptConsent(widget.consentChallenge);
if (result['redirectTo'] != null) { final redirectTo = result['redirectTo']?.toString() ?? '';
html.window.location.href = result['redirectTo']; if (redirectTo.isNotEmpty) {
} else { if (webWindow.hasOpener() && webWindow.redirectOpenerTo(redirectTo)) {
setState(() { // 팝업에서 호출된 경우, 부모 창으로 리다이렉트 후 현재 창을 닫습니다.
_error = 'Consent accepted, but no redirect URL received.'; webWindow.close();
_isLoading = false; return;
}); }
webWindow.redirectTo(redirectTo);
return;
} }
setState(() {
_error = '동의는 완료됐지만 이동할 주소를 받지 못했습니다.';
});
} catch (e) { } catch (e) {
setState(() { setState(() {
_error = 'Failed to accept consent: $e'; _error = '동의 처리 중 오류가 발생했습니다: $e';
_isLoading = false;
}); });
} finally {
if (mounted) {
setState(() => _isSubmitting = false);
}
} }
} }
void _rejectConsent() {
webWindow.alert('동의를 취소했습니다. 창을 닫아 주세요.');
}
Map<String, dynamic>? _client() {
final info = _consentInfo;
if (info == null) return null;
final client = info['client'];
if (client is Map<String, dynamic>) {
return client;
}
return null;
}
String _resolveClientName(Map<String, dynamic>? client) {
final name = client?['client_name']?.toString().trim();
if (name != null && name.isNotEmpty) {
return name;
}
final id = client?['client_id']?.toString().trim();
if (id != null && id.isNotEmpty) {
return id;
}
return '알 수 없는 앱';
}
String? _resolveClientId(Map<String, dynamic>? client) {
final id = client?['client_id']?.toString().trim();
if (id != null && id.isNotEmpty) {
return id;
}
return null;
}
String? _resolveClientLogo(Map<String, dynamic>? client) {
final logo = client?['logo_uri']?.toString().trim();
if (logo != null && logo.isNotEmpty) {
return logo;
}
final metadata = client?['metadata'];
if (metadata is Map<String, dynamic>) {
final metaLogo = metadata['logo_url']?.toString().trim();
if (metaLogo != null && metaLogo.isNotEmpty) {
return metaLogo;
}
}
return null;
}
List<String> _requestedScopes() {
final scopes = _consentInfo?['requested_scope'];
if (scopes is List) {
return scopes.map((e) => e.toString()).toList();
}
return const [];
}
String _scopeDescription(String scope) {
switch (scope) {
case 'openid':
return '로그인 상태 확인을 위한 기본 식별자';
case 'profile':
return '이름, 사용자 식별자 등 기본 프로필 정보';
case 'email':
return '이메일 주소 정보';
case 'phone':
return '휴대폰 번호 정보';
default:
return '앱에서 요청한 추가 권한';
}
}
Widget _buildInfoChip(IconData icon, String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: _subtle,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: _border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: _ink),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: _ink,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final client = _client();
final clientName = _resolveClientName(client);
final clientId = _resolveClientId(client);
final logoUrl = _resolveClientLogo(client);
final scopes = _requestedScopes();
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Grant Access')), backgroundColor: _subtle,
body: Center( body: SafeArea(
child: _isLoading child: Center(
? const CircularProgressIndicator() child: SingleChildScrollView(
: _error != null padding: const EdgeInsets.all(24),
? Text(_error!, style: const TextStyle(color: Colors.red)) child: ConstrainedBox(
: _consentInfo != null constraints: const BoxConstraints(maxWidth: 560),
? Card( child: Container(
elevation: 4, decoration: BoxDecoration(
margin: const EdgeInsets.all(16), color: _surface,
child: Padding( borderRadius: BorderRadius.circular(20),
padding: const EdgeInsets.all(24.0), border: Border.all(color: _border),
child: Column( boxShadow: [
mainAxisSize: MainAxisSize.min, BoxShadow(
children: [ color: Colors.black.withOpacity(0.04),
Text( blurRadius: 18,
'${_consentInfo!['client']?['client_name'] ?? 'An application'} wants to access your account', offset: const Offset(0, 8),
style: Theme.of(context).textTheme.headlineSmall, ),
textAlign: TextAlign.center, ],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(28, 28, 28, 24),
child: _isLoading
? Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
'권한 정보를 불러오는 중입니다...',
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
), ),
const SizedBox(height: 24), ),
const Text('This will allow the application to:'), ],
const SizedBox(height: 16), )
if (_consentInfo!['requested_scope'] != null) : _consentInfo == null
...(_consentInfo!['requested_scope'] as List) ? Column(
.map((scope) => ListTile( mainAxisSize: MainAxisSize.min,
leading: const Icon(Icons.check_circle_outline), crossAxisAlignment: CrossAxisAlignment.start,
title: Text(scope.toString()), children: [
)) Text(
.toList(), '요청 정보를 확인할 수 없습니다.',
const SizedBox(height: 24), style: theme.textTheme.bodyMedium?.copyWith(
Row( color: Colors.grey[700],
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextButton(
onPressed: () {
// TODO: Implement reject consent
html.window.alert('Consent rejected. You can close this window.');
},
child: const Text('Deny'),
), ),
ElevatedButton( ),
onPressed: _acceptConsent, if (_error != null) ...[
child: const Text('Allow'), const SizedBox(height: 12),
Text(
_error!,
style: theme.textTheme.bodySmall?.copyWith(
color: const Color(0xFFB91C1C),
),
), ),
], ],
) const SizedBox(height: 16),
], OutlinedButton(
), onPressed: _fetchConsentInfo,
), style: OutlinedButton.styleFrom(
) foregroundColor: _ink,
: const Text('No consent information available.'), side: const BorderSide(color: _border),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: const Text('다시 시도'),
),
],
)
: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: _subtle,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: _border),
),
child: logoUrl == null
? const Icon(
Icons.lock_outline,
color: _ink,
)
: ClipRRect(
borderRadius:
BorderRadius.circular(14),
child: Image.network(
logoUrl,
fit: BoxFit.cover,
errorBuilder: (
context,
error,
stackTrace,
) {
return const Icon(
Icons.lock_outline,
color: _ink,
);
},
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'앱 권한 요청',
style: theme.textTheme.titleLarge
?.copyWith(
fontWeight: FontWeight.w700,
color: _ink,
),
),
const SizedBox(height: 6),
Text(
clientName,
style: theme.textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.w600,
color: _ink,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (clientId != null)
_buildInfoChip(
Icons.vpn_key_outlined,
'Client ID: $clientId',
),
_buildInfoChip(
Icons.security_outlined,
'요청 권한 ${scopes.length}',
),
],
),
],
),
),
],
),
const SizedBox(height: 16),
Text(
'이 앱이 아래 정보에 접근하려고 합니다. 계속 진행하려면 동의 여부를 선택해 주세요.',
style: theme.textTheme.bodyMedium?.copyWith(
color: Colors.grey[700],
height: 1.5,
),
),
if (_error != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFFEE2E2),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0xFFFCA5A5),
),
),
child: Row(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
const Icon(
Icons.error_outline,
color: Color(0xFFB91C1C),
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
_error!,
style: theme.textTheme.bodySmall
?.copyWith(
color: const Color(0xFFB91C1C),
height: 1.4,
),
),
),
],
),
),
],
const SizedBox(height: 20),
Text(
'요청된 권한',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: _ink,
),
),
const SizedBox(height: 12),
if (scopes.isEmpty)
Text(
'요청된 권한 정보가 없습니다.',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
)
else
Column(
children: scopes
.map(
(scope) => Container(
margin: const EdgeInsets.only(
bottom: 10,
),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: _subtle,
borderRadius:
BorderRadius.circular(12),
border:
Border.all(color: _border),
),
child: Row(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
const Icon(
Icons.check_circle_outline,
color: _accent,
size: 20,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment
.start,
children: [
Text(
scope,
style: theme.textTheme
.bodyMedium
?.copyWith(
fontWeight:
FontWeight.w600,
color: _ink,
),
),
const SizedBox(height: 4),
Text(
_scopeDescription(
scope),
style: theme.textTheme
.bodySmall
?.copyWith(
color:
Colors.grey[600],
),
),
],
),
),
],
),
),
)
.toList(),
),
const SizedBox(height: 12),
Text(
'동의 후 자동으로 서비스로 이동합니다.',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: 20),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
OutlinedButton(
onPressed: _isSubmitting
? null
: _rejectConsent,
style: OutlinedButton.styleFrom(
foregroundColor: _ink,
padding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 12,
),
side: const BorderSide(
color: _border,
),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(10),
),
),
child: const Text('취소'),
),
FilledButton(
onPressed: _isSubmitting
? null
: _acceptConsent,
style: FilledButton.styleFrom(
backgroundColor: _ink,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(10),
),
),
child: _isSubmitting
? Row(
mainAxisSize: MainAxisSize.min,
children: const [
SizedBox(
width: 16,
height: 16,
child:
CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
),
SizedBox(width: 8),
Text('처리 중...'),
],
)
: const Text('동의하고 계속하기'),
),
],
),
],
),
),
),
),
),
),
), ),
); );
} }

View File

@@ -7,18 +7,20 @@ class ErrorScreen extends StatelessWidget {
final String? errorId; final String? errorId;
final String? errorCode; final String? errorCode;
final String? description; final String? description;
final bool? isProdOverride;
const ErrorScreen({ const ErrorScreen({
super.key, super.key,
this.errorId, this.errorId,
this.errorCode, this.errorCode,
this.description, this.description,
this.isProdOverride,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final isProd = AuthProxyService.isProdEnv; final isProd = isProdOverride ?? AuthProxyService.isProdEnv;
final normalizedCode = (errorCode ?? '').trim(); final normalizedCode = (errorCode ?? '').trim();
final hasCode = normalizedCode.isNotEmpty; final hasCode = normalizedCode.isNotEmpty;
final whitelistMessage = errorWhitelistMessages[normalizedCode]; final whitelistMessage = errorWhitelistMessages[normalizedCode];

View File

@@ -10,7 +10,7 @@ import '../../../core/services/auth_proxy_service.dart';
import '../../../core/services/auth_token_store.dart'; import '../../../core/services/auth_token_store.dart';
import '../../../core/notifiers/auth_notifier.dart'; import '../../../core/notifiers/auth_notifier.dart';
import '../../profile/domain/notifiers/profile_notifier.dart'; import '../../profile/domain/notifiers/profile_notifier.dart';
import 'dart:html' as html; import '../../../core/services/web_window.dart';
class LoginScreen extends ConsumerStatefulWidget { class LoginScreen extends ConsumerStatefulWidget {
final String? verificationToken; final String? verificationToken;
@@ -109,10 +109,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
return; return;
} }
final pendingProvider = AuthTokenStore.getPendingProvider(); final pendingProvider = AuthTokenStore.getPendingProvider();
final provider = pendingProvider ?? AuthTokenStore.getProvider(); final provider = pendingProvider ?? AuthTokenStore.getProvider() ?? 'ory';
if (provider == null || !provider.toLowerCase().contains('ory')) {
return;
}
try { try {
await AuthProxyService.checkCookieSession(); await AuthProxyService.checkCookieSession();
@@ -657,7 +654,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (mounted) Navigator.of(context).pop(); if (mounted) Navigator.of(context).pop();
if (redirectTo != null && redirectTo.isNotEmpty) { if (redirectTo != null && redirectTo.isNotEmpty) {
html.window.location.href = redirectTo; webWindow.redirectTo(redirectTo);
return; return;
} }
@@ -885,6 +882,24 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
debugPrint("[Auth] Failed to pre-fetch profile: $e"); debugPrint("[Auth] Failed to pre-fetch profile: $e");
} }
if (_loginChallenge != null && _loginChallenge!.isNotEmpty) {
try {
final res = await AuthProxyService.acceptOidcLogin(
_loginChallenge!,
token: token,
);
final redirectTo = res['redirectTo'] as String?;
if (redirectTo != null && redirectTo.isNotEmpty) {
debugPrint("[Auth] OIDC login accepted. Redirecting to: $redirectTo");
webWindow.redirectTo(redirectTo);
return;
}
} catch (e) {
_showError("OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요.");
return;
}
}
if (WebAuthIntegration.isPopup()) { if (WebAuthIntegration.isPopup()) {
debugPrint("[Auth] Popup detected. Notifying opener and attempting to close."); debugPrint("[Auth] Popup detected. Notifying opener and attempting to close.");
WebAuthIntegration.sendLoginSuccess(token); WebAuthIntegration.sendLoginSuccess(token);

View File

@@ -16,6 +16,7 @@ class _QRScanScreenState extends State<QRScanScreen> {
final _log = Logger('QRScanScreen'); final _log = Logger('QRScanScreen');
final MobileScannerController controller = MobileScannerController( final MobileScannerController controller = MobileScannerController(
detectionSpeed: DetectionSpeed.noDuplicates, detectionSpeed: DetectionSpeed.noDuplicates,
autoStart: false,
); );
bool _isScanned = false; bool _isScanned = false;
bool _isCheckingSession = false; bool _isCheckingSession = false;
@@ -28,6 +29,9 @@ class _QRScanScreenState extends State<QRScanScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_bootstrapCookieSession(); _bootstrapCookieSession();
WidgetsBinding.instance.addPostFrameCallback((_) {
_startScannerIfNeeded();
});
} }
Future<bool> _bootstrapCookieSession() async { Future<bool> _bootstrapCookieSession() async {
@@ -52,6 +56,28 @@ class _QRScanScreenState extends State<QRScanScreen> {
} }
} }
Future<void> _startScannerIfNeeded() async {
if (controller.value.isRunning || controller.value.isStarting) {
return;
}
try {
await controller.start();
} catch (e) {
_log.warning('Scanner start failed: $e');
}
}
Future<void> _stopScannerIfRunning() async {
if (!controller.value.isRunning && !controller.value.isStarting) {
return;
}
try {
await controller.stop();
} catch (e) {
_log.warning('Scanner stop failed: $e');
}
}
@override @override
void dispose() { void dispose() {
controller.dispose(); controller.dispose();
@@ -65,6 +91,7 @@ class _QRScanScreenState extends State<QRScanScreen> {
for (final barcode in barcodes) { for (final barcode in barcodes) {
if (barcode.rawValue != null) { if (barcode.rawValue != null) {
_isScanned = true; _isScanned = true;
await _stopScannerIfRunning();
if (mounted) { if (mounted) {
setState(() => _isProcessing = true); setState(() => _isProcessing = true);
} }
@@ -142,14 +169,14 @@ class _QRScanScreenState extends State<QRScanScreen> {
_isSuccess = null; _isSuccess = null;
_resultMessage = null; _resultMessage = null;
}); });
controller.start(); _startScannerIfNeeded();
} }
Future<void> _requestCameraPermission() async { Future<void> _requestCameraPermission() async {
if (_isRequestingCamera) return; if (_isRequestingCamera) return;
setState(() => _isRequestingCamera = true); setState(() => _isRequestingCamera = true);
try { try {
await controller.start(); await _startScannerIfNeeded();
} catch (e) { } catch (e) {
_log.warning('Camera permission request failed: $e'); _log.warning('Camera permission request failed: $e');
if (mounted) { if (mounted) {

View File

@@ -791,6 +791,23 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
); );
} }
if (activities.isEmpty) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'연동된 RP가 없습니다.',
style: TextStyle(fontSize: 14, color: Colors.grey[700], fontWeight: FontWeight.w600),
),
const SizedBox(height: 6),
Text(
'RP를 연동하면 최근 활동과 상태가 표시됩니다.',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
);
}
return grid; return grid;
}, },
); );
@@ -815,33 +832,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
); );
} }
items.addAll([
_ActivityItem(
appName: 'BEPs',
lastAuthAt: '연동 필요',
status: '미연동',
canLogout: false,
),
_ActivityItem(
appName: 'KNGIL',
lastAuthAt: '연동 필요',
status: '미연동',
canLogout: false,
),
_ActivityItem(
appName: 'C.E.L',
lastAuthAt: '연동 필요',
status: '미연동',
canLogout: false,
),
_ActivityItem(
appName: 'EG-BIM',
lastAuthAt: '연동 필요',
status: '미연동',
canLogout: false,
),
]);
return items; return items;
} }

View File

@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:userfront/features/auth/presentation/error_screen.dart';
Future<void> _pumpErrorScreen(
WidgetTester tester, {
String? errorCode,
String? description,
bool? isProdOverride,
}) async {
await tester.pumpWidget(
MaterialApp(
home: ErrorScreen(
errorCode: errorCode,
description: description,
isProdOverride: isProdOverride,
),
),
);
await tester.pump();
}
void main() {
testWidgets('개발환경은 원문 메시지를 노출한다', (WidgetTester tester) async {
await _pumpErrorScreen(
tester,
errorCode: 'custom_error',
description: '원문 메시지',
isProdOverride: false,
);
expect(find.text('오류: custom_error'), findsOneWidget);
expect(find.text('원문 메시지'), findsOneWidget);
expect(find.text('오류 종류: custom_error'), findsOneWidget);
});
testWidgets('프로덕션은 whitelist 메시지를 노출한다', (WidgetTester tester) async {
await _pumpErrorScreen(
tester,
errorCode: 'settings_disabled',
description: '원문 메시지',
isProdOverride: true,
);
expect(find.text('인증 과정에서 오류가 발생했습니다'), findsOneWidget);
expect(find.text('현재 계정 설정 화면은 준비 중입니다.'), findsOneWidget);
expect(find.text('원문 메시지'), findsNothing);
expect(find.text('오류 종류: settings_disabled'), findsOneWidget);
});
testWidgets('프로덕션은 비허용 에러를 unknown_error로 처리한다', (WidgetTester tester) async {
await _pumpErrorScreen(
tester,
errorCode: 'weird_error',
description: '원문 메시지',
isProdOverride: true,
);
expect(find.text('인증 과정에서 오류가 발생했습니다'), findsOneWidget);
expect(find.text('에러가 계속되면 관리자에게 문의해주세요'), findsOneWidget);
expect(find.text('원문 메시지'), findsNothing);
expect(find.text('오류 종류: unknown_error'), findsOneWidget);
});
}