1
0
forked from baron/baron-sso

audit 로그 개선. kratos 코드발급 링크로 전송까지 진행 완료 #104

This commit is contained in:
Lectom C Han
2026-01-29 01:20:19 +09:00
parent ff17259117
commit b88de7ec91
46 changed files with 2843 additions and 585 deletions

View File

@@ -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 변수들

107
Makefile
View File

@@ -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

View File

@@ -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 {

View File

@@ -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:

View File

@@ -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
)

View File

@@ -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=

View File

@@ -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

View File

@@ -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)
}

File diff suppressed because it is too large Load Diff

View File

@@ -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"])
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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)
})
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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))
}
})
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View File

@@ -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,
}

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="ko">
<body style="font-family: sans-serif; line-height: 1.6;">
<h2>Baron SSO 로그인</h2>
<p>아래 버튼을 클릭하면 로그인이 완료됩니다.</p>
<!-- 운영 링크: https://app.brsw.kr/verify?loginId={{ .To }}&code={{ .LoginCode }} -->
<p>
<a href="http://localhost:5000/verify?loginId={{ .To }}&code={{ .LoginCode }}"
style="display: inline-block; padding: 10px 16px; background: #1a1f2c; color: #fff; text-decoration: none; border-radius: 6px;">
로그인 완료하기
</a>
</p>
<p>또는 아래 로그인 코드를 입력해도 됩니다.</p>
<p style="font-size: 18px; font-weight: bold;">{{ .LoginCode }}</p>
<p style="color: #666; font-size: 12px;">요청하지 않았다면 이 메일을 무시해 주세요.</p>
</body>
</html>

View File

@@ -0,0 +1,10 @@
Baron SSO 로그인
# 운영 링크: https://app.brsw.kr/verify?loginId={{ .To }}&code={{ .LoginCode }}
아래 링크를 클릭하면 로그인이 완료됩니다.
http://localhost:5000/verify?loginId={{ .To }}&code={{ .LoginCode }}
로그인 코드: {{ .LoginCode }}
요청하지 않았다면 이 메일을 무시해 주세요.

View File

@@ -0,0 +1 @@
Baron SSO 로그인 링크

View File

@@ -0,0 +1,4 @@
[Baron SSO] 로그인 링크
# 운영 링크: https://app.brsw.kr/verify?loginId={{ .To }}&code={{ .LoginCode }}
http://localhost:5000/verify?loginId={{ .To }}&code={{ .LoginCode }}
코드: {{ .LoginCode }}

View File

@@ -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
}
}
}
}

View File

@@ -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

View File

@@ -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": {

89
docs/auth-flow.md Normal file
View File

@@ -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 <token>`로 전달
- 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 세션 교환 정책 확정

View File

@@ -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`

View File

@@ -29,7 +29,6 @@ class AuditService {
'event_type': eventType,
'status': status,
'details': details,
'timestamp': DateTime.now().toIso8601String(),
}),
);

View File

@@ -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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<Map<String, dynamic>> verifyMagicLink(String token) async {
@@ -83,6 +104,25 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> 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<Map<String, dynamic>> 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<void> approveQrLogin(String pendingRef, String token) async {

View File

@@ -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();
}
}

View File

@@ -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();

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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();

View File

@@ -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<LoginScreen>
// 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<LoginScreen>
// 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<void> _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<LoginScreen>
setState(() {
_isQrLoading = true;
_qrImageBase64 = null;
_qrUserCode = null;
_qrRemainingSeconds = 0;
});
@@ -117,6 +156,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_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<LoginScreen>
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<LoginScreen>
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<LoginScreen>
}
}
Future<void> _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<LoginScreen>
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<LoginScreen>
// 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<LoginScreen>
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<LoginScreen>
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<LoginScreen>
);
// 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<LoginScreen>
}
}
Future<void> _pollForSession(String pendingRef) async {
Future<void> _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<LoginScreen>
}
}
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<LoginScreen>
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<LoginScreen>
),
),
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<LoginScreen>
),
);
}
}
}

View File

@@ -110,13 +110,17 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
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 = <String>["최소 ${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<ResetPasswordScreen> {
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;

View File

@@ -781,36 +781,47 @@ class _SignupScreenState extends State<SignupScreen> {
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 = <String>["최소 $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<SignupScreen> {
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<SignupScreen> {
),
);
}
}
}

View File

@@ -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<void> _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],

View File

@@ -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<String?> _getToken() async {
final session = await Descope.sessionManager.session;
return session?.sessionToken.jwt;
return AuthTokenStore.getToken();
}
Future<UserProfile> 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 = <String, String>{
'Content-Type': 'application/json',
};
if (!useCookie && token != null) {
headers['Authorization'] = 'Bearer $token';
}
final response = await client.get(url, headers: headers);
client.close();
if (response.statusCode == 200) {
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 = <String, String>{
'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<void> 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 = <String, String>{
'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<void> 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 = <String, String>{
'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}');

View File

@@ -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' ||