1
0
forked from baron/baron-sso

ory-hosting 기본구동

This commit is contained in:
Lectom C Han
2026-01-27 22:58:49 +09:00
parent 41f0549435
commit c3f7b18afc
31 changed files with 1910 additions and 176 deletions

View File

@@ -9,6 +9,7 @@ import (
"baron-sso-backend/internal/service"
"baron-sso-backend/internal/validator"
"fmt"
"log"
"log/slog"
"os"
"strconv"
@@ -22,6 +23,11 @@ import (
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/gofiber/fiber/v2/middleware/requestid"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
gormLogger "gorm.io/gorm/logger"
"baron-sso-backend/internal/bootstrap"
)
func getEnv(key, fallback string) string {
@@ -93,6 +99,7 @@ func main() {
// -----------------------------------
// 2. Initialize DB Connections
// ClickHouse
chHost := getEnv("CLICKHOUSE_HOST", "localhost")
chPort, _ := strconv.Atoi(getEnv("CLICKHOUSE_PORT_NATIVE", "9000"))
chUser := getEnv("CLICKHOUSE_USER", "default")
@@ -104,6 +111,46 @@ func main() {
slog.Warn("Failed to connect to ClickHouse. Audit logs will fail.", "error", err)
}
// PostgreSQL (Meta Store)
pgHost := getEnv("DB_HOST", "localhost")
pgPort := getEnv("DB_PORT", "5432")
pgUser := getEnv("DB_USER", "baron")
pgPass := getEnv("DB_PASSWORD", "password")
pgName := getEnv("DB_NAME", "baron_sso")
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul",
pgHost, pgUser, pgPass, pgName, pgPort)
gormLog := gormLogger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
gormLogger.Config{
SlowThreshold: time.Second,
LogLevel: gormLogger.Warn,
IgnoreRecordNotFoundError: true,
Colorful: true,
},
)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: gormLog,
})
if err != nil {
slog.Error("❌ Failed to connect to PostgreSQL", "error", err)
// For local dev without Postgres, we might want to continue or panic.
// But bootstrap requires DB.
if getEnv("APP_ENV", "dev") == "production" {
os.Exit(1)
}
} else {
slog.Info("✅ Connected to PostgreSQL")
// Run Bootstrap (Migrations & Seeding)
if err := bootstrap.Run(db); err != nil {
slog.Error("❌ Bootstrap failed", "error", err)
// Panic or Exit depending on policy.
}
}
redisService, err := service.NewRedisService()
if err != nil {
slog.Warn("Failed to connect to Redis. Auth features may fail.", "error", err)
@@ -111,7 +158,7 @@ func main() {
// 2. Initialize Handlers
auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService)
authHandler := handler.NewAuthHandler(redisService, idpProvider)
adminHandler := handler.NewAdminHandler()
// 3. Initialize Fiber

View File

@@ -13,6 +13,10 @@ require (
github.com/go-redis/redis/v8 v8.11.5
github.com/gofiber/fiber/v2 v2.52.10
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.46.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
require (
@@ -35,8 +39,12 @@ require (
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
@@ -50,6 +58,7 @@ require (
github.com/paulmach/orb v0.12.0 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // 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/valyala/bytebufferpool v1.0.0 // indirect
@@ -58,7 +67,8 @@ require (
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
)

View File

@@ -68,6 +68,18 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
@@ -75,8 +87,9 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -117,12 +130,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
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/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=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
@@ -169,6 +186,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -197,8 +216,9 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
@@ -206,3 +226,7 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

View File

@@ -0,0 +1,81 @@
package bootstrap
import (
"baron-sso-backend/internal/domain"
"errors"
"fmt"
"log/slog"
"os"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// Run executes the application bootstrap logic (migrations, seeding, etc.)
func Run(db *gorm.DB) error {
slog.Info("[Bootstrap] Starting application bootstrap...")
// 1. Auto Migration
if err := migrateSchemas(db); err != nil {
return fmt.Errorf("migration failed: %w", err)
}
// 2. Seed Initial Admin User
if err := seedAdminUser(db); err != nil {
return fmt.Errorf("seeding admin failed: %w", err)
}
slog.Info("[Bootstrap] Bootstrap completed successfully.")
return nil
}
func migrateSchemas(db *gorm.DB) error {
slog.Info("[Bootstrap] Migrating database schemas...")
// Add all domain models here
return db.AutoMigrate(
&domain.User{},
// &domain.RelyingParty{}, // TODO: Uncomment when model is ready
// &domain.UserConsent{}, // TODO: Uncomment when model is ready
)
}
func seedAdminUser(db *gorm.DB) error {
adminEmail := os.Getenv("ADMIN_EMAIL")
adminPassword := os.Getenv("ADMIN_PASSWORD")
if adminEmail == "" || adminPassword == "" {
slog.Warn("[Bootstrap] ADMIN_EMAIL or ADMIN_PASSWORD not set. Skipping admin seeding.")
return nil
}
var user domain.User
if err := db.Where("email = ?", adminEmail).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
slog.Info("[Bootstrap] Creating initial admin user", "email", adminEmail)
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
adminUser := domain.User{
Email: adminEmail,
PasswordHash: string(hashedPassword),
Name: "System Admin",
Role: "admin", // Assuming 'role' field exists or handling via attributes
// Add other required fields
}
if err := db.Create(&adminUser).Error; err != nil {
return err
}
slog.Info("[Bootstrap] Admin user created successfully.")
} else {
return err
}
} else {
slog.Info("[Bootstrap] Admin user already exists.", "email", adminEmail)
}
return nil
}

View File

@@ -34,6 +34,8 @@ type Token struct {
type AuthInfo struct {
SessionToken *Token
RefreshToken *Token
// Subject는 IDP 세션이 대표하는 주체(예: Kratos identity.id)를 나타냅니다.
Subject string
}
// IdentityProvider is the interface that all IDP adapters must implement.
@@ -42,6 +44,10 @@ type IdentityProvider interface {
// GetMetadata returns the schema support information for this IDP.
// This is used for startup-time validation.
GetMetadata() (*IDPMetadata, error)
// CreateUser는 BrokerUser 스키마를 기반으로 신규 사용자를 생성하고 주체 ID(예: identity.id)를 반환합니다.
CreateUser(user *BrokerUser, password string) (string, error)
// SignIn은 로그인 ID/비밀번호로 인증해 세션 정보를 반환합니다.
SignIn(loginID, password string) (*AuthInfo, error)
InitiatePasswordReset(loginID, redirectUrl string) error
VerifyPasswordResetToken(token string) (*AuthInfo, error)
UpdateUserPassword(loginID, newPassword string, r *http.Request) error

View File

@@ -0,0 +1,33 @@
package domain
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// User represents the user model stored in PostgreSQL
type User struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
Email string `gorm:"uniqueIndex;not null" json:"email"`
PasswordHash string `gorm:"not null" json:"-"`
Name string `gorm:"not null" json:"name"`
Phone string `json:"phone"`
Role string `gorm:"default:'user'" json:"role"` // 'admin', 'user'
AffiliationType string `json:"affiliationType"`
CompanyCode string `json:"companyCode"`
Department string `json:"department"`
Status string `gorm:"default:'active'" json:"status"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// BeforeCreate hook to generate UUID if not present
func (u *User) BeforeCreate(tx *gorm.DB) (err error) {
if u.ID == "" {
u.ID = uuid.New().String()
}
return
}

View File

@@ -2,7 +2,6 @@ package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/idp"
"baron-sso-backend/internal/logger"
"baron-sso-backend/internal/service"
"context"
@@ -14,7 +13,6 @@ import (
"math/rand"
"os"
"regexp"
"strconv"
"strings"
"time"
@@ -70,7 +68,7 @@ func GenerateSecureToken(length int) string {
return hex.EncodeToString(b)
}
func NewAuthHandler(redisService *service.RedisService) *AuthHandler {
func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider) *AuthHandler {
projectID := os.Getenv("DESCOPE_PROJECT_ID")
managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY")
@@ -86,13 +84,6 @@ func NewAuthHandler(redisService *service.RedisService) *AuthHandler {
}
}
idpProvider, err := idp.InitializeProvider()
if err != nil {
slog.Error("Failed to initialize IDP Provider", "error", err)
// Depending on the application's needs, you might want to panic here
// if the IDP provider is essential for the application to run.
}
return &AuthHandler{
ProjectID: projectID,
SmsService: service.NewSmsService(),
@@ -342,12 +333,11 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Phone not verified"})
}
// 3. Create User in Descope
if h.DescopeClient == nil {
if h.IdpProvider == nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Identity provider unavailable"})
}
// Normalize Phone for Descope (E.164)
// Normalize Phone (E.164 형태로 보관)
normalizedPhone := strings.ReplaceAll(req.Phone, "-", "")
normalizedPhone = strings.ReplaceAll(normalizedPhone, " ", "")
if strings.HasPrefix(normalizedPhone, "010") {
@@ -356,54 +346,42 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
normalizedPhone = "+" + normalizedPhone
}
descopeUser := &descope.UserRequest{}
descopeUser.Email = req.Email
descopeUser.Phone = normalizedPhone
descopeUser.Name = req.Name
descopeUser.CustomAttributes = map[string]any{
// IDP에 전달할 BrokerUser 스키마 구성
attributes := map[string]interface{}{
"department": req.Department,
"affiliationType": req.AffiliationType,
"companyCode": req.CompanyCode,
"department": req.Department,
"termsAccepted": req.TermsAccepted,
"createdAt": time.Now().Format(time.RFC3339),
// grade는 기존 스키마 필수 키이므로 기본값을 설정
"grade": "member",
}
brokerUser := &domain.BrokerUser{
Email: req.Email,
Name: req.Name,
PhoneNumber: normalizedPhone,
Attributes: attributes,
}
// Create user
// Note: Descope `Create` does not set password. We usually need `Create` then `Password().Update`
// or use a specialized signup flow.
// `Management.User().Create` creates a user but doesn't set a password credential immediately unless specified?
// Actually `User().Create` creates the identity.
// To set password, we use `h.DescopeClient.Management.User().SetPassword(...)`
// Check if user exists (Double check)
exists, _ := h.DescopeClient.Management.User().Load(context.Background(), req.Email)
if exists != nil {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "User already exists"})
}
// Create
_, err := h.DescopeClient.Management.User().Create(context.Background(), req.Email, descopeUser)
providerID, err := h.IdpProvider.CreateUser(brokerUser, req.Password)
if err != nil {
slog.Error("[Signup] Failed to create user in Descope", "error", err)
slog.Error("[Signup] Failed to create user via IDP", "provider", h.IdpProvider.Name(), "error", err)
if strings.Contains(err.Error(), "already exists") {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "User already exists"})
}
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create user"})
}
// Set Password
err = h.DescopeClient.Management.User().SetPassword(context.Background(), req.Email, req.Password)
if err != nil {
slog.Error("[Signup] Failed to set password", "error", err)
// Rollback? Delete user?
h.DescopeClient.Management.User().Delete(context.Background(), req.Email)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": fmt.Sprintf("Failed to set password: %v", err)})
}
// 4. Cleanup Redis
h.RedisService.Delete(emailKey)
h.RedisService.Delete(phoneKey)
slog.Info("[Signup] New user registered", "email", req.Email, "type", req.AffiliationType)
slog.Info("[Signup] New user registered", "email", req.Email, "type", req.AffiliationType, "provider", h.IdpProvider.Name(), "subject", providerID)
return c.JSON(fiber.Map{"success": true, "message": "User registered successfully"})
return c.JSON(fiber.Map{
"success": true,
"message": "User registered successfully",
"provider": h.IdpProvider.Name(),
"subject": providerID,
})
}
// --- Helpers ---
@@ -750,38 +728,43 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
ale.Log(slog.LevelInfo, "Attempting to login")
if h.DescopeClient == nil {
if h.IdpProvider == nil {
ale.Status = fiber.StatusInternalServerError
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = "Descope Client is nil!"
ale.Log(slog.LevelError, "Descope Client is nil")
ale.DescopeError = "IDP Provider is nil"
ale.Log(slog.LevelError, "IDP Provider is nil")
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
}
// Sign in using Descope
authInfo, err := h.DescopeClient.Auth.Password().SignIn(context.Background(), req.LoginID, req.Password, nil)
authInfo, err := h.IdpProvider.SignIn(loginID, req.Password)
if err != nil {
ale.Status = fiber.StatusUnauthorized
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = err.Error()
ale.Log(slog.LevelWarn, "Descope sign-in failed")
// [Changed] Check if it's a "User not found" error to be more specific
if strings.Contains(err.Error(), "E062107") || strings.Contains(err.Error(), "not found") {
ale.Log(slog.LevelWarn, "IDP sign-in failed", slog.String("provider", h.IdpProvider.Name()))
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "identity") {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not registered"})
}
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
}
ale.Status = fiber.StatusOK
ale.LatencyMs = time.Since(startTime)
ale.SessionJwt = authInfo.SessionToken.JWT
ale.Log(slog.LevelInfo, "Login successful")
return c.JSON(fiber.Map{
ale.Log(slog.LevelInfo, "Login successful", slog.String("provider", h.IdpProvider.Name()), slog.String("subject", authInfo.Subject))
resp := fiber.Map{
"sessionJwt": authInfo.SessionToken.JWT,
"status": "ok",
})
"provider": h.IdpProvider.Name(),
}
if authInfo.RefreshToken != nil {
resp["refreshJwt"] = authInfo.RefreshToken.JWT
}
if authInfo.Subject != "" {
resp["subject"] = authInfo.Subject
}
return c.JSON(resp)
}
// InitiatePasswordReset - 사용자가 비밀번호 재설정을 시작하면, loginID 유형에 따라 Descope를 통해 이메일 또는 SMS를 보냅니다.
@@ -997,6 +980,12 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
ale := logger.NewAuditLogEntry(c, "complete")
ale.Operation = "UpdateUserPassword"
providerName := "unknown"
if h.IdpProvider != nil {
providerName = h.IdpProvider.Name()
}
isDescopeProvider := strings.Contains(strings.ToLower(providerName), "descope")
var req struct {
NewPassword string `json:"newPassword"`
}
@@ -1044,78 +1033,79 @@ func (h *AuthHandler) CompletePasswordReset(c *fiber.Ctx) error {
// 디버깅을 위해 요청된 새 비밀번호를 로그로 출력
ale.Log(slog.LevelInfo, "Received new password for reset")
// Validate password complexity dynamically based on Descope policy
policy, err := h.DescopeClient.Auth.Password().GetPasswordPolicy(context.Background())
if err != nil {
// If policy fetch fails, log warning and proceed (or fallback to basic check)
ale.Log(slog.LevelWarn, "Failed to fetch password policy, skipping dynamic validation: "+err.Error())
} else {
if len(req.NewPassword) < int(policy.MinLength) {
ale.Status = fiber.StatusBadRequest
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = fmt.Sprintf("Password must be at least %d characters long", policy.MinLength)
ale.Log(slog.LevelWarn, "Validation failed: password too short")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": ale.DescopeError})
}
if policy.Lowercase {
if ok, _ := regexp.MatchString(`[a-z]`, req.NewPassword); !ok {
ale.Status = fiber.StatusBadRequest
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = "Password must contain at least one lowercase letter"
ale.Log(slog.LevelWarn, "Validation failed: no lowercase letter")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one lowercase letter"})
}
}
if policy.Uppercase {
if ok, _ := regexp.MatchString(`[A-Z]`, req.NewPassword); !ok {
ale.Status = fiber.StatusBadRequest
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = "Password must contain at least one uppercase letter"
ale.Log(slog.LevelWarn, "Validation failed: no uppercase letter")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one uppercase letter"})
}
}
if policy.Number {
if ok, _ := regexp.MatchString(`[0-9]`, req.NewPassword); !ok {
ale.Status = fiber.StatusBadRequest
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = "Password must contain at least one number"
ale.Log(slog.LevelWarn, "Validation failed: no number")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one number"})
}
}
if policy.NonAlphanumeric {
if ok, _ := regexp.MatchString(`[\W_]`, req.NewPassword); !ok {
ale.Status = fiber.StatusBadRequest
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = "Password must contain at least one special character"
ale.Log(slog.LevelWarn, "Validation failed: no special character")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one special character"})
}
}
if len(req.NewPassword) < 8 {
ale.Status = fiber.StatusBadRequest
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = "Password must be at least 8 characters long"
ale.Log(slog.LevelWarn, "Validation failed: password too short (fallback policy)")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": ale.DescopeError})
}
ale.Log(slog.LevelInfo, "Attempting to update password via Descope Auth API")
if isDescopeProvider && h.DescopeClient != nil {
// Validate password complexity (Descope only)
policy, err := h.DescopeClient.Auth.Password().GetPasswordPolicy(context.Background())
if err != nil {
ale.Log(slog.LevelWarn, "Failed to fetch password policy, skipping dynamic validation: "+err.Error())
} else {
if len(req.NewPassword) < int(policy.MinLength) {
ale.Status = fiber.StatusBadRequest
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = fmt.Sprintf("Password must be at least %d characters long", policy.MinLength)
ale.Log(slog.LevelWarn, "Validation failed: password too short")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": ale.DescopeError})
}
if policy.Lowercase {
if ok, _ := regexp.MatchString(`[a-z]`, req.NewPassword); !ok {
ale.Status = fiber.StatusBadRequest
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = "Password must contain at least one lowercase letter"
ale.Log(slog.LevelWarn, "Validation failed: no lowercase letter")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one lowercase letter"})
}
}
if policy.Uppercase {
if ok, _ := regexp.MatchString(`[A-Z]`, req.NewPassword); !ok {
ale.Status = fiber.StatusBadRequest
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = "Password must contain at least one uppercase letter"
ale.Log(slog.LevelWarn, "Validation failed: no uppercase letter")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one uppercase letter"})
}
}
if policy.Number {
if ok, _ := regexp.MatchString(`[0-9]`, req.NewPassword); !ok {
ale.Status = fiber.StatusBadRequest
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = "Password must contain at least one number"
ale.Log(slog.LevelWarn, "Validation failed: no number")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one number"})
}
}
if policy.NonAlphanumeric {
if ok, _ := regexp.MatchString(`[\W_]`, req.NewPassword); !ok {
ale.Status = fiber.StatusBadRequest
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = "Password must contain at least one special character"
ale.Log(slog.LevelWarn, "Validation failed: no special character")
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Password must contain at least one special character"})
}
}
}
} else if isDescopeProvider && h.DescopeClient == nil {
ale.Log(slog.LevelWarn, "Descope selected but client is nil; skipping policy validation")
}
// Descope Management API를 통해 비밀번호 업데이트
if h.DescopeClient == nil {
ale.Log(slog.LevelInfo, "Attempting to update password via IDP", slog.String("idp", providerName))
if h.IdpProvider == nil {
ale.Status = fiber.StatusInternalServerError
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = "Descope Client is nil!"
ale.Log(slog.LevelError, "Descope Client is nil")
ale.DescopeError = "IDP Provider is nil"
ale.Log(slog.LevelError, "IDP Provider is nil")
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
}
if err := h.DescopeClient.Management.User().SetActivePassword(context.Background(), loginID, req.NewPassword); err != nil {
// Descope 에러 상세를 감사 로그에 포함
if de, ok := err.(*descope.Error); ok {
if statusRaw, ok := de.Info[descope.ErrorInfoKeys.HTTPResponseStatusCode]; ok {
if statusInt, convErr := strconv.Atoi(fmt.Sprintf("%v", statusRaw)); convErr == nil {
ale.DescopeStatus = statusInt
}
}
ale.DescopeBody = de.Message
}
if err := h.IdpProvider.UpdateUserPassword(loginID, req.NewPassword, nil); err != nil {
ale.Status = fiber.StatusInternalServerError
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = err.Error()

View File

@@ -3,12 +3,26 @@ package idp
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"strings"
)
// Ory 계열(kratos/hydra)와 Descope 등 공급자 문자열을 정규화하기 위한 매핑.
var providerAliases = map[string]string{
"ory": "ory",
"hydra": "ory",
"kratos": "ory",
"ory-kratos": "ory",
"ory_hydra": "ory",
"ory_kratos": "ory",
"descope": "descope",
"descope_sso": "descope",
}
// getEnv는 환경 변수를 읽거나 대체 값을 반환하는 헬퍼 함수입니다.
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
@@ -22,42 +36,182 @@ func getEnv(key, fallback string) string {
func InitializeProvider() (domain.IdentityProvider, error) {
rawProviders := getEnv("IDP_PROVIDER", "descope") // 기본값은 descope입니다.
providers := strings.Split(rawProviders, ",")
slog.Info("Initializing IDP", "providers", rawProviders)
slog.Info("Initializing IDP chain", "providers", rawProviders)
var initialized []domain.IdentityProvider
for _, p := range providers {
providerName := strings.TrimSpace(strings.ToLower(p))
if canonical, ok := providerAliases[providerName]; ok {
providerName = canonical
}
switch providerName {
case "ory":
// Kratos/Hydra 주 공급자
oryProvider := service.NewOryProvider()
initialized = append(initialized, oryProvider)
case "descope":
descopeProjectID := getEnv("DESCOPE_PROJECT_ID", "")
descopeManagementKey := getEnv("DESCOPE_MANAGEMENT_KEY", "")
// 선택된 공급자에 대한 키가 설정되었는지 확인하기 위한 기본 유효성 검사
if descopeProjectID == "" || descopeManagementKey == "" {
return nil, fmt.Errorf("DESCOPE_PROJECT_ID and DESCOPE_MANAGEMENT_KEY must be set for the 'descope' provider")
slog.Warn("Skipping Descope provider due to missing credentials")
continue
}
return service.NewDescopeProvider(descopeProjectID, descopeManagementKey), nil
initialized = append(initialized, service.NewDescopeProvider(descopeProjectID, descopeManagementKey))
// --- 향후 공급자 구현 ---
// case "ory":
// // oryURL := getEnv("ORY_URL", "")
// // if oryURL == "" {
// // return nil, fmt.Errorf("ORY_URL must be set for the 'ory' provider")
// // }
// // return service.NewOryProvider(oryURL), nil
// // return nil, fmt.Errorf(\"'ory' provider is not yet implemented\")
// case "keycloak":
// // keycloakURL := getEnv("KEYCLOAK_URL", "")
// // keycloakRealm := getEnv("KEYCLOAK_REALM", "")
// // if keycloakURL == "" || keycloakRealm == "" {
// // return nil, fmt.Errorf("KEYCLOAK_URL and KEYCLOAK_REALM must be set for the 'keycloak' provider")
// // }
// // return service.NewKeycloakProvider(keycloakURL, keycloakRealm), nil
// // return nil, fmt.Errorf(\"'keycloak' provider is not yet implemented\")
default:
// 알 수 없는 공급자는 건너뛰고 다음 후보를 시도
slog.Warn("Skipping unsupported IDP provider entry", "provider", providerName)
}
}
return nil, fmt.Errorf("unsupported or unknown IDP_PROVIDER specified: %s", rawProviders)
if len(initialized) == 0 {
return nil, fmt.Errorf("no valid IDP_PROVIDER entries configured from: %s", rawProviders)
}
if len(initialized) == 1 {
slog.Info("Initialized IDP provider", "provider", initialized[0].Name())
return initialized[0], nil
}
chain := newChainedProvider(initialized)
slog.Info("Initialized IDP provider chain", "providers", chain.Name())
return chain, nil
}
// newChainedProvider는 우선순위 순으로 IDP를 시도하는 체인을 생성합니다.
func newChainedProvider(providers []domain.IdentityProvider) domain.IdentityProvider {
names := make([]string, len(providers))
for i, p := range providers {
names[i] = p.Name()
}
return &chainedProvider{
providers: providers,
names: names,
}
}
// chainedProvider는 다중 IDP를 우선순위대로 호출하며 실패 시 폴백합니다.
type chainedProvider struct {
providers []domain.IdentityProvider
names []string
}
func (c *chainedProvider) Name() string {
return strings.Join(c.names, " > ")
}
func (c *chainedProvider) GetMetadata() (*domain.IDPMetadata, error) {
supported := make([]string, 0)
seen := make(map[string]bool)
for _, p := range c.providers {
meta, err := p.GetMetadata()
if err != nil {
return nil, fmt.Errorf("failed to fetch metadata from %s: %w", p.Name(), err)
}
for _, field := range meta.SupportedFields {
if !seen[field] {
seen[field] = true
supported = append(supported, field)
}
}
}
return &domain.IDPMetadata{SupportedFields: supported}, nil
}
func (c *chainedProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
var errs []error
for idx, p := range c.providers {
id, err := p.CreateUser(user, password)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err))
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 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...))
}
func (c *chainedProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
var errs []error
for idx, p := range c.providers {
info, err := p.SignIn(loginID, password)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err))
slog.Warn("IDP provider failed", "provider", p.Name(), "operation", "SignIn", "error", err)
continue
}
if idx > 0 {
slog.Info("IDP fallback succeeded", "operation", "SignIn", "provider", p.Name())
}
return info, nil
}
if len(errs) == 0 {
return nil, fmt.Errorf("no IDP providers available for SignIn")
}
return nil, fmt.Errorf("all IDP providers failed for SignIn: %w", errors.Join(errs...))
}
func (c *chainedProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
return c.tryProviders("InitiatePasswordReset", func(p domain.IdentityProvider) error {
return p.InitiatePasswordReset(loginID, redirectUrl)
})
}
func (c *chainedProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
var errs []error
for idx, p := range c.providers {
info, err := p.VerifyPasswordResetToken(token)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err))
slog.Warn("IDP VerifyPasswordResetToken failed", "provider", p.Name(), "error", err)
continue
}
if idx > 0 {
slog.Info("IDP fallback succeeded", "operation", "VerifyPasswordResetToken", "provider", p.Name())
}
return info, nil
}
if len(errs) == 0 {
return nil, fmt.Errorf("no IDP providers available for VerifyPasswordResetToken")
}
return nil, fmt.Errorf("all IDP providers failed for VerifyPasswordResetToken: %w", errors.Join(errs...))
}
func (c *chainedProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
return c.tryProviders("UpdateUserPassword", func(p domain.IdentityProvider) error {
return p.UpdateUserPassword(loginID, newPassword, r)
})
}
func (c *chainedProvider) tryProviders(operation string, fn func(domain.IdentityProvider) error) error {
var errs []error
for idx, p := range c.providers {
if err := fn(p); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", p.Name(), err))
slog.Warn("IDP provider failed", "provider", p.Name(), "operation", operation, "error", err)
continue
}
if idx > 0 {
slog.Info("IDP fallback succeeded", "operation", operation, "provider", p.Name())
}
return nil
}
if len(errs) == 0 {
return fmt.Errorf("no IDP providers available for %s", operation)
}
return fmt.Errorf("all IDP providers failed for %s: %w", operation, errors.Join(errs...))
}

View File

@@ -0,0 +1,115 @@
package idp
import (
"baron-sso-backend/internal/domain"
"errors"
"net/http"
"reflect"
"strings"
"testing"
)
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
}
func (s *stubProvider) Name() string { return s.name }
func (s *stubProvider) GetMetadata() (*domain.IDPMetadata, error) {
return &domain.IDPMetadata{SupportedFields: s.metadata}, nil
}
func (s *stubProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
s.createCalls++
if s.createErr != nil {
return "", s.createErr
}
return "created-id", nil
}
func (s *stubProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
s.signInCalls++
if s.signInErr != nil {
return nil, s.signInErr
}
return &domain.AuthInfo{Subject: "subject-123"}, nil
}
func (s *stubProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
s.initiateCalls++
return s.initiateErr
}
func (s *stubProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
s.verifyCalls++
if s.verifyErr != nil {
return nil, s.verifyErr
}
if s.verifyResponse != nil {
return s.verifyResponse, nil
}
return &domain.AuthInfo{}, nil
}
func (s *stubProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
s.updateCalls++
return s.updateErr
}
func TestChainedProviderMetadataUnion(t *testing.T) {
p1 := &stubProvider{name: "primary", metadata: []string{"id", "email"}}
p2 := &stubProvider{name: "backup", metadata: []string{"email", "phone_number", "grade"}}
chain := newChainedProvider([]domain.IdentityProvider{p1, p2})
meta, err := chain.GetMetadata()
if err != nil {
t.Fatalf("GetMetadata returned error: %v", err)
}
expected := []string{"id", "email", "phone_number", "grade"}
if !reflect.DeepEqual(meta.SupportedFields, expected) {
t.Fatalf("metadata mismatch: got %v, want %v", meta.SupportedFields, expected)
}
}
func TestChainedProviderUpdateUserPasswordFallback(t *testing.T) {
p1 := &stubProvider{name: "primary", metadata: []string{"id"}, updateErr: errors.New("boom")}
p2 := &stubProvider{name: "backup", metadata: []string{"id"}}
chain := newChainedProvider([]domain.IdentityProvider{p1, p2})
if err := chain.UpdateUserPassword("user@example.com", "Sup3r!Pass123", nil); err != nil {
t.Fatalf("expected fallback to succeed, got error: %v", err)
}
if p1.updateCalls != 1 || p2.updateCalls != 1 {
t.Fatalf("unexpected call counts: p1=%d p2=%d", p1.updateCalls, p2.updateCalls)
}
}
func TestChainedProviderUpdateUserPasswordAllFail(t *testing.T) {
p1 := &stubProvider{name: "primary", metadata: []string{"id"}, updateErr: errors.New("fail1")}
p2 := &stubProvider{name: "backup", metadata: []string{"id"}, updateErr: errors.New("fail2")}
chain := newChainedProvider([]domain.IdentityProvider{p1, p2})
err := chain.UpdateUserPassword("user@example.com", "Sup3r!Pass123", nil)
if err == nil {
t.Fatalf("expected error when all providers fail")
}
if !strings.Contains(err.Error(), "all IDP providers failed") {
t.Fatalf("unexpected error: %v", err)
}
if p1.updateCalls != 1 || p2.updateCalls != 1 {
t.Fatalf("unexpected call counts: p1=%d p2=%d", p1.updateCalls, p2.updateCalls)
}
}

View File

@@ -7,6 +7,7 @@ import (
"log/slog"
"net/http"
"os"
"strings"
"time"
"github.com/descope/go-sdk/descope"
@@ -69,6 +70,81 @@ func (d *DescopeProvider) GetMetadata() (*domain.IDPMetadata, error) {
}, nil
}
// CreateUser는 Descope Management API를 사용해 사용자를 생성합니다.
func (d *DescopeProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
if d.Client == nil {
return "", fmt.Errorf("descope provider: client is nil")
}
if user == nil {
return "", fmt.Errorf("descope provider: user payload is nil")
}
if user.Email == "" || password == "" {
return "", fmt.Errorf("descope provider: email and password are required")
}
normalizedPhone := user.PhoneNumber
normalizedPhone = strings.ReplaceAll(normalizedPhone, "-", "")
normalizedPhone = strings.ReplaceAll(normalizedPhone, " ", "")
if strings.HasPrefix(normalizedPhone, "010") {
normalizedPhone = "+82" + normalizedPhone[1:]
} else if strings.HasPrefix(normalizedPhone, "82") {
normalizedPhone = "+" + normalizedPhone
}
// 존재 여부 확인
exists, _ := d.Client.Management.User().Load(context.Background(), user.Email)
if exists != nil {
return "", fmt.Errorf("descope provider: user already exists")
}
descopeUser := &descope.UserRequest{}
descopeUser.Email = user.Email
descopeUser.Phone = normalizedPhone
descopeUser.Name = user.Name
descopeUser.CustomAttributes = map[string]any{}
for k, v := range user.Attributes {
descopeUser.CustomAttributes[k] = v
}
descopeUser.CustomAttributes["createdAt"] = time.Now().Format(time.RFC3339)
if _, err := d.Client.Management.User().Create(context.Background(), user.Email, descopeUser); err != nil {
return "", fmt.Errorf("descope provider: create user failed: %w", err)
}
if err := d.Client.Management.User().SetPassword(context.Background(), user.Email, password); err != nil {
_ = d.Client.Management.User().Delete(context.Background(), user.Email)
return "", fmt.Errorf("descope provider: set password failed: %w", err)
}
slog.Info("Descope user created", "email", user.Email)
return user.Email, nil
}
// SignIn은 Descope Password 로그인 후 세션 토큰을 반환합니다.
func (d *DescopeProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
if d.Client == nil {
return nil, fmt.Errorf("descope provider: client is nil")
}
authInfo, err := d.Client.Auth.Password().SignIn(context.Background(), loginID, password, nil)
if err != nil {
return nil, 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) InitiatePasswordReset(loginID, redirectUrl string) error {
ctx := context.Background()
err := d.Client.Auth.Password().SendPasswordReset(ctx, loginID, redirectUrl, nil)

View File

@@ -0,0 +1,331 @@
package service
import (
"baron-sso-backend/internal/domain"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"time"
)
// OryProvider는 Kratos/Hydra를 기반으로 하는 IDP 어댑터의 최소 스켈레톤입니다.
// 지금은 스키마 메타데이터만 반환하며, 나머지 동작은 후속 작업에서 구현합니다.
type OryProvider struct {
KratosAdminURL string
KratosPublicURL string
HydraAdminURL string
HTTPClient *http.Client
}
func NewOryProvider() *OryProvider {
return &OryProvider{
KratosAdminURL: getenv("KRATOS_ADMIN_URL", "http://kratos:4434"),
KratosPublicURL: getenv("KRATOS_PUBLIC_URL", "http://kratos:4433"),
HydraAdminURL: getenv("HYDRA_ADMIN_URL", "http://hydra:4445"),
}
}
func (o *OryProvider) Name() string {
return "Ory (Kratos/Hydra)"
}
// GetMetadata는 BrokerUser가 요구하는 필드를 Kratos traits에 매핑 가능하다는 가정으로 반환합니다.
func (o *OryProvider) GetMetadata() (*domain.IDPMetadata, error) {
return &domain.IDPMetadata{
SupportedFields: []string{
"id", "email", "name", "phone_number",
"grade", "department", "affiliationType", "companyCode",
},
}, nil
}
// CreateUser는 Kratos Admin API를 통해 identity를 생성합니다.
func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
if user == nil {
return "", fmt.Errorf("ory provider: user payload is nil")
}
if user.Email == "" || password == "" {
return "", fmt.Errorf("ory provider: email and password are required")
}
// 중복 확인
existingID, err := o.findIdentityID(user.Email)
if err != nil {
return "", fmt.Errorf("ory provider: search identity failed: %w", err)
}
if existingID != "" {
return "", fmt.Errorf("ory provider: identity already exists for email=%s", user.Email)
}
traits := map[string]interface{}{
"email": user.Email,
"name": user.Name,
"phone_number": user.PhoneNumber,
}
for k, v := range user.Attributes {
traits[k] = v
}
payload := map[string]interface{}{
"schema_id": "default",
"traits": traits,
"credentials": map[string]interface{}{
"password": map[string]interface{}{
"config": map[string]string{
"password": password,
},
},
},
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, fmt.Sprintf("%s/admin/identities", o.KratosAdminURL), bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("ory provider: build create request failed: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := o.httpClient().Do(req)
if err != nil {
return "", fmt.Errorf("ory provider: create identity request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return "", fmt.Errorf("ory provider: create identity failed status=%d body=%s", resp.StatusCode, string(respBody))
}
var created struct {
ID string `json:"id"`
}
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
return "", fmt.Errorf("ory provider: decode create identity response failed: %w", err)
}
slog.Info("Ory identity created", "identity_id", created.ID, "email", user.Email)
return created.ID, nil
}
// SignIn은 Kratos Public API의 login API 플로우를 사용해 세션 토큰을 발급합니다.
func (o *OryProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
if loginID == "" || password == "" {
return nil, fmt.Errorf("ory provider: loginID and password are required")
}
flowID, err := o.startLoginFlow()
if err != nil {
return nil, err
}
body, _ := json.Marshal(map[string]string{
"identifier": loginID,
"password": password,
"method": "password",
})
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 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 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 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 response failed: %w", err)
}
if result.SessionToken == "" {
return nil, fmt.Errorf("ory provider: empty session token returned")
}
slog.Info("Ory login 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
}
// 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)
return nil
}
// VerifyPasswordResetToken는 내부 토큰 검증 흐름을 사용하므로 아직 구현하지 않습니다.
func (o *OryProvider) VerifyPasswordResetToken(token string) (*domain.AuthInfo, error) {
return nil, fmt.Errorf("ory provider: VerifyPasswordResetToken not implemented (internal token flow expected)")
}
// UpdateUserPassword: Kratos Admin API를 통해 비밀번호를 갱신합니다.
func (o *OryProvider) UpdateUserPassword(loginID, newPassword string, r *http.Request) error {
if loginID == "" || newPassword == "" {
return fmt.Errorf("ory provider: loginID or new password missing")
}
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)
}
payload := map[string]interface{}{
"credentials": map[string]interface{}{
"password": map[string]interface{}{
"config": map[string]string{
"password": newPassword,
},
},
},
}
body, _ := json.Marshal(payload)
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 request failed: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := o.httpClient().Do(req)
if err != nil {
return fmt.Errorf("ory provider: request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("ory provider: password update failed status=%d body=%s", resp.StatusCode, string(respBody))
}
slog.Info("Ory password updated via Kratos admin", "identity_id", identityID, "loginID", loginID)
return nil
}
func getenv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// findIdentityID: Kratos Admin API에서 credentials_identifier로 검색 후 첫 번째 identity id 반환
func (o *OryProvider) findIdentityID(loginID string) (string, error) {
u, err := url.Parse(fmt.Sprintf("%s/admin/identities", o.KratosAdminURL))
if err != nil {
return "", err
}
query := u.Query()
query.Set("credentials_identifier", loginID)
u.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u.String(), nil)
if err != nil {
return "", err
}
resp, err := o.httpClient().Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return "", nil
}
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return "", fmt.Errorf("kratos admin search failed status=%d body=%s", resp.StatusCode, string(body))
}
var identities []struct {
ID string `json:"id"`
}
if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil {
return "", fmt.Errorf("decode response failed: %w", err)
}
if len(identities) == 0 {
return "", nil
}
return identities[0].ID, nil
}
func (o *OryProvider) httpClient() *http.Client {
if o.HTTPClient != nil {
return o.HTTPClient
}
return &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
},
}
}
// 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)
if err != nil {
return "", fmt.Errorf("ory provider: build login flow request failed: %w", err)
}
resp, err := o.httpClient().Do(req)
if err != nil {
return "", fmt.Errorf("ory provider: login flow request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return "", fmt.Errorf("ory provider: login flow failed status=%d body=%s", resp.StatusCode, string(body))
}
var result struct {
ID string `json:"id"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("ory provider: decode login flow failed: %w", err)
}
if result.ID == "" {
return "", fmt.Errorf("ory provider: empty login flow id")
}
return result.ID, nil
}

View File

@@ -0,0 +1,149 @@
package service
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// clientForHandler returns an http.Client that routes requests to the given handler
// without real network sockets.
func clientForHandler(h http.Handler) *http.Client {
return &http.Client{
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
// Clone request body for handler
var bodyBytes []byte
if req.Body != nil {
bodyBytes, _ = io.ReadAll(req.Body)
}
r := httptest.NewRequest(req.Method, req.URL.String(), bytes.NewReader(bodyBytes))
r.Header = req.Header.Clone()
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w.Result(), nil
}),
}
}
type roundTripperFunc func(req *http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
func TestUpdateUserPassword_Success(t *testing.T) {
const (
loginID = "user@example.com"
identityID = "7f0dc8c3-9d5d-4f57-b3d1-123456789abc"
newPassword = "Sup3rStr0ng!Pass#2026"
)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
q := r.URL.Query()
if got := q.Get("credentials_identifier"); got != loginID {
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, got)
}
_ = json.NewEncoder(w).Encode([]map[string]string{
{"id": identityID},
})
return
case r.URL.Path == "/admin/identities/"+identityID && r.Method == http.MethodPatch:
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), newPassword) {
t.Fatalf("payload missing new password, body=%s", string(body))
}
w.WriteHeader(http.StatusOK)
return
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
})
provider := &OryProvider{
KratosAdminURL: "http://kratos-admin.local",
HTTPClient: clientForHandler(handler),
}
if err := provider.UpdateUserPassword(loginID, newPassword, nil); err != nil {
t.Fatalf("UpdateUserPassword returned error: %v", err)
}
}
func TestUpdateUserPassword_NotFound(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet {
http.NotFound(w, r)
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
})
provider := &OryProvider{
KratosAdminURL: "http://kratos-admin.local",
HTTPClient: clientForHandler(handler),
}
err := provider.UpdateUserPassword("user@example.com", "Sup3rStr0ng!Pass#2026", nil)
if err == nil || !strings.Contains(err.Error(), "identity not found") {
t.Fatalf("expected identity not found error, got: %v", err)
}
}
func TestUpdateUserPassword_ServerError(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/admin/identities") && r.Method == http.MethodGet:
_ = json.NewEncoder(w).Encode([]map[string]string{
{"id": "abc"},
})
return
case r.URL.Path == "/admin/identities/abc" && r.Method == http.MethodPatch:
http.Error(w, "boom", http.StatusInternalServerError)
return
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
}
})
provider := &OryProvider{
KratosAdminURL: "http://kratos-admin.local",
HTTPClient: clientForHandler(handler),
}
err := provider.UpdateUserPassword("user@example.com", "Sup3rStr0ng!Pass#2026", nil)
if err == nil || !strings.Contains(err.Error(), "password update failed") {
t.Fatalf("expected server error, got: %v", err)
}
}
func TestFindIdentityID_QueryEncoding(t *testing.T) {
loginID := "user+alias@example.com"
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
values, _ := url.ParseQuery(r.URL.RawQuery)
if values.Get("credentials_identifier") != loginID {
t.Fatalf("expected credentials_identifier=%s, got=%s", loginID, values.Get("credentials_identifier"))
}
_ = json.NewEncoder(w).Encode([]map[string]string{
{"id": "id-123"},
})
})
provider := &OryProvider{
KratosAdminURL: "http://kratos-admin.local",
HTTPClient: clientForHandler(handler),
}
id, err := provider.findIdentityID(loginID)
if err != nil {
t.Fatalf("findIdentityID returned error: %v", err)
}
if id != "id-123" {
t.Fatalf("expected id-123, got %s", id)
}
}

View File

@@ -21,6 +21,14 @@ func (m *MockProvider) GetMetadata() (*domain.IDPMetadata, error) {
}, nil
}
func (m *MockProvider) CreateUser(user *domain.BrokerUser, password string) (string, error) {
return "", nil
}
func (m *MockProvider) SignIn(loginID, password string) (*domain.AuthInfo, error) {
return &domain.AuthInfo{}, nil
}
// Stub implementations to satisfy the IdentityProvider interface for this unit test.
func (m *MockProvider) InitiatePasswordReset(loginID, redirectUrl string) error {
return nil

BIN
backend/server Executable file

Binary file not shown.