forked from baron/baron-sso
내정보 페이지 사용성개선, adminFront user 정보 연동.
This commit is contained in:
@@ -99,6 +99,9 @@ func main() {
|
||||
}
|
||||
slog.Info("✅ IDP Schema Validation Passed", "idp", idpProvider.Name())
|
||||
// -----------------------------------
|
||||
if err := bootstrap.SeedAdminIdentity(idpProvider); err != nil {
|
||||
slog.Error("❌ Admin identity seed failed", "error", err)
|
||||
}
|
||||
|
||||
// 2. Initialize DB Connections
|
||||
// ClickHouse
|
||||
@@ -186,7 +189,9 @@ func main() {
|
||||
adminHandler := handler.NewAdminHandler()
|
||||
devHandler := handler.NewDevHandler()
|
||||
tenantHandler := handler.NewTenantHandler(db)
|
||||
userHandler := handler.NewUserHandler(db)
|
||||
kratosAdminService := service.NewKratosAdminService()
|
||||
oryAdminProvider := service.NewOryProvider()
|
||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider)
|
||||
apiKeyHandler := handler.NewApiKeyHandler(db)
|
||||
|
||||
// 3. Initialize Fiber
|
||||
@@ -398,10 +403,10 @@ func main() {
|
||||
|
||||
// API Group
|
||||
api := app.Group("/api/v1")
|
||||
|
||||
|
||||
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{}{
|
||||
@@ -451,6 +456,7 @@ func main() {
|
||||
user.Put("/me", authHandler.UpdateMe)
|
||||
user.Post("/me/send-code", authHandler.SendUpdateCode)
|
||||
user.Post("/me/verify-code", authHandler.VerifyUpdateCode)
|
||||
user.Get("/rp/linked", authHandler.ListLinkedRps)
|
||||
|
||||
// Admin Routes
|
||||
admin := api.Group("/admin")
|
||||
|
||||
@@ -2,12 +2,9 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -20,11 +17,7 @@ func Run(db *gorm.DB) error {
|
||||
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] User seed skipped (Kratos is SoT)")
|
||||
slog.Info("[Bootstrap] Bootstrap completed successfully.")
|
||||
return nil
|
||||
}
|
||||
@@ -33,7 +26,6 @@ func migrateSchemas(db *gorm.DB) error {
|
||||
slog.Info("[Bootstrap] Migrating database schemas...")
|
||||
// Add all domain models here
|
||||
return db.AutoMigrate(
|
||||
&domain.User{},
|
||||
&domain.Tenant{},
|
||||
&domain.ApiKey{},
|
||||
&domain.IdentityProviderConfig{},
|
||||
@@ -41,44 +33,3 @@ func migrateSchemas(db *gorm.DB) error {
|
||||
// &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.Unscoped().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
|
||||
}
|
||||
|
||||
51
backend/internal/bootstrap/kratos_seed.go
Normal file
51
backend/internal/bootstrap/kratos_seed.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SeedAdminIdentity creates the initial admin identity in the configured IDP.
|
||||
func SeedAdminIdentity(idp domain.IdentityProvider) error {
|
||||
if idp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL"))
|
||||
adminPassword := os.Getenv("ADMIN_PASSWORD")
|
||||
if adminEmail == "" || adminPassword == "" {
|
||||
slog.Warn("[Bootstrap] ADMIN_EMAIL or ADMIN_PASSWORD not set. Skipping admin identity seed.")
|
||||
return nil
|
||||
}
|
||||
|
||||
adminName := strings.TrimSpace(os.Getenv("ADMIN_NAME"))
|
||||
if adminName == "" {
|
||||
adminName = "System Admin"
|
||||
}
|
||||
|
||||
user := &domain.BrokerUser{
|
||||
Email: adminEmail,
|
||||
Name: adminName,
|
||||
PhoneNumber: "",
|
||||
Attributes: map[string]interface{}{
|
||||
"department": "Admin",
|
||||
"affiliationType": "internal",
|
||||
"companyCode": "",
|
||||
"grade": "admin",
|
||||
},
|
||||
}
|
||||
|
||||
_, err := idp.CreateUser(user, adminPassword)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "already exists") {
|
||||
slog.Info("[Bootstrap] Admin identity already exists in IDP", "email", adminEmail)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Info("[Bootstrap] Admin identity created in IDP", "email", adminEmail, "idp", idp.Name())
|
||||
return nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/logger"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/utils"
|
||||
"bytes"
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
@@ -18,7 +19,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -75,6 +76,7 @@ type AuthHandler struct {
|
||||
DescopeClient *client.DescopeClient
|
||||
IdpProvider domain.IdentityProvider
|
||||
AuditRepo domain.AuditRepository
|
||||
Hydra *service.HydraAdminService
|
||||
}
|
||||
|
||||
type signupState struct {
|
||||
@@ -156,6 +158,7 @@ func NewAuthHandler(redisService *service.RedisService, idpProvider domain.Ident
|
||||
DescopeClient: descopeClient,
|
||||
IdpProvider: idpProvider,
|
||||
AuditRepo: auditRepo,
|
||||
Hydra: service.NewHydraAdminService(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,49 +500,7 @@ func (h *AuthHandler) resolvePasswordPolicy() *domain.PasswordPolicy {
|
||||
|
||||
// validatePasswordWithPolicy는 정책 기준으로 비밀번호를 검증합니다.
|
||||
func validatePasswordWithPolicy(policy *domain.PasswordPolicy, password string) error {
|
||||
if policy == nil {
|
||||
return nil
|
||||
}
|
||||
if policy.MinLength > 0 && len(password) < policy.MinLength {
|
||||
return fmt.Errorf("비밀번호는 최소 %d자 이상이어야 합니다", policy.MinLength)
|
||||
}
|
||||
|
||||
types := 0
|
||||
hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
|
||||
hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
|
||||
hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
|
||||
hasSymbol := regexp.MustCompile(`[\W_]`).MatchString(password)
|
||||
if hasLower {
|
||||
types++
|
||||
}
|
||||
if hasUpper {
|
||||
types++
|
||||
}
|
||||
if hasNumber {
|
||||
types++
|
||||
}
|
||||
if hasSymbol {
|
||||
types++
|
||||
}
|
||||
|
||||
if policy.MinCharacterTypes > 0 && types < policy.MinCharacterTypes {
|
||||
return fmt.Errorf("비밀번호는 영문 대/소문자/숫자/특수문자 중 %d가지 이상을 포함해야 합니다", policy.MinCharacterTypes)
|
||||
}
|
||||
|
||||
if policy.Lowercase && !hasLower {
|
||||
return fmt.Errorf("비밀번호에 소문자가 포함되어야 합니다")
|
||||
}
|
||||
if policy.Uppercase && !hasUpper {
|
||||
return fmt.Errorf("비밀번호에 대문자가 포함되어야 합니다")
|
||||
}
|
||||
if policy.Number && !hasNumber {
|
||||
return fmt.Errorf("비밀번호에 숫자가 포함되어야 합니다")
|
||||
}
|
||||
if policy.NonAlphanumeric && !hasSymbol {
|
||||
return fmt.Errorf("비밀번호에 특수문자가 포함되어야 합니다")
|
||||
}
|
||||
|
||||
return nil
|
||||
return utils.ValidatePasswordWithPolicy(policy, password)
|
||||
}
|
||||
|
||||
// GetPasswordPolicy는 IDP 기준 비밀번호 정책을 제공합니다.
|
||||
@@ -2345,6 +2306,109 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
type linkedRpSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Logo string `json:"logo,omitempty"`
|
||||
LastAuthenticatedAt string `json:"lastAuthenticatedAt,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
}
|
||||
|
||||
type linkedRpListResponse struct {
|
||||
Items []linkedRpSummary `json:"items"`
|
||||
}
|
||||
|
||||
type linkedRpRecord struct {
|
||||
linkedRpSummary
|
||||
lastAuth time.Time
|
||||
}
|
||||
|
||||
func (h *AuthHandler) ListLinkedRps(c *fiber.Ctx) error {
|
||||
if h.Hydra == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "hydra admin unavailable"})
|
||||
}
|
||||
|
||||
subject, err := h.resolveConsentSubject(c)
|
||||
if err != nil || subject == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
||||
}
|
||||
|
||||
sessions, err := h.Hydra.ListConsentSessions(c.Context(), subject, "")
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
records := make(map[string]*linkedRpRecord)
|
||||
for _, session := range sessions {
|
||||
clientID := strings.TrimSpace(session.Client.ClientID)
|
||||
if clientID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(session.Client.ClientName)
|
||||
if name == "" {
|
||||
name = clientID
|
||||
}
|
||||
|
||||
lastAuth := time.Time{}
|
||||
if session.AuthenticatedAt != nil {
|
||||
lastAuth = *session.AuthenticatedAt
|
||||
} else if session.RequestedAt != nil {
|
||||
lastAuth = *session.RequestedAt
|
||||
}
|
||||
|
||||
scopes := session.GrantedScope
|
||||
if len(scopes) == 0 && strings.TrimSpace(session.Client.Scope) != "" {
|
||||
scopes = strings.Fields(session.Client.Scope)
|
||||
}
|
||||
|
||||
existing := records[clientID]
|
||||
if existing == nil {
|
||||
records[clientID] = &linkedRpRecord{
|
||||
linkedRpSummary: linkedRpSummary{
|
||||
ID: clientID,
|
||||
Name: name,
|
||||
Logo: extractHydraClientLogo(session.Client.Metadata),
|
||||
Status: hydraClientStatus(session.Client.Metadata),
|
||||
Scopes: scopes,
|
||||
},
|
||||
lastAuth: lastAuth,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if existing.Name == "" {
|
||||
existing.Name = name
|
||||
}
|
||||
if existing.Logo == "" {
|
||||
existing.Logo = extractHydraClientLogo(session.Client.Metadata)
|
||||
}
|
||||
existing.Scopes = mergeScopes(existing.Scopes, scopes)
|
||||
if lastAuth.After(existing.lastAuth) {
|
||||
existing.lastAuth = lastAuth
|
||||
}
|
||||
}
|
||||
|
||||
ordered := make([]*linkedRpRecord, 0, len(records))
|
||||
for _, record := range records {
|
||||
ordered = append(ordered, record)
|
||||
}
|
||||
sort.Slice(ordered, func(i, j int) bool {
|
||||
return ordered[i].lastAuth.After(ordered[j].lastAuth)
|
||||
})
|
||||
|
||||
items := make([]linkedRpSummary, 0, len(ordered))
|
||||
for _, record := range ordered {
|
||||
if !record.lastAuth.IsZero() {
|
||||
record.LastAuthenticatedAt = record.lastAuth.Format(time.RFC3339)
|
||||
}
|
||||
items = append(items, record.linkedRpSummary)
|
||||
}
|
||||
|
||||
return c.JSON(linkedRpListResponse{Items: items})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
|
||||
token := h.getBearerToken(c)
|
||||
if token != "" {
|
||||
@@ -2384,6 +2448,19 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
|
||||
return h.getKratosProfileWithCookie(cookie)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) resolveConsentSubject(c *fiber.Ctx) (string, error) {
|
||||
token := h.getBearerToken(c)
|
||||
if token != "" {
|
||||
return h.resolveIdentityID(c, token)
|
||||
}
|
||||
cookie := c.Get("Cookie")
|
||||
if cookie == "" {
|
||||
return "", fmt.Errorf("missing authorization token")
|
||||
}
|
||||
identityID, _, err := h.getKratosIdentityWithCookie(cookie)
|
||||
return identityID, err
|
||||
}
|
||||
|
||||
func isAuthEventType(eventType string) bool {
|
||||
normalized := strings.ToLower(eventType)
|
||||
return strings.Contains(normalized, " /api/v1/auth/")
|
||||
@@ -3345,3 +3422,68 @@ func (h *AuthHandler) VerifyUpdateCode(c *fiber.Ctx) error {
|
||||
|
||||
return c.JSON(fiber.Map{"success": true})
|
||||
}
|
||||
|
||||
func hydraClientStatus(metadata map[string]interface{}) string {
|
||||
if metadata == nil {
|
||||
return "active"
|
||||
}
|
||||
if value, ok := metadata["status"].(string); ok {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
if normalized != "" {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
return "active"
|
||||
}
|
||||
|
||||
func extractHydraClientLogo(metadata map[string]interface{}) string {
|
||||
if metadata == nil {
|
||||
return ""
|
||||
}
|
||||
candidates := []string{
|
||||
"logo",
|
||||
"logo_url",
|
||||
"logoUrl",
|
||||
"logo_uri",
|
||||
"logoUri",
|
||||
"app_logo",
|
||||
"appLogo",
|
||||
}
|
||||
for _, key := range candidates {
|
||||
if value, ok := metadata[key]; ok {
|
||||
if logo, ok := value.(string); ok {
|
||||
logo = strings.TrimSpace(logo)
|
||||
if logo != "" {
|
||||
return logo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mergeScopes(current []string, next []string) []string {
|
||||
if len(next) == 0 {
|
||||
return current
|
||||
}
|
||||
seen := make(map[string]struct{}, len(current)+len(next))
|
||||
for _, scope := range current {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" {
|
||||
continue
|
||||
}
|
||||
seen[scope] = struct{}{}
|
||||
}
|
||||
for _, scope := range next {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[scope]; ok {
|
||||
continue
|
||||
}
|
||||
seen[scope] = struct{}{}
|
||||
current = append(current, scope)
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
@@ -2,21 +2,24 @@ package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"errors"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/utils"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
DB *gorm.DB
|
||||
KratosAdmin *service.KratosAdminService
|
||||
OryProvider *service.OryProvider
|
||||
}
|
||||
|
||||
func NewUserHandler(db *gorm.DB) *UserHandler {
|
||||
return &UserHandler{DB: db}
|
||||
func NewUserHandler(kratosAdmin *service.KratosAdminService, oryProvider *service.OryProvider) *UserHandler {
|
||||
return &UserHandler{
|
||||
KratosAdmin: kratosAdmin,
|
||||
OryProvider: oryProvider,
|
||||
}
|
||||
}
|
||||
|
||||
type userSummary struct {
|
||||
@@ -30,6 +33,7 @@ type userSummary struct {
|
||||
Department string `json:"department"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
InitialPassword string `json:"initialPassword,omitempty"`
|
||||
}
|
||||
|
||||
type userListResponse struct {
|
||||
@@ -40,8 +44,8 @@ type userListResponse struct {
|
||||
}
|
||||
|
||||
func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
|
||||
if h.KratosAdmin == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"})
|
||||
}
|
||||
|
||||
limit := c.QueryInt("limit", 50)
|
||||
@@ -55,33 +59,45 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
query := h.DB.Model(&domain.User{})
|
||||
if search != "" {
|
||||
like := "%" + search + "%"
|
||||
query = query.Where("email ILIKE ? OR name ILIKE ?", like, like)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
identities, err := h.KratosAdmin.ListIdentities(c.Context())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
var users []domain.User
|
||||
if err := query.Order("created_at desc").Limit(limit).Offset(offset).Find(&users).Error; err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
filtered := make([]service.KratosIdentity, 0, len(identities))
|
||||
if search == "" {
|
||||
filtered = identities
|
||||
} else {
|
||||
searchLower := strings.ToLower(search)
|
||||
for _, identity := range identities {
|
||||
email := strings.ToLower(extractTraitString(identity.Traits, "email"))
|
||||
name := strings.ToLower(extractTraitString(identity.Traits, "name"))
|
||||
if strings.Contains(email, searchLower) || strings.Contains(name, searchLower) {
|
||||
filtered = append(filtered, identity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items := make([]userSummary, 0, len(users))
|
||||
for _, u := range users {
|
||||
items = append(items, mapUserSummary(u))
|
||||
total := int64(len(filtered))
|
||||
if offset > len(filtered) {
|
||||
offset = len(filtered)
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(filtered) {
|
||||
end = len(filtered)
|
||||
}
|
||||
|
||||
items := make([]userSummary, 0, end-offset)
|
||||
for _, identity := range filtered[offset:end] {
|
||||
items = append(items, mapIdentitySummary(identity))
|
||||
}
|
||||
|
||||
return c.JSON(userListResponse{Items: items, Limit: limit, Offset: offset, Total: total})
|
||||
}
|
||||
|
||||
func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
|
||||
if h.KratosAdmin == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"})
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(c.Params("id"))
|
||||
@@ -89,20 +105,20 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"})
|
||||
}
|
||||
|
||||
var user domain.User
|
||||
if err := h.DB.First(&user, "id = ?", userID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
||||
}
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if identity == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
||||
}
|
||||
|
||||
return c.JSON(mapUserSummary(user))
|
||||
return c.JSON(mapIdentitySummary(*identity))
|
||||
}
|
||||
|
||||
func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
|
||||
if h.OryProvider == nil || h.KratosAdmin == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"})
|
||||
}
|
||||
|
||||
var req struct {
|
||||
@@ -122,52 +138,83 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
if email == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "email is required"})
|
||||
}
|
||||
password := req.Password
|
||||
if password == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "password is required"})
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"})
|
||||
}
|
||||
|
||||
// Check duplicates
|
||||
var exists domain.User
|
||||
if err := h.DB.Where("email = ?", email).First(&exists).Error; err == nil {
|
||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "email already exists"})
|
||||
password := strings.TrimSpace(req.Password)
|
||||
policy, err := h.OryProvider.GetPasswordPolicy()
|
||||
if err != nil || policy == nil {
|
||||
policy = &domain.PasswordPolicy{
|
||||
MinLength: 12,
|
||||
Lowercase: true,
|
||||
Uppercase: false,
|
||||
Number: true,
|
||||
NonAlphanumeric: true,
|
||||
MinCharacterTypes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
generatedPassword := ""
|
||||
if password == "" {
|
||||
generated, genErr := utils.GeneratePasswordWithPolicy(policy)
|
||||
if genErr != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to generate password"})
|
||||
}
|
||||
password = generated
|
||||
generatedPassword = generated
|
||||
} else {
|
||||
if err := utils.ValidatePasswordWithPolicy(policy, password); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
role := strings.TrimSpace(req.Role)
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
|
||||
attributes := map[string]interface{}{
|
||||
"department": req.Department,
|
||||
"affiliationType": "internal",
|
||||
"companyCode": req.CompanyCode,
|
||||
"grade": role,
|
||||
}
|
||||
|
||||
brokerUser := &domain.BrokerUser{
|
||||
Email: email,
|
||||
Name: name,
|
||||
PhoneNumber: normalizePhoneNumber(req.Phone),
|
||||
Attributes: attributes,
|
||||
}
|
||||
|
||||
identityID, err := h.OryProvider.CreateUser(brokerUser, password)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to hash password"})
|
||||
}
|
||||
|
||||
user := domain.User{
|
||||
Email: email,
|
||||
PasswordHash: string(hashedPassword),
|
||||
Name: name,
|
||||
Phone: req.Phone,
|
||||
Role: req.Role, // default "user" handled by GORM if empty, but struct default usually works on zero value? GORM default tag works for zero value.
|
||||
CompanyCode: req.CompanyCode,
|
||||
Department: req.Department,
|
||||
Status: "active",
|
||||
AffiliationType: "internal", // Defaulting for now
|
||||
}
|
||||
|
||||
if user.Role == "" {
|
||||
user.Role = "user"
|
||||
}
|
||||
|
||||
if err := h.DB.Create(&user).Error; err != nil {
|
||||
if strings.Contains(err.Error(), "already exists") {
|
||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "email already exists"})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(mapUserSummary(user))
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if identity == nil {
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": identityID, "initialPassword": generatedPassword})
|
||||
}
|
||||
|
||||
response := mapIdentitySummary(*identity)
|
||||
if generatedPassword != "" {
|
||||
response.InitialPassword = generatedPassword
|
||||
}
|
||||
return c.Status(fiber.StatusCreated).JSON(response)
|
||||
}
|
||||
|
||||
func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
|
||||
if h.KratosAdmin == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"})
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(c.Params("id"))
|
||||
@@ -175,13 +222,13 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"})
|
||||
}
|
||||
|
||||
var user domain.User
|
||||
if err := h.DB.First(&user, "id = ?", userID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
||||
}
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if identity == nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Password *string `json:"password"`
|
||||
@@ -196,46 +243,48 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
|
||||
}
|
||||
|
||||
traits := identity.Traits
|
||||
if traits == nil {
|
||||
traits = map[string]interface{}{}
|
||||
}
|
||||
if req.Name != nil {
|
||||
user.Name = strings.TrimSpace(*req.Name)
|
||||
traits["name"] = strings.TrimSpace(*req.Name)
|
||||
}
|
||||
if req.Phone != nil {
|
||||
user.Phone = strings.TrimSpace(*req.Phone)
|
||||
}
|
||||
if req.Role != nil {
|
||||
user.Role = strings.TrimSpace(*req.Role)
|
||||
}
|
||||
if req.Status != nil {
|
||||
status := strings.ToLower(strings.TrimSpace(*req.Status))
|
||||
if status == "active" || status == "inactive" || status == "blocked" {
|
||||
user.Status = status
|
||||
}
|
||||
traits["phone_number"] = normalizePhoneNumber(strings.TrimSpace(*req.Phone))
|
||||
}
|
||||
if req.CompanyCode != nil {
|
||||
user.CompanyCode = strings.TrimSpace(*req.CompanyCode)
|
||||
traits["companyCode"] = strings.TrimSpace(*req.CompanyCode)
|
||||
}
|
||||
if req.Department != nil {
|
||||
user.Department = strings.TrimSpace(*req.Department)
|
||||
traits["department"] = strings.TrimSpace(*req.Department)
|
||||
}
|
||||
|
||||
if req.Password != nil && *req.Password != "" {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to hash password"})
|
||||
if req.Role != nil {
|
||||
role := strings.TrimSpace(*req.Role)
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
user.PasswordHash = string(hashedPassword)
|
||||
traits["grade"] = role
|
||||
}
|
||||
|
||||
if err := h.DB.Save(&user).Error; err != nil {
|
||||
state := normalizeKratosState(req.Status)
|
||||
updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), userID, traits, state)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(mapUserSummary(user))
|
||||
if req.Password != nil && *req.Password != "" {
|
||||
if err := h.KratosAdmin.UpdateIdentityPassword(c.Context(), userID, *req.Password); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(mapIdentitySummary(*updated))
|
||||
}
|
||||
|
||||
func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "database not available"})
|
||||
if h.KratosAdmin == nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"})
|
||||
}
|
||||
|
||||
userID := strings.TrimSpace(c.Params("id"))
|
||||
@@ -243,25 +292,88 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "user id is required"})
|
||||
}
|
||||
|
||||
// Soft delete
|
||||
if err := h.DB.Delete(&domain.User{}, "id = ?", userID).Error; err != nil {
|
||||
if err := h.KratosAdmin.DeleteIdentity(c.Context(), userID); err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
func mapUserSummary(u domain.User) userSummary {
|
||||
func mapIdentitySummary(identity service.KratosIdentity) userSummary {
|
||||
traits := identity.Traits
|
||||
role := extractTraitString(traits, "grade")
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
return userSummary{
|
||||
ID: u.ID,
|
||||
Email: u.Email,
|
||||
Name: u.Name,
|
||||
Phone: u.Phone,
|
||||
Role: u.Role,
|
||||
Status: u.Status,
|
||||
CompanyCode: u.CompanyCode,
|
||||
Department: u.Department,
|
||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
UpdatedAt: u.UpdatedAt.Format(time.RFC3339),
|
||||
ID: identity.ID,
|
||||
Email: extractTraitString(traits, "email"),
|
||||
Name: extractTraitString(traits, "name"),
|
||||
Phone: extractTraitString(traits, "phone_number"),
|
||||
Role: role,
|
||||
Status: normalizeStatus(identity.State),
|
||||
CompanyCode: extractTraitString(traits, "companyCode"),
|
||||
Department: extractTraitString(traits, "department"),
|
||||
CreatedAt: formatTime(identity.CreatedAt),
|
||||
UpdatedAt: formatTime(identity.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func extractTraitString(traits map[string]interface{}, key string) string {
|
||||
if traits == nil {
|
||||
return ""
|
||||
}
|
||||
if raw, ok := traits[key]; ok {
|
||||
if value, ok := raw.(string); ok {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func formatTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func normalizeStatus(state string) string {
|
||||
state = strings.ToLower(strings.TrimSpace(state))
|
||||
if state == "inactive" || state == "blocked" || state == "active" {
|
||||
return state
|
||||
}
|
||||
if state == "" {
|
||||
return "active"
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func normalizeKratosState(status *string) string {
|
||||
if status == nil {
|
||||
return ""
|
||||
}
|
||||
value := strings.ToLower(strings.TrimSpace(*status))
|
||||
if value == "blocked" {
|
||||
return "inactive"
|
||||
}
|
||||
if value == "active" || value == "inactive" {
|
||||
return value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizePhoneNumber(phone string) string {
|
||||
normalized := strings.ReplaceAll(phone, "-", "")
|
||||
normalized = strings.ReplaceAll(normalized, " ", "")
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(normalized, "010") {
|
||||
return "+82" + normalized[1:]
|
||||
}
|
||||
if strings.HasPrefix(normalized, "82") {
|
||||
return "+" + normalized
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
194
backend/internal/service/kratos_admin_service.go
Normal file
194
backend/internal/service/kratos_admin_service.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type KratosIdentity struct {
|
||||
ID string `json:"id"`
|
||||
Traits map[string]interface{} `json:"traits"`
|
||||
State string `json:"state,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type KratosAdminService struct {
|
||||
AdminURL string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func NewKratosAdminService() *KratosAdminService {
|
||||
return &KratosAdminService{
|
||||
AdminURL: getenvKratos("KRATOS_ADMIN_URL", "http://kratos:4434"),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *KratosAdminService) ListIdentities(ctx context.Context) ([]KratosIdentity, error) {
|
||||
endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return nil, fmt.Errorf("kratos admin list identities failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var identities []KratosIdentity
|
||||
if err := json.NewDecoder(resp.Body).Decode(&identities); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return identities, nil
|
||||
}
|
||||
|
||||
func (s *KratosAdminService) GetIdentity(ctx context.Context, identityID string) (*KratosIdentity, error) {
|
||||
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return nil, fmt.Errorf("kratos admin get identity failed status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var identity KratosIdentity
|
||||
if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &identity, nil
|
||||
}
|
||||
|
||||
func (s *KratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) {
|
||||
payload := map[string]interface{}{
|
||||
"schema_id": "default",
|
||||
"traits": traits,
|
||||
}
|
||||
if strings.TrimSpace(state) != "" {
|
||||
payload["state"] = strings.TrimSpace(state)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return nil, fmt.Errorf("kratos admin update identity failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var updated KratosIdentity
|
||||
if err := json.NewDecoder(resp.Body).Decode(&updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &updated, nil
|
||||
}
|
||||
|
||||
func (s *KratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
|
||||
payload := map[string]interface{}{
|
||||
"credentials": map[string]interface{}{
|
||||
"password": map[string]interface{}{
|
||||
"config": map[string]string{
|
||||
"password": newPassword,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return fmt.Errorf("kratos admin update password failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KratosAdminService) DeleteIdentity(ctx context.Context, identityID string) error {
|
||||
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return fmt.Errorf("kratos admin delete identity failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *KratosAdminService) httpClient() *http.Client {
|
||||
if s.HTTPClient != nil {
|
||||
return s.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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func getenvKratos(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
204
backend/internal/utils/password_policy.go
Normal file
204
backend/internal/utils/password_policy.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
lowercaseChars = "abcdefghijklmnopqrstuvwxyz"
|
||||
uppercaseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
numberChars = "0123456789"
|
||||
symbolChars = "!@#$%^&*()-_=+[]{}<>?/,.~"
|
||||
)
|
||||
|
||||
// ValidatePasswordWithPolicy validates a password against the given policy.
|
||||
func ValidatePasswordWithPolicy(policy *domain.PasswordPolicy, password string) error {
|
||||
if policy == nil {
|
||||
return nil
|
||||
}
|
||||
if len(password) < policy.MinLength {
|
||||
return fmt.Errorf("비밀번호는 최소 %d자 이상이어야 합니다", policy.MinLength)
|
||||
}
|
||||
|
||||
hasLower := false
|
||||
hasUpper := false
|
||||
hasNumber := false
|
||||
hasSymbol := false
|
||||
types := 0
|
||||
|
||||
for _, ch := range password {
|
||||
switch {
|
||||
case ch >= 'a' && ch <= 'z':
|
||||
hasLower = true
|
||||
case ch >= 'A' && ch <= 'Z':
|
||||
hasUpper = true
|
||||
case ch >= '0' && ch <= '9':
|
||||
hasNumber = true
|
||||
default:
|
||||
hasSymbol = true
|
||||
}
|
||||
}
|
||||
|
||||
if hasLower {
|
||||
types++
|
||||
}
|
||||
if hasUpper {
|
||||
types++
|
||||
}
|
||||
if hasNumber {
|
||||
types++
|
||||
}
|
||||
if hasSymbol {
|
||||
types++
|
||||
}
|
||||
|
||||
if policy.MinCharacterTypes > 0 && types < policy.MinCharacterTypes {
|
||||
return fmt.Errorf("비밀번호는 영문 대/소문자/숫자/특수문자 중 %d가지 이상을 포함해야 합니다", policy.MinCharacterTypes)
|
||||
}
|
||||
|
||||
if policy.Lowercase && !hasLower {
|
||||
return fmt.Errorf("비밀번호에 소문자가 포함되어야 합니다")
|
||||
}
|
||||
if policy.Uppercase && !hasUpper {
|
||||
return fmt.Errorf("비밀번호에 대문자가 포함되어야 합니다")
|
||||
}
|
||||
if policy.Number && !hasNumber {
|
||||
return fmt.Errorf("비밀번호에 숫자가 포함되어야 합니다")
|
||||
}
|
||||
if policy.NonAlphanumeric && !hasSymbol {
|
||||
return fmt.Errorf("비밀번호에 특수문자가 포함되어야 합니다")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GeneratePasswordWithPolicy creates a random password that satisfies the policy.
|
||||
func GeneratePasswordWithPolicy(policy *domain.PasswordPolicy) (string, error) {
|
||||
if policy == nil {
|
||||
policy = &domain.PasswordPolicy{}
|
||||
}
|
||||
|
||||
categories := []struct {
|
||||
name string
|
||||
required bool
|
||||
chars string
|
||||
}{
|
||||
{name: "lower", required: policy.Lowercase, chars: lowercaseChars},
|
||||
{name: "upper", required: policy.Uppercase, chars: uppercaseChars},
|
||||
{name: "number", required: policy.Number, chars: numberChars},
|
||||
{name: "symbol", required: policy.NonAlphanumeric, chars: symbolChars},
|
||||
}
|
||||
|
||||
selected := make([]string, 0, len(categories))
|
||||
required := make([]string, 0, len(categories))
|
||||
for _, cat := range categories {
|
||||
if cat.chars == "" {
|
||||
continue
|
||||
}
|
||||
if cat.required {
|
||||
required = append(required, cat.chars)
|
||||
}
|
||||
selected = append(selected, cat.chars)
|
||||
}
|
||||
|
||||
if len(selected) == 0 {
|
||||
selected = []string{lowercaseChars, uppercaseChars, numberChars, symbolChars}
|
||||
}
|
||||
|
||||
additionalTypes := policy.MinCharacterTypes - len(required)
|
||||
if additionalTypes > 0 {
|
||||
pool := make([]string, 0, len(selected))
|
||||
for _, cat := range selected {
|
||||
isRequired := false
|
||||
for _, req := range required {
|
||||
if req == cat {
|
||||
isRequired = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isRequired {
|
||||
pool = append(pool, cat)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < additionalTypes && len(pool) > 0; i++ {
|
||||
idx, err := randomIndex(len(pool))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
required = append(required, pool[idx])
|
||||
pool = append(pool[:idx], pool[idx+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
minLength := policy.MinLength
|
||||
if minLength <= 0 {
|
||||
minLength = 12
|
||||
}
|
||||
if minLength < len(required) {
|
||||
minLength = len(required)
|
||||
}
|
||||
|
||||
passwordRunes := make([]rune, 0, minLength)
|
||||
for _, charset := range required {
|
||||
ch, err := randomChar(charset)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
passwordRunes = append(passwordRunes, ch)
|
||||
}
|
||||
|
||||
combined := ""
|
||||
for _, charset := range selected {
|
||||
combined += charset
|
||||
}
|
||||
for len(passwordRunes) < minLength {
|
||||
ch, err := randomChar(combined)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
passwordRunes = append(passwordRunes, ch)
|
||||
}
|
||||
|
||||
if err := shuffleRunes(passwordRunes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(passwordRunes), nil
|
||||
}
|
||||
|
||||
func randomIndex(max int) (int, error) {
|
||||
if max <= 0 {
|
||||
return 0, fmt.Errorf("invalid max")
|
||||
}
|
||||
b := make([]byte, 1)
|
||||
for {
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if int(b[0]) < max*(256/max) {
|
||||
return int(b[0]) % max, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func randomChar(chars string) (rune, error) {
|
||||
idx, err := randomIndex(len(chars))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return rune(chars[idx]), nil
|
||||
}
|
||||
|
||||
func shuffleRunes(values []rune) error {
|
||||
for i := len(values) - 1; i > 0; i-- {
|
||||
j, err := randomIndex(i + 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
values[i], values[j] = values[j], values[i]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user