diff --git a/adminfront/biome.json b/adminfront/biome.json index 04f99bc8..29399f2f 100644 --- a/adminfront/biome.json +++ b/adminfront/biome.json @@ -1,6 +1,7 @@ { "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", "formatter": { + "enabled": true, "indentStyle": "space" }, "linter": { diff --git a/adminfront/package.json b/adminfront/package.json index c1be63e9..20bb9b5a 100644 --- a/adminfront/package.json +++ b/adminfront/package.json @@ -7,6 +7,8 @@ "dev": "vite --host 0.0.0.0", "build": "tsc -b && vite build", "lint": "biome check .", + "lint:fix": "biome check . --write", + "format": "biome format . --write", "preview": "vite preview", "test": "playwright test", "test:ui": "playwright test --ui" diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx index 3fb5c9c4..6d32ada3 100644 --- a/adminfront/src/features/users/UserCreatePage.tsx +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { ArrowLeft, Loader2, Save } from "lucide-react"; +import { ArrowLeft, ClipboardCopy, Loader2, Save } from "lucide-react"; import * as React from "react"; import { useForm } from "react-hook-form"; import { Link, useNavigate } from "react-router-dom"; @@ -14,12 +14,19 @@ import { } from "../../components/ui/card"; import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; -import { createUser, type UserCreateRequest } from "../../lib/adminApi"; +import { + createUser, + type UserCreateRequest, + type UserCreateResponse, +} from "../../lib/adminApi"; function UserCreatePage() { const navigate = useNavigate(); const queryClient = useQueryClient(); const [error, setError] = React.useState(null); + const [generatedPassword, setGeneratedPassword] = React.useState(null); + const [createdEmail, setCreatedEmail] = React.useState(null); + const [autoPassword, setAutoPassword] = React.useState(true); const { register, @@ -39,8 +46,13 @@ function UserCreatePage() { const mutation = useMutation({ mutationFn: createUser, - onSuccess: () => { + onSuccess: (data: UserCreateResponse) => { queryClient.invalidateQueries({ queryKey: ["users"] }); + if (data.initialPassword) { + setGeneratedPassword(data.initialPassword); + setCreatedEmail(data.email); + return; + } navigate("/users"); }, onError: (err: AxiosError<{ error?: string }>) => { @@ -50,9 +62,31 @@ function UserCreatePage() { const onSubmit = (data: UserCreateRequest) => { setError(null); + setGeneratedPassword(null); + setCreatedEmail(null); + + if (autoPassword) { + mutation.mutate({ ...data, password: "" }); + return; + } + + if (!data.password) { + setError("비밀번호를 입력하거나 자동 생성을 사용해 주세요."); + return; + } + mutation.mutate(data); }; + const onCopyPassword = async () => { + if (!generatedPassword) return; + try { + await navigator.clipboard.writeText(generatedPassword); + } catch (_) { + // ignore + } + }; + return (
@@ -74,12 +108,33 @@ function UserCreatePage() {
+ {generatedPassword && ( + + + 초기 비밀번호 생성 완료 + + {createdEmail ? `${createdEmail} 계정의 초기 비밀번호입니다.` : "초기 비밀번호가 생성되었습니다."} + + + +
+ {generatedPassword} + +
+
+ +
+
+
+ )} + 계정 정보 - - 새로운 사용자를 시스템에 등록합니다. - + 새로운 사용자를 시스템에 등록합니다.
@@ -102,22 +157,29 @@ function UserCreatePage() {
- +
+ + +

- 초기 비밀번호를 설정합니다. + {autoPassword + ? "비워두면 시스템이 초기 비밀번호를 자동 생성합니다." + : "초기 비밀번호를 직접 설정합니다."}

- {errors.password && ( -

{errors.password.message}

- )}
@@ -203,4 +265,4 @@ function UserCreatePage() { ); } -export default UserCreatePage; \ No newline at end of file +export default UserCreatePage; diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 1eb631be..e27fbb29 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -215,6 +215,7 @@ function UserListPage() { variant="ghost" size="icon" onClick={() => navigate(`/users/${user.id}`)} + aria-label={`사용자 수정: ${user.name}`} > @@ -224,6 +225,7 @@ function UserListPage() { className="text-destructive hover:text-destructive" onClick={() => handleDelete(user.id, user.name)} disabled={deleteMutation.isPending} + aria-label={`사용자 삭제: ${user.name}`} > diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 965d5092..ec4844e3 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -182,7 +182,7 @@ export type UserListResponse = { export type UserCreateRequest = { email: string; - password: string; + password?: string; name: string; phone?: string; role?: string; @@ -190,6 +190,10 @@ export type UserCreateRequest = { department?: string; }; +export type UserCreateResponse = UserSummary & { + initialPassword?: string; +}; + export type UserUpdateRequest = { password?: string; name?: string; @@ -215,7 +219,7 @@ export async function fetchUser(userId: string) { } export async function createUser(payload: UserCreateRequest) { - const { data } = await apiClient.post( + const { data } = await apiClient.post( "/v1/admin/users", payload, ); diff --git a/adminfront/tests/user-management.spec.ts b/adminfront/tests/user-management.spec.ts new file mode 100644 index 00000000..ebd4e730 --- /dev/null +++ b/adminfront/tests/user-management.spec.ts @@ -0,0 +1,139 @@ +import { expect, test } from "@playwright/test"; + +type UserSummary = { + id: string; + email: string; + name: string; + phone?: string; + role: string; + status: string; + companyCode?: string; + department?: string; + createdAt: string; + updatedAt: string; +}; + +type UserCreatePayload = { + email: string; + password?: string; + name: string; + phone?: string; + role?: string; + companyCode?: string; + department?: string; +}; + +test("user create and delete flow", async ({ page }) => { + const users: UserSummary[] = []; + let idSeq = 1; + + await page.route("**/api/v1/admin/users**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname; + const isCollection = path.endsWith("/api/v1/admin/users"); + const isItem = path.includes("/api/v1/admin/users/"); + + if (request.method() === "GET" && isCollection) { + const search = url.searchParams.get("search")?.toLowerCase() ?? ""; + const limit = Number(url.searchParams.get("limit") ?? "50"); + const offset = Number(url.searchParams.get("offset") ?? "0"); + const filtered = search + ? users.filter( + (user) => + user.name.toLowerCase().includes(search) || + user.email.toLowerCase().includes(search), + ) + : users; + const items = filtered.slice(offset, offset + limit); + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items, + limit, + offset, + total: filtered.length, + }), + }); + return; + } + + if (request.method() === "POST" && isCollection) { + const payload = request.postDataJSON() as UserCreatePayload; + const now = new Date().toISOString(); + const user: UserSummary = { + id: `user-${idSeq++}`, + email: payload.email, + name: payload.name, + phone: payload.phone, + role: payload.role ?? "user", + status: "active", + companyCode: payload.companyCode, + department: payload.department, + createdAt: now, + updatedAt: now, + }; + users.unshift(user); + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(user), + }); + return; + } + + if (request.method() === "DELETE" && isItem) { + const userId = path.split("/").pop(); + const index = users.findIndex((user) => user.id === userId); + if (index === -1) { + await route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ error: "User not found" }), + }); + return; + } + users.splice(index, 1); + await route.fulfill({ status: 204, body: "" }); + return; + } + + await route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ error: "Not found" }), + }); + }); + + await page.goto("/users"); + + await page.getByRole("link", { name: "사용자 추가" }).click(); + await expect(page).toHaveURL(/\/users\/new$/); + + const uniqueEmail = `playwright-${Date.now()}@example.com`; + + await page.getByRole("checkbox", { name: "자동 생성" }).setChecked(false); + await page.getByLabel("이메일").fill(uniqueEmail); + await page.getByLabel("비밀번호").fill("Test1234!"); + await page.getByLabel("이름").fill("Playwright User"); + await page.getByLabel("전화번호").fill("010-0000-0000"); + await page.getByLabel("회사 코드").fill("E2E"); + await page.getByLabel("부서").fill("QA"); + await page.getByLabel("역할 (Role)").selectOption("admin"); + + await page.getByRole("button", { name: "사용자 생성" }).click(); + await expect(page).toHaveURL(/\/users$/); + + const createdRow = page.locator("tbody tr").filter({ hasText: uniqueEmail }); + await expect(createdRow).toBeVisible(); + + page.once("dialog", (dialog) => dialog.accept()); + await createdRow.getByRole("button", { name: /사용자 삭제/ }).click(); + + await expect( + page.locator("tbody tr").filter({ hasText: uniqueEmail }), + ).toHaveCount(0); +}); diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index de360afa..055a44bb 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -99,6 +99,9 @@ func main() { } slog.Info("✅ IDP Schema Validation Passed", "idp", idpProvider.Name()) // ----------------------------------- + if err := bootstrap.SeedAdminIdentity(idpProvider); err != nil { + slog.Error("❌ Admin identity seed failed", "error", err) + } // 2. Initialize DB Connections // ClickHouse @@ -186,7 +189,9 @@ func main() { adminHandler := handler.NewAdminHandler() devHandler := handler.NewDevHandler() tenantHandler := handler.NewTenantHandler(db) - userHandler := handler.NewUserHandler(db) + kratosAdminService := service.NewKratosAdminService() + oryAdminProvider := service.NewOryProvider() + userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider) apiKeyHandler := handler.NewApiKeyHandler(db) // 3. Initialize Fiber @@ -398,10 +403,10 @@ func main() { // API Group api := app.Group("/api/v1") - + workerCount, _ := strconv.Atoi(getEnv("AUDIT_WORKER_COUNT", "5")) queueSize, _ := strconv.Atoi(getEnv("AUDIT_QUEUE_SIZE", "2000")) - + api.Use(middleware.AuditMiddleware(middleware.AuditConfig{ Repo: auditRepo, ExcludePaths: map[string]struct{}{ @@ -451,6 +456,7 @@ func main() { user.Put("/me", authHandler.UpdateMe) user.Post("/me/send-code", authHandler.SendUpdateCode) user.Post("/me/verify-code", authHandler.VerifyUpdateCode) + user.Get("/rp/linked", authHandler.ListLinkedRps) // Admin Routes admin := api.Group("/admin") diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 35b83e87..a1aaa2bd 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -2,12 +2,9 @@ package bootstrap import ( "baron-sso-backend/internal/domain" - "errors" "fmt" "log/slog" - "os" - "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) @@ -20,11 +17,7 @@ func Run(db *gorm.DB) error { return fmt.Errorf("migration failed: %w", err) } - // 2. Seed Initial Admin User - if err := seedAdminUser(db); err != nil { - return fmt.Errorf("seeding admin failed: %w", err) - } - + slog.Info("[Bootstrap] User seed skipped (Kratos is SoT)") slog.Info("[Bootstrap] Bootstrap completed successfully.") return nil } @@ -33,7 +26,6 @@ func migrateSchemas(db *gorm.DB) error { slog.Info("[Bootstrap] Migrating database schemas...") // Add all domain models here return db.AutoMigrate( - &domain.User{}, &domain.Tenant{}, &domain.ApiKey{}, &domain.IdentityProviderConfig{}, @@ -41,44 +33,3 @@ func migrateSchemas(db *gorm.DB) error { // &domain.UserConsent{}, // TODO: Uncomment when model is ready ) } - -func seedAdminUser(db *gorm.DB) error { - adminEmail := os.Getenv("ADMIN_EMAIL") - adminPassword := os.Getenv("ADMIN_PASSWORD") - - if adminEmail == "" || adminPassword == "" { - slog.Warn("[Bootstrap] ADMIN_EMAIL or ADMIN_PASSWORD not set. Skipping admin seeding.") - return nil - } - - var user domain.User - if err := db.Unscoped().Where("email = ?", adminEmail).First(&user).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - slog.Info("[Bootstrap] Creating initial admin user", "email", adminEmail) - - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost) - if err != nil { - return err - } - - adminUser := domain.User{ - Email: adminEmail, - PasswordHash: string(hashedPassword), - Name: "System Admin", - Role: "admin", // Assuming 'role' field exists or handling via attributes - // Add other required fields - } - - if err := db.Create(&adminUser).Error; err != nil { - return err - } - slog.Info("[Bootstrap] Admin user created successfully.") - } else { - return err - } - } else { - slog.Info("[Bootstrap] Admin user already exists.", "email", adminEmail) - } - - return nil -} diff --git a/backend/internal/bootstrap/kratos_seed.go b/backend/internal/bootstrap/kratos_seed.go new file mode 100644 index 00000000..7ddb4a4c --- /dev/null +++ b/backend/internal/bootstrap/kratos_seed.go @@ -0,0 +1,51 @@ +package bootstrap + +import ( + "baron-sso-backend/internal/domain" + "log/slog" + "os" + "strings" +) + +// SeedAdminIdentity creates the initial admin identity in the configured IDP. +func SeedAdminIdentity(idp domain.IdentityProvider) error { + if idp == nil { + return nil + } + + adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL")) + adminPassword := os.Getenv("ADMIN_PASSWORD") + if adminEmail == "" || adminPassword == "" { + slog.Warn("[Bootstrap] ADMIN_EMAIL or ADMIN_PASSWORD not set. Skipping admin identity seed.") + return nil + } + + adminName := strings.TrimSpace(os.Getenv("ADMIN_NAME")) + if adminName == "" { + adminName = "System Admin" + } + + user := &domain.BrokerUser{ + Email: adminEmail, + Name: adminName, + PhoneNumber: "", + Attributes: map[string]interface{}{ + "department": "Admin", + "affiliationType": "internal", + "companyCode": "", + "grade": "admin", + }, + } + + _, err := idp.CreateUser(user, adminPassword) + if err != nil { + if strings.Contains(err.Error(), "already exists") { + slog.Info("[Bootstrap] Admin identity already exists in IDP", "email", adminEmail) + return nil + } + return err + } + + slog.Info("[Bootstrap] Admin identity created in IDP", "email", adminEmail, "idp", idp.Name()) + return nil +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index f931f01a..c1d3b882 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -4,6 +4,7 @@ import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/logger" "baron-sso-backend/internal/service" + "baron-sso-backend/internal/utils" "bytes" "context" crand "crypto/rand" @@ -18,7 +19,7 @@ import ( "net/http" "net/url" "os" - "regexp" + "sort" "strconv" "strings" "time" @@ -75,6 +76,7 @@ type AuthHandler struct { DescopeClient *client.DescopeClient IdpProvider domain.IdentityProvider AuditRepo domain.AuditRepository + Hydra *service.HydraAdminService } type signupState struct { @@ -156,6 +158,7 @@ func NewAuthHandler(redisService *service.RedisService, idpProvider domain.Ident DescopeClient: descopeClient, IdpProvider: idpProvider, AuditRepo: auditRepo, + Hydra: service.NewHydraAdminService(), } } @@ -497,49 +500,7 @@ func (h *AuthHandler) resolvePasswordPolicy() *domain.PasswordPolicy { // validatePasswordWithPolicy는 정책 기준으로 비밀번호를 검증합니다. func validatePasswordWithPolicy(policy *domain.PasswordPolicy, password string) error { - if policy == nil { - return nil - } - if policy.MinLength > 0 && len(password) < policy.MinLength { - return fmt.Errorf("비밀번호는 최소 %d자 이상이어야 합니다", policy.MinLength) - } - - types := 0 - hasLower := regexp.MustCompile(`[a-z]`).MatchString(password) - hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password) - hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password) - hasSymbol := regexp.MustCompile(`[\W_]`).MatchString(password) - if hasLower { - types++ - } - if hasUpper { - types++ - } - if hasNumber { - types++ - } - if hasSymbol { - types++ - } - - if policy.MinCharacterTypes > 0 && types < policy.MinCharacterTypes { - return fmt.Errorf("비밀번호는 영문 대/소문자/숫자/특수문자 중 %d가지 이상을 포함해야 합니다", policy.MinCharacterTypes) - } - - if policy.Lowercase && !hasLower { - return fmt.Errorf("비밀번호에 소문자가 포함되어야 합니다") - } - if policy.Uppercase && !hasUpper { - return fmt.Errorf("비밀번호에 대문자가 포함되어야 합니다") - } - if policy.Number && !hasNumber { - return fmt.Errorf("비밀번호에 숫자가 포함되어야 합니다") - } - if policy.NonAlphanumeric && !hasSymbol { - return fmt.Errorf("비밀번호에 특수문자가 포함되어야 합니다") - } - - return nil + return utils.ValidatePasswordWithPolicy(policy, password) } // GetPasswordPolicy는 IDP 기준 비밀번호 정책을 제공합니다. @@ -2345,6 +2306,109 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { }) } +type linkedRpSummary struct { + ID string `json:"id"` + Name string `json:"name"` + Logo string `json:"logo,omitempty"` + LastAuthenticatedAt string `json:"lastAuthenticatedAt,omitempty"` + Status string `json:"status"` + Scopes []string `json:"scopes,omitempty"` +} + +type linkedRpListResponse struct { + Items []linkedRpSummary `json:"items"` +} + +type linkedRpRecord struct { + linkedRpSummary + lastAuth time.Time +} + +func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error { + if h.Hydra == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "hydra admin unavailable"}) + } + + subject, err := h.resolveConsentSubject(c) + if err != nil || subject == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + } + + sessions, err := h.Hydra.ListConsentSessions(c.Context(), subject, "") + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + records := make(map[string]*linkedRpRecord) + for _, session := range sessions { + clientID := strings.TrimSpace(session.Client.ClientID) + if clientID == "" { + continue + } + + name := strings.TrimSpace(session.Client.ClientName) + if name == "" { + name = clientID + } + + lastAuth := time.Time{} + if session.AuthenticatedAt != nil { + lastAuth = *session.AuthenticatedAt + } else if session.RequestedAt != nil { + lastAuth = *session.RequestedAt + } + + scopes := session.GrantedScope + if len(scopes) == 0 && strings.TrimSpace(session.Client.Scope) != "" { + scopes = strings.Fields(session.Client.Scope) + } + + existing := records[clientID] + if existing == nil { + records[clientID] = &linkedRpRecord{ + linkedRpSummary: linkedRpSummary{ + ID: clientID, + Name: name, + Logo: extractHydraClientLogo(session.Client.Metadata), + Status: hydraClientStatus(session.Client.Metadata), + Scopes: scopes, + }, + lastAuth: lastAuth, + } + continue + } + + if existing.Name == "" { + existing.Name = name + } + if existing.Logo == "" { + existing.Logo = extractHydraClientLogo(session.Client.Metadata) + } + existing.Scopes = mergeScopes(existing.Scopes, scopes) + if lastAuth.After(existing.lastAuth) { + existing.lastAuth = lastAuth + } + } + + ordered := make([]*linkedRpRecord, 0, len(records)) + for _, record := range records { + ordered = append(ordered, record) + } + sort.Slice(ordered, func(i, j int) bool { + return ordered[i].lastAuth.After(ordered[j].lastAuth) + }) + + items := make([]linkedRpSummary, 0, len(ordered)) + for _, record := range ordered { + if !record.lastAuth.IsZero() { + record.LastAuthenticatedAt = record.lastAuth.Format(time.RFC3339) + } + items = append(items, record.linkedRpSummary) + } + + return c.JSON(linkedRpListResponse{Items: items}) +} + func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) { token := h.getBearerToken(c) if token != "" { @@ -2384,6 +2448,19 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe return h.getKratosProfileWithCookie(cookie) } +func (h *AuthHandler) resolveConsentSubject(c *fiber.Ctx) (string, error) { + token := h.getBearerToken(c) + if token != "" { + return h.resolveIdentityID(c, token) + } + cookie := c.Get("Cookie") + if cookie == "" { + return "", fmt.Errorf("missing authorization token") + } + identityID, _, err := h.getKratosIdentityWithCookie(cookie) + return identityID, err +} + func isAuthEventType(eventType string) bool { normalized := strings.ToLower(eventType) return strings.Contains(normalized, " /api/v1/auth/") @@ -3345,3 +3422,68 @@ func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error { return c.JSON(fiber.Map{"success": true}) } + +func hydraClientStatus(metadata map[string]interface{}) string { + if metadata == nil { + return "active" + } + if value, ok := metadata["status"].(string); ok { + normalized := strings.ToLower(strings.TrimSpace(value)) + if normalized != "" { + return normalized + } + } + return "active" +} + +func extractHydraClientLogo(metadata map[string]interface{}) string { + if metadata == nil { + return "" + } + candidates := []string{ + "logo", + "logo_url", + "logoUrl", + "logo_uri", + "logoUri", + "app_logo", + "appLogo", + } + for _, key := range candidates { + if value, ok := metadata[key]; ok { + if logo, ok := value.(string); ok { + logo = strings.TrimSpace(logo) + if logo != "" { + return logo + } + } + } + } + return "" +} + +func mergeScopes(current []string, next []string) []string { + if len(next) == 0 { + return current + } + seen := make(map[string]struct{}, len(current)+len(next)) + for _, scope := range current { + scope = strings.TrimSpace(scope) + if scope == "" { + continue + } + seen[scope] = struct{}{} + } + for _, scope := range next { + scope = strings.TrimSpace(scope) + if scope == "" { + continue + } + if _, ok := seen[scope]; ok { + continue + } + seen[scope] = struct{}{} + current = append(current, scope) + } + return current +} diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 8dee9917..1769291a 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -2,21 +2,24 @@ package handler import ( "baron-sso-backend/internal/domain" - "errors" + "baron-sso-backend/internal/service" + "baron-sso-backend/internal/utils" "strings" "time" "github.com/gofiber/fiber/v2" - "golang.org/x/crypto/bcrypt" - "gorm.io/gorm" ) type UserHandler struct { - DB *gorm.DB + KratosAdmin *service.KratosAdminService + OryProvider *service.OryProvider } -func NewUserHandler(db *gorm.DB) *UserHandler { - return &UserHandler{DB: db} +func NewUserHandler(kratosAdmin *service.KratosAdminService, oryProvider *service.OryProvider) *UserHandler { + return &UserHandler{ + KratosAdmin: kratosAdmin, + OryProvider: oryProvider, + } } type userSummary struct { @@ -30,6 +33,7 @@ type userSummary struct { Department string `json:"department"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` + InitialPassword string `json:"initialPassword,omitempty"` } type userListResponse struct { @@ -40,8 +44,8 @@ type userListResponse struct { } func (h *UserHandler) ListUsers(c *fiber.Ctx) error { - if h.DB == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + if h.KratosAdmin == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"}) } limit := c.QueryInt("limit", 50) @@ -55,33 +59,45 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error { offset = 0 } - query := h.DB.Model(&domain.User{}) - if search != "" { - like := "%" + search + "%" - query = query.Where("email ILIKE ? OR name ILIKE ?", like, like) - } - - var total int64 - if err := query.Count(&total).Error; err != nil { + identities, err := h.KratosAdmin.ListIdentities(c.Context()) + if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } - var users []domain.User - if err := query.Order("created_at desc").Limit(limit).Offset(offset).Find(&users).Error; err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + filtered := make([]service.KratosIdentity, 0, len(identities)) + if search == "" { + filtered = identities + } else { + searchLower := strings.ToLower(search) + for _, identity := range identities { + email := strings.ToLower(extractTraitString(identity.Traits, "email")) + name := strings.ToLower(extractTraitString(identity.Traits, "name")) + if strings.Contains(email, searchLower) || strings.Contains(name, searchLower) { + filtered = append(filtered, identity) + } + } } - items := make([]userSummary, 0, len(users)) - for _, u := range users { - items = append(items, mapUserSummary(u)) + total := int64(len(filtered)) + if offset > len(filtered) { + offset = len(filtered) + } + end := offset + limit + if end > len(filtered) { + end = len(filtered) + } + + items := make([]userSummary, 0, end-offset) + for _, identity := range filtered[offset:end] { + items = append(items, mapIdentitySummary(identity)) } return c.JSON(userListResponse{Items: items, Limit: limit, Offset: offset, Total: total}) } func (h *UserHandler) GetUser(c *fiber.Ctx) error { - if h.DB == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + if h.KratosAdmin == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"}) } userID := strings.TrimSpace(c.Params("id")) @@ -89,20 +105,20 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"}) } - var user domain.User - if err := h.DB.First(&user, "id = ?", userID).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"}) - } + identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) + if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } + if identity == nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"}) + } - return c.JSON(mapUserSummary(user)) + return c.JSON(mapIdentitySummary(*identity)) } func (h *UserHandler) CreateUser(c *fiber.Ctx) error { - if h.DB == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + if h.OryProvider == nil || h.KratosAdmin == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"}) } var req struct { @@ -122,52 +138,83 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { if email == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "email is required"}) } - password := req.Password - if password == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "password is required"}) - } name := strings.TrimSpace(req.Name) if name == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"}) } - // Check duplicates - var exists domain.User - if err := h.DB.Where("email = ?", email).First(&exists).Error; err == nil { - return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "email already exists"}) + password := strings.TrimSpace(req.Password) + policy, err := h.OryProvider.GetPasswordPolicy() + if err != nil || policy == nil { + policy = &domain.PasswordPolicy{ + MinLength: 12, + Lowercase: true, + Uppercase: false, + Number: true, + NonAlphanumeric: true, + MinCharacterTypes: 0, + } } - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + generatedPassword := "" + if password == "" { + generated, genErr := utils.GeneratePasswordWithPolicy(policy) + if genErr != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate password"}) + } + password = generated + generatedPassword = generated + } else { + if err := utils.ValidatePasswordWithPolicy(policy, password); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) + } + } + + role := strings.TrimSpace(req.Role) + if role == "" { + role = "user" + } + + attributes := map[string]interface{}{ + "department": req.Department, + "affiliationType": "internal", + "companyCode": req.CompanyCode, + "grade": role, + } + + brokerUser := &domain.BrokerUser{ + Email: email, + Name: name, + PhoneNumber: normalizePhoneNumber(req.Phone), + Attributes: attributes, + } + + identityID, err := h.OryProvider.CreateUser(brokerUser, password) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to hash password"}) - } - - user := domain.User{ - Email: email, - PasswordHash: string(hashedPassword), - Name: name, - Phone: req.Phone, - Role: req.Role, // default "user" handled by GORM if empty, but struct default usually works on zero value? GORM default tag works for zero value. - CompanyCode: req.CompanyCode, - Department: req.Department, - Status: "active", - AffiliationType: "internal", // Defaulting for now - } - - if user.Role == "" { - user.Role = "user" - } - - if err := h.DB.Create(&user).Error; err != nil { + if strings.Contains(err.Error(), "already exists") { + return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "email already exists"}) + } return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } - return c.Status(fiber.StatusCreated).JSON(mapUserSummary(user)) + identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + if identity == nil { + return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": identityID, "initialPassword": generatedPassword}) + } + + response := mapIdentitySummary(*identity) + if generatedPassword != "" { + response.InitialPassword = generatedPassword + } + return c.Status(fiber.StatusCreated).JSON(response) } func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { - if h.DB == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + if h.KratosAdmin == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"}) } userID := strings.TrimSpace(c.Params("id")) @@ -175,13 +222,13 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"}) } - var user domain.User - if err := h.DB.First(&user, "id = ?", userID).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"}) - } + identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) + if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } + if identity == nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"}) + } var req struct { Password *string `json:"password"` @@ -196,46 +243,48 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) } + traits := identity.Traits + if traits == nil { + traits = map[string]interface{}{} + } if req.Name != nil { - user.Name = strings.TrimSpace(*req.Name) + traits["name"] = strings.TrimSpace(*req.Name) } if req.Phone != nil { - user.Phone = strings.TrimSpace(*req.Phone) - } - if req.Role != nil { - user.Role = strings.TrimSpace(*req.Role) - } - if req.Status != nil { - status := strings.ToLower(strings.TrimSpace(*req.Status)) - if status == "active" || status == "inactive" || status == "blocked" { - user.Status = status - } + traits["phone_number"] = normalizePhoneNumber(strings.TrimSpace(*req.Phone)) } if req.CompanyCode != nil { - user.CompanyCode = strings.TrimSpace(*req.CompanyCode) + traits["companyCode"] = strings.TrimSpace(*req.CompanyCode) } if req.Department != nil { - user.Department = strings.TrimSpace(*req.Department) + traits["department"] = strings.TrimSpace(*req.Department) } - - if req.Password != nil && *req.Password != "" { - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to hash password"}) + if req.Role != nil { + role := strings.TrimSpace(*req.Role) + if role == "" { + role = "user" } - user.PasswordHash = string(hashedPassword) + traits["grade"] = role } - if err := h.DB.Save(&user).Error; err != nil { + state := normalizeKratosState(req.Status) + updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), userID, traits, state) + if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } - return c.JSON(mapUserSummary(user)) + if req.Password != nil && *req.Password != "" { + if err := h.KratosAdmin.UpdateIdentityPassword(c.Context(), userID, *req.Password); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + } + + return c.JSON(mapIdentitySummary(*updated)) } func (h *UserHandler) DeleteUser(c *fiber.Ctx) error { - if h.DB == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"}) + if h.KratosAdmin == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"}) } userID := strings.TrimSpace(c.Params("id")) @@ -243,25 +292,88 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"}) } - // Soft delete - if err := h.DB.Delete(&domain.User{}, "id = ?", userID).Error; err != nil { + if err := h.KratosAdmin.DeleteIdentity(c.Context(), userID); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } return c.SendStatus(fiber.StatusNoContent) } -func mapUserSummary(u domain.User) userSummary { +func mapIdentitySummary(identity service.KratosIdentity) userSummary { + traits := identity.Traits + role := extractTraitString(traits, "grade") + if role == "" { + role = "user" + } return userSummary{ - ID: u.ID, - Email: u.Email, - Name: u.Name, - Phone: u.Phone, - Role: u.Role, - Status: u.Status, - CompanyCode: u.CompanyCode, - Department: u.Department, - CreatedAt: u.CreatedAt.Format(time.RFC3339), - UpdatedAt: u.UpdatedAt.Format(time.RFC3339), + ID: identity.ID, + Email: extractTraitString(traits, "email"), + Name: extractTraitString(traits, "name"), + Phone: extractTraitString(traits, "phone_number"), + Role: role, + Status: normalizeStatus(identity.State), + CompanyCode: extractTraitString(traits, "companyCode"), + Department: extractTraitString(traits, "department"), + CreatedAt: formatTime(identity.CreatedAt), + UpdatedAt: formatTime(identity.UpdatedAt), } } + +func extractTraitString(traits map[string]interface{}, key string) string { + if traits == nil { + return "" + } + if raw, ok := traits[key]; ok { + if value, ok := raw.(string); ok { + return value + } + } + return "" +} + +func formatTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.Format(time.RFC3339) +} + +func normalizeStatus(state string) string { + state = strings.ToLower(strings.TrimSpace(state)) + if state == "inactive" || state == "blocked" || state == "active" { + return state + } + if state == "" { + return "active" + } + return state +} + +func normalizeKratosState(status *string) string { + if status == nil { + return "" + } + value := strings.ToLower(strings.TrimSpace(*status)) + if value == "blocked" { + return "inactive" + } + if value == "active" || value == "inactive" { + return value + } + return "" +} + +func normalizePhoneNumber(phone string) string { + normalized := strings.ReplaceAll(phone, "-", "") + normalized = strings.ReplaceAll(normalized, " ", "") + if normalized == "" { + return "" + } + if strings.HasPrefix(normalized, "010") { + return "+82" + normalized[1:] + } + if strings.HasPrefix(normalized, "82") { + return "+" + normalized + } + return normalized +} diff --git a/backend/internal/service/kratos_admin_service.go b/backend/internal/service/kratos_admin_service.go new file mode 100644 index 00000000..719ec393 --- /dev/null +++ b/backend/internal/service/kratos_admin_service.go @@ -0,0 +1,194 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "strings" + "time" +) + +type KratosIdentity struct { + ID string `json:"id"` + Traits map[string]interface{} `json:"traits"` + State string `json:"state,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` +} + +type KratosAdminService struct { + AdminURL string + HTTPClient *http.Client +} + +func NewKratosAdminService() *KratosAdminService { + return &KratosAdminService{ + AdminURL: getenvKratos("KRATOS_ADMIN_URL", "http://kratos:4434"), + } +} + +func (s *KratosAdminService) ListIdentities(ctx context.Context) ([]KratosIdentity, error) { + endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + resp, err := s.httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return nil, fmt.Errorf("kratos admin list identities failed status=%d body=%s", resp.StatusCode, string(body)) + } + + var identities []KratosIdentity + if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil { + return nil, err + } + return identities, nil +} + +func (s *KratosAdminService) GetIdentity(ctx context.Context, identityID string) (*KratosIdentity, error) { + endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + + resp, err := s.httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return nil, fmt.Errorf("kratos admin get identity failed status=%d body=%s", resp.StatusCode, string(body)) + } + + var identity KratosIdentity + if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil { + return nil, err + } + return &identity, nil +} + +func (s *KratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) { + payload := map[string]interface{}{ + "schema_id": "default", + "traits": traits, + } + if strings.TrimSpace(state) != "" { + payload["state"] = strings.TrimSpace(state) + } + + body, _ := json.Marshal(payload) + endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return nil, fmt.Errorf("kratos admin update identity failed status=%d body=%s", resp.StatusCode, string(respBody)) + } + + var updated KratosIdentity + if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil { + return nil, err + } + return &updated, nil +} + +func (s *KratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error { + payload := map[string]interface{}{ + "credentials": map[string]interface{}{ + "password": map[string]interface{}{ + "config": map[string]string{ + "password": newPassword, + }, + }, + }, + } + body, _ := json.Marshal(payload) + endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID) + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.httpClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return fmt.Errorf("kratos admin update password failed status=%d body=%s", resp.StatusCode, string(respBody)) + } + return nil +} + +func (s *KratosAdminService) DeleteIdentity(ctx context.Context, identityID string) error { + endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + + resp, err := s.httpClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return fmt.Errorf("kratos admin delete identity failed status=%d body=%s", resp.StatusCode, string(respBody)) + } + return nil +} + +func (s *KratosAdminService) httpClient() *http.Client { + if s.HTTPClient != nil { + return s.HTTPClient + } + return &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 5 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 5 * time.Second, + }, + } +} + +func getenvKratos(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/backend/internal/utils/password_policy.go b/backend/internal/utils/password_policy.go new file mode 100644 index 00000000..c0685365 --- /dev/null +++ b/backend/internal/utils/password_policy.go @@ -0,0 +1,204 @@ +package utils + +import ( + "baron-sso-backend/internal/domain" + "crypto/rand" + "fmt" +) + +const ( + lowercaseChars = "abcdefghijklmnopqrstuvwxyz" + uppercaseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + numberChars = "0123456789" + symbolChars = "!@#$%^&*()-_=+[]{}<>?/,.~" +) + +// ValidatePasswordWithPolicy validates a password against the given policy. +func ValidatePasswordWithPolicy(policy *domain.PasswordPolicy, password string) error { + if policy == nil { + return nil + } + if len(password) < policy.MinLength { + return fmt.Errorf("비밀번호는 최소 %d자 이상이어야 합니다", policy.MinLength) + } + + hasLower := false + hasUpper := false + hasNumber := false + hasSymbol := false + types := 0 + + for _, ch := range password { + switch { + case ch >= 'a' && ch <= 'z': + hasLower = true + case ch >= 'A' && ch <= 'Z': + hasUpper = true + case ch >= '0' && ch <= '9': + hasNumber = true + default: + hasSymbol = true + } + } + + if hasLower { + types++ + } + if hasUpper { + types++ + } + if hasNumber { + types++ + } + if hasSymbol { + types++ + } + + if policy.MinCharacterTypes > 0 && types < policy.MinCharacterTypes { + return fmt.Errorf("비밀번호는 영문 대/소문자/숫자/특수문자 중 %d가지 이상을 포함해야 합니다", policy.MinCharacterTypes) + } + + if policy.Lowercase && !hasLower { + return fmt.Errorf("비밀번호에 소문자가 포함되어야 합니다") + } + if policy.Uppercase && !hasUpper { + return fmt.Errorf("비밀번호에 대문자가 포함되어야 합니다") + } + if policy.Number && !hasNumber { + return fmt.Errorf("비밀번호에 숫자가 포함되어야 합니다") + } + if policy.NonAlphanumeric && !hasSymbol { + return fmt.Errorf("비밀번호에 특수문자가 포함되어야 합니다") + } + + return nil +} + +// GeneratePasswordWithPolicy creates a random password that satisfies the policy. +func GeneratePasswordWithPolicy(policy *domain.PasswordPolicy) (string, error) { + if policy == nil { + policy = &domain.PasswordPolicy{} + } + + categories := []struct { + name string + required bool + chars string + }{ + {name: "lower", required: policy.Lowercase, chars: lowercaseChars}, + {name: "upper", required: policy.Uppercase, chars: uppercaseChars}, + {name: "number", required: policy.Number, chars: numberChars}, + {name: "symbol", required: policy.NonAlphanumeric, chars: symbolChars}, + } + + selected := make([]string, 0, len(categories)) + required := make([]string, 0, len(categories)) + for _, cat := range categories { + if cat.chars == "" { + continue + } + if cat.required { + required = append(required, cat.chars) + } + selected = append(selected, cat.chars) + } + + if len(selected) == 0 { + selected = []string{lowercaseChars, uppercaseChars, numberChars, symbolChars} + } + + additionalTypes := policy.MinCharacterTypes - len(required) + if additionalTypes > 0 { + pool := make([]string, 0, len(selected)) + for _, cat := range selected { + isRequired := false + for _, req := range required { + if req == cat { + isRequired = true + break + } + } + if !isRequired { + pool = append(pool, cat) + } + } + + for i := 0; i < additionalTypes && len(pool) > 0; i++ { + idx, err := randomIndex(len(pool)) + if err != nil { + return "", err + } + required = append(required, pool[idx]) + pool = append(pool[:idx], pool[idx+1:]...) + } + } + + minLength := policy.MinLength + if minLength <= 0 { + minLength = 12 + } + if minLength < len(required) { + minLength = len(required) + } + + passwordRunes := make([]rune, 0, minLength) + for _, charset := range required { + ch, err := randomChar(charset) + if err != nil { + return "", err + } + passwordRunes = append(passwordRunes, ch) + } + + combined := "" + for _, charset := range selected { + combined += charset + } + for len(passwordRunes) < minLength { + ch, err := randomChar(combined) + if err != nil { + return "", err + } + passwordRunes = append(passwordRunes, ch) + } + + if err := shuffleRunes(passwordRunes); err != nil { + return "", err + } + + return string(passwordRunes), nil +} + +func randomIndex(max int) (int, error) { + if max <= 0 { + return 0, fmt.Errorf("invalid max") + } + b := make([]byte, 1) + for { + if _, err := rand.Read(b); err != nil { + return 0, err + } + if int(b[0]) < max*(256/max) { + return int(b[0]) % max, nil + } + } +} + +func randomChar(chars string) (rune, error) { + idx, err := randomIndex(len(chars)) + if err != nil { + return 0, err + } + return rune(chars[idx]), nil +} + +func shuffleRunes(values []rune) error { + for i := len(values) - 1; i > 0; i-- { + j, err := randomIndex(i + 1) + if err != nil { + return err + } + values[i], values[j] = values[j], values[i] + } + return nil +} diff --git a/compose.infra.yaml b/compose.infra.yaml index 2baa1405..5e88b917 100644 --- a/compose.infra.yaml +++ b/compose.infra.yaml @@ -28,6 +28,8 @@ services: image: clickhouse/clickhouse-server:latest container_name: baron_clickhouse restart: always + volumes: + - clickhouse_data:/var/lib/clickhouse environment: CLICKHOUSE_USER: ${CLICKHOUSE_USER:-baron} CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-password} diff --git a/docker-compose.yaml b/docker-compose.yaml index 60fadef1..40ee6519 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -19,7 +19,7 @@ services: - NAVER_SENDER_PHONE_NUMBER=${NAVER_SENDER_PHONE_NUMBER} - USERFRONT_URL=${USERFRONT_URL} - REDIS_ADDR=${REDIS_ADDR} - - IDP_PROVIDER=${IDP_PROVIDER:-ory,descope} + - IDP_PROVIDER=${IDP_PROVIDER:-ory} - KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434} - HYDRA_ADMIN_URL=${HYDRA_ADMIN_URL:-http://hydra:4445} - HYDRA_PUBLIC_URL=${HYDRA_PUBLIC_URL:-http://hydra:4444} @@ -89,7 +89,6 @@ services: env_file: - .env environment: - - DESCOPE_PROJECT_ID=${DESCOPE_PROJECT_ID} - BACKEND_URL=${BACKEND_URL} - USERFRONT_URL=${USERFRONT_URL} - APP_ENV=${APP_ENV} @@ -104,7 +103,6 @@ services: condition: service_healthy command: > /bin/sh -c "mkdir -p /usr/share/nginx/html/assets && - echo \"DESCOPE_PROJECT_ID=$${DESCOPE_PROJECT_ID}\" > /usr/share/nginx/html/assets/.env && echo \"BACKEND_URL=$${BACKEND_URL}\" >> /usr/share/nginx/html/assets/.env && echo \"USERFRONT_URL=$${USERFRONT_URL}\" >> /usr/share/nginx/html/assets/.env && echo \"APP_ENV=$${APP_ENV}\" >> /usr/share/nginx/html/assets/.env && diff --git a/docs/hydra-rp-consent-try.md b/docs/hydra-rp-consent-try.md new file mode 100644 index 00000000..6ea13645 --- /dev/null +++ b/docs/hydra-rp-consent-try.md @@ -0,0 +1,39 @@ +# Hydra RP Consent 시도 기록 + +## 목표 +- 샘플 RP(`52a597f0-5b06-4fcb-b804-93e88a56a75a`)와 사용자(`b24051@hanmaceng.co.kr`, Kratos ID: `22607c1b-bfbf-4a90-9505-36b348472e7a`) 사이에 Hydra consent 세션을 생성. + +## 시도한 방법과 실패 원인 + +### 1) hydra 컨테이너 내부에서 `sh` 실행 후 스크립트 수행 +- 시도: `docker exec -i ory_hydra sh -lc '...'` +- 실패: `sh`가 존재하지 않음 (Hydra 컨테이너가 distroless 이미지). +- 원인: 쉘 바이너리 미포함. + +### 2) `curlimages/curl` 컨테이너를 hydra 네트워크에 붙여서 consent 생성 흐름 수행 +- 시도 흐름: + 1. `/oauth2/auth` 호출로 `login_challenge` 획득 + 2. Admin API로 `login_challenge` 수락 + 3. `login_accept.redirect_to`(보통 `http://127.0.0.1:3000/...`)로 이동해 `consent_challenge` 추출 +- 실패: `login_accept.redirect_to`가 **consent app(127.0.0.1:3000)**로 향하는데, 해당 서비스가 떠 있지 않아 접근 불가. +- 원인: consent app가 실행 중이 아니라 127.0.0.1:3000 접속 실패. + +### 3) `login_accept.redirect_to`를 그대로 호출해 Location 헤더에서 consent_challenge 추출 시도 +- 실패: 위와 동일하게 consent app 경로를 직접 호출하게 되어 연결 실패. +- 원인: consent app 미기동. + +### 4) DCR(동적 클라이언트 등록) 시 metadata 포함 시도 +- 실패: `invalid_client_metadata` (DCR에서는 `metadata`를 설정할 수 없음) +- 원인: Hydra DCR 정책 제한. + +## 요약 +- **핵심 실패 원인**은 consent app(로그인/동의 UI)이 실행 중이 아니라서 redirect_to를 따라가면 접속이 불가능한 점. +- distroless 이미지로 인해 `docker exec`로 쉘 스크립트를 바로 실행할 수 없음. + +## 다음 시도(새 방식) +- consent app로 직접 이동하지 않고, **Hydra public endpoint**에서 `login_verifier`를 이용해 `consent_challenge`를 추출한 뒤 Admin API로 수락. +- 흐름: + 1. `/oauth2/auth` → `login_challenge` + 2. `/oauth2/auth/requests/login/accept` → `login_verifier` + 3. `/oauth2/auth?client_id=...&login_verifier=...` 호출 → Location 헤더에서 `consent_challenge` 추출 + 4. `/oauth2/auth/requests/consent/accept` 호출 diff --git a/userfront/assets/fonts/NotoSansKR-Bold.ttf b/userfront/assets/fonts/NotoSansKR-Bold.ttf new file mode 100644 index 00000000..c7aef6b0 Binary files /dev/null and b/userfront/assets/fonts/NotoSansKR-Bold.ttf differ diff --git a/userfront/assets/fonts/NotoSansKR-Regular.ttf b/userfront/assets/fonts/NotoSansKR-Regular.ttf new file mode 100644 index 00000000..984b17fb Binary files /dev/null and b/userfront/assets/fonts/NotoSansKR-Regular.ttf differ diff --git a/userfront/lib/features/auth/presentation/forgot_password_screen.dart b/userfront/lib/features/auth/presentation/forgot_password_screen.dart index 5e776265..fdb83611 100644 --- a/userfront/lib/features/auth/presentation/forgot_password_screen.dart +++ b/userfront/lib/features/auth/presentation/forgot_password_screen.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../../../core/services/auth_proxy_service.dart'; class ForgotPasswordScreen extends StatefulWidget { @@ -91,7 +90,7 @@ class _ForgotPasswordScreenState extends State { children: [ Text( "비밀번호를 잊으셨나요?", - style: GoogleFonts.outfit( + style: TextStyle( fontSize: 28, fontWeight: FontWeight.bold, ), diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index b03527c0..3b22a9a8 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:descope/descope.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -47,6 +46,8 @@ class _LoginScreenState extends ConsumerState int _linkExpireSeconds = 0; Timer? _linkExpireTimer; bool _verificationOnly = false; + bool _verificationApproved = false; + String _verificationMessage = ''; bool _drySendEnabled = false; @override @@ -352,15 +353,72 @@ class _LoginScreenState extends ConsumerState _onLoginSuccess(token, provider: provider); } + bool _hasLocalSession() { + if (AuthTokenStore.getToken() != null) { + return true; + } + return AuthTokenStore.usesCookie(); + } + + void _markVerificationApproved(String message) { + if (!mounted) return; + setState(() { + _verificationApproved = true; + _verificationMessage = message; + }); + } + + Widget _buildVerificationResultView() { + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.check_circle_outline, color: Colors.green, size: 72), + const SizedBox(height: 16), + const Text( + '승인 완료', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.green), + ), + const SizedBox(height: 12), + Text( + _verificationMessage.isEmpty ? '로그인 승인에 성공했습니다.' : _verificationMessage, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.black54), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: () => context.go('/'), + child: const Text('확인'), + ), + ], + ), + ), + ); + } + Future _verifyToken(String token) async { debugPrint("[Auth] Starting verification for token: $token"); try { // Use Backend to verify the token (Backend-Driven Flow) - await AuthProxyService.verifyMagicLink(token); + final res = await AuthProxyService.verifyMagicLink(token); debugPrint("[Auth] Verification successful for token: $token"); + final jwt = res['token'] ?? res['sessionJwt']; + final provider = res['provider'] as String?; + final hasLocalSession = _hasLocalSession(); + + if (jwt is String && jwt.isNotEmpty) { + if (hasLocalSession) { + _markVerificationApproved("승인되었습니다. 이미 로그인된 브라우저입니다."); + return; + } + _completeLoginFromToken(jwt, provider: provider); + return; + } if (mounted) { - _showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); + _markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); } } catch (e) { debugPrint("[Auth] Verification FAILED for token: $token. Error: $e"); @@ -382,23 +440,26 @@ class _LoginScreenState extends ConsumerState final jwt = res['sessionJwt'] ?? res['token']; final status = res['status']?.toString(); debugPrint("[Auth] Code verification successful for loginId: $sanitizedLoginId"); + final hasLocalSession = _hasLocalSession(); if (jwt == null && status == 'approved') { if (mounted) { - _showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); - } - return; - } - - if (_verificationOnly) { - if (mounted) { - _showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); + _markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); } return; } if (jwt is String && jwt.isNotEmpty) { + if (hasLocalSession) { + _markVerificationApproved("승인되었습니다. 이미 로그인된 브라우저입니다."); + return; + } _completeLoginFromToken(jwt, provider: res['provider'] as String?); + return; + } + + if (_verificationOnly && mounted) { + _markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); } } catch (e) { debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e"); @@ -417,23 +478,26 @@ class _LoginScreenState extends ConsumerState final jwt = res['sessionJwt'] ?? res['token']; final status = res['status']?.toString(); debugPrint("[Auth] Short code verification successful"); + final hasLocalSession = _hasLocalSession(); if (jwt == null && status == 'approved') { if (mounted) { - _showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); - } - return; - } - - if (_verificationOnly) { - if (mounted) { - _showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); + _markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); } return; } if (jwt is String && jwt.isNotEmpty) { + if (hasLocalSession) { + _markVerificationApproved("승인되었습니다. 이미 로그인된 브라우저입니다."); + return; + } _completeLoginFromToken(jwt, provider: res['provider'] as String?); + return; + } + + if (_verificationOnly && mounted) { + _markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); } } catch (e) { debugPrint("[Auth] Short code verification FAILED. Error: $e"); @@ -784,6 +848,19 @@ class _LoginScreenState extends ConsumerState @override Widget build(BuildContext context) { + if (_verificationOnly && _verificationApproved) { + return Scaffold( + appBar: AppBar( + title: const Text('로그인 승인'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go('/'), + ), + ), + body: _buildVerificationResultView(), + ); + } + return Scaffold( body: LayoutBuilder( builder: (context, constraints) { @@ -800,7 +877,7 @@ class _LoginScreenState extends ConsumerState children: [ Text( "Baron 통합로그인", - style: GoogleFonts.outfit( + style: TextStyle( fontSize: 32, fontWeight: FontWeight.bold, ), diff --git a/userfront/lib/features/auth/presentation/login_success_screen.dart b/userfront/lib/features/auth/presentation/login_success_screen.dart index a3b97896..eeb5a2ee 100644 --- a/userfront/lib/features/auth/presentation/login_success_screen.dart +++ b/userfront/lib/features/auth/presentation/login_success_screen.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; class LoginSuccessScreen extends StatelessWidget { const LoginSuccessScreen({super.key}); @@ -18,7 +17,7 @@ class LoginSuccessScreen extends StatelessWidget { const SizedBox(height: 24), Text( "로그인 완료", - style: GoogleFonts.outfit( + style: TextStyle( fontSize: 32, fontWeight: FontWeight.bold, ), diff --git a/userfront/lib/features/auth/presentation/qr_scan_screen.dart b/userfront/lib/features/auth/presentation/qr_scan_screen.dart index cac86bc4..3bcd15fd 100644 --- a/userfront/lib/features/auth/presentation/qr_scan_screen.dart +++ b/userfront/lib/features/auth/presentation/qr_scan_screen.dart @@ -206,7 +206,7 @@ class _QRScanScreenState extends State { MobileScanner( controller: controller, onDetect: _onDetect, - errorBuilder: (context, error, child) { + errorBuilder: (context, error) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/userfront/lib/features/auth/presentation/reset_password_screen.dart b/userfront/lib/features/auth/presentation/reset_password_screen.dart index ae9fad26..cd19ca5d 100644 --- a/userfront/lib/features/auth/presentation/reset_password_screen.dart +++ b/userfront/lib/features/auth/presentation/reset_password_screen.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:go_router/go_router.dart'; import '../../../core/services/auth_proxy_service.dart'; @@ -150,7 +149,7 @@ class _ResetPasswordScreenState extends State { children: [ Text( "새로운 비밀번호 설정", - style: GoogleFonts.outfit( + style: TextStyle( fontSize: 28, fontWeight: FontWeight.bold, ), diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index 7d2a3da4..219f694e 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:go_router/go_router.dart'; import '../../../core/services/auth_proxy_service.dart'; @@ -332,7 +331,7 @@ class _SignupScreenState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text('서비스 이용을 위해\n약관에 동의해주세요', - style: GoogleFonts.outfit( + style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, height: 1.3)), const SizedBox(height: 24), // 모두 동의 버튼 @@ -597,7 +596,7 @@ class _SignupScreenState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('본인 확인을 위해\n인증을 진행해주세요', style: GoogleFonts.outfit(fontSize: 20, fontWeight: FontWeight.bold)), + Text('본인 확인을 위해\n인증을 진행해주세요', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const SizedBox(height: 16), // 가족사 이메일 안내 문구 Container( @@ -713,7 +712,7 @@ class _SignupScreenState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('회원님의\n소속 정보를 알려주세요', style: GoogleFonts.outfit(fontSize: 20, fontWeight: FontWeight.bold)), + Text('회원님의\n소속 정보를 알려주세요', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const SizedBox(height: 24), TextFormField( controller: _nameController, @@ -826,7 +825,7 @@ class _SignupScreenState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('마지막으로\n비밀번호를 설정해주세요', style: GoogleFonts.outfit(fontSize: 20, fontWeight: FontWeight.bold)), + Text('마지막으로\n비밀번호를 설정해주세요', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const SizedBox(height: 16), // 비밀번호 정책 안내 박스 Container( @@ -918,7 +917,7 @@ class _SignupScreenState extends State { return Scaffold( backgroundColor: Colors.white, appBar: AppBar( - title: Text('회원가입', style: GoogleFonts.outfit(fontWeight: FontWeight.bold)), + title: Text('회원가입', style: TextStyle(fontWeight: FontWeight.bold)), elevation: 0, backgroundColor: Colors.white, foregroundColor: Colors.black, diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index d850e9a8..3372e0fa 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -4,7 +4,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:descope/descope.dart'; import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/services/auth_token_store.dart'; @@ -84,6 +83,45 @@ class AuditLogEntry { } } +class LinkedRp { + final String id; + final String name; + final String logo; + final String status; + final List scopes; + final DateTime? lastAuthenticatedAt; + + LinkedRp({ + required this.id, + required this.name, + required this.logo, + required this.status, + required this.scopes, + required this.lastAuthenticatedAt, + }); + + factory LinkedRp.fromJson(Map json) { + final rawLastAuth = json['lastAuthenticatedAt']?.toString() ?? ''; + DateTime? parsedLastAuth; + if (rawLastAuth.isNotEmpty) { + try { + parsedLastAuth = DateTime.parse(rawLastAuth).toLocal(); + } catch (_) { + parsedLastAuth = null; + } + } + + return LinkedRp( + id: json['id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + logo: json['logo']?.toString() ?? '', + status: json['status']?.toString() ?? '', + scopes: (json['scopes'] as List?)?.whereType().toList() ?? [], + lastAuthenticatedAt: parsedLastAuth, + ); + } +} + class DashboardScreen extends ConsumerStatefulWidget { const DashboardScreen({super.key}); @@ -98,12 +136,14 @@ class _DashboardScreenState extends ConsumerState { static const _subtle = Color(0xFFF7F8FA); Future>? _auditFuture; + Future>? _linkedRpsFuture; bool _showAllActivities = false; @override void initState() { super.initState(); _auditFuture = _fetchAuditLogs(); + _linkedRpsFuture = _fetchLinkedRps(); } Future _logout() async { @@ -172,10 +212,14 @@ class _DashboardScreenState extends ConsumerState { await ref.read(profileProvider.notifier).loadProfile(); setState(() { _auditFuture = _fetchAuditLogs(); + _linkedRpsFuture = _fetchLinkedRps(); }); if (_auditFuture != null) { await _auditFuture; } + if (_linkedRpsFuture != null) { + await _linkedRpsFuture; + } } static String _envOrDefault(String key, String fallback) { @@ -216,6 +260,37 @@ class _DashboardScreenState extends ConsumerState { return logs; } + Future> _fetchLinkedRps() async { + final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); + 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 = { + '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 linked rps'); + } + + final body = jsonDecode(response.body) as Map; + final items = (body['items'] as List?) ?? []; + final linkedRps = items + .whereType>() + .map(LinkedRp.fromJson) + .toList(); + + return linkedRps; + } + DateTime? _getJwtIssuedAt() { final token = AuthTokenStore.getToken(); if (token == null || token.isEmpty) { @@ -391,7 +466,7 @@ class _DashboardScreenState extends ConsumerState { appBar: AppBar( title: Text( 'Baron 통합로그인', - style: GoogleFonts.outfit(fontWeight: FontWeight.bold), + style: TextStyle(fontWeight: FontWeight.bold), ), elevation: 0, backgroundColor: _surface, @@ -442,7 +517,7 @@ class _DashboardScreenState extends ConsumerState { ], _buildSectionTitle('활동상황', '현재 연결된 앱과 최근 인증 상태입니다.'), const SizedBox(height: 12), - _buildActivityGrid(sessionIssuedAt, isMobile), + _buildActivitySection(isMobile), const SizedBox(height: 28), _buildSectionTitle('접속이력', 'Baron 통합로그인 기준의 최근 접근 기록입니다.'), const SizedBox(height: 12), @@ -545,16 +620,52 @@ class _DashboardScreenState extends ConsumerState { ); } - Widget _buildActivityGrid(DateTime? signupAt, bool isMobile) { - final signupLabel = signupAt != null ? _formatDateTime(signupAt) : '확인 필요'; - final activities = [ - _ActivityItem( - appName: 'Baron 통합로그인', - lastAuthAt: signupLabel, - status: '활성', - canLogout: true, - onLogout: _logout, - ), + Widget _buildActivitySection(bool isMobile) { + return FutureBuilder>( + future: _linkedRpsFuture, + builder: (context, snapshot) { + final activities = _buildActivityItems(snapshot.data ?? []); + final grid = _buildActivityGrid(activities, isMobile); + + if (snapshot.hasError) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + grid, + const SizedBox(height: 8), + Text( + '연동 정보를 불러오지 못했습니다.', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ], + ); + } + + return grid; + }, + ); + } + + List<_ActivityItem> _buildActivityItems(List linkedRps) { + final items = <_ActivityItem>[]; + for (final rp in linkedRps) { + final lastAuthLabel = rp.lastAuthenticatedAt != null + ? _formatDateTime(rp.lastAuthenticatedAt!) + : '연동됨'; + final normalizedStatus = rp.status.toLowerCase(); + final statusLabel = normalizedStatus.isEmpty || normalizedStatus == 'active' ? '활성' : '비활성'; + final name = rp.name.isNotEmpty ? rp.name : rp.id; + items.add( + _ActivityItem( + appName: name, + lastAuthAt: lastAuthLabel, + status: statusLabel, + canLogout: false, + ), + ); + } + + items.addAll([ _ActivityItem( appName: 'BEPs', lastAuthAt: '연동 필요', @@ -579,7 +690,12 @@ class _DashboardScreenState extends ConsumerState { status: '미연동', canLogout: false, ), - ]; + ]); + + return items; + } + + Widget _buildActivityGrid(List<_ActivityItem> activities, bool isMobile) { if (!isMobile) { return Wrap( @@ -660,7 +776,7 @@ class _DashboardScreenState extends ConsumerState { ), const SizedBox(height: 12), Text( - '가입일시', + '최근 인증', style: TextStyle(fontSize: 12, color: Colors.grey[600]), ), const SizedBox(height: 4), diff --git a/userfront/lib/features/profile/presentation/pages/profile_page.dart b/userfront/lib/features/profile/presentation/pages/profile_page.dart index 92e1101d..7a46ab02 100644 --- a/userfront/lib/features/profile/presentation/pages/profile_page.dart +++ b/userfront/lib/features/profile/presentation/pages/profile_page.dart @@ -2,7 +2,6 @@ import 'package:descope/descope.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/services/auth_token_store.dart'; import '../../../../core/ui/layout_breakpoints.dart'; @@ -28,6 +27,10 @@ class _ProfilePageState extends ConsumerState { TextEditingController? _phoneController; TextEditingController? _departmentController; TextEditingController? _codeController; + final FocusNode _nameFocus = FocusNode(); + final FocusNode _departmentFocus = FocusNode(); + final FocusNode _phoneFocus = FocusNode(); + final FocusNode _phoneCodeFocus = FocusNode(); String _initialPhone = ''; bool _isPhoneChanged = false; @@ -41,6 +44,10 @@ class _ProfilePageState extends ConsumerState { _phoneController?.dispose(); _departmentController?.dispose(); _codeController?.dispose(); + _nameFocus.dispose(); + _departmentFocus.dispose(); + _phoneFocus.dispose(); + _phoneCodeFocus.dispose(); super.dispose(); } @@ -153,7 +160,7 @@ class _ProfilePageState extends ConsumerState { } } - Future _verifyCode() async { + Future _verifyCode(UserProfile profile) async { final phone = _phoneController?.text ?? ''; final code = _codeController?.text ?? ''; if (code.isEmpty) return; @@ -170,6 +177,9 @@ class _ProfilePageState extends ConsumerState { const SnackBar(content: Text('인증되었습니다.')), ); } + if (_editingField == 'phone') { + await _saveField(profile); + } } catch (e) { setState(() => _isVerifying = false); if (mounted) { @@ -180,6 +190,19 @@ class _ProfilePageState extends ConsumerState { } } + void _autoSaveIfEditing(UserProfile profile, String field) { + if (_editingField != field) return; + if (_isVerifying) return; + _saveField(profile); + } + + void _handlePhoneFocusChange(UserProfile profile) { + if (_editingField != 'phone') return; + if (_isVerifying) return; + if (_phoneFocus.hasFocus || _phoneCodeFocus.hasFocus) return; + _saveField(profile); + } + Future _saveField(UserProfile profile) async { if (_editingField == null) return; @@ -431,28 +454,24 @@ class _ProfilePageState extends ConsumerState { children: [ Text(label, style: const TextStyle(fontWeight: FontWeight.w600)), const SizedBox(height: 8), - TextField( - controller: controller, - decoration: InputDecoration( - border: const OutlineInputBorder(), - hintText: label, + Focus( + focusNode: field == 'name' ? _nameFocus : _departmentFocus, + onFocusChange: (hasFocus) { + if (!hasFocus) { + _autoSaveIfEditing(profile, field); + } + }, + child: TextField( + controller: controller, + focusNode: field == 'name' ? _nameFocus : _departmentFocus, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _autoSaveIfEditing(profile, field), + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: label, + ), ), ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: isUpdating ? null : () => _cancelEditing(profile), - child: const Text('취소'), - ), - const SizedBox(width: 8), - ElevatedButton( - onPressed: isUpdating ? null : () => _saveField(profile), - child: const Text('확인'), - ), - ], - ), ], ); } @@ -482,17 +501,28 @@ class _ProfilePageState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( - child: TextField( - controller: _phoneController, - keyboardType: TextInputType.phone, - decoration: InputDecoration( - border: const OutlineInputBorder(), - hintText: '01012345678', - suffixIcon: _isPhoneVerified - ? const Icon(Icons.check_circle, color: Colors.green) - : null, + child: Focus( + focusNode: _phoneFocus, + onFocusChange: (hasFocus) { + if (!hasFocus) { + _handlePhoneFocusChange(profile); + } + }, + child: TextField( + controller: _phoneController, + focusNode: _phoneFocus, + keyboardType: TextInputType.phone, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _autoSaveIfEditing(profile, 'phone'), + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: '01012345678', + suffixIcon: _isPhoneVerified + ? const Icon(Icons.check_circle, color: Colors.green) + : null, + ), + enabled: !_isPhoneVerified, ), - enabled: !_isPhoneVerified, ), ), const SizedBox(width: 8), @@ -509,18 +539,29 @@ class _ProfilePageState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( - child: TextField( - controller: _codeController, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - border: OutlineInputBorder(), - hintText: '인증번호 6자리', + child: Focus( + focusNode: _phoneCodeFocus, + onFocusChange: (hasFocus) { + if (!hasFocus) { + _handlePhoneFocusChange(profile); + } + }, + child: TextField( + controller: _codeController, + focusNode: _phoneCodeFocus, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _verifyCode(profile), + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: '인증번호 6자리', + ), ), ), ), const SizedBox(width: 8), ElevatedButton( - onPressed: _isVerifying ? null : _verifyCode, + onPressed: _isVerifying ? null : () => _verifyCode(profile), child: const Text('확인'), ), ], @@ -534,21 +575,6 @@ class _ProfilePageState extends ConsumerState { style: TextStyle(color: Colors.orange, fontSize: 12), ), ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: isUpdating ? null : () => _cancelEditing(profile), - child: const Text('취소'), - ), - const SizedBox(width: 8), - ElevatedButton( - onPressed: isUpdating ? null : () => _saveField(profile), - child: const Text('확인'), - ), - ], - ), ], ); } @@ -556,59 +582,69 @@ class _ProfilePageState extends ConsumerState { Widget _buildContent(UserProfile profile, bool isUpdating) { return RefreshIndicator( onRefresh: () => ref.read(profileProvider.notifier).loadProfile(), - child: ListView( - padding: const EdgeInsets.all(24.0), - children: [ - _buildHeaderCard(profile), - const SizedBox(height: 28), - _buildSectionTitle('기본 정보', '계정 기본 정보를 관리합니다.'), - const SizedBox(height: 12), - _buildCard( - Column( - children: [ - _buildEditableTile( - field: 'name', - label: '이름', - value: profile.name, - profile: profile, - isUpdating: isUpdating, - controller: _nameController!, - ), - const Divider(height: 24), - _buildReadOnlyTile('이메일', profile.email), - const Divider(height: 24), - _buildPhoneEditor(profile, isUpdating), - ], - ), - ), - const SizedBox(height: 28), - _buildSectionTitle('조직 정보', '소속 및 구분 정보입니다.'), - const SizedBox(height: 12), - _buildCard( - Column( - children: [ - _buildEditableTile( - field: 'department', - label: '소속', - value: profile.department, - profile: profile, - isUpdating: isUpdating, - controller: _departmentController!, - ), - const Divider(height: 24), - _buildReadOnlyTile('구분', profile.affiliationType), - if (profile.companyCode.isNotEmpty) ...[ - const Divider(height: 24), - _buildReadOnlyTile('회사코드', profile.companyCode), + child: LayoutBuilder( + builder: (context, constraints) { + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1200), + child: ListView( + padding: const EdgeInsets.all(24.0), + children: [ + _buildHeaderCard(profile), + const SizedBox(height: 28), + _buildSectionTitle('기본 정보', '계정 기본 정보를 관리합니다.'), + const SizedBox(height: 12), + _buildCard( + Column( + children: [ + _buildEditableTile( + field: 'name', + label: '이름', + value: profile.name, + profile: profile, + isUpdating: isUpdating, + controller: _nameController!, + ), + const Divider(height: 24), + _buildReadOnlyTile('이메일', profile.email), + const Divider(height: 24), + _buildPhoneEditor(profile, isUpdating), + ], + ), + ), + const SizedBox(height: 28), + _buildSectionTitle('조직 정보', '소속 및 구분 정보입니다.'), + const SizedBox(height: 12), + _buildCard( + Column( + children: [ + _buildEditableTile( + field: 'department', + label: '소속', + value: profile.department, + profile: profile, + isUpdating: isUpdating, + controller: _departmentController!, + ), + const Divider(height: 24), + _buildReadOnlyTile('구분', profile.affiliationType), + if (profile.companyCode.isNotEmpty) ...[ + const Divider(height: 24), + _buildReadOnlyTile('회사코드', profile.companyCode), + ], + ], + ), + ), + if (isUpdating || _isVerifying) ...[ + const SizedBox(height: 24), + const Center(child: CircularProgressIndicator()), + ], ], - ], + ), ), - ), - if (isUpdating || _isVerifying) ...[ - const SizedBox(height: 24), - const Center(child: CircularProgressIndicator()), - ], - ], + ); + }, ), ); } @@ -652,7 +688,7 @@ class _ProfilePageState extends ConsumerState { appBar: AppBar( title: Text( 'Baron 통합로그인', - style: GoogleFonts.outfit(fontWeight: FontWeight.bold), + style: TextStyle(fontWeight: FontWeight.bold), ), elevation: 0, backgroundColor: _surface, diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index 8dfec393..d9ecdfd1 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -4,7 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:descope/descope.dart'; import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_web_plugins/url_strategy.dart'; import 'features/auth/presentation/login_screen.dart'; import 'features/auth/presentation/signup_screen.dart'; @@ -23,6 +23,18 @@ import 'package:logging/logging.dart'; final _log = Logger('Main'); +Future _loadBundledFonts() async { + const family = 'NotoSansKR'; + final loader = FontLoader(family); + try { + loader.addFont(rootBundle.load('assets/fonts/NotoSansKR-Regular.ttf')); + loader.addFont(rootBundle.load('assets/fonts/NotoSansKR-Bold.ttf')); + await loader.load(); + } catch (e) { + _log.warning("Failed to preload bundled fonts: $e"); + } +} + void main() async { WidgetsFlutterBinding.ensureInitialized(); usePathUrlStrategy(); @@ -51,6 +63,9 @@ void main() async { // 0. Initialize Logger LoggerService.init(); + // 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화 + await _loadBundledFonts(); + // Initialize Descope (프로젝트 ID가 없으면 경고만 남기고 진행) final projectId = dotenv.maybeGet('DESCOPE_PROJECT_ID') ?? ''; if (projectId.isEmpty || projectId == 'your-project-id') { @@ -228,7 +243,7 @@ class BaronSSOApp extends StatelessWidget { brightness: Brightness.light, ), useMaterial3: true, - textTheme: GoogleFonts.interTextTheme(), + fontFamily: 'NotoSansKR', pageTransitionsTheme: const PageTransitionsTheme( builders: { TargetPlatform.android: NoTransitionsBuilder(), diff --git a/userfront/pubspec.lock b/userfront/pubspec.lock index 5ee563c5..3e6488da 100644 --- a/userfront/pubspec.lock +++ b/userfront/pubspec.lock @@ -117,10 +117,10 @@ packages: dependency: "direct main" description: name: descope - sha256: da73578c619aefb82411ddca3e61423006a36900ff8cdc4b4ef42c4863f8ad36 + sha256: cae7d22e47d7d1c35d5a1cdfad2ae5f3b5e755476f215ddfa4dbeab6937f3ac2 url: "https://pub.dev" source: hosted - version: "0.9.11" + version: "0.9.12" fake_async: dependency: transitive description: @@ -133,10 +133,10 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.5" file: dependency: transitive description: @@ -170,10 +170,10 @@ packages: dependency: "direct main" description: name: flutter_riverpod - sha256: "9e2d6907f12cc7d23a846847615941bddee8709bf2bfd274acdf5e80bcf22fde" + sha256: a3cd0547353c1990bf5ad64f73143e5ce7a780409639559ad83a743ff3b945e4 url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.2.0" flutter_test: dependency: "direct dev" description: flutter @@ -184,10 +184,10 @@ packages: description: flutter source: sdk version: "0.0.0" - userfront_server_client: + frontend_server_client: dependency: transitive description: - name: userfront_server_client + name: frontend_server_client sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 url: "https://pub.dev" source: hosted @@ -208,14 +208,6 @@ packages: url: "https://pub.dev" source: hosted version: "17.0.1" - google_fonts: - dependency: "direct main" - description: - name: google_fonts - sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 - url: "https://pub.dev" - source: hosted - version: "6.3.3" http: dependency: "direct main" description: @@ -260,10 +252,10 @@ packages: dependency: transitive description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.10.0" leak_tracker: dependency: transitive description: @@ -296,8 +288,16 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + logger: + dependency: "direct main" + description: + name: logger + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + url: "https://pub.dev" + source: hosted + version: "2.6.2" logging: - dependency: transitive + dependency: "direct main" description: name: logging sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 @@ -336,6 +336,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: c6184bf2913dd66be244108c9c27ca04b01caf726321c44b0e7a7a1e32d41044 + url: "https://pub.dev" + source: hosted + version: "7.1.4" node_preamble: dependency: transitive description: @@ -360,62 +368,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" - path_provider: - dependency: transitive - description: - name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" - url: "https://pub.dev" - source: hosted - version: "2.1.5" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e - url: "https://pub.dev" - source: hosted - version: "2.2.22" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" - url: "https://pub.dev" - source: hosted - version: "2.5.1" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.dev" - source: hosted - version: "2.2.1" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -440,14 +392,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" riverpod: dependency: transitive description: name: riverpod - sha256: c406de02bff19d920b832bddfb8283548bfa05ce41c59afba57ce643e116aa59 + sha256: "9026676260f31bb5279cc5e59ca292145d8bab4aabede9aa2555c2a626ec66f1" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.2.0" shelf: dependency: transitive description: @@ -633,10 +601,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" url_launcher_windows: dependency: transitive description: @@ -665,10 +633,10 @@ packages: dependency: transitive description: name: watcher - sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249 + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.1" web: dependency: transitive description: @@ -701,14 +669,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" yaml: dependency: transitive description: @@ -719,4 +679,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.10.4 <4.0.0" - flutter: ">=3.35.0" + flutter: ">=3.38.0" diff --git a/userfront/pubspec.yaml b/userfront/pubspec.yaml index e468faf4..0be958f8 100644 --- a/userfront/pubspec.yaml +++ b/userfront/pubspec.yaml @@ -38,13 +38,12 @@ dependencies: go_router: ^17.0.1 descope: ^0.9.11 http: ^1.6.0 - google_fonts: ^6.3.3 - flutter_dotenv: ^5.1.0 + flutter_dotenv: ^6.0.0 url_launcher: ^6.3.2 logging: ^1.2.0 logger: ^2.0.0 qr_flutter: ^4.1.0 - mobile_scanner: ^6.0.0 + mobile_scanner: ^7.1.4 dev_dependencies: flutter_test: @@ -96,3 +95,9 @@ flutter: # # For details regarding fonts from package dependencies, # see https://flutter.dev/to/font-from-package + fonts: + - family: NotoSansKR + fonts: + - asset: assets/fonts/NotoSansKR-Regular.ttf + - asset: assets/fonts/NotoSansKR-Bold.ttf + weight: 700