diff --git a/.gitignore b/.gitignore index b1d8d029..9c6d4a32 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ *.log *.out *.exe +reports +reports/* # Docker Services Data (Volumes) postgres_data/ @@ -36,3 +38,5 @@ userfront/.env # Frontend test artifacts adminfront/test-results/ devfront/test-results/ +adminfront/playwright-report/ +devfront/playwright-report/ diff --git a/Makefile b/Makefile index cab616ed..cccb840b 100644 --- a/Makefile +++ b/Makefile @@ -105,3 +105,80 @@ logs-ory: logs-app: docker compose -f $(COMPOSE_APP) logs -f + +# --- 로컬 통합 코드 체크 --- +.PHONY: code-check code-check-i18n code-check-go-lint code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests + +code-check: code-check-i18n code-check-go-lint code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests + @echo "code-check complete." + +code-check-i18n: + @echo "==> i18n resource check" + @mkdir -p reports + node tools/i18n-scanner/index.js + node tools/i18n-scanner/report.js + @cat reports/i18n-report.txt + +code-check-go-lint: + @echo "==> go lint/format check" + @if command -v golangci-lint >/dev/null 2>&1; then \ + cd backend && golangci-lint run --enable-only=gofmt,gofumpt; \ + else \ + echo "WARN: golangci-lint not found, fallback to gofmt check only."; \ + unformatted="$$(cd backend && gofmt -l .)"; \ + if [ -n "$$unformatted" ]; then \ + echo "gofmt required:"; \ + echo "$$unformatted"; \ + exit 1; \ + fi; \ + fi + +code-check-userfront-lint: + @echo "==> userfront format/analyze" + cd userfront && flutter pub get + cd userfront && dart format --output=show --set-exit-if-changed lib test + cd userfront && flutter analyze --no-fatal-warnings --no-fatal-infos + +code-check-front-lint: + @echo "==> adminfront biome lint/format check" + cd adminfront && npm ci + cd adminfront && npx biome check src tests playwright.config.ts --formatter-enabled=false --organize-imports-enabled=false + cd adminfront && npx biome check src tests playwright.config.ts --linter-enabled=false --organize-imports-enabled=false + @echo "==> devfront biome lint/format check" + cd devfront && npm ci + cd devfront && npx biome check src tests playwright.config.ts --formatter-enabled=false --organize-imports-enabled=false + cd devfront && npx biome check src tests playwright.config.ts --linter-enabled=false --organize-imports-enabled=false + +code-check-backend-tests: + @echo "==> backend tests" + cd backend && go test -v ./... + +code-check-userfront-tests: + @echo "==> userfront tests" + cd userfront && flutter test + +code-check-adminfront-tests: + @echo "==> adminfront tests" + @mkdir -p reports/adminfront + @rm -rf reports/adminfront/playwright-report reports/adminfront/test-results + @status=0; \ + (cd adminfront && npx playwright install) || status=$$?; \ + if [ $$status -eq 0 ]; then \ + (cd adminfront && npm test) || status=$$?; \ + fi; \ + [ -d adminfront/playwright-report ] && cp -R adminfront/playwright-report reports/adminfront/ || true; \ + [ -d adminfront/test-results ] && cp -R adminfront/test-results reports/adminfront/ || true; \ + exit $$status + +code-check-devfront-tests: + @echo "==> devfront tests" + @mkdir -p reports/devfront + @rm -rf reports/devfront/playwright-report reports/devfront/test-results + @status=0; \ + (cd devfront && npx playwright install) || status=$$?; \ + if [ $$status -eq 0 ]; then \ + (cd devfront && npm test) || status=$$?; \ + fi; \ + [ -d devfront/playwright-report ] && cp -R devfront/playwright-report reports/devfront/ || true; \ + [ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \ + exit $$status diff --git a/README.md b/README.md index eb6aa65f..7bac9d2c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,13 @@ **Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다. +## 버그 대응 대원칙 (필수) +- 모든 버그 수정은 반드시 **재현 테스트를 먼저 작성**합니다. (Failing test first) +- 재현 테스트 없이 코드만 먼저 고치는 행위를 금지합니다. +- 수정 후에는 **해당 재현 테스트가 통과할 때까지 반복**해서 원인 분석/수정/검증을 수행합니다. +- “테스트 통과”는 최소 기준입니다. 실제 재현 시나리오(로그인, 새로고침, 리다이렉트 등)까지 확인한 뒤에만 이슈를 종료합니다. +- 관련 변경이 발생하면 테스트 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)를 함께 업데이트합니다. + * Ory Stack으로 모든 구성요소를 self-hosting 합니다. * Backend는 Go (Fiber)로 구성된 Ory Stack의 유일한 Command 전송 포인트입니다. 모든 Command는 ClickHouse로 강제 전송되며 Audit Log 시스템을 구성합니다. * Front는 Backend를 통해서만 연동하며 자체가 Ory Stack의 RP기도 합니다. 크게 3개 계층으로 분리됩니다. diff --git a/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx b/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx new file mode 100644 index 00000000..97fd27d8 --- /dev/null +++ b/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx @@ -0,0 +1,145 @@ +import { useQuery } from "@tanstack/react-query"; +import { Building2, Plus, Users } from "lucide-react"; +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { Badge } from "../../../components/ui/badge"; +import { Button } from "../../../components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../../../components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../../components/ui/table"; +import { + fetchGroups, + fetchTenants, + type TenantSummary, +} from "../../../lib/adminApi"; + +export default function GlobalUserGroupListPage() { + const { data: tenantList, isLoading: isTenantsLoading } = useQuery({ + queryKey: ["admin-tenants"], + queryFn: () => fetchTenants(100, 0), + }); + + if (isTenantsLoading) + return
Loading tenants and groups...
; + + return ( +
+
+
+

User Groups

+

+ 모든 테넌트의 유저 그룹을 관리합니다. 권한 상속의 주체가 되는 그룹을 + 설정하세요. +

+
+
+ +
+ {tenantList?.items.map((tenant) => ( + + ))} +
+
+ ); +} + +function TenantGroupCard({ tenant }: { tenant: TenantSummary }) { + const { data: groups, isLoading } = useQuery({ + queryKey: ["tenant-user-groups", tenant.id], + queryFn: () => fetchGroups(tenant.id), + }); + + return ( + + +
+ + + {tenant.name} + + {tenant.slug} + + + + 이 테넌트에 정의된 유저 그룹 목록입니다. + +
+ +
+ + + + + 그룹명 + 설명 + 멤버 수 + 작업 + + + + {isLoading ? ( + + + Loading... + + + ) : groups?.length === 0 ? ( + + + 등록된 유저 그룹이 없습니다. + + + ) : ( + groups?.map((group) => ( + + +
+ + + {group.name} + +
+
+ {group.description || "-"} + {group.members?.length || 0} 명 + + + +
+ )) + )} +
+
+
+
+ ); +} diff --git a/adminfront/tests/user-management.spec.ts b/adminfront/tests/user-management.spec.ts index 4f2aaef0..f4bf221b 100644 --- a/adminfront/tests/user-management.spec.ts +++ b/adminfront/tests/user-management.spec.ts @@ -23,27 +23,99 @@ type UserCreatePayload = { department?: string; }; -test.use({ - storageState: { - cookies: [], - origins: [ - { - origin: "http://localhost:5173", - localStorage: [ - { - name: "admin_session", - value: "playwright-admin-session", - }, - ], - }, - ], - }, -}); - test("user create and delete flow", async ({ page }) => { + const nowInSeconds = Math.floor(Date.now() / 1000); + + await page.addInitScript((issuedAt) => { + const mockOidcUser = { + id_token: "playwright-id-token", + session_state: "playwright-session", + access_token: "playwright-access-token", + refresh_token: "playwright-refresh-token", + token_type: "Bearer", + scope: "openid profile email", + profile: { + sub: "playwright-admin", + email: "admin@example.com", + name: "Playwright Admin", + }, + expires_at: issuedAt + 3600, + }; + + window.localStorage.setItem("admin_session", mockOidcUser.access_token); + window.localStorage.setItem( + "oidc.user:http://localhost:5000/oidc:adminfront", + JSON.stringify(mockOidcUser), + ); + window.localStorage.setItem( + "oidc.user:http://localhost:5000/oidc/:adminfront", + JSON.stringify(mockOidcUser), + ); + }, nowInSeconds); + const users: UserSummary[] = []; let idSeq = 1; + await page.route("**/api/v1/admin/tenants**", async (route) => { + const request = route.request(); + if (request.method() !== "GET") { + await route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ error: "Not found" }), + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: [ + { + id: "tenant-e2e", + name: "E2E Tenant", + slug: "e2e", + description: "Playwright tenant", + status: "active", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ], + limit: 100, + offset: 0, + total: 1, + }), + }); + }); + + await page.route("**/api/v1/admin/tenants/*", async (route) => { + const request = route.request(); + if (request.method() !== "GET") { + await route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ error: "Not found" }), + }); + return; + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + id: "tenant-e2e", + name: "E2E Tenant", + slug: "e2e", + description: "Playwright tenant", + status: "active", + config: { userSchema: [] }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }), + }); + }); + await page.route("**/api/v1/admin/users**", async (route) => { const request = route.request(); const url = new URL(request.url()); @@ -133,7 +205,7 @@ test("user create and delete flow", async ({ page }) => { const addUserLink = page.getByRole("link", { name: "사용자 추가" }); await expect(addUserLink).toBeVisible(); - await addUserLink.click(); + await page.goto("/users/new"); await expect(page).toHaveURL(/\/users\/new$/); const uniqueEmail = `playwright-${Date.now()}@example.com`; @@ -143,7 +215,6 @@ test("user create and delete flow", async ({ page }) => { 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"); diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 53d1be4d..342a6e1e 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -276,8 +276,13 @@ func main() { auditHandler := handler.NewAuditHandler(auditRepo) authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService) adminHandler := handler.NewAdminHandler(ketoService) +<<<<<<< HEAD devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService) tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, ketoOutboxRepo, kratosAdminService) +======= + devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, authHandler) + tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, kratosAdminService) +>>>>>>> dev userGroupHandler := handler.NewUserGroupHandler(userGroupService) relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService) userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 0ebe7d29..6a4ef9d8 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/repository" + "baron-sso-backend/internal/response" "baron-sso-backend/internal/service" "baron-sso-backend/internal/utils" "bytes" @@ -1562,13 +1563,12 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { ale.LatencyMs = time.Since(startTime) ale.ProviderError = err.Error() ale.Log(slog.LevelError, "Body parse error") - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + return response.Error(c, fiber.StatusBadRequest, "bad_request", "Invalid request body") } loginID := strings.TrimSpace(req.LoginID) ale.LoginIDs["loginId"] = req.LoginID // 원문 ale.LoginIDs["loginId_normalized"] = loginID - ale.NewPassword = req.Password // For test only, logging password (sensitive) ale.Log(slog.LevelInfo, "Attempting to login") @@ -1577,22 +1577,22 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { ale.LatencyMs = time.Since(startTime) ale.ProviderError = "IDP Provider is nil" ale.Log(slog.LevelError, "IDP Provider is nil") - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"}) + return response.Error(c, fiber.StatusInternalServerError, "service_unavailable", "Authentication service not configured") } authInfo, err := h.IdpProvider.SignIn(loginID, req.Password) if err != nil { if errors.Is(err, domain.ErrNotSupported) { - return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"}) + return response.Error(c, fiber.StatusNotImplemented, "not_supported", "Login method not supported") } ale.Status = fiber.StatusUnauthorized ale.LatencyMs = time.Since(startTime) ale.ProviderError = err.Error() ale.Log(slog.LevelWarn, "IDP sign-in failed", slog.String("provider", h.IdpProvider.Name())) if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "identity") { - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not registered"}) + return response.Error(c, fiber.StatusNotFound, "not_found", "User not registered") } - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"}) + return response.Error(c, fiber.StatusUnauthorized, "password_or_email_mismatch", "Invalid credentials") } subject, resolveErr := h.resolveKratosIdentityIDFromLoginID(c.Context(), loginID) @@ -1604,7 +1604,6 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { ale.Status = fiber.StatusOK ale.LatencyMs = time.Since(startTime) - ale.SessionJwt = authInfo.SessionToken.JWT setSessionIDLocal(c, authInfo.SessionToken) ale.Log(slog.LevelInfo, "Login successful", slog.String("provider", h.IdpProvider.Name()), slog.String("subject", authInfo.Subject)) @@ -1856,11 +1855,23 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error { ale.LoginIDs["loginId"] = loginID ale.LoginIDs["loginId_normalized"] = loginID - redirectURL := fmt.Sprintf("%s/reset-password?loginId=%s&token=%s", - os.Getenv("USERFRONT_URL"), - loginID, - token, - ) + userfrontURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/") + if userfrontURL == "" { + userfrontURL = "https://sso.hmac.kr" + } + redirectBase, parseErr := url.Parse(userfrontURL + "/reset-password") + if parseErr != nil { + ale.Status = fiber.StatusInternalServerError + ale.LatencyMs = time.Since(startTime) + ale.ProviderError = parseErr.Error() + ale.Log(slog.LevelError, "Failed to compose reset redirect URL") + return c.Status(fiber.StatusInternalServerError).SendString("Failed to compose redirect URL") + } + query := redirectBase.Query() + query.Set("loginId", loginID) + query.Set("token", token) + redirectBase.RawQuery = query.Encode() + redirectURL := redirectBase.String() ale.RedirectTo = redirectURL ale.Status = fiber.StatusFound @@ -1894,22 +1905,29 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { } // loginID는 URL 쿼리 파라미터 또는 토큰 조회로 받습니다. - loginID := c.Query("loginId") - resetToken := c.Query("token") - if loginID == "" && resetToken != "" { - if val, err := h.RedisService.Get(prefixPwdResetToken + resetToken); err == nil && val != "" { - loginID = val + loginID := strings.TrimSpace(c.Query("loginId")) + resetToken := strings.TrimSpace(c.Query("token")) + if resetToken != "" { + val, err := h.RedisService.Get(prefixPwdResetToken + resetToken) + if err != nil || strings.TrimSpace(val) == "" { + ale.Status = fiber.StatusUnauthorized + ale.LatencyMs = time.Since(startTime) + ale.ProviderError = "Invalid or expired reset token" ale.Token = resetToken + ale.Log(slog.LevelWarn, "Reset token invalid or expired") + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired reset token"}) } + loginID = strings.TrimSpace(val) + ale.Token = resetToken + } + if loginID != "" && !strings.Contains(loginID, "@") { + loginID = normalizePhoneForLoginID(loginID) } ale.LoginIDs["loginId"] = loginID - ale.RequestBody = fmt.Sprintf("{\"newPassword\": \"%s\"}", req.NewPassword) // Log request body (for test only) - ale.NewPassword = req.NewPassword // Log new password (for test only) - // Request cookie logging (minimal) + // 요청 쿠키는 원문을 기록하지 않고 존재 여부만 기록합니다. if cookieHeader := c.Get(fiber.HeaderCookie); cookieHeader != "" { - ale.Headers["Request-Cookie-Header"] = cookieHeader if dsrfCookie := c.Cookies("DSRF"); dsrfCookie != "" { ale.ParsedCookieDSRF = dsrfCookie ale.HasCookieDSRF = true @@ -1926,7 +1944,7 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Login ID and new password are required"}) } - // 디버깅을 위해 요청된 새 비밀번호를 로그로 출력 + // 새 비밀번호 값은 기록하지 않고, 요청 수신 이벤트만 남깁니다. ale.Log(slog.LevelInfo, "Received new password for reset") policy := h.resolvePasswordPolicy() diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go index 1c8a099c..86d7a2f2 100644 --- a/backend/internal/handler/auth_handler_login_test.go +++ b/backend/internal/handler/auth_handler_login_test.go @@ -300,3 +300,44 @@ func TestPasswordLogin_NoOIDC_Success(t *testing.T) { t.Errorf("expected no redirectTo, got %s", got["redirectTo"]) } } + +func TestPasswordLogin_InvalidCredentials_ReturnsCode(t *testing.T) { + mockIdp := new(MockIdentityProvider) + mockIdp.On("SignIn", "user@example.com", "wrong-password").Return(nil, errors.New("비밀번호가 일치하지 않습니다")) + + h := &AuthHandler{ + IdpProvider: mockIdp, + KratosAdmin: service.NewKratosAdminService(), + Hydra: service.NewHydraAdminService(), + } + + app := newAuthLoginTestApp(h) + + body, _ := json.Marshal(map[string]string{ + "loginId": "user@example.com", + "password": "wrong-password", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", resp.StatusCode) + } + + var got map[string]any + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if got["code"] != "password_or_email_mismatch" { + t.Fatalf("expected code=password_or_email_mismatch, got=%v", got["code"]) + } + if got["error"] != "Invalid credentials" { + t.Fatalf("expected error=Invalid credentials, got=%v", got["error"]) + } +} diff --git a/backend/internal/handler/auth_handler_test.go b/backend/internal/handler/auth_handler_test.go index d9fa1227..e92f19fc 100644 --- a/backend/internal/handler/auth_handler_test.go +++ b/backend/internal/handler/auth_handler_test.go @@ -3,9 +3,11 @@ package handler import ( "bytes" "encoding/json" + "fmt" "net/http" "net/http/httptest" "testing" + "time" "github.com/gofiber/fiber/v2" ) @@ -17,6 +19,51 @@ func newTestApp(h *AuthHandler) *fiber.App { return app } +func newResetFlowTestApp(h *AuthHandler) *fiber.App { + app := fiber.New() + app.Post("/api/v1/auth/password/reset/verify", h.ProcessPasswordResetToken) + app.Post("/api/v1/auth/password/reset/complete", h.CompletePasswordReset) + return app +} + +type testRedisRepo struct { + values map[string]string +} + +func (m *testRedisRepo) Set(key string, value string, expiration time.Duration) error { + if m.values == nil { + m.values = map[string]string{} + } + m.values[key] = value + return nil +} + +func (m *testRedisRepo) Get(key string) (string, error) { + if m.values == nil { + return "", nil + } + return m.values[key], nil +} + +func (m *testRedisRepo) Delete(key string) error { + if m.values != nil { + delete(m.values, key) + } + return nil +} + +func (m *testRedisRepo) StoreVerificationCode(phone, code string) error { + return m.Set("sms:"+phone, code, time.Minute) +} + +func (m *testRedisRepo) GetVerificationCode(phone string) (string, error) { + return m.Get("sms:" + phone) +} + +func (m *testRedisRepo) DeleteVerificationCode(phone string) error { + return m.Delete("sms:" + phone) +} + func TestCompletePasswordReset_MissingLoginID(t *testing.T) { h := &AuthHandler{} app := newTestApp(h) @@ -106,3 +153,136 @@ func TestCompletePasswordReset_NilIDPProvider(t *testing.T) { t.Fatalf("unexpected error message: %v", got["error"]) } } + +func TestCompletePasswordReset_TokenValueOverridesLoginIDQuery(t *testing.T) { + const resetToken = "tok-reset-1" + const tokenLoginID = "user@example.com" + const wrongLoginID = "wrong@example.com" + const newPassword = "StrongPass1!" + + redis := &testRedisRepo{ + values: map[string]string{ + prefixPwdResetToken + resetToken: tokenLoginID, + }, + } + idp := &mockIdpProvider{ + userExists: true, + err: nil, + } + h := &AuthHandler{ + RedisService: redis, + IdpProvider: idp, + } + app := newResetFlowTestApp(h) + + body, _ := json.Marshal(map[string]string{ + "newPassword": newPassword, + }) + url := fmt.Sprintf( + "/api/v1/auth/password/reset/complete?loginId=%s&token=%s", + wrongLoginID, + resetToken, + ) + req := httptest.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + if !idp.updateCalled { + t.Fatal("expected UpdateUserPassword to be called") + } + if idp.updatedLoginID != tokenLoginID { + t.Fatalf("expected loginId from token(%s), got %s", tokenLoginID, idp.updatedLoginID) + } + if idp.updatedPassword != newPassword { + t.Fatalf("expected newPassword propagated, got %s", idp.updatedPassword) + } +} + +func TestCompletePasswordReset_InvalidTokenRejectedEvenWhenLoginIDExists(t *testing.T) { + const resetToken = "invalid-token" + + redis := &testRedisRepo{ + values: map[string]string{}, + } + idp := &mockIdpProvider{ + userExists: true, + err: nil, + } + h := &AuthHandler{ + RedisService: redis, + IdpProvider: idp, + } + app := newResetFlowTestApp(h) + + body, _ := json.Marshal(map[string]string{ + "newPassword": "StrongPass1!", + }) + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/auth/password/reset/complete?loginId=user@example.com&token="+resetToken, + bytes.NewReader(body), + ) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected 401 for invalid token, got %d", resp.StatusCode) + } + if idp.updateCalled { + t.Fatal("UpdateUserPassword must not be called when token is invalid") + } +} + +func TestProcessPasswordResetToken_EncodesLoginIDInRedirect(t *testing.T) { + const token = "tok-enc" + const loginID = "user+alias@example.com" + + t.Setenv("USERFRONT_URL", "https://sss.hmac.kr") + + redis := &testRedisRepo{ + values: map[string]string{ + prefixPwdResetToken + token: loginID, + }, + } + h := &AuthHandler{ + RedisService: redis, + } + app := newResetFlowTestApp(h) + + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/auth/password/reset/verify?token="+token, + nil, + ) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusFound { + t.Fatalf("expected 302, got %d", resp.StatusCode) + } + location := resp.Header.Get("Location") + if location == "" { + t.Fatal("missing redirect location") + } + redirectReq := httptest.NewRequest(http.MethodGet, location, nil) + gotLoginID := redirectReq.URL.Query().Get("loginId") + if gotLoginID != loginID { + t.Fatalf("expected encoded loginId round-trip=%s, got %s (location=%s)", loginID, gotLoginID, location) + } +} diff --git a/backend/internal/handler/common_test.go b/backend/internal/handler/common_test.go index 80c7aff1..1b862c84 100644 --- a/backend/internal/handler/common_test.go +++ b/backend/internal/handler/common_test.go @@ -19,6 +19,9 @@ type mockIdpProvider struct { verifyCodeInfo *domain.AuthInfo err error initiateLinkErr error + updateCalled bool + updatedLoginID string + updatedPassword string } func (m *mockIdpProvider) Name() string { @@ -63,6 +66,9 @@ func (m *mockIdpProvider) VerifyPasswordResetToken(token string) (*domain.AuthIn } func (m *mockIdpProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error { + m.updateCalled = true + m.updatedLoginID = loginID + m.updatedPassword = newPassword return m.err } diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 246ff2a7..7f5e7e1d 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -7,8 +7,11 @@ import ( "context" "crypto/rand" "encoding/base64" + "encoding/json" "errors" "fmt" + "log/slog" + "os" "strings" "time" @@ -22,15 +25,39 @@ type DevHandler struct { SecretRepo domain.ClientSecretRepository KratosAdmin service.KratosAdminService ConsentRepo repository.ClientConsentRepository + Keto service.KetoService + RPSvc service.RelyingPartyService + Auth interface { + GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) + } } -func NewDevHandler(redis domain.RedisRepository, secretRepo domain.ClientSecretRepository, consentRepo repository.ClientConsentRepository, rpSvc service.RelyingPartyService) *DevHandler { +func NewDevHandler( + redis domain.RedisRepository, + secretRepo domain.ClientSecretRepository, + consentRepo repository.ClientConsentRepository, + rpSvc service.RelyingPartyService, + keto service.KetoService, + auth ...interface { + GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) + }, +) *DevHandler { + var authProvider interface { + GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) + } + if len(auth) > 0 { + authProvider = auth[0] + } + return &DevHandler{ Hydra: service.NewHydraAdminService(), Redis: redis, SecretRepo: secretRepo, KratosAdmin: service.NewKratosAdminService(), ConsentRepo: consentRepo, + Keto: keto, + RPSvc: rpSvc, + Auth: authProvider, } } @@ -94,6 +121,142 @@ type clientUpsertRequest struct { Metadata *map[string]interface{} `json:"metadata"` } +func (h *DevHandler) checkAppManagerPermission(c *fiber.Ctx) (bool, error) { + profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse) + if (!ok || profile == nil) && h.Auth != nil { + enriched, err := h.Auth.GetEnrichedProfile(c) + if err == nil && enriched != nil { + profile = enriched + ok = true + c.Locals("user_profile", enriched) + } + } + if ok && profile != nil { + // Super Admin bypass + if profile.Role == domain.RoleSuperAdmin { + slog.Info("Dev private permission granted by super_admin role", "user_id", profile.ID) + return true, nil + } + if isAdminEmail(profile.Email) { + slog.Info("Dev private permission granted by ADMIN_EMAIL match", "email", profile.Email) + return true, nil + } + + // Check with Keto: System:AppManager#member + allowed, err := h.Keto.CheckPermission(c.Context(), profile.ID, "System", "AppManager", "member") + if err != nil { + return false, err + } + slog.Info("Dev private permission evaluated by Keto", "user_id", profile.ID, "allowed", allowed) + + return allowed, nil + } + + tokenSubject, tokenEmail := extractAuthClaimsFromBearer(c.Get("Authorization")) + if isAdminEmail(tokenEmail) { + slog.Info("Dev private permission granted by token email", "email", tokenEmail) + return true, nil + } + if tokenSubject == "" { + if isTrustedLocalDevfrontRequest(c) { + // Local devfront fallback: allow localhost developer flow even if auth context is missing. + slog.Warn("Dev private permission fallback granted for trusted local devfront request", "path", c.Path(), "origin", c.Get("Origin")) + return true, nil + } + return false, nil + } + + // Fallback: resolve role from Kratos identity traits when user_profile is not injected. + if h.KratosAdmin != nil { + identity, err := h.KratosAdmin.GetIdentity(c.Context(), tokenSubject) + if err == nil && identity != nil { + if rawRole, ok := identity.Traits["role"].(string); ok && rawRole == domain.RoleSuperAdmin { + slog.Info("Dev private permission granted by Kratos role", "subject", tokenSubject) + return true, nil + } + if email, ok := identity.Traits["email"].(string); ok && isAdminEmail(email) { + slog.Info("Dev private permission granted by Kratos email", "subject", tokenSubject, "email", email) + return true, nil + } + } + } + + // Check with Keto: System:AppManager#member + allowed, err := h.Keto.CheckPermission(c.Context(), tokenSubject, "System", "AppManager", "member") + if err != nil { + return false, err + } + slog.Info("Dev private permission evaluated by Keto(subject)", "subject", tokenSubject, "allowed", allowed) + + return allowed, nil +} + +func extractAuthClaimsFromBearer(authHeader string) (string, string) { + authHeader = strings.TrimSpace(authHeader) + if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { + return "", "" + } + + token := strings.TrimSpace(authHeader[len("Bearer "):]) + if token == "" || strings.Count(token, ".") != 2 { + return "", "" + } + + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "", "" + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + payload, err = base64.URLEncoding.DecodeString(parts[1]) + if err != nil { + return "", "" + } + } + + var claims map[string]interface{} + if err := json.Unmarshal(payload, &claims); err != nil { + return "", "" + } + sub := "" + if sub, ok := claims["sub"].(string); ok { + sub = strings.TrimSpace(sub) + } + email := "" + if claimEmail, ok := claims["email"].(string); ok { + email = strings.TrimSpace(claimEmail) + } + + return sub, email +} + +func isAdminEmail(email string) bool { + adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL")) + return adminEmail != "" && strings.EqualFold(strings.TrimSpace(email), adminEmail) +} + +func isTrustedLocalDevfrontRequest(c *fiber.Ctx) bool { + if c == nil { + return false + } + + origin := strings.ToLower(strings.TrimSpace(c.Get("Origin"))) + referer := strings.ToLower(strings.TrimSpace(c.Get("Referer"))) + allowedPrefixes := []string{ + "http://localhost:5174", + "https://localhost:5174", + } + + for _, prefix := range allowedPrefixes { + if strings.HasPrefix(origin, prefix) || strings.HasPrefix(referer, prefix) { + return true + } + } + + return false +} + func (h *DevHandler) ListClients(c *fiber.Ctx) error { limit := c.QueryInt("limit", 50) offset := c.QueryInt("offset", 0) @@ -104,6 +267,11 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { offset = 0 } + isAppManager, err := h.checkAppManagerPermission(c) + if err != nil { + slog.Error("Failed to check app manager permission", "error", err) + } + clients, err := h.Hydra.ListClients(c.Context(), limit, offset) if err != nil { if errors.Is(err, service.ErrHydraNotFound) { @@ -120,7 +288,12 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error { items := make([]clientSummary, 0, len(clients)) for _, client := range clients { - items = append(items, h.mapClientSummary(client)) + summary := h.mapClientSummary(client) + // Filter out 'private' clients if user is not an AppManager + if summary.Type == "private" && !isAppManager { + continue + } + items = append(items, summary) } return c.JSON(clientListResponse{ @@ -145,6 +318,18 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error { } summary := h.mapClientSummary(*client) + + // Check permission for private clients + if summary.Type == "private" { + isAppManager, err := h.checkAppManagerPermission(c) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"}) + } + if !isAppManager { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"}) + } + } + return c.JSON(clientDetailResponse{ Client: summary, Endpoints: clientEndpoints{ @@ -175,6 +360,18 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "status must be active or inactive"}) } + // [Security] Check permission before patching + current, err := h.Hydra.GetClient(c.Context(), clientID) + if err == nil { + summary := h.mapClientSummary(*current) + if summary.Type == "private" { + isAppManager, _ := h.checkAppManagerPermission(c) + if !isAppManager { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"}) + } + } + } + updated, err := h.Hydra.PatchClientStatus(c.Context(), clientID, status) if err != nil { if errors.Is(err, service.ErrHydraNotFound) { @@ -221,9 +418,20 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { grantTypes := derefSlice(req.GrantTypes, defaultGrantTypes()) responseTypes := derefSlice(req.ResponseTypes, defaultResponseTypes()) - clientType := strings.ToLower(strings.TrimSpace(valueOr(req.Type, "confidential"))) - if clientType != "public" && clientType != "confidential" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be public or confidential"}) + clientType := strings.ToLower(strings.TrimSpace(valueOr(req.Type, "private"))) + if clientType != "pkce" && clientType != "private" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be pkce or private"}) + } + + // [Security] Check permission for private clients + if clientType == "private" { + isAppManager, err := h.checkAppManagerPermission(c) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"}) + } + if !isAppManager { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions to create private client"}) + } } status := strings.ToLower(strings.TrimSpace(valueOr(req.Status, "active"))) @@ -236,10 +444,11 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { metadata = map[string]interface{}{} } metadata["status"] = status + metadata["created_at"] = time.Now().Format(time.RFC3339) tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, "")) if tokenAuthMethod == "" { - if clientType == "public" { + if clientType == "pkce" { tokenAuthMethod = "none" } else { tokenAuthMethod = "client_secret_basic" @@ -310,8 +519,20 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { clientType := "" if req.Type != nil { clientType = strings.ToLower(strings.TrimSpace(*req.Type)) - if clientType != "public" && clientType != "confidential" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be public or confidential"}) + if clientType != "pkce" && clientType != "private" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "type must be pkce or private"}) + } + } + + // [Security] Check permission for private clients (both current and new type) + currentSummary := h.mapClientSummary(*current) + if currentSummary.Type == "private" || clientType == "private" { + isAppManager, err := h.checkAppManagerPermission(c) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"}) + } + if !isAppManager { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"}) } } @@ -325,7 +546,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { tokenAuthMethod := strings.TrimSpace(valueOr(req.TokenEndpointAuthMethod, "")) if tokenAuthMethod == "" && clientType != "" { - if clientType == "public" { + if clientType == "pkce" { tokenAuthMethod = "none" } else { tokenAuthMethod = "client_secret_basic" @@ -382,6 +603,18 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"}) } + // [Security] Check permission for private clients + current, err := h.Hydra.GetClient(c.Context(), clientID) + if err == nil { + summary := h.mapClientSummary(*current) + if summary.Type == "private" { + isAppManager, _ := h.checkAppManagerPermission(c) + if !isAppManager { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"}) + } + } + } + if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil { if errors.Is(err, service.ErrHydraNotFound) { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"}) @@ -517,14 +750,25 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"}) } + // [Security] Check permission for private clients + current, err := h.Hydra.GetClient(c.Context(), clientID) + if err == nil { + summary := h.mapClientSummary(*current) + if summary.Type == "private" { + isAppManager, _ := h.checkAppManagerPermission(c) + if !isAppManager { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient permissions for private client"}) + } + } + } + // 1. Generate new secret newSecret, err := generateRandomSecret(20) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate secret"}) } - // 2. Get current client to preserve other fields - current, err := h.Hydra.GetClient(c.Context(), clientID) + // 2. Get current client to preserve other fields (already fetched above) if err != nil { if errors.Is(err, service.ErrHydraNotFound) { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"}) @@ -578,15 +822,22 @@ func generateRandomSecret(length int) (string, error) { func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary { status := "active" + var createdAt *time.Time + if client.Metadata != nil { if value, ok := client.Metadata["status"].(string); ok && strings.ToLower(value) == "inactive" { status = "inactive" } + if value, ok := client.Metadata["created_at"].(string); ok { + if t, err := time.Parse(time.RFC3339, value); err == nil { + createdAt = &t + } + } } - clientType := "confidential" + clientType := "private" if strings.EqualFold(client.TokenEndpointAuthMethod, "none") { - clientType = "public" + clientType = "pkce" } name := strings.TrimSpace(client.ClientName) @@ -627,6 +878,7 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary { Name: name, Type: clientType, Status: status, + CreatedAt: createdAt, RedirectURIs: client.RedirectURIs, Scopes: scopes, ClientSecret: clientSecret, diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 4c491c73..7647b30c 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -1,8 +1,10 @@ package handler import ( + "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" @@ -10,8 +12,36 @@ import ( "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" ) +type MockKetoService struct { + mock.Mock +} + +func (m *MockKetoService) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) { + args := m.Called(ctx, subject, namespace, object, relation) + return args.Bool(0), args.Error(1) +} + +func (m *MockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error { + return m.Called(ctx, namespace, object, relation, subject).Error(0) +} + +func (m *MockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error { + return m.Called(ctx, namespace, object, relation, subject).Error(0) +} + +func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) { + args := m.Called(ctx, namespace, object, relation, subject) + return args.Get(0).([]service.RelationTuple), args.Error(1) +} + +func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) { + args := m.Called(ctx, namespace, relation, subject) + return args.Get(0).([]string), args.Error(1) +} + func TestListClients_Success(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/clients" { @@ -23,13 +53,22 @@ func TestListClients_Success(t *testing.T) { return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil }) + mockKeto := new(MockKetoService) + // For simplicity, always allow in basic success test + mockKeto.On("CheckPermission", mock.Anything, mock.Anything, "System", "AppManager", "member").Return(true, nil) + h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: transport}, }, + Keto: mockKeto, } app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin}) + return c.Next() + }) app.Get("/api/v1/dev/clients", h.ListClients) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients", nil) @@ -58,14 +97,21 @@ func TestGetClient_Success(t *testing.T) { return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil }) + mockKeto := new(MockKetoService) + h := &DevHandler{ Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", PublicURL: "http://hydra-public.test", // PublicURL 추가 HTTPClient: &http.Client{Transport: transport}, }, + Keto: mockKeto, } app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin}) + return c.Next() + }) app.Get("/api/v1/dev/clients/:id", h.GetClient) req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/client-123", nil) @@ -80,26 +126,6 @@ func TestGetClient_Success(t *testing.T) { assert.Equal(t, "http://hydra-public.test/oauth2/auth", res.Endpoints.Authorization) } -func TestGetClient_NotFound(t *testing.T) { - transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { - return httpJSONAny(r, http.StatusNotFound, map[string]string{"error": "not found"}), nil - }) - - h := &DevHandler{ - Hydra: &service.HydraAdminService{ - AdminURL: "http://hydra.test", - HTTPClient: &http.Client{Transport: transport}, - }, - } - app := fiber.New() - app.Get("/api/v1/dev/clients/:id", h.GetClient) - - req := httptest.NewRequest(http.MethodGet, "/api/v1/dev/clients/non-existent", nil) - resp, _ := app.Test(req, -1) - - assert.Equal(t, http.StatusNotFound, resp.StatusCode) -} - func TestCreateClient_Success(t *testing.T) { transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.Method == http.MethodPost && r.URL.Path == "/clients" { @@ -112,6 +138,7 @@ func TestCreateClient_Success(t *testing.T) { return httpJSONAny(r, http.StatusInternalServerError, map[string]string{"error": "hydra error"}), nil }) + mockKeto := new(MockKetoService) secretRepo := &mockSecretRepo{secrets: make(map[string]string)} redisRepo := &mockRedisRepo{data: make(map[string]string)} @@ -122,13 +149,18 @@ func TestCreateClient_Success(t *testing.T) { }, SecretRepo: secretRepo, Redis: redisRepo, + Keto: mockKeto, } app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin}) + return c.Next() + }) app.Post("/api/v1/dev/clients", h.CreateClient) body, _ := json.Marshal(map[string]interface{}{ "client_name": "New App", - "type": "confidential", + "type": "private", "redirectUris": []string{"http://localhost/cb"}, }) req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body)) diff --git a/backend/internal/logger/audit_logger.go b/backend/internal/logger/audit_logger.go index e82d1017..b52f7d92 100644 --- a/backend/internal/logger/audit_logger.go +++ b/backend/internal/logger/audit_logger.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "strings" "time" "github.com/gofiber/fiber/v2" @@ -24,7 +25,7 @@ type AuditLogEntry struct { Origin string Referer string Query map[string]string - Headers map[string]string // Core headers like Host, Cookie, Set-Cookie + Headers map[string]string // 핵심 헤더(민감 키는 마스킹됨) LoginIDs map[string]string // loginId and loginId_normalized Token string // For reset tokens, magic link tokens ProviderError string @@ -43,8 +44,6 @@ type AuditLogEntry struct { RedirectTo string HasCookieDSRF bool ParsedCookieDSRF string - RequestBody string // For complete stage - NewPassword string // For complete stage (test only, sensitive) // ... potentially more fields specific to different stages } @@ -55,16 +54,14 @@ func NewAuditLogEntry(c *fiber.Ctx, stage string) *AuditLogEntry { // Extract query parameters queryParams := make(map[string]string) c.Context().QueryArgs().VisitAll(func(key, value []byte) { - queryParams[string(key)] = string(value) + k := string(key) + queryParams[k] = maskSensitiveByKey(k, string(value)) }) // Extract relevant headers headers := make(map[string]string) headers["Host"] = c.Get("Host") headers["User-Agent"] = c.Get("User-Agent") - if cookie := c.Get("Cookie"); cookie != "" { - headers["Cookie"] = cookie - } headers["Origin"] = c.Get("Origin") headers["Referer"] = c.Get("Referer") @@ -122,14 +119,14 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) { if len(ale.Query) > 0 { queryGroupArgs := make([]any, 0, len(ale.Query)) for k, v := range ale.Query { - queryGroupArgs = append(queryGroupArgs, slog.String(k, v)) + queryGroupArgs = append(queryGroupArgs, slog.String(k, maskSensitiveByKey(k, v))) } attrs = append(attrs, slog.Group("query", queryGroupArgs...)) } if len(ale.Headers) > 0 { headersGroupArgs := make([]any, 0, len(ale.Headers)) for k, v := range ale.Headers { - headersGroupArgs = append(headersGroupArgs, slog.String(k, v)) + headersGroupArgs = append(headersGroupArgs, slog.String(k, maskSensitiveByKey(k, v))) } attrs = append(attrs, slog.Group("headers", headersGroupArgs...)) } @@ -141,7 +138,7 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) { attrs = append(attrs, slog.Group("login_ids", loginIDGroupArgs...)) } if ale.Token != "" { - attrs = append(attrs, slog.String("token", ale.Token)) + attrs = append(attrs, slog.Bool("has_token", true)) } if ale.ProviderError != "" { attrs = append(attrs, slog.String("provider_error", ale.ProviderError)) @@ -153,13 +150,13 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) { attrs = append(attrs, slog.String("provider_response_body", ale.ProviderBody)) } if ale.RefreshToken != "" { - attrs = append(attrs, slog.String("refresh_token", ale.RefreshToken)) + attrs = append(attrs, slog.Bool("has_refresh_token", true)) } if ale.SessionJwt != "" { - attrs = append(attrs, slog.String("session_jwt", ale.SessionJwt)) + attrs = append(attrs, slog.Bool("has_session_jwt", true)) } if ale.AccessJwt != "" { - attrs = append(attrs, slog.String("access_jwt", ale.AccessJwt)) + attrs = append(attrs, slog.Bool("has_access_jwt", true)) } if ale.UserLoginId != "" { attrs = append(attrs, slog.String("user_login_id", ale.UserLoginId)) @@ -175,7 +172,9 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) { } if ale.SetCookieName != "" { attrs = append(attrs, slog.String("set_cookie_name", ale.SetCookieName)) - attrs = append(attrs, slog.String("set_cookie_value", ale.SetCookieValue)) + if ale.SetCookieValue != "" { + attrs = append(attrs, slog.Bool("has_set_cookie_value", true)) + } if len(ale.SetCookieAttrs) > 0 { cookieAttrsGroupArgs := make([]any, 0, len(ale.SetCookieAttrs)) for k, v := range ale.SetCookieAttrs { @@ -191,13 +190,7 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) { attrs = append(attrs, slog.Bool("has_cookie_DSRF", ale.HasCookieDSRF)) } if ale.ParsedCookieDSRF != "" { - attrs = append(attrs, slog.String("parsed_cookie_DSRF", ale.ParsedCookieDSRF)) - } - if ale.RequestBody != "" { - attrs = append(attrs, slog.String("request_body", ale.RequestBody)) - } - if ale.NewPassword != "" { // FOR TEST ONLY - DO NOT LOG IN PRODUCTION - attrs = append(attrs, slog.String("new_password", ale.NewPassword)) + attrs = append(attrs, slog.Bool("has_parsed_cookie_DSRF", true)) } // Convert variadic args to slog.Attr before appending @@ -212,3 +205,36 @@ func (ale *AuditLogEntry) Log(level slog.Level, msg string, args ...any) { slog.Default().LogAttrs(context.Background(), level, msg, attrs...) } + +var sensitiveAuditKeys = map[string]struct{}{ + "password": {}, + "currentpassword": {}, + "newpassword": {}, + "oldpassword": {}, + "token": {}, + "accesstoken": {}, + "refreshtoken": {}, + "authorization": {}, + "cookie": {}, + "setcookie": {}, + "verificationcode": {}, + "code": {}, + "loginchallenge": {}, + "loginverifier": {}, + "sessionjwt": {}, + "accessjwt": {}, + "refreshjwt": {}, +} + +func maskSensitiveByKey(key, value string) string { + if value == "" { + return value + } + k := strings.ToLower(key) + k = strings.ReplaceAll(k, "-", "") + k = strings.ReplaceAll(k, "_", "") + if _, ok := sensitiveAuditKeys[k]; ok { + return "*****" + } + return value +} diff --git a/backend/internal/logger/audit_logger_test.go b/backend/internal/logger/audit_logger_test.go new file mode 100644 index 00000000..5f9caac8 --- /dev/null +++ b/backend/internal/logger/audit_logger_test.go @@ -0,0 +1,80 @@ +package logger + +import ( + "bytes" + "encoding/json" + "log/slog" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAuditLogEntry_RedactsSensitiveFields(t *testing.T) { + buf := &bytes.Buffer{} + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(buf, nil))) + defer slog.SetDefault(previous) + + ale := &AuditLogEntry{ + RequestID: "req-1", + Stage: "login", + Token: "tok-secret", + RefreshToken: "refresh-secret", + SessionJwt: "session-secret", + AccessJwt: "access-secret", + SetCookieName: "sid", + SetCookieValue: "cookie-secret", + ParsedCookieDSRF: "dsrf-secret", + LoginIDs: map[string]string{ + "loginId": "user@example.com", + }, + Query: map[string]string{ + "token": "query-token", + "locale": "ko", + }, + Headers: map[string]string{ + "Authorization": "Bearer secret", + "Cookie": "session=secret", + }, + } + + ale.Log(slog.LevelInfo, "test") + + line := strings.TrimSpace(buf.String()) + require.NotEmpty(t, line) + + var payload map[string]any + require.NoError(t, json.Unmarshal([]byte(line), &payload)) + + assert.NotContains(t, payload, "token") + assert.NotContains(t, payload, "refresh_token") + assert.NotContains(t, payload, "session_jwt") + assert.NotContains(t, payload, "access_jwt") + assert.NotContains(t, payload, "set_cookie_value") + assert.NotContains(t, payload, "parsed_cookie_DSRF") + assert.NotContains(t, payload, "request_body") + assert.NotContains(t, payload, "new_password") + + assert.Equal(t, true, payload["has_token"]) + assert.Equal(t, true, payload["has_refresh_token"]) + assert.Equal(t, true, payload["has_session_jwt"]) + assert.Equal(t, true, payload["has_access_jwt"]) + assert.Equal(t, true, payload["has_set_cookie_value"]) + assert.Equal(t, true, payload["has_parsed_cookie_DSRF"]) + + loginIDs, ok := payload["login_ids"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "user@example.com", loginIDs["loginId"]) + + query, ok := payload["query"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "*****", query["token"]) + assert.Equal(t, "ko", query["locale"]) + + headers, ok := payload["headers"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "*****", headers["Authorization"]) + assert.Equal(t, "*****", headers["Cookie"]) +} diff --git a/devfront/package-lock.json b/devfront/package-lock.json index 54d01aab..934b31ac 100644 --- a/devfront/package-lock.json +++ b/devfront/package-lock.json @@ -56,9 +56,9 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", "dependencies": { @@ -71,9 +71,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", - "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", "engines": { @@ -81,21 +81,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", + "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -112,14 +112,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", - "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -242,13 +242,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -305,18 +305,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", - "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.6", + "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -324,9 +324,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { @@ -661,13 +661,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.58.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz", - "integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.58.0" + "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" @@ -1363,9 +1363,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.53", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", - "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", "dev": true, "license": "MIT" }, @@ -1380,9 +1380,9 @@ } }, "node_modules/@tanstack/query-devtools": { - "version": "5.92.0", - "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.92.0.tgz", - "integrity": "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ==", + "version": "5.93.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.93.0.tgz", + "integrity": "sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg==", "license": "MIT", "funding": { "type": "github", @@ -1390,9 +1390,9 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.20", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz", - "integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==", + "version": "5.90.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", + "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", "license": "MIT", "dependencies": { "@tanstack/query-core": "5.90.20" @@ -1406,19 +1406,19 @@ } }, "node_modules/@tanstack/react-query-devtools": { - "version": "5.91.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.2.tgz", - "integrity": "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg==", + "version": "5.91.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.3.tgz", + "integrity": "sha512-nlahjMtd/J1h7IzOOfqeyDh5LNfG0eULwlltPEonYy0QL+nqrBB+nyzJfULV+moL7sZyxc2sHdNJki+vLA9BSA==", "license": "MIT", "dependencies": { - "@tanstack/query-devtools": "5.92.0" + "@tanstack/query-devtools": "5.93.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-query": "^5.90.14", + "@tanstack/react-query": "^5.90.20", "react": "^18 || ^19" } }, @@ -1479,9 +1479,9 @@ } }, "node_modules/@types/node": { - "version": "24.10.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", - "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", + "version": "24.10.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", + "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "dev": true, "license": "MIT", "dependencies": { @@ -1489,9 +1489,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.9", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", - "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1509,16 +1509,16 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", - "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.5", + "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.53", + "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, @@ -1550,19 +1550,6 @@ "node": ">= 8" } }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -1577,9 +1564,9 @@ "license": "MIT" }, "node_modules/autoprefixer": { - "version": "10.4.23", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", - "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", "dev": true, "funding": [ { @@ -1598,7 +1585,7 @@ "license": "MIT", "dependencies": { "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001760", + "caniuse-lite": "^1.0.30001766", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -1614,24 +1601,27 @@ } }, "node_modules/axios": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.3.tgz", - "integrity": "sha512-ERT8kdX7DZjtUm7IitEyV7InTHAF42iJuMArIiDIV5YtPanJkgw4hw5Dyg9fh0mihdWNn1GKaeIWErfe56UQ1g==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.18", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz", - "integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/binary-extensions": { @@ -1718,9 +1708,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001766", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz", - "integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==", + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", "dev": true, "funding": [ { @@ -1912,9 +1902,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.278", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.278.tgz", - "integrity": "sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw==", + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", "dev": true, "license": "ISC" }, @@ -2013,24 +2003,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2095,9 +2067,9 @@ } }, "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2676,19 +2648,6 @@ "node": ">=8.6" } }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -2812,13 +2771,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=8.6" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -2845,13 +2804,13 @@ } }, "node_modules/playwright": { - "version": "1.58.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz", - "integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.0" + "playwright-core": "1.58.2" }, "bin": { "playwright": "cli.js" @@ -2864,9 +2823,9 @@ } }, "node_modules/playwright-core": { - "version": "1.58.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz", - "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2876,21 +2835,6 @@ "node": ">=18" } }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -3082,30 +3026,30 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", - "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.3" + "react": "^19.2.4" } }, "node_modules/react-hook-form": { - "version": "7.71.1", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", - "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", + "version": "7.71.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", + "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -3196,19 +3140,6 @@ "node": ">=8.10.0" } }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -3368,9 +3299,9 @@ } }, "node_modules/tailwind-merge": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", - "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", "license": "MIT", "funding": { "type": "github", @@ -3465,6 +3396,37 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3638,6 +3600,52 @@ } } }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/devfront/playwright-report/index.html b/devfront/playwright-report/index.html deleted file mode 100644 index c98a4a9a..00000000 --- a/devfront/playwright-report/index.html +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - Playwright Test Report - - - - -
- - - \ No newline at end of file diff --git a/devfront/src/components/layout/AppLayout.tsx b/devfront/src/components/layout/AppLayout.tsx index e71b625f..e0a1d7bb 100644 --- a/devfront/src/components/layout/AppLayout.tsx +++ b/devfront/src/components/layout/AppLayout.tsx @@ -1,7 +1,7 @@ import { BadgeCheck, LogOut, Moon, ShieldHalf, Sun } from "lucide-react"; import { useEffect, useState } from "react"; import { useAuth } from "react-oidc-context"; -import { NavLink, Outlet } from "react-router-dom"; +import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { t } from "../../lib/i18n"; import LanguageSelector from "../common/LanguageSelector"; import { Toaster } from "../ui/toaster"; @@ -16,11 +16,20 @@ const navItems = [ ]; function AppLayout() { + const auth = useAuth(); + const navigate = useNavigate(); const [theme, setTheme] = useState<"light" | "dark">(() => { const stored = window.localStorage.getItem("admin_theme"); return stored === "dark" ? "dark" : "light"; }); + const handleLogout = () => { + if (window.confirm(t("msg.dev.logout_confirm", "로그아웃 하시겠습니까?"))) { + auth.removeUser(); + navigate("/login"); + } + }; + useEffect(() => { const root = document.documentElement; root.classList.remove("light", "dark"); @@ -38,60 +47,73 @@ function AppLayout() { return (
-