From 39594f8e2111f994c731887e28612986d55fee43 Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Wed, 28 Jan 2026 17:30:23 +0900 Subject: [PATCH 1/8] README update --- README.md | 105 +- .../src/features/audit/AuditLogsPage.tsx | 938 ++++++++++-------- compose.ory.yaml | 38 +- docker/ory/clickhouse/init.sql | 22 + docker/ory/oathkeeper/entrypoint.sh | 22 + docker/ory/oathkeeper/oathkeeper.yml | 6 +- docker/ory/oathkeeper/rules.draft.json | 112 +++ docker/ory/oathkeeper/rules.json | 93 +- docker/ory/oathkeeper/rules.prod.json | 92 ++ docker/ory/oathkeeper/rules.stage.json | 92 ++ docker/ory/vector/vector.toml | 52 + 11 files changed, 1127 insertions(+), 445 deletions(-) create mode 100644 docker/ory/clickhouse/init.sql create mode 100755 docker/ory/oathkeeper/entrypoint.sh create mode 100644 docker/ory/oathkeeper/rules.draft.json create mode 100644 docker/ory/oathkeeper/rules.prod.json create mode 100644 docker/ory/oathkeeper/rules.stage.json create mode 100644 docker/ory/vector/vector.toml diff --git a/README.md b/README.md index bcf64e05..560e9604 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,48 @@ # Baron SSO -**Baron SSO**는 화이트 라벨링된 사용자 인증 허브이자 통합 런처입니다. -**Descope**를 활용하여 안전한 비밀번호 없는 인증(Enchanted Link)을 제공하며, Flutter로 구현된 커스텀 UI를 통해 매끄러운 사용자 경험을 보장합니다. Backend는 Go (Fiber)와 ClickHouse를 사용하여 대용량 감사 로그(Audit Log)를 관리합니다. +**Baron 통합로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다. + +* Ory Stack으로 모든 구성요소를 self-hosting 합니다. +* Backend는 Go (Fiber)로 구성된 Ory Stack의 유일한 Command 전송 포인트입니다. 모든 Command는 ClickHouse로 강제 전송되며 Audit Log 시스템을 구성합니다. +* Front는 Backend를 통해서만 연동하며 자체가 Ory Stack의 RP기도 합니다. 크게 3개 계층으로 분리됩니다. + * UserFront: Flutter로 구현된 커스텀 UI를 통해 매끄러운 사용자 경험을 보장합니다. + * 로그인 : 비밀번호, SMS, QR 스캔 등의 수단으로 로그인 가능 + * 향후 모바일 앱 지원으로 인증 Push 승인 등 MFA 확장 (예정) + * 정보변경, 앱 연동 이력 확인 등 개인화 기능 + * 사용 가능한 앱 리스트 (예정) + * AdminFront: 사용자 관리 등 Admin 기능 + * DevFront: RP 관리 등 개발자 기능 ## 🏗 아키텍처 (Architecture) ### 0. Ory Stack - Ory Kratos: 사용자 인증/계정 관리(Identity). -- Kratos Selfservice UI: Kratos 셀프서비스 플로우 UI. - Ory Hydra: OAuth2/OIDC 발급 및 토큰 관리. - Ory Keto: 권한/정책 기반 접근 제어. - Oathkeeper: 인증/인가 프록시 및 라우팅 게이트웨이. +```mermaid +flowchart + subgraph Edge + OK["Oathkeeper
(Only Public Entry)"] + end + + subgraph App + BE["Backend
(Only Upstream)"] + end + + subgraph OryStack + KR[Kratos] + HY[Hydra] + KE[Keto] + KR --- HY --- KE + end + BE -->|Command| OryStack + OK -->|Query| KR + OK -->|Query| HY + OK -->|Query| KE +``` + ### 1. Backend (Go Fiber) - **Language**: Go 1.25+ - **Framework**: Fiber v2.25+ @@ -23,7 +54,7 @@ - `POST /api/v1/audit`: 감사 로그 수집 API - userfront가 바라보는 backend -### 2. userfront(Flutter Web/App) +### 2. UserFront(Flutter Web/App) - **Framework**: Flutter 3.32+ - **Key Packages**: `flutter_riverpod`, `go_router` - **Features**: @@ -36,7 +67,7 @@ - 앱 별 사용량(호출량) 등 통계 - 핵심 Audit 대상 -### 4. devfront(Web) - 향후 분리 예정 +### 4. devfront(Web) - **Framework**: Vite, React 19+, Shadcn/ui 등 - **Features**: - RP 등록 및 관리 @@ -54,19 +85,61 @@ ### 전체 연결 구조도 ```mermaid -flowchart LR - AF[adminfront] -->|"OIDC authorize/token (PKCE)"| HY["Hydra (OIDC 엔진)"] - DF[devfront] -->|"OIDC authorize/token (PKCE)"| HY - UF["userfront (Login/Consent UI)"] <-->|Hydra login/consent redirect| HY - UF -->|Kratos Browser Flow| KR["Kratos (SoT: identities/traits)"] - KR -->|subject=identity.id| HY - HY -->|ID/Access Token| RP[Relying Party Apps] - MG["Magic Link Wrapper (Fiber)"] -->|1회용 토큰→Kratos CreateSession| KR - MG -->|"Hydra Login Accept (옵션)"| HY - DS["Descope/Social IDP"] -->|Kratos Social/OIDC| KR +flowchart TD + subgraph Clients ["External Clients"] + AF[adminfront] + DF[devfront] + UF["userfront"] + DS["일반SW"] + end + + subgraph AppService ["Control Plane"] + BE["Backend (Command/Audit Controller)"] + end + + subgraph OryBundle ["Ory Deployment Stack"] + direction TB + OK["Oathkeeper (Public Proxy/OIDC)"] + + subgraph OryEngines ["Ory Services"] + direction LR + HY["Hydra"] + KR["Kratos"] + KE["Keto"] + end + + ICH[(Internal Clickhouse)] + + %% Internal Flow within Bundle + OK -->|Routing/Queries| OryEngines + OK -.->|Access/Usage Log| ICH + end + + subgraph AuditDB ["Audit Storage"] + ECH[(External Clickhouse)] + end + + %% Key Command Path + AF & DF & UF & DS ==>|Actions / Commands| BE + + %% Backend Responsibilities + BE -->|Admin/State Control| OryEngines + BE -.->|Mandatory Audit Log| ECH + + %% Connection Note (Hidden flow mentioned in logic) + %% OK is technically the entry for OIDC, but removed as per request + + %% Styles + style OryBundle fill:#f8f9fa,stroke:#333,stroke-width:2px + style BE fill:#bbf,stroke:#333,stroke-width:2px + style ECH fill:#fdd,stroke:#333 + style ICH fill:#dfd,stroke:#333 + style OK fill:#f9f,stroke:#333 + style OryEngines fill:#fff,stroke:#999,stroke-dasharray: 5 5 ``` -Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. Magic Link는 Fiber 래퍼가 Kratos 세션을 만들고 필요 시 Hydra Login Accept를 수행합니다. Descope 등 보조 IDP는 Kratos Social/OIDC provider로 연결합니다. + +Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. 비지니스로직은 Backend를 통해서, 기본 인증 로직은 Ory Stack을 통해 진행됩니다. --- diff --git a/adminfront/src/features/audit/AuditLogsPage.tsx b/adminfront/src/features/audit/AuditLogsPage.tsx index a504554a..5bb32f46 100644 --- a/adminfront/src/features/audit/AuditLogsPage.tsx +++ b/adminfront/src/features/audit/AuditLogsPage.tsx @@ -1,460 +1,562 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { - ChevronDown, - ChevronUp, - Copy, - ListChecks, - RefreshCw, - Search, - Terminal, + ChevronDown, + ChevronUp, + Copy, + ListChecks, + RefreshCw, + Search, + Terminal, } from "lucide-react"; import * as React from "react"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, } from "../../components/ui/card"; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, } from "../../components/ui/table"; import type { AuditLog } from "../../lib/adminApi"; import { fetchAuditLogs } from "../../lib/adminApi"; const defaultAuditFilters = [ - "method:POST path:/api/v1/*", - "status:failure", - "latency_ms:>1000", + "method:POST path:/api/v1/*", + "status:failure", + "latency_ms:>1000", ]; type AuditDetails = { - request_id?: string; - method?: string; - path?: string; - status?: number; - latency_ms?: number; - error?: string; - tenant_id?: string; - actor_id?: string; - action?: string; - target?: string; - before?: unknown; - after?: unknown; + request_id?: string; + method?: string; + path?: string; + status?: number; + latency_ms?: number; + error?: string; + tenant_id?: string; + actor_id?: string; + action?: string; + target?: string; + before?: unknown; + after?: unknown; }; function parseDetails(details?: string): AuditDetails { - if (!details) { - return {}; - } - try { - const parsed = JSON.parse(details); - if (parsed && typeof parsed === "object") { - return parsed as AuditDetails; + if (!details) { + return {}; } - } catch {} - return {}; + try { + const parsed = JSON.parse(details); + if (parsed && typeof parsed === "object") { + return parsed as AuditDetails; + } + } catch {} + return {}; } function formatCellValue(value: unknown) { - if (value === null || value === undefined || value === "") { - return "-"; - } - if (typeof value === "string") { - return value; - } - try { - return JSON.stringify(value); - } catch { - return String(value); - } + if (value === null || value === undefined || value === "") { + return "-"; + } + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } } function formatIsoDateTime(value: string) { - if (!value) { - return { date: "-", time: "-" }; - } - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) { - return { date: value, time: "-" }; - } - const date = parsed.toISOString().slice(0, 10); - const time = parsed.toLocaleTimeString("ko-KR", { hour12: false }); - return { date, time }; + if (!value) { + return { date: "-", time: "-" }; + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return { date: value, time: "-" }; + } + const date = parsed.toISOString().slice(0, 10); + const time = parsed.toLocaleTimeString("ko-KR", { hour12: false }); + return { date, time }; } function AuditLogsPage() { - const [filters, setFilters] = React.useState(defaultAuditFilters); - const [filterDraft, setFilterDraft] = React.useState(""); - const [expandedRows, setExpandedRows] = React.useState< - Record - >({}); + const [filters, setFilters] = React.useState(defaultAuditFilters); + const [filterDraft, setFilterDraft] = React.useState(""); + const [expandedRows, setExpandedRows] = React.useState< + Record + >({}); - const handleCopy = (value: string) => { - if (!value) { - return; + const handleCopy = (value: string) => { + if (!value) { + return; + } + navigator.clipboard.writeText(value); + }; + const { + data, + isLoading, + error, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isFetching, + refetch, + } = useInfiniteQuery({ + queryKey: ["audit-logs"], + queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.next_cursor || undefined, + }); + + const logs = + data?.pages?.flatMap( + (page) => + page?.items?.filter((item): item is AuditLog => + Boolean(item), + ) ?? [], + ) ?? []; + + const handleAddFilter = () => { + const trimmed = filterDraft.trim(); + if (!trimmed) { + return; + } + setFilters((prev) => + prev.includes(trimmed) ? prev : [...prev, trimmed], + ); + setFilterDraft(""); + }; + + if (isLoading) { + return
Loading audit logs...
; } - navigator.clipboard.writeText(value); - }; - const { - data, - isLoading, - error, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isFetching, - refetch, - } = useInfiniteQuery({ - queryKey: ["audit-logs"], - queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam), - initialPageParam: undefined as string | undefined, - getNextPageParam: (lastPage) => lastPage.next_cursor || undefined, - }); - const logs = - data?.pages?.flatMap((page) => - page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [], - ) ?? []; - - const handleAddFilter = () => { - const trimmed = filterDraft.trim(); - if (!trimmed) { - return; + if (error) { + const errMsg = + (error as AxiosError<{ error?: string }>).response?.data?.error ?? + (error as Error).message; + return ( +
+ Error loading logs: {errMsg} +
+ ); } - setFilters((prev) => (prev.includes(trimmed) ? prev : [...prev, trimmed])); - setFilterDraft(""); - }; - if (isLoading) { - return
Loading audit logs...
; - } - - if (error) { - const errMsg = - (error as AxiosError<{ error?: string }>).response?.data?.error ?? - (error as Error).message; return ( -
- Error loading logs: {errMsg} -
- ); - } - - return ( -
-
-
-
- Audit - / - Logs -
-

감사 로그

-

- Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 - 추후 세션 연동 시 자동 채워집니다. -

-
-
- - -
-
- -
- - -
- Audit registry - 로드된 로그 {logs.length}건 -
- Command only -
- -
-
- - setFilterDraft(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - handleAddFilter(); - } - }} - placeholder="필터 추가 (예: status:failure)" - className="w-full bg-transparent text-sm text-foreground outline-none" - /> - -
- {filters.length === 0 ? ( - - 필터 없음 - - ) : ( - filters.map((filter) => ( - - - {filter} - - - )) - )} -
- - - - TIME - ACTOR (ID) - REQUEST - PATH - STATUS - Action / Target - - - - - {isLoading && ( - - 로딩 중... - - )} - {!isLoading && logs.length === 0 && ( - - - 아직 수집된 감사 로그가 없습니다. - - - )} - {logs.map((row, index) => { - const details = parseDetails(row.details); - const actionLabel = - details.action || - (details.method && details.path - ? `${details.method} ${details.path}` - : row.event_type); - const rowKey = `${row.event_id}-${row.timestamp}-${index}`; - const isExpanded = Boolean(expandedRows[rowKey]); - return ( - - - - {(() => { - const { date, time } = formatIsoDateTime( - row.timestamp, - ); - return ( -
-
{date}
-
{time}
-
- ); - })()} -
- -
- - {row.user_id || details.actor_id || "-"} - - {(row.user_id || details.actor_id) && ( - - )} -
-
- -
- - {formatCellValue(details.request_id)} - - {details.request_id && ( - - )} -
-
- -
- {formatCellValue(details.method)} -
-
- {formatCellValue(details.path)} -
-
- - - {row.status} - - - -
- {actionLabel} -
- {details.target && ( -
- - Target · {details.target} - - -
- )} -
- - - -
- {isExpanded && ( - - -
-
-
- Request -
-
- Request ID · {formatCellValue(details.request_id)} -
-
- Event ID · {formatCellValue(row.event_id)} -
-
IP · {formatCellValue(row.ip_address)}
-
- Latency ·{" "} - {details.latency_ms !== undefined - ? `${details.latency_ms}ms` - : "-"} -
-
-
-
- Actor -
-
- Actor ID · {row.user_id || details.actor_id || "-"} -
-
Tenant · {formatCellValue(details.tenant_id)}
-
Device · {formatCellValue(row.device_id)}
-
-
-
- Result -
-
- Error · {formatCellValue(details.error)} -
-
- Before · {formatCellValue(details.before)} -
-
- After · {formatCellValue(details.after)} -
-
-
-
-
- )} -
- ); - })} -
-
-
- {hasNextPage ? ( - - ) : ( - - End of audit feed - - )} -
-
-
+ + 새로고침 + + +
+ -
- - ); +
+ + +
+ Audit registry + + 로드된 로그 {logs.length}건 + +
+ Command only +
+ +
+
+ + + setFilterDraft(event.target.value) + } + onKeyDown={(event) => { + if (event.key === "Enter") { + handleAddFilter(); + } + }} + placeholder="필터 추가 (예: status:failure)" + className="w-full bg-transparent text-sm text-foreground outline-none" + /> + +
+ {filters.length === 0 ? ( + + 필터 없음 + + ) : ( + filters.map((filter) => ( + + + {filter} + + + )) + )} +
+ + + + + TIME + + + ACTOR (ID) + + REQUEST + PATH + + STATUS + + Action / Target + + + + + {isLoading && ( + + + 로딩 중... + + + )} + {!isLoading && logs.length === 0 && ( + + + 아직 수집된 감사 로그가 없습니다. + + + )} + {logs.map((row, index) => { + const details = parseDetails(row.details); + const actionLabel = + details.action || + (details.method && details.path + ? `${details.method} ${details.path}` + : row.event_type); + const rowKey = `${row.event_id}-${row.timestamp}-${index}`; + const isExpanded = Boolean( + expandedRows[rowKey], + ); + return ( + + + + {(() => { + const { date, time } = + formatIsoDateTime( + row.timestamp, + ); + return ( +
+
+ {date} +
+
+ {time} +
+
+ ); + })()} +
+ +
+ + {row.user_id || + details.actor_id || + "-"} + + {(row.user_id || + details.actor_id) && ( + + )} +
+
+ +
+ + {formatCellValue( + details.request_id, + )} + + {details.request_id && ( + + )} +
+
+ +
+ {formatCellValue( + details.method, + )} +
+
+ {formatCellValue( + details.path, + )} +
+
+ + + {row.status} + + + +
+ {actionLabel} +
+ {details.target && ( +
+ + Target ·{" "} + {details.target} + + +
+ )} +
+ + + +
+ {isExpanded && ( + + +
+
+
+ Request +
+
+ Request ID ·{" "} + {formatCellValue( + details.request_id, + )} +
+
+ Event ID ·{" "} + {formatCellValue( + row.event_id, + )} +
+
+ IP ·{" "} + {formatCellValue( + row.ip_address, + )} +
+
+ Latency ·{" "} + {details.latency_ms !== + undefined + ? `${details.latency_ms}ms` + : "-"} +
+
+
+
+ Actor +
+
+ Actor ID ·{" "} + {row.user_id || + details.actor_id || + "-"} +
+
+ Tenant ·{" "} + {formatCellValue( + details.tenant_id, + )} +
+
+ Device ·{" "} + {formatCellValue( + row.device_id, + )} +
+
+
+
+ Result +
+
+ Error ·{" "} + {formatCellValue( + details.error, + )} +
+
+ Before ·{" "} + {formatCellValue( + details.before, + )} +
+
+ After ·{" "} + {formatCellValue( + details.after, + )} +
+
+
+
+
+ )} +
+ ); + })} +
+
+
+ {hasNextPage ? ( + + ) : ( + + End of audit feed + + )} +
+
+
+
+ + ); } export default AuditLogsPage; diff --git a/compose.ory.yaml b/compose.ory.yaml index 3baa589f..7d92a4fd 100644 --- a/compose.ory.yaml +++ b/compose.ory.yaml @@ -90,8 +90,6 @@ services: kratos-ui: image: oryd/kratos-selfservice-ui-node:${KRATOS_UI_NODE_VERSION:-v25.4.0} container_name: ory_kratos_ui - ports: - - "${KRATOS_UI_PORT:-4455}:4455" environment: - KRATOS_PUBLIC_URL=${KRATOS_PUBLIC_URL:-http://kratos:4433/} - KRATOS_BROWSER_URL=${KRATOS_BROWSER_URL:-http://localhost:${KRATOS_PUBLIC_PORT:-4433}} @@ -119,8 +117,6 @@ services: hydra: image: oryd/hydra:${HYDRA_VERSION:-v25.4.0} container_name: ory_hydra - ports: - - "${HYDRA_PUBLIC_PORT:-4441}:4444" environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB}?sslmode=disable&max_conns=20 - URLS_SELF_ISSUER=${BACKEND_URL:-http://127.0.0.1:3000} @@ -188,9 +184,6 @@ services: keto: image: oryd/keto:${KETO_VERSION:-v25.4.0} container_name: ory_keto - ports: - - "${KETO_READ_PORT:-4466}:4466" - - "${KETO_WRITE_PORT:-4467}:4467" environment: - DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB}?sslmode=disable&max_conns=20 volumes: @@ -207,13 +200,39 @@ services: image: oryd/oathkeeper:v0.40.6 container_name: ory_oathkeeper ports: - - "4456:4456" # API - "4457:4455" # Proxy environment: - LOG_LEVEL=debug + - APP_ENV=${APP_ENV:-development} volumes: - ./docker/ory/oathkeeper:/etc/config/oathkeeper - command: serve proxy -c /etc/config/oathkeeper/oathkeeper.yml + - ./docker/ory/oathkeeper/logs:/var/log/oathkeeper + command: ["/etc/config/oathkeeper/entrypoint.sh"] + networks: + - ory-net + + ory_clickhouse: + image: clickhouse/clickhouse-server:latest + container_name: ory_clickhouse + environment: + - CLICKHOUSE_USER=${ORY_CLICKHOUSE_USER:-ory} + - CLICKHOUSE_PASSWORD=${ORY_CLICKHOUSE_PASSWORD:-orypass} + volumes: + - ory_clickhouse_data:/var/lib/clickhouse + - ./docker/ory/clickhouse:/docker-entrypoint-initdb.d + networks: + - ory-net + + ory_vector: + image: timberio/vector:0.36.0-alpine + container_name: ory_vector + volumes: + - ./docker/ory/vector:/etc/vector + - ./docker/ory/oathkeeper/logs:/var/log/oathkeeper + command: ["-c", "/etc/vector/vector.toml"] + depends_on: + - oathkeeper + - ory_clickhouse networks: - ory-net @@ -268,6 +287,7 @@ services: volumes: ory_postgres_data: + ory_clickhouse_data: networks: ory-net: diff --git a/docker/ory/clickhouse/init.sql b/docker/ory/clickhouse/init.sql new file mode 100644 index 00000000..37dc8d1e --- /dev/null +++ b/docker/ory/clickhouse/init.sql @@ -0,0 +1,22 @@ +CREATE DATABASE IF NOT EXISTS ory; + +CREATE TABLE IF NOT EXISTS ory.oathkeeper_access_logs ( + timestamp DateTime64(3) DEFAULT now64(3), + request_id String DEFAULT '', + method String DEFAULT '', + path String DEFAULT '', + status UInt16 DEFAULT 0, + latency_ms UInt32 DEFAULT 0, + rp String DEFAULT '', + action String DEFAULT '', + target String DEFAULT '', + subject String DEFAULT '', + client_ip String DEFAULT '', + user_agent String DEFAULT '', + decision String DEFAULT '', + trace_id String DEFAULT '', + span_id String DEFAULT '', + raw String DEFAULT '' +) ENGINE = MergeTree() +ORDER BY (timestamp, request_id) +TTL timestamp + INTERVAL 30 DAY; diff --git a/docker/ory/oathkeeper/entrypoint.sh b/docker/ory/oathkeeper/entrypoint.sh new file mode 100755 index 00000000..99174eb6 --- /dev/null +++ b/docker/ory/oathkeeper/entrypoint.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env sh +set -eu + +APP_ENV_VALUE="${APP_ENV:-}" + +case "$APP_ENV_VALUE" in + production|prod) + RULES_FILE="/etc/config/oathkeeper/rules.prod.json" + ;; + stage|staging) + RULES_FILE="/etc/config/oathkeeper/rules.stage.json" + ;; + *) + RULES_FILE="/etc/config/oathkeeper/rules.json" + ;; + esac + +export RULES_FILE + +echo "[oathkeeper] APP_ENV=$APP_ENV_VALUE rules=$RULES_FILE" + +exec /bin/sh -c "oathkeeper serve proxy -c /etc/config/oathkeeper/oathkeeper.yml 2>&1 | tee /var/log/oathkeeper/access.log" diff --git a/docker/ory/oathkeeper/oathkeeper.yml b/docker/ory/oathkeeper/oathkeeper.yml index 044d7f21..cb3b787b 100644 --- a/docker/ory/oathkeeper/oathkeeper.yml +++ b/docker/ory/oathkeeper/oathkeeper.yml @@ -4,13 +4,17 @@ serve: api: port: 4456 +log: + level: info + format: json + errors: fallback: - json access_rules: repositories: - - file:///etc/config/oathkeeper/rules.json + - file://${RULES_FILE:-/etc/config/oathkeeper/rules.json} authenticators: noop: diff --git a/docker/ory/oathkeeper/rules.draft.json b/docker/ory/oathkeeper/rules.draft.json new file mode 100644 index 00000000..835689ec --- /dev/null +++ b/docker/ory/oathkeeper/rules.draft.json @@ -0,0 +1,112 @@ +[ + { + "id": "public-health", + "description": "공개 헬스체크 (TODO: 도메인 제한)", + "match": { + "url": "http://<.*>/health", + "methods": ["GET"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "public-auth", + "description": "인증/회원가입 등 공개 엔드포인트", + "match": { + "url": "http://<.*>/api/v1/auth/<.*>", + "methods": ["GET", "POST", "OPTIONS"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "backend-command", + "description": "Command 요청은 Backend로 전달 (Audit 강제)", + "match": { + "url": "http://<.*>/api/v1/<.*>", + "methods": ["POST", "PUT", "PATCH", "DELETE"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "cookie_session" } + ], + "authorizer": { "handler": "remote_json" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "backend-query", + "description": "Backend Query (admin/dev 포함)", + "match": { + "url": "http://<.*>/api/v1/<.*>", + "methods": ["GET"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "cookie_session" } + ], + "authorizer": { "handler": "remote_json" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "kratos-public", + "description": "Kratos Public API를 /kratos로 노출", + "match": { + "url": "http://<.*>/kratos/<.*>", + "methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] + }, + "upstream": { + "url": "http://kratos:4433", + "strip_path": "/kratos" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "hydra-public", + "description": "Hydra Public API를 /hydra로 노출", + "match": { + "url": "http://<.*>/hydra/<.*>", + "methods": ["GET", "POST", "OPTIONS"] + }, + "upstream": { + "url": "http://hydra:4444", + "strip_path": "/hydra" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + } +] diff --git a/docker/ory/oathkeeper/rules.json b/docker/ory/oathkeeper/rules.json index 0637a088..e02c3382 100644 --- a/docker/ory/oathkeeper/rules.json +++ b/docker/ory/oathkeeper/rules.json @@ -1 +1,92 @@ -[] \ No newline at end of file +[ + { + "id": "public-health", + "description": "공개 헬스체크", + "match": { + "url": "http://<.*>/health", + "methods": ["GET"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "public-preflight", + "description": "CORS preflight", + "match": { + "url": "http://<.*>/api/v1/<.*>", + "methods": ["OPTIONS"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "public-auth", + "description": "인증/회원가입 등 공개 엔드포인트", + "match": { + "url": "http://<.*>/api/v1/auth/<.*>", + "methods": ["GET", "POST", "OPTIONS"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "backend-command", + "description": "Command 요청은 Backend로 전달 (Audit 강제)", + "match": { + "url": "http://<.*>/api/v1/<.*>", + "methods": ["POST", "PUT", "PATCH", "DELETE"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "cookie_session" } + ], + "authorizer": { "handler": "remote_json" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "backend-query", + "description": "Backend Query (admin/dev 포함)", + "match": { + "url": "http://<.*>/api/v1/<.*>", + "methods": ["GET"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "cookie_session" } + ], + "authorizer": { "handler": "remote_json" }, + "mutators": [ + { "handler": "noop" } + ] + } +] diff --git a/docker/ory/oathkeeper/rules.prod.json b/docker/ory/oathkeeper/rules.prod.json new file mode 100644 index 00000000..b84e202c --- /dev/null +++ b/docker/ory/oathkeeper/rules.prod.json @@ -0,0 +1,92 @@ +[ + { + "id": "public-health", + "description": "공개 헬스체크 (PROD 도메인)", + "match": { + "url": "https://auth.brsw.kr/health", + "methods": ["GET"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "public-preflight", + "description": "CORS preflight (PROD 도메인)", + "match": { + "url": "https://auth.brsw.kr/api/v1/<.*>", + "methods": ["OPTIONS"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "public-auth", + "description": "인증/회원가입 등 공개 엔드포인트 (PROD 도메인)", + "match": { + "url": "https://auth.brsw.kr/api/v1/auth/<.*>", + "methods": ["GET", "POST", "OPTIONS"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "backend-command", + "description": "Command 요청은 Backend로 전달 (Audit 강제)", + "match": { + "url": "https://auth.brsw.kr/api/v1/<.*>", + "methods": ["POST", "PUT", "PATCH", "DELETE"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "cookie_session" } + ], + "authorizer": { "handler": "remote_json" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "backend-query", + "description": "Backend Query (admin/dev 포함)", + "match": { + "url": "https://auth.brsw.kr/api/v1/<.*>", + "methods": ["GET"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "cookie_session" } + ], + "authorizer": { "handler": "remote_json" }, + "mutators": [ + { "handler": "noop" } + ] + } +] diff --git a/docker/ory/oathkeeper/rules.stage.json b/docker/ory/oathkeeper/rules.stage.json new file mode 100644 index 00000000..3dabd9a0 --- /dev/null +++ b/docker/ory/oathkeeper/rules.stage.json @@ -0,0 +1,92 @@ +[ + { + "id": "public-health", + "description": "공개 헬스체크 (STAGE 도메인)", + "match": { + "url": "https://sso.hmac.kr/health", + "methods": ["GET"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "public-preflight", + "description": "CORS preflight (STAGE 도메인)", + "match": { + "url": "https://sso.hmac.kr/api/v1/<.*>", + "methods": ["OPTIONS"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "public-auth", + "description": "인증/회원가입 등 공개 엔드포인트 (STAGE 도메인)", + "match": { + "url": "https://sso.hmac.kr/api/v1/auth/<.*>", + "methods": ["GET", "POST", "OPTIONS"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "backend-command", + "description": "Command 요청은 Backend로 전달 (Audit 강제)", + "match": { + "url": "https://sso.hmac.kr/api/v1/<.*>", + "methods": ["POST", "PUT", "PATCH", "DELETE"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "cookie_session" } + ], + "authorizer": { "handler": "remote_json" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "backend-query", + "description": "Backend Query (admin/dev 포함)", + "match": { + "url": "https://sso.hmac.kr/api/v1/<.*>", + "methods": ["GET"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "cookie_session" } + ], + "authorizer": { "handler": "remote_json" }, + "mutators": [ + { "handler": "noop" } + ] + } +] diff --git a/docker/ory/vector/vector.toml b/docker/ory/vector/vector.toml new file mode 100644 index 00000000..c237bece --- /dev/null +++ b/docker/ory/vector/vector.toml @@ -0,0 +1,52 @@ +[sources.oathkeeper_file] + type = "file" + include = ["/var/log/oathkeeper/access.log"] + read_from = "beginning" + +[transforms.oathkeeper_parse] + type = "remap" + inputs = ["oathkeeper_file"] + source = ''' + .raw = .message + parsed = parse_json(.message) ?? {} + + .timestamp = to_timestamp(.timestamp) ?? now() + .request_id = parsed.request_id ?? parsed.req_id ?? "" + request_method = get(parsed, ["request", "method"]) ?? "" + request_path = get(parsed, ["request", "path"]) ?? "" + request_url = get(parsed, ["request", "url"]) ?? "" + .method = parsed.method ?? parsed.http_method ?? request_method ?? "" + .path = parsed.path ?? parsed.http_path ?? request_path ?? request_url ?? "" + response_status = get(parsed, ["response", "status"]) ?? 0 + .status = to_int(parsed.status ?? parsed.status_code ?? response_status ?? 0) ?? 0 + .latency_ms = to_int(parsed.latency_ms ?? parsed.duration_ms ?? parsed.took ?? 0) ?? 0 + identity_id = get(parsed, ["identity", "id"]) ?? "" + .subject = parsed.subject ?? identity_id ?? "" + .client_ip = parsed.client_ip ?? parsed.remote_ip ?? parsed.ip ?? "" + headers = get(parsed, ["headers"]) ?? {} + .user_agent = parsed.user_agent + if is_null(.user_agent) { .user_agent = get(headers, ["User-Agent"]) } + if is_null(.user_agent) { .user_agent = "" } + + .decision = parsed.decision + if is_null(.decision) { .decision = parsed.result } + if is_null(.decision) { .decision = "" } + + .trace_id = parsed.trace_id + if is_null(.trace_id) { .trace_id = "" } + + .span_id = parsed.span_id + if is_null(.span_id) { .span_id = "" } + + .rp = "" + .action = "" + .target = "" + ''' + +[sinks.clickhouse] + type = "clickhouse" + inputs = ["oathkeeper_parse"] + endpoint = "http://ory_clickhouse:8123" + database = "ory" + table = "oathkeeper_access_logs" + compression = "gzip" From ff17259117b5e5ec42ced8ac219b90389d5dbd35 Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Wed, 28 Jan 2026 20:07:52 +0900 Subject: [PATCH 2/8] =?UTF-8?q?oathkeeper=20=EB=8F=99=EC=9E=91=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.sample | 9 ++ README.md | 30 +++--- backend/cmd/server/health_monitor.go | 132 ++++++++++++++++++++++++ backend/cmd/server/main.go | 48 +++++++++ compose.ory.yaml | 55 +--------- docker/ory/oathkeeper/entrypoint.sh | 18 +++- docker/ory/oathkeeper/oathkeeper.yml | 9 +- docker/ory/oathkeeper/rules.active.json | 92 +++++++++++++++++ mcp/compose.mcp.ory.yaml | 43 ++++++++ 9 files changed, 368 insertions(+), 68 deletions(-) create mode 100644 backend/cmd/server/health_monitor.go create mode 100644 docker/ory/oathkeeper/rules.active.json create mode 100644 mcp/compose.mcp.ory.yaml diff --git a/.env.sample b/.env.sample index 2495893c..509bf2e0 100644 --- a/.env.sample +++ b/.env.sample @@ -96,6 +96,15 @@ HYDRA_ADMIN_URL=http://hydra:4445 HYDRA_PUBLIC_URL=http://hydra:4444 JWKS_URL=http://oathkeeper:4456/.well-known/jwks.json +# Oathkeeper 실행 사용자/프로브 설정 +OATHKEEPER_VERSION=v25.4.0 +OATHKEEPER_UID=1001 +OATHKEEPER_GID=1001 +OATHKEEPER_HEALTH_URL=http://oathkeeper:4456/health/ready +OATHKEEPER_HEALTH_INTERVAL_SECONDS=10 +OATHKEEPER_HEALTH_TIMEOUT_SECONDS=2 +OATHKEEPER_HEALTH_ENABLED=true + # Kratos Selfservice UI required secrets (local only) COOKIE_SECRET=localcookie123 CSRF_COOKIE_NAME=__HOST-baronSSO_csrf diff --git a/README.md b/README.md index 560e9604..a21532fa 100644 --- a/README.md +++ b/README.md @@ -207,36 +207,36 @@ docker compose -f compose.ory.yaml --profile mcp up -d hydra-mcp-server kratos-m ``` - MCP 서버는 stdio 기반이라 외부 포트를 열지 않습니다. -- MCP 클라이언트에서 `npx`로 실행하는 설정 예시입니다. -- `hydra-mcp`는 첫 실행 시 캐시 디렉터리에 의존성을 자동 설치합니다(수동 `npm install` 불필요). +- 최초 실행시거나 빌드된 이미지가 없으면 `docker compose -f mcp/compose.mcp.ory.yaml build' 후에 사용 가능합니다 ```toml [mcp_servers.kratos-mcp] -command = "npx" -args = ["-y", "mcp-ory-kratos"] +command = "docker" +args = ["compose", "-f", "mcp/compose.mcp.ory.yaml", "run", "--rm", "--no-deps", "kratos-mcp-server"] [mcp_servers.kratos-mcp.env] -KRATOS_ADMIN_URL = "http://localhost:4434" +KRATOS_ADMIN_URL = "http://kratos:4434" [mcp_servers.hydra-mcp] -command = "npx" -args = ["-y", "/home/lectom/repos/baron-sso/mcp/hydra-mcp"] +command = "docker" +args = ["compose", "-f", "mcp/compose.mcp.ory.yaml", "run", "--rm", "--no-deps", "hydra-mcp-server"] [mcp_servers.hydra-mcp.env] -HYDRA_PUBLIC_URL = "http://localhost:4441" -HYDRA_ADMIN_URL = "http://localhost:4445" +HYDRA_PUBLIC_URL = "http://hydra:4444" +HYDRA_ADMIN_URL = "http://hydra:4445" [mcp_servers.keto-mcp] -command = "npx" -args = ["-y", "/home/lectom/repos/baron-sso/mcp/keto-mcp"] +command = "docker" +args = ["compose", "-f", "mcp/compose.mcp.ory.yaml", "run", "--rm", "--no-deps", "keto-mcp-server"] [mcp_servers.keto-mcp.env] -KETO_READ_URL = "http://localhost:4466" -KETO_WRITE_URL = "http://localhost:4467" +KETO_READ_URL = "http://keto:4466" +KETO_WRITE_URL = "http://keto:4467" ``` ### 로컬 개발 (Manual) -Docker 없이 코드를 수정하며 개발하려면: +Docker 없이는 개발할 수 없지만 Backend 및 [user/admin/dev]Front 코드는 개발모드로 수정하며 개발가능. +백그라운드로 infra 및 ory stack이 구동중이라는 가정 **Backend:** ```bash @@ -292,6 +292,6 @@ baron_sso/ ## 📝 상태 및 로드맵 (Status & Roadmap) - [x] **Phase 1**: 초기 설정 및 아키텍처 설계 (완료) - [x] **Phase 2**: Backend Audit API 구현 (일부 완료) -- [x] **Phase 3**: userfront 로그인 UI 인증 로직 (완료) +- [ ] **Phase 3**: userfront 로그인 UI 인증 로직 (예정) - [ ] **Phase 4**: adminfront 기능 추가 (예정) - [ ] **Phase 5**: 대시보드 및 통합 런처 구현 (예정) diff --git a/backend/cmd/server/health_monitor.go b/backend/cmd/server/health_monitor.go new file mode 100644 index 00000000..ecfe4eb9 --- /dev/null +++ b/backend/cmd/server/health_monitor.go @@ -0,0 +1,132 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "sync" + "time" +) + +type HTTPProbe struct { + name string + url string + interval time.Duration + timeout time.Duration + client *http.Client + mu sync.RWMutex + status string + lastError string + lastChecked time.Time + lastSuccess time.Time +} + +type ProbeSnapshot struct { + Status string + Error string + LastChecked time.Time + LastSuccess time.Time +} + +func NewHTTPProbe(name, url string, interval, timeout time.Duration) *HTTPProbe { + if interval <= 0 { + interval = 10 * time.Second + } + if timeout <= 0 { + timeout = 2 * time.Second + } + + return &HTTPProbe{ + name: name, + url: url, + interval: interval, + timeout: timeout, + client: &http.Client{ + Timeout: timeout, + }, + } +} + +// Start는 프로브를 백그라운드에서 주기적으로 실행합니다. +func (p *HTTPProbe) Start() { + go func() { + p.checkOnce() + ticker := time.NewTicker(p.interval) + defer ticker.Stop() + for range ticker.C { + p.checkOnce() + } + }() +} + +func (p *HTTPProbe) Snapshot() ProbeSnapshot { + p.mu.RLock() + defer p.mu.RUnlock() + return ProbeSnapshot{ + Status: p.status, + Error: p.lastError, + LastChecked: p.lastChecked, + LastSuccess: p.lastSuccess, + } +} + +func (p *HTTPProbe) StatusText() string { + s := p.Snapshot() + if s.Status == "ok" { + return "ok" + } + if s.Status == "" { + return "unknown" + } + if s.Error == "" { + return "error" + } + return "error: " + s.Error +} + +func (p *HTTPProbe) checkOnce() { + ctx, cancel := context.WithTimeout(context.Background(), p.timeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil) + if err != nil { + p.update("error", fmt.Sprintf("request build failed: %v", err), false) + return + } + + resp, err := p.client.Do(req) + if err != nil { + p.update("error", err.Error(), false) + return + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + p.update("error", fmt.Sprintf("status=%d", resp.StatusCode), false) + return + } + + p.update("ok", "", true) +} + +func (p *HTTPProbe) update(status, errMsg string, success bool) { + p.mu.Lock() + prevStatus := p.status + p.status = status + p.lastError = errMsg + p.lastChecked = time.Now() + if success { + p.lastSuccess = p.lastChecked + } + p.mu.Unlock() + + if prevStatus == status { + return + } + if status == "ok" { + slog.Info("Service probe recovered", "name", p.name, "url", p.url) + return + } + slog.Error("Service probe failed", "name", p.name, "url", p.url, "error", errMsg) +} diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index db9dd4ee..b52bc94d 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -158,6 +158,28 @@ func main() { slog.Warn("Failed to connect to Redis. Auth features may fail.", "error", err) } + // Oathkeeper 상태를 주기적으로 확인해 다운을 감지합니다. + var oathkeeperProbe *HTTPProbe + if strings.ToLower(getEnv("OATHKEEPER_HEALTH_ENABLED", "true")) != "false" { + intervalSec, err := strconv.Atoi(getEnv("OATHKEEPER_HEALTH_INTERVAL_SECONDS", "10")) + if err != nil || intervalSec <= 0 { + intervalSec = 10 + } + timeoutSec, err := strconv.Atoi(getEnv("OATHKEEPER_HEALTH_TIMEOUT_SECONDS", "2")) + if err != nil || timeoutSec <= 0 { + timeoutSec = 2 + } + oathkeeperProbe = NewHTTPProbe( + "oathkeeper", + getEnv("OATHKEEPER_HEALTH_URL", "http://oathkeeper:4456/health/ready"), + time.Duration(intervalSec)*time.Second, + time.Duration(timeoutSec)*time.Second, + ) + oathkeeperProbe.Start() + } else { + slog.Info("Oathkeeper probe disabled") + } + // 2. Initialize Handlers auditHandler := handler.NewAuditHandler(auditRepo) authHandler := handler.NewAuthHandler(redisService, idpProvider) @@ -321,6 +343,32 @@ func main() { status = "degraded" } + // Check Oathkeeper + if oathkeeperProbe != nil { + snapshot := oathkeeperProbe.Snapshot() + switch snapshot.Status { + case "ok": + checks["oathkeeper"] = "ok" + case "": + checks["oathkeeper"] = "unknown" + if status != "error" { + status = "degraded" + } + default: + if snapshot.Error == "" { + checks["oathkeeper"] = "error" + } else { + checks["oathkeeper"] = "error: " + snapshot.Error + } + status = "error" + } + } else { + checks["oathkeeper"] = "disabled" + if status != "error" { + status = "degraded" + } + } + if status == "error" { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ "status": status, diff --git a/compose.ory.yaml b/compose.ory.yaml index 7d92a4fd..8236279b 100644 --- a/compose.ory.yaml +++ b/compose.ory.yaml @@ -71,22 +71,6 @@ services: - ory-net - kratosnet - kratos-mcp-server: - build: - context: ./mcp/kratos-mcp - container_name: mcp_ory_kratos - profiles: - - mcp - stdin_open: true - tty: true - init: true - environment: - - KRATOS_ADMIN_URL=http://kratos:4434 - depends_on: - - kratos - networks: - - ory-net - kratos-ui: image: oryd/kratos-selfservice-ui-node:${KRATOS_UI_NODE_VERSION:-v25.4.0} container_name: ory_kratos_ui @@ -133,39 +117,7 @@ services: - ory-net - hydranet - hydra-mcp-server: - build: - context: ./mcp/hydra-mcp - container_name: mcp_ory_hydra - profiles: - - mcp - stdin_open: true - tty: true - init: true - environment: - - HYDRA_PUBLIC_URL=http://hydra:4444 - - HYDRA_ADMIN_URL=http://hydra:4445 - depends_on: - - hydra - networks: - - ory-net - keto-mcp-server: - build: - context: ./mcp/keto-mcp - container_name: mcp_ory_keto - profiles: - - mcp - stdin_open: true - tty: true - init: true - environment: - - KETO_READ_URL=http://keto:4466 - - KETO_WRITE_URL=http://keto:4467 - depends_on: - - keto - networks: - - ory-net # --- Keto --- keto-migrate: @@ -197,17 +149,18 @@ services: # --- Oathkeeper --- oathkeeper: - image: oryd/oathkeeper:v0.40.6 + image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v25.4.0} container_name: ory_oathkeeper + user: "${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001}" ports: - "4457:4455" # Proxy environment: - - LOG_LEVEL=debug + - LOG_LEVEL=${OATHKEEPER_LOG_LEVEL:info} - APP_ENV=${APP_ENV:-development} volumes: - ./docker/ory/oathkeeper:/etc/config/oathkeeper - ./docker/ory/oathkeeper/logs:/var/log/oathkeeper - command: ["/etc/config/oathkeeper/entrypoint.sh"] + entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"] networks: - ory-net diff --git a/docker/ory/oathkeeper/entrypoint.sh b/docker/ory/oathkeeper/entrypoint.sh index 99174eb6..506af8cd 100755 --- a/docker/ory/oathkeeper/entrypoint.sh +++ b/docker/ory/oathkeeper/entrypoint.sh @@ -19,4 +19,20 @@ export RULES_FILE echo "[oathkeeper] APP_ENV=$APP_ENV_VALUE rules=$RULES_FILE" -exec /bin/sh -c "oathkeeper serve proxy -c /etc/config/oathkeeper/oathkeeper.yml 2>&1 | tee /var/log/oathkeeper/access.log" +RULES_ACTIVE="/etc/config/oathkeeper/rules.active.json" +if [ ! -f "$RULES_FILE" ]; then + echo "[oathkeeper] rules file not found: $RULES_FILE" + exit 1 +fi +cp "$RULES_FILE" "$RULES_ACTIVE" + +LOG_DIR="/var/log/oathkeeper" +LOG_FILE="${LOG_DIR}/access.log" +mkdir -p "$LOG_DIR" +if ! touch "$LOG_FILE" 2>/dev/null; then + echo "[oathkeeper] log file not writable: $LOG_FILE" + ls -ld "$LOG_DIR" || true + exit 1 +fi + +exec /bin/sh -c "oathkeeper serve proxy -c /etc/config/oathkeeper/oathkeeper.yml 2>&1 | tee \"$LOG_FILE\"" diff --git a/docker/ory/oathkeeper/oathkeeper.yml b/docker/ory/oathkeeper/oathkeeper.yml index cb3b787b..7e30286c 100644 --- a/docker/ory/oathkeeper/oathkeeper.yml +++ b/docker/ory/oathkeeper/oathkeeper.yml @@ -14,7 +14,7 @@ errors: access_rules: repositories: - - file://${RULES_FILE:-/etc/config/oathkeeper/rules.json} + - file:///etc/config/oathkeeper/rules.active.json authenticators: noop: @@ -34,6 +34,13 @@ authorizers: enabled: true config: remote: http://keto:4466/check + payload: | + { + "namespace": "permissions", + "object": "{{ print .Request.URL.Path }}", + "relation": "access", + "subject_id": "{{ print .Subject }}" + } mutators: noop: diff --git a/docker/ory/oathkeeper/rules.active.json b/docker/ory/oathkeeper/rules.active.json new file mode 100644 index 00000000..e02c3382 --- /dev/null +++ b/docker/ory/oathkeeper/rules.active.json @@ -0,0 +1,92 @@ +[ + { + "id": "public-health", + "description": "공개 헬스체크", + "match": { + "url": "http://<.*>/health", + "methods": ["GET"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "public-preflight", + "description": "CORS preflight", + "match": { + "url": "http://<.*>/api/v1/<.*>", + "methods": ["OPTIONS"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "public-auth", + "description": "인증/회원가입 등 공개 엔드포인트", + "match": { + "url": "http://<.*>/api/v1/auth/<.*>", + "methods": ["GET", "POST", "OPTIONS"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "noop" } + ], + "authorizer": { "handler": "allow" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "backend-command", + "description": "Command 요청은 Backend로 전달 (Audit 강제)", + "match": { + "url": "http://<.*>/api/v1/<.*>", + "methods": ["POST", "PUT", "PATCH", "DELETE"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "cookie_session" } + ], + "authorizer": { "handler": "remote_json" }, + "mutators": [ + { "handler": "noop" } + ] + }, + { + "id": "backend-query", + "description": "Backend Query (admin/dev 포함)", + "match": { + "url": "http://<.*>/api/v1/<.*>", + "methods": ["GET"] + }, + "upstream": { + "url": "http://baron_backend:3000" + }, + "authenticators": [ + { "handler": "cookie_session" } + ], + "authorizer": { "handler": "remote_json" }, + "mutators": [ + { "handler": "noop" } + ] + } +] diff --git a/mcp/compose.mcp.ory.yaml b/mcp/compose.mcp.ory.yaml new file mode 100644 index 00000000..695a9214 --- /dev/null +++ b/mcp/compose.mcp.ory.yaml @@ -0,0 +1,43 @@ +services: + kratos-mcp-server: + build: + context: ./kratos-mcp + container_name: mcp_ory_kratos + stdin_open: true + tty: true + init: true + environment: + - KRATOS_ADMIN_URL=http://kratos:4434 + networks: + - ory-net + + hydra-mcp-server: + build: + context: ./hydra-mcp + container_name: mcp_ory_hydra + stdin_open: true + tty: true + init: true + environment: + - HYDRA_PUBLIC_URL=http://hydra:4444 + - HYDRA_ADMIN_URL=http://hydra:4445 + networks: + - ory-net + + keto-mcp-server: + build: + context: ./keto-mcp + container_name: mcp_ory_keto + stdin_open: true + tty: true + init: true + environment: + - KETO_READ_URL=http://keto:4466 + - KETO_WRITE_URL=http://keto:4467 + networks: + - ory-net + +networks: + ory-net: + external: true + name: ory-net \ No newline at end of file From b88de7ec9175280e6d18ecb198f86f1ee600c2a4 Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Thu, 29 Jan 2026 01:20:19 +0900 Subject: [PATCH 3/8] =?UTF-8?q?audit=20=EB=A1=9C=EA=B7=B8=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0.=20kratos=20=EC=BD=94=EB=93=9C=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=EB=A1=9C=20=EC=A0=84=EC=86=A1=EA=B9=8C?= =?UTF-8?q?=EC=A7=80=20=EC=A7=84=ED=96=89=20=EC=99=84=EB=A3=8C=20#104?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.sample | 12 +- Makefile | 107 +- backend/cmd/server/main.go | 23 +- backend/docs/openapi.yaml | 36 +- backend/go.mod | 5 + backend/go.sum | 2 + backend/internal/domain/idp_models.go | 32 + backend/internal/handler/admin_handler.go | 22 + backend/internal/handler/auth_handler.go | 1060 ++++++++++++----- backend/internal/handler/auth_handler_test.go | 2 +- backend/internal/idp/factory.go | 135 ++- backend/internal/idp/factory_test.go | 78 +- .../internal/middleware/audit_middleware.go | 183 +++ .../middleware/audit_middleware_test.go | 117 ++ backend/internal/middleware/audit_required.go | 106 -- backend/internal/service/descope_service.go | 149 +++ backend/internal/service/ory_service.go | 361 +++++- backend/internal/utils/masking.go | 79 ++ backend/internal/utils/masking_test.go | 59 + .../validator/schema_validator_test.go | 20 + compose.ory.yaml | 1 - docker-compose.yaml | 2 +- docker/ory/kratos/courier-http.jsonnet | 8 + .../login_code/valid/email.body.gotmpl | 17 + .../valid/email.body.plaintext.gotmpl | 10 + .../login_code/valid/email.subject.gotmpl | 1 + .../login_code/valid/sms.body.gotmpl | 4 + docker/ory/kratos/identity.schema.json | 75 +- docker/ory/kratos/kratos.yml | 10 + docker/ory/oathkeeper/rules.prod.json | 10 +- docs/auth-flow.md | 89 ++ docs/ory-usage.md | 1 + .../lib/core/services/audit_service.dart | 1 - .../lib/core/services/auth_proxy_service.dart | 50 +- .../lib/core/services/auth_token_store.dart | 32 + .../core/services/auth_token_store_stub.dart | 41 + .../core/services/auth_token_store_web.dart | 49 + userfront/lib/core/services/http_client.dart | 7 + .../lib/core/services/http_client_stub.dart | 9 + .../lib/core/services/http_client_web.dart | 12 + .../auth/presentation/login_screen.dart | 233 +++- .../presentation/reset_password_screen.dart | 33 +- .../auth/presentation/signup_screen.dart | 32 +- .../presentation/dashboard_screen.dart | 17 +- .../data/repositories/profile_repository.dart | 82 +- userfront/lib/main.dart | 14 +- 46 files changed, 2843 insertions(+), 585 deletions(-) create mode 100644 backend/internal/middleware/audit_middleware.go create mode 100644 backend/internal/middleware/audit_middleware_test.go delete mode 100644 backend/internal/middleware/audit_required.go create mode 100644 backend/internal/utils/masking.go create mode 100644 backend/internal/utils/masking_test.go create mode 100644 docker/ory/kratos/courier-http.jsonnet create mode 100644 docker/ory/kratos/courier-templates/login_code/valid/email.body.gotmpl create mode 100644 docker/ory/kratos/courier-templates/login_code/valid/email.body.plaintext.gotmpl create mode 100644 docker/ory/kratos/courier-templates/login_code/valid/email.subject.gotmpl create mode 100644 docker/ory/kratos/courier-templates/login_code/valid/sms.body.gotmpl create mode 100644 docs/auth-flow.md create mode 100644 userfront/lib/core/services/auth_token_store.dart create mode 100644 userfront/lib/core/services/auth_token_store_stub.dart create mode 100644 userfront/lib/core/services/auth_token_store_web.dart create mode 100644 userfront/lib/core/services/http_client.dart create mode 100644 userfront/lib/core/services/http_client_stub.dart create mode 100644 userfront/lib/core/services/http_client_web.dart diff --git a/.env.sample b/.env.sample index 509bf2e0..b10cb5bf 100644 --- a/.env.sample +++ b/.env.sample @@ -6,6 +6,10 @@ APP_ENV=stage # 애플리케이션 실행 환경 (dev, stage, production) TZ=Asia/Seoul + +# IDP_PROVIDER는 우선순위 순으로 콤마 구분 (예: Kratos/Hydra 우선, Descope 백업) +IDP_PROVIDER=ory + # --- Infrastructure Ports --- DB_PORT=5432 CLICKHOUSE_PORT_HTTP=8123 @@ -25,11 +29,15 @@ DB_NAME=baron_sso COOKIE_SECRET=super-secret-key-must-be-32-bytes! JWT_SECRET=super-secret-key-must-be-32-bytes! REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트(컨테이너 내부 기준) +CORS_ALLOWED_ORIGINS=http://localhost:5000 # 쿠키 인증 사용 시 정확한 Origin 지정 필요 + +# Audit System Configuration +AUDIT_WORKER_COUNT=5 # 비동기 감사 로그 처리를 위한 고루틴 워커 수 +AUDIT_QUEUE_SIZE=2000 # 감사 로그 대기열(채널) 버퍼 크기 # Descope Project ID (Required for Auth) DESCOPE_PROJECT_ID=P2t...your_descope_project_id DESCOPE_MANAGEMENT_KEY=your_descope_management_key_here -DESCOPE_TEST_ACCOUNT=dyddus1210@gmail.com # 테스트 자동화용 계정(loginId). 없으면 생성 후 비밀번호 변경 시나리오 실행 DESCOPE_TEST_ACCOUNT=tester@baroncs.co.kr # --- Naver Cloud Services --- @@ -52,8 +60,6 @@ ADMIN_PASSWORD=adminPasswordIsNotSimple USERFRONT_URL=https://sso.hmac.kr # 프론트엔드 접속 주소 (이메일/SMS 링크 생성 시 사용) BACKEND_URL=https://sso.hmac.kr # 프론트엔드에서 참조할 백엔드 API 주소 -# IDP_PROVIDER는 우선순위 순으로 콤마 구분 (예: Kratos/Hydra 우선, Descope 백업) -IDP_PROVIDER=ory,descope # ory-stack 변수들 diff --git a/Makefile b/Makefile index 3bbfca16..de3e624d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -# Makefile for Ory Stack +# Baron SSO용 Docker Compose 헬퍼 # 환경 변수 로드 ifneq (,$(wildcard ./.env)) @@ -6,62 +6,81 @@ ifneq (,$(wildcard ./.env)) export endif -# --- 기본 실행 (All Apps) --- -# DB 상태 체크 후 모든 App 서비스 실행 -up: check-db - @echo "Starting ALL Ory services (Profile: app)..." - docker compose --profile app up -d +# Compose 파일 경로 +COMPOSE_INFRA := compose.infra.yaml +COMPOSE_ORY := compose.ory.yaml +COMPOSE_APP := docker-compose.yaml -# --- 개별 서비스 실행 --- -# Kratos만 실행 -up-kratos: check-db - @echo "Starting Ory Kratos..." - docker compose --profile kratos up -d +# --- 기본 실행 --- +# 주의: --remove-orphan 사용 금지 (다른 스택이 orphan으로 판단되어 종료될 수 있음) +up-all: + @echo "Starting ALL stacks (infra + ory + app)..." + docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up -d -# Hydra만 실행 -up-hydra: check-db - @echo "Starting Ory Hydra..." - docker compose --profile hydra up -d - -# Keto만 실행 -up-keto: check-db - @echo "Starting Ory Keto..." - docker compose --profile keto up -d - -# --- 인프라 (DB) 실행 --- -# PostgreSQL 실행 +# --- 개별 스택 실행 --- up-infra: - @echo "Starting Infrastructure (PostgreSQL)..." - docker compose --profile infra up -d + @echo "Starting Infra stack (postgres/clickhouse/redis)..." + docker compose -f $(COMPOSE_INFRA) up -d + +up-ory: + @echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..." + docker compose -f $(COMPOSE_ORY) up -d + +up-app: + @echo "Starting App stack (backend/userfront/adminfront/devfront)..." + docker compose -f $(COMPOSE_APP) up -d + +up-backend: + @echo "Starting Backend only..." + docker compose -f $(COMPOSE_APP) up -d backend + +up-dev: up-infra up-ory + @echo "Dev stack is up (infra + ory)." + +up-front-dev: up-infra up-ory up-backend + @echo "Dev stack is up (infra + ory + backend)." # --- 종료 (Down) --- -# 모든 서비스 및 인프라 종료 -down: - @echo "Stopping ALL services (Infra + App)..." - docker compose --profile infra --profile app down +down-all: + @echo "Stopping ALL stacks (infra + ory + app)..." + docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down -# App 서비스만 종료 (DB는 유지) down-app: - @echo "Stopping App services..." - docker compose --profile app down + @echo "Stopping App stack..." + docker compose -f $(COMPOSE_APP) down + +down-backend: + @echo "Stopping Backend only..." + docker compose -f $(COMPOSE_APP) stop backend -# 인프라만 종료 (주의: App 서비스 에러 가능성 있음) down-infra: - @echo "Stopping Infrastructure..." - docker compose --profile infra down + @echo "Stopping Infra stack..." + docker compose -f $(COMPOSE_INFRA) down + +down-ory: + @echo "Stopping Ory stack..." + docker compose -f $(COMPOSE_ORY) down # --- 유틸리티 --- -# DB 상태 확인 로직 -check-db: - @echo "Checking database status..." - @if [ "$$(docker inspect -f '{{.State.Health.Status}}' ory-postgres 2>/dev/null)" != "healthy" ]; then \ - echo "Error: Database is not running or not healthy."; \ +# 인프라 상태 확인 +check-infra: + @echo "Checking infra status..." + @if [ "$$(docker inspect -f '{{.State.Health.Status}}' baron_postgres 2>/dev/null)" != "healthy" ]; then \ + echo "Error: PostgreSQL is not running or not healthy."; \ echo "Please run 'make up-infra' first."; \ exit 1; \ else \ - echo "Database is healthy."; \ + echo "PostgreSQL is healthy."; \ fi -# 로그 확인 -logs: - docker compose -f compose.ory.yaml logs -f +ps: + docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) ps + +logs-infra: + docker compose -f $(COMPOSE_INFRA) logs -f + +logs-ory: + docker compose -f $(COMPOSE_ORY) logs -f + +logs-app: + docker compose -f $(COMPOSE_APP) logs -f diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index b52bc94d..4434aad2 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -267,10 +267,13 @@ func main() { }) app.Use(recover.New()) + allowedOrigins := getEnv("CORS_ALLOWED_ORIGINS", "http://localhost:5000") + allowCredentials := allowedOrigins != "*" app.Use(cors.New(cors.Config{ - AllowOrigins: "*", // Adjust in production - AllowHeaders: "Origin, Content-Type, Accept, Authorization", - AllowMethods: "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS", + AllowOrigins: allowedOrigins, + AllowHeaders: "Origin, Content-Type, Accept, Authorization", + AllowMethods: "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS", + AllowCredentials: allowCredentials, })) // Ensure COOKIE_SECRET is exactly 32 bytes for AES-256 @@ -384,12 +387,19 @@ func main() { // API Group api := app.Group("/api/v1") - api.Use(middleware.RequireAudit(middleware.AuditRequiredConfig{ + + 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{}{ "/api/v1/audit": {}, "/api/v1/client-log": {}, }, + BodyDump: true, + WorkerCount: workerCount, + QueueSize: queueSize, })) api.Post("/audit", auditHandler.CreateLog) api.Get("/audit", auditHandler.ListLogs) @@ -399,6 +409,7 @@ func main() { auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink) auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink) auth.Post("/magic-link/verify", authHandler.VerifyMagicLink) + auth.Post("/login/code/verify", authHandler.VerifyLoginCode) auth.Post("/password/login", authHandler.PasswordLogin) auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset) // [Changed] Use Interstitial Page for GET to prevent Scanner consumption @@ -431,6 +442,7 @@ func main() { // Admin Routes admin := api.Group("/admin") admin.Get("/check", adminHandler.CheckAuth) + admin.Get("/stats", adminHandler.GetSystemStats) admin.Get("/tenants", tenantHandler.ListTenants) admin.Post("/tenants", tenantHandler.CreateTenant) admin.Get("/tenants/:id", tenantHandler.GetTenant) @@ -454,6 +466,9 @@ func main() { // Webhook for Descope Generic Email Gateway (Fake Email Strategy) auth.Post("/webhooks/descope-email", authHandler.HandleDescopeEmailRelay) + // Webhook for Kratos courier (HTTP delivery) + auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay) + // Client Logging Route (Standardized & Flattened) api.Post("/client-log", func(c *fiber.Ctx) error { type LogReq struct { diff --git a/backend/docs/openapi.yaml b/backend/docs/openapi.yaml index 82710529..e227460d 100644 --- a/backend/docs/openapi.yaml +++ b/backend/docs/openapi.yaml @@ -230,7 +230,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/MessageResponse" + $ref: "#/components/schemas/MagicLinkVerifyResponse" /api/v1/auth/sms: post: @@ -266,7 +266,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/MessageResponse" + $ref: "#/components/schemas/SmsVerifyResponse" /api/v1/auth/qr/init: post: @@ -908,18 +908,28 @@ components: type: boolean nonAlphanumeric: type: boolean + minCharacterTypes: + type: integer EnchantedLinkInitRequest: type: object properties: loginId: type: string + uri: + type: string + method: + type: string EnchantedLinkInitResponse: type: object properties: + linkId: + type: string pendingRef: type: string + maskedEmail: + type: string expiresIn: type: integer @@ -943,22 +953,36 @@ components: token: type: string + MagicLinkVerifyResponse: + type: object + properties: + token: + type: string + message: + type: string + SmsSendRequest: type: object properties: - phone: - type: string - message: + phoneNumber: type: string SmsVerifyRequest: type: object properties: - phone: + phoneNumber: type: string code: type: string + SmsVerifyResponse: + type: object + properties: + token: + type: string + message: + type: string + QrInitResponse: type: object properties: diff --git a/backend/go.mod b/backend/go.mod index b82896d0..fc9026fe 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -14,6 +14,7 @@ require ( github.com/gofiber/fiber/v2 v2.52.10 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 + github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.46.0 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 @@ -34,6 +35,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect github.com/aws/smithy-go v1.24.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/go-faster/city v1.0.1 // indirect @@ -57,10 +59,12 @@ require ( github.com/mattn/go-runewidth v0.0.16 // indirect github.com/paulmach/orb v0.12.0 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect @@ -71,4 +75,5 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 0614daa0..7eed1f6a 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -137,6 +137,8 @@ github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/backend/internal/domain/idp_models.go b/backend/internal/domain/idp_models.go index 694640d0..47b4db8e 100644 --- a/backend/internal/domain/idp_models.go +++ b/backend/internal/domain/idp_models.go @@ -1,10 +1,14 @@ package domain import ( + "errors" "net/http" "time" ) +// ErrNotSupported는 IDP가 특정 인증 흐름을 지원하지 않을 때 반환합니다. +var ErrNotSupported = errors.New("idp: not supported") + // BrokerUser is the standard user model used within Baron SSO business logic. // It defines the canonical set of fields that must be supported by any underlying IDP. type BrokerUser struct { @@ -24,6 +28,16 @@ type IDPMetadata struct { SupportedFields []string } +// PasswordPolicy는 비밀번호 정책 정보를 표현합니다. +type PasswordPolicy struct { + MinLength int + Lowercase bool + Uppercase bool + Number bool + NonAlphanumeric bool + MinCharacterTypes int +} + // Token represents a session or refresh token. type Token struct { JWT string @@ -38,6 +52,14 @@ type AuthInfo struct { Subject string } +// LinkLoginInit는 링크 로그인 초기화 결과입니다. +type LinkLoginInit struct { + FlowID string + ExpiresAt time.Time + // Mode는 링크 로그인 완료 후 세션 처리 방식입니다. (예: "cookie") + Mode string +} + // IdentityProvider is the interface that all IDP adapters must implement. type IdentityProvider interface { Name() string @@ -48,6 +70,16 @@ type IdentityProvider interface { CreateUser(user *BrokerUser, password string) (string, error) // SignIn은 로그인 ID/비밀번호로 인증해 세션 정보를 반환합니다. SignIn(loginID, password string) (*AuthInfo, error) + // UserExists는 loginID 기준으로 사용자 존재 여부를 확인합니다. + UserExists(loginID string) (bool, error) + // IssueSession은 비밀번호 없이 세션을 발급해야 하는 흐름에서 사용합니다. + IssueSession(loginID string) (*AuthInfo, error) + // InitiateLinkLogin은 링크 기반 로그인 요청을 IDP에 전달합니다. + InitiateLinkLogin(loginID, returnTo string) (*LinkLoginInit, error) + // VerifyLoginCode는 링크/코드 기반 로그인에서 코드를 제출해 세션을 발급합니다. + VerifyLoginCode(loginID, flowID, code string) (*AuthInfo, error) + // GetPasswordPolicy는 IDP가 제공하는 비밀번호 정책을 반환합니다. + GetPasswordPolicy() (*PasswordPolicy, error) InitiatePasswordReset(loginID, redirectUrl string) error VerifyPasswordResetToken(token string) (*AuthInfo, error) UpdateUserPassword(loginID, newPassword string, r *http.Request) error diff --git a/backend/internal/handler/admin_handler.go b/backend/internal/handler/admin_handler.go index f283e556..5511b2b4 100644 --- a/backend/internal/handler/admin_handler.go +++ b/backend/internal/handler/admin_handler.go @@ -3,6 +3,8 @@ package handler import ( "log/slog" "os" + "runtime" + "time" "github.com/descope/go-sdk/descope/client" "github.com/gofiber/fiber/v2" @@ -39,3 +41,23 @@ func NewAdminHandler() *AdminHandler { func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"}) } + +// GetSystemStats returns runtime statistics for monitoring +func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error { + var m runtime.MemStats + runtime.ReadMemStats(&m) + + stats := fiber.Map{ + "goroutines": runtime.NumGoroutine(), + "cpus": runtime.NumCPU(), + "memory": fiber.Map{ + "alloc": m.Alloc, + "totalAlign": m.TotalAlloc, + "sys": m.Sys, + "numGC": m.NumGC, + }, + "timestamp": time.Now(), + } + + return c.Status(fiber.StatusOK).JSON(stats) +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index d049552c..745f263b 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -4,19 +4,24 @@ import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/logger" "baron-sso-backend/internal/service" + "bytes" "context" crand "crypto/rand" "encoding/hex" "encoding/json" + "errors" "fmt" + "io" "log/slog" "math/rand" + "net/http" + "net/url" "os" "regexp" + "strconv" "strings" "time" - "github.com/descope/go-sdk/descope" "github.com/descope/go-sdk/descope/client" "github.com/gofiber/fiber/v2" ) @@ -25,6 +30,8 @@ const ( // Redis Key Prefixes prefixSession = "enchanted_session:" prefixToken = "enchanted_token:" + prefixLoginCode = "login_code_flow:" + prefixPollMeta = "poll_meta:" prefixSignupEmail = "signup:email:" prefixSignupPhone = "signup:phone:" @@ -41,6 +48,8 @@ const ( smsCodeTTL = 3 * time.Minute prefixPwdResetToken = "pwdreset_token:" pwdResetExpiration = 15 * time.Minute + minPollInterval = 2 * time.Second + loginCodeExpiration = 10 * time.Minute ) type AuthHandler struct { @@ -68,6 +77,30 @@ func GenerateSecureToken(length int) string { return hex.EncodeToString(b) } +func GenerateUserCode() string { + const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ" + return fmt.Sprintf("%c%c-%03d", + letters[rand.Intn(len(letters))], + letters[rand.Intn(len(letters))], + rand.Intn(1000), + ) +} + +func checkPollInterval(redis *service.RedisService, key string, interval time.Duration) (bool, int) { + now := time.Now().UnixMilli() + val, err := redis.Get(key) + if err == nil && val != "" { + if last, parseErr := strconv.ParseInt(val, 10, 64); parseErr == nil { + if now-last < interval.Milliseconds() { + _ = redis.Set(key, fmt.Sprintf("%d", now), defaultExpiration) + return true, int(interval.Seconds()) + 1 + } + } + } + _ = redis.Set(key, fmt.Sprintf("%d", now), defaultExpiration) + return false, int(interval.Seconds()) +} + func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider) *AuthHandler { projectID := os.Getenv("DESCOPE_PROJECT_ID") managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY") @@ -96,7 +129,7 @@ func NewAuthHandler(redisService *service.RedisService, idpProvider domain.Ident // --- Signup Flow Handlers --- -// CheckEmail - Checks if email is available (not registered in Descope) +// CheckEmail - 이메일 사용 가능 여부를 확인합니다. func (h *AuthHandler) CheckEmail(c *fiber.Ctx) error { var req domain.CheckEmailRequest if err := c.BodyParser(&req); err != nil { @@ -108,23 +141,17 @@ func (h *AuthHandler) CheckEmail(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid email format"}) } - if h.DescopeClient == nil { + if h.IdpProvider == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) } - // Search in Descope - // Note: Descope doesn't have a direct "exists" check, we use Load or Search. - // Since we are checking availability for signup, we want "User not found". - exists, err := h.DescopeClient.Management.User().Load(context.Background(), req.Email) - - // If err is nil and exists is not nil, user exists. - if err == nil && exists != nil { + exists, err := h.IdpProvider.UserExists(req.Email) + if err != nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) + } + if exists { return c.JSON(fiber.Map{"available": false, "message": "Email already registered"}) } - - // Check if specific error is "not found" or just assume if Load fails it might be free. - // Typically Descope Load returns error if not found? Let's assume so or check error message. - // Actually, strictly speaking, we should handle specific errors, but for MVP: return c.JSON(fiber.Map{"available": true}) } @@ -297,26 +324,10 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Terms must be accepted"}) } - // Password Validation - if len(req.Password) < 12 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must be at least 12 characters"}) - } - // Check complexity (at least 2 types: lower, upper, digit, special) - types := 0 - if strings.ContainsAny(req.Password, "abcdefghijklmnopqrstuvwxyz") { - types++ - } - if strings.ContainsAny(req.Password, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") { - types++ - } - if strings.ContainsAny(req.Password, "0123456789") { - types++ - } - if strings.ContainsAny(req.Password, "!@#$%^&*()_+-=[]{}|;:,.<>?") { - types++ - } - if types < 2 { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least 2 types of characters (letters, numbers, symbols)"}) + // 비밀번호 정책 검증 + policy := h.resolvePasswordPolicy() + if err := validatePasswordWithPolicy(policy, req.Password); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) } // 2. Verify Auth Status (Redis) @@ -363,6 +374,9 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { providerID, err := h.IdpProvider.CreateUser(brokerUser, req.Password) if err != nil { + if errors.Is(err, domain.ErrNotSupported) { + return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Signup method not supported"}) + } slog.Error("[Signup] Failed to create user via IDP", "provider", h.IdpProvider.Name(), "error", err) if strings.Contains(err.Error(), "already exists") { return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "User already exists"}) @@ -398,6 +412,19 @@ func (h *AuthHandler) getBearerToken(c *fiber.Ctx) string { return parts[1] } +// normalizePhoneForLoginID는 전화번호를 IDP 조회에 적합한 형태(E.164)로 정규화합니다. +func normalizePhoneForLoginID(phone string) string { + normalized := strings.ReplaceAll(phone, "-", "") + normalized = strings.ReplaceAll(normalized, " ", "") + if strings.HasPrefix(normalized, "010") { + return "+82" + normalized[1:] + } + if strings.HasPrefix(normalized, "82") { + return "+" + normalized + } + return normalized +} + func (h *AuthHandler) getSignupState(key string) (*signupState, error) { val, err := h.RedisService.Get(key) if err != nil || val == "" { @@ -418,23 +445,82 @@ func (h *AuthHandler) saveSignupState(key string, state *signupState, ttl time.D return h.RedisService.Set(key, string(data), ttl) } -// GetPasswordPolicy exposes the current Descope password policy to the userfront for dynamic validation. -func (h *AuthHandler) GetPasswordPolicy(c *fiber.Ctx) error { - if h.DescopeClient == nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Descope client not configured"}) +// resolvePasswordPolicy는 IDP 정책을 우선 사용하고, 없으면 기본 정책을 반환합니다. +func (h *AuthHandler) resolvePasswordPolicy() *domain.PasswordPolicy { + if h.IdpProvider != nil { + policy, err := h.IdpProvider.GetPasswordPolicy() + if err == nil && policy != nil { + return policy + } + } + return &domain.PasswordPolicy{ + MinLength: 12, + Lowercase: true, + Uppercase: false, + Number: true, + NonAlphanumeric: true, + MinCharacterTypes: 0, + } +} + +// 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) } - policy, err := h.DescopeClient.Auth.Password().GetPasswordPolicy(context.Background()) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + 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 +} + +// GetPasswordPolicy는 IDP 기준 비밀번호 정책을 제공합니다. +func (h *AuthHandler) GetPasswordPolicy(c *fiber.Ctx) error { + policy := h.resolvePasswordPolicy() return c.JSON(fiber.Map{ - "minLength": policy.MinLength, - "lowercase": policy.Lowercase, - "uppercase": policy.Uppercase, - "number": policy.Number, - "nonAlphanumeric": policy.NonAlphanumeric, + "minLength": policy.MinLength, + "lowercase": policy.Lowercase, + "uppercase": policy.Uppercase, + "number": policy.Number, + "nonAlphanumeric": policy.NonAlphanumeric, + "minCharacterTypes": policy.MinCharacterTypes, }) } @@ -475,12 +561,26 @@ func (h *AuthHandler) VerifySms(c *fiber.Ctx) error { h.RedisService.DeleteVerificationCode(sanitizedPhone) - // Note: In a real scenario, you might want to generate a Descope JWT here too - // using the same logic as VerifyMagicLink, but for now returning a placeholder - // or you can call the Descope logic if needed. - token := "sms-verified-placeholder-token" + if h.IdpProvider == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Authentication service not configured"}) + } - return c.JSON(fiber.Map{"token": token}) + loginID := normalizePhoneForLoginID(req.PhoneNumber) + authInfo, err := h.IdpProvider.IssueSession(loginID) + if err != nil { + if errors.Is(err, domain.ErrNotSupported) { + return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"}) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) + } + if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) + } + + return c.JSON(fiber.Map{ + "token": authInfo.SessionToken.JWT, + "message": "Login successful", + }) } // InitEnchantedLink - Custom Implementation (Restored) @@ -493,34 +593,57 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { loginID := strings.ReplaceAll(req.LoginID, "-", "") loginID = strings.ReplaceAll(loginID, " ", "") + lookupLoginID := loginID + if !strings.Contains(loginID, "@") { + lookupLoginID = normalizePhoneForLoginID(loginID) + } - // [New] Check if user exists before sending link - if h.DescopeClient != nil { - user, err := h.DescopeClient.Management.User().Load(context.Background(), loginID) - if err != nil || user == nil { - // Try searching by phone if not found by LoginID - searchPhone := loginID - if !strings.Contains(searchPhone, "@") { - if strings.HasPrefix(searchPhone, "010") { - searchPhone = "+82" + searchPhone[1:] - } else if strings.HasPrefix(searchPhone, "82") { - searchPhone = "+" + searchPhone - } - } - searchOptions := &descope.UserSearchOptions{ - Phones: []string{searchPhone}, - Limit: 1, - } - users, _, errSearch := h.DescopeClient.Management.User().SearchAll(context.Background(), searchOptions) - if errSearch != nil || len(users) == 0 { - slog.Warn("[Enchanted] User not found", "loginID", loginID) - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not registered"}) - } - // 검색 결과가 있더라도 loginID는 사용자가 입력한 원래 값을 유지 (발송 수단 결정을 위해) + // 사용자 존재 여부 확인 + if h.IdpProvider == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) + } + exists, err := h.IdpProvider.UserExists(lookupLoginID) + if err != nil { + slog.Warn("[Enchanted] IDP user lookup failed", "loginID", loginID, "error", err) + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) + } + if !exists { + slog.Warn("[Enchanted] User not found", "loginID", loginID) + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not registered"}) + } + + userfrontURL := os.Getenv("USERFRONT_URL") + if userfrontURL == "" { + userfrontURL = "http://sso.hmac.kr" + } + if req.URI != "" { + userfrontURL = req.URI + } + + if init, err := h.IdpProvider.InitiateLinkLogin(lookupLoginID, userfrontURL); err == nil && init != nil && init.Mode != "" { + if init.FlowID != "" { + _ = h.RedisService.Set(prefixLoginCode+lookupLoginID, init.FlowID, loginCodeExpiration) } + expiresIn := 0 + if !init.ExpiresAt.IsZero() { + expiresIn = int(time.Until(init.ExpiresAt).Seconds()) + } + return c.JSON(fiber.Map{ + "linkId": "Sent", + "pendingRef": init.FlowID, + "maskedEmail": loginID, + "mode": init.Mode, + "provider": h.IdpProvider.Name(), + "expiresIn": expiresIn, + "interval": int(minPollInterval.Seconds()), + }) + } else if err != nil && !errors.Is(err, domain.ErrNotSupported) { + slog.Error("[Enchanted] Link login init failed", "provider", h.IdpProvider.Name(), "error", err) + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) } // [Changed] 토큰 길이를 사용자의 요청에 맞춰 6글자(3바이트)로, pendingRef를 8글자(4바이트)로 조정 + userCode := GenerateUserCode() token := GenerateSecureToken(3) pendingRef := GenerateSecureToken(3) @@ -528,14 +651,10 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { // Store in Redis h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), defaultExpiration) - h.RedisService.Set(prefixToken+token, fmt.Sprintf(`{"pendingRef":"%s","loginId":"%s"}`, pendingRef, loginID), defaultExpiration) + h.RedisService.Set(prefixToken+token, fmt.Sprintf(`{"pendingRef":"%s","loginId":"%s"}`, pendingRef, lookupLoginID), defaultExpiration) // Generate Link - userfrontURL := os.Getenv("USERFRONT_URL") slog.Info("[Enchanted] Read USERFRONT_URL", "url", userfrontURL) - if userfrontURL == "" { - userfrontURL = "http://sso.hmac.kr" - } link := fmt.Sprintf("%s/verify/%s", userfrontURL, token) // Route based on LoginID type @@ -555,9 +674,10 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { +

간편 코드: %s

만약 본인이 요청하지 않았다면 이 메일을 무시하셔도 됩니다.

- `, link) + `, link, userCode) slog.Info("[Enchanted] Sending Email via AWS SES", "loginID", loginID) if err := h.EmailService.SendEmail(loginID, subject, body); err != nil { @@ -566,7 +686,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { } } else { // Send SMS - content := fmt.Sprintf("[Baron SSO] 로그인 링크: %s", link) + content := fmt.Sprintf("[Baron SSO] 로그인 링크: %s | 코드: %s", link, userCode) slog.Info("[Enchanted] Sending SMS via Naver Cloud", "loginID", loginID) if err := h.SmsService.SendSms(loginID, content); err != nil { @@ -579,6 +699,9 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { "linkId": "Sent", "pendingRef": pendingRef, "maskedEmail": loginID, + "expiresIn": int(defaultExpiration.Seconds()), + "interval": int(minPollInterval.Seconds()), + "userCode": userCode, }) } @@ -589,9 +712,17 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) } + pollKey := prefixPollMeta + "enchanted:" + req.PendingRef + if slowDown, interval := checkPollInterval(h.RedisService, pollKey, minPollInterval); slowDown { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "slow_down", + "interval": interval, + }) + } + val, err := h.RedisService.Get(prefixSession + req.PendingRef) if err != nil || val == "" { - return c.JSON(fiber.Map{"status": statusPending}) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "expired_token"}) } var data map[string]string @@ -605,7 +736,10 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { }) } - return c.JSON(fiber.Map{"status": statusPending}) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "authorization_pending", + "interval": int(minPollInterval.Seconds()), + }) } // VerifyMagicLink - Validate token and login (Restored) @@ -632,60 +766,23 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { slog.Info("[Verify] Token valid", "loginID", loginID, "pendingRef", pendingRef) - // 1. Generate Descope Session Directly (Management SDK) - if h.DescopeClient == nil { - slog.Error("[Verify] Descope Client is nil!") - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Descope Client not configured"}) + if h.IdpProvider == nil { + slog.Error("[Verify] IDP Provider is nil") + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"}) } - // [Fix] Search for existing user by phone to prevent fragmentation - // Normalize Phone Number for Search (E.164) - searchPhone := loginID - if !strings.Contains(searchPhone, "@") { - // If it looks like a KR mobile number (010...), format to +8210... - if strings.HasPrefix(searchPhone, "010") { - searchPhone = "+82" + searchPhone[1:] - } else if strings.HasPrefix(searchPhone, "82") { - searchPhone = "+" + searchPhone - } - } - - slog.Info("[Verify] Searching for user", "phone", searchPhone) - searchOptions := &descope.UserSearchOptions{ - Phones: []string{searchPhone}, - Limit: 1, - } - - var targetLoginID string - users, _, errSearch := h.DescopeClient.Management.User().SearchAll(context.Background(), searchOptions) - - if errSearch == nil && len(users) > 0 { - if len(users[0].LoginIDs) > 0 { - targetLoginID = users[0].LoginIDs[0] - slog.Info("[Verify] User found", "existingLoginID", targetLoginID) - } else { - // Should not happen for a valid user, but fallback to UserID or searchPhone - slog.Warn("[Verify] User found but no LoginIDs, using UserID") - targetLoginID = users[0].UserID - } - } else { - // [Changed] If not found, do NOT auto-create. Return error. - slog.Warn("[Verify] User not found by phone", "loginID", searchPhone) - return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not registered"}) - } - - slog.Info("[Verify] Generating embedded link", "loginID", targetLoginID) - embeddedToken, err := h.DescopeClient.Management.User().GenerateEmbeddedLink(context.Background(), targetLoginID, nil, 0) + authInfo, err := h.IdpProvider.IssueSession(loginID) if err != nil { - slog.Error("[Verify] Descope Error", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate upstream token"}) + if errors.Is(err, domain.ErrNotSupported) { + slog.Warn("[Verify] IDP session issue not supported", "provider", h.IdpProvider.Name()) + return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"}) + } + slog.Error("[Verify] IDP session issue failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) } - - slog.Info("[Verify] Exchanging embedded token for session JWT") - authInfo, err := h.DescopeClient.Auth.MagicLink().Verify(context.Background(), embeddedToken, nil) - if err != nil { - slog.Error("[Verify] Final verification failed", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to verify upstream token"}) + if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { + slog.Error("[Verify] IDP returned empty session") + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) } sessionToken := authInfo.SessionToken.JWT @@ -702,6 +799,60 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { }) } +// VerifyLoginCode - Verify Kratos login code and issue session. +func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { + var req struct { + LoginID string `json:"loginId"` + Code string `json:"code"` + } + if err := c.BodyParser(&req); err != nil { + slog.Error("[LoginCode] Body parse error", "error", err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + + loginID := strings.TrimSpace(req.LoginID) + loginID = strings.ReplaceAll(loginID, " ", "+") + if loginID == "" || req.Code == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "loginId and code are required"}) + } + + lookupLoginID := loginID + if !strings.Contains(loginID, "@") { + lookupLoginID = normalizePhoneForLoginID(loginID) + } + + if h.IdpProvider == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) + } + + flowID, err := h.RedisService.Get(prefixLoginCode + lookupLoginID) + if err != nil || flowID == "" { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Login flow expired"}) + } + + authInfo, err := h.IdpProvider.VerifyLoginCode(lookupLoginID, flowID, req.Code) + if err != nil { + if errors.Is(err, domain.ErrNotSupported) { + return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"}) + } + slog.Error("[LoginCode] Verify failed", "loginID", loginID, "error", err) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid code"}) + } + if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) + } + + h.RedisService.Delete(prefixLoginCode + lookupLoginID) + + return c.JSON(fiber.Map{ + "token": authInfo.SessionToken.JWT, + "sessionJwt": authInfo.SessionToken.JWT, + "provider": h.IdpProvider.Name(), + "subject": authInfo.Subject, + "message": "Login successful", + }) +} + // PasswordLogin - Authenticate a user with login ID and password. func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { startTime := time.Now() @@ -738,6 +889,9 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { 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"}) + } ale.Status = fiber.StatusUnauthorized ale.LatencyMs = time.Since(startTime) ale.DescopeError = err.Error() @@ -973,7 +1127,7 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error { return c.Redirect(redirectURL) } -// CompletePasswordReset - 제공된 loginID와 새 비밀번호로 Descope에 비밀번호를 업데이트합니다. +// CompletePasswordReset - 제공된 loginID와 새 비밀번호로 IDP 비밀번호를 업데이트합니다. // 리프레시 토큰은 요청 쿠키에 포함되어 있어야 합니다. func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { startTime := time.Now() @@ -984,7 +1138,6 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { if h.IdpProvider != nil { providerName = h.IdpProvider.Name() } - isDescopeProvider := strings.Contains(strings.ToLower(providerName), "descope") var req struct { NewPassword string `json:"newPassword"` @@ -1033,66 +1186,13 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { // 디버깅을 위해 요청된 새 비밀번호를 로그로 출력 ale.Log(slog.LevelInfo, "Received new password for reset") - if len(req.NewPassword) < 8 { + policy := h.resolvePasswordPolicy() + if err := validatePasswordWithPolicy(policy, req.NewPassword); err != nil { ale.Status = fiber.StatusBadRequest ale.LatencyMs = time.Since(startTime) - ale.DescopeError = "Password must be at least 8 characters long" - ale.Log(slog.LevelWarn, "Validation failed: password too short (fallback policy)") - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": ale.DescopeError}) - } - - if isDescopeProvider && h.DescopeClient != nil { - // Validate password complexity (Descope only) - policy, err := h.DescopeClient.Auth.Password().GetPasswordPolicy(context.Background()) - if err != nil { - ale.Log(slog.LevelWarn, "Failed to fetch password policy, skipping dynamic validation: "+err.Error()) - } else { - if len(req.NewPassword) < int(policy.MinLength) { - ale.Status = fiber.StatusBadRequest - ale.LatencyMs = time.Since(startTime) - ale.DescopeError = fmt.Sprintf("Password must be at least %d characters long", policy.MinLength) - ale.Log(slog.LevelWarn, "Validation failed: password too short") - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": ale.DescopeError}) - } - if policy.Lowercase { - if ok, _ := regexp.MatchString(`[a-z]`, req.NewPassword); !ok { - ale.Status = fiber.StatusBadRequest - ale.LatencyMs = time.Since(startTime) - ale.DescopeError = "Password must contain at least one lowercase letter" - ale.Log(slog.LevelWarn, "Validation failed: no lowercase letter") - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one lowercase letter"}) - } - } - if policy.Uppercase { - if ok, _ := regexp.MatchString(`[A-Z]`, req.NewPassword); !ok { - ale.Status = fiber.StatusBadRequest - ale.LatencyMs = time.Since(startTime) - ale.DescopeError = "Password must contain at least one uppercase letter" - ale.Log(slog.LevelWarn, "Validation failed: no uppercase letter") - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one uppercase letter"}) - } - } - if policy.Number { - if ok, _ := regexp.MatchString(`[0-9]`, req.NewPassword); !ok { - ale.Status = fiber.StatusBadRequest - ale.LatencyMs = time.Since(startTime) - ale.DescopeError = "Password must contain at least one number" - ale.Log(slog.LevelWarn, "Validation failed: no number") - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one number"}) - } - } - if policy.NonAlphanumeric { - if ok, _ := regexp.MatchString(`[\W_]`, req.NewPassword); !ok { - ale.Status = fiber.StatusBadRequest - ale.LatencyMs = time.Since(startTime) - ale.DescopeError = "Password must contain at least one special character" - ale.Log(slog.LevelWarn, "Validation failed: no special character") - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one special character"}) - } - } - } - } else if isDescopeProvider && h.DescopeClient == nil { - ale.Log(slog.LevelWarn, "Descope selected but client is nil; skipping policy validation") + ale.DescopeError = err.Error() + ale.Log(slog.LevelWarn, "Validation failed: "+err.Error()) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()}) } ale.Log(slog.LevelInfo, "Attempting to update password via IDP", slog.String("idp", providerName)) @@ -1125,6 +1225,7 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { // InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다. func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error { pendingRef := GenerateSecureToken(16) + userCode := GenerateUserCode() // QR 코드 페이로드를 실제 접속 가능한 URL로 변경합니다. userfrontURL := os.Getenv("USERFRONT_URL") @@ -1142,6 +1243,8 @@ func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error { "qrCode": qrPayload, // 프론트엔드에서 이 텍스트로 QR을 생성하거나, 이미지를 반환 "pendingRef": pendingRef, "expiresIn": 300, + "interval": int(minPollInterval.Seconds()), + "userCode": userCode, }) } @@ -1154,9 +1257,17 @@ func (h *AuthHandler) PollQRLogin(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"}) } + pollKey := prefixPollMeta + "qr:" + req.PendingRef + if slowDown, interval := checkPollInterval(h.RedisService, pollKey, minPollInterval); slowDown { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "slow_down", + "interval": interval, + }) + } + val, err := h.RedisService.Get(prefixSession + req.PendingRef) if err != nil || val == "" { - return c.JSON(fiber.Map{"status": "expired"}) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "expired_token"}) } var data map[string]string @@ -1170,7 +1281,10 @@ func (h *AuthHandler) PollQRLogin(c *fiber.Ctx) error { }) } - return c.JSON(fiber.Map{"status": statusPending}) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "authorization_pending", + "interval": int(minPollInterval.Seconds()), + }) } // ScanQRLogin - Step 3: 모바일 앱에서 QR 스캔 후 승인할 때 호출합니다. @@ -1209,6 +1323,147 @@ func (h *AuthHandler) ProxyToDescope(c *fiber.Ctx, path string, payload interfac return c.Status(501).SendString("Descope Proxy Disabled") } +type kratosCourierRequest struct { + Recipient string `json:"recipient"` + TemplateType string `json:"template_type"` + TemplateData map[string]interface{} `json:"template_data"` + Subject string `json:"subject"` + Body string `json:"body"` +} + +// HandleKratosCourierRelay - Kratos courier HTTP 요청을 받아 메일/SMS 발송으로 변환합니다. +func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { + var req kratosCourierRequest + if err := c.BodyParser(&req); err != nil { + slog.Error("[Kratos Courier] Body parsing failed", "error", err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + + if req.Recipient == "" { + slog.Warn("[Kratos Courier] Missing recipient") + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Missing recipient"}) + } + + subject, body := h.buildKratosCourierMessage(&req) + if strings.TrimSpace(body) == "" { + slog.Warn("[Kratos Courier] Empty body", "recipient", req.Recipient, "template", req.TemplateType) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Empty message"}) + } + + if strings.Contains(req.Recipient, "@") { + if h.EmailService == nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"}) + } + if err := h.EmailService.SendEmail(req.Recipient, subject, body); err != nil { + slog.Error("[Kratos Courier] Email send failed", "to", req.Recipient, "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send email"}) + } + slog.Info("[Kratos Courier] Email sent", "to", req.Recipient, "template", req.TemplateType) + return c.JSON(fiber.Map{"status": "ok"}) + } + + if h.SmsService == nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "SMS service not configured"}) + } + phone := sanitizePhoneForSms(req.Recipient) + if err := h.SmsService.SendSms(phone, body); err != nil { + slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) + } + slog.Info("[Kratos Courier] SMS sent", "to", phone, "template", req.TemplateType) + return c.JSON(fiber.Map{"status": "ok"}) +} + +func (h *AuthHandler) buildKratosCourierMessage(req *kratosCourierRequest) (string, string) { + subject := strings.TrimSpace(req.Subject) + body := strings.TrimSpace(req.Body) + if body != "" || subject != "" { + if subject == "" { + subject = "[Baron SSO] 알림" + } + return subject, body + } + + templateType := strings.ToLower(req.TemplateType) + loginCode := extractFirstString(req.TemplateData, "login_code") + verificationCode := extractFirstString(req.TemplateData, "verification_code") + recoveryCode := extractFirstString(req.TemplateData, "recovery_code") + code := firstNonEmpty(loginCode, verificationCode, recoveryCode, extractFirstString(req.TemplateData, "code")) + + label := "알림" + if loginCode != "" || strings.Contains(templateType, "login") { + label = "로그인" + } else if verificationCode != "" || strings.Contains(templateType, "verification") { + label = "인증" + } else if recoveryCode != "" || strings.Contains(templateType, "recovery") { + label = "복구" + } else if strings.Contains(templateType, "code") { + label = "인증" + } + + if subject == "" { + if label == "알림" { + subject = "[Baron SSO] 알림" + } else { + subject = fmt.Sprintf("[Baron SSO] %s 코드", label) + } + } + + if code == "" { + return subject, fmt.Sprintf("[Baron SSO] %s 요청이 도착했습니다", label) + } + + message := fmt.Sprintf("[Baron SSO] %s 코드: %s", label, code) + if label == "로그인" { + baseURL := os.Getenv("USERFRONT_URL") + if baseURL == "" { + baseURL = "http://localhost:5000" + } + baseURL = strings.TrimRight(baseURL, "/") + link := fmt.Sprintf("%s/verify?loginId=%s&code=%s", + baseURL, + url.QueryEscape(req.Recipient), + url.QueryEscape(code), + ) + message = fmt.Sprintf("%s | 링크: %s", message, link) + } + + return subject, message +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func extractFirstString(data map[string]interface{}, keys ...string) string { + if data == nil { + return "" + } + for _, key := range keys { + if val, ok := data[key]; ok { + if str, ok := val.(string); ok && str != "" { + return str + } + } + } + return "" +} + +func sanitizePhoneForSms(phone string) string { + sanitized := strings.TrimSpace(phone) + if strings.HasPrefix(sanitized, "+82") { + sanitized = "0" + sanitized[3:] + } + sanitized = strings.ReplaceAll(sanitized, "-", "") + sanitized = strings.ReplaceAll(sanitized, " ", "") + return sanitized +} + // HandleDescopeSmsRelay func (h *AuthHandler) HandleDescopeSmsRelay(c *fiber.Ctx) error { var req struct { @@ -1305,123 +1560,346 @@ func (h *AuthHandler) formatPhoneForStorage(phone string) string { // GetMe - Returns current user's profile with 010 phone format func (h *AuthHandler) GetMe(c *fiber.Ctx) error { token := h.getBearerToken(c) - if token == "" { + if token != "" { + if looksLikeJWT(token) && h.DescopeClient != nil { + authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) + if err == nil && authorized { + userResponse, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load user profile"}) + } + + dept, _ := userResponse.CustomAttributes["department"].(string) + affType, _ := userResponse.CustomAttributes["affiliationType"].(string) + compCode, _ := userResponse.CustomAttributes["companyCode"].(string) + + resp := domain.UserProfileResponse{ + ID: userResponse.UserID, + Email: userResponse.Email, + Name: userResponse.Name, + Phone: h.formatPhoneForDisplay(userResponse.Phone), + Department: dept, + AffiliationType: affType, + CompanyCode: compCode, + } + return c.JSON(resp) + } + } + + profile, err := h.getKratosProfile(token) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + } + return c.JSON(profile) + } + + cookie := c.Get("Cookie") + if cookie == "" { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization token"}) } - - authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) - if err != nil || !authorized { + profile, err := h.getKratosProfileWithCookie(cookie) + if err != nil { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) } + return c.JSON(profile) +} - userResponse, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID) +func looksLikeJWT(token string) bool { + return strings.Count(token, ".") == 2 +} + +func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, error) { + if looksLikeJWT(token) && h.DescopeClient != nil { + authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) + if err == nil && authorized { + return userToken.ID, nil + } + } + id, _, err := h.getKratosIdentity(token) + return id, err +} + +func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) { + kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") + if kratosURL == "" { + kratosURL = "http://kratos:4433" + } + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load user profile"}) + return "", nil, err + } + req.Header.Set("X-Session-Token", sessionToken) + + resp, err := http.DefaultClient.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 whoami failed status=%d body=%s", resp.StatusCode, string(body)) } - dept, _ := userResponse.CustomAttributes["department"].(string) - affType, _ := userResponse.CustomAttributes["affiliationType"].(string) - compCode, _ := userResponse.CustomAttributes["companyCode"].(string) + var result struct { + Identity struct { + ID string `json:"id"` + Traits map[string]interface{} `json:"traits"` + } `json:"identity"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", nil, err + } - resp := domain.UserProfileResponse{ - ID: userResponse.UserID, - Email: userResponse.Email, - Name: userResponse.Name, - Phone: h.formatPhoneForDisplay(userResponse.Phone), + return result.Identity.ID, result.Identity.Traits, nil +} + +func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]interface{}, error) { + kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") + if kratosURL == "" { + kratosURL = "http://kratos:4433" + } + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil) + if err != nil { + return "", nil, err + } + req.Header.Set("Cookie", cookie) + + resp, err := http.DefaultClient.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 whoami failed status=%d body=%s", resp.StatusCode, string(body)) + } + + var result struct { + Identity struct { + ID string `json:"id"` + Traits map[string]interface{} `json:"traits"` + } `json:"identity"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", nil, err + } + + return result.Identity.ID, result.Identity.Traits, nil +} + +func (h *AuthHandler) updateKratosIdentity(identityID string, traits map[string]interface{}) error { + kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/") + if kratosAdminURL == "" { + kratosAdminURL = "http://kratos:4434" + } + + payload := map[string]interface{}{ + "schema_id": "default", + "traits": traits, + } + body, _ := json.Marshal(payload) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, fmt.Sprintf("%s/admin/identities/%s", kratosAdminURL, identityID), bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.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 failed status=%d body=%s", resp.StatusCode, string(respBody)) + } + return nil +} + +func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) { + identityID, traits, err := h.getKratosIdentity(sessionToken) + if err != nil { + return nil, err + } + + email, _ := traits["email"].(string) + name, _ := traits["name"].(string) + phone, _ := traits["phone_number"].(string) + dept, _ := traits["department"].(string) + affType, _ := traits["affiliationType"].(string) + compCode, _ := traits["companyCode"].(string) + + profile := &domain.UserProfileResponse{ + ID: identityID, + Email: email, + Name: name, + Phone: h.formatPhoneForDisplay(phone), Department: dept, AffiliationType: affType, CompanyCode: compCode, } + return profile, nil +} - return c.JSON(resp) +func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) { + identityID, traits, err := h.getKratosIdentityWithCookie(cookie) + if err != nil { + return nil, err + } + + email, _ := traits["email"].(string) + name, _ := traits["name"].(string) + phone, _ := traits["phone_number"].(string) + dept, _ := traits["department"].(string) + affType, _ := traits["affiliationType"].(string) + compCode, _ := traits["companyCode"].(string) + + profile := &domain.UserProfileResponse{ + ID: identityID, + Email: email, + Name: name, + Phone: h.formatPhoneForDisplay(phone), + Department: dept, + AffiliationType: affType, + CompanyCode: compCode, + } + return profile, nil } // UpdateMe - Updates current user's profile with phone verification check func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error { token := h.getBearerToken(c) - if token == "" { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization token"}) - } - - authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) - if err != nil || !authorized { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) - } - var req domain.UpdateUserRequest if err := c.BodyParser(&req); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) } - // 1. Load current user to check changes - currentUser, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load current user"}) + if token != "" && looksLikeJWT(token) && h.DescopeClient != nil { + authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) + if err == nil && authorized { + // 1. Load current user to check changes + currentUser, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to load current user"}) + } + + newPhoneStorage := h.formatPhoneForStorage(req.Phone) + oldPhoneStorage := currentUser.Phone + + slog.Info("[UpdateMe] Checking changes", "userID", userToken.ID, "oldPhone", oldPhoneStorage, "newPhone", newPhoneStorage, "newName", req.Name) + + // 2. Handle Phone Number Change + if newPhoneStorage != "" && newPhoneStorage != oldPhoneStorage { + // Check verification status in Redis + verifyKey := "verify_update_phone:" + userToken.ID + ":" + newPhoneStorage + val, _ := h.RedisService.Get(verifyKey) + if val != "verified" { + slog.Warn("[UpdateMe] Phone verification missing", "key", verifyKey) + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "휴대폰 번호 변경을 위해 SMS 인증이 필요합니다."}) + } + + // Update Phone in Descope and mark as verified + slog.Info("[UpdateMe] Updating phone number", "userID", userToken.ID, "newPhone", newPhoneStorage) + _, err = h.DescopeClient.Management.User().UpdatePhone(c.Context(), userToken.ID, newPhoneStorage, true, false) + if err != nil { + slog.Error("Failed to update phone in Descope", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "전화번호 업데이트에 실패했습니다."}) + } + + // If the old phone was used as a LoginID, replace it with the new one + for _, loginID := range currentUser.LoginIDs { + // Normalize for comparison + normID := strings.ReplaceAll(loginID, "+82", "0") + normOld := strings.ReplaceAll(oldPhoneStorage, "+82", "0") + + if loginID == oldPhoneStorage || (normOld != "" && normID == normOld) { + slog.Info("[UpdateMe] Updating LoginID", "old", loginID, "new", newPhoneStorage) + _, err = h.DescopeClient.Management.User().UpdateLoginID(c.Context(), loginID, newPhoneStorage) + if err != nil { + slog.Warn("Failed to update LoginID", "error", err) + } + break + } + } + + // Clear verification after successful update + h.RedisService.Delete(verifyKey) + } + + // 3. Update Name if changed + if req.Name != "" && req.Name != currentUser.Name { + slog.Info("[UpdateMe] Updating display name", "userID", userToken.ID, "newName", req.Name) + _, err = h.DescopeClient.Management.User().UpdateDisplayName(c.Context(), userToken.ID, req.Name) + if err != nil { + slog.Error("Failed to update user name", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "이름 업데이트에 실패했습니다."}) + } + } + + // 4. Update Custom Attributes (Department) + if req.Department != "" { + slog.Info("[UpdateMe] Updating department", "userID", userToken.ID, "dept", req.Department) + if _, err := h.DescopeClient.Management.User().UpdateCustomAttribute(c.Context(), userToken.ID, "department", req.Department); err != nil { + slog.Error("Failed to update department", "error", err) + } + } + + slog.Info("[UpdateMe] Profile update completed successfully", "userID", userToken.ID) + + return c.JSON(fiber.Map{ + "status": "success", + "updatedAt": time.Now().Format(time.RFC3339), + }) + } } + var ( + identityID string + traits map[string]interface{} + err error + ) + if token != "" { + identityID, traits, err = h.getKratosIdentity(token) + } else { + cookie := c.Get("Cookie") + if cookie == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization token"}) + } + identityID, traits, err = h.getKratosIdentityWithCookie(cookie) + } + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + } + + currentPhone, _ := traits["phone_number"].(string) newPhoneStorage := h.formatPhoneForStorage(req.Phone) - oldPhoneStorage := currentUser.Phone - slog.Info("[UpdateMe] Checking changes", "userID", userToken.ID, "oldPhone", oldPhoneStorage, "newPhone", newPhoneStorage, "newName", req.Name) + slog.Info("[UpdateMe] Checking changes (Kratos)", "identityID", identityID, "oldPhone", currentPhone, "newPhone", newPhoneStorage, "newName", req.Name) - // 2. Handle Phone Number Change - if newPhoneStorage != "" && newPhoneStorage != oldPhoneStorage { - // Check verification status in Redis - verifyKey := "verify_update_phone:" + userToken.ID + ":" + newPhoneStorage + if newPhoneStorage != "" && newPhoneStorage != currentPhone { + verifyKey := "verify_update_phone:" + identityID + ":" + newPhoneStorage val, _ := h.RedisService.Get(verifyKey) if val != "verified" { - slog.Warn("[UpdateMe] Phone verification missing", "key", verifyKey) + slog.Warn("[UpdateMe] Phone verification missing (Kratos)", "key", verifyKey) return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "휴대폰 번호 변경을 위해 SMS 인증이 필요합니다."}) } - - // Update Phone in Descope and mark as verified - slog.Info("[UpdateMe] Updating phone number", "userID", userToken.ID, "newPhone", newPhoneStorage) - _, err = h.DescopeClient.Management.User().UpdatePhone(c.Context(), userToken.ID, newPhoneStorage, true, false) - if err != nil { - slog.Error("Failed to update phone in Descope", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "전화번호 업데이트에 실패했습니다."}) - } - - // If the old phone was used as a LoginID, replace it with the new one - for _, loginID := range currentUser.LoginIDs { - // Normalize for comparison - normID := strings.ReplaceAll(loginID, "+82", "0") - normOld := strings.ReplaceAll(oldPhoneStorage, "+82", "0") - - if loginID == oldPhoneStorage || (normOld != "" && normID == normOld) { - slog.Info("[UpdateMe] Updating LoginID", "old", loginID, "new", newPhoneStorage) - _, err = h.DescopeClient.Management.User().UpdateLoginID(c.Context(), loginID, newPhoneStorage) - if err != nil { - slog.Warn("Failed to update LoginID", "error", err) - } - break - } - } - - // Clear verification after successful update + traits["phone_number"] = newPhoneStorage h.RedisService.Delete(verifyKey) } - // 3. Update Name if changed - if req.Name != "" && req.Name != currentUser.Name { - slog.Info("[UpdateMe] Updating display name", "userID", userToken.ID, "newName", req.Name) - _, err = h.DescopeClient.Management.User().UpdateDisplayName(c.Context(), userToken.ID, req.Name) - if err != nil { - slog.Error("Failed to update user name", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "이름 업데이트에 실패했습니다."}) - } + if req.Name != "" { + traits["name"] = req.Name } - - // 4. Update Custom Attributes (Department) if req.Department != "" { - slog.Info("[UpdateMe] Updating department", "userID", userToken.ID, "dept", req.Department) - if _, err := h.DescopeClient.Management.User().UpdateCustomAttribute(c.Context(), userToken.ID, "department", req.Department); err != nil { - slog.Error("Failed to update department", "error", err) - } + traits["department"] = req.Department } - slog.Info("[UpdateMe] Profile update completed successfully", "userID", userToken.ID) + if err := h.updateKratosIdentity(identityID, traits); err != nil { + slog.Error("Failed to update profile in Kratos", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "프로필 업데이트에 실패했습니다."}) + } + slog.Info("[UpdateMe] Profile update completed successfully (Kratos)", "identityID", identityID) return c.JSON(fiber.Map{ "status": "success", "updatedAt": time.Now().Format(time.RFC3339), @@ -1431,12 +1909,20 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error { // SendUpdateCode - Sends OTP for phone number change func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error { token := h.getBearerToken(c) - if token == "" { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + var ( + userID string + err error + ) + if token != "" { + userID, err = h.resolveIdentityID(c, token) + } else { + cookie := c.Get("Cookie") + if cookie == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Unauthorized"}) + } + userID, _, err = h.getKratosIdentityWithCookie(cookie) } - - authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) - if err != nil || !authorized { + if err != nil || userID == "" { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) } @@ -1451,7 +1937,7 @@ func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error { code := fmt.Sprintf("%06d", rand.Intn(1000000)) // Store code in Redis - key := "otp_update_phone:" + userToken.ID + ":" + phone + key := "otp_update_phone:" + userID + ":" + phone h.RedisService.Set(key, code, 5*time.Minute) // Send SMS @@ -1464,8 +1950,20 @@ func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error { // VerifyUpdateCode - Verifies OTP for phone number change func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error { token := h.getBearerToken(c) - authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) - if err != nil || !authorized { + var ( + userID string + err error + ) + if token != "" { + userID, err = h.resolveIdentityID(c, token) + } else { + cookie := c.Get("Cookie") + if cookie == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + } + userID, _, err = h.getKratosIdentityWithCookie(cookie) + } + if err != nil || userID == "" { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) } @@ -1478,7 +1976,7 @@ func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error { } phone := h.formatPhoneForStorage(req.Phone) - key := "otp_update_phone:" + userToken.ID + ":" + phone + key := "otp_update_phone:" + userID + ":" + phone storedCode, _ := h.RedisService.Get(key) if storedCode == "" || storedCode != req.Code { @@ -1486,7 +1984,7 @@ func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error { } // Mark as verified for 10 minutes - verifyKey := "verify_update_phone:" + userToken.ID + ":" + phone + verifyKey := "verify_update_phone:" + userID + ":" + phone h.RedisService.Set(verifyKey, "verified", 10*time.Minute) h.RedisService.Delete(key) diff --git a/backend/internal/handler/auth_handler_test.go b/backend/internal/handler/auth_handler_test.go index 746b82eb..1a06bcac 100644 --- a/backend/internal/handler/auth_handler_test.go +++ b/backend/internal/handler/auth_handler_test.go @@ -72,7 +72,7 @@ func TestCompletePasswordReset_InvalidPasswordPolicy(t *testing.T) { if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { t.Fatalf("failed to decode response: %v", err) } - if got["error"] != "Password must be at least 8 characters long" { + if got["error"] != "비밀번호는 최소 12자 이상이어야 합니다" { t.Fatalf("unexpected error message: %v", got["error"]) } } diff --git a/backend/internal/idp/factory.go b/backend/internal/idp/factory.go index b05b812c..221e027a 100644 --- a/backend/internal/idp/factory.go +++ b/backend/internal/idp/factory.go @@ -124,43 +124,144 @@ func (c *chainedProvider) GetMetadata() (*domain.IDPMetadata, error) { } func (c *chainedProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) { - var errs []error - for idx, p := range c.providers { + for _, p := range c.providers { id, err := p.CreateUser(user, password) if err != nil { - errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err)) + if errors.Is(err, domain.ErrNotSupported) { + continue + } slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "CreateUser", "error", err) - continue - } - if idx > 0 { - slog.Info("IDP fallback succeeded", "operation", "CreateUser", "provider", p.Name()) + return "", err } return id, nil } - if len(errs) == 0 { - return "", fmt.Errorf("no IDP providers available for CreateUser") - } - return "", fmt.Errorf("all IDP providers failed for CreateUser: %w", errors.Join(errs...)) + return "", domain.ErrNotSupported } func (c *chainedProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) { - var errs []error - for idx, p := range c.providers { + for _, p := range c.providers { info, err := p.SignIn(loginID, password) if err != nil { - errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err)) + if errors.Is(err, domain.ErrNotSupported) { + continue + } slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "SignIn", "error", err) + return nil, err + } + return info, nil + } + return nil, domain.ErrNotSupported +} + +func (c *chainedProvider) UserExists(loginID string) (bool, error) { + var errs []error + for _, p := range c.providers { + exists, err := p.UserExists(loginID) + if err != nil { + if errors.Is(err, domain.ErrNotSupported) { + continue + } + errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err)) + continue + } + if exists { + return true, nil + } + } + if len(errs) == 0 { + return false, nil + } + return false, fmt.Errorf("all IDP providers failed for UserExists: %w", errors.Join(errs...)) +} + +func (c *chainedProvider) IssueSession(loginID string) (*domain.AuthInfo, error) { + var errs []error + for idx, p := range c.providers { + info, err := p.IssueSession(loginID) + if err != nil { + if errors.Is(err, domain.ErrNotSupported) { + continue + } + errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err)) + slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "IssueSession", "error", err) continue } if idx > 0 { - slog.Info("IDP fallback succeeded", "operation", "SignIn", "provider", p.Name()) + slog.Info("IDP fallback succeeded", "operation", "IssueSession", "provider", p.Name()) } return info, nil } if len(errs) == 0 { - return nil, fmt.Errorf("no IDP providers available for SignIn") + return nil, domain.ErrNotSupported } - return nil, fmt.Errorf("all IDP providers failed for SignIn: %w", errors.Join(errs...)) + return nil, fmt.Errorf("all IDP providers failed for IssueSession: %w", errors.Join(errs...)) +} + +func (c *chainedProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) { + var errs []error + for idx, p := range c.providers { + info, err := p.InitiateLinkLogin(loginID, returnTo) + if err != nil { + if errors.Is(err, domain.ErrNotSupported) { + continue + } + errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err)) + slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "InitiateLinkLogin", "error", err) + continue + } + if idx > 0 { + slog.Info("IDP fallback succeeded", "operation", "InitiateLinkLogin", "provider", p.Name()) + } + return info, nil + } + if len(errs) == 0 { + return nil, domain.ErrNotSupported + } + return nil, fmt.Errorf("all IDP providers failed for InitiateLinkLogin: %w", errors.Join(errs...)) +} + +func (c *chainedProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) { + var errs []error + for idx, p := range c.providers { + info, err := p.VerifyLoginCode(loginID, flowID, code) + if err != nil { + if errors.Is(err, domain.ErrNotSupported) { + continue + } + errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err)) + slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "VerifyLoginCode", "error", err) + continue + } + if idx > 0 { + slog.Info("IDP fallback succeeded", "operation", "VerifyLoginCode", "provider", p.Name()) + } + return info, nil + } + if len(errs) == 0 { + return nil, domain.ErrNotSupported + } + return nil, fmt.Errorf("all IDP providers failed for VerifyLoginCode: %w", errors.Join(errs...)) +} + +func (c *chainedProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { + var errs []error + for _, p := range c.providers { + policy, err := p.GetPasswordPolicy() + if err != nil { + if errors.Is(err, domain.ErrNotSupported) { + continue + } + errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err)) + continue + } + if policy != nil { + return policy, nil + } + } + if len(errs) == 0 { + return nil, domain.ErrNotSupported + } + return nil, fmt.Errorf("all IDP providers failed for GetPasswordPolicy: %w", errors.Join(errs...)) } func (c *chainedProvider) InitiatePasswordReset(loginID, redirectUrl string) error { diff --git a/backend/internal/idp/factory_test.go b/backend/internal/idp/factory_test.go index 923c2083..9778d29d 100644 --- a/backend/internal/idp/factory_test.go +++ b/backend/internal/idp/factory_test.go @@ -10,19 +10,31 @@ import ( ) type stubProvider struct { - name string - metadata []string - createErr error - initiateErr error - verifyErr error - updateErr error - signInErr error - initiateCalls int - verifyCalls int - updateCalls int - signInCalls int - createCalls int - verifyResponse *domain.AuthInfo + name string + metadata []string + createErr error + initiateErr error + verifyErr error + updateErr error + signInErr error + userExistsErr error + issueErr error + linkInitErr error + verifyCodeErr error + policyErr error + initiateCalls int + verifyCalls int + updateCalls int + signInCalls int + createCalls int + userExistsCalls int + issueCalls int + linkInitCalls int + verifyCodeCalls int + policyCalls int + verifyResponse *domain.AuthInfo + userExists bool + policy *domain.PasswordPolicy } func (s *stubProvider) Name() string { return s.name } @@ -47,6 +59,46 @@ func (s *stubProvider) SignIn(loginID, password string) (*domain.AuthInfo, error return &domain.AuthInfo{Subject: "subject-123"}, nil } +func (s *stubProvider) UserExists(loginID string) (bool, error) { + s.userExistsCalls++ + if s.userExistsErr != nil { + return false, s.userExistsErr + } + return s.userExists, nil +} + +func (s *stubProvider) IssueSession(loginID string) (*domain.AuthInfo, error) { + s.issueCalls++ + if s.issueErr != nil { + return nil, s.issueErr + } + return &domain.AuthInfo{Subject: "issue-subject"}, nil +} + +func (s *stubProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) { + s.linkInitCalls++ + if s.linkInitErr != nil { + return nil, s.linkInitErr + } + return &domain.LinkLoginInit{FlowID: "flow-123", Mode: "cookie"}, nil +} + +func (s *stubProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) { + s.verifyCodeCalls++ + if s.verifyCodeErr != nil { + return nil, s.verifyCodeErr + } + return &domain.AuthInfo{Subject: "verify-code-subject"}, nil +} + +func (s *stubProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { + s.policyCalls++ + if s.policyErr != nil { + return nil, s.policyErr + } + return s.policy, nil +} + func (s *stubProvider) InitiatePasswordReset(loginID, redirectUrl string) error { s.initiateCalls++ return s.initiateErr diff --git a/backend/internal/middleware/audit_middleware.go b/backend/internal/middleware/audit_middleware.go new file mode 100644 index 00000000..ea26520a --- /dev/null +++ b/backend/internal/middleware/audit_middleware.go @@ -0,0 +1,183 @@ +package middleware + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/utils" + "encoding/json" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" +) + +type AuditConfig struct { + Repo domain.AuditRepository + ExcludePaths map[string]struct{} + BodyDump bool + WorkerCount int + QueueSize int +} + +// AuditMiddleware provides comprehensive audit logging for all requests. +// It enforces strict logging for state-changing commands (POST, PUT, DELETE, PATCH) +// and best-effort logging for queries (GET, HEAD, OPTIONS). +func AuditMiddleware(config AuditConfig) fiber.Handler { + // 0. Initialize Worker Pool for Async Logging + if config.WorkerCount <= 0 { + config.WorkerCount = 5 // Default workers + } + if config.QueueSize <= 0 { + config.QueueSize = 1000 // Default queue size + } + + auditQueue := make(chan *domain.AuditLog, config.QueueSize) + var once sync.Once + + // Start workers only once + once.Do(func() { + for i := 0; i < config.WorkerCount; i++ { + go func(workerID int) { + slog.Debug("Audit worker started", "id", workerID) + for log := range auditQueue { + func() { + defer func() { + if r := recover(); r != nil { + slog.Error("Audit worker panic recovery", "reason", r, "req_id", log.EventID) + } + }() + if err := config.Repo.Create(log); err != nil { + slog.Warn("Failed to write async audit log", "error", err, "req_id", log.EventID) + } + }() + } + }(i) + } + }) + + // Default methods classification + writeMethods := map[string]struct{}{ + fiber.MethodPost: {}, + fiber.MethodPut: {}, + fiber.MethodPatch: {}, + fiber.MethodDelete: {}, + } + + if config.ExcludePaths == nil { + config.ExcludePaths = map[string]struct{}{} + } + + return func(c *fiber.Ctx) error { + // 1. Check exclusions + if _, excluded := config.ExcludePaths[c.Path()]; excluded { + return c.Next() + } + + // 2. Setup context variables + start := time.Now() + reqID := c.Get("X-Request-Id") + if reqID == "" { + reqID = uuid.New().String() + c.Set("X-Request-Id", reqID) + } + + // 3. Process Request + err := c.Next() + + // 4. Gather Metrics & Context + latency := time.Since(start) + status := c.Response().StatusCode() + + // If Fiber handler returned an error, status might default to 500 or be in the error + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + status = fiberErr.Code + } else { + status = fiber.StatusInternalServerError + } + } + + statusText := "success" + if status >= fiber.StatusBadRequest { + statusText = "failure" + } + + // 5. Extract User Context (populated by AuthMiddleware/TenantGuard) + userID, _ := c.Locals("user_id").(string) + loginID, _ := c.Locals("login_id").(string) + tenantID, _ := c.Locals("tenant_id").(string) + + // 6. Capture & Mask Body + var maskedBody string + if config.BodyDump { + if c.Method() != fiber.MethodGet && c.Method() != fiber.MethodHead { + bodyBytes := c.Body() + if len(bodyBytes) > 0 { + maskedBytes := utils.MaskSensitiveJSON(bodyBytes) + maskedBody = string(maskedBytes) + } + } + } + + // 7. Construct Details JSON + details := map[string]any{ + "request_id": reqID, + "method": c.Method(), + "path": c.Path(), + "status": status, + "latency_ms": latency.Milliseconds(), + "login_id": loginID, + "tenant_id": tenantID, + "request_body": maskedBody, + } + if err != nil { + details["error"] = err.Error() + } + + detailsJSON, _ := json.Marshal(details) + + // 8. Create Audit Log Object + auditLog := &domain.AuditLog{ + EventID: reqID, + Timestamp: start, + UserID: userID, + EventType: fmt.Sprintf("%s %s", c.Method(), c.Path()), + Status: statusText, + IPAddress: c.IP(), + UserAgent: c.Get("User-Agent"), + Details: string(detailsJSON), + } + + // 9. Store Log (Policy Enforcement) + _, isWrite := writeMethods[c.Method()] + + if config.Repo == nil { + if isWrite { + slog.Error("Audit repository missing for command", "req_id", reqID) + return fiber.NewError(fiber.StatusServiceUnavailable, "Audit system unavailable") + } + return err + } + + if isWrite { + // Strict Mode: Synchronous write + if createErr := config.Repo.Create(auditLog); createErr != nil { + slog.Error("Failed to write audit log (sync)", "error", createErr, "req_id", reqID) + return fiber.NewError(fiber.StatusServiceUnavailable, "Audit logging failed") + } + } else { + // Best Effort: Load Shedding via Buffered Channel + select { + case auditQueue <- auditLog: + // Successfully queued + default: + // Queue full -> DROP (Load Shedding) + slog.Warn("Audit queue full, dropping log (load shedding)", "req_id", reqID, "path", c.Path()) + } + } + + return err + } +} diff --git a/backend/internal/middleware/audit_middleware_test.go b/backend/internal/middleware/audit_middleware_test.go new file mode 100644 index 00000000..4042dd72 --- /dev/null +++ b/backend/internal/middleware/audit_middleware_test.go @@ -0,0 +1,117 @@ +package middleware + +import ( + "baron-sso-backend/internal/domain" + "context" + "encoding/json" + "errors" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// MockAuditRepository is a mock implementation of AuditRepository +type MockAuditRepository struct { + mock.Mock +} + +func (m *MockAuditRepository) Create(log *domain.AuditLog) error { + args := m.Called(log) + return args.Error(0) +} + +func (m *MockAuditRepository) FindPage(ctx context.Context, limit int, cursor *domain.AuditCursor) ([]domain.AuditLog, error) { + args := m.Called(ctx, limit, cursor) + return args.Get(0).([]domain.AuditLog), args.Error(1) +} + +func (m *MockAuditRepository) Ping(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +func TestAuditMiddleware(t *testing.T) { + t.Run("POST request - Sync Success", func(t *testing.T) { + app := fiber.New() + mockRepo := new(MockAuditRepository) + + app.Use(AuditMiddleware(AuditConfig{ + Repo: mockRepo, + BodyDump: true, + })) + + app.Post("/test", func(c *fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + mockRepo.On("Create", mock.MatchedBy(func(log *domain.AuditLog) bool { + var details map[string]any + json.Unmarshal([]byte(log.Details), &details) + return log.Status == "success" && + details["method"] == "POST" && + details["request_body"] == `{"password":"*****","user":"test"}` + })).Return(nil) + + req := httptest.NewRequest("POST", "/test", strings.NewReader(`{"user": "test", "password": "mypassword"}`)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req) + assert.Equal(t, fiber.StatusOK, resp.StatusCode) + mockRepo.AssertExpectations(t) + }) + + t.Run("POST request - Sync Failure (Strict Mode)", func(t *testing.T) { + app := fiber.New() + mockRepo := new(MockAuditRepository) + + app.Use(AuditMiddleware(AuditConfig{ + Repo: mockRepo, + })) + + app.Post("/test", func(c *fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + mockRepo.On("Create", mock.Anything).Return(errors.New("db error")) + + req := httptest.NewRequest("POST", "/test", nil) + resp, _ := app.Test(req) + + // Should return 503 because Audit failed on a Write method + assert.Equal(t, fiber.StatusServiceUnavailable, resp.StatusCode) + }) + + t.Run("GET request - Async Load Shedding", func(t *testing.T) { + app := fiber.New() + mockRepo := new(MockAuditRepository) + + // Set very small queue and no workers to force load shedding + app.Use(AuditMiddleware(AuditConfig{ + Repo: mockRepo, + QueueSize: 1, + WorkerCount: 0, // This will be defaulted to 5 by the code, so let's use another way or just small queue + })) + + app.Get("/test", func(c *fiber.Ctx) error { + return c.SendStatus(fiber.StatusOK) + }) + + // 1. First request fills the queue + mockRepo.On("Create", mock.Anything).Return(nil) + + req1 := httptest.NewRequest("GET", "/test", nil) + resp1, _ := app.Test(req1) + assert.Equal(t, fiber.StatusOK, resp1.StatusCode) + + // 2. Second request should be dropped (load shedding) if workers are slow + // Since we can't easily pause workers without modifying code, + // this test mostly ensures the non-blocking send doesn't hang. + req2 := httptest.NewRequest("GET", "/test", nil) + resp2, _ := app.Test(req2) + assert.Equal(t, fiber.StatusOK, resp2.StatusCode) + }) +} \ No newline at end of file diff --git a/backend/internal/middleware/audit_required.go b/backend/internal/middleware/audit_required.go deleted file mode 100644 index 5741df0f..00000000 --- a/backend/internal/middleware/audit_required.go +++ /dev/null @@ -1,106 +0,0 @@ -package middleware - -import ( - "baron-sso-backend/internal/domain" - "encoding/json" - "fmt" - "log/slog" - "time" - - "github.com/gofiber/fiber/v2" - "github.com/google/uuid" -) - -type AuditRequiredConfig struct { - Repo domain.AuditRepository - ExcludePaths map[string]struct{} - CommandMethods map[string]struct{} -} - -func RequireAudit(config AuditRequiredConfig) fiber.Handler { - commandMethods := config.CommandMethods - if len(commandMethods) == 0 { - commandMethods = map[string]struct{}{ - fiber.MethodPost: {}, - fiber.MethodPut: {}, - fiber.MethodPatch: {}, - fiber.MethodDelete: {}, - } - } - - excludePaths := config.ExcludePaths - if excludePaths == nil { - excludePaths = map[string]struct{}{} - } - - return func(c *fiber.Ctx) error { - if _, ok := commandMethods[c.Method()]; !ok { - return c.Next() - } - if _, excluded := excludePaths[c.Path()]; excluded { - return c.Next() - } - if config.Repo == nil { - return fiber.NewError(fiber.StatusServiceUnavailable, "audit repository unavailable") - } - - start := time.Now() - reqID := c.Get("X-Request-Id") - if reqID == "" { - reqID = uuid.New().String() - c.Set("X-Request-Id", reqID) - } - - err := c.Next() - latency := time.Since(start) - - status := c.Response().StatusCode() - if err != nil { - if fiberErr, ok := err.(*fiber.Error); ok { - status = fiberErr.Code - } else { - status = fiber.StatusInternalServerError - } - } - - statusText := "success" - if status >= fiber.StatusBadRequest { - statusText = "failure" - } - - details := map[string]any{ - "request_id": reqID, - "method": c.Method(), - "path": c.Path(), - "status": status, - "latency_ms": latency.Milliseconds(), - } - if err != nil { - details["error"] = err.Error() - } - - detailsJSON, jsonErr := json.Marshal(details) - if jsonErr != nil { - slog.Warn("failed to marshal audit details", "error", jsonErr, "req_id", reqID) - } - - auditLog := &domain.AuditLog{ - EventID: reqID, - Timestamp: time.Now(), - UserID: "", - EventType: fmt.Sprintf("%s %s", c.Method(), c.Path()), - Status: statusText, - IPAddress: c.IP(), - UserAgent: c.Get("User-Agent"), - DeviceID: "", - Details: string(detailsJSON), - } - - if createErr := config.Repo.Create(auditLog); createErr != nil { - slog.Error("audit log write failed", "error", createErr, "req_id", reqID, "path", c.Path()) - return fiber.NewError(fiber.StatusServiceUnavailable, "audit logging unavailable") - } - - return err - } -} diff --git a/backend/internal/service/descope_service.go b/backend/internal/service/descope_service.go index e9f44889..42d0bab5 100644 --- a/backend/internal/service/descope_service.go +++ b/backend/internal/service/descope_service.go @@ -145,6 +145,101 @@ func (d *DescopeProvider) SignIn(loginID, password string) (*domain.AuthInfo, er return res, nil } +// UserExists는 loginID(이메일/전화번호) 기준으로 사용자가 있는지 확인합니다. +func (d *DescopeProvider) UserExists(loginID string) (bool, error) { + if d.Client == nil { + return false, fmt.Errorf("descope provider: client is nil") + } + + ctx := context.Background() + if strings.Contains(loginID, "@") { + user, err := d.Client.Management.User().Load(ctx, loginID) + if err != nil { + if isDescopeNotFound(err) { + return false, nil + } + return false, err + } + return user != nil, nil + } + + phone := normalizePhone(loginID) + searchOptions := &descope.UserSearchOptions{ + Phones: []string{phone}, + Limit: 1, + } + users, _, err := d.Client.Management.User().SearchAll(ctx, searchOptions) + if err != nil { + return false, err + } + return len(users) > 0, nil +} + +// IssueSession은 비밀번호 없이 로그인 세션을 발급합니다. +func (d *DescopeProvider) IssueSession(loginID string) (*domain.AuthInfo, error) { + if d.Client == nil { + return nil, fmt.Errorf("descope provider: client is nil") + } + ctx := context.Background() + + targetLoginID, err := d.resolveLoginID(loginID) + if err != nil { + return nil, err + } + + embeddedToken, err := d.Client.Management.User().GenerateEmbeddedLink(ctx, targetLoginID, nil, 0) + if err != nil { + return nil, fmt.Errorf("descope provider: generate embedded link failed: %w", err) + } + + authInfo, err := d.Client.Auth.MagicLink().Verify(ctx, embeddedToken, nil) + if err != nil { + return nil, fmt.Errorf("descope provider: magic link verify failed: %w", err) + } + + res := &domain.AuthInfo{ + SessionToken: &domain.Token{ + JWT: authInfo.SessionToken.JWT, + Expiration: time.Unix(authInfo.SessionToken.Expiration, 0), + }, + Subject: authInfo.User.UserID, + } + if authInfo.RefreshToken != nil { + res.RefreshToken = &domain.Token{ + JWT: authInfo.RefreshToken.JWT, + Expiration: time.Unix(authInfo.RefreshToken.Expiration, 0), + } + } + return res, nil +} + +func (d *DescopeProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) { + return nil, domain.ErrNotSupported +} + +func (d *DescopeProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) { + return nil, domain.ErrNotSupported +} + +// GetPasswordPolicy는 Descope 비밀번호 정책을 반환합니다. +func (d *DescopeProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { + if d.Client == nil { + return nil, fmt.Errorf("descope provider: client is nil") + } + policy, err := d.Client.Auth.Password().GetPasswordPolicy(context.Background()) + if err != nil { + return nil, err + } + return &domain.PasswordPolicy{ + MinLength: int(policy.MinLength), + Lowercase: policy.Lowercase, + Uppercase: policy.Uppercase, + Number: policy.Number, + NonAlphanumeric: policy.NonAlphanumeric, + MinCharacterTypes: 0, + }, nil +} + func (d *DescopeProvider) InitiatePasswordReset(loginID, redirectUrl string) error { ctx := context.Background() err := d.Client.Auth.Password().SendPasswordReset(ctx, loginID, redirectUrl, nil) @@ -197,3 +292,57 @@ func (d *DescopeProvider) UpdateUserPassword(loginID, newPassword string, r *htt ctx := context.Background() return d.Client.Auth.Password().UpdateUserPassword(ctx, loginID, newPassword, r) } + +func (d *DescopeProvider) resolveLoginID(loginID string) (string, error) { + if strings.Contains(loginID, "@") { + return loginID, nil + } + + phone := normalizePhone(loginID) + searchOptions := &descope.UserSearchOptions{ + Phones: []string{phone}, + Limit: 1, + } + users, _, err := d.Client.Management.User().SearchAll(context.Background(), searchOptions) + if err != nil { + return "", fmt.Errorf("descope provider: user search failed: %w", err) + } + if len(users) == 0 { + return "", fmt.Errorf("descope provider: user not found") + } + if len(users[0].LoginIDs) > 0 { + return users[0].LoginIDs[0], nil + } + if users[0].UserID != "" { + return users[0].UserID, nil + } + return "", fmt.Errorf("descope provider: user found but login id missing") +} + +func normalizePhone(phone string) string { + normalized := strings.ReplaceAll(phone, "-", "") + normalized = strings.ReplaceAll(normalized, " ", "") + if strings.HasPrefix(normalized, "010") { + return "+82" + normalized[1:] + } + if strings.HasPrefix(normalized, "82") { + return "+" + normalized + } + return normalized +} + +func isDescopeNotFound(err error) bool { + if de, ok := err.(*descope.Error); ok { + if rawStatus, ok := de.Info[descope.ErrorInfoKeys.HTTPResponseStatusCode]; ok { + switch v := rawStatus.(type) { + case int: + return v == http.StatusNotFound + case float64: + return int(v) == http.StatusNotFound + case string: + return v == fmt.Sprintf("%d", http.StatusNotFound) + } + } + } + return false +} diff --git a/backend/internal/service/ory_service.go b/backend/internal/service/ory_service.go index bdbc6afe..655455a2 100644 --- a/backend/internal/service/ory_service.go +++ b/backend/internal/service/ory_service.go @@ -12,6 +12,7 @@ import ( "net/http" "net/url" "os" + "strings" "time" ) @@ -63,6 +64,15 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri if existingID != "" { return "", fmt.Errorf("ory provider: identity already exists for email=%s", user.Email) } + if user.PhoneNumber != "" { + existingPhoneID, err := o.findIdentityID(user.PhoneNumber) + if err != nil { + return "", fmt.Errorf("ory provider: search identity failed: %w", err) + } + if existingPhoneID != "" { + return "", fmt.Errorf("ory provider: identity already exists for phone=%s", user.PhoneNumber) + } + } traits := map[string]interface{}{ "email": user.Email, @@ -84,6 +94,27 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri }, }, } + verifiable := []map[string]interface{}{ + { + "value": user.Email, + "verified": true, + "via": "email", + }, + } + if user.PhoneNumber != "" { + verifiable = append(verifiable, map[string]interface{}{ + "value": user.PhoneNumber, + "verified": true, + "via": "sms", + }) + } + payload["verifiable_addresses"] = verifiable + payload["recovery_addresses"] = []map[string]interface{}{ + { + "value": user.Email, + "via": "email", + }, + } body, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, fmt.Sprintf("%s/admin/identities", o.KratosAdminURL), bytes.NewReader(body)) @@ -119,7 +150,7 @@ func (o *OryProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) return nil, fmt.Errorf("ory provider: loginID and password are required") } - flowID, err := o.startLoginFlow() + flowID, err := o.startLoginFlow("") if err != nil { return nil, err } @@ -178,6 +209,326 @@ func (o *OryProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) }, nil } +// UserExists는 Kratos Admin API로 loginID 존재 여부를 확인합니다. +func (o *OryProvider) UserExists(loginID string) (bool, error) { + if loginID == "" { + return false, fmt.Errorf("ory provider: loginID is empty") + } + identityID, err := o.findIdentityID(loginID) + if err != nil { + return false, fmt.Errorf("ory provider: find identity failed: %w", err) + } + return identityID != "", nil +} + +// IssueSession은 Ory에서 별도 세션 발급이 필요할 때 사용합니다. (현재 미지원) +func (o *OryProvider) IssueSession(loginID string) (*domain.AuthInfo, error) { + return nil, domain.ErrNotSupported +} + +// InitiateLinkLogin은 Kratos Public API로 링크 로그인 플로우를 시작하고 이메일 전송을 트리거합니다. +func (o *OryProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) { + if loginID == "" { + return nil, fmt.Errorf("ory provider: loginID is required") + } + + init, err := o.submitLoginCodeInit(loginID, returnTo) + if err == nil { + return init, nil + } + + if shouldBootstrapCodeLogin(err) { + if ensureErr := o.ensureCodeLoginIdentifier(loginID); ensureErr == nil { + return o.submitLoginCodeInit(loginID, returnTo) + } else { + slog.Warn("Ory code login bootstrap failed", "loginID", loginID, "error", ensureErr) + } + } + + return nil, err +} + +func (o *OryProvider) submitLoginCodeInit(loginID, returnTo string) (*domain.LinkLoginInit, error) { + flowID, err := o.startLoginFlow(returnTo) + if err != nil { + return nil, err + } + + body, _ := json.Marshal(map[string]string{ + "method": "code", + "identifier": loginID, + }) + loginURL := fmt.Sprintf("%s/self-service/login?flow=%s", o.KratosPublicURL, flowID) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, loginURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("ory provider: build link login request failed: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := o.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("ory provider: link login request failed: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode >= 300 { + init, ok := parseKratosLinkLoginResponse(flowID, respBody) + if ok { + slog.Info("Ory link login initiated with non-2xx response", "loginID", loginID, "flow_id", flowID, "status", resp.StatusCode) + return init, nil + } + return nil, fmt.Errorf("ory provider: link login failed status=%d body=%s", resp.StatusCode, string(respBody)) + } + + var result struct { + ExpiresAt time.Time `json:"expires_at"` + } + _ = json.Unmarshal(respBody, &result) + + slog.Info("Ory link login initiated", "loginID", loginID, "flow_id", flowID) + + return &domain.LinkLoginInit{ + FlowID: flowID, + ExpiresAt: result.ExpiresAt, + Mode: "link", + }, nil +} + +func parseKratosLinkLoginResponse(flowID string, body []byte) (*domain.LinkLoginInit, bool) { + if len(body) == 0 { + return nil, false + } + var parsed struct { + ExpiresAt time.Time `json:"expires_at"` + State string `json:"state"` + Active string `json:"active"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, false + } + state := strings.ToLower(parsed.State) + active := strings.ToLower(parsed.Active) + if strings.Contains(state, "sent") || active == "code" { + return &domain.LinkLoginInit{ + FlowID: flowID, + ExpiresAt: parsed.ExpiresAt, + Mode: "link", + }, true + } + return nil, false +} + +func shouldBootstrapCodeLogin(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "has not setup sign in with code") || + strings.Contains(msg, "4000035") +} + +type kratosVerifiableAddress struct { + Value string `json:"value"` + Via string `json:"via"` + Verified bool `json:"verified"` + Status string `json:"status,omitempty"` +} + +func (o *OryProvider) ensureCodeLoginIdentifier(loginID string) error { + identityID, err := o.findIdentityID(loginID) + if err != nil { + return fmt.Errorf("ory provider: find identity failed: %w", err) + } + if identityID == "" { + return fmt.Errorf("ory provider: identity not found for loginID=%s", loginID) + } + + identity, err := o.fetchIdentity(identityID) + if err != nil { + return err + } + + via := "sms" + if strings.Contains(loginID, "@") { + via = "email" + } + + exists := false + existingIndex := -1 + addresses := make([]kratosVerifiableAddress, 0, len(identity.VerifiableAddresses)+1) + for idx, addr := range identity.VerifiableAddresses { + addresses = append(addresses, kratosVerifiableAddress{ + Value: addr.Value, + Via: addr.Via, + Verified: addr.Verified, + Status: addr.Status, + }) + if addr.Value == loginID && addr.Via == via { + exists = true + existingIndex = idx + } + } + ops := make([]map[string]interface{}, 0, 2) + if !exists { + ops = append(ops, map[string]interface{}{ + "op": "add", + "path": "/verifiable_addresses/-", + "value": map[string]interface{}{ + "value": loginID, + "via": via, + "verified": true, + "status": "completed", + }, + }) + } else { + addr := identity.VerifiableAddresses[existingIndex] + if !addr.Verified { + ops = append(ops, map[string]interface{}{ + "op": "replace", + "path": fmt.Sprintf("/verifiable_addresses/%d/verified", existingIndex), + "value": true, + }) + } + if addr.Status != "" && addr.Status != "completed" { + ops = append(ops, map[string]interface{}{ + "op": "replace", + "path": fmt.Sprintf("/verifiable_addresses/%d/status", existingIndex), + "value": "completed", + }) + } + } + + if len(ops) == 0 { + slog.Info("Ory identity verifiable address already ready", "identity_id", identityID, "loginID", loginID, "via", via) + return nil + } + + return o.patchIdentity(identityID, ops) +} + +type kratosIdentity struct { + VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"` +} + +func (o *OryProvider) patchIdentity(identityID string, ops []map[string]interface{}) error { + body, _ := json.Marshal(ops) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("ory provider: build identity patch failed: %w", err) + } + req.Header.Set("Content-Type", "application/json-patch+json") + + resp, err := o.httpClient().Do(req) + if err != nil { + return fmt.Errorf("ory provider: identity patch failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return fmt.Errorf("ory provider: identity patch failed status=%d body=%s", resp.StatusCode, string(respBody)) + } + + slog.Info("Ory identity patched", "identity_id", identityID, "ops", len(ops)) + return nil +} + +func (o *OryProvider) fetchIdentity(identityID string) (*kratosIdentity, error) { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), nil) + if err != nil { + return nil, fmt.Errorf("ory provider: build identity get failed: %w", err) + } + + resp, err := o.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("ory provider: identity get failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("ory provider: identity get failed status=%d body=%s", resp.StatusCode, string(body)) + } + + var identity kratosIdentity + if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil { + return nil, fmt.Errorf("ory provider: decode identity failed: %w", err) + } + return &identity, nil +} + +// VerifyLoginCode는 Kratos 로그인 코드 제출로 세션을 발급합니다. +func (o *OryProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) { + if loginID == "" || flowID == "" || code == "" { + return nil, fmt.Errorf("ory provider: loginID, flowID and code are required") + } + + body, _ := json.Marshal(map[string]string{ + "method": "code", + "identifier": loginID, + "code": code, + }) + loginURL := fmt.Sprintf("%s/self-service/login?flow=%s", o.KratosPublicURL, flowID) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, loginURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("ory provider: build login code request failed: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := o.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("ory provider: login code request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return nil, fmt.Errorf("ory provider: login code failed status=%d body=%s", resp.StatusCode, string(respBody)) + } + + var result struct { + SessionToken string `json:"session_token"` + SessionTokenExpiresAt time.Time `json:"session_token_expires_at"` + Session struct { + Identity struct { + ID string `json:"id"` + } `json:"identity"` + } `json:"session"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("ory provider: decode login code response failed: %w", err) + } + if result.SessionToken == "" { + return nil, fmt.Errorf("ory provider: empty session token returned") + } + + slog.Info("Ory login code successful", + "identity_id", result.Session.Identity.ID, + "loginID", loginID, + "expires_at", result.SessionTokenExpiresAt, + ) + + return &domain.AuthInfo{ + SessionToken: &domain.Token{ + JWT: result.SessionToken, + Expiration: result.SessionTokenExpiresAt, + }, + Subject: result.Session.Identity.ID, + }, nil +} + +// GetPasswordPolicy는 Ory 환경에서 사용하는 기본 정책을 반환합니다. +func (o *OryProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { + return &domain.PasswordPolicy{ + MinLength: 12, + Lowercase: true, + Uppercase: false, + Number: true, + NonAlphanumeric: true, + MinCharacterTypes: 0, + }, nil +} + // InitiatePasswordReset는 현재 내부 토큰/메일 흐름을 사용하고 있으므로 NO-OP로 둡니다. func (o *OryProvider) InitiatePasswordReset(loginID, redirectUrl string) error { slog.Info("Ory InitiatePasswordReset bypassed (handled by app internal flow)", "loginID", loginID, "redirect", redirectUrl) @@ -301,8 +652,12 @@ func (o *OryProvider) httpClient() *http.Client { } // startLoginFlow는 Kratos Public API에서 login flow ID를 발급받습니다. -func (o *OryProvider) startLoginFlow() (string, error) { - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/self-service/login/api", o.KratosPublicURL), nil) +func (o *OryProvider) startLoginFlow(returnTo string) (string, error) { + loginURL := fmt.Sprintf("%s/self-service/login/api", o.KratosPublicURL) + if returnTo != "" { + loginURL = loginURL + "?return_to=" + url.QueryEscape(returnTo) + } + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, loginURL, nil) if err != nil { return "", fmt.Errorf("ory provider: build login flow request failed: %w", err) } diff --git a/backend/internal/utils/masking.go b/backend/internal/utils/masking.go new file mode 100644 index 00000000..ab83f5ca --- /dev/null +++ b/backend/internal/utils/masking.go @@ -0,0 +1,79 @@ +package utils + +import ( + "encoding/json" + "strings" +) + +var sensitiveKeys = map[string]struct{}{ + "password": {}, + "newpassword": {}, + "oldpassword": {}, + "token": {}, + "accesstoken": {}, + "access_token": {}, + "refreshtoken": {}, + "refresh_token": {}, + "secret": {}, + "clientsecret": {}, + "client_secret": {}, + "authorization": {}, + "cookie": {}, + "set-cookie": {}, + "verificationcode": {}, + "verification_code": {}, + "code": {}, // Auth code (sensitive) +} + +// MaskSensitiveJSON parses a JSON byte slice and masks values of sensitive keys. +// Returns the original data if it's not valid JSON. +func MaskSensitiveJSON(data []byte) []byte { + if len(data) == 0 { + return data + } + + var obj interface{} + if err := json.Unmarshal(data, &obj); err != nil { + // Not a JSON object/array, return as is + return data + } + + masked := maskValue(obj) + + result, err := json.Marshal(masked) + if err != nil { + return data + } + return result +} + +func maskValue(v interface{}) interface{} { + switch val := v.(type) { + case map[string]interface{}: + newMap := make(map[string]interface{}, len(val)) + for k, v := range val { + if isSensitive(k) { + newMap[k] = "*****" + } else { + newMap[k] = maskValue(v) + } + } + return newMap + case []interface{}: + newArr := make([]interface{}, len(val)) + for i, v := range val { + newArr[i] = maskValue(v) + } + return newArr + default: + return val + } +} + +func isSensitive(key string) bool { + // Check case-insensitive + // Remove common separators for looser matching? No, stick to lowercase check for now. + k := strings.ToLower(key) + _, ok := sensitiveKeys[k] + return ok +} diff --git a/backend/internal/utils/masking_test.go b/backend/internal/utils/masking_test.go new file mode 100644 index 00000000..6db6c30c --- /dev/null +++ b/backend/internal/utils/masking_test.go @@ -0,0 +1,59 @@ +package utils + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMaskSensitiveJSON(t *testing.T) { + tests := []struct { + name string + input string + expected string // We'll check containment or specific structure + }{ + { + name: "Flat object with password", + input: `{"username": "user", "password": "secret123"}`, + expected: `{"password":"*****","username":"user"}`, + }, + { + name: "Nested object with token", + input: `{"data": {"token": "abc-def", "id": 123}}`, + expected: `{"data":{"id":123,"token":"*****"}}`, + }, + { + name: "Case insensitive key", + input: `{"NewPassword": "changed"}`, + expected: `{"NewPassword":"*****"}`, + }, + { + name: "Array of objects", + input: `[{"secret": "s1"}, {"secret": "s2"}]`, + expected: `[{"secret":"*****"},{"secret":"*****"}]`, + }, + { + name: "Invalid JSON", + input: `not-json`, + expected: `not-json`, + }, + { + name: "Empty JSON", + input: ``, + expected: ``, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := MaskSensitiveJSON([]byte(tt.input)) + // Since JSON map order is undefined, exact string match might fail if keys are reordered. + // Ideally we should unmarshal and compare maps, or use assert.JSONEq + if tt.name == "Invalid JSON" || tt.name == "Empty JSON" { + assert.Equal(t, tt.expected, string(result)) + } else { + assert.JSONEq(t, tt.expected, string(result)) + } + }) + } +} diff --git a/backend/internal/validator/schema_validator_test.go b/backend/internal/validator/schema_validator_test.go index 75ebcbc7..5bcf405f 100644 --- a/backend/internal/validator/schema_validator_test.go +++ b/backend/internal/validator/schema_validator_test.go @@ -29,6 +29,26 @@ func (m *MockProvider) SignIn(loginID, password string) (*domain.AuthInfo, error return &domain.AuthInfo{}, nil } +func (m *MockProvider) UserExists(loginID string) (bool, error) { + return false, nil +} + +func (m *MockProvider) IssueSession(loginID string) (*domain.AuthInfo, error) { + return nil, domain.ErrNotSupported +} + +func (m *MockProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkLoginInit, error) { + return nil, domain.ErrNotSupported +} + +func (m *MockProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) { + return nil, domain.ErrNotSupported +} + +func (m *MockProvider) GetPasswordPolicy() (*domain.PasswordPolicy, error) { + return nil, domain.ErrNotSupported +} + // Stub implementations to satisfy the IdentityProvider interface for this unit test. func (m *MockProvider) InitiatePasswordReset(loginID, redirectUrl string) error { return nil diff --git a/compose.ory.yaml b/compose.ory.yaml index 8236279b..53ffae17 100644 --- a/compose.ory.yaml +++ b/compose.ory.yaml @@ -155,7 +155,6 @@ services: ports: - "4457:4455" # Proxy environment: - - LOG_LEVEL=${OATHKEEPER_LOG_LEVEL:info} - APP_ENV=${APP_ENV:-development} volumes: - ./docker/ory/oathkeeper:/etc/config/oathkeeper diff --git a/docker-compose.yaml b/docker-compose.yaml index 9cb73a97..23d59b02 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -36,7 +36,7 @@ services: - ory-net volumes: - ./backend:/app - command: ["go", "run", "./cmd/server/main.go"] + command: ["go", "run", "./cmd/server"] healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] diff --git a/docker/ory/kratos/courier-http.jsonnet b/docker/ory/kratos/courier-http.jsonnet new file mode 100644 index 00000000..10cf3f72 --- /dev/null +++ b/docker/ory/kratos/courier-http.jsonnet @@ -0,0 +1,8 @@ +// Kratos courier HTTP payload을 backend로 전달하는 템플릿입니다. +function(ctx) + local data = if std.objectHas(ctx, "template_data") && ctx.template_data != null then ctx.template_data else {}; + { + recipient: ctx.recipient, + template_type: ctx.template_type, + template_data: data, + } diff --git a/docker/ory/kratos/courier-templates/login_code/valid/email.body.gotmpl b/docker/ory/kratos/courier-templates/login_code/valid/email.body.gotmpl new file mode 100644 index 00000000..2d2b1c20 --- /dev/null +++ b/docker/ory/kratos/courier-templates/login_code/valid/email.body.gotmpl @@ -0,0 +1,17 @@ + + + +

Baron SSO 로그인

+

아래 버튼을 클릭하면 로그인이 완료됩니다.

+ +

+ + 로그인 완료하기 + +

+

또는 아래 로그인 코드를 입력해도 됩니다.

+

{{ .LoginCode }}

+

요청하지 않았다면 이 메일을 무시해 주세요.

+ + diff --git a/docker/ory/kratos/courier-templates/login_code/valid/email.body.plaintext.gotmpl b/docker/ory/kratos/courier-templates/login_code/valid/email.body.plaintext.gotmpl new file mode 100644 index 00000000..0f08b2a7 --- /dev/null +++ b/docker/ory/kratos/courier-templates/login_code/valid/email.body.plaintext.gotmpl @@ -0,0 +1,10 @@ +Baron SSO 로그인 + +# 운영 링크: https://app.brsw.kr/verify?loginId={{ .To }}&code={{ .LoginCode }} + +아래 링크를 클릭하면 로그인이 완료됩니다. +http://localhost:5000/verify?loginId={{ .To }}&code={{ .LoginCode }} + +로그인 코드: {{ .LoginCode }} + +요청하지 않았다면 이 메일을 무시해 주세요. diff --git a/docker/ory/kratos/courier-templates/login_code/valid/email.subject.gotmpl b/docker/ory/kratos/courier-templates/login_code/valid/email.subject.gotmpl new file mode 100644 index 00000000..2ca5dc3b --- /dev/null +++ b/docker/ory/kratos/courier-templates/login_code/valid/email.subject.gotmpl @@ -0,0 +1 @@ +Baron SSO 로그인 링크 diff --git a/docker/ory/kratos/courier-templates/login_code/valid/sms.body.gotmpl b/docker/ory/kratos/courier-templates/login_code/valid/sms.body.gotmpl new file mode 100644 index 00000000..f3f6fdf4 --- /dev/null +++ b/docker/ory/kratos/courier-templates/login_code/valid/sms.body.gotmpl @@ -0,0 +1,4 @@ +[Baron SSO] 로그인 링크 +# 운영 링크: https://app.brsw.kr/verify?loginId={{ .To }}&code={{ .LoginCode }} +http://localhost:5000/verify?loginId={{ .To }}&code={{ .LoginCode }} +코드: {{ .LoginCode }} diff --git a/docker/ory/kratos/identity.schema.json b/docker/ory/kratos/identity.schema.json index 16bcbadf..d967d074 100644 --- a/docker/ory/kratos/identity.schema.json +++ b/docker/ory/kratos/identity.schema.json @@ -16,6 +16,10 @@ "credentials": { "password": { "identifier": true + }, + "code": { + "identifier": true, + "via": "email" } }, "recovery": { @@ -27,17 +31,68 @@ } }, "name": { - "type": "object", - "properties": { - "first": { - "type": "string", - "title": "First Name" - }, - "last": { - "type": "string", - "title": "Last Name" + "type": "string", + "title": "Name" + }, + "phone_number": { + "type": "string", + "title": "Phone Number", + "minLength": 7, + "ory.sh/kratos": { + "credentials": { + "password": { + "identifier": true + }, + "code": { + "identifier": true, + "via": "sms" + } } } + }, + "department": { + "type": "string", + "title": "Department" + }, + "affiliationType": { + "type": "string", + "title": "Affiliation Type" + }, + "companyCode": { + "type": "string", + "title": "Company Code" + }, + "displayname": { + "type": "string", + "title": "Display Name" + }, + "completeForm": { + "type": "boolean", + "title": "Complete Form" + }, + "team": { + "type": "string", + "title": "Team" + }, + "taxCode": { + "type": "string", + "title": "Tax Code" + }, + "familyCompany": { + "type": "string", + "title": "Family Company" + }, + "familyUniqueKey": { + "type": "string", + "title": "Family Unique Key" + }, + "personal": { + "type": "boolean", + "title": "Personal" + }, + "grade": { + "type": "string", + "title": "Grade" } }, "required": [ @@ -46,4 +101,4 @@ "additionalProperties": false } } -} \ No newline at end of file +} diff --git a/docker/ory/kratos/kratos.yml b/docker/ory/kratos/kratos.yml index 27ed71a2..1bcc337e 100644 --- a/docker/ory/kratos/kratos.yml +++ b/docker/ory/kratos/kratos.yml @@ -23,6 +23,7 @@ selfservice: enabled: true code: enabled: true + passwordless_enabled: true flows: error: @@ -72,5 +73,14 @@ identity: url: file:///etc/config/kratos/identity.schema.json courier: + template_override_path: /etc/config/kratos/courier-templates + delivery_strategy: http + http: + request_config: + url: http://baron_backend:3000/api/v1/auth/webhooks/kratos-courier + method: POST + body: file:///etc/config/kratos/courier-http.jsonnet + headers: + Content-Type: application/json smtp: connection_uri: smtps://test:test@mailslurper:1025/?skip_ssl_verify=true diff --git a/docker/ory/oathkeeper/rules.prod.json b/docker/ory/oathkeeper/rules.prod.json index b84e202c..d6537966 100644 --- a/docker/ory/oathkeeper/rules.prod.json +++ b/docker/ory/oathkeeper/rules.prod.json @@ -3,7 +3,7 @@ "id": "public-health", "description": "공개 헬스체크 (PROD 도메인)", "match": { - "url": "https://auth.brsw.kr/health", + "url": "https://app.brsw.kr/health", "methods": ["GET"] }, "upstream": { @@ -21,7 +21,7 @@ "id": "public-preflight", "description": "CORS preflight (PROD 도메인)", "match": { - "url": "https://auth.brsw.kr/api/v1/<.*>", + "url": "https://app.brsw.kr/api/v1/<.*>", "methods": ["OPTIONS"] }, "upstream": { @@ -39,7 +39,7 @@ "id": "public-auth", "description": "인증/회원가입 등 공개 엔드포인트 (PROD 도메인)", "match": { - "url": "https://auth.brsw.kr/api/v1/auth/<.*>", + "url": "https://app.brsw.kr/api/v1/auth/<.*>", "methods": ["GET", "POST", "OPTIONS"] }, "upstream": { @@ -57,7 +57,7 @@ "id": "backend-command", "description": "Command 요청은 Backend로 전달 (Audit 강제)", "match": { - "url": "https://auth.brsw.kr/api/v1/<.*>", + "url": "https://app.brsw.kr/api/v1/<.*>", "methods": ["POST", "PUT", "PATCH", "DELETE"] }, "upstream": { @@ -75,7 +75,7 @@ "id": "backend-query", "description": "Backend Query (admin/dev 포함)", "match": { - "url": "https://auth.brsw.kr/api/v1/<.*>", + "url": "https://app.brsw.kr/api/v1/<.*>", "methods": ["GET"] }, "upstream": { diff --git a/docs/auth-flow.md b/docs/auth-flow.md new file mode 100644 index 00000000..95c1be59 --- /dev/null +++ b/docs/auth-flow.md @@ -0,0 +1,89 @@ +# 인증/로그인 플로우 정리 (Backend IDP 추상화 기준) + +이 문서는 **Backend IDP 추상화(IdentityProvider)**를 기준으로, 현재 지원하는 로그인 방식과 UserFront 연동 API, 그리고 **Kratos 세션 공유 방식**을 정리합니다. + +> 목적: ID/Password 방식부터 시작해, 현재 지원 중인 로그인 플로우를 **IDP 추상화를 해치지 않는 범위**에서 일관되게 구현하고, Front/Backend/Oathkeeper 간 세션 전달 방식을 명확히 한다. + +--- + +## 1) 지원 로그인 방식 요약 + +| 방식 | Backend 엔드포인트 | 세션 토큰 반환 | 비고 | +|---|---|---|---| +| ID/Password | `POST /api/v1/auth/password/login` | `sessionJwt` | IDP 추상화 사용 (Ory/Descope) | +| Enchanted Link (Email/SMS) | `POST /api/v1/auth/enchanted-link/init` → `POST /api/v1/auth/enchanted-link/poll` | `sessionJwt` | 링크 클릭 시 `POST /api/v1/auth/magic-link/verify` 호출 | +| Magic Link Verify | `POST /api/v1/auth/magic-link/verify` | `token` | Polling 세션 갱신용 | +| SMS 코드 | `POST /api/v1/auth/sms` → `POST /api/v1/auth/verify-sms` | `token` | 현재는 내부 토큰(placeholder). Kratos 세션 교환 필요 | +| QR 로그인 | `POST /api/v1/auth/qr/init` → `POST /api/v1/auth/qr/poll` | `sessionJwt` | 모바일 승인: `POST /api/v1/auth/qr/approve` | + +--- + +## 2) UserFront 연동 API 매핑 + +### 2.1 ID/Password 로그인 +1. `POST /api/v1/auth/password/login` +2. 응답의 `sessionJwt` 사용 + +### 2.2 Enchanted Link (Email/SMS) +1. `POST /api/v1/auth/enchanted-link/init` → `pendingRef` 수신 +2. `POST /api/v1/auth/enchanted-link/poll`로 폴링 +3. 사용자가 링크 클릭하면 UserFront가 `POST /api/v1/auth/magic-link/verify` 호출 +4. Polling 응답에서 `sessionJwt` 수신 + +### 2.3 QR 로그인 +1. `POST /api/v1/auth/qr/init` → `qrCode`, `pendingRef` 수신 +2. 웹은 `POST /api/v1/auth/qr/poll`로 폴링 +3. 모바일 앱은 `POST /api/v1/auth/qr/approve`로 승인 +4. Polling 응답에서 `sessionJwt` 수신 + +### 2.4 SMS 코드 로그인 +1. `POST /api/v1/auth/sms`로 코드 발송 +2. `POST /api/v1/auth/verify-sms`로 코드 검증 +3. 현재는 내부 토큰 반환 (IDP 세션 교환은 TODO) + +--- + +## 3) Kratos 세션 생성/공유 방식 + +### 3.1 생성 (ID/Password 기준) +- Backend가 IDP 추상화(`IdentityProvider.SignIn`)를 호출해 `sessionJwt`를 발급 +- **Ory(Kratos)**의 경우: + - Kratos Login API를 통해 `session_token`을 반환 + - 이 값이 `sessionJwt`로 응답됨 + +### 3.2 공유 (Backend → UserFront / Oathkeeper) +현재 공유 방식은 **두 가지 선택지**가 있습니다. + +**A) Backend가 쿠키로 전달 (권장 방향)** +- `sessionJwt`가 Kratos `session_token`인 경우 `ory_kratos_session` 쿠키로 `Set-Cookie` +- Oathkeeper `cookie_session` authenticator가 Kratos `/sessions/whoami`로 검증 가능 + +**B) UserFront가 토큰을 보관/전달 (현재 동작)** +- `sessionJwt`를 로컬에 저장 후 Backend 호출 시 `Authorization: Bearer `로 전달 +- Oathkeeper 경유 경로에서는 쿠키가 필요하므로, 별도 토큰 교환 또는 Oathkeeper 인증기 추가가 필요 + +> 현재 구현은 **B 방식에 가깝고**, Oathkeeper 통과를 위한 쿠키 전달은 추가 구현이 필요합니다. + +--- + +## 4) IDP 추상화 관점에서의 구현 상태 + +- **ID/Password 로그인**: IDP 추상화 사용 (Ory/Descope) — 정상 +- **Enchanted/Magic Link**: 현재는 Descope 기반 로직이 포함됨. Ory 전환 시 Kratos `code/link` 플로우로 교체 필요 +- **SMS 코드**: 내부 토큰(placeholder). Kratos 세션 교환 로직 추가 필요 +- **QR 로그인**: 모바일 세션 토큰을 웹 세션으로 전달. Ory일 경우 Kratos 세션 토큰을 전달하도록 UI/토큰 저장 방식 정비 필요 + +--- + +## 5) UserFront 주의사항 + +- `sessionJwt`가 **JWT 형식이 아닐 수 있음** (Kratos session token은 opaque 가능) +- 현재 UserFront는 Descope SDK 기반 세션 처리 로직이 포함되어 있어, Ory 사용 시 이 부분은 분리/대체가 필요함 + +--- + +## 6) 다음 액션 제안 + +1. **Kratos 세션 쿠키 전달 방식(A) 구현** +2. Enchanted/Magic Link의 Ory 대응(로그인 코드/링크 방식) 설계 +3. SMS 코드/QR 플로우의 Kratos 세션 교환 정책 확정 diff --git a/docs/ory-usage.md b/docs/ory-usage.md index ed3ecf12..fa3367ec 100644 --- a/docs/ory-usage.md +++ b/docs/ory-usage.md @@ -95,3 +95,4 @@ docker run --rm --network baron_net curlimages/curl:8.10.1 -fsS http://kratos:44 - `compose.ory.yaml` - `docker/ory/kratos/kratos.yml` - `.env.sample` +- `docs/auth-flow.md` diff --git a/userfront/lib/core/services/audit_service.dart b/userfront/lib/core/services/audit_service.dart index 082d6a6d..5c37ed76 100644 --- a/userfront/lib/core/services/audit_service.dart +++ b/userfront/lib/core/services/audit_service.dart @@ -29,7 +29,6 @@ class AuditService { 'event_type': eventType, 'status': status, 'details': details, - 'timestamp': DateTime.now().toIso8601String(), }), ); diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index ced64e33..0cc15c0e 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'http_client.dart'; class AuthProxyService { static String _envOrDefault(String key, String fallback) { @@ -22,6 +23,24 @@ class AuthProxyService { } } + static Future> checkCookieSession() async { + final url = Uri.parse('$_baseUrl/api/v1/user/me'); + final client = createHttpClient(withCredentials: true); + try { + final response = await client.get( + url, + headers: {'Content-Type': 'application/json'}, + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } + throw Exception('Failed to load profile: ${response.body}'); + } finally { + client.close(); + } + } + static Future> initEnchantedLink(String loginId, {String? method}) async { final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init'); final userfrontUrl = _envOrDefault('USERFRONT_URL', 'http://sso.hmac.kr'); @@ -60,9 +79,11 @@ class AuthProxyService { if (response.statusCode == 200) { return jsonDecode(response.body); - } else { - throw Exception('Polling failed: ${response.body}'); } + if (response.statusCode == 400) { + return jsonDecode(response.body); + } + throw Exception('Polling failed: ${response.body}'); } static Future> verifyMagicLink(String token) async { @@ -83,6 +104,25 @@ class AuthProxyService { } } + static Future> verifyLoginCode(String loginId, String code) async { + final url = Uri.parse('$_baseUrl/api/v1/auth/login/code/verify'); + + final response = await http.post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'loginId': loginId, + 'code': code, + }), + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + throw Exception('Verification failed: ${response.body}'); + } + } + static Future> loginWithPassword(String loginId, String password) async { final url = Uri.parse('$_baseUrl/api/v1/auth/password/login'); @@ -205,9 +245,11 @@ class AuthProxyService { if (response.statusCode == 200) { return jsonDecode(response.body); - } else { - throw Exception('QR Polling failed: ${response.body}'); } + if (response.statusCode == 400) { + return jsonDecode(response.body); + } + throw Exception('QR Polling failed: ${response.body}'); } static Future approveQrLogin(String pendingRef, String token) async { diff --git a/userfront/lib/core/services/auth_token_store.dart b/userfront/lib/core/services/auth_token_store.dart new file mode 100644 index 00000000..5ba93582 --- /dev/null +++ b/userfront/lib/core/services/auth_token_store.dart @@ -0,0 +1,32 @@ +import 'auth_token_store_stub.dart' + if (dart.library.html) 'auth_token_store_web.dart'; + +class AuthTokenStore { + static String? getToken() => authTokenStore.getToken(); + + static String? getProvider() => authTokenStore.getProvider(); + + static bool usesCookie() => authTokenStore.usesCookie(); + + static void setToken(String token, {String? provider}) { + authTokenStore.setToken(token, provider: provider); + } + + static void setCookieMode({String? provider}) { + authTokenStore.setCookieMode(provider: provider); + } + + static String? getPendingProvider() => authTokenStore.getPendingProvider(); + + static void setPendingProvider(String? provider) { + authTokenStore.setPendingProvider(provider); + } + + static void clearPendingProvider() { + authTokenStore.setPendingProvider(null); + } + + static void clear() { + authTokenStore.clear(); + } +} diff --git a/userfront/lib/core/services/auth_token_store_stub.dart b/userfront/lib/core/services/auth_token_store_stub.dart new file mode 100644 index 00000000..229a4783 --- /dev/null +++ b/userfront/lib/core/services/auth_token_store_stub.dart @@ -0,0 +1,41 @@ +class AuthTokenStore { + String? _token; + String? _provider; + bool _cookieMode = false; + String? _pendingProvider; + + String? getToken() => _token; + + String? getProvider() => _provider; + + bool usesCookie() => _cookieMode; + + void setToken(String token, {String? provider}) { + _token = token; + _cookieMode = false; + _provider = provider; + } + + void setCookieMode({String? provider}) { + _cookieMode = true; + _token = null; + if (provider != null) { + _provider = provider; + } + } + + String? getPendingProvider() => _pendingProvider; + + void setPendingProvider(String? provider) { + _pendingProvider = provider; + } + + void clear() { + _token = null; + _provider = null; + _cookieMode = false; + _pendingProvider = null; + } +} + +final authTokenStore = AuthTokenStore(); diff --git a/userfront/lib/core/services/auth_token_store_web.dart b/userfront/lib/core/services/auth_token_store_web.dart new file mode 100644 index 00000000..dc7c6851 --- /dev/null +++ b/userfront/lib/core/services/auth_token_store_web.dart @@ -0,0 +1,49 @@ +import 'dart:html' as html; + +class AuthTokenStore { + static const _tokenKey = 'baron_auth_token'; + static const _providerKey = 'baron_auth_provider'; + static const _cookieModeKey = 'baron_auth_cookie_mode'; + static const _pendingProviderKey = 'baron_auth_pending_provider'; + + String? getToken() => html.window.localStorage[_tokenKey]; + + String? getProvider() => html.window.localStorage[_providerKey]; + + bool usesCookie() => html.window.localStorage[_cookieModeKey] == '1'; + + void setToken(String token, {String? provider}) { + html.window.localStorage[_tokenKey] = token; + html.window.localStorage.remove(_cookieModeKey); + if (provider != null) { + html.window.localStorage[_providerKey] = provider; + } + } + + void setCookieMode({String? provider}) { + html.window.localStorage[_cookieModeKey] = '1'; + html.window.localStorage.remove(_tokenKey); + if (provider != null) { + html.window.localStorage[_providerKey] = provider; + } + } + + String? getPendingProvider() => html.window.localStorage[_pendingProviderKey]; + + void setPendingProvider(String? provider) { + if (provider == null || provider.isEmpty) { + html.window.localStorage.remove(_pendingProviderKey); + return; + } + html.window.localStorage[_pendingProviderKey] = provider; + } + + void clear() { + html.window.localStorage.remove(_tokenKey); + html.window.localStorage.remove(_providerKey); + html.window.localStorage.remove(_cookieModeKey); + html.window.localStorage.remove(_pendingProviderKey); + } +} + +final authTokenStore = AuthTokenStore(); diff --git a/userfront/lib/core/services/http_client.dart b/userfront/lib/core/services/http_client.dart new file mode 100644 index 00000000..4be49f7e --- /dev/null +++ b/userfront/lib/core/services/http_client.dart @@ -0,0 +1,7 @@ +import 'package:http/http.dart' as http; +import 'http_client_stub.dart' + if (dart.library.html) 'http_client_web.dart'; + +http.Client createHttpClient({bool withCredentials = false}) { + return httpClientFactory.create(withCredentials: withCredentials); +} diff --git a/userfront/lib/core/services/http_client_stub.dart b/userfront/lib/core/services/http_client_stub.dart new file mode 100644 index 00000000..1b85678d --- /dev/null +++ b/userfront/lib/core/services/http_client_stub.dart @@ -0,0 +1,9 @@ +import 'package:http/http.dart' as http; + +class HttpClientFactory { + http.Client create({bool withCredentials = false}) { + return http.Client(); + } +} + +final httpClientFactory = HttpClientFactory(); diff --git a/userfront/lib/core/services/http_client_web.dart b/userfront/lib/core/services/http_client_web.dart new file mode 100644 index 00000000..8b6d49cf --- /dev/null +++ b/userfront/lib/core/services/http_client_web.dart @@ -0,0 +1,12 @@ +import 'package:http/browser_client.dart'; +import 'package:http/http.dart' as http; + +class HttpClientFactory { + http.Client create({bool withCredentials = false}) { + final client = BrowserClient(); + client.withCredentials = withCredentials; + return client; + } +} + +final httpClientFactory = HttpClientFactory(); diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 8e456415..9850654f 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -8,9 +8,9 @@ import 'package:descope/descope.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:qr_flutter/qr_flutter.dart'; -import '../../../core/services/audit_service.dart'; import '../../../core/services/web_auth_integration.dart'; import '../../../core/services/auth_proxy_service.dart'; +import '../../../core/services/auth_token_store.dart'; import '../../../core/notifiers/auth_notifier.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; @@ -33,10 +33,12 @@ class _LoginScreenState extends ConsumerState // QR Login Variables String? _qrImageBase64; String? _qrPendingRef; + String? _qrUserCode; bool _isQrLoading = false; Timer? _qrPollingTimer; int _qrRemainingSeconds = 0; Timer? _qrCountdownTimer; + int _qrPollIntervalMs = 2000; @override void initState() { @@ -47,22 +49,58 @@ class _LoginScreenState extends ConsumerState // Check for tokens (Path Parameter or Legacy Query Parameter) WidgetsBinding.instance.addPostFrameCallback((_) { - if (widget.verificationToken != null) { + final uri = Uri.base; + final loginIdParam = uri.queryParameters['loginId']; + final codeParam = uri.queryParameters['code']; + if (loginIdParam != null && codeParam != null) { + _verifyLoginCode(loginIdParam, codeParam); + } else if (widget.verificationToken != null) { _verifyToken(widget.verificationToken!); - } else { - final uri = Uri.base; - if (uri.queryParameters.containsKey('t')) { - _verifyToken(uri.queryParameters['t']!); - } + } else if (uri.queryParameters.containsKey('t')) { + _verifyToken(uri.queryParameters['t']!); } - final uri = Uri.base; + _tryCookieSession(); + if (uri.queryParameters.containsKey('redirect_url')) { _redirectUrl = uri.queryParameters['redirect_url']; } }); } + Future _tryCookieSession({bool silent = true}) async { + if (AuthTokenStore.getToken() != null) { + return; + } + final pendingProvider = AuthTokenStore.getPendingProvider(); + final provider = pendingProvider ?? AuthTokenStore.getProvider(); + if (provider == null || !provider.toLowerCase().contains('ory')) { + return; + } + + try { + await AuthProxyService.checkCookieSession(); + AuthTokenStore.setCookieMode(provider: provider); + AuthTokenStore.clearPendingProvider(); + if (mounted) { + await ref.read(profileProvider.notifier).loadProfile(); + _onCookieLoginSuccess(provider); + } + } catch (e) { + if (!silent) { + _showError("로그인 확인 실패: ${e.toString().replaceFirst("Exception: ", "")}"); + } + } + } + + void _onCookieLoginSuccess(String provider) { + debugPrint("[Auth] Cookie-based login success. Provider: $provider"); + AuthNotifier.instance.notify(); + if (mounted) { + context.go('/'); + } + } + // Helper to decode JWT and get loginId String _getLoginIdFromJwt(String jwt) { try { @@ -107,6 +145,7 @@ class _LoginScreenState extends ConsumerState setState(() { _isQrLoading = true; _qrImageBase64 = null; + _qrUserCode = null; _qrRemainingSeconds = 0; }); @@ -117,6 +156,13 @@ class _LoginScreenState extends ConsumerState _qrImageBase64 = res['qrCode']; _qrPendingRef = res['pendingRef']; _qrRemainingSeconds = res['expiresIn'] ?? 300; + _qrUserCode = res['userCode']?.toString(); + final interval = res['interval']; + if (interval is int && interval > 0) { + _qrPollIntervalMs = interval * 1000; + } else { + _qrPollIntervalMs = 2000; + } _isQrLoading = false; }); _startQrPolling(); @@ -144,7 +190,7 @@ class _LoginScreenState extends ConsumerState void _startQrPolling() { _qrPollingTimer?.cancel(); - _qrPollingTimer = Timer.periodic(const Duration(milliseconds: 1500), (timer) async { + _qrPollingTimer = Timer.periodic(Duration(milliseconds: _qrPollIntervalMs), (timer) async { if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) { timer.cancel(); return; @@ -152,6 +198,33 @@ class _LoginScreenState extends ConsumerState try { final res = await AuthProxyService.pollQrStatus(_qrPendingRef!); + if (res['error'] == 'slow_down') { + final interval = res['interval']; + if (interval is int && interval > 0) { + final nextIntervalMs = interval * 1000; + if (nextIntervalMs != _qrPollIntervalMs) { + _qrPollIntervalMs = nextIntervalMs; + timer.cancel(); + _startQrPolling(); + return; + } + } else { + _qrPollIntervalMs += 500; + timer.cancel(); + _startQrPolling(); + return; + } + } + if (res['error'] == 'authorization_pending') { + return; + } + if (res['error'] == 'expired_token') { + timer.cancel(); + _qrCountdownTimer?.cancel(); + _showError("QR 세션이 만료되었습니다."); + return; + } + if (res['status'] == 'ok' && res['sessionJwt'] != null) { timer.cancel(); _qrCountdownTimer?.cancel(); @@ -233,6 +306,34 @@ class _LoginScreenState extends ConsumerState } } + Future _verifyLoginCode(String loginId, String code) async { + final sanitizedLoginId = loginId.replaceAll(' ', '+'); + debugPrint("[Auth] Starting code verification for loginId: $sanitizedLoginId"); + try { + final res = await AuthProxyService.verifyLoginCode(sanitizedLoginId, code); + final jwt = res['sessionJwt'] ?? res['token']; + debugPrint("[Auth] Code verification successful for loginId: $sanitizedLoginId"); + + if (jwt != null && mounted) { + final isJwt = (jwt as String).split('.').length == 3; + if (isJwt) { + final displayName = _getLoginIdFromJwt(jwt); + final dummyUser = DescopeUser( + 'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [], + ); + final session = DescopeSession.fromJwt(jwt, jwt, dummyUser); + Descope.sessionManager.manageSession(session); + } + _onLoginSuccess(jwt, provider: res['provider'] as String?); + } + } catch (e) { + debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e"); + if (mounted) { + _showError("Verification failed: $e"); + } + } + } + @override void dispose() { _stopQrPolling(); @@ -271,9 +372,10 @@ class _LoginScreenState extends ConsumerState try { final res = await AuthProxyService.loginWithPassword(loginId, password); final jwt = res['sessionJwt']; + final provider = res['provider'] as String?; if (jwt != null && mounted) { Navigator.of(context).pop(); // 로딩 닫기 - _onLoginSuccess(jwt); + _onLoginSuccess(jwt, provider: provider); } } catch (e) { if (mounted) Navigator.of(context).pop(); // 로딩 닫기 @@ -326,11 +428,42 @@ class _LoginScreenState extends ConsumerState // 1. Init via Backend API final initResponse = await AuthProxyService.initEnchantedLink(loginId); final pendingRef = initResponse['pendingRef']; - debugPrint("[Auth] Link Sent. PendingRef: $pendingRef"); + final mode = (initResponse['mode'] ?? '').toString(); + final provider = (initResponse['provider'] ?? '').toString(); + final interval = initResponse['interval']; + debugPrint("[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider"); if (mounted) { Navigator.of(context).pop(); // Close Loading + if (mode == 'link' || provider.toLowerCase().contains('ory')) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: Text(isEmail ? "이메일 전송됨" : "SMS 전송됨"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(isEmail + ? "입력하신 이메일로 로그인 링크를 보냈습니다." + : "입력하신 번호로 로그인 링크를 보냈습니다."), + const SizedBox(height: 12), + const Text("메일/문자 링크를 열면 이 탭에서 자동으로 로그인됩니다."), + const SizedBox(height: 16), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text("닫기"), + ) + ], + ), + ), + ); + return; + } + showDialog( context: context, barrierDismissible: false, @@ -339,9 +472,9 @@ class _LoginScreenState extends ConsumerState content: Column( mainAxisSize: MainAxisSize.min, children: [ - Text(isEmail - ? "입력하신 이메일로 로그인 링크를 보냈습니다." - : "입력하신 번호로 로그인 링크를 보냈습니다."), + Text(isEmail + ? "입력하신 이메일로 로그인 링크를 보냈습니다." + : "입력하신 번호로 로그인 링크를 보냈습니다."), const SizedBox(height: 16), const LinearProgressIndicator(), const SizedBox(height: 16), @@ -349,8 +482,8 @@ class _LoginScreenState extends ConsumerState onPressed: () { debugPrint("[Auth] Polling canceled by user"); Navigator.of(context).pop(); - }, - child: const Text("취소") + }, + child: const Text("취소"), ) ], ), @@ -358,7 +491,10 @@ class _LoginScreenState extends ConsumerState ); // 2. Poll Backend manually - _pollForSession(pendingRef); + final initialInterval = (interval is int && interval > 0) + ? Duration(seconds: interval) + : const Duration(seconds: 2); + _pollForSession(pendingRef, initialInterval: initialInterval); } } catch (e) { debugPrint("[Auth] Initialization failed: $e"); @@ -371,18 +507,39 @@ class _LoginScreenState extends ConsumerState } } - Future _pollForSession(String pendingRef) async { + Future _pollForSession(String pendingRef, {Duration? initialInterval}) async { int attempts = 0; const maxAttempts = 60; // 2 minutes + var pollInterval = initialInterval ?? const Duration(seconds: 2); debugPrint("[Auth] Starting poll for ref: $pendingRef"); while (attempts < maxAttempts && mounted) { - await Future.delayed(const Duration(seconds: 2)); + await Future.delayed(pollInterval); attempts++; try { final result = await AuthProxyService.pollEnchantedLink(pendingRef); + if (result['error'] == 'slow_down') { + final interval = result['interval']; + if (interval is int && interval > 0) { + pollInterval = Duration(seconds: interval); + } else { + pollInterval += const Duration(seconds: 1); + } + continue; + } + if (result['error'] == 'authorization_pending') { + continue; + } + if (result['error'] == 'expired_token') { + if (mounted) { + Navigator.of(context).pop(); // Close Polling Dialog + _showError("Login timed out."); + } + return; + } + if (result['status'] == 'ok') { final jwt = result['sessionJwt']; if (jwt != null) { @@ -452,23 +609,31 @@ class _LoginScreenState extends ConsumerState } } - void _onLoginSuccess(String token) async { + void _onLoginSuccess(String token, {String? provider}) async { if (!mounted) return; _logTokenDetails(token); final userId = _getUserIdFromJwt(token); + final providerName = provider ?? AuthTokenStore.getProvider(); + final isJwt = token.split('.').length == 3; + final isOry = (providerName ?? '').toLowerCase().contains('ory') || !isJwt; + + AuthTokenStore.setToken(token, provider: providerName); + AuthTokenStore.clearPendingProvider(); // [New] 로그인 성공 직후 백엔드에서 전체 프로필 정보를 가져와 세션 업데이트 try { - // 임시 세션 생성 (API 호출을 위해) - final tempUser = DescopeUser(userId, [], 0, 'User', null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], []); - final tempSession = DescopeSession.fromJwt(token, token, tempUser); - Descope.sessionManager.manageSession(tempSession); + if (!isOry) { + // 임시 세션 생성 (API 호출을 위해) + final tempUser = DescopeUser(userId, [], 0, 'User', null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], []); + final tempSession = DescopeSession.fromJwt(token, token, tempUser); + Descope.sessionManager.manageSession(tempSession); + } // 백엔드 GetMe 호출 (프로필 노티파이어 사용) final profile = await ref.read(profileProvider.notifier).loadProfile(); - if (profile != null) { + if (profile != null && !isOry) { // 실제 정보로 세션 유저 정보 교체 final realUser = DescopeUser( userId, [], 0, profile.name, null, profile.email, false, profile.phone, false, {}, '', '', '', false, 'enabled', [], [], [], @@ -480,14 +645,6 @@ class _LoginScreenState extends ConsumerState debugPrint("[Auth] Failed to pre-fetch profile: $e"); } - // Record Audit Log - AuditService.logEvent( - userId: userId, - eventType: "LOGIN_SUCCESS", - status: "SUCCESS", - details: "User logged in via Baron SSO", - ); - // 1. Handle Popup Flow if (WebAuthIntegration.isPopup()) { debugPrint("[Auth] Popup detected. Notifying opener and attempting to close."); @@ -680,6 +837,14 @@ class _LoginScreenState extends ConsumerState ), ), const SizedBox(height: 8), + if (_qrUserCode != null) ...[ + Text( + "코드: $_qrUserCode", + textAlign: TextAlign.center, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + ], const Text( "모바일 앱으로 스캔하세요", textAlign: TextAlign.center, @@ -727,4 +892,4 @@ class _LoginScreenState extends ConsumerState ), ); } -} \ No newline at end of file +} diff --git a/userfront/lib/features/auth/presentation/reset_password_screen.dart b/userfront/lib/features/auth/presentation/reset_password_screen.dart index ae29d76b..ae9fad26 100644 --- a/userfront/lib/features/auth/presentation/reset_password_screen.dart +++ b/userfront/lib/features/auth/presentation/reset_password_screen.dart @@ -110,13 +110,17 @@ class _ResetPasswordScreenState extends State { if (_isPolicyLoading) { return "비밀번호 정책을 불러오는 중입니다..."; } - final minLength = (_policy?['minLength'] as int?) ?? 8; + final minLength = (_policy?['minLength'] as int?) ?? 12; + final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0; final requiresLower = _policy?['lowercase'] ?? true; - final requiresUpper = _policy?['uppercase'] ?? true; + final requiresUpper = _policy?['uppercase'] ?? false; final requiresNumber = _policy?['number'] ?? true; final requiresSymbol = _policy?['nonAlphanumeric'] ?? true; final parts = ["최소 ${minLength}자 이상"]; + if (minTypes > 0) { + parts.add("영문 대/소문자/숫자/특수문자 중 ${minTypes}가지 이상"); + } if (requiresLower) parts.add("소문자 1개 이상"); if (requiresUpper) parts.add("대문자 1개 이상"); if (requiresNumber) parts.add("숫자 1개 이상"); @@ -182,20 +186,35 @@ class _ResetPasswordScreenState extends State { if (val.isEmpty) { return '비밀번호를 입력해주세요.'; } - final minLength = (_policy?['minLength'] as int?) ?? 8; + final minLength = (_policy?['minLength'] as int?) ?? 12; if (val.length < minLength) { return '비밀번호는 최소 $minLength자 이상이어야 합니다.'; } - if ((_policy?['lowercase'] ?? true) && !RegExp(r'(?=.*[a-z])').hasMatch(val)) { + final hasLower = RegExp(r'[a-z]').hasMatch(val); + final hasUpper = RegExp(r'[A-Z]').hasMatch(val); + final hasNumber = RegExp(r'[0-9]').hasMatch(val); + final hasSymbol = RegExp(r'[\W_]').hasMatch(val); + int typeCount = 0; + if (hasLower) typeCount++; + if (hasUpper) typeCount++; + if (hasNumber) typeCount++; + if (hasSymbol) typeCount++; + + final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0; + if (minTypes > 0 && typeCount < minTypes) { + return '비밀번호는 영문 대/소문자/숫자/특수문자 중 $minTypes가지 이상 포함해야 합니다.'; + } + + if ((_policy?['lowercase'] ?? true) && !hasLower) { return '최소 1개 이상의 소문자를 포함해야 합니다.'; } - if ((_policy?['uppercase'] ?? true) && !RegExp(r'(?=.*[A-Z])').hasMatch(val)) { + if ((_policy?['uppercase'] ?? false) && !hasUpper) { return '최소 1개 이상의 대문자를 포함해야 합니다.'; } - if ((_policy?['number'] ?? true) && !RegExp(r'(?=.*\d)').hasMatch(val)) { + if ((_policy?['number'] ?? true) && !hasNumber) { return '최소 1개 이상의 숫자를 포함해야 합니다.'; } - if ((_policy?['nonAlphanumeric'] ?? true) && !RegExp(r'(?=.*[\W_])').hasMatch(val)) { + if ((_policy?['nonAlphanumeric'] ?? true) && !hasSymbol) { return '최소 1개 이상의 특수문자를 포함해야 합니다.'; } return null; diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index 82cfb6b7..7d2a3da4 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -781,36 +781,47 @@ class _SignupScreenState extends State { if (_isPolicyLoading) { return "비밀번호 정책을 불러오는 중입니다..."; } - final minLength = (_policy?['minLength'] as int?) ?? 8; + final minLength = (_policy?['minLength'] as int?) ?? 12; + final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0; final requiresLower = _policy?['lowercase'] ?? true; - final requiresUpper = _policy?['uppercase'] ?? true; + final requiresUpper = _policy?['uppercase'] ?? false; final requiresNumber = _policy?['number'] ?? true; final requiresSymbol = _policy?['nonAlphanumeric'] ?? true; final parts = ["최소 $minLength자 이상"]; + if (minTypes > 0) { + parts.add("영문 대/소문자/숫자/특수문자 중 ${minTypes}가지 이상"); + } if (requiresUpper) parts.add("대문자"); if (requiresLower) parts.add("소문자"); if (requiresNumber) parts.add("숫자"); if (requiresSymbol) parts.add("특수문자"); - return "보안 정책: ${parts.join(', ')}를 각각 최소 1자 이상 포함해야 합니다."; + return "보안 정책: ${parts.join(', ')}"; } Widget _buildStepPassword() { String p = _passwordController.text; // Default Policy Fallback - final minLength = (_policy?['minLength'] as int?) ?? 8; + final minLength = (_policy?['minLength'] as int?) ?? 12; + final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0; final requiresLower = _policy?['lowercase'] ?? true; - final requiresUpper = _policy?['uppercase'] ?? true; + final requiresUpper = _policy?['uppercase'] ?? false; final requiresNumber = _policy?['number'] ?? true; final requiresSymbol = _policy?['nonAlphanumeric'] ?? true; bool hasLength = p.length >= minLength; - bool hasUpper = !requiresUpper || p.contains(RegExp(r'[A-Z]')); - bool hasLower = !requiresLower || p.contains(RegExp(r'[a-z]')); - bool hasDigit = !requiresNumber || p.contains(RegExp(r'[0-9]')); - bool hasSpecial = !requiresSymbol || p.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]')); + bool hasUpper = p.contains(RegExp(r'[A-Z]')); + bool hasLower = p.contains(RegExp(r'[a-z]')); + bool hasDigit = p.contains(RegExp(r'[0-9]')); + bool hasSpecial = p.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]')); + int typeCount = 0; + if (hasUpper) typeCount++; + if (hasLower) typeCount++; + if (hasDigit) typeCount++; + if (hasSpecial) typeCount++; + bool hasTypeCount = minTypes <= 0 || typeCount >= minTypes; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -850,6 +861,7 @@ class _SignupScreenState extends State { spacing: 10, children: [ _cryptoCheck('$minLength자 이상', hasLength), + if (minTypes > 0) _cryptoCheck('문자 유형 ${minTypes}가지 이상', hasTypeCount), if (requiresUpper) _cryptoCheck('대문자', hasUpper), if (requiresLower) _cryptoCheck('소문자', hasLower), if (requiresNumber) _cryptoCheck('숫자', hasDigit), @@ -967,4 +979,4 @@ class _SignupScreenState extends State { ), ); } -} \ No newline at end of file +} diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 3c5eb135..da0cf4c4 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -1,15 +1,19 @@ import 'package:flutter/material.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 '../../../../core/notifiers/auth_notifier.dart'; +import '../../../../core/services/auth_token_store.dart'; +import '../../profile/domain/notifiers/profile_notifier.dart'; -class DashboardScreen extends StatelessWidget { +class DashboardScreen extends ConsumerWidget { const DashboardScreen({super.key}); Future _logout(BuildContext context) async { // ignore: use_build_context_synchronously Descope.sessionManager.clearSession(); + AuthTokenStore.clear(); AuthNotifier.instance.notify(); } @@ -18,9 +22,16 @@ class DashboardScreen extends StatelessWidget { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final profile = ref.watch(profileProvider).value; final user = Descope.sessionManager.session?.user; - final userName = user?.name ?? user?.email ?? user?.phone ?? 'User'; + final userName = user?.name ?? + user?.email ?? + user?.phone ?? + profile?.name ?? + profile?.email ?? + profile?.phone ?? + 'User'; return Scaffold( backgroundColor: Colors.grey[50], diff --git a/userfront/lib/features/profile/data/repositories/profile_repository.dart b/userfront/lib/features/profile/data/repositories/profile_repository.dart index 005df473..4b3ed55c 100644 --- a/userfront/lib/features/profile/data/repositories/profile_repository.dart +++ b/userfront/lib/features/profile/data/repositories/profile_repository.dart @@ -1,8 +1,8 @@ import 'dart:convert'; -import 'package:http/http.dart' as http; import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../models/user_profile_model.dart'; -import 'package:descope/descope.dart'; +import '../../../../core/services/auth_token_store.dart'; +import '../../../../core/services/http_client.dart'; class ProfileRepository { static String _envOrDefault(String key, String fallback) { @@ -16,22 +16,26 @@ class ProfileRepository { // Helper to get session token static Future _getToken() async { - final session = await Descope.sessionManager.session; - return session?.sessionToken.jwt; + return AuthTokenStore.getToken(); } Future getMyProfile() async { final token = await _getToken(); - if (token == null) throw Exception('No active session'); + final useCookie = AuthTokenStore.usesCookie(); + if (token == null && !useCookie) { + throw Exception('No active session'); + } final url = Uri.parse('$_baseUrl/api/v1/user/me'); - final response = await http.get( - url, - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer $token', - }, - ); + 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) { return UserProfile.fromJson(jsonDecode(response.body)); @@ -46,21 +50,27 @@ class ProfileRepository { required String department, }) async { final token = await _getToken(); - if (token == null) throw Exception('No active session'); + final useCookie = AuthTokenStore.usesCookie(); + if (token == null && !useCookie) throw Exception('No active session'); final url = Uri.parse('$_baseUrl/api/v1/user/me'); - final response = await http.put( + final client = createHttpClient(withCredentials: useCookie); + final headers = { + 'Content-Type': 'application/json', + }; + if (!useCookie && token != null) { + headers['Authorization'] = 'Bearer $token'; + } + final response = await client.put( url, - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer $token', - }, + headers: headers, body: jsonEncode({ 'name': name, 'phone': phone, 'department': department, }), ); + client.close(); if (response.statusCode != 200) { throw Exception('Failed to update profile: ${response.body}'); @@ -69,17 +79,23 @@ class ProfileRepository { Future sendUpdateCode(String phone) async { final token = await _getToken(); - if (token == null) throw Exception('No active session'); + final useCookie = AuthTokenStore.usesCookie(); + if (token == null && !useCookie) throw Exception('No active session'); final url = Uri.parse('$_baseUrl/api/v1/user/me/send-code'); - final response = await http.post( + final client = createHttpClient(withCredentials: useCookie); + final headers = { + 'Content-Type': 'application/json', + }; + if (!useCookie && token != null) { + headers['Authorization'] = 'Bearer $token'; + } + final response = await client.post( url, - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer $token', - }, + headers: headers, body: jsonEncode({'phone': phone}), ); + client.close(); if (response.statusCode != 200) { throw Exception('인증번호 전송 실패: ${response.body}'); @@ -88,17 +104,23 @@ class ProfileRepository { Future verifyUpdateCode(String phone, String code) async { final token = await _getToken(); - if (token == null) throw Exception('No active session'); + final useCookie = AuthTokenStore.usesCookie(); + if (token == null && !useCookie) throw Exception('No active session'); final url = Uri.parse('$_baseUrl/api/v1/user/me/verify-code'); - final response = await http.post( + final client = createHttpClient(withCredentials: useCookie); + final headers = { + 'Content-Type': 'application/json', + }; + if (!useCookie && token != null) { + headers['Authorization'] = 'Bearer $token'; + } + final response = await client.post( url, - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer $token', - }, + headers: headers, body: jsonEncode({'phone': phone, 'code': code}), ); + client.close(); if (response.statusCode != 200) { throw Exception('인증 실패: ${response.body}'); diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index 8aa98188..5a4b23e7 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -17,6 +17,7 @@ import 'features/admin/presentation/user_management_screen.dart'; import 'features/profile/presentation/pages/profile_page.dart'; import 'features/profile/presentation/pages/edit_profile_page.dart'; import 'core/services/auth_proxy_service.dart'; +import 'core/services/auth_token_store.dart'; import 'core/services/logger_service.dart'; import 'core/notifiers/auth_notifier.dart'; import 'package:logging/logging.dart'; @@ -108,6 +109,13 @@ final _router = GoRouter( return const SignupScreen(); }, ), + GoRoute( + path: '/verify', + builder: (context, state) { + _routerLogger.info("Navigating to /verify (query)"); + return const LoginScreen(); + }, + ), GoRoute( path: '/verify/:token', builder: (context, state) { @@ -157,13 +165,17 @@ final _router = GoRouter( ), ], redirect: (context, state) { - final isLoggedIn = + final hasDescopeSession = Descope.sessionManager.session?.refreshToken?.isExpired == false; + final hasStoredToken = AuthTokenStore.getToken() != null; + final hasCookieSession = AuthTokenStore.usesCookie(); + final isLoggedIn = hasDescopeSession || hasStoredToken || hasCookieSession; final path = state.uri.path; // Public paths that don't require login final isPublicPath = path == '/signin' || path == '/signup' || + path == '/verify' || path.startsWith('/verify/') || path == '/approve' || path == '/forgot-password' || From 742964cf71eb024fbecb66aa9986e05ae1802937 Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Thu, 29 Jan 2026 09:28:48 +0900 Subject: [PATCH 4/8] =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EB=B0=9C=EC=86=A1=20?= =?UTF-8?q?=EC=A4=91=EA=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 1 + backend/internal/domain/idp_models.go | 2 + backend/internal/handler/auth_handler.go | 278 ++++++++++++++++-- backend/internal/service/ory_service.go | 146 ++++++++- compose.infra.yaml | 1 + .../login_code/valid/sms.body.gotmpl | 2 +- test/test_sms.py | 2 +- .../lib/core/services/auth_proxy_service.dart | 29 +- .../auth/presentation/login_screen.dart | 244 ++++++++++----- userfront/lib/main.dart | 8 + 10 files changed, 603 insertions(+), 110 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 4434aad2..a75b8336 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -410,6 +410,7 @@ func main() { auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink) auth.Post("/magic-link/verify", authHandler.VerifyMagicLink) auth.Post("/login/code/verify", authHandler.VerifyLoginCode) + auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode) auth.Post("/password/login", authHandler.PasswordLogin) auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset) // [Changed] Use Interstitial Page for GET to prevent Scanner consumption diff --git a/backend/internal/domain/idp_models.go b/backend/internal/domain/idp_models.go index 47b4db8e..e043977f 100644 --- a/backend/internal/domain/idp_models.go +++ b/backend/internal/domain/idp_models.go @@ -58,6 +58,8 @@ type LinkLoginInit struct { ExpiresAt time.Time // Mode는 링크 로그인 완료 후 세션 처리 방식입니다. (예: "cookie") Mode string + // LoginID는 IDP에 실제 전달된 식별자입니다. + LoginID string } // IdentityProvider is the interface that all IDP adapters must implement. diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 745f263b..37825b27 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -31,6 +31,10 @@ const ( prefixSession = "enchanted_session:" prefixToken = "enchanted_token:" prefixLoginCode = "login_code_flow:" + prefixLoginCodePending = "login_code_pending:" + prefixLoginCodeSmsTarget = "login_code_sms_target:" + prefixLoginCodeSmsLookup = "login_code_sms_lookup:" + prefixLoginCodeShort = "login_code_short:" prefixPollMeta = "poll_meta:" prefixSignupEmail = "signup:email:" prefixSignupPhone = "signup:phone:" @@ -202,7 +206,7 @@ func (h *AuthHandler) SendSignupEmailCode(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"}) } - subject := "[Baron SSO] 회원가입 인증코드" + subject := "[Baron 통합로그인] 회원가입 인증코드" body := fmt.Sprintf(`

이메일 인증

@@ -252,7 +256,7 @@ func (h *AuthHandler) SendSignupSmsCode(c *fiber.Ctx) error { h.saveSignupState(key, newState, signupStateExpiration) // 4. Send SMS - content := fmt.Sprintf("[Baron SSO] 인증번호 [%s]를 입력해주세요.", code) + content := fmt.Sprintf("[Baron 통합로그인] 인증번호 [%s]를 입력해주세요.", code) go h.SmsService.SendSms(phone, content) return c.JSON(fiber.Map{"message": "Verification code sent"}) @@ -535,7 +539,7 @@ func (h *AuthHandler) SendSms(c *fiber.Ctx) error { sanitizedPhone := strings.ReplaceAll(req.PhoneNumber, "-", "") rand.Seed(time.Now().UnixNano()) code := fmt.Sprintf("%06d", rand.Intn(1000000)) - content := fmt.Sprintf("[Baron SSO] 인증번호: %s", code) + content := fmt.Sprintf("[Baron 통합로그인] 인증번호: %s", code) h.RedisService.StoreVerificationCode(sanitizedPhone, code) if err := h.SmsService.SendSms(sanitizedPhone, content); err != nil { @@ -621,8 +625,19 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { } if init, err := h.IdpProvider.InitiateLinkLogin(lookupLoginID, userfrontURL); err == nil && init != nil && init.Mode != "" { + keyLoginID := lookupLoginID + if init.LoginID != "" { + keyLoginID = init.LoginID + } if init.FlowID != "" { - _ = h.RedisService.Set(prefixLoginCode+lookupLoginID, init.FlowID, loginCodeExpiration) + _ = h.RedisService.Set(prefixLoginCode+keyLoginID, init.FlowID, loginCodeExpiration) + } + pendingRef := GenerateSecureToken(3) + h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), loginCodeExpiration) + _ = h.RedisService.Set(prefixLoginCodePending+keyLoginID, pendingRef, loginCodeExpiration) + if !strings.Contains(loginID, "@") && keyLoginID != lookupLoginID { + _ = h.RedisService.Set(prefixLoginCodeSmsTarget+keyLoginID, lookupLoginID, loginCodeExpiration) + _ = h.RedisService.Set(prefixLoginCodeSmsLookup+lookupLoginID, keyLoginID, loginCodeExpiration) } expiresIn := 0 if !init.ExpiresAt.IsZero() { @@ -630,7 +645,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { } return c.JSON(fiber.Map{ "linkId": "Sent", - "pendingRef": init.FlowID, + "pendingRef": pendingRef, "maskedEmail": loginID, "mode": init.Mode, "provider": h.IdpProvider.Name(), @@ -665,7 +680,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"}) } - subject := "[Baron SSO] 로그인 링크" + subject := "[Baron 통합로그인] 링크" body := fmt.Sprintf(`

Baron SSO 로그인

@@ -686,7 +701,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { } } else { // Send SMS - content := fmt.Sprintf("[Baron SSO] 로그인 링크: %s | 코드: %s", link, userCode) + content := fmt.Sprintf("[Baron 통합로그인] 로그인 링크: %s | 코드: %s", link, userCode) slog.Info("[Enchanted] Sending SMS via Naver Cloud", "loginID", loginID) if err := h.SmsService.SendSms(loginID, content); err != nil { @@ -714,7 +729,7 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { pollKey := prefixPollMeta + "enchanted:" + req.PendingRef if slowDown, interval := checkPollInterval(h.RedisService, pollKey, minPollInterval); slowDown { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + return c.JSON(fiber.Map{ "error": "slow_down", "interval": interval, }) @@ -722,7 +737,7 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { val, err := h.RedisService.Get(prefixSession + req.PendingRef) if err != nil || val == "" { - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "expired_token"}) + return c.JSON(fiber.Map{"error": "expired_token"}) } var data map[string]string @@ -736,7 +751,7 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { }) } - return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + return c.JSON(fiber.Map{ "error": "authorization_pending", "interval": int(minPollInterval.Seconds()), }) @@ -802,8 +817,9 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { // VerifyLoginCode - Verify Kratos login code and issue session. func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { var req struct { - LoginID string `json:"loginId"` - Code string `json:"code"` + LoginID string `json:"loginId"` + Code string `json:"code"` + PendingRef string `json:"pendingRef"` } if err := c.BodyParser(&req); err != nil { slog.Error("[LoginCode] Body parse error", "error", err) @@ -843,6 +859,110 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { } h.RedisService.Delete(prefixLoginCode + lookupLoginID) + h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID) + + pendingRef := strings.TrimSpace(req.PendingRef) + if pendingRef == "" { + storedRef, _ := h.RedisService.Get(prefixLoginCodePending + lookupLoginID) + pendingRef = storedRef + } + if pendingRef != "" { + sessionData, _ := json.Marshal(map[string]string{ + "status": statusSuccess, + "jwt": authInfo.SessionToken.JWT, + }) + h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration) + h.RedisService.Delete(prefixLoginCodePending + lookupLoginID) + h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID) + return c.JSON(fiber.Map{ + "status": "approved", + "pendingRef": pendingRef, + "provider": h.IdpProvider.Name(), + "subject": authInfo.Subject, + "message": "Login approved", + }) + } + + return c.JSON(fiber.Map{ + "token": authInfo.SessionToken.JWT, + "sessionJwt": authInfo.SessionToken.JWT, + "provider": h.IdpProvider.Name(), + "subject": authInfo.Subject, + "message": "Login successful", + }) +} + +// VerifyLoginShortCode - Verify short code (2 letters + 6 digits) and issue/approve session. +func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error { + var req struct { + ShortCode string `json:"shortCode"` + } + if err := c.BodyParser(&req); err != nil { + slog.Error("[LoginShortCode] Body parse error", "error", err) + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + + shortCode := strings.ToUpper(strings.TrimSpace(req.ShortCode)) + if shortCode == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "shortCode is required"}) + } + + val, _ := h.RedisService.Get(prefixLoginCodeShort + shortCode) + if val == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired code"}) + } + + var payload shortLoginCodePayload + if err := json.Unmarshal([]byte(val), &payload); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Invalid code payload"}) + } + if payload.LoginID == "" || payload.Code == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired code"}) + } + + if h.IdpProvider == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"}) + } + + flowID, err := h.RedisService.Get(prefixLoginCode + payload.LoginID) + if err != nil || flowID == "" { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Login flow expired"}) + } + + authInfo, err := h.IdpProvider.VerifyLoginCode(payload.LoginID, flowID, payload.Code) + if err != nil { + if errors.Is(err, domain.ErrNotSupported) { + return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"}) + } + slog.Error("[LoginShortCode] Verify failed", "loginID", payload.LoginID, "error", err) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid code"}) + } + if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"}) + } + + h.RedisService.Delete(prefixLoginCode + payload.LoginID) + h.RedisService.Delete(prefixLoginCodeShort + shortCode) + h.RedisService.Delete(prefixLoginCodeSmsTarget + payload.LoginID) + h.RedisService.Delete(prefixLoginCodeSmsLookup + payload.LoginID) + + if payload.PendingRef != "" { + sessionData, _ := json.Marshal(map[string]string{ + "status": statusSuccess, + "jwt": authInfo.SessionToken.JWT, + }) + h.RedisService.Set(prefixSession+payload.PendingRef, string(sessionData), loginCodeExpiration) + h.RedisService.Delete(prefixLoginCodePending + payload.LoginID) + return c.JSON(fiber.Map{ + "status": "approved", + "pendingRef": payload.PendingRef, + "token": authInfo.SessionToken.JWT, + "sessionJwt": authInfo.SessionToken.JWT, + "provider": h.IdpProvider.Name(), + "subject": authInfo.Subject, + "message": "Login approved", + }) + } return c.JSON(fiber.Map{ "token": authInfo.SessionToken.JWT, @@ -998,7 +1118,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { ale.Log(slog.LevelError, "Email service not configured") return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"}) } - subject := "[Baron SSO] 비밀번호 재설정" + subject := "[Baron 통합로그인] 비밀번호 재설정" body := fmt.Sprintf(`

Baron SSO 비밀번호 재설정

@@ -1017,7 +1137,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send reset email"}) } } else { - if err := h.SmsService.SendSms(loginID, fmt.Sprintf("[Baron SSO] 비밀번호 재설정 링크: %s", resetLink)); err != nil { + if err := h.SmsService.SendSms(loginID, fmt.Sprintf("[Baron 통합로그인] 비밀번호 재설정 링크: %s", resetLink)); err != nil { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.DescopeError = err.Error() @@ -1350,6 +1470,22 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Empty message"}) } + if strings.Contains(req.Recipient, "@") { + if target, _ := h.RedisService.Get(prefixLoginCodeSmsTarget + req.Recipient); target != "" { + phone := sanitizePhoneForSms(target) + smsBody := h.buildKratosShortSmsBody(&req, req.Recipient, phone) + if smsBody == "" { + smsBody = body + } + if err := h.SmsService.SendSms(phone, smsBody); err != nil { + slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) + } + slog.Info("[Kratos Courier] SMS sent (email relay)", "to", phone, "template", req.TemplateType) + return c.JSON(fiber.Map{"status": "ok"}) + } + } + if strings.Contains(req.Recipient, "@") { if h.EmailService == nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"}) @@ -1366,7 +1502,20 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "SMS service not configured"}) } phone := sanitizePhoneForSms(req.Recipient) - if err := h.SmsService.SendSms(phone, body); err != nil { + loginID := req.Recipient + if !strings.Contains(loginID, "@") { + lookup := normalizePhoneForLoginID(loginID) + if email, _ := h.RedisService.Get(prefixLoginCodeSmsLookup + lookup); email != "" { + loginID = email + } else { + loginID = lookup + } + } + smsBody := h.buildKratosShortSmsBody(&req, loginID, phone) + if smsBody == "" { + smsBody = body + } + if err := h.SmsService.SendSms(phone, smsBody); err != nil { slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) } @@ -1379,7 +1528,7 @@ func (h *AuthHandler) buildKratosCourierMessage(req *kratosCourierRequest) (stri body := strings.TrimSpace(req.Body) if body != "" || subject != "" { if subject == "" { - subject = "[Baron SSO] 알림" + subject = "[Baron 통합로그인] 알림" } return subject, body } @@ -1403,23 +1552,38 @@ func (h *AuthHandler) buildKratosCourierMessage(req *kratosCourierRequest) (stri if subject == "" { if label == "알림" { - subject = "[Baron SSO] 알림" + subject = "[Baron 통합로그인] 알림" } else { - subject = fmt.Sprintf("[Baron SSO] %s 코드", label) + subject = fmt.Sprintf("[Baron 통합로그인] %s 코드", label) } } if code == "" { - return subject, fmt.Sprintf("[Baron SSO] %s 요청이 도착했습니다", label) + return subject, fmt.Sprintf("[Baron 통합로그인] %s 요청이 도착했습니다", label) } - message := fmt.Sprintf("[Baron SSO] %s 코드: %s", label, code) + message := fmt.Sprintf("[Baron 통합로그인] %s 코드: %s", label, code) if label == "로그인" { baseURL := os.Getenv("USERFRONT_URL") if baseURL == "" { baseURL = "http://localhost:5000" } baseURL = strings.TrimRight(baseURL, "/") + loginID := req.Recipient + if !strings.Contains(loginID, "@") { + loginID = normalizePhoneForLoginID(loginID) + } + pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID) + if pendingRef != "" { + message = fmt.Sprintf("%s | 링크: %s/verify?loginId=%s&code=%s&pendingRef=%s", + message, + baseURL, + url.QueryEscape(req.Recipient), + url.QueryEscape(code), + url.QueryEscape(pendingRef), + ) + return subject, message + } link := fmt.Sprintf("%s/verify?loginId=%s&code=%s", baseURL, url.QueryEscape(req.Recipient), @@ -1431,6 +1595,78 @@ func (h *AuthHandler) buildKratosCourierMessage(req *kratosCourierRequest) (stri return subject, message } +type shortLoginCodePayload struct { + LoginID string `json:"loginId"` + Code string `json:"code"` + PendingRef string `json:"pendingRef"` +} + +func (h *AuthHandler) buildKratosShortSmsBody(req *kratosCourierRequest, loginID, phone string) string { + if req == nil || loginID == "" { + return "" + } + code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code")) + if code == "" { + return "" + } + shortCode := h.generateShortCode(code) + if shortCode == "" { + return "" + } + + pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID) + payload := shortLoginCodePayload{ + LoginID: loginID, + Code: code, + PendingRef: pendingRef, + } + raw, _ := json.Marshal(payload) + _ = h.RedisService.Set(prefixLoginCodeShort+shortCode, string(raw), loginCodeExpiration) + + baseURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/") + if baseURL == "" { + baseURL = "http://localhost:5000" + } + + link := fmt.Sprintf("%s/l/%s", baseURL, shortCode) + return fmt.Sprintf("[Baron 통합로그인] %s", link) +} + +func (h *AuthHandler) generateShortCode(code string) string { + const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + for i := 0; i < 10; i++ { + b := make([]byte, 2) + if _, err := crand.Read(b); err != nil { + break + } + prefix := string(letters[int(b[0])%len(letters)]) + string(letters[int(b[1])%len(letters)]) + shortCode := prefix + code + if val, _ := h.RedisService.Get(prefixLoginCodeShort + shortCode); val == "" { + return shortCode + } + } + return "" +} + +func normalizeLoginCode(code string) string { + if code == "" { + return "" + } + digits := make([]rune, 0, len(code)) + for _, ch := range code { + if ch >= '0' && ch <= '9' { + digits = append(digits, ch) + } + } + if len(digits) < 6 { + return "" + } + if len(digits) > 6 { + digits = digits[:6] + } + return string(digits) +} + func firstNonEmpty(values ...string) string { for _, value := range values { if value != "" { @@ -1941,7 +2177,7 @@ func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error { h.RedisService.Set(key, code, 5*time.Minute) // Send SMS - content := fmt.Sprintf("[Baron SSO] 정보 수정 인증번호: [%s]", code) + content := fmt.Sprintf("[Baron 통합로그인] 정보 수정 인증번호: [%s]", code) go h.SmsService.SendSms(phone, content) return c.JSON(fiber.Map{"message": "인증번호가 전송되었습니다."}) diff --git a/backend/internal/service/ory_service.go b/backend/internal/service/ory_service.go index 655455a2..9bc54e4f 100644 --- a/backend/internal/service/ory_service.go +++ b/backend/internal/service/ory_service.go @@ -232,22 +232,64 @@ func (o *OryProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkL return nil, fmt.Errorf("ory provider: loginID is required") } - init, err := o.submitLoginCodeInit(loginID, returnTo) + effectiveLoginID, err := o.resolveEffectiveLoginID(loginID) + if err != nil { + return nil, err + } + + if err := o.ensureCodeLoginIdentifier(effectiveLoginID); err != nil { + return nil, err + } + + init, err := o.submitLoginCodeInit(effectiveLoginID, returnTo) if err == nil { + init.LoginID = effectiveLoginID return init, nil } if shouldBootstrapCodeLogin(err) { - if ensureErr := o.ensureCodeLoginIdentifier(loginID); ensureErr == nil { - return o.submitLoginCodeInit(loginID, returnTo) + if ensureErr := o.ensureCodeLoginIdentifier(effectiveLoginID); ensureErr == nil { + init, initErr := o.submitLoginCodeInit(effectiveLoginID, returnTo) + if initErr == nil { + init.LoginID = effectiveLoginID + } + return init, initErr } else { - slog.Warn("Ory code login bootstrap failed", "loginID", loginID, "error", ensureErr) + slog.Warn("Ory code login bootstrap failed", "loginID", effectiveLoginID, "error", ensureErr) } } return nil, err } +func (o *OryProvider) resolveEffectiveLoginID(loginID string) (string, error) { + if strings.Contains(loginID, "@") { + return loginID, nil + } + + identityID, err := o.findIdentityID(loginID) + if err != nil { + return "", err + } + if identityID == "" { + return "", fmt.Errorf("ory provider: identity not found for loginID=%s", loginID) + } + + fullIdentity, err := o.fetchIdentityFull(identityID) + if err != nil { + return "", err + } + if fullIdentity != nil { + if emailRaw, ok := fullIdentity.Traits["email"]; ok { + if email, ok := emailRaw.(string); ok && email != "" { + return email, nil + } + } + } + + return "", fmt.Errorf("ory provider: email trait missing for loginID=%s", loginID) +} + func (o *OryProvider) submitLoginCodeInit(loginID, returnTo string) (*domain.LinkLoginInit, error) { flowID, err := o.startLoginFlow(returnTo) if err != nil { @@ -404,13 +446,83 @@ func (o *OryProvider) ensureCodeLoginIdentifier(loginID string) error { return nil } - return o.patchIdentity(identityID, ops) + if err := o.patchIdentity(identityID, ops); err != nil { + slog.Warn("Ory identity patch failed, trying full update", "identity_id", identityID, "error", err) + } + + fullIdentity, err := o.fetchIdentityFull(identityID) + if err != nil { + return err + } + + addresses = make([]kratosVerifiableAddress, 0, len(fullIdentity.VerifiableAddresses)+1) + found := false + for _, addr := range fullIdentity.VerifiableAddresses { + addresses = append(addresses, kratosVerifiableAddress{ + Value: addr.Value, + Via: addr.Via, + Verified: addr.Verified, + Status: addr.Status, + }) + if addr.Value == loginID && addr.Via == via { + found = true + } + } + if !found { + addresses = append(addresses, kratosVerifiableAddress{ + Value: loginID, + Via: via, + Verified: true, + Status: "completed", + }) + } + + payload := map[string]interface{}{ + "schema_id": fullIdentity.SchemaID, + "traits": fullIdentity.Traits, + "verifiable_addresses": addresses, + } + if len(fullIdentity.RecoveryAddresses) > 0 { + payload["recovery_addresses"] = fullIdentity.RecoveryAddresses + } + + body, _ := json.Marshal(payload) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("ory provider: build identity update failed: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := o.httpClient().Do(req) + if err != nil { + return fmt.Errorf("ory provider: identity update failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return fmt.Errorf("ory provider: identity update failed status=%d body=%s", resp.StatusCode, string(respBody)) + } + + slog.Info("Ory identity updated with verifiable address", "identity_id", identityID, "loginID", loginID, "via", via) + return nil } type kratosIdentity struct { VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"` } +type kratosRecoveryAddress struct { + Value string `json:"value"` + Via string `json:"via"` +} + +type kratosIdentityFull struct { + SchemaID string `json:"schema_id"` + Traits map[string]interface{} `json:"traits"` + VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"` + RecoveryAddresses []kratosRecoveryAddress `json:"recovery_addresses"` +} + func (o *OryProvider) patchIdentity(identityID string, ops []map[string]interface{}) error { body, _ := json.Marshal(ops) req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body)) @@ -457,6 +569,30 @@ func (o *OryProvider) fetchIdentity(identityID string) (*kratosIdentity, error) return &identity, nil } +func (o *OryProvider) fetchIdentityFull(identityID string) (*kratosIdentityFull, error) { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), nil) + if err != nil { + return nil, fmt.Errorf("ory provider: build identity get failed: %w", err) + } + + resp, err := o.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("ory provider: identity get failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("ory provider: identity get failed status=%d body=%s", resp.StatusCode, string(body)) + } + + var identity kratosIdentityFull + if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil { + return nil, fmt.Errorf("ory provider: decode identity failed: %w", err) + } + return &identity, nil +} + // VerifyLoginCode는 Kratos 로그인 코드 제출로 세션을 발급합니다. func (o *OryProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) { if loginID == "" || flowID == "" || code == "" { diff --git a/compose.infra.yaml b/compose.infra.yaml index f53bfe4e..2baa1405 100644 --- a/compose.infra.yaml +++ b/compose.infra.yaml @@ -27,6 +27,7 @@ services: clickhouse: image: clickhouse/clickhouse-server:latest container_name: baron_clickhouse + restart: always environment: CLICKHOUSE_USER: ${CLICKHOUSE_USER:-baron} CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-password} diff --git a/docker/ory/kratos/courier-templates/login_code/valid/sms.body.gotmpl b/docker/ory/kratos/courier-templates/login_code/valid/sms.body.gotmpl index f3f6fdf4..cacba938 100644 --- a/docker/ory/kratos/courier-templates/login_code/valid/sms.body.gotmpl +++ b/docker/ory/kratos/courier-templates/login_code/valid/sms.body.gotmpl @@ -1,4 +1,4 @@ -[Baron SSO] 로그인 링크 +[Baron 통합로그인] 로그인 링크 # 운영 링크: https://app.brsw.kr/verify?loginId={{ .To }}&code={{ .LoginCode }} http://localhost:5000/verify?loginId={{ .To }}&code={{ .LoginCode }} 코드: {{ .LoginCode }} diff --git a/test/test_sms.py b/test/test_sms.py index 3ce0c0bb..a8cc4520 100644 --- a/test/test_sms.py +++ b/test/test_sms.py @@ -61,7 +61,7 @@ def main(): "contentType": "COMM", "countryCode": "82", "from": sender_phone, - "content": "[Baron SSO] Test message from Python script.", + "content": "[Baron 통합로그인] Test message from Python script.", "messages": [ { "to": recipient_phone diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 0cc15c0e..43bd815d 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -104,15 +104,38 @@ class AuthProxyService { } } - static Future> verifyLoginCode(String loginId, String code) async { + static Future> verifyLoginCode(String loginId, String code, {String? pendingRef}) async { final url = Uri.parse('$_baseUrl/api/v1/auth/login/code/verify'); + final payload = { + 'loginId': loginId, + 'code': code, + }; + if (pendingRef != null && pendingRef.isNotEmpty) { + payload['pendingRef'] = pendingRef; + } + + final response = await http.post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(payload), + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + throw Exception('Verification failed: ${response.body}'); + } + } + + static Future> verifyLoginShortCode(String shortCode) async { + final url = Uri.parse('$_baseUrl/api/v1/auth/login/code/verify-short'); + final response = await http.post( url, headers: {'Content-Type': 'application/json'}, body: jsonEncode({ - 'loginId': loginId, - 'code': code, + 'shortCode': shortCode, }), ); diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 9850654f..dd433c49 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -39,6 +39,9 @@ class _LoginScreenState extends ConsumerState int _qrRemainingSeconds = 0; Timer? _qrCountdownTimer; int _qrPollIntervalMs = 2000; + final TextEditingController _shortCodePrefixController = TextEditingController(); + final TextEditingController _shortCodeDigitsController = TextEditingController(); + String? _linkPendingRef; @override void initState() { @@ -52,8 +55,13 @@ class _LoginScreenState extends ConsumerState final uri = Uri.base; final loginIdParam = uri.queryParameters['loginId']; final codeParam = uri.queryParameters['code']; + final pendingRefParam = uri.queryParameters['pendingRef']; + if (uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l') { + final shortCode = uri.pathSegments[1]; + _verifyShortCode(shortCode); + } if (loginIdParam != null && codeParam != null) { - _verifyLoginCode(loginIdParam, codeParam); + _verifyLoginCode(loginIdParam, codeParam, pendingRef: pendingRefParam); } else if (widget.verificationToken != null) { _verifyToken(widget.verificationToken!); } else if (uri.queryParameters.containsKey('t')) { @@ -101,6 +109,12 @@ class _LoginScreenState extends ConsumerState } } + void _resetLinkLoginState() { + _linkPendingRef = null; + _shortCodePrefixController.clear(); + _shortCodeDigitsController.clear(); + } + // Helper to decode JWT and get loginId String _getLoginIdFromJwt(String jwt) { try { @@ -306,14 +320,26 @@ class _LoginScreenState extends ConsumerState } } - Future _verifyLoginCode(String loginId, String code) async { + Future _verifyLoginCode(String loginId, String code, {String? pendingRef}) async { final sanitizedLoginId = loginId.replaceAll(' ', '+'); debugPrint("[Auth] Starting code verification for loginId: $sanitizedLoginId"); try { - final res = await AuthProxyService.verifyLoginCode(sanitizedLoginId, code); + final res = await AuthProxyService.verifyLoginCode( + sanitizedLoginId, + code, + pendingRef: pendingRef, + ); final jwt = res['sessionJwt'] ?? res['token']; + final status = res['status']?.toString(); debugPrint("[Auth] Code verification successful for loginId: $sanitizedLoginId"); + if (jwt == null && status == 'approved') { + if (mounted) { + _showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); + } + return; + } + if (jwt != null && mounted) { final isJwt = (jwt as String).split('.').length == 3; if (isJwt) { @@ -334,6 +360,43 @@ class _LoginScreenState extends ConsumerState } } + Future _verifyShortCode(String shortCode) async { + final sanitized = shortCode.trim().toUpperCase(); + if (sanitized.isEmpty) return; + debugPrint("[Auth] Starting short code verification for code: $sanitized"); + try { + final res = await AuthProxyService.verifyLoginShortCode(sanitized); + final jwt = res['sessionJwt'] ?? res['token']; + final status = res['status']?.toString(); + debugPrint("[Auth] Short code verification successful"); + + if (jwt == null && status == 'approved') { + if (mounted) { + _showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); + } + return; + } + + if (jwt != null && mounted) { + final isJwt = (jwt as String).split('.').length == 3; + if (isJwt) { + final displayName = _getLoginIdFromJwt(jwt); + final dummyUser = DescopeUser( + 'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [], + ); + final session = DescopeSession.fromJwt(jwt, jwt, dummyUser); + Descope.sessionManager.manageSession(session); + } + _onLoginSuccess(jwt, provider: res['provider'] as String?); + } + } catch (e) { + debugPrint("[Auth] Short code verification FAILED. Error: $e"); + if (mounted) { + _showError("Verification failed: $e"); + } + } + } + @override void dispose() { _stopQrPolling(); @@ -341,6 +404,8 @@ class _LoginScreenState extends ConsumerState _linkIdController.dispose(); _passwordLoginIdController.dispose(); _passwordController.dispose(); + _shortCodePrefixController.dispose(); + _shortCodeDigitsController.dispose(); super.dispose(); } @@ -434,61 +499,14 @@ class _LoginScreenState extends ConsumerState debugPrint("[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider"); if (mounted) { + setState(() { + _linkPendingRef = pendingRef?.toString(); + }); Navigator.of(context).pop(); // Close Loading - if (mode == 'link' || provider.toLowerCase().contains('ory')) { - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => AlertDialog( - title: Text(isEmail ? "이메일 전송됨" : "SMS 전송됨"), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(isEmail - ? "입력하신 이메일로 로그인 링크를 보냈습니다." - : "입력하신 번호로 로그인 링크를 보냈습니다."), - const SizedBox(height: 12), - const Text("메일/문자 링크를 열면 이 탭에서 자동으로 로그인됩니다."), - const SizedBox(height: 16), - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text("닫기"), - ) - ], - ), - ), - ); - return; - } - - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => AlertDialog( - title: Text(isEmail ? "이메일 전송됨" : "SMS 전송됨"), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(isEmail - ? "입력하신 이메일로 로그인 링크를 보냈습니다." - : "입력하신 번호로 로그인 링크를 보냈습니다."), - const SizedBox(height: 16), - const LinearProgressIndicator(), - const SizedBox(height: 16), - TextButton( - onPressed: () { - debugPrint("[Auth] Polling canceled by user"); - Navigator.of(context).pop(); - }, - child: const Text("취소"), - ) - ], - ), - ), - ); + _showInfo(isEmail + ? "입력하신 이메일로 로그인 링크를 보냈습니다." + : "입력하신 번호로 로그인 링크를 보냈습니다."); // 2. Poll Backend manually final initialInterval = (interval is int && interval > 0) @@ -499,6 +517,11 @@ class _LoginScreenState extends ConsumerState } catch (e) { debugPrint("[Auth] Initialization failed: $e"); if (mounted && Navigator.canPop(context)) Navigator.of(context).pop(); + if (mounted) { + setState(() { + _linkPendingRef = null; + }); + } if (e.toString().contains("User not registered")) { _showUnregisteredDialog(); } else { @@ -584,6 +607,13 @@ class _LoginScreenState extends ConsumerState } } + void _showInfo(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), backgroundColor: Colors.green), + ); + } + void _logTokenDetails(String jwt) { try { final parts = jwt.split('.'); @@ -682,6 +712,7 @@ class _LoginScreenState extends ConsumerState FilledButton( onPressed: () { Navigator.pop(context); + _resetLinkLoginState(); context.push('/signup'); }, child: const Text("회원가입 하기"), @@ -769,35 +800,90 @@ class _LoginScreenState extends ConsumerState ), ), - // 2. 로그인 링크 전송 폼 + // 2. 로그인 링크 전송 -> 전송 후 코드 입력으로 전환 Padding( padding: const EdgeInsets.only(top: 16.0), child: Column( children: [ - TextField( - controller: _linkIdController, - decoration: const InputDecoration( - labelText: "이메일 또는 휴대폰 번호", - hintText: "", - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.person_outline), + if (_linkPendingRef == null) ...[ + TextField( + controller: _linkIdController, + decoration: const InputDecoration( + labelText: "이메일 또는 휴대폰 번호", + hintText: "", + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person_outline), + ), + onSubmitted: (_) => _handleLinkLogin(), ), - onSubmitted: (_) => _handleLinkLogin(), - ), - const SizedBox(height: 24), - FilledButton( - onPressed: _handleLinkLogin, - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(50), + const SizedBox(height: 24), + FilledButton( + onPressed: _handleLinkLogin, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(50), + ), + child: const Text("로그인 링크 전송"), ), - child: const Text("로그인 링크 전송"), - ), - const SizedBox(height: 24), - const Text( - "입력하신 정보로 로그인 링크를 전송합니다.", - style: TextStyle(color: Colors.grey, fontSize: 12), - textAlign: TextAlign.center, - ), + const SizedBox(height: 24), + const Text( + "입력하신 정보로 로그인 링크를 전송합니다.", + style: TextStyle(color: Colors.grey, fontSize: 12), + textAlign: TextAlign.center, + ), + ], + if (_linkPendingRef != null) ...[ + const Text( + "링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.", + style: TextStyle(color: Colors.grey, fontSize: 12), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + flex: 2, + child: TextField( + controller: _shortCodePrefixController, + textCapitalization: TextCapitalization.characters, + decoration: const InputDecoration( + labelText: "AA", + border: OutlineInputBorder(), + ), + maxLength: 2, + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 4, + child: TextField( + controller: _shortCodeDigitsController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: "000000", + border: OutlineInputBorder(), + ), + maxLength: 6, + ), + ), + ], + ), + const SizedBox(height: 12), + FilledButton( + onPressed: () { + final prefix = _shortCodePrefixController.text.trim().toUpperCase(); + final digits = _shortCodeDigitsController.text.trim(); + if (prefix.length != 2 || digits.length != 6) { + _showError("문자 2개와 숫자 6자리를 입력해 주세요."); + return; + } + _verifyShortCode(prefix + digits); + }, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(45), + ), + child: const Text("코드로 로그인"), + ), + ], ], ), ), diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index 5a4b23e7..1f13a44e 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -124,6 +124,14 @@ final _router = GoRouter( return LoginScreen(verificationToken: token); }, ), + GoRoute( + path: '/l/:shortCode', + builder: (context, state) { + final shortCode = state.pathParameters['shortCode']; + _routerLogger.info("Navigating to /l with code: $shortCode"); + return const LoginScreen(); + }, + ), GoRoute( path: '/forgot-password', builder: (context, state) { From 8faa08e3770e526de8799746b2fb412155264434 Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Thu, 29 Jan 2026 09:40:01 +0900 Subject: [PATCH 5/8] =?UTF-8?q?=EB=A7=81=ED=81=AC=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EB=8F=99=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/handler/auth_handler.go | 59 +++++++++++++++---- .../auth/presentation/login_screen.dart | 56 +++++++++++++++++- 2 files changed, 100 insertions(+), 15 deletions(-) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 37825b27..553ef9b6 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -28,16 +28,16 @@ import ( const ( // Redis Key Prefixes - prefixSession = "enchanted_session:" - prefixToken = "enchanted_token:" - prefixLoginCode = "login_code_flow:" - prefixLoginCodePending = "login_code_pending:" + prefixSession = "enchanted_session:" + prefixToken = "enchanted_token:" + prefixLoginCode = "login_code_flow:" + prefixLoginCodePending = "login_code_pending:" prefixLoginCodeSmsTarget = "login_code_sms_target:" prefixLoginCodeSmsLookup = "login_code_sms_lookup:" - prefixLoginCodeShort = "login_code_short:" - prefixPollMeta = "poll_meta:" - prefixSignupEmail = "signup:email:" - prefixSignupPhone = "signup:phone:" + prefixLoginCodeShort = "login_code_short:" + prefixPollMeta = "poll_meta:" + prefixSignupEmail = "signup:email:" + prefixSignupPhone = "signup:phone:" // Session Statuses statusPending = "pending" @@ -54,6 +54,7 @@ const ( pwdResetExpiration = 15 * time.Minute minPollInterval = 2 * time.Second loginCodeExpiration = 10 * time.Minute + linkResendCooldown = 60 * time.Second ) type AuthHandler struct { @@ -651,6 +652,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { "provider": h.IdpProvider.Name(), "expiresIn": expiresIn, "interval": int(minPollInterval.Seconds()), + "resendAfter": int(linkResendCooldown.Seconds()), }) } else if err != nil && !errors.Is(err, domain.ErrNotSupported) { slog.Error("[Enchanted] Link login init failed", "provider", h.IdpProvider.Name(), "error", err) @@ -716,6 +718,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { "maskedEmail": loginID, "expiresIn": int(defaultExpiration.Seconds()), "interval": int(minPollInterval.Seconds()), + "resendAfter": int(linkResendCooldown.Seconds()), "userCode": userCode, }) } @@ -1490,6 +1493,10 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { if h.EmailService == nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"}) } + if shortSubject, shortBody := h.buildKratosShortEmailBody(&req, req.Recipient); shortBody != "" { + subject = shortSubject + body = shortBody + } if err := h.EmailService.SendEmail(req.Recipient, subject, body); err != nil { slog.Error("[Kratos Courier] Email send failed", "to", req.Recipient, "error", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send email"}) @@ -1602,16 +1609,44 @@ type shortLoginCodePayload struct { } func (h *AuthHandler) buildKratosShortSmsBody(req *kratosCourierRequest, loginID, phone string) string { - if req == nil || loginID == "" { + _, link, ok := h.prepareKratosShortLogin(req, loginID) + if !ok { return "" } + return fmt.Sprintf("[Baron 통합로그인] %s", link) +} + +func (h *AuthHandler) buildKratosShortEmailBody(req *kratosCourierRequest, loginID string) (string, string) { + shortCode, link, ok := h.prepareKratosShortLogin(req, loginID) + if !ok { + return "", "" + } + subject := "[Baron 통합로그인] 로그인 링크" + body := fmt.Sprintf(` +
+

Baron SSO 로그인

+

아래 버튼을 클릭하여 로그인을 완료해 주세요.

+ +

간편 코드: %s

+

링크가 열리지 않으면 위 간편 코드를 입력해 로그인할 수 있습니다.

+
+ `, link, shortCode) + return subject, body +} + +func (h *AuthHandler) prepareKratosShortLogin(req *kratosCourierRequest, loginID string) (string, string, bool) { + if req == nil || loginID == "" { + return "", "", false + } code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code")) if code == "" { - return "" + return "", "", false } shortCode := h.generateShortCode(code) if shortCode == "" { - return "" + return "", "", false } pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID) @@ -1629,7 +1664,7 @@ func (h *AuthHandler) buildKratosShortSmsBody(req *kratosCourierRequest, loginID } link := fmt.Sprintf("%s/l/%s", baseURL, shortCode) - return fmt.Sprintf("[Baron 통합로그인] %s", link) + return shortCode, link, true } func (h *AuthHandler) generateShortCode(code string) string { diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index dd433c49..ee092bf4 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -42,6 +42,10 @@ class _LoginScreenState extends ConsumerState final TextEditingController _shortCodePrefixController = TextEditingController(); final TextEditingController _shortCodeDigitsController = TextEditingController(); String? _linkPendingRef; + String? _lastLinkLoginId; + bool _lastLinkIsEmail = true; + int _linkResendSeconds = 0; + Timer? _linkResendTimer; @override void initState() { @@ -111,10 +115,30 @@ class _LoginScreenState extends ConsumerState void _resetLinkLoginState() { _linkPendingRef = null; + _lastLinkLoginId = null; + _lastLinkIsEmail = true; + _linkResendTimer?.cancel(); + _linkResendTimer = null; + _linkResendSeconds = 0; _shortCodePrefixController.clear(); _shortCodeDigitsController.clear(); } + void _startLinkResendTimer(int seconds) { + _linkResendSeconds = seconds; + _linkResendTimer?.cancel(); + _linkResendTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) return; + setState(() { + if (_linkResendSeconds > 0) { + _linkResendSeconds--; + } else { + timer.cancel(); + } + }); + }); + } + // Helper to decode JWT and get loginId String _getLoginIdFromJwt(String jwt) { try { @@ -406,6 +430,7 @@ class _LoginScreenState extends ConsumerState _passwordController.dispose(); _shortCodePrefixController.dispose(); _shortCodeDigitsController.dispose(); + _linkResendTimer?.cancel(); super.dispose(); } @@ -496,11 +521,14 @@ class _LoginScreenState extends ConsumerState final mode = (initResponse['mode'] ?? '').toString(); final provider = (initResponse['provider'] ?? '').toString(); final interval = initResponse['interval']; + final resendAfter = initResponse['resendAfter']; debugPrint("[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider"); if (mounted) { setState(() { _linkPendingRef = pendingRef?.toString(); + _lastLinkLoginId = loginId; + _lastLinkIsEmail = isEmail; }); Navigator.of(context).pop(); // Close Loading @@ -512,15 +540,16 @@ class _LoginScreenState extends ConsumerState final initialInterval = (interval is int && interval > 0) ? Duration(seconds: interval) : const Duration(seconds: 2); + if (resendAfter is int && resendAfter > 0) { + _startLinkResendTimer(resendAfter); + } _pollForSession(pendingRef, initialInterval: initialInterval); } } catch (e) { debugPrint("[Auth] Initialization failed: $e"); if (mounted && Navigator.canPop(context)) Navigator.of(context).pop(); if (mounted) { - setState(() { - _linkPendingRef = null; - }); + setState(_resetLinkLoginState); } if (e.toString().contains("User not registered")) { _showUnregisteredDialog(); @@ -537,6 +566,9 @@ class _LoginScreenState extends ConsumerState debugPrint("[Auth] Starting poll for ref: $pendingRef"); while (attempts < maxAttempts && mounted) { + if (_linkPendingRef != pendingRef) { + return; + } await Future.delayed(pollInterval); attempts++; @@ -883,6 +915,24 @@ class _LoginScreenState extends ConsumerState ), child: const Text("코드로 로그인"), ), + const SizedBox(height: 12), + TextButton( + onPressed: _linkResendSeconds > 0 + ? null + : () { + final loginId = _lastLinkLoginId ?? _linkIdController.text.trim(); + if (loginId.isEmpty) { + _showError("이메일 또는 휴대폰 번호를 입력해 주세요."); + return; + } + _startEnchantedFlow(loginId, isEmail: _lastLinkIsEmail || loginId.contains('@')); + }, + child: Text( + _linkResendSeconds > 0 + ? "재발송 (${_formatTime(_linkResendSeconds)})" + : "재발송", + ), + ), ], ], ), From 209314fea79467e16551dfc453e9ac5c732a12d7 Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Thu, 29 Jan 2026 09:57:36 +0900 Subject: [PATCH 6/8] =?UTF-8?q?=EB=A7=81=ED=81=AC=EB=A1=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=ED=95=98=EA=B8=B0=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/domain/auth_models.go | 1 + backend/internal/handler/auth_handler.go | 22 ++++- .../lib/core/services/auth_proxy_service.dart | 11 ++- .../auth/presentation/login_screen.dart | 90 ++++++++++++++++--- 4 files changed, 107 insertions(+), 17 deletions(-) diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go index 1bd91fc9..476cd069 100644 --- a/backend/internal/domain/auth_models.go +++ b/backend/internal/domain/auth_models.go @@ -4,6 +4,7 @@ type EnchantedLinkInitRequest struct { LoginID string `json:"loginId"` URI string `json:"uri,omitempty"` // Redirect URI (optional for polling flow) Method string `json:"method,omitempty"` // "email" or "sms" + CodeOnly bool `json:"codeOnly,omitempty"` } type EnchantedLinkInitResponse struct { diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 553ef9b6..f9969d23 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -35,6 +35,7 @@ const ( prefixLoginCodeSmsTarget = "login_code_sms_target:" prefixLoginCodeSmsLookup = "login_code_sms_lookup:" prefixLoginCodeShort = "login_code_short:" + prefixLoginCodeSmsOnly = "login_code_sms_only:" prefixPollMeta = "poll_meta:" prefixSignupEmail = "signup:email:" prefixSignupPhone = "signup:phone:" @@ -630,6 +631,11 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { if init.LoginID != "" { keyLoginID = init.LoginID } + if !strings.Contains(loginID, "@") && req.CodeOnly { + _ = h.RedisService.Set(prefixLoginCodeSmsOnly+keyLoginID, "1", loginCodeExpiration) + } else { + _ = h.RedisService.Delete(prefixLoginCodeSmsOnly + keyLoginID) + } if init.FlowID != "" { _ = h.RedisService.Set(prefixLoginCode+keyLoginID, init.FlowID, loginCodeExpiration) } @@ -644,6 +650,9 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { if !init.ExpiresAt.IsZero() { expiresIn = int(time.Until(init.ExpiresAt).Seconds()) } + if expiresIn <= 0 { + expiresIn = int(loginCodeExpiration.Seconds()) + } return c.JSON(fiber.Map{ "linkId": "Sent", "pendingRef": pendingRef, @@ -1609,10 +1618,13 @@ type shortLoginCodePayload struct { } func (h *AuthHandler) buildKratosShortSmsBody(req *kratosCourierRequest, loginID, phone string) string { - _, link, ok := h.prepareKratosShortLogin(req, loginID) + shortCode, link, ok := h.prepareKratosShortLogin(req, loginID) if !ok { return "" } + if h.isSmsCodeOnly(loginID) { + return fmt.Sprintf("[Baron 통합로그인] 로그인 코드: %s", shortCode) + } return fmt.Sprintf("[Baron 통합로그인] %s", link) } @@ -1667,6 +1679,14 @@ func (h *AuthHandler) prepareKratosShortLogin(req *kratosCourierRequest, loginID return shortCode, link, true } +func (h *AuthHandler) isSmsCodeOnly(loginID string) bool { + if loginID == "" { + return false + } + val, _ := h.RedisService.Get(prefixLoginCodeSmsOnly + loginID) + return val != "" +} + func (h *AuthHandler) generateShortCode(code string) string { const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" for i := 0; i < 10; i++ { diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 43bd815d..ddd653bb 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -41,17 +41,24 @@ class AuthProxyService { } } - static Future> initEnchantedLink(String loginId, {String? method}) async { + static Future> initEnchantedLink( + String loginId, { + String? method, + bool? codeOnly, + }) async { final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init'); final userfrontUrl = _envOrDefault('USERFRONT_URL', 'http://sso.hmac.kr'); - final body = { + final body = { 'loginId': loginId, 'uri': userfrontUrl, }; if (method != null) { body['method'] = method; } + if (codeOnly == true) { + body['codeOnly'] = true; + } final response = await http.post( url, diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index ee092bf4..1294efe7 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -46,6 +46,8 @@ class _LoginScreenState extends ConsumerState bool _lastLinkIsEmail = true; int _linkResendSeconds = 0; Timer? _linkResendTimer; + int _linkExpireSeconds = 0; + Timer? _linkExpireTimer; @override void initState() { @@ -120,6 +122,9 @@ class _LoginScreenState extends ConsumerState _linkResendTimer?.cancel(); _linkResendTimer = null; _linkResendSeconds = 0; + _linkExpireTimer?.cancel(); + _linkExpireTimer = null; + _linkExpireSeconds = 0; _shortCodePrefixController.clear(); _shortCodeDigitsController.clear(); } @@ -139,6 +144,25 @@ class _LoginScreenState extends ConsumerState }); } + void _startLinkExpireTimer(int seconds) { + _linkExpireSeconds = seconds; + _linkExpireTimer?.cancel(); + _linkExpireTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) return; + if (_linkExpireSeconds > 0) { + setState(() { + _linkExpireSeconds--; + }); + return; + } + timer.cancel(); + if (mounted) { + setState(_resetLinkLoginState); + context.go('/signin'); + } + }); + } + // Helper to decode JWT and get loginId String _getLoginIdFromJwt(String jwt) { try { @@ -505,7 +529,7 @@ class _LoginScreenState extends ConsumerState } } - Future _startEnchantedFlow(String loginId, {required bool isEmail}) async { + Future _startEnchantedFlow(String loginId, {required bool isEmail, bool codeOnly = false}) async { try { if (mounted) { showDialog( @@ -516,12 +540,16 @@ class _LoginScreenState extends ConsumerState } // 1. Init via Backend API - final initResponse = await AuthProxyService.initEnchantedLink(loginId); + final initResponse = await AuthProxyService.initEnchantedLink( + loginId, + codeOnly: codeOnly, + ); final pendingRef = initResponse['pendingRef']; final mode = (initResponse['mode'] ?? '').toString(); final provider = (initResponse['provider'] ?? '').toString(); final interval = initResponse['interval']; final resendAfter = initResponse['resendAfter']; + final expiresIn = initResponse['expiresIn']; debugPrint("[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider"); if (mounted) { @@ -543,6 +571,9 @@ class _LoginScreenState extends ConsumerState if (resendAfter is int && resendAfter > 0) { _startLinkResendTimer(resendAfter); } + if (expiresIn is int && expiresIn > 0) { + _startLinkExpireTimer(expiresIn); + } _pollForSession(pendingRef, initialInterval: initialInterval); } } catch (e) { @@ -890,9 +921,12 @@ class _LoginScreenState extends ConsumerState child: TextField( controller: _shortCodeDigitsController, keyboardType: TextInputType.number, - decoration: const InputDecoration( + decoration: InputDecoration( labelText: "000000", - border: OutlineInputBorder(), + border: const OutlineInputBorder(), + hintText: _linkExpireSeconds > 0 + ? "유효시간 ${_formatTime(_linkExpireSeconds)}" + : "000000", ), maxLength: 6, ), @@ -917,22 +951,50 @@ class _LoginScreenState extends ConsumerState ), const SizedBox(height: 12), TextButton( - onPressed: _linkResendSeconds > 0 - ? null - : () { - final loginId = _lastLinkLoginId ?? _linkIdController.text.trim(); - if (loginId.isEmpty) { - _showError("이메일 또는 휴대폰 번호를 입력해 주세요."); - return; - } - _startEnchantedFlow(loginId, isEmail: _lastLinkIsEmail || loginId.contains('@')); - }, + onPressed: () { + if (_linkResendSeconds > 0) { + _showInfo("재발송은 ${_formatTime(_linkResendSeconds)} 후 가능합니다."); + return; + } + final loginId = _lastLinkLoginId ?? _linkIdController.text.trim(); + if (loginId.isEmpty) { + _showError("이메일 또는 휴대폰 번호를 입력해 주세요."); + return; + } + _startEnchantedFlow( + loginId, + isEmail: _lastLinkIsEmail || loginId.contains('@'), + codeOnly: false, + ); + }, child: Text( _linkResendSeconds > 0 ? "재발송 (${_formatTime(_linkResendSeconds)})" : "재발송", ), ), + if (!_lastLinkIsEmail) ...[ + const SizedBox(height: 4), + TextButton( + onPressed: () { + if (_linkResendSeconds > 0) { + _showInfo("재발송은 ${_formatTime(_linkResendSeconds)} 후 가능합니다."); + return; + } + final loginId = _lastLinkLoginId ?? _linkIdController.text.trim(); + if (loginId.isEmpty) { + _showError("휴대폰 번호를 입력해 주세요."); + return; + } + _startEnchantedFlow( + loginId, + isEmail: false, + codeOnly: true, + ); + }, + child: const Text("코드만 받기(${_formatTime(_linkResendSeconds)})"), + ), + ], ], ], ), From 77d4e9fd77cbb2785ee096d48f9e678bb1149289 Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Thu, 29 Jan 2026 14:42:15 +0900 Subject: [PATCH 7/8] =?UTF-8?q?=ED=95=9C=20endpoint=20URL=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EC=84=9C=EB=B9=99=20#120?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.sample | 38 +++-- README.md | 1 + backend/internal/handler/auth_handler.go | 161 +++++++++++++++++- compose.ory.yaml | 4 + docker-compose.yaml | 6 +- docs/auth-flow.md | 4 +- userfront/.env.sample | 34 ---- userfront/Dockerfile | 1 + .../auth/presentation/approve_qr_screen.dart | 7 +- .../auth/presentation/login_screen.dart | 13 +- .../auth/presentation/qr_scan_screen.dart | 6 +- userfront/nginx.conf | 52 +++++- 12 files changed, 254 insertions(+), 73 deletions(-) delete mode 100644 userfront/.env.sample diff --git a/.env.sample b/.env.sample index b10cb5bf..63b4c206 100644 --- a/.env.sample +++ b/.env.sample @@ -57,17 +57,19 @@ ADMIN_EMAIL=admin@baron.co.kr ADMIN_PASSWORD=adminPasswordIsNotSimple # --- URLs for Proxy/Handoff --- -USERFRONT_URL=https://sso.hmac.kr # 프론트엔드 접속 주소 (이메일/SMS 링크 생성 시 사용) -BACKEND_URL=https://sso.hmac.kr # 프론트엔드에서 참조할 백엔드 API 주소 - +# Project Public Base URL (Served by UserFront Nginx) +USERFRONT_URL=https://sso.hmac.kr +# Services proxied via Nginx +BACKEND_URL=${USERFRONT_URL}/api +OATHKEEPER_PUBLIC_URL=${USERFRONT_URL} # ory-stack 변수들 ORY_POSTGRES_TAG=17-trixie ORY_POSTGRES_USER=ory ORY_POSTGRES_PASSWORD=EuBV5ywvXFehkggHQrnYo5727MseEi6i9 ORY_POSTGRES_DB=ory -ORY_POSTGRES_PORT=5433 +# ORY_POSTGRES_PORT=5433 # Internal only KRATOS_DB=ory_kratos HYDRA_DB=ory_hydra @@ -75,31 +77,39 @@ KETO_DB=ory_keto # Ory Kratos Configuration KRATOS_VERSION=v25.4.0-distroless -KRATOS_PUBLIC_PORT=4433 -KRATOS_ADMINFRONT_PORT=4434 +# KRATOS_PUBLIC_PORT=4433 # Internal only +# KRATOS_ADMINFRONT_PORT=4434 # Internal only KRATOS_UI_NODE_VERSION=v25.4.0 -KRATOS_UI_PORT=4455 +# KRATOS_UI_PORT=4455 # Internal only # Ory Hydra Configuration HYDRA_VERSION=v25.4.0-distroless -HYDRA_PUBLIC_PORT=4441 -HYDRA_ADMINFRONT_PORT=4445 +# HYDRA_PUBLIC_PORT=4441 # Internal only +# HYDRA_ADMINFRONT_PORT=4445 # Internal only # Ory Keto Configuration KETO_VERSION=v25.4.0-distroless -KETO_READ_PORT=4466 -KETO_WRITE_PORT=4467 +# KETO_READ_PORT=4466 # Internal only +# KETO_WRITE_PORT=4467 # Internal only # Kratos Selfservice UI upstreams (override for deployments) ORY_SDK_URL=http://kratos:4433 KRATOS_PUBLIC_URL=http://kratos:4433 KRATOS_ADMIN_URL=http://kratos:4434 -# 브라우저가 접근할 Kratos Public/UI 외부 URL (리버스 프록시/도메인 환경 고려) -KRATOS_BROWSER_URL=http://localhost:4433 + +# 브라우저가 접근할 Kratos Public/UI 외부 URL +# Oathkeeper가 /auth 경로를 Kratos Public API로 라우팅합니다. +KRATOS_BROWSER_URL=${OATHKEEPER_PUBLIC_URL}/auth +# Kratos UI는 별도 서브도메인이 없으면 UserFront가 렌더링하거나 /kratos-ui 등으로 라우팅 필요 +# 현재는 예시로 로컬 포트 유지 (프로덕션에선 UserFront에 통합됨) KRATOS_UI_URL=http://localhost:4455 + HYDRA_ADMIN_URL=http://hydra:4445 -HYDRA_PUBLIC_URL=http://hydra:4444 +# Oathkeeper가 /oidc 경로를 Hydra Public API로 라우팅합니다. +HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc + +# Oathkeeper JWKS (내부 통신용) JWKS_URL=http://oathkeeper:4456/.well-known/jwks.json # Oathkeeper 실행 사용자/프로브 설정 diff --git a/README.md b/README.md index a21532fa..f6aeaa86 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,7 @@ Ory Stack과 애플리케이션 간 통신을 위한 도커 네트워크를 생 docker network create -d bridge ory-net docker network create hydranet docker network create kratosnet +docker network create public_net #서비스용 ``` #### 2. 인프라 및 Ory Stack 실행 diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index f9969d23..d7d43d40 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -22,6 +22,7 @@ import ( "strings" "time" + "github.com/descope/go-sdk/descope" "github.com/descope/go-sdk/descope/client" "github.com/gofiber/fiber/v2" ) @@ -1357,7 +1358,6 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { // InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다. func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error { pendingRef := GenerateSecureToken(16) - userCode := GenerateUserCode() // QR 코드 페이로드를 실제 접속 가능한 URL로 변경합니다. userfrontURL := os.Getenv("USERFRONT_URL") @@ -1376,7 +1376,6 @@ func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error { "pendingRef": pendingRef, "expiresIn": 300, "interval": int(minPollInterval.Seconds()), - "userCode": userCode, }) } @@ -1433,17 +1432,26 @@ func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error { slog.Info("[QR] Scan & Approve", "pendingRef", req.PendingRef) + if req.Token == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing session token"}) + } + // 1. Redis에서 세션 확인 val, err := h.RedisService.Get(prefixSession + req.PendingRef) if err != nil || val == "" { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Session expired or not found"}) } - // 2. 모바일 유저의 토큰으로 새 세션 토큰(웹용)을 발행하거나 그대로 전달 + // 2. 모바일 토큰은 승인 검증용으로만 사용하고, 웹 전용 세션을 새로 발급 + sessionToken, err := h.issueQRWebSession(c, req.Token) + if err != nil { + slog.Error("[QR] Issue web session failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue web session"}) + } sessionData, _ := json.Marshal(map[string]string{ "status": statusSuccess, - "jwt": req.Token, + "jwt": sessionToken, }) h.RedisService.Set(prefixSession+req.PendingRef, string(sessionData), 5*time.Minute) @@ -1910,6 +1918,108 @@ func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, err return id, err } +func (h *AuthHandler) issueQRWebSession(c *fiber.Ctx, token string) (string, error) { + if looksLikeJWT(token) && h.DescopeClient != nil { + authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) + if err == nil && authorized { + loginID, err := h.resolveDescopeLoginID(c.Context(), userToken) + if err != nil { + return "", err + } + authInfo, err := h.IdpProvider.IssueSession(loginID) + if err != nil { + return "", err + } + if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { + return "", fmt.Errorf("descope issue session returned empty token") + } + return authInfo.SessionToken.JWT, nil + } + } + + identityID, _, err := h.getKratosIdentity(token) + if err != nil { + return "", err + } + return h.issueKratosSession(c.Context(), identityID) +} + +func (h *AuthHandler) resolveDescopeLoginID(ctx context.Context, token *descope.Token) (string, error) { + if token == nil { + return "", fmt.Errorf("descope token is nil") + } + + if loginID := extractLoginIDFromClaims(token.Claims); loginID != "" { + return loginID, nil + } + + if h.DescopeClient == nil { + return "", fmt.Errorf("descope client is nil") + } + + user, err := h.DescopeClient.Management.User().Load(ctx, token.ID) + if err != nil { + return "", err + } + if user == nil { + return "", fmt.Errorf("descope user not found") + } + if loginID := pickPrimaryLoginID(user.LoginIDs); loginID != "" { + return loginID, nil + } + if user.Email != "" { + return user.Email, nil + } + if user.Phone != "" { + return user.Phone, nil + } + return "", fmt.Errorf("descope login id not found") +} + +func pickPrimaryLoginID(loginIDs []string) string { + for _, id := range loginIDs { + if strings.Contains(id, "@") { + return id + } + } + if len(loginIDs) > 0 { + return loginIDs[0] + } + return "" +} + +func extractLoginIDFromClaims(claims map[string]any) string { + if claims == nil { + return "" + } + + candidateKeys := []string{"loginId", "login_id", "email", "phone_number", "phone", "phoneNumber"} + for _, key := range candidateKeys { + if raw, ok := claims[key]; ok { + if value, ok := raw.(string); ok && value != "" { + return value + } + } + } + + if raw, ok := claims["loginIds"]; ok { + switch ids := raw.(type) { + case []string: + return pickPrimaryLoginID(ids) + case []any: + casted := make([]string, 0, len(ids)) + for _, item := range ids { + if value, ok := item.(string); ok && value != "" { + casted = append(casted, value) + } + } + return pickPrimaryLoginID(casted) + } + } + + return "" +} + func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string]interface{}, error) { kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") if kratosURL == "" { @@ -1944,6 +2054,49 @@ func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string return result.Identity.ID, result.Identity.Traits, nil } +func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string) (string, error) { + if identityID == "" { + return "", fmt.Errorf("kratos identity id is empty") + } + + kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/") + if kratosAdminURL == "" { + kratosAdminURL = "http://kratos:4434" + } + + payload := map[string]interface{}{ + "identity_id": identityID, + } + body, _ := json.Marshal(payload) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, kratosAdminURL+"/admin/sessions", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode >= 300 { + return "", fmt.Errorf("kratos admin create session failed status=%d body=%s", resp.StatusCode, string(respBody)) + } + + var parsed struct { + SessionToken string `json:"session_token"` + } + if err := json.Unmarshal(respBody, &parsed); err != nil { + return "", err + } + if parsed.SessionToken == "" { + return "", fmt.Errorf("kratos admin session token missing: %s", string(respBody)) + } + return parsed.SessionToken, nil +} + func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[string]interface{}, error) { kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/") if kratosURL == "" { diff --git a/compose.ory.yaml b/compose.ory.yaml index 53ffae17..1743a1c5 100644 --- a/compose.ory.yaml +++ b/compose.ory.yaml @@ -162,6 +162,7 @@ services: entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"] networks: - ory-net + - public_net ory_clickhouse: image: clickhouse/clickhouse-server:latest @@ -251,3 +252,6 @@ networks: kratosnet: external: true name: kratosnet + public_net: + external: true + name: public_net diff --git a/docker-compose.yaml b/docker-compose.yaml index 23d59b02..8ff21446 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -80,7 +80,6 @@ services: - /app/node_modules networks: - baron_net - userfront: build: context: ./userfront @@ -97,6 +96,8 @@ services: - "${USERFRONT_PORT:-5000}:5000" networks: - baron_net + - public_net + depends_on: backend: condition: service_healthy @@ -126,3 +127,6 @@ networks: ory-net: external: true name: ory-net + public_net: + external: true + name: public_net diff --git a/docs/auth-flow.md b/docs/auth-flow.md index 95c1be59..45ba01e0 100644 --- a/docs/auth-flow.md +++ b/docs/auth-flow.md @@ -33,7 +33,7 @@ ### 2.3 QR 로그인 1. `POST /api/v1/auth/qr/init` → `qrCode`, `pendingRef` 수신 2. 웹은 `POST /api/v1/auth/qr/poll`로 폴링 -3. 모바일 앱은 `POST /api/v1/auth/qr/approve`로 승인 +3. 모바일 앱은 `POST /api/v1/auth/qr/approve`로 승인 (모바일 세션 토큰은 승인 검증용) 4. Polling 응답에서 `sessionJwt` 수신 ### 2.4 SMS 코드 로그인 @@ -71,7 +71,7 @@ - **ID/Password 로그인**: IDP 추상화 사용 (Ory/Descope) — 정상 - **Enchanted/Magic Link**: 현재는 Descope 기반 로직이 포함됨. Ory 전환 시 Kratos `code/link` 플로우로 교체 필요 - **SMS 코드**: 내부 토큰(placeholder). Kratos 세션 교환 로직 추가 필요 -- **QR 로그인**: 모바일 세션 토큰을 웹 세션으로 전달. Ory일 경우 Kratos 세션 토큰을 전달하도록 UI/토큰 저장 방식 정비 필요 +- **QR 로그인**: 모바일 세션 토큰은 승인 검증용으로만 사용하고, 백엔드에서 웹 전용 세션을 새로 발급 --- diff --git a/userfront/.env.sample b/userfront/.env.sample deleted file mode 100644 index 5317d76e..00000000 --- a/userfront/.env.sample +++ /dev/null @@ -1,34 +0,0 @@ -# ========================================== -# Baron SSO - Unified Environment Configuration -# ========================================== - -# --- General System --- -APP_ENV=development -TZ=Asia/Seoul - -# --- Infrastructure Ports --- -DB_PORT=5432 -CLICKHOUSE_PORT_HTTP=8123 -CLICKHOUSE_PORT_NATIVE=9000 -BACKEND_PORT=3000 -USERFRONT_PORT=5000 - -# --- Database Credentials (PostgreSQL) --- -DB_USER=baron -DB_PASSWORD=password -DB_NAME=baron_sso - -# --- Backend Configuration --- -# Must be 32 bytes. Generate with `openssl rand -hex 32` -COOKIE_SECRET=super-secret-key-must-be-32-bytes! -REDIS_ADDR=redis:6379 - -# --- Frontend Configuration --- -# Descope Project ID (Required for Auth) -DESCOPE_PROJECT_ID=P2t...your_descope_project_id - -# --- Naver Cloud Services --- -NAVER_CLOUD_ACCESS_KEY=ncp_iam_... -NAVER_CLOUD_SECRET_KEY=ncp_iam_... -NAVER_CLOUD_SERVICE_ID=ncp:sms:kr:...:... -NAVER_SENDER_PHONE_NUMBER=... diff --git a/userfront/Dockerfile b/userfront/Dockerfile index 1e8a7b95..47052a28 100644 --- a/userfront/Dockerfile +++ b/userfront/Dockerfile @@ -1,5 +1,6 @@ # Stage 1: Build Flutter FROM ghcr.io/cirruslabs/flutter:stable AS build +ENV RUN_FLUTTER_AS_ROOT=true # ENV RUN_FLUTTER_AS_ROOT=true WORKDIR /app COPY . . diff --git a/userfront/lib/features/auth/presentation/approve_qr_screen.dart b/userfront/lib/features/auth/presentation/approve_qr_screen.dart index 2422f006..744e8abf 100644 --- a/userfront/lib/features/auth/presentation/approve_qr_screen.dart +++ b/userfront/lib/features/auth/presentation/approve_qr_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:descope/descope.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/services/auth_proxy_service.dart'; +import '../../../../core/services/auth_token_store.dart'; class ApproveQrScreen extends StatefulWidget { final String? pendingRef; @@ -19,8 +20,9 @@ class _ApproveQrScreenState extends State { Future _handleApprove() async { if (widget.pendingRef == null) return; + final storedToken = AuthTokenStore.getToken(); final session = Descope.sessionManager.session; - if (session == null || session.refreshToken.isExpired) { + if (storedToken == null && (session == null || session.refreshToken.isExpired)) { setState(() => _message = "Please log in on your phone first."); context.go('/signin'); // Redirect to login return; @@ -32,9 +34,10 @@ class _ApproveQrScreenState extends State { }); // jwt 유효성 확인 try { + final token = storedToken ?? session?.sessionToken.jwt ?? ''; await AuthProxyService.approveQrLogin( widget.pendingRef!, - session.sessionToken.jwt, + token, ); setState(() { _success = true; diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 1294efe7..ea3bb2cb 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -33,7 +33,6 @@ class _LoginScreenState extends ConsumerState // QR Login Variables String? _qrImageBase64; String? _qrPendingRef; - String? _qrUserCode; bool _isQrLoading = false; Timer? _qrPollingTimer; int _qrRemainingSeconds = 0; @@ -207,7 +206,6 @@ class _LoginScreenState extends ConsumerState setState(() { _isQrLoading = true; _qrImageBase64 = null; - _qrUserCode = null; _qrRemainingSeconds = 0; }); @@ -218,7 +216,6 @@ class _LoginScreenState extends ConsumerState _qrImageBase64 = res['qrCode']; _qrPendingRef = res['pendingRef']; _qrRemainingSeconds = res['expiresIn'] ?? 300; - _qrUserCode = res['userCode']?.toString(); final interval = res['interval']; if (interval is int && interval > 0) { _qrPollIntervalMs = interval * 1000; @@ -992,7 +989,7 @@ class _LoginScreenState extends ConsumerState codeOnly: true, ); }, - child: const Text("코드만 받기(${_formatTime(_linkResendSeconds)})"), + child: Text("코드만 받기(${_formatTime(_linkResendSeconds)})"), ), ], ], @@ -1035,14 +1032,6 @@ class _LoginScreenState extends ConsumerState ), ), const SizedBox(height: 8), - if (_qrUserCode != null) ...[ - Text( - "코드: $_qrUserCode", - textAlign: TextAlign.center, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - ], const Text( "모바일 앱으로 스캔하세요", textAlign: TextAlign.center, diff --git a/userfront/lib/features/auth/presentation/qr_scan_screen.dart b/userfront/lib/features/auth/presentation/qr_scan_screen.dart index d750808d..43aac077 100644 --- a/userfront/lib/features/auth/presentation/qr_scan_screen.dart +++ b/userfront/lib/features/auth/presentation/qr_scan_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; import 'package:descope/descope.dart'; import '../../../core/services/auth_proxy_service.dart'; +import '../../../core/services/auth_token_store.dart'; class QRScanScreen extends StatefulWidget { const QRScanScreen({super.key}); @@ -49,7 +50,8 @@ class _QRScanScreenState extends State { _log.info('QR Code detected raw: $qrData, ref: $pendingRef'); - final sessionToken = Descope.sessionManager.session?.sessionToken.jwt; + final sessionToken = AuthTokenStore.getToken() ?? + Descope.sessionManager.session?.sessionToken.jwt; if (sessionToken == null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -119,4 +121,4 @@ class _QRScanScreenState extends State { ), ); } -} \ No newline at end of file +} diff --git a/userfront/nginx.conf b/userfront/nginx.conf index fd8da73a..900d1b56 100644 --- a/userfront/nginx.conf +++ b/userfront/nginx.conf @@ -25,7 +25,7 @@ server { access_log /var/log/nginx/access.log json_combined; - # Backend API Proxy + # --- Backend API Proxy --- location /api { proxy_pass http://baron_backend:3000; proxy_set_header Host $host; @@ -34,7 +34,55 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # Frontend Static Files + # --- Ory Stack Proxy (via Oathkeeper) --- + # Kratos Public API + location /auth { + proxy_pass http://oathkeeper:4455; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Hydra Public API + location /oidc { + proxy_pass http://oathkeeper:4455; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # --- Internal Web Apps Proxy --- 초반에는 외부 오픈 없이 Private Net 내부에서만 운영 + # AdminFront (Vite Dev Server or Nginx) + # location /admin { + # proxy_pass http://baron_adminfront:5173; + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # proxy_set_header X-Forwarded-Proto $scheme; + + # # WebSocket support (for Vite HMR) + # proxy_http_version 1.1; + # proxy_set_header Upgrade $http_upgrade; + # proxy_set_header Connection "upgrade"; + # } + + # # DevFront (Vite Dev Server or Nginx) + # location /dev { + # proxy_pass http://baron_devfront:5173; + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # proxy_set_header X-Forwarded-Proto $scheme; + + # # WebSocket support (for Vite HMR) + # proxy_http_version 1.1; + # proxy_set_header Upgrade $http_upgrade; + # proxy_set_header Connection "upgrade"; + # } + + # --- UserFront Static Files --- location / { root /usr/share/nginx/html; index index.html; From ff655dc7c7a7e940d6cb71aa263b40c397b117b5 Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Thu, 29 Jan 2026 16:35:08 +0900 Subject: [PATCH 8/8] =?UTF-8?q?QR=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 42 ++- backend/internal/handler/auth_handler.go | 347 +++++++++++++++--- docker/ory/kratos/kratos.yml | 136 +++---- .../lib/core/services/auth_proxy_service.dart | 44 ++- .../auth/presentation/approve_qr_screen.dart | 43 ++- .../auth/presentation/login_screen.dart | 67 ++-- .../auth/presentation/qr_scan_screen.dart | 171 +++++++-- userfront/lib/main.dart | 9 + 8 files changed, 656 insertions(+), 203 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index a75b8336..ec8060b0 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -292,24 +292,30 @@ func main() { Key: cookieSecret, })) - app.Get("/docs", func(c *fiber.Ctx) error { - return c.SendFile("./docs/swagger-ui/index.html") - }) - app.Get("/docs/", func(c *fiber.Ctx) error { - return c.SendFile("./docs/swagger-ui/index.html") - }) - app.Static("/docs", "./docs/swagger-ui") - app.Get("/redoc", func(c *fiber.Ctx) error { - return c.SendFile("./docs/redoc/index.html") - }) - app.Get("/redoc/", func(c *fiber.Ctx) error { - return c.SendFile("./docs/redoc/index.html") - }) - app.Static("/redoc", "./docs/redoc") - app.Get("/openapi.yaml", func(c *fiber.Ctx) error { - c.Type("yaml") - return c.SendFile("./docs/openapi.yaml") - }) + // [Security] Disable Swagger/ReDoc in Production + if appEnv != "production" { + app.Get("/docs", func(c *fiber.Ctx) error { + return c.SendFile("./docs/swagger-ui/index.html") + }) + app.Get("/docs/", func(c *fiber.Ctx) error { + return c.SendFile("./docs/swagger-ui/index.html") + }) + app.Static("/docs", "./docs/swagger-ui") + app.Get("/redoc", func(c *fiber.Ctx) error { + return c.SendFile("./docs/redoc/index.html") + }) + app.Get("/redoc/", func(c *fiber.Ctx) error { + return c.SendFile("./docs/redoc/index.html") + }) + app.Static("/redoc", "./docs/redoc") + app.Get("/openapi.yaml", func(c *fiber.Ctx) error { + c.Type("yaml") + return c.SendFile("./docs/openapi.yaml") + }) + slog.Info("📚 API Docs enabled", "swagger", "/docs", "redoc", "/redoc") + } else { + slog.Info("🔒 API Docs disabled in production") + } // Routes app.Get("/", func(c *fiber.Ctx) error { diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index d7d43d40..e7c74e55 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -37,7 +37,11 @@ const ( prefixLoginCodeSmsLookup = "login_code_sms_lookup:" prefixLoginCodeShort = "login_code_short:" prefixLoginCodeSmsOnly = "login_code_sms_only:" + prefixLoginCodeQrPending = "login_code_qr_pending:" + prefixLoginCodeQr = "login_code_qr:" prefixPollMeta = "poll_meta:" + prefixQrRef = "qr_ref:" + prefixQrPending = "qr_pending:" prefixSignupEmail = "signup:email:" prefixSignupPhone = "signup:phone:" @@ -84,6 +88,21 @@ func GenerateSecureToken(length int) string { return hex.EncodeToString(b) } +func GenerateSecureAlnumToken(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + if length <= 0 { + return "" + } + buf := make([]byte, length) + if _, err := crand.Read(buf); err != nil { + return "" + } + for i := range buf { + buf[i] = charset[int(buf[i])%len(charset)] + } + return string(buf) +} + func GenerateUserCode() string { const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ" return fmt.Sprintf("%c%c-%03d", @@ -1358,18 +1377,23 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error { // InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다. func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error { pendingRef := GenerateSecureToken(16) + qrRef := GenerateSecureAlnumToken(64) + if qrRef == "" { + qrRef = GenerateSecureToken(16) + } // QR 코드 페이로드를 실제 접속 가능한 URL로 변경합니다. userfrontURL := os.Getenv("USERFRONT_URL") if userfrontURL == "" { userfrontURL = "https://sso.hmac.kr" } - qrPayload := fmt.Sprintf("%s/approve?ref=%s", userfrontURL, pendingRef) + qrPayload := fmt.Sprintf("%s/ql/%s", strings.TrimRight(userfrontURL, "/"), qrRef) - slog.Info("[QR] Init", "pendingRef", pendingRef, "url", qrPayload) + slog.Info("[QR] Init", "pendingRef", pendingRef, "qrRef", qrRef, "url", qrPayload) // Redis에 초기 상태 저장 (5분 만료) h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), 5*time.Minute) + h.RedisService.Set(prefixQrRef+qrRef, pendingRef, 5*time.Minute) return c.JSON(fiber.Map{ "qrCode": qrPayload, // 프론트엔드에서 이 텍스트로 QR을 생성하거나, 이미지를 반환 @@ -1430,30 +1454,66 @@ func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"}) } - slog.Info("[QR] Scan & Approve", "pendingRef", req.PendingRef) - - if req.Token == "" { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing session token"}) + rawRef := strings.TrimSpace(req.PendingRef) + pendingRef, err := h.resolveQrPendingRef(rawRef) + if err != nil || pendingRef == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid pendingRef"}) } + slog.Info("[QR] Scan & Approve", "pendingRef", pendingRef) + // 1. Redis에서 세션 확인 - val, err := h.RedisService.Get(prefixSession + req.PendingRef) + val, err := h.RedisService.Get(prefixSession + pendingRef) if err != nil || val == "" { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Session expired or not found"}) } - // 2. 모바일 토큰은 승인 검증용으로만 사용하고, 웹 전용 세션을 새로 발급 - sessionToken, err := h.issueQRWebSession(c, req.Token) - if err != nil { - slog.Error("[QR] Issue web session failed", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue web session"}) + if req.Token == "" { + cookie := c.Get(fiber.HeaderCookie) + if cookie == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing session token"}) + } + _, traits, err := h.getKratosIdentityWithCookie(cookie) + if err != nil { + slog.Warn("[QR] Cookie session invalid", "error", err) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + } + loginID := pickLoginIDFromTraits(traits) + if loginID == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + } + if !strings.Contains(loginID, "@") { + loginID = normalizePhoneForLoginID(loginID) + } + if err := h.startQrCodeLoginForQr(loginID, pendingRef, rawRef); err != nil { + slog.Error("[QR] Start code login failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue web session"}) + } + return c.JSON(fiber.Map{"message": "QR Login Approved"}) } - sessionData, _ := json.Marshal(map[string]string{ - "status": statusSuccess, - "jwt": sessionToken, - }) - h.RedisService.Set(prefixSession+req.PendingRef, string(sessionData), 5*time.Minute) + // 2. 모바일 토큰은 승인 검증용으로만 사용하고, 웹 전용 세션을 새로 발급 + if sessionToken, err := h.tryIssueDescopeQrSession(c, req.Token); err != nil { + slog.Error("[QR] Issue web session failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue web session"}) + } else if sessionToken != "" { + sessionData, _ := json.Marshal(map[string]string{ + "status": statusSuccess, + "jwt": sessionToken, + }) + h.RedisService.Set(prefixSession+pendingRef, string(sessionData), 5*time.Minute) + return c.JSON(fiber.Map{"message": "QR Login Approved"}) + } + + loginID, err := h.resolveKratosLoginID(req.Token) + if err != nil { + slog.Warn("[QR] Invalid token", "error", err) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + } + if err := h.startQrCodeLoginForQr(loginID, pendingRef, rawRef); err != nil { + slog.Error("[QR] Start code login failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue web session"}) + } return c.JSON(fiber.Map{"message": "QR Login Approved"}) } @@ -1484,6 +1544,66 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Missing recipient"}) } + loginID := req.Recipient + if !strings.Contains(loginID, "@") { + loginID = normalizePhoneForLoginID(loginID) + } + if pendingRef, _ := h.RedisService.Get(prefixLoginCodeQrPending + loginID); pendingRef != "" { + code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code")) + if code == "" { + slog.Error("[QR] Missing login code in courier", "loginID", loginID) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Missing login code"}) + } + flowID, _ := h.RedisService.Get(prefixLoginCode + loginID) + if flowID == "" { + slog.Error("[QR] Missing login flow for code verify", "loginID", loginID) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Login flow expired"}) + } + authInfo, err := h.IdpProvider.VerifyLoginCode(loginID, flowID, code) + if err != nil || authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { + slog.Error("[QR] Code verify failed", "loginID", loginID, "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to verify login code"}) + } + sessionData, _ := json.Marshal(map[string]string{ + "status": statusSuccess, + "jwt": authInfo.SessionToken.JWT, + }) + h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration) + h.RedisService.Delete(prefixLoginCodeQrPending + loginID) + h.RedisService.Delete(prefixLoginCode + loginID) + h.RedisService.Delete(prefixLoginCodeQr + pendingRef) + slog.Info("[QR] Code verified and session issued", "loginID", loginID, "pendingRef", pendingRef) + return c.JSON(fiber.Map{"status": "ok"}) + } + if pendingRef, _ := h.RedisService.Get(prefixQrPending + loginID); pendingRef != "" { + code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code")) + if code == "" { + slog.Error("[QR] Missing login code in courier", "loginID", loginID) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Missing login code"}) + } + flowID, _ := h.RedisService.Get(prefixLoginCode + loginID) + if flowID == "" { + slog.Error("[QR] Missing login flow for code verify", "loginID", loginID) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Login flow expired"}) + } + authInfo, err := h.IdpProvider.VerifyLoginCode(loginID, flowID, code) + if err != nil || authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { + slog.Error("[QR] Code verify failed", "loginID", loginID, "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to verify login code"}) + } + sessionData, _ := json.Marshal(map[string]string{ + "status": statusSuccess, + "jwt": authInfo.SessionToken.JWT, + }) + h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration) + h.RedisService.Delete(prefixQrPending + loginID) + h.RedisService.Delete(prefixLoginCode + loginID) + h.RedisService.Delete(prefixLoginCodeSmsTarget + loginID) + h.RedisService.Delete(prefixLoginCodeSmsLookup + loginID) + slog.Info("[QR] Code verified and session issued", "loginID", loginID, "pendingRef", pendingRef) + return c.JSON(fiber.Map{"status": "ok"}) + } + subject, body := h.buildKratosCourierMessage(&req) if strings.TrimSpace(body) == "" { slog.Warn("[Kratos Courier] Empty body", "recipient", req.Recipient, "template", req.TemplateType) @@ -1526,16 +1646,16 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "SMS service not configured"}) } phone := sanitizePhoneForSms(req.Recipient) - loginID := req.Recipient - if !strings.Contains(loginID, "@") { - lookup := normalizePhoneForLoginID(loginID) + smsLoginID := req.Recipient + if !strings.Contains(smsLoginID, "@") { + lookup := normalizePhoneForLoginID(smsLoginID) if email, _ := h.RedisService.Get(prefixLoginCodeSmsLookup + lookup); email != "" { - loginID = email + smsLoginID = email } else { - loginID = lookup + smsLoginID = lookup } } - smsBody := h.buildKratosShortSmsBody(&req, loginID, phone) + smsBody := h.buildKratosShortSmsBody(&req, smsLoginID, phone) if smsBody == "" { smsBody = body } @@ -1918,30 +2038,171 @@ func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, err return id, err } -func (h *AuthHandler) issueQRWebSession(c *fiber.Ctx, token string) (string, error) { - if looksLikeJWT(token) && h.DescopeClient != nil { - authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) - if err == nil && authorized { - loginID, err := h.resolveDescopeLoginID(c.Context(), userToken) - if err != nil { - return "", err - } - authInfo, err := h.IdpProvider.IssueSession(loginID) - if err != nil { - return "", err - } - if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { - return "", fmt.Errorf("descope issue session returned empty token") - } - return authInfo.SessionToken.JWT, nil - } +func (h *AuthHandler) tryIssueDescopeQrSession(c *fiber.Ctx, token string) (string, error) { + if !looksLikeJWT(token) || h.DescopeClient == nil { + return "", nil } - - identityID, _, err := h.getKratosIdentity(token) + authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) + if err != nil || !authorized { + return "", nil + } + loginID, err := h.resolveDescopeLoginID(c.Context(), userToken) if err != nil { return "", err } - return h.issueKratosSession(c.Context(), identityID) + authInfo, err := h.IdpProvider.IssueSession(loginID) + if err != nil { + return "", err + } + if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { + return "", fmt.Errorf("descope issue session returned empty token") + } + return authInfo.SessionToken.JWT, nil +} + +func (h *AuthHandler) resolveKratosLoginID(token string) (string, error) { + _, traits, err := h.getKratosIdentity(token) + if err != nil { + return "", err + } + loginID := pickLoginIDFromTraits(traits) + if loginID == "" { + return "", fmt.Errorf("kratos login id missing") + } + if !strings.Contains(loginID, "@") { + loginID = normalizePhoneForLoginID(loginID) + } + return loginID, nil +} + +func pickLoginIDFromTraits(traits map[string]interface{}) string { + if traits == nil { + return "" + } + keys := []string{"email", "phone", "phone_number", "phoneNumber", "mobile", "mobile_number"} + for _, key := range keys { + if raw, ok := traits[key]; ok { + if value, ok := raw.(string); ok && value != "" { + return value + } + } + } + return "" +} + +func (h *AuthHandler) resolveQrPendingRef(raw string) (string, error) { + ref := strings.TrimSpace(raw) + if ref == "" { + return "", fmt.Errorf("empty ref") + } + if strings.HasPrefix(ref, "http") { + if parsed, err := url.Parse(ref); err == nil { + if value := parsed.Query().Get("ref"); value != "" { + ref = value + } else if len(parsed.Path) > 0 { + segments := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(segments) >= 2 && segments[0] == "ql" { + ref = segments[1] + } + } + } + } + if ref == "" { + return "", fmt.Errorf("invalid ref") + } + if mapped, _ := h.RedisService.Get(prefixQrRef + ref); mapped != "" { + return mapped, nil + } + return ref, nil +} + +func (h *AuthHandler) resolveQrRef(raw string) string { + ref := strings.TrimSpace(raw) + if ref == "" { + return "" + } + if strings.HasPrefix(ref, "http") { + if parsed, err := url.Parse(ref); err == nil { + if value := parsed.Query().Get("ref"); value != "" { + return value + } + if len(parsed.Path) > 0 { + segments := strings.Split(strings.Trim(parsed.Path, "/"), "/") + if len(segments) >= 2 && segments[0] == "ql" { + return segments[1] + } + } + } + } + return ref +} + +func (h *AuthHandler) startQrCodeLogin(loginID, pendingRef string) error { + if h.IdpProvider == nil { + return fmt.Errorf("identity provider unavailable") + } + userfrontURL := os.Getenv("USERFRONT_URL") + if userfrontURL == "" { + userfrontURL = "http://sso.hmac.kr" + } + _ = h.RedisService.Set(prefixQrPending+loginID, pendingRef, loginCodeExpiration) + init, err := h.IdpProvider.InitiateLinkLogin(loginID, userfrontURL) + if err != nil { + h.RedisService.Delete(prefixQrPending + loginID) + if errors.Is(err, domain.ErrNotSupported) { + return fmt.Errorf("login method not supported") + } + return err + } + effectiveLoginID := loginID + if init != nil && init.LoginID != "" { + effectiveLoginID = init.LoginID + } + if effectiveLoginID != loginID { + _ = h.RedisService.Set(prefixQrPending+effectiveLoginID, pendingRef, loginCodeExpiration) + } + if init != nil && init.FlowID != "" { + _ = h.RedisService.Set(prefixLoginCode+effectiveLoginID, init.FlowID, loginCodeExpiration) + } + return nil +} + +func (h *AuthHandler) startQrCodeLoginForQr(loginID, pendingRef, rawRef string) error { + if h.IdpProvider == nil { + return fmt.Errorf("identity provider unavailable") + } + userfrontURL := os.Getenv("USERFRONT_URL") + if userfrontURL == "" { + userfrontURL = "http://sso.hmac.kr" + } + + init, err := h.IdpProvider.InitiateLinkLogin(loginID, userfrontURL) + if err != nil { + if errors.Is(err, domain.ErrNotSupported) { + return fmt.Errorf("login method not supported") + } + return err + } + + effectiveLoginID := loginID + if init != nil && init.LoginID != "" { + effectiveLoginID = init.LoginID + } + if init == nil || init.FlowID == "" { + return fmt.Errorf("login flow missing") + } + + qrRef := h.resolveQrRef(rawRef) + qrPayload, _ := json.Marshal(map[string]string{ + "pendingRef": pendingRef, + "qrRef": qrRef, + "loginId": effectiveLoginID, + "approvedAt": time.Now().UTC().Format(time.RFC3339), + }) + _ = h.RedisService.Set(prefixLoginCodeQr+pendingRef, string(qrPayload), loginCodeExpiration) + _ = h.RedisService.Set(prefixLoginCodeQrPending+effectiveLoginID, pendingRef, loginCodeExpiration) + _ = h.RedisService.Set(prefixLoginCode+effectiveLoginID, init.FlowID, loginCodeExpiration) + return nil } func (h *AuthHandler) resolveDescopeLoginID(ctx context.Context, token *descope.Token) (string, error) { diff --git a/docker/ory/kratos/kratos.yml b/docker/ory/kratos/kratos.yml index 1bcc337e..277dc1d0 100644 --- a/docker/ory/kratos/kratos.yml +++ b/docker/ory/kratos/kratos.yml @@ -3,84 +3,90 @@ version: v1.3.0 dsn: memory serve: - public: - base_url: http://localhost:4433/ - cors: - enabled: true - admin: - base_url: http://localhost:4434/ + public: + base_url: http://localhost:4433/ + cors: + enabled: true + admin: + base_url: http://localhost:4434/ selfservice: - default_browser_return_url: http://localhost:4455/ - allowed_return_urls: - - http://localhost:4455 - - http://localhost:5000 + default_browser_return_url: http://localhost:4455/ + allowed_return_urls: + - http://localhost:4455 + - http://localhost:5000 + - https://sss.hmac.kr + - https://sss.hmac.kr/ + - https://sso.hmac.kr + - https://sso.hmac.kr/ + - https://app.hmac.kr + - https://app.hmac.kr/ - methods: - password: - enabled: true - link: - enabled: true - code: - enabled: true - passwordless_enabled: true + methods: + password: + enabled: true + link: + enabled: true + code: + enabled: true + passwordless_enabled: true - flows: - error: - ui_url: http://localhost:4455/error - settings: - ui_url: http://localhost:4455/settings - privileged_session_max_age: 15m - recovery: - ui_url: http://localhost:4455/recovery - use: code - verification: - ui_url: http://localhost:4455/verification - use: code - logout: - after: - default_browser_return_url: http://localhost:4455/login - login: - ui_url: http://localhost:4455/login - lifespan: 10m - registration: - ui_url: http://localhost:4455/registration - lifespan: 10m + flows: + error: + ui_url: http://localhost:4455/error + settings: + ui_url: http://localhost:4455/settings + privileged_session_max_age: 15m + recovery: + ui_url: http://localhost:4455/recovery + use: code + verification: + ui_url: http://localhost:4455/verification + use: code + logout: + after: + default_browser_return_url: http://localhost:4455/login + login: + ui_url: http://localhost:4455/login + lifespan: 10m + registration: + ui_url: http://localhost:4455/registration + lifespan: 10m log: - level: debug - format: text - leak_sensitive_values: true + level: debug + format: text + leak_sensitive_values: true secrets: - cookie: - - PLEASE-CHANGE-ME-I-AM-VERY-INSECURE - cipher: - - 32-LONG-SECRET-NOT-SECURE-AT-ALL + cookie: + - PLEASE-CHANGE-ME-I-AM-VERY-INSECURE + cipher: + - 32-LONG-SECRET-NOT-SECURE-AT-ALL ciphers: - algorithm: xchacha20-poly1305 + algorithm: xchacha20-poly1305 hashers: - algorithm: bcrypt - bcrypt: - cost: 8 + algorithm: bcrypt + bcrypt: + cost: 8 identity: - default_schema_id: default - schemas: - - id: default - url: file:///etc/config/kratos/identity.schema.json + default_schema_id: default + schemas: + - id: default + url: file:///etc/config/kratos/identity.schema.json courier: - template_override_path: /etc/config/kratos/courier-templates - delivery_strategy: http - http: - request_config: - url: http://baron_backend:3000/api/v1/auth/webhooks/kratos-courier - method: POST - body: file:///etc/config/kratos/courier-http.jsonnet - headers: - Content-Type: application/json - smtp: - connection_uri: smtps://test:test@mailslurper:1025/?skip_ssl_verify=true + template_override_path: /etc/config/kratos/courier-templates + delivery_strategy: http + http: + request_config: + url: http://baron_backend:3000/api/v1/auth/webhooks/kratos-courier + method: POST + body: file:///etc/config/kratos/courier-http.jsonnet + headers: + Content-Type: application/json + smtp: + connection_uri: smtps://test:test@mailslurper:1025/?skip_ssl_verify=true diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index ddd653bb..2fb3897b 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -282,19 +282,41 @@ class AuthProxyService { throw Exception('QR Polling failed: ${response.body}'); } - static Future approveQrLogin(String pendingRef, String token) async { + static Future approveQrLogin( + String pendingRef, { + String? token, + bool withCredentials = false, + }) async { final url = Uri.parse('$_baseUrl/api/v1/auth/qr/approve'); // Mapping to ScanQRLogin on backend - final response = await http.post( - url, - headers: {'Content-Type': 'application/json'}, - body: jsonEncode({ - 'pendingRef': pendingRef, - 'token': token, - }), - ); + final payload = { + 'pendingRef': pendingRef, + }; + if (token != null && token.isNotEmpty) { + payload['token'] = token; + } - if (response.statusCode != 200) { - throw Exception('QR Approval failed: ${response.body}'); + http.Client? client; + try { + if (withCredentials) { + client = createHttpClient(withCredentials: true); + } + final response = await (client != null + ? client.post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(payload), + ) + : http.post( + url, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(payload), + )); + + if (response.statusCode != 200) { + throw Exception('QR Approval failed: ${response.body}'); + } + } finally { + client?.close(); } } diff --git a/userfront/lib/features/auth/presentation/approve_qr_screen.dart b/userfront/lib/features/auth/presentation/approve_qr_screen.dart index 744e8abf..ea0b480f 100644 --- a/userfront/lib/features/auth/presentation/approve_qr_screen.dart +++ b/userfront/lib/features/auth/presentation/approve_qr_screen.dart @@ -16,13 +16,46 @@ class _ApproveQrScreenState extends State { bool _isLoading = false; String? _message; bool _success = false; + bool _isCheckingSession = false; + + @override + void initState() { + super.initState(); + _bootstrapCookieSession(); + } + + Future _bootstrapCookieSession() async { + if (AuthTokenStore.usesCookie()) { + return true; + } + if (_isCheckingSession) { + return false; + } + setState(() => _isCheckingSession = true); + try { + await AuthProxyService.checkCookieSession(); + AuthTokenStore.setCookieMode(provider: 'ory'); + return true; + } catch (_) { + return false; + } finally { + if (mounted) { + setState(() => _isCheckingSession = false); + } + } + } Future _handleApprove() async { if (widget.pendingRef == null) return; final storedToken = AuthTokenStore.getToken(); final session = Descope.sessionManager.session; - if (storedToken == null && (session == null || session.refreshToken.isExpired)) { + final usesCookie = AuthTokenStore.usesCookie(); + var hasCookie = usesCookie; + if (storedToken == null && (session == null || session.refreshToken.isExpired) && !hasCookie) { + hasCookie = await _bootstrapCookieSession(); + } + if (storedToken == null && (session == null || session.refreshToken.isExpired) && !hasCookie) { setState(() => _message = "Please log in on your phone first."); context.go('/signin'); // Redirect to login return; @@ -37,7 +70,8 @@ class _ApproveQrScreenState extends State { final token = storedToken ?? session?.sessionToken.jwt ?? ''; await AuthProxyService.approveQrLogin( widget.pendingRef!, - token, + token: token, + withCredentials: hasCookie, ); setState(() { _success = true; @@ -57,7 +91,10 @@ class _ApproveQrScreenState extends State { @override Widget build(BuildContext context) { - final isLoggedIn = Descope.sessionManager.session?.refreshToken.isExpired == false; + final hasStoredToken = AuthTokenStore.getToken() != null; + final hasDescopeSession = Descope.sessionManager.session?.refreshToken.isExpired == false; + final usesCookie = AuthTokenStore.usesCookie(); + final isLoggedIn = hasStoredToken || hasDescopeSession || usesCookie || _isCheckingSession; return Scaffold( appBar: AppBar(title: const Text("QR Login Approval")), diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index ea3bb2cb..18accd55 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -288,33 +288,36 @@ class _LoginScreenState extends ConsumerState timer.cancel(); _qrCountdownTimer?.cancel(); - final jwt = res['sessionJwt']; - final displayName = _getLoginIdFromJwt(jwt); - // Create User & Session for Descope SDK - final dummyUser = DescopeUser( - 'unknown', // userId - [], // loginIds - 0, // createdAt - displayName, // name - null, // picture (Uri?) - '', // email - false, // isVerifiedEmail - '', // phone - false, // isVerifiedPhone - {}, // customAttributes - '', // givenName - '', // middleName - '', // familyName - false, // hasPassword - 'enabled', // status - [], // roleNames - [], // ssoAppIds - [], // oauthProviders (List) - ); - final session = DescopeSession.fromJwt(jwt, jwt, dummyUser); - Descope.sessionManager.manageSession(session); + final token = res['sessionJwt'] as String; + final isJwt = token.split('.').length == 3; + if (isJwt) { + final displayName = _getLoginIdFromJwt(token); + // Create User & Session for Descope SDK + final dummyUser = DescopeUser( + 'unknown', // userId + [], // loginIds + 0, // createdAt + displayName, // name + null, // picture (Uri?) + '', // email + false, // isVerifiedEmail + '', // phone + false, // isVerifiedPhone + {}, // customAttributes + '', // givenName + '', // middleName + '', // familyName + false, // hasPassword + 'enabled', // status + [], // roleNames + [], // ssoAppIds + [], // oauthProviders (List) + ); + final session = DescopeSession.fromJwt(token, token, dummyUser); + Descope.sessionManager.manageSession(session); + } - _onLoginSuccess(jwt); + _onLoginSuccess(token); } } catch (e) { debugPrint("[QR] Polling error: $e"); @@ -906,8 +909,10 @@ class _LoginScreenState extends ConsumerState controller: _shortCodePrefixController, textCapitalization: TextCapitalization.characters, decoration: const InputDecoration( - labelText: "AA", + labelText: "영문 2자리", border: OutlineInputBorder(), + hintText: "AB", + hintStyle: TextStyle(color: Colors.grey), ), maxLength: 2, ), @@ -919,11 +924,13 @@ class _LoginScreenState extends ConsumerState controller: _shortCodeDigitsController, keyboardType: TextInputType.number, decoration: InputDecoration( - labelText: "000000", + labelText: "숫자 6자리", border: const OutlineInputBorder(), - hintText: _linkExpireSeconds > 0 + hintText: "345678", + hintStyle: const TextStyle(color: Colors.grey), + suffixText: _linkExpireSeconds > 0 ? "유효시간 ${_formatTime(_linkExpireSeconds)}" - : "000000", + : null, ), maxLength: 6, ), diff --git a/userfront/lib/features/auth/presentation/qr_scan_screen.dart b/userfront/lib/features/auth/presentation/qr_scan_screen.dart index 43aac077..cac86bc4 100644 --- a/userfront/lib/features/auth/presentation/qr_scan_screen.dart +++ b/userfront/lib/features/auth/presentation/qr_scan_screen.dart @@ -19,6 +19,38 @@ class _QRScanScreenState extends State { detectionSpeed: DetectionSpeed.noDuplicates, ); bool _isScanned = false; + bool _isCheckingSession = false; + bool _isProcessing = false; + bool? _isSuccess; + String? _resultMessage; + + @override + void initState() { + super.initState(); + _bootstrapCookieSession(); + } + + Future _bootstrapCookieSession() async { + if (AuthTokenStore.usesCookie()) { + return true; + } + if (_isCheckingSession) { + return false; + } + setState(() => _isCheckingSession = true); + try { + await AuthProxyService.checkCookieSession(); + AuthTokenStore.setCookieMode(provider: 'ory'); + return true; + } catch (e) { + _log.info('Cookie session check failed: $e'); + return false; + } finally { + if (mounted) { + setState(() => _isCheckingSession = false); + } + } + } @override void dispose() { @@ -33,6 +65,9 @@ class _QRScanScreenState extends State { for (final barcode in barcodes) { if (barcode.rawValue != null) { _isScanned = true; + if (mounted) { + setState(() => _isProcessing = true); + } String qrData = barcode.rawValue!; String pendingRef = qrData; @@ -42,6 +77,12 @@ class _QRScanScreenState extends State { final uri = Uri.parse(qrData); if (uri.queryParameters.containsKey('ref')) { pendingRef = uri.queryParameters['ref']!; + } else if (uri.pathSegments.isNotEmpty) { + final segments = uri.pathSegments; + final qlIndex = segments.indexOf('ql'); + if (qlIndex != -1 && qlIndex + 1 < segments.length) { + pendingRef = segments[qlIndex + 1]; + } } } catch (e) { _log.warning('Failed to parse QR URL: $qrData', e); @@ -49,10 +90,15 @@ class _QRScanScreenState extends State { } _log.info('QR Code detected raw: $qrData, ref: $pendingRef'); + final approveRef = qrData; - final sessionToken = AuthTokenStore.getToken() ?? - Descope.sessionManager.session?.sessionToken.jwt; - if (sessionToken == null) { + final storedToken = AuthTokenStore.getToken(); + final sessionToken = storedToken ?? Descope.sessionManager.session?.sessionToken.jwt; + var usesCookie = AuthTokenStore.usesCookie(); + if (sessionToken == null && !usesCookie) { + usesCookie = await _bootstrapCookieSession(); + } + if (sessionToken == null && !usesCookie) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('로그인이 필요합니다.'), backgroundColor: Colors.red), @@ -64,28 +110,27 @@ class _QRScanScreenState extends State { try { // Call backend API to approve login with clean ref - await AuthProxyService.approveQrLogin(pendingRef, sessionToken); + await AuthProxyService.approveQrLogin( + approveRef, + token: sessionToken, + withCredentials: usesCookie, + ); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('로그인 승인 완료!'), - backgroundColor: Colors.green, - ), - ); - // Wait a bit and go back - await Future.delayed(const Duration(milliseconds: 500)); - if (mounted) context.pop(); + setState(() { + _isSuccess = true; + _resultMessage = 'QR 승인 완료! PC 화면에서 로그인이 진행됩니다.'; + _isProcessing = false; + }); } } catch (e) { _log.severe("QR Approval Failed", e); if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('승인 실패: $e'), backgroundColor: Colors.red), - ); - // Allow rescanning after a delay - await Future.delayed(const Duration(seconds: 2)); - _isScanned = false; + setState(() { + _isSuccess = false; + _resultMessage = 'QR 승인 실패: $e'; + _isProcessing = false; + }); } } break; @@ -93,6 +138,58 @@ class _QRScanScreenState extends State { } } + void _resetScan() { + setState(() { + _isScanned = false; + _isProcessing = false; + _isSuccess = null; + _resultMessage = null; + }); + controller.start(); + } + + Widget _buildResultView() { + final success = _isSuccess == true; + final icon = success ? Icons.check_circle_outline : Icons.error_outline; + final color = success ? Colors.green : Colors.red; + final title = success ? '승인 완료' : '승인 실패'; + final message = _resultMessage ?? ''; + + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: color, size: 72), + const SizedBox(height: 16), + Text( + title, + style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: color), + ), + const SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.black54), + ), + const SizedBox(height: 24), + if (!success) + FilledButton( + onPressed: _resetScan, + child: const Text('다시 스캔'), + ), + if (success) + FilledButton( + onPressed: () => context.pop(), + child: const Text('닫기'), + ), + ], + ), + ), + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -103,22 +200,30 @@ class _QRScanScreenState extends State { onPressed: () => context.pop(), ), ), - body: MobileScanner( - controller: controller, - onDetect: _onDetect, - errorBuilder: (context, error, child) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + body: _isSuccess == null + ? Stack( children: [ - const Icon(Icons.error, color: Colors.red, size: 50), - const SizedBox(height: 10), - Text('Camera Error: ${error.errorCode}'), + MobileScanner( + controller: controller, + onDetect: _onDetect, + errorBuilder: (context, error, child) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error, color: Colors.red, size: 50), + const SizedBox(height: 10), + Text('Camera Error: ${error.errorCode}'), + ], + ), + ); + }, + ), + if (_isProcessing || _isCheckingSession) + const Center(child: CircularProgressIndicator()), ], - ), - ); - }, - ), + ) + : _buildResultView(), ); } } diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index 1f13a44e..7745b09e 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -157,6 +157,14 @@ final _router = GoRouter( return ApproveQrScreen(pendingRef: ref); }, ), + GoRoute( + path: '/ql/:ref', + builder: (context, state) { + final ref = state.pathParameters['ref']; + _routerLogger.info("Navigating to /ql with ref: $ref"); + return ApproveQrScreen(pendingRef: ref); + }, + ), GoRoute( path: '/scan', builder: (context, state) { @@ -186,6 +194,7 @@ final _router = GoRouter( path == '/verify' || path.startsWith('/verify/') || path == '/approve' || + path.startsWith('/ql/') || path == '/forgot-password' || path == '/reset-password';