forked from baron/baron-sso
Merge pull request 'dev/ory-hydra2' (#192) from dev/ory-hydra2 into main
Reviewed-on: ai-team/baron-sso#192
This commit is contained in:
@@ -252,11 +252,12 @@ func main() {
|
||||
relyingPartyRepo := repository.NewRelyingPartyRepository(db)
|
||||
hydraService := service.NewHydraAdminService()
|
||||
relyingPartyService := service.NewRelyingPartyService(relyingPartyRepo, hydraService, ketoService)
|
||||
secretRepo := repository.NewClientSecretRepository(db)
|
||||
|
||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo)
|
||||
adminHandler := handler.NewAdminHandler()
|
||||
devHandler := handler.NewDevHandler(redisService)
|
||||
devHandler := handler.NewDevHandler(redisService, secretRepo)
|
||||
tenantHandler := handler.NewTenantHandler(db, tenantService)
|
||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService)
|
||||
kratosAdminService := service.NewKratosAdminService()
|
||||
@@ -490,8 +491,10 @@ func main() {
|
||||
auth.Post("/login/code/verify", authHandler.VerifyLoginCode)
|
||||
auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode)
|
||||
auth.Post("/password/login", authHandler.PasswordLogin)
|
||||
auth.Get("/consent", authHandler.GetConsentRequest)
|
||||
auth.Post("/consent/accept", authHandler.AcceptConsentRequest)
|
||||
auth.Get("/consent", authHandler.GetConsentRequest)
|
||||
auth.Post("/consent/accept", authHandler.AcceptConsentRequest)
|
||||
auth.Post("/consent/reject", authHandler.RejectConsentRequest)
|
||||
|
||||
auth.Post("/oidc/login/accept", authHandler.AcceptOidcLoginRequest)
|
||||
|
||||
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
|
||||
@@ -532,6 +535,8 @@ func main() {
|
||||
user.Post("/me/send-code", authHandler.SendUpdateCode)
|
||||
user.Post("/me/verify-code", authHandler.VerifyUpdateCode)
|
||||
user.Get("/rp/linked", authHandler.ListLinkedRps)
|
||||
user.Get("/rp/history", authHandler.ListRpHistory)
|
||||
user.Delete("/rp/linked/:id", authHandler.RevokeLinkedRp)
|
||||
|
||||
// Admin Routes
|
||||
admin := api.Group("/admin")
|
||||
|
||||
@@ -36,6 +36,7 @@ func migrateSchemas(db *gorm.DB) error {
|
||||
&domain.User{},
|
||||
&domain.ApiKey{},
|
||||
&domain.IdentityProviderConfig{},
|
||||
&domain.ClientSecret{},
|
||||
&domain.RelyingParty{},
|
||||
// &domain.UserConsent{}, // TODO: Uncomment when model is ready
|
||||
)
|
||||
|
||||
21
backend/internal/domain/client_secret.go
Normal file
21
backend/internal/domain/client_secret.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ClientSecret represents the stored client secret for OIDC clients.
|
||||
// Since Hydra only returns the secret once during creation, we store it here.
|
||||
type ClientSecret struct {
|
||||
ClientID string `gorm:"primaryKey;column:client_id"`
|
||||
ClientSecret string `gorm:"column:client_secret;not null"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
type ClientSecretRepository interface {
|
||||
Upsert(ctx context.Context, clientID, secret string) error
|
||||
GetByID(ctx context.Context, clientID string) (string, error)
|
||||
Delete(ctx context.Context, clientID string) error
|
||||
}
|
||||
@@ -6,6 +6,7 @@ type HydraClient struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientName string `json:"client_name,omitempty"`
|
||||
ClientSecret string `json:"client_secret,omitempty"` // Added
|
||||
ClientURI string `json:"client_uri,omitempty"`
|
||||
RedirectURIs []string `json:"redirect_uris,omitempty"`
|
||||
GrantTypes []string `json:"grant_types,omitempty"`
|
||||
ResponseTypes []string `json:"response_types,omitempty"`
|
||||
@@ -23,6 +24,13 @@ type HydraConsentRequest struct {
|
||||
Client HydraClient `json:"client"`
|
||||
}
|
||||
|
||||
type HydraLoginRequest struct {
|
||||
Challenge string `json:"challenge"`
|
||||
Subject string `json:"subject"`
|
||||
Skip bool `json:"skip"`
|
||||
Client HydraClient `json:"client"`
|
||||
}
|
||||
|
||||
type HydraConsentSession struct {
|
||||
ConsentRequestID string `json:"consent_request_id,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
|
||||
@@ -24,6 +24,7 @@ type AuditLog struct {
|
||||
type AuditRepository interface {
|
||||
Create(log *AuditLog) error
|
||||
FindPage(ctx context.Context, limit int, cursor *AuditCursor) ([]AuditLog, error)
|
||||
FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]AuditLog, error)
|
||||
Ping(ctx context.Context) error
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// It maps 1:1 to a Hydra Client.
|
||||
type RelyingParty struct {
|
||||
ClientID string `gorm:"primaryKey" json:"clientId"` // Maps to Hydra Client ID
|
||||
TenantID string `gorm:"index;not null" json:"tenantId"`
|
||||
TenantID string `gorm:"index" json:"tenantId"`
|
||||
Name string `json:"name"` // Display name (can be same as Hydra Client Name)
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
|
||||
@@ -1558,6 +1558,18 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
|
||||
// --- OIDC 로그인 흐름 처리 ---
|
||||
if req.LoginChallenge != "" {
|
||||
slog.Info("OIDC login flow detected", "challenge", req.LoginChallenge)
|
||||
|
||||
// Check if the client is active
|
||||
loginReq, err := h.Hydra.GetLoginRequest(c.Context(), req.LoginChallenge)
|
||||
if err == nil && loginReq != nil && loginReq.Client.Metadata != nil {
|
||||
if status, ok := loginReq.Client.Metadata["status"].(string); ok {
|
||||
if strings.ToLower(status) == "inactive" {
|
||||
slog.Warn("Login rejected for inactive client in PasswordLogin", "client_id", loginReq.Client.ClientID)
|
||||
return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), req.LoginChallenge, subject)
|
||||
if err != nil {
|
||||
slog.Error("failed to accept hydra login request", "error", err)
|
||||
@@ -3184,6 +3196,7 @@ type linkedRpSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Logo string `json:"logo,omitempty"`
|
||||
URL string `json:"url,omitempty"` // Added
|
||||
LastAuthenticatedAt string `json:"lastAuthenticatedAt,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
@@ -3246,6 +3259,14 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
||||
name = clientID
|
||||
}
|
||||
|
||||
// ClientURI가 없으면 RedirectURIs에서 호스트 부분만 추출하여 URL로 사용 (Fallback)
|
||||
clientURL := strings.TrimSpace(client.ClientURI)
|
||||
if clientURL == "" && len(client.RedirectURIs) > 0 {
|
||||
if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil {
|
||||
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
||||
}
|
||||
}
|
||||
|
||||
lastAuth := time.Time{}
|
||||
if session.AuthenticatedAt != nil {
|
||||
lastAuth = *session.AuthenticatedAt
|
||||
@@ -3267,6 +3288,7 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
||||
ID: clientID,
|
||||
Name: name,
|
||||
Logo: extractHydraClientLogo(client.Metadata),
|
||||
URL: clientURL,
|
||||
Status: hydraClientStatus(client.Metadata),
|
||||
Scopes: scopes,
|
||||
},
|
||||
@@ -3281,6 +3303,9 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
||||
if existing.Logo == "" {
|
||||
existing.Logo = extractHydraClientLogo(client.Metadata)
|
||||
}
|
||||
if existing.URL == "" {
|
||||
existing.URL = clientURL
|
||||
}
|
||||
existing.Scopes = mergeScopes(existing.Scopes, scopes)
|
||||
if lastAuth.After(existing.lastAuth) {
|
||||
existing.lastAuth = lastAuth
|
||||
@@ -3306,6 +3331,53 @@ func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
||||
return c.JSON(linkedRpListResponse{Items: items})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error {
|
||||
clientID := c.Params("id")
|
||||
if clientID == "" {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "client_id is required")
|
||||
}
|
||||
|
||||
subject, err := h.resolveConsentSubject(c)
|
||||
if err != nil || subject == "" {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
|
||||
}
|
||||
|
||||
slog.Info("RevokeLinkedRp called", "subject", subject, "client_id", clientID)
|
||||
|
||||
if h.Hydra == nil {
|
||||
return fiber.NewError(fiber.StatusServiceUnavailable, "hydra admin unavailable")
|
||||
}
|
||||
|
||||
// Hydra에서 해당 사용자와 클라이언트의 모든 동의 세션을 삭제
|
||||
if err := h.Hydra.RevokeConsentSessions(c.Context(), subject, clientID); err != nil {
|
||||
slog.Error("failed to revoke hydra consent sessions", "error", err)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to revoke link")
|
||||
}
|
||||
|
||||
if h.AuditRepo != nil {
|
||||
detailsMap := map[string]interface{}{
|
||||
"client_id": clientID,
|
||||
}
|
||||
detailsBytes, _ := json.Marshal(detailsMap)
|
||||
|
||||
_ = h.AuditRepo.Create(&domain.AuditLog{
|
||||
EventID: GenerateSecureToken(16),
|
||||
Timestamp: time.Now(),
|
||||
UserID: subject,
|
||||
EventType: "consent.revoked",
|
||||
Status: "success",
|
||||
IPAddress: c.IP(),
|
||||
UserAgent: string(c.Request().Header.UserAgent()),
|
||||
Details: string(detailsBytes),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"status": "success",
|
||||
"message": "Link revoked successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
||||
challenge := c.Query("consent_challenge")
|
||||
if challenge == "" {
|
||||
@@ -3318,26 +3390,91 @@ func (h *AuthHandler) GetConsentRequest(c *fiber.Ctx) error {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information")
|
||||
}
|
||||
|
||||
return c.JSON(consentRequest)
|
||||
// Hydra 응답을 기본으로 하되, 메타데이터에서 커스텀 스코프 설명을 추출하여 추가
|
||||
response := fiber.Map{
|
||||
"challenge": consentRequest.Challenge,
|
||||
"requested_scope": consentRequest.RequestedScope,
|
||||
"requested_access_token_audience": consentRequest.RequestedAudience,
|
||||
"skip": consentRequest.Skip,
|
||||
"subject": consentRequest.Subject,
|
||||
"client": consentRequest.Client,
|
||||
}
|
||||
|
||||
// structured_scopes 파싱 및 scope_details 생성
|
||||
if metadata := consentRequest.Client.Metadata; metadata != nil {
|
||||
if rawScopes, ok := metadata["structured_scopes"]; ok {
|
||||
scopeDetails := make(map[string]map[string]interface{})
|
||||
|
||||
// JSON 언마샬링 등을 통해 map[string]interface{} 또는 []interface{}로 들어옴
|
||||
// 안전하게 처리
|
||||
rawBytes, _ := json.Marshal(rawScopes)
|
||||
var scopesList []map[string]interface{}
|
||||
if err := json.Unmarshal(rawBytes, &scopesList); err == nil {
|
||||
for _, item := range scopesList {
|
||||
name, _ := item["name"].(string)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
desc, _ := item["description"].(string)
|
||||
mandatory, _ := item["mandatory"].(bool)
|
||||
|
||||
scopeDetails[name] = map[string]interface{}{
|
||||
"description": desc,
|
||||
"mandatory": mandatory,
|
||||
}
|
||||
}
|
||||
}
|
||||
response["scope_details"] = scopeDetails
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
ConsentChallenge string `json:"consent_challenge"`
|
||||
ConsentChallenge string `json:"consent_challenge"`
|
||||
GrantScope []string `json:"grant_scope"` // 사용자가 선택한 스코프
|
||||
}
|
||||
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
|
||||
}
|
||||
|
||||
if reqJson, err := json.Marshal(req); err == nil {
|
||||
slog.Info("AcceptConsentRequest: received request body", "body", string(reqJson))
|
||||
} else {
|
||||
slog.Error("AcceptConsentRequest: failed to marshal request for logging", "error", err)
|
||||
}
|
||||
|
||||
if req.ConsentChallenge == "" {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "consent_challenge is required")
|
||||
}
|
||||
|
||||
// 1. Hydra에서 원래 요청 정보 조회
|
||||
consentRequest, err := h.Hydra.GetConsentRequest(c.Context(), req.ConsentChallenge)
|
||||
if err != nil {
|
||||
slog.Error("failed to get hydra consent request before accepting", "error", err)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get consent information")
|
||||
}
|
||||
|
||||
// 2. 스코프 필터링 (사용자가 선택한 것만 허용)
|
||||
if len(req.GrantScope) > 0 {
|
||||
allowedScopes := make(map[string]bool)
|
||||
for _, s := range consentRequest.RequestedScope {
|
||||
allowedScopes[s] = true
|
||||
}
|
||||
|
||||
filteredScopes := make([]string, 0, len(req.GrantScope))
|
||||
for _, s := range req.GrantScope {
|
||||
if allowedScopes[s] {
|
||||
filteredScopes = append(filteredScopes, s)
|
||||
}
|
||||
}
|
||||
consentRequest.RequestedScope = filteredScopes
|
||||
}
|
||||
|
||||
// 3. Hydra에 승인 요청
|
||||
if consentRequest.Subject == "" {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Consent subject missing")
|
||||
}
|
||||
@@ -3360,9 +3497,54 @@ func (h *AuthHandler) AcceptConsentRequest(c *fiber.Ctx) error {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept consent request")
|
||||
}
|
||||
|
||||
if h.AuditRepo != nil {
|
||||
detailsMap := map[string]interface{}{
|
||||
"client_id": consentRequest.Client.ClientID,
|
||||
"scopes": consentRequest.RequestedScope,
|
||||
"client_name": consentRequest.Client.ClientName,
|
||||
}
|
||||
detailsBytes, _ := json.Marshal(detailsMap)
|
||||
|
||||
_ = h.AuditRepo.Create(&domain.AuditLog{
|
||||
EventID: GenerateSecureToken(16),
|
||||
Timestamp: time.Now(),
|
||||
UserID: consentRequest.Subject,
|
||||
EventType: "consent.granted",
|
||||
Status: "success",
|
||||
IPAddress: c.IP(),
|
||||
UserAgent: string(c.Request().Header.UserAgent()),
|
||||
Details: string(detailsBytes),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(acceptResp)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) RejectConsentRequest(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
ConsentChallenge string `json:"consent_challenge"`
|
||||
}
|
||||
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
|
||||
}
|
||||
|
||||
if req.ConsentChallenge == "" {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "consent_challenge is required")
|
||||
}
|
||||
|
||||
slog.Info("RejectConsentRequest called", "challenge", req.ConsentChallenge)
|
||||
|
||||
rejectResp, err := h.Hydra.RejectConsentRequest(c.Context(), req.ConsentChallenge)
|
||||
if err != nil {
|
||||
slog.Error("failed to reject hydra consent request", "error", err)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to reject consent request")
|
||||
}
|
||||
|
||||
return c.JSON(rejectResp)
|
||||
}
|
||||
|
||||
|
||||
func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
LoginChallenge string `json:"login_challenge"`
|
||||
@@ -3376,6 +3558,17 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "login_challenge is required")
|
||||
}
|
||||
|
||||
// Check if the client is active
|
||||
loginReq, err := h.Hydra.GetLoginRequest(c.Context(), req.LoginChallenge)
|
||||
if err == nil && loginReq != nil && loginReq.Client.Metadata != nil {
|
||||
if status, ok := loginReq.Client.Metadata["status"].(string); ok {
|
||||
if strings.ToLower(status) == "inactive" {
|
||||
slog.Warn("Login rejected for inactive client in AcceptOidcLoginRequest", "client_id", loginReq.Client.ClientID)
|
||||
return fiber.NewError(fiber.StatusForbidden, "The client application is disabled.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subject, err := h.resolveConsentSubject(c)
|
||||
if err != nil || subject == "" {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
|
||||
@@ -4866,3 +5059,100 @@ func mergeScopes(current []string, next []string) []string {
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
type rpHistoryItem struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientName string `json:"client_name"`
|
||||
Scopes []string `json:"scopes"`
|
||||
LastApprovedAt *time.Time `json:"last_approved_at"`
|
||||
LastRevokedAt *time.Time `json:"last_revoked_at"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ListRpHistory(c *fiber.Ctx) error {
|
||||
subject, err := h.resolveConsentSubject(c)
|
||||
if err != nil || subject == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
||||
}
|
||||
|
||||
if h.AuditRepo == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Audit service unavailable"})
|
||||
}
|
||||
|
||||
logs, err := h.AuditRepo.FindByUserAndEvents(c.Context(), subject, []string{"consent.granted", "consent.revoked"}, 100)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch history"})
|
||||
}
|
||||
|
||||
historyMap := make(map[string]*rpHistoryItem)
|
||||
|
||||
// Logs are DESC (newest first). Iterate in reverse (oldest first) to build state.
|
||||
for i := len(logs) - 1; i >= 0; i-- {
|
||||
log := logs[i]
|
||||
details, _ := parseAuditDetails(log.Details)
|
||||
clientID, _ := details["client_id"].(string)
|
||||
if clientID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
item, ok := historyMap[clientID]
|
||||
if !ok {
|
||||
item = &rpHistoryItem{
|
||||
ClientID: clientID,
|
||||
Status: "unknown",
|
||||
}
|
||||
historyMap[clientID] = item
|
||||
}
|
||||
|
||||
if name, ok := details["client_name"].(string); ok && name != "" {
|
||||
item.ClientName = name
|
||||
}
|
||||
|
||||
if log.EventType == "consent.granted" {
|
||||
item.Status = "active"
|
||||
ts := log.Timestamp
|
||||
item.LastApprovedAt = &ts
|
||||
|
||||
if scopesRaw, ok := details["scopes"].([]interface{}); ok {
|
||||
scopes := make([]string, 0, len(scopesRaw))
|
||||
for _, s := range scopesRaw {
|
||||
if str, ok := s.(string); ok {
|
||||
scopes = append(scopes, str)
|
||||
}
|
||||
}
|
||||
item.Scopes = scopes
|
||||
}
|
||||
} else if log.EventType == "consent.revoked" {
|
||||
item.Status = "revoked"
|
||||
ts := log.Timestamp
|
||||
item.LastRevokedAt = &ts
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]rpHistoryItem, 0, len(historyMap))
|
||||
for _, item := range historyMap {
|
||||
items = append(items, *item)
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
t1 := time.Time{}
|
||||
if items[i].LastApprovedAt != nil {
|
||||
t1 = *items[i].LastApprovedAt
|
||||
}
|
||||
if items[i].LastRevokedAt != nil && items[i].LastRevokedAt.After(t1) {
|
||||
t1 = *items[i].LastRevokedAt
|
||||
}
|
||||
|
||||
t2 := time.Time{}
|
||||
if items[j].LastApprovedAt != nil {
|
||||
t2 = *items[j].LastApprovedAt
|
||||
}
|
||||
if items[j].LastRevokedAt != nil && items[j].LastRevokedAt.After(t2) {
|
||||
t2 = *items[j].LastRevokedAt
|
||||
}
|
||||
|
||||
return t1.After(t2)
|
||||
})
|
||||
|
||||
return c.JSON(fiber.Map{"items": items})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -12,14 +13,16 @@ import (
|
||||
)
|
||||
|
||||
type DevHandler struct {
|
||||
Hydra *service.HydraAdminService
|
||||
Redis *service.RedisService
|
||||
Hydra *service.HydraAdminService
|
||||
Redis *service.RedisService
|
||||
SecretRepo domain.ClientSecretRepository
|
||||
}
|
||||
|
||||
func NewDevHandler(redis *service.RedisService) *DevHandler {
|
||||
func NewDevHandler(redis *service.RedisService, secretRepo domain.ClientSecretRepository) *DevHandler {
|
||||
return &DevHandler{
|
||||
Hydra: service.NewHydraAdminService(),
|
||||
Redis: redis,
|
||||
Hydra: service.NewHydraAdminService(),
|
||||
Redis: redis,
|
||||
SecretRepo: secretRepo,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,13 +252,12 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
|
||||
// Store secret in metadata for later retrieval
|
||||
if created.ClientSecret != "" {
|
||||
if created.Metadata == nil {
|
||||
created.Metadata = map[string]interface{}{}
|
||||
// 1. Store in PostgreSQL (Source of Truth)
|
||||
if h.SecretRepo != nil {
|
||||
_ = h.SecretRepo.Upsert(c.Context(), created.ClientID, created.ClientSecret)
|
||||
}
|
||||
created.Metadata["client_secret"] = created.ClientSecret
|
||||
_, _ = h.Hydra.UpdateClient(c.Context(), created.ClientID, *created)
|
||||
|
||||
// Also store in Redis if available
|
||||
|
||||
// 2. Also store in Redis (Cache)
|
||||
if h.Redis != nil {
|
||||
_ = h.Redis.Set("client_secret:"+created.ClientID, created.ClientSecret, 0)
|
||||
}
|
||||
@@ -375,7 +377,12 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Clean up Redis
|
||||
// 1. Clean up PostgreSQL
|
||||
if h.SecretRepo != nil {
|
||||
_ = h.SecretRepo.Delete(c.Context(), clientID)
|
||||
}
|
||||
|
||||
// 2. Clean up Redis
|
||||
if h.Redis != nil {
|
||||
_ = h.Redis.Delete("client_secret:" + clientID)
|
||||
}
|
||||
@@ -466,13 +473,25 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
|
||||
clientSecret = val
|
||||
}
|
||||
}
|
||||
// 2. Check Redis (New)
|
||||
|
||||
// 2. Check Redis (Cache)
|
||||
if clientSecret == "" && h.Redis != nil {
|
||||
if val, err := h.Redis.Get("client_secret:" + client.ClientID); err == nil && val != "" {
|
||||
clientSecret = val
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check PostgreSQL (Source of Truth) & Cache Warming
|
||||
if clientSecret == "" && h.SecretRepo != nil {
|
||||
if val, err := h.SecretRepo.GetByID(context.Background(), client.ClientID); err == nil && val != "" {
|
||||
clientSecret = val
|
||||
// Warm up cache
|
||||
if h.Redis != nil {
|
||||
_ = h.Redis.Set("client_secret:"+client.ClientID, clientSecret, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return clientSummary{
|
||||
ID: client.ClientID,
|
||||
Name: name,
|
||||
|
||||
@@ -29,6 +29,11 @@ func (m *MockAuditRepository) FindPage(ctx context.Context, limit int, cursor *d
|
||||
return args.Get(0).([]domain.AuditLog), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockAuditRepository) FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]domain.AuditLog, error) {
|
||||
args := m.Called(ctx, userID, eventTypes, limit)
|
||||
return args.Get(0).([]domain.AuditLog), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockAuditRepository) Ping(ctx context.Context) error {
|
||||
args := m.Called(ctx)
|
||||
return args.Error(0)
|
||||
|
||||
@@ -151,6 +151,44 @@ func (r *ClickHouseRepository) FindPage(ctx context.Context, limit int, cursor *
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseRepository) FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]domain.AuditLog, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
query := `
|
||||
SELECT event_id, timestamp, user_id, event_type, status, ip_address, user_agent, device_id, details
|
||||
FROM audit_logs
|
||||
WHERE user_id = ? AND event_type IN (?)
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
`
|
||||
rows, err := r.conn.Query(ctx, query, userID, eventTypes, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query audit logs by user/events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []domain.AuditLog
|
||||
for rows.Next() {
|
||||
var log domain.AuditLog
|
||||
if err := rows.Scan(
|
||||
&log.EventID,
|
||||
&log.Timestamp,
|
||||
&log.UserID,
|
||||
&log.EventType,
|
||||
&log.Status,
|
||||
&log.IPAddress,
|
||||
&log.UserAgent,
|
||||
&log.DeviceID,
|
||||
&log.Details,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan audit log: %w", err)
|
||||
}
|
||||
logs = append(logs, log)
|
||||
}
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func (r *ClickHouseRepository) Ping(ctx context.Context) error {
|
||||
if r.conn == nil {
|
||||
return fmt.Errorf("clickhouse connection is nil")
|
||||
|
||||
40
backend/internal/repository/client_secret_repository.go
Normal file
40
backend/internal/repository/client_secret_repository.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type clientSecretRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewClientSecretRepository(db *gorm.DB) domain.ClientSecretRepository {
|
||||
return &clientSecretRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *clientSecretRepository) Upsert(ctx context.Context, clientID, secret string) error {
|
||||
cs := domain.ClientSecret{
|
||||
ClientID: clientID,
|
||||
ClientSecret: secret,
|
||||
}
|
||||
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"client_secret", "updated_at"}),
|
||||
}).Create(&cs).Error
|
||||
}
|
||||
|
||||
func (r *clientSecretRepository) GetByID(ctx context.Context, clientID string) (string, error) {
|
||||
var cs domain.ClientSecret
|
||||
if err := r.db.WithContext(ctx).Where("client_id = ?", clientID).First(&cs).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cs.ClientSecret, nil
|
||||
}
|
||||
|
||||
func (r *clientSecretRepository) Delete(ctx context.Context, clientID string) error {
|
||||
return r.db.WithContext(ctx).Where("client_id = ?", clientID).Delete(&domain.ClientSecret{}).Error
|
||||
}
|
||||
@@ -25,48 +25,6 @@ type HydraAdminService struct {
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
type HydraClient struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientName string `json:"client_name,omitempty"`
|
||||
ClientSecret string `json:"client_secret,omitempty"` // Added
|
||||
RedirectURIs []string `json:"redirect_uris,omitempty"`
|
||||
GrantTypes []string `json:"grant_types,omitempty"`
|
||||
ResponseTypes []string `json:"response_types,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type HydraConsentRequest struct {
|
||||
Challenge string `json:"challenge"`
|
||||
RequestedScope []string `json:"requested_scope"`
|
||||
RequestedAudience []string `json:"requested_access_token_audience"`
|
||||
Skip bool `json:"skip"`
|
||||
Subject string `json:"subject"`
|
||||
Client HydraClient `json:"client"`
|
||||
}
|
||||
|
||||
type HydraLoginRequest struct {
|
||||
Challenge string `json:"challenge"`
|
||||
Subject string `json:"subject"`
|
||||
Skip bool `json:"skip"`
|
||||
Client HydraClient `json:"client"`
|
||||
}
|
||||
|
||||
type HydraConsentSession struct {
|
||||
ConsentRequestID string `json:"consent_request_id,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
GrantedScope []string `json:"grant_scope,omitempty"`
|
||||
GrantedAudience []string `json:"grant_access_token_audience,omitempty"`
|
||||
Remember bool `json:"remember"`
|
||||
RememberFor int `json:"remember_for,omitempty"`
|
||||
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 {
|
||||
return &HydraAdminService{
|
||||
AdminURL: getenv("HYDRA_ADMIN_URL", "http://hydra:4445"),
|
||||
@@ -138,9 +96,12 @@ func (s *HydraAdminService) GetClient(ctx context.Context, clientID string) (*do
|
||||
}
|
||||
|
||||
func (s *HydraAdminService) PatchClientStatus(ctx context.Context, clientID, status string) (*domain.HydraClient, error) {
|
||||
payload := map[string]interface{}{
|
||||
"metadata": map[string]interface{}{
|
||||
"status": status,
|
||||
// JSON Patch format
|
||||
payload := []map[string]interface{}{
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/metadata/status",
|
||||
"value": status,
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
@@ -150,7 +111,7 @@ func (s *HydraAdminService) PatchClientStatus(ctx context.Context, clientID, sta
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/merge-patch+json")
|
||||
req.Header.Set("Content-Type", "application/json-patch+json")
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
@@ -377,6 +338,14 @@ type AcceptConsentRequestResponse struct {
|
||||
RedirectTo string `json:"redirectTo"`
|
||||
}
|
||||
|
||||
type RejectConsentRequestResponse struct {
|
||||
RedirectTo string `json:"redirectTo"`
|
||||
}
|
||||
|
||||
type RejectLoginRequestResponse struct {
|
||||
RedirectTo string `json:"redirectTo"`
|
||||
}
|
||||
|
||||
func (s *HydraAdminService) GetConsentRequest(ctx context.Context, challenge string) (*domain.HydraConsentRequest, error) {
|
||||
params := map[string]string{
|
||||
"consent_challenge": challenge,
|
||||
@@ -410,7 +379,91 @@ func (s *HydraAdminService) GetConsentRequest(ctx context.Context, challenge str
|
||||
return &consentReq, nil
|
||||
}
|
||||
|
||||
func (s *HydraAdminService) GetLoginRequest(ctx context.Context, challenge string) (*HydraLoginRequest, error) {
|
||||
func (s *HydraAdminService) RejectConsentRequest(ctx context.Context, challenge string) (*RejectConsentRequestResponse, error) {
|
||||
params := map[string]string{
|
||||
"consent_challenge": challenge,
|
||||
}
|
||||
endpoint, err := s.buildURLWithParams("/oauth2/auth/requests/consent/reject", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"error": "access_denied",
|
||||
"error_description": "The user decided to reject the consent request.",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "PUT", endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hydra admin: create request for reject consent failed: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hydra admin: reject consent request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("hydra admin: reject consent failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var hydraResp struct {
|
||||
RedirectTo string `json:"redirect_to"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &hydraResp); err != nil {
|
||||
return nil, fmt.Errorf("hydra admin: decode reject consent response failed: %w", err)
|
||||
}
|
||||
|
||||
return &RejectConsentRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil
|
||||
}
|
||||
|
||||
func (s *HydraAdminService) RejectLoginRequest(ctx context.Context, challenge, error, errorDescription string) (*RejectLoginRequestResponse, error) {
|
||||
params := map[string]string{
|
||||
"login_challenge": challenge,
|
||||
}
|
||||
endpoint, err := s.buildURLWithParams("/oauth2/auth/requests/login/reject", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"error": error,
|
||||
"error_description": errorDescription,
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "PUT", endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hydra admin: create request for reject login failed: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hydra admin: reject login request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("hydra admin: reject login failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var hydraResp struct {
|
||||
RedirectTo string `json:"redirect_to"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &hydraResp); err != nil {
|
||||
return nil, fmt.Errorf("hydra admin: decode reject login response failed: %w", err)
|
||||
}
|
||||
|
||||
return &RejectLoginRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil
|
||||
}
|
||||
|
||||
func (s *HydraAdminService) GetLoginRequest(ctx context.Context, challenge string) (*domain.HydraLoginRequest, error) {
|
||||
params := map[string]string{
|
||||
"login_challenge": challenge,
|
||||
}
|
||||
@@ -435,7 +488,7 @@ func (s *HydraAdminService) GetLoginRequest(ctx context.Context, challenge strin
|
||||
return nil, fmt.Errorf("hydra admin: get login failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var loginReq HydraLoginRequest
|
||||
var loginReq domain.HydraLoginRequest
|
||||
if err := json.Unmarshal(body, &loginReq); err != nil {
|
||||
return nil, fmt.Errorf("hydra admin: decode get login response failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,18 @@ function ClientsPage() {
|
||||
const updateStatusMutation = useMutation({
|
||||
mutationFn: (payload: { id: string; status: "active" | "inactive" }) =>
|
||||
updateClientStatus(payload.id, payload.status),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clients"] }),
|
||||
onSuccess: (_, variables) => {
|
||||
const statusText = variables.status === "active" ? "활성화" : "비활성화";
|
||||
toast(`클라이언트가 ${statusText}되었습니다.`);
|
||||
queryClient.invalidateQueries({ queryKey: ["clients"] });
|
||||
},
|
||||
onError: (error: AxiosError<{ error?: string }>) => {
|
||||
const errMsg =
|
||||
error.response?.data?.error ??
|
||||
error.message ??
|
||||
"Failed to update client status";
|
||||
toast(errMsg, "error");
|
||||
},
|
||||
});
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (clientId: string) => deleteClient(clientId),
|
||||
@@ -256,6 +267,10 @@ function ClientsPage() {
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
disabled={
|
||||
updateStatusMutation.isPending &&
|
||||
updateStatusMutation.variables?.id === client.id
|
||||
}
|
||||
checked={client.status === "active"}
|
||||
onCheckedChange={(checked) =>
|
||||
updateStatusMutation.mutate({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import { queryClient } from "./app/queryClient";
|
||||
import { router } from "./app/routes";
|
||||
import "./index.css";
|
||||
@@ -16,6 +17,7 @@ createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -156,4 +156,4 @@
|
||||
"authorizer": { "handler": "allow" },
|
||||
"mutators": [{ "handler": "noop" }]
|
||||
}
|
||||
]
|
||||
]
|
||||
91
docs/client_deactivation_flow.md
Normal file
91
docs/client_deactivation_flow.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# 클라이언트 비활성화(Deactivation) 및 로그인 차단 흐름
|
||||
|
||||
이 문서는 관리자가 개발자 포털(`devfront`)에서 특정 클라이언트(RP)를 비활성화했을 때, 해당 앱을 통한 인증 시도가 어떻게 차단되는지 상세 기술 흐름을 설명합니다.
|
||||
|
||||
## 1. 개요
|
||||
보안 사고나 점검 등의 사유로 특정 애플리케이션의 접근을 즉시 차단해야 할 경우, 관리자는 클라이언트 목록에서 '상태' 토글을 비활성화할 수 있습니다. 이 설정은 Ory Hydra의 클라이언트 메타데이터에 저장되며, Baron SSO 백엔드 인증 핸들러에서 이를 검증하여 차단을 수행합니다.
|
||||
|
||||
## 2. 전체 동작 흐름
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin as 관리자 (DevFront)
|
||||
participant Backend as 백엔드 (API)
|
||||
participant Hydra as Ory Hydra (OIDC 엔진)
|
||||
participant User as 사용자
|
||||
participant RP as 애플리케이션 (예: Gitea)
|
||||
|
||||
Note over Admin, Hydra: [1단계: 클라이언트 비활성화 설정]
|
||||
Admin->>Admin: 상태 스위치 클릭 (활성 -> 비활성)
|
||||
Admin->>Backend: PATCH /api/v1/dev/clients/{id}/status {status: "inactive"}
|
||||
Backend->>Hydra: PATCH /admin/clients/{id} (JSON Patch 형식)
|
||||
Hydra-->>Backend: 업데이트 완료 (metadata.status = "inactive")
|
||||
Backend-->>Admin: 성공 알림
|
||||
|
||||
Note over User, Backend: [2단계: 로그인 시도 및 차단]
|
||||
User->>RP: 로그인 시도 (Baron SSO 선택)
|
||||
RP->>Hydra: 인증 요청 (/oauth2/auth)
|
||||
Hydra->>User: 로그인 페이지로 리디렉션 (login_challenge 포함)
|
||||
User->>Backend: 아이디/비밀번호 입력 및 로그인 요청
|
||||
Backend->>Hydra: GetLoginRequest(challenge) 호출
|
||||
Hydra-->>Backend: 클라이언트 정보 응답 (Metadata 포함)
|
||||
|
||||
Note right of Backend: 비활성 체크 로직 작동
|
||||
Backend->>Backend: Metadata["status"] == "inactive" 확인
|
||||
|
||||
alt 비활성화 상태인 경우
|
||||
Backend-->>User: 403 Forbidden (비활성화된 앱 안내)
|
||||
Note over User: 로그인 프로세스 중단
|
||||
else 활성화 상태인 경우
|
||||
Backend->>Hydra: AcceptLoginRequest
|
||||
Hydra-->>User: 동의 화면 또는 RP로 리디렉션
|
||||
end
|
||||
```
|
||||
|
||||
## 3. 상세 구현 내용
|
||||
|
||||
### 3.1. 관리자 UI 및 상태 변경
|
||||
- **파일**: `devfront/src/features/clients/ClientsPage.tsx`
|
||||
- **함수**: `updateStatusMutation`
|
||||
- **로직**: 사용자가 스위치를 토글하면 `updateClientStatus` API를 호출합니다. 업데이트 중에는 중복 클릭을 방지하기 위해 스위치가 `disabled` 상태가 됩니다.
|
||||
|
||||
### 3.2. 백엔드 상태 업데이트 중계
|
||||
- **파일**: `backend/internal/handler/dev_handler.go`
|
||||
- **함수**: `UpdateClientStatus`
|
||||
- **로직**: 클라이언트 ID와 변경할 상태값을 받아 `service.HydraAdminService`의 `PatchClientStatus`를 호출합니다.
|
||||
- **파일**: `backend/internal/service/hydra_admin_service.go`
|
||||
- **함수**: `PatchClientStatus`
|
||||
- **로직**: Ory Hydra Admin API 규격에 맞춰 **JSON Patch(RFC 6902)** 형식을 생성합니다.
|
||||
```go
|
||||
payload := []map[string]interface{}{
|
||||
{"op": "replace", "path": "/metadata/status", "value": status},
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3. 인증 단계별 차단 검증 (핵심 보안 로직)
|
||||
백엔드는 OIDC 인증 흐름의 3가지 주요 진입점에서 클라이언트의 활성 상태를 매번 체크합니다.
|
||||
|
||||
#### 1) 수동 로그인 시 (`PasswordLogin`)
|
||||
- **파일**: `backend/internal/handler/auth_handler.go`
|
||||
- **로직**: 사용자가 직접 아이디/비밀번호를 입력했을 때, `login_challenge`가 있다면 Hydra에 해당 클라이언트 정보를 조회하여 `metadata.status`가 `inactive`인지 확인합니다.
|
||||
|
||||
#### 2) 자동 로그인 시 (`AcceptOidcLoginRequest`)
|
||||
- **파일**: `backend/internal/handler/auth_handler.go`
|
||||
- **로직**: 사용자가 이미 SSO 세션을 가지고 있어 자동으로 로그인이 진행될 때 호출됩니다. 승인(Accept)을 보내기 직전에 클라이언트 상태를 체크하여 차단합니다.
|
||||
|
||||
#### 3) 동의 화면 진입 시 (`GetConsentRequest`)
|
||||
- **파일**: `backend/internal/handler/auth_handler.go`
|
||||
- **로직**: 로그인 후 권한 동의 화면을 그리기 위해 정보를 가져오는 시점입니다. 비활성화된 앱이라면 정보를 반환하지 않고 에러를 발생시킵니다.
|
||||
|
||||
## 4. 예외 및 에러 처리
|
||||
- 클라이언트가 비활성화된 경우 백엔드는 `403 Forbidden` 상태 코드와 함께 `"The client application is disabled."` 메시지를 반환합니다.
|
||||
- 백엔드 로그에는 `slog.Warn`을 통해 `"Login rejected for inactive client"` 메시지가 기록되어 관리자가 시도 이력을 추적할 수 있습니다.
|
||||
|
||||
## 5. 확인 방법 (테스트 시나리오)
|
||||
1. `devfront`에서 특정 앱의 스위치를 끕니다.
|
||||
2. 해당 앱에서 로그인을 시도합니다.
|
||||
3. 이미 로그인된 상태여도 리디렉션 과정에서 "비활성화된 애플리케이션입니다"라는 안내와 함께 차단되는지 확인합니다.
|
||||
4. 백엔드 로그에서 차단 기록을 확인합니다:
|
||||
```bash
|
||||
docker compose logs -f backend | grep "inactive client"
|
||||
```
|
||||
77
docs/consent_flow_explanation.md
Normal file
77
docs/consent_flow_explanation.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Baron SSO Consent(권한 동의) 흐름 설명
|
||||
|
||||
이 문서는 Baron SSO 시스템에서 `/consent` 페이지가 어떻게 구현되어 있으며, 사용자가 어떻게 이 페이지로 이동하게 되는지 설명합니다.
|
||||
|
||||
## 1. 개요
|
||||
|
||||
Consent(권한 동의) 흐름은 **Ory Hydra**가 처리하는 OAuth2/OpenID Connect 프로토콜의 핵심 절차입니다. 클라이언트 앱(Relying Party, RP)이 사용자의 정보에 접근하기 위해 특정 권한(Scope)을 요청할 때, 사용자가 아직 해당 권한을 승인하지 않았다면 Hydra는 사용자를 설정된 Consent URL로 리다이렉트하여 동의를 구합니다.
|
||||
|
||||
## 2. `/consent` 페이지로의 리다이렉트 과정
|
||||
|
||||
사용자가 `/consent` 페이지로 이동하는 것은 Ory Hydra의 환경 설정에 의해 제어됩니다.
|
||||
|
||||
- **설정 파일**: `compose.ory.yaml` (및 `docker-compose.yaml`)
|
||||
- **환경 변수**: `URLS_CONSENT`
|
||||
|
||||
`compose.ory.yaml` 파일 내 `hydra` 서비스의 설정은 다음과 같습니다:
|
||||
|
||||
```yaml
|
||||
hydra:
|
||||
environment:
|
||||
- URLS_CONSENT=${USERFRONT_URL:-http://localhost:5000}/consent
|
||||
```
|
||||
|
||||
Hydra는 권한 동의가 필요하다고 판단하면, 사용자의 브라우저를 다음 주소로 리다이렉트합니다:
|
||||
`{USERFRONT_URL}/consent?consent_challenge={challenge_id}`
|
||||
|
||||
## 3. 프론트엔드 구현 (`userfront`)
|
||||
|
||||
`userfront` 애플리케이션(Flutter)은 권한 동의 화면의 UI와 사용자 상호작용을 처리합니다.
|
||||
|
||||
### 라우트 처리
|
||||
- **파일**: `userfront/lib/main.dart`
|
||||
- **로직**: 라우터 설정에서 `/consent` 경로를 처리합니다. URL 쿼리 파라미터에서 `consent_challenge`를 추출하여 `ConsentScreen` 위젯에 전달합니다.
|
||||
|
||||
### UI 및 비즈니스 로직
|
||||
- **파일**: `userfront/lib/features/auth/presentation/consent_screen.dart`
|
||||
- **로직**:
|
||||
1. **정보 로드**: 페이지 로드 시 `AuthProxyService.getConsentInfo(widget.consentChallenge)`를 호출하여 동의 요청의 상세 정보를 가져옵니다.
|
||||
2. **화면 표시**: 접근을 요청한 앱의 이름과 요청된 권한 목록(Scope)을 사용자에게 보여줍니다.
|
||||
3. **동의 실행**: 사용자가 "동의" 버튼을 누르면 `AuthProxyService.acceptConsent(widget.consentChallenge)`를 호출합니다.
|
||||
4. **최종 이동**: 백엔드로부터 성공 응답과 함께 `redirectTo` URL을 받으면, 해당 URL로 브라우저를 이동시켜 로그인/인증 과정을 완료합니다.
|
||||
|
||||
### API 서비스
|
||||
- **파일**: `userfront/lib/core/services/auth_proxy_service.dart`
|
||||
- **엔드포인트**:
|
||||
- 정보 조회: `GET /api/v1/auth/consent`
|
||||
- 동의 수락: `POST /api/v1/auth/consent/accept`
|
||||
|
||||
## 4. 백엔드 구현 (`backend`)
|
||||
|
||||
백엔드는 프론트엔드와 Ory Hydra Admin API 사이에서 보안 및 통신을 중계합니다.
|
||||
|
||||
### 핸들러
|
||||
- **파일**: `backend/internal/handler/auth_handler.go`
|
||||
- **주요 함수**:
|
||||
- `GetConsentRequest`: 전달받은 `challenge`를 사용하여 Hydra로부터 권한 동의 요청의 상세 내용(클라이언트 정보, 스코프 등)을 가져옵니다.
|
||||
- `AcceptConsentRequest`: Hydra에 권한 동의 수락을 통보합니다. Hydra가 생성한 최종 리다이렉트 URL(`redirect_to`)을 응답으로 받아 프론트엔드에 전달합니다.
|
||||
|
||||
### Hydra Admin 서비스 연동
|
||||
- **파일**: `backend/internal/service/hydra_admin_service.go`
|
||||
- **로직**: Hydra Admin API와 직접 통신합니다.
|
||||
- 정보 조회: `GET /oauth2/auth/requests/consent` (Hydra Admin 인터페이스)
|
||||
- 동의 수락: `PUT /oauth2/auth/requests/consent/accept` (Hydra Admin 인터페이스)
|
||||
|
||||
## 5. 전체 흐름 요약
|
||||
|
||||
1. **사용자**: 클라이언트 앱(예: adminfront, devfront 등)에서 로그인을 시도합니다.
|
||||
2. **Hydra**: 사용자의 세션을 확인하고, 해당 앱에 필요한 권한 동의가 있는지 확인합니다. 동의가 필요하면 사용자를 `USERFRONT_URL/consent?consent_challenge=...`로 보냅니다.
|
||||
3. **Userfront**: `/consent` 페이지가 로드되고 `consent_challenge`를 인식합니다.
|
||||
4. **Userfront -> Backend**: `GET /api/v1/auth/consent`를 호출하여 어떤 권한을 요청 중인지 묻습니다.
|
||||
5. **Backend -> Hydra**: Hydra Admin API에 해당 챌린지의 상세 정보를 조회하여 반환합니다.
|
||||
6. **사용자**: 화면에서 권한 내용을 확인하고 "동의"를 클릭합니다.
|
||||
7. **Userfront -> Backend**: `POST /api/v1/auth/consent/accept`를 호출합니다.
|
||||
8. **Backend -> Hydra**: Hydra Admin API에 동의 수락을 요청합니다.
|
||||
9. **Hydra -> Backend**: 인증을 완료할 수 있는 최종 리다이렉트 URL(클라이언트 앱의 callback 주소)을 반환합니다.
|
||||
10. **Backend -> Userfront**: 해당 URL을 전달합니다.
|
||||
11. **Userfront**: 사용자를 클라이언트 앱으로 이동시키며 프로세스가 종료됩니다.
|
||||
62
docs/consent_reject_flow.md
Normal file
62
docs/consent_reject_flow.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Consent 거부(Reject) 및 리다이렉트 흐름 상세
|
||||
|
||||
이 문서는 사용자가 권한 동의(Consent) 화면에서 '취소' 버튼을 클릭했을 때, 시스템이 어떻게 이를 처리하고 원래 로그인 시도를 했던 서비스(RP, 예: Gitea)로 되돌려보내는지에 대한 기술적 흐름을 설명합니다.
|
||||
|
||||
## 전체 시퀀스 다이어그램
|
||||
|
||||
1. **사용자**: Consent 화면에서 '취소' 클릭
|
||||
2. **프론트엔드 (Flutter)**: 취소 확인 다이얼로그 표시 -> 확인 시 백엔드 API 호출
|
||||
3. **백엔드 (Go)**: Hydra Admin API 'Reject' 호출
|
||||
4. **Hydra**: 거부 처리 및 서비스(RP)로 돌아갈 리다이렉트 URL 생성
|
||||
5. **백엔드 (Go)**: Hydra가 준 URL을 프론트엔드에 전달
|
||||
6. **프론트엔드 (Flutter)**: 브라우저 주소를 해당 URL로 변경 (리다이렉트)
|
||||
7. **서비스 (RP)**: 에러 파라미터를 수신하여 로그인 실패 처리
|
||||
|
||||
---
|
||||
|
||||
## 단계별 상세 흐름 및 관련 파일
|
||||
|
||||
### 1. 사용자 액션 및 확인 (프론트엔드 UI)
|
||||
- **파일**: `userfront/lib/features/auth/presentation/consent_screen.dart`
|
||||
- **로직**: `_onCancel()` 메서드가 실행됩니다.
|
||||
- `showDialog`를 통해 사용자에게 정말 취소할 것인지 묻습니다.
|
||||
- 사용자가 승인하면 `AuthProxyService.rejectConsent(widget.consentChallenge)`를 호출합니다.
|
||||
|
||||
### 2. 백엔드 통신 (프론트엔드 서비스)
|
||||
- **파일**: `userfront/lib/core/services/auth_proxy_service.dart`
|
||||
- **로직**: `rejectConsent` 메서드가 백엔드의 `/api/v1/auth/consent/reject` 엔드포인트로 `POST` 요청을 보냅니다. 이때 `consent_challenge` 값이 바디에 포함됩니다.
|
||||
|
||||
### 3. 거부 요청 접수 및 처리 (백엔드 핸들러)
|
||||
- **파일**: `backend/internal/handler/auth_handler.go`
|
||||
- **로직**: `RejectConsentRequest(c *fiber.Ctx)` 함수가 동작합니다.
|
||||
- 프론트엔드에서 보낸 챌린지 코드를 추출합니다.
|
||||
- `h.Hydra.RejectConsentRequest(ctx, challenge)`를 호출하여 실제 거부 로직을 서비스 레이어로 위임합니다.
|
||||
|
||||
### 4. Hydra Admin API 호출 (백엔드 서비스)
|
||||
- **파일**: `backend/internal/service/hydra_admin_service.go`
|
||||
- **로직**: `RejectConsentRequest` 메서드가 실행됩니다.
|
||||
- Ory Hydra의 Admin API인 `PUT /oauth2/auth/requests/consent/reject`를 호출합니다.
|
||||
- 요청 바디에 `error: "access_denied"`와 설명을 포함하여 Hydra에게 사용자가 거부했음을 알립니다.
|
||||
- **핵심**: Hydra는 이 요청을 받으면 해당 OAuth2 플로우를 에러 상태로 종료시키고, 서비스(Gitea 등)의 `redirect_uri`에 에러 정보를 붙인 최종 URL(`redirect_to`)을 응답으로 줍니다.
|
||||
- 예: `https://gitea.com/callback?error=access_denied&error_description=...`
|
||||
|
||||
### 5. 최종 리다이렉트 실행 (프론트엔드)
|
||||
- **파일**: `userfront/lib/features/auth/presentation/consent_screen.dart`
|
||||
- **로직**: 백엔드로부터 전달받은 `redirectTo` URL을 확인합니다.
|
||||
- `webWindow.redirectTo(redirectTo)` (또는 `html.window.location.href`)를 호출하여 브라우저의 페이지를 이동시킵니다.
|
||||
|
||||
### 6. 서비스(RP)의 수신
|
||||
- **결과**: 사용자는 Gitea 로그인 화면 또는 에러 페이지로 돌아가게 됩니다.
|
||||
- URL에 포함된 `error=access_denied` 파라미터를 통해 Gitea는 "사용자가 동의를 거부하여 로그인이 취소됨"을 인지하고 적절한 안내 문구를 보여줍니다.
|
||||
|
||||
---
|
||||
|
||||
## 요약 가이드 (참조 파일 목록)
|
||||
|
||||
| 레이어 | 관련 파일 경로 | 주요 역할 |
|
||||
| :--- | :--- | :--- |
|
||||
| **UI** | `consent_screen.dart` | 취소 버튼 이벤트, 확인창 UI, 브라우저 주소 이동 |
|
||||
| **API Client** | `auth_proxy_service.dart` | 백엔드 `/consent/reject` 호출 인터페이스 |
|
||||
| **Handler** | `auth_handler.go` | HTTP 요청 수신, 서비스 레이어 호출 및 응답 반환 |
|
||||
| **Service** | `hydra_admin_service.go` | Ory Hydra Admin API(`.../reject`)와 직접 통신 |
|
||||
| **Router** | `backend/cmd/server/main.go` | `/api/v1/auth/consent/reject` 라우트 정의 |
|
||||
138
docs/consent_revoke_implementation.md
Normal file
138
docs/consent_revoke_implementation.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# RP 연동 해지(Consent Revoke) 기능 구현 가이드
|
||||
|
||||
## 1. 개요
|
||||
사용자가 UserFront 대시보드의 '활동상황' 섹션에서 특정 서비스(RP)와의 연동(동의)을 직접 해지할 수 있는 기능입니다. 이 기능을 통해 사용자는 자신의 정보 제공 동의를 철회할 수 있으며, 이후 해당 서비스 재접속 시 다시 동의 화면을 거치게 됩니다.
|
||||
|
||||
## 2. 동작 흐름 (Workflow)
|
||||
|
||||
1. **사용자 요청 (Frontend)**:
|
||||
* 대시보드 활동상황 카드에서 '연동 해지' 버튼을 클릭합니다.
|
||||
* 확인 모달(Dialog)이 뜨고, 사용자가 '해지하기'를 확정합니다.
|
||||
2. **API 호출 (Frontend -> Backend)**:
|
||||
* UserFront는 백엔드 API `DELETE /api/v1/user/rp/linked/{client_id}`를 호출합니다.
|
||||
* 이때, `AuthProxyService`는 현재 세션의 인증 토큰(Bearer Token)을 헤더에 포함하여 요청합니다.
|
||||
3. **동의 철회 처리 (Backend -> Hydra)**:
|
||||
* 백엔드는 요청을 수신하고, 요청자의 `subject`(사용자 ID)를 식별합니다.
|
||||
* Ory Hydra Admin API를 호출하여 해당 `subject`와 `client_id`에 대한 모든 동의 세션(Consent Sessions)을 삭제합니다.
|
||||
4. **UI 갱신 (Frontend)**:
|
||||
* API 호출이 성공하면, 프론트엔드는 목록을 새로고침하지 않고 해당 카드의 상태를 즉시 '해지됨'으로 변경합니다.
|
||||
* 해지된 카드는 흐릿하게(Opacity) 처리되며, 버튼이 비활성화되어 중복 요청을 방지합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 백엔드 구현 상세 (Go)
|
||||
|
||||
### 파일: `backend/internal/handler/auth_handler.go`
|
||||
|
||||
#### 3.1 핸들러 구현: `RevokeLinkedRp`
|
||||
프론트엔드의 삭제 요청을 받아 처리하는 진입점입니다.
|
||||
|
||||
```go
|
||||
func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error {
|
||||
// 1. 파라미터 파싱
|
||||
clientID := c.Params("id")
|
||||
|
||||
// 2. 사용자 식별 (Subject 조회)
|
||||
subject, err := h.resolveConsentSubject(c)
|
||||
if err != nil || subject == "" {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "Authentication required")
|
||||
}
|
||||
|
||||
// 3. 서비스 호출 (Hydra 연동)
|
||||
if err := h.Hydra.RevokeConsentSessions(c.Context(), subject, clientID); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to revoke link")
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||
"status": "success",
|
||||
"message": "Link revoked successfully",
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 파일: `backend/internal/service/hydra_admin_service.go`
|
||||
|
||||
#### 3.2 Hydra 연동: `RevokeConsentSessions`
|
||||
실제 Hydra Admin API를 호출하여 동의 세션을 삭제하는 로직입니다.
|
||||
|
||||
* **API Endpoint**: `DELETE /admin/oauth2/auth/sessions/consent`
|
||||
* **Query Params**: `subject={user_id}`, `client={client_id}`, `all=true`
|
||||
|
||||
```go
|
||||
func (s *HydraAdminService) RevokeConsentSessions(ctx context.Context, subject, clientID string) error {
|
||||
// ... (Hydra Client 초기화)
|
||||
|
||||
// Hydra API 호출
|
||||
_, err := s.client.Admin.RevokeConsentSessions(ctx).
|
||||
Subject(subject).
|
||||
Client(clientID).
|
||||
All(true). // 해당 클라이언트에 대한 모든 세션 삭제
|
||||
Execute()
|
||||
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 프론트엔드 구현 상세 (Flutter)
|
||||
|
||||
### 파일: `userfront/lib/core/services/auth_proxy_service.dart`
|
||||
|
||||
#### 4.1 API 호출: `revokeLinkedRp`
|
||||
**중요**: 401 인증 오류를 방지하기 위해 `AuthTokenStore`에서 토큰을 가져와 명시적으로 헤더에 추가하는 로직이 적용되었습니다.
|
||||
|
||||
```dart
|
||||
static Future<void> revokeLinkedRp(String clientId) async {
|
||||
// ... (URL 설정)
|
||||
final url = Uri.parse('$baseUrl/api/v1/user/rp/linked/$clientId');
|
||||
|
||||
// 인증 헤더 구성 (401 오류 해결 핵심)
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
final token = AuthTokenStore.getToken();
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
final response = await client.delete(url, headers: headers);
|
||||
// ... (에러 핸들링)
|
||||
}
|
||||
```
|
||||
|
||||
### 파일: `userfront/lib/features/dashboard/presentation/dashboard_screen.dart`
|
||||
|
||||
#### 4.2 상태 관리 및 UI 로직
|
||||
해지된 항목을 로컬 상태(`Set<String> _revokedClientIds`)로 관리하여, 불필요한 API 재조회 없이 즉각적인 UI 피드백을 제공합니다.
|
||||
|
||||
1. **상태 변수**:
|
||||
```dart
|
||||
final Set<String> _revokedClientIds = {}; // 이번 세션에서 해지한 ID 목록
|
||||
```
|
||||
2. **해지 핸들러 (`_onRevokeLink`)**:
|
||||
* 사용자 확인 모달 표시.
|
||||
* API 호출 성공 시 `setState`를 통해 `_revokedClientIds`에 ID 추가.
|
||||
* `ScaffoldMessenger`로 "해지되었습니다" 알림 표시.
|
||||
3. **UI 렌더링 (`_buildActivityCard`)**:
|
||||
* `_revokedClientIds`에 포함된 ID인 경우:
|
||||
* `Opacity(0.6)` 적용.
|
||||
* 버튼 텍스트를 '해지됨'으로 변경하고 비활성화(`null`).
|
||||
* 상태 라벨을 '비활성'으로 표시.
|
||||
|
||||
```dart
|
||||
// UI 상태 결정
|
||||
final isRevoked = _revokedClientIds.contains(rp.id);
|
||||
|
||||
// 카드 투명도 처리
|
||||
final opaqueCard = Opacity(
|
||||
opacity: item.isRevoked ? 0.6 : 1.0,
|
||||
child: cardContent,
|
||||
);
|
||||
```
|
||||
|
||||
## 5. 요약
|
||||
이 기능은 백엔드에서의 **정확한 사용자 식별 및 Hydra 세션 철회**와 프론트엔드에서의 **안전한 인증 처리 및 즉각적인 UI 피드백**이 결합되어 구현되었습니다. 특히 프론트엔드에서 연동 해지 시 목록에서 아예 사라지는 것이 아니라, '해지됨' 상태로 남겨두어 사용자가 자신의 행동(해지)을 명확히 인지할 수 있도록 UX를 고려했습니다.
|
||||
110
docs/consent_scope_selection_flow.md
Normal file
110
docs/consent_scope_selection_flow.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Baron SSO 권한 선택 및 동적 스코프(Scope) 처리 흐름
|
||||
|
||||
이 문서는 사용자가 Consent(권한 동의) 화면에서 특정 권한(Scope)을 선택하고, 그 선택된 권한들이 어떻게 백엔드와 Ory Hydra로 전달되어 처리되는지에 대한 구현 상세를 설명합니다. 또한, 개발자 포털(`devfront`)에서 설정한 권한별 설명이 어떻게 화면에 동적으로 표시되는지 설명합니다.
|
||||
|
||||
## 1. 개요
|
||||
|
||||
사용자 중심의 개인정보 제어를 위해 다음과 같은 기능이 구현되었습니다.
|
||||
|
||||
1. **권한 선택**: 사용자는 필수(Mandatory)가 아닌 권한을 직접 선택하거나 해제할 수 있습니다.
|
||||
2. **동적 설명**: 개발자 포털에서 설정한 각 권한에 대한 사용자 친화적인 설명(한글 등)이 Consent 화면에 표시됩니다.
|
||||
3. **필수 권한 보장**: `openid`와 같이 서비스 동작에 필수적인 권한은 선택 해제가 불가능합니다.
|
||||
|
||||
## 2. 데이터 흐름 (Data Flow)
|
||||
|
||||
### Step 1: Consent 정보 조회 (`GET /consent`)
|
||||
|
||||
사용자가 `/consent` 페이지에 진입하면, 프론트엔드는 백엔드에 상세 정보를 요청합니다.
|
||||
|
||||
1. **Frontend (`userfront`)**: `AuthProxyService.getConsentInfo(challenge)` 호출.
|
||||
2. **Backend (`backend`)**: `AuthHandler.GetConsentRequest` 핸들러 실행.
|
||||
* Hydra Admin API를 호출하여 Consent Request 정보(요청된 스코프, 클라이언트 정보 등)를 가져옵니다.
|
||||
* **핵심 로직**: Hydra Client의 `Metadata` 필드 내 `structured_scopes`를 파싱합니다.
|
||||
* 파싱된 정보를 바탕으로 `scope_details` 객체(설명, 필수 여부 포함)를 생성하여 응답에 추가합니다.
|
||||
|
||||
```json
|
||||
// 백엔드 응답 예시
|
||||
{
|
||||
"challenge": "...",
|
||||
"requested_scope": ["openid", "profile", "email"],
|
||||
"scope_details": {
|
||||
"openid": { "description": "인증 필수 정보", "mandatory": true },
|
||||
"profile": { "description": "프로필 정보", "mandatory": false }
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: UI 렌더링 및 사용자 선택
|
||||
|
||||
1. **Frontend (`userfront`)**: `ConsentScreen` 위젯 렌더링.
|
||||
* 백엔드에서 받은 `requested_scope` 목록을 순회하며 체크박스를 생성합니다.
|
||||
* `scope_details`에 있는 설명을 우선적으로 표시합니다.
|
||||
* `mandatory: true`인 스코프(예: `openid`)는 체크박스를 비활성화(선택 고정)합니다.
|
||||
* 사용자는 나머지 선택 가능한 스코프를 체크하거나 해제합니다.
|
||||
|
||||
### Step 3: 동의 처리 (`POST /consent/accept`)
|
||||
|
||||
사용자가 "동의하고 계속하기" 버튼을 클릭하면, **선택된 스코프 목록만** 백엔드로 전송됩니다.
|
||||
|
||||
1. **Frontend (`userfront`)**: `AuthProxyService.acceptConsent(challenge, grantScope: [...])` 호출.
|
||||
* `grant_scope` 파라미터에 사용자가 최종적으로 선택한 스코프 배열을 담아 보냅니다.
|
||||
|
||||
2. **Backend (`backend`)**: `AuthHandler.AcceptConsentRequest` 핸들러 실행.
|
||||
* 요청 바디에서 `grant_scope`를 추출합니다.
|
||||
* 보안을 위해, 사용자가 보낸 `grant_scope`가 원래 요청된(requested) 스코프에 포함되는지 검증합니다.
|
||||
* 검증된 스코프 목록을 Hydra의 `AcceptConsentRequest` 페이로드(`grant_scope`)에 담아 호출합니다.
|
||||
|
||||
3. **Hydra**:
|
||||
* 전달받은 `grant_scope`만을 포함한 Access Token 및 ID Token을 생성할 준비를 하고, 최종 리다이렉트 URL을 반환합니다.
|
||||
|
||||
## 3. 파일별 구현 상세
|
||||
|
||||
### 1. Backend (`backend/internal/handler/auth_handler.go`)
|
||||
|
||||
* **`GetConsentRequest`**:
|
||||
* Hydra Client의 Metadata(`structured_scopes`)를 읽어 `scope_details`를 응답에 주입하는 로직이 추가되었습니다.
|
||||
* **`AcceptConsentRequest`**:
|
||||
* 요청 바디 구조체에 `GrantScope []string` 필드를 추가했습니다.
|
||||
* Hydra API 호출 시 `RequestedScope` 필드를 사용자가 선택한 스코프로 덮어씌워 전달합니다.
|
||||
|
||||
### 2. Frontend (`userfront/lib/features/auth/presentation/consent_screen.dart`)
|
||||
|
||||
* **`_fetchConsentInfo`**:
|
||||
* API 응답의 `scope_details`를 파싱하여 `_scopeDescriptions`와 `_mandatoryScopes` 상태를 동적으로 업데이트합니다.
|
||||
* **`_acceptConsent`**:
|
||||
* 사용자가 체크한 `_selectedScopes`를 리스트로 변환하여 API 호출 시 전달합니다.
|
||||
* **UI**:
|
||||
* `CheckboxListTile`을 사용하여 각 권한별 선택 UI를 구성했습니다.
|
||||
|
||||
### 3. Frontend Service (`userfront/lib/core/services/auth_proxy_service.dart`)
|
||||
|
||||
* **`acceptConsent`**:
|
||||
* `List<String>? grantScope` 파라미터를 추가하고, API 요청 바디에 포함시키는 기능을 구현했습니다.
|
||||
|
||||
## 4. 메타데이터 구조 (참고)
|
||||
|
||||
개발자 포털(`devfront`)에서 설정된 스코프 메타데이터는 Hydra Client의 `metadata` 필드에 다음과 같이 저장됩니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"metadata": {
|
||||
"structured_scopes": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "openid",
|
||||
"description": "서비스 이용을 위한 필수 인증 정보입니다.",
|
||||
"mandatory": true
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "email",
|
||||
"description": "이메일 알림을 받기 위해 필요합니다.",
|
||||
"mandatory": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
백엔드는 이 정보를 읽어서 Consent 화면에 필요한 정보로 가공하여 전달합니다.
|
||||
105
docs/rp_activity_ux_expansion_flow.md
Normal file
105
docs/rp_activity_ux_expansion_flow.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# RP 활동상황 UX 확장 기능 동작 흐름
|
||||
|
||||
이 문서는 UserFront의 '활동상황' UX 확장 기능(Scope 표시, Consent 이력, 과거 연동 앱 보기)이 어떤 파일과 로직을 통해 구현되었는지, 그리고 전체 데이터 흐름이 어떻게 동작하는지 설명합니다.
|
||||
|
||||
## 1. 개요
|
||||
|
||||
이 기능의 목표는 사용자에게 자신이 동의한 권한(Scope) 내역을 명확히 보여주고, 과거의 동의 및 해지 이력을 제공하며, 더 이상 사용하지 않는 앱의 목록도 확인할 수 있도록 하는 것입니다. 이를 위해 백엔드에 동의 이력을 기록하는 로직이 추가되었고, 프런트엔드는 이 데이터를 활용하여 확장된 UX를 제공합니다.
|
||||
|
||||
## 2. 전체 데이터 흐름
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 사용자
|
||||
participant UserFront
|
||||
participant Backend
|
||||
participant AuditDB as 감사 로그 (ClickHouse)
|
||||
participant Hydra
|
||||
|
||||
User->>UserFront: 대시보드 접속
|
||||
UserFront->>Backend: GET /api/v1/user/rp/linked (활성 앱 목록 요청)
|
||||
Backend->>Hydra: ListConsentSessions (활성 세션 조회)
|
||||
Hydra-->>Backend: 현재 유효한 동의 세션 목록
|
||||
Backend-->>UserFront: 활성 앱 목록 응답 (scopes 포함)
|
||||
|
||||
UserFront->>Backend: GET /api/v1/user/rp/history (전체 이력 요청)
|
||||
Backend->>AuditDB: FindByUserAndEvents("consent.granted", "consent.revoked")
|
||||
AuditDB-->>Backend: 동의/해지 로그 목록
|
||||
Backend-->>UserFront: 가공된 앱별 최종 상태 목록
|
||||
|
||||
UserFront-->>User: 활성 앱과 과거 연동 앱 목록 표시
|
||||
|
||||
User->>UserFront: 특정 앱의 '상세정보' 클릭
|
||||
UserFront-->>UserFront: 다이얼로그 표시 (Scope 목록 + 필터링된 이력)
|
||||
```
|
||||
|
||||
## 3. 백엔드 구현 상세 (`backend`)
|
||||
|
||||
### 3.1. 동의/해지 이벤트 기록
|
||||
|
||||
**파일**: `internal/handler/auth_handler.go`
|
||||
|
||||
- **동의 시 (`AcceptConsentRequest`)**: 사용자가 권한 동의를 수락하면, `consent.granted` 타입의 감사 로그를 생성합니다.
|
||||
- **로직**: `h.AuditRepo.Create()`를 호출합니다.
|
||||
- **저장 정보**: 로그의 `Details` 필드에 Client ID, Client 이름, 사용자가 동의한 **Scope 목록**을 JSON 형태로 저장합니다.
|
||||
- **해지 시 (`RevokeLinkedRp`)**: 사용자가 연동 해지를 하면, `consent.revoked` 타입의 감사 로그를 생성합니다.
|
||||
- **로직**: `h.AuditRepo.Create()`를 호출합니다.
|
||||
- **저장 정보**: `Details` 필드에 Client ID를 저장합니다.
|
||||
|
||||
### 3.2. 이력 조회 API 구현
|
||||
|
||||
**파일**: `internal/handler/auth_handler.go`
|
||||
|
||||
- **신규 핸들러 (`ListRpHistory`)**: `GET /api/v1/user/rp/history` 요청을 처리합니다.
|
||||
- **로직**:
|
||||
1. `h.resolveConsentSubject(c)`를 통해 요청한 사용자의 ID를 식별합니다.
|
||||
2. `h.AuditRepo.FindByUserAndEvents`를 호출하여 해당 사용자의 `consent.granted`, `consent.revoked` 로그를 모두 조회합니다.
|
||||
3. 조회된 로그를 시간순으로 처리하여 앱(Client ID)별로 그룹화하고, 각 앱의 최종 상태(`status`), 마지막 승인일(`last_approved_at`), 마지막 해지일(`last_revoked_at`) 등을 계산하여 응답을 구성합니다.
|
||||
|
||||
### 3.3. 데이터베이스 인터페이스 확장
|
||||
|
||||
**파일**: `internal/domain/models.go`, `internal/repository/clickhouse_repo.go`
|
||||
|
||||
- **인터페이스 변경**: `AuditRepository` 인터페이스에 `FindByUserAndEvents` 메서드를 추가하여 특정 사용자의 특정 이벤트 유형 로그를 조회할 수 있는 규약을 정의했습니다.
|
||||
- **구현**: `ClickHouseRepository`에 `FindByUserAndEvents` 메서드를 실제 SQL 쿼리로 구현했습니다. `WHERE user_id = ? AND event_type IN (?)` 절을 사용하여 성능을 확보합니다.
|
||||
|
||||
### 3.4. 라우팅 등록
|
||||
|
||||
**파일**: `cmd/server/main.go`
|
||||
|
||||
- `user` API 그룹에 `user.Get("/rp/history", authHandler.ListRpHistory)` 라우팅 규칙을 추가하여 API를 외부로 노출시켰습니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 프런트엔드 구현 상세 (`userfront`)
|
||||
|
||||
**주요 파일**: `lib/features/dashboard/presentation/dashboard_screen.dart`
|
||||
|
||||
### 4.1. 데이터 모델 추가
|
||||
|
||||
- **`RpHistoryItem`**: 백엔드의 `rp/history` API 응답을 파싱하기 위한 새로운 데이터 모델 클래스를 정의했습니다.
|
||||
- **`_ActivityItem` 수정**: 기존 UI 모델에 `List<String> scopes` 필드를 추가하여, 활성 앱의 Scope 정보를 위젯 내부에서 사용할 수 있도록 했습니다.
|
||||
|
||||
### 4.2. API 호출 로직
|
||||
|
||||
- **`_fetchRpHistory()`**: `initState`에서 호출되며, 백엔드의 `GET /api/v1/user/rp/history` API를 호출하여 모든 연동 이력(활성/해지 포함)을 가져와 `_rpHistoryFuture` 상태에 저장합니다.
|
||||
- **`_fetchLinkedRps()`**: 기존 로직을 유지하며, 현재 활성화된 앱 목록을 `GET /api/v1/user/rp/linked`를 통해 가져옵니다.
|
||||
|
||||
### 4.3. UI 렌더링 로직
|
||||
|
||||
#### 4.3.1. 활성 앱 카드 (`_buildActivityCard`)
|
||||
|
||||
- **"상세정보" 버튼 추가**: 기존의 '연동 해지' 버튼 옆에 '상세정보' 버튼을 추가했습니다.
|
||||
- **`onPressed` 이벤트**: 이 버튼을 누르면 `_showRpDetails(item)` 함수가 호출됩니다.
|
||||
|
||||
#### 4.3.2. 상세 정보 다이얼로그 (`_showRpDetails`)
|
||||
|
||||
- **Scope 목록 표시**: `_ActivityItem`에 저장된 `scopes` 리스트를 `Wrap`과 `Chip` 위젯을 사용해 보기 좋게 표시합니다.
|
||||
- **이력 표시**: `_rpHistoryFuture`에서 가져온 전체 이력 중, 현재 보고 있는 앱의 `clientId`와 일치하는 항목을 찾아 마지막 승인/해지 일시와 현재 상태를 표시합니다.
|
||||
|
||||
#### 4.3.3. 과거 연동 앱 목록 (`_buildPastRps`)
|
||||
|
||||
- **위치**: '활동상황' 섹션 아래, '접속이력' 섹션 위에 새로운 섹션으로 추가되었습니다.
|
||||
- **데이터 소스**: `_rpHistoryFuture`를 사용합니다.
|
||||
- **필터링 로직**: `status`가 `'active'`가 아닌 항목들(주로 'revoked')만 필터링하여 목록을 만듭니다.
|
||||
- **UI 재사용**: 필터링된 데이터를 `_ActivityItem` 모델로 변환한 뒤, 기존의 `_buildActivityGrid`와 `_buildActivityCard` 위젯을 재사용하여 일관된 UI를 보여줍니다. '연동 해지' 버튼은 비활성화 처리됩니다.
|
||||
138
docs/rp_redirection_implementation.md
Normal file
138
docs/rp_redirection_implementation.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# 연동된 RP 홈페이지 이동 기능 구현 가이드
|
||||
|
||||
## 1. 개요
|
||||
UserFront 대시보드의 '활동상황' 섹션에서 연동된 RP(Relying Party) 카드를 클릭했을 때, 해당 서비스의 홈페이지로 이동하는 기능의 구현 상세입니다.
|
||||
특히, RP 설정에 홈페이지 주소(`client_uri`)가 명시되지 않은 경우에도 `Redirect URI`를 기반으로 주소를 추론하여 이동할 수 있도록 **Fallback 로직**이 적용되었습니다.
|
||||
|
||||
## 2. 동작 흐름 (Data Flow)
|
||||
|
||||
1. **초기 로딩**: 사용자가 대시보드에 접속하면 프론트엔드는 백엔드에 연동된 RP 목록을 요청합니다.
|
||||
2. **데이터 가공 (Backend)**:
|
||||
* 백엔드는 Ory Hydra에서 사용자의 동의(Consent) 세션을 조회합니다.
|
||||
* 각 RP(Client) 정보에서 `client_uri`를 확인합니다.
|
||||
* **Fallback**: 만약 `client_uri`가 비어있다면, `redirect_uris`의 첫 번째 주소를 파싱하여 `Scheme`과 `Host` (예: `https://gitea.hmac.kr`)를 추출해 홈페이지 주소로 사용합니다.
|
||||
3. **렌더링 (Frontend)**:
|
||||
* 응답받은 목록을 기반으로 카드를 생성합니다.
|
||||
* RP 상태가 '활성(active)'인 경우에만 클릭 이벤트를 활성화합니다.
|
||||
4. **사용자 인터랙션**:
|
||||
* 사용자가 카드를 클릭하면 `url_launcher`를 통해 새 브라우저 탭에서 해당 주소를 엽니다.
|
||||
* 주소가 없는 경우 사용자에게 안내 메시지(SnackBar)를 표시합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 백엔드 구현 상세 (Go)
|
||||
|
||||
### 파일: `backend/internal/handler/auth_handler.go`
|
||||
|
||||
#### 3.1 구조체 변경
|
||||
API 응답 모델인 `linkedRpSummary`에 `URL` 필드를 추가하여 프론트엔드로 전달할 수 있게 했습니다.
|
||||
|
||||
```go
|
||||
type linkedRpSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Logo string `json:"logo,omitempty"`
|
||||
URL string `json:"url,omitempty"` // 추가된 필드
|
||||
LastAuthenticatedAt string `json:"lastAuthenticatedAt,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 URL 할당 및 Fallback 로직 (`ListLinkedRps`)
|
||||
Hydra Client 정보 매핑 시, `ClientURI` 부재 시 `RedirectURIs`를 활용하는 로직이 핵심입니다.
|
||||
|
||||
```go
|
||||
// ClientURI가 없으면 RedirectURIs에서 호스트 부분만 추출하여 URL로 사용 (Fallback)
|
||||
clientURL := strings.TrimSpace(client.ClientURI)
|
||||
if clientURL == "" && len(client.RedirectURIs) > 0 {
|
||||
// 예: https://gitea.hmac.kr/callback -> https://gitea.hmac.kr
|
||||
if parsed, err := url.Parse(client.RedirectURIs[0]); err == nil {
|
||||
clientURL = fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
records[clientID] = &linkedRpRecord{
|
||||
linkedRpSummary: linkedRpSummary{
|
||||
// ...
|
||||
URL: clientURL, // 가공된 URL 할당
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 프론트엔드 구현 상세 (Flutter)
|
||||
|
||||
### 파일: `userfront/lib/features/dashboard/presentation/dashboard_screen.dart`
|
||||
|
||||
#### 4.1 모델 업데이트
|
||||
백엔드 응답을 처리하기 위해 `LinkedRp` 모델에 `url` 필드를 추가했습니다.
|
||||
|
||||
```dart
|
||||
class LinkedRp {
|
||||
final String id;
|
||||
// ...
|
||||
final String url; // 추가된 필드
|
||||
|
||||
factory LinkedRp.fromJson(Map<String, dynamic> json) {
|
||||
return LinkedRp(
|
||||
// ...
|
||||
url: json['url']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.2 UI 인터랙션 구현 (`_buildActivityCard`)
|
||||
카드의 클릭 가능 여부를 판단하고, 클릭 시 이동 로직을 처리합니다.
|
||||
|
||||
1. **클릭 조건**: RP 상태가 '활성'(`isActive`)이면 클릭 가능하도록 설정합니다.
|
||||
2. **시각적 피드백**:
|
||||
* 활성 상태인 경우 테두리 색상(Green)과 그림자 효과(BoxShadow)를 적용하여 클릭 가능함을 암시합니다.
|
||||
* `MouseRegion`을 사용하여 마우스 오버 시 포인터 커서(`SystemMouseCursors.click`)를 표시합니다.
|
||||
3. **이동 로직**:
|
||||
* `url_launcher` 패키지의 `launchUrl`을 사용합니다.
|
||||
* URL이 비어있거나 유효하지 않은 경우 `ScaffoldMessenger`를 통해 안내 메시지를 띄웁니다.
|
||||
|
||||
```dart
|
||||
// 활성 상태면 클릭 가능
|
||||
final isClickable = isActive;
|
||||
|
||||
// ... (UI 스타일링 코드 생략)
|
||||
|
||||
if (isClickable) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () async {
|
||||
if (item.url != null && item.url!.isNotEmpty) {
|
||||
final uri = Uri.parse(item.url!);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri);
|
||||
} else {
|
||||
// 브라우저 실행 실패 시
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('해당 링크를 열 수 없습니다.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// URL 정보가 없는 경우 (백엔드 Fallback 실패 등)
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('이동할 페이지 주소(Client URI)가 설정되지 않았습니다.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: opaqueCard,
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 요약
|
||||
이 기능은 사용자가 별도의 설정 없이도 연동된 서비스로 쉽게 이동할 수 있도록 편의성을 제공합니다. 백엔드에서의 **지능적인 주소 추론**과 프론트엔드에서의 **직관적인 UI 피드백**이 결합되어 완성되었습니다.
|
||||
@@ -3,6 +3,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'http_client.dart';
|
||||
import 'web_window.dart';
|
||||
import 'auth_token_store.dart';
|
||||
|
||||
class AuthProxyService {
|
||||
static String _envOrDefault(String key, String fallback) {
|
||||
@@ -238,12 +239,19 @@ class AuthProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> acceptConsent(String consentChallenge) async {
|
||||
static Future<Map<String, dynamic>> acceptConsent(String consentChallenge, {List<String>? grantScope}) async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/auth/consent/accept');
|
||||
final body = <String, dynamic>{
|
||||
'consent_challenge': consentChallenge,
|
||||
};
|
||||
if (grantScope != null) {
|
||||
body['grant_scope'] = grantScope;
|
||||
}
|
||||
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({'consent_challenge': consentChallenge}),
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
@@ -254,6 +262,26 @@ class AuthProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> rejectConsent(String consentChallenge) async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/auth/consent/reject');
|
||||
final body = <String, dynamic>{
|
||||
'consent_challenge': consentChallenge,
|
||||
};
|
||||
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode(body),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
} else {
|
||||
final errorBody = jsonDecode(response.body);
|
||||
throw Exception(errorBody['error'] ?? 'Failed to reject consent');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> acceptOidcLogin(
|
||||
String loginChallenge, {
|
||||
String? token,
|
||||
@@ -567,6 +595,64 @@ class AuthProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<dynamic>> fetchLinkedRps() async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/rp/linked');
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
final token = AuthTokenStore.getToken();
|
||||
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await client.get(
|
||||
url,
|
||||
headers: headers,
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
return data['items'] ?? [];
|
||||
} else {
|
||||
throw Exception('연동된 앱 목록을 불러오지 못했습니다.');
|
||||
}
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> revokeLinkedRp(String clientId) async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/rp/linked/$clientId');
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
final token = AuthTokenStore.getToken();
|
||||
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
try {
|
||||
final response = await client.delete(
|
||||
url,
|
||||
headers: headers,
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
final errorBody = jsonDecode(response.body);
|
||||
throw Exception(errorBody['error'] ?? '연동 해지에 실패했습니다.');
|
||||
}
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> sendLog(String level, String message, {Map<String, dynamic>? data}) async {
|
||||
if (!_canSendClientLog()) {
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:userfront/core/services/auth_proxy_service.dart';
|
||||
import 'package:userfront/core/services/web_window.dart';
|
||||
|
||||
@@ -22,6 +23,21 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
bool _isLoading = true;
|
||||
bool _isSubmitting = false;
|
||||
String? _error;
|
||||
|
||||
// 사용자가 선택한 스코프 목록
|
||||
final Set<String> _selectedScopes = {};
|
||||
|
||||
// 권한별 설명 매핑 (동적으로 업데이트됨)
|
||||
Map<String, String> _scopeDescriptions = {
|
||||
'openid': 'OpenID 인증 정보 (로그인 상태 확인)',
|
||||
'profile': '기본 프로필 정보 (이름, 사용자 식별자)',
|
||||
'email': '이메일 주소 (계정 식별 및 알림 용도)',
|
||||
'offline_access': '오프라인 접근 (로그인 유지)',
|
||||
'phone': '휴대폰 번호 (본인 인증 및 알림)',
|
||||
};
|
||||
|
||||
// 필수 권한 목록 (동적으로 업데이트됨)
|
||||
Set<String> _mandatoryScopes = {'openid'};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -32,13 +48,44 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
Future<void> _fetchConsentInfo() async {
|
||||
try {
|
||||
final info = await AuthProxyService.getConsentInfo(widget.consentChallenge);
|
||||
|
||||
// 백엔드에서 전달받은 커스텀 스코프 정보(scope_details) 적용
|
||||
if (info['scope_details'] != null) {
|
||||
final details = info['scope_details'] as Map<String, dynamic>;
|
||||
|
||||
details.forEach((scope, detail) {
|
||||
if (detail is Map<String, dynamic>) {
|
||||
// 설명 업데이트
|
||||
if (detail['description'] != null && detail['description'].toString().isNotEmpty) {
|
||||
_scopeDescriptions[scope] = detail['description'].toString();
|
||||
}
|
||||
// 필수 여부 업데이트
|
||||
if (detail['mandatory'] == true) {
|
||||
_mandatoryScopes.add(scope);
|
||||
} else {
|
||||
// openid는 기본적으로 필수지만 설정에서 굳이 껐다면?
|
||||
// 안전을 위해 openid는 항상 필수로 유지하는 것이 좋지만,
|
||||
// 여기서는 서버 설정을 존중하되 openid는 예외처리 할 수도 있음.
|
||||
// 우선 서버 설정이 있으면 반영 (단, openid는 제거하지 않음)
|
||||
if (scope != 'openid') {
|
||||
_mandatoryScopes.remove(scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 초기 선택 상태 설정: 모든 요청된 스코프를 기본 선택
|
||||
final requestedScopes = (info['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
|
||||
_selectedScopes.addAll(requestedScopes);
|
||||
|
||||
setState(() {
|
||||
_consentInfo = info;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = '권한 정보를 불러오지 못했습니다: $e';
|
||||
_error = '동의 정보를 불러오는데 실패했습니다: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
@@ -50,491 +97,284 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final result =
|
||||
await AuthProxyService.acceptConsent(widget.consentChallenge);
|
||||
final redirectTo = result['redirectTo']?.toString() ?? '';
|
||||
if (redirectTo.isNotEmpty) {
|
||||
if (webWindow.hasOpener() && webWindow.redirectOpenerTo(redirectTo)) {
|
||||
// 팝업에서 호출된 경우, 부모 창으로 리다이렉트 후 현재 창을 닫습니다.
|
||||
webWindow.close();
|
||||
return;
|
||||
}
|
||||
webWindow.redirectTo(redirectTo);
|
||||
return;
|
||||
// 선택된 스코프만 리스트로 변환하여 전송
|
||||
final result = await AuthProxyService.acceptConsent(
|
||||
widget.consentChallenge,
|
||||
grantScope: _selectedScopes.toList(),
|
||||
);
|
||||
|
||||
if (result['redirectTo'] != null) {
|
||||
webWindow.redirectTo(result['redirectTo']);
|
||||
} else {
|
||||
setState(() {
|
||||
_error = '동의가 처리되었으나, 리다이렉트 URL을 받지 못했습니다.';
|
||||
_isSubmitting = false;
|
||||
});
|
||||
}
|
||||
setState(() {
|
||||
_error = '동의는 완료됐지만 이동할 주소를 받지 못했습니다.';
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_error = '동의 처리 중 오류가 발생했습니다: $e';
|
||||
_error = '동의 처리에 실패했습니다: $e';
|
||||
_isSubmitting = 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,
|
||||
),
|
||||
Future<void> _onCancel() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('동의 취소'),
|
||||
content: const Text('권한 동의를 취소하면 해당 서비스를 이용할 수 없습니다. 취소하시겠습니까?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('아니오'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('예, 취소합니다'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
final resp = await AuthProxyService.rejectConsent(widget.consentChallenge);
|
||||
final redirectTo = resp['redirectTo'];
|
||||
if (redirectTo != null) {
|
||||
webWindow.redirectTo(redirectTo);
|
||||
} else {
|
||||
if (mounted) context.go('/');
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _isSubmitting = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('취소 처리 중 오류가 발생했습니다: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
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(
|
||||
backgroundColor: _subtle,
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: _border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.04),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
backgroundColor: Colors.grey[100],
|
||||
body: Center(
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: _error != null
|
||||
? _buildErrorCard()
|
||||
: _buildConsentCard(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorCard() {
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(24),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!, textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConsentCard(BuildContext context) {
|
||||
final clientName = _consentInfo?['client']?['client_name'] ?? '알 수 없는 앱';
|
||||
final clientId = _consentInfo?['client']?['client_id'] ?? '-';
|
||||
final clientLogo = _consentInfo?['client']?['logo_uri'];
|
||||
final requestedScopes = (_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 1. 헤더 영역
|
||||
const Text(
|
||||
'접근 권한 요청',
|
||||
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(28, 28, 28, 24),
|
||||
child: _isLoading
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\n계속 진행하려면 동의 여부를 선택해 주세요.',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 2. 서비스 정보 영역
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[50],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey[200]!),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (clientLogo != null && clientLogo.toString().isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundImage: NetworkImage(clientLogo),
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
)
|
||||
else
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 16),
|
||||
child: CircleAvatar(
|
||||
radius: 24,
|
||||
child: Icon(Icons.apps),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'권한 정보를 불러오는 중입니다...',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.grey[600],
|
||||
clientName,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'클라이언트 ID: $clientId',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: _consentInfo == null
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'요청 정보를 확인할 수 없습니다.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
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,
|
||||
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('동의하고 계속하기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 3. 권한 선택 영역
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'요청된 권한',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'총 ${requestedScopes.length}개',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Divider(),
|
||||
...requestedScopes.map((scope) {
|
||||
final isMandatory = _mandatoryScopes.contains(scope);
|
||||
final description = _scopeDescriptions[scope] ?? scope;
|
||||
final isSelected = _selectedScopes.contains(scope);
|
||||
|
||||
return CheckboxListTile(
|
||||
title: Text(
|
||||
scope, // 스코프 키 (예: openid)
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(description),
|
||||
value: isSelected,
|
||||
onChanged: isMandatory
|
||||
? null // 필수 항목은 변경 불가 (비활성화 상태로 체크됨)
|
||||
: (bool? value) {
|
||||
setState(() {
|
||||
if (value == true) {
|
||||
_selectedScopes.add(scope);
|
||||
} else {
|
||||
_selectedScopes.remove(scope);
|
||||
}
|
||||
});
|
||||
},
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
activeColor: Theme.of(context).primaryColor,
|
||||
);
|
||||
}).toList(),
|
||||
const Divider(),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 4. 버튼 영역
|
||||
ElevatedButton(
|
||||
onPressed: _isSubmitting ? null : _acceptConsent,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
backgroundColor: const Color(0xFF1A1F2C), // 브랜드 컬러
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: _isSubmitting
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'동의하고 계속하기',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton(
|
||||
onPressed: _isSubmitting ? null : _onCancel,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'동의 후 자동으로 서비스로 이동합니다.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,9 +4,11 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../../../core/notifiers/auth_notifier.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/services/http_client.dart';
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../../core/ui/layout_breakpoints.dart';
|
||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||
|
||||
@@ -105,6 +107,7 @@ class LinkedRp {
|
||||
final String id;
|
||||
final String name;
|
||||
final String logo;
|
||||
final String url;
|
||||
final String status;
|
||||
final List<String> scopes;
|
||||
final DateTime? lastAuthenticatedAt;
|
||||
@@ -113,6 +116,7 @@ class LinkedRp {
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.logo,
|
||||
required this.url,
|
||||
required this.status,
|
||||
required this.scopes,
|
||||
required this.lastAuthenticatedAt,
|
||||
@@ -133,6 +137,7 @@ class LinkedRp {
|
||||
id: json['id']?.toString() ?? '',
|
||||
name: json['name']?.toString() ?? '',
|
||||
logo: json['logo']?.toString() ?? '',
|
||||
url: json['url']?.toString() ?? '',
|
||||
status: json['status']?.toString() ?? '',
|
||||
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
||||
lastAuthenticatedAt: parsedLastAuth,
|
||||
@@ -140,6 +145,44 @@ class LinkedRp {
|
||||
}
|
||||
}
|
||||
|
||||
class RpHistoryItem {
|
||||
final String clientId;
|
||||
final String clientName;
|
||||
final List<String> scopes;
|
||||
final DateTime? lastApprovedAt;
|
||||
final DateTime? lastRevokedAt;
|
||||
final String status;
|
||||
|
||||
RpHistoryItem({
|
||||
required this.clientId,
|
||||
required this.clientName,
|
||||
required this.scopes,
|
||||
this.lastApprovedAt,
|
||||
this.lastRevokedAt,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
factory RpHistoryItem.fromJson(Map<String, dynamic> json) {
|
||||
DateTime? parseDate(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
try {
|
||||
return DateTime.parse(raw).toLocal();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return RpHistoryItem(
|
||||
clientId: json['client_id']?.toString() ?? '',
|
||||
clientName: json['client_name']?.toString() ?? '',
|
||||
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
||||
lastApprovedAt: parseDate(json['last_approved_at']?.toString()),
|
||||
lastRevokedAt: parseDate(json['last_revoked_at']?.toString()),
|
||||
status: json['status']?.toString() ?? 'unknown',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DashboardScreen extends ConsumerStatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
@@ -159,9 +202,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
bool _auditLoading = false;
|
||||
bool _auditLoadingMore = false;
|
||||
String? _auditError;
|
||||
bool _isRevoking = false;
|
||||
|
||||
Future<List<LinkedRp>>? _linkedRpsFuture;
|
||||
Future<List<RpHistoryItem>>? _rpHistoryFuture;
|
||||
bool _showAllActivities = false;
|
||||
final Set<String> _revokedClientIds = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -169,6 +215,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
_pageScrollController.addListener(_onPageScroll);
|
||||
_loadAuditLogs(reset: true);
|
||||
_linkedRpsFuture = _fetchLinkedRps();
|
||||
_rpHistoryFuture = _fetchRpHistory();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -182,6 +229,52 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
AuthNotifier.instance.notify();
|
||||
}
|
||||
|
||||
Future<void> _onRevokeLink(String clientId, String appName) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('연동 해지'),
|
||||
content: Text('$appName 앱과의 연동을 해지하시겠습니까?\n해지하면 다음 로그인 시 다시 동의가 필요합니다.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('해지하기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
setState(() => _isRevoking = true);
|
||||
try {
|
||||
await AuthProxyService.revokeLinkedRp(clientId);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('$appName 연동이 해지되었습니다.')),
|
||||
);
|
||||
setState(() {
|
||||
_revokedClientIds.add(clientId);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('해지 실패: $e')),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isRevoking = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onScanQR() {
|
||||
context.push('/scan');
|
||||
}
|
||||
@@ -195,6 +288,94 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _showRpDetails(_ActivityItem item) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(item.appName),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('권한 (Scopes)', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
if (item.scopes.isEmpty)
|
||||
const Text('요청된 권한이 없습니다.', style: TextStyle(color: Colors.grey))
|
||||
else
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: item.scopes.map((s) => Chip(
|
||||
label: Text(s, style: const TextStyle(fontSize: 12)),
|
||||
visualDensity: VisualDensity.compact,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
)).toList(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text('상태 이력', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
FutureBuilder<List<RpHistoryItem>>(
|
||||
future: _rpHistoryFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const SizedBox(height: 20, child: LinearProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError || !snapshot.hasData) {
|
||||
return const Text('이력을 불러올 수 없습니다.', style: TextStyle(color: Colors.grey));
|
||||
}
|
||||
final history = snapshot.data!.where((h) => h.clientId == item.clientId).toList();
|
||||
if (history.isEmpty) {
|
||||
// Fallback to item data if no history found (e.g. fresh login)
|
||||
return Text('최근 인증: ${item.lastAuthAt}');
|
||||
}
|
||||
final h = history.first;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (h.lastApprovedAt != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.check_circle_outline, size: 16, color: Colors.green),
|
||||
const SizedBox(width: 8),
|
||||
Text('승인: ${_formatDateTime(h.lastApprovedAt!)}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (h.lastRevokedAt != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.cancel_outlined, size: 16, color: Colors.redAccent),
|
||||
const SizedBox(width: 8),
|
||||
Text('해지: ${_formatDateTime(h.lastRevokedAt!)}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text('현재 상태: ${h.status == 'active' ? '활성' : '해지됨'}',
|
||||
style: TextStyle(color: h.status == 'active' ? Colors.green : Colors.grey)),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSideMenu(BuildContext context, {required bool closeOnTap}) {
|
||||
return SafeArea(
|
||||
child: ListView(
|
||||
@@ -251,6 +432,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
await ref.read(profileProvider.notifier).loadProfile();
|
||||
await _loadAuditLogs(reset: true);
|
||||
setState(() {
|
||||
_revokedClientIds.clear();
|
||||
_linkedRpsFuture = _fetchLinkedRps();
|
||||
});
|
||||
if (_linkedRpsFuture != null) {
|
||||
@@ -375,6 +557,37 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
return linkedRps;
|
||||
}
|
||||
|
||||
Future<List<RpHistoryItem>> _fetchRpHistory() async {
|
||||
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
||||
final url = Uri.parse('$baseUrl/api/v1/user/rp/history');
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
final token = AuthTokenStore.getToken();
|
||||
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
|
||||
final response = await client.get(url, headers: headers);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to load rp history');
|
||||
}
|
||||
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final items = (body['items'] as List?) ?? [];
|
||||
final history = items
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(RpHistoryItem.fromJson)
|
||||
.toList();
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
DateTime? _getJwtIssuedAt() {
|
||||
final token = AuthTokenStore.getToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
@@ -673,6 +886,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
const SizedBox(height: 12),
|
||||
_buildActivitySection(isMobile),
|
||||
const SizedBox(height: 28),
|
||||
_buildSectionTitle('과거 연동 앱', '이전에 연동했던 앱 목록입니다.'),
|
||||
const SizedBox(height: 12),
|
||||
_buildPastRps(isMobile),
|
||||
const SizedBox(height: 28),
|
||||
_buildSectionTitle('접속이력', 'Baron 통합로그인 기준의 최근 접근 기록입니다.'),
|
||||
const SizedBox(height: 12),
|
||||
_buildAccessHistory(timelineWide),
|
||||
@@ -817,21 +1034,65 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPastRps(bool isMobile) {
|
||||
return FutureBuilder<List<RpHistoryItem>>(
|
||||
future: _rpHistoryFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const SizedBox(height: 40, child: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
final pastItems = (snapshot.data ?? []).where((h) => h.status != 'active').toList();
|
||||
if (pastItems.isEmpty) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'과거 연동 이력이 없습니다.',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[700], fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final activities = pastItems.map((h) => _ActivityItem(
|
||||
clientId: h.clientId,
|
||||
appName: h.clientName.isNotEmpty ? h.clientName : h.clientId,
|
||||
lastAuthAt: h.lastRevokedAt != null ? '해지: ${_formatDateTime(h.lastRevokedAt!)}' : '해지됨',
|
||||
status: '해지됨',
|
||||
scopes: h.scopes,
|
||||
canLogout: false,
|
||||
isRevoked: true,
|
||||
onRevoke: null,
|
||||
)).toList();
|
||||
|
||||
return _buildActivityGrid(activities, isMobile);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<_ActivityItem> _buildActivityItems(List<LinkedRp> linkedRps) {
|
||||
final items = <_ActivityItem>[];
|
||||
for (final rp in linkedRps) {
|
||||
final isRevoked = _revokedClientIds.contains(rp.id);
|
||||
final lastAuthLabel = rp.lastAuthenticatedAt != null
|
||||
? _formatDateTime(rp.lastAuthenticatedAt!)
|
||||
: '연동됨';
|
||||
|
||||
final normalizedStatus = rp.status.toLowerCase();
|
||||
final statusLabel = normalizedStatus.isEmpty || normalizedStatus == 'active' ? '활성' : '비활성';
|
||||
final statusLabel = isRevoked ? '비활성' : (normalizedStatus.isEmpty || normalizedStatus == 'active' ? '활성' : '비활성');
|
||||
final name = rp.name.isNotEmpty ? rp.name : rp.id;
|
||||
items.add(
|
||||
_ActivityItem(
|
||||
clientId: rp.id,
|
||||
appName: name,
|
||||
lastAuthAt: lastAuthLabel,
|
||||
status: statusLabel,
|
||||
scopes: rp.scopes,
|
||||
canLogout: false,
|
||||
isRevoked: isRevoked,
|
||||
onRevoke: isRevoked ? null : () => _onRevokeLink(rp.id, name),
|
||||
url: rp.url, // URL 전달
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -885,14 +1146,29 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
}
|
||||
|
||||
Widget _buildActivityCard(_ActivityItem item) {
|
||||
final statusColor = item.status == '활성' ? Colors.green : Colors.grey;
|
||||
return Container(
|
||||
final isActive = item.status == '활성';
|
||||
final statusColor = isActive ? Colors.green : Colors.grey;
|
||||
final borderColor = isActive ? Colors.green.withOpacity(0.5) : _border;
|
||||
final borderWidth = isActive ? 1.5 : 1.0;
|
||||
|
||||
// 활성 상태면 클릭 가능 (URL 유무와 관계없이)
|
||||
final isClickable = isActive;
|
||||
|
||||
// 카드 컨텐츠
|
||||
final cardContent = Container(
|
||||
width: 260,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: _surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: _border),
|
||||
border: Border.all(color: borderColor, width: borderWidth),
|
||||
boxShadow: isActive ? [
|
||||
BoxShadow(
|
||||
color: Colors.green.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
)
|
||||
] : null,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -929,20 +1205,93 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: _ink),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: item.canLogout ? item.onLogout : null,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: _ink,
|
||||
side: const BorderSide(color: _border),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => _showRpDetails(item),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: _ink,
|
||||
side: const BorderSide(color: _border),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
child: const Text('상세정보', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
),
|
||||
child: const Text('로그아웃'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (item.canLogout)
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: item.onLogout,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: _ink,
|
||||
side: const BorderSide(color: _border),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
child: const Text('로그아웃', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
),
|
||||
if (item.canLogout) const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: (_isRevoking || item.isRevoked) ? null : item.onRevoke,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: item.isRevoked ? Colors.grey : Colors.redAccent,
|
||||
side: BorderSide(color: item.isRevoked ? Colors.grey : Colors.redAccent, width: 0.5),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
child: _isRevoking && !item.isRevoked
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.redAccent),
|
||||
)
|
||||
: Text(item.isRevoked ? '해지됨' : '연동 해지', style: const TextStyle(fontSize: 13)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// Opacity 적용
|
||||
final opaqueCard = Opacity(
|
||||
opacity: item.isRevoked ? 0.6 : 1.0,
|
||||
child: cardContent,
|
||||
);
|
||||
|
||||
// 클릭 가능한 경우 InkWell로 감싸기
|
||||
if (isClickable) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () async {
|
||||
if (item.url != null && item.url!.isNotEmpty) {
|
||||
final uri = Uri.parse(item.url!);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri);
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('해당 링크를 열 수 없습니다.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('이동할 페이지 주소(Client URI)가 설정되지 않았습니다.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: opaqueCard,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return opaqueCard;
|
||||
}
|
||||
|
||||
Widget _buildAccessHistory(bool isWide) {
|
||||
@@ -1136,17 +1485,27 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||
}
|
||||
|
||||
class _ActivityItem {
|
||||
final String clientId;
|
||||
final String appName;
|
||||
final String lastAuthAt;
|
||||
final String status;
|
||||
final String? url;
|
||||
final List<String> scopes;
|
||||
final bool canLogout;
|
||||
final bool isRevoked;
|
||||
final VoidCallback? onLogout;
|
||||
final VoidCallback? onRevoke;
|
||||
|
||||
_ActivityItem({
|
||||
required this.clientId,
|
||||
required this.appName,
|
||||
required this.lastAuthAt,
|
||||
required this.status,
|
||||
required this.scopes,
|
||||
required this.canLogout,
|
||||
this.url,
|
||||
this.isRevoked = false,
|
||||
this.onLogout,
|
||||
this.onRevoke,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user