forked from baron/baron-sso
audit 로그 개선. kratos 코드발급 링크로 전송까지 진행 완료 #104
This commit is contained in:
12
.env.sample
12
.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 변수들
|
||||
|
||||
107
Makefile
107
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
183
backend/internal/middleware/audit_middleware.go
Normal file
183
backend/internal/middleware/audit_middleware.go
Normal 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
|
||||
}
|
||||
}
|
||||
117
backend/internal/middleware/audit_middleware_test.go
Normal file
117
backend/internal/middleware/audit_middleware_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
79
backend/internal/utils/masking.go
Normal file
79
backend/internal/utils/masking.go
Normal 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
|
||||
}
|
||||
59
backend/internal/utils/masking_test.go
Normal file
59
backend/internal/utils/masking_test.go
Normal 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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
8
docker/ory/kratos/courier-http.jsonnet
Normal file
8
docker/ory/kratos/courier-http.jsonnet
Normal 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,
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,10 @@
|
||||
Baron SSO 로그인
|
||||
|
||||
# 운영 링크: https://app.brsw.kr/verify?loginId={{ .To }}&code={{ .LoginCode }}
|
||||
|
||||
아래 링크를 클릭하면 로그인이 완료됩니다.
|
||||
http://localhost:5000/verify?loginId={{ .To }}&code={{ .LoginCode }}
|
||||
|
||||
로그인 코드: {{ .LoginCode }}
|
||||
|
||||
요청하지 않았다면 이 메일을 무시해 주세요.
|
||||
@@ -0,0 +1 @@
|
||||
Baron SSO 로그인 링크
|
||||
@@ -0,0 +1,4 @@
|
||||
[Baron SSO] 로그인 링크
|
||||
# 운영 링크: https://app.brsw.kr/verify?loginId={{ .To }}&code={{ .LoginCode }}
|
||||
http://localhost:5000/verify?loginId={{ .To }}&code={{ .LoginCode }}
|
||||
코드: {{ .LoginCode }}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
89
docs/auth-flow.md
Normal 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 세션 교환 정책 확정
|
||||
@@ -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`
|
||||
|
||||
@@ -29,7 +29,6 @@ class AuditService {
|
||||
'event_type': eventType,
|
||||
'status': status,
|
||||
'details': details,
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
32
userfront/lib/core/services/auth_token_store.dart
Normal file
32
userfront/lib/core/services/auth_token_store.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
41
userfront/lib/core/services/auth_token_store_stub.dart
Normal file
41
userfront/lib/core/services/auth_token_store_stub.dart
Normal 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();
|
||||
49
userfront/lib/core/services/auth_token_store_web.dart
Normal file
49
userfront/lib/core/services/auth_token_store_web.dart
Normal 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();
|
||||
7
userfront/lib/core/services/http_client.dart
Normal file
7
userfront/lib/core/services/http_client.dart
Normal 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);
|
||||
}
|
||||
9
userfront/lib/core/services/http_client_stub.dart
Normal file
9
userfront/lib/core/services/http_client_stub.dart
Normal 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();
|
||||
12
userfront/lib/core/services/http_client_web.dart
Normal file
12
userfront/lib/core/services/http_client_web.dart
Normal 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();
|
||||
@@ -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>
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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}');
|
||||
|
||||
@@ -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' ||
|
||||
|
||||
Reference in New Issue
Block a user