diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index c57a8608..5ac0d999 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -182,7 +182,7 @@ func main() {
// 2. Initialize Handlers
auditHandler := handler.NewAuditHandler(auditRepo)
- authHandler := handler.NewAuthHandler(redisService, idpProvider)
+ authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo)
adminHandler := handler.NewAdminHandler()
devHandler := handler.NewDevHandler()
tenantHandler := handler.NewTenantHandler(db)
@@ -414,6 +414,7 @@ func main() {
}))
api.Post("/audit", auditHandler.CreateLog)
api.Get("/audit", auditHandler.ListLogs)
+ api.Get("/audit/auth/timeline", authHandler.GetAuthTimeline)
// Auth Proxy Routes
auth := api.Group("/auth")
diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go
index 476cd069..b87fdf9b 100644
--- a/backend/internal/domain/auth_models.go
+++ b/backend/internal/domain/auth_models.go
@@ -5,6 +5,8 @@ type EnchantedLinkInitRequest struct {
URI string `json:"uri,omitempty"` // Redirect URI (optional for polling flow)
Method string `json:"method,omitempty"` // "email" or "sms"
CodeOnly bool `json:"codeOnly,omitempty"`
+ DryRun bool `json:"dryRun,omitempty"`
+ DrySend bool `json:"drySend,omitempty"`
}
type EnchantedLinkInitResponse struct {
@@ -83,6 +85,8 @@ type UpdateUserRequest struct {
// PasswordResetInitiateRequest is the request body for initiating a password reset.
type PasswordResetInitiateRequest struct {
LoginID string `json:"loginId"`
+ DryRun bool `json:"dryRun,omitempty"`
+ DrySend bool `json:"drySend,omitempty"`
}
// PasswordResetCompleteRequest is the request body for completing a password reset.
diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go
index e7c74e55..142f54f8 100644
--- a/backend/internal/handler/auth_handler.go
+++ b/backend/internal/handler/auth_handler.go
@@ -61,6 +61,7 @@ const (
minPollInterval = 2 * time.Second
loginCodeExpiration = 10 * time.Minute
linkResendCooldown = 60 * time.Second
+ prefixDrySend = "dry_send:"
)
type AuthHandler struct {
@@ -70,6 +71,7 @@ type AuthHandler struct {
RedisService *service.RedisService
DescopeClient *client.DescopeClient
IdpProvider domain.IdentityProvider
+ AuditRepo domain.AuditRepository
}
type signupState struct {
@@ -127,7 +129,7 @@ func checkPollInterval(redis *service.RedisService, key string, interval time.Du
return false, int(interval.Seconds())
}
-func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider) *AuthHandler {
+func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository) *AuthHandler {
projectID := os.Getenv("DESCOPE_PROJECT_ID")
managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY")
@@ -150,6 +152,7 @@ func NewAuthHandler(redisService *service.RedisService, idpProvider domain.Ident
RedisService: redisService,
DescopeClient: descopeClient,
IdpProvider: idpProvider,
+ AuditRepo: auditRepo,
}
}
@@ -646,6 +649,10 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
userfrontURL = req.URI
}
+ drySend := (req.DrySend || req.DryRun) && service.IsDryRunAllowed()
+ if (req.DrySend || req.DryRun) && !service.IsDryRunAllowed() {
+ slog.Warn("[Enchanted] DrySend ignored in production", "loginID", loginID)
+ }
if init, err := h.IdpProvider.InitiateLinkLogin(lookupLoginID, userfrontURL); err == nil && init != nil && init.Mode != "" {
keyLoginID := lookupLoginID
if init.LoginID != "" {
@@ -662,6 +669,12 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
pendingRef := GenerateSecureToken(3)
h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), loginCodeExpiration)
_ = h.RedisService.Set(prefixLoginCodePending+keyLoginID, pendingRef, loginCodeExpiration)
+ if drySend {
+ _ = h.RedisService.Set(prefixDrySend+keyLoginID, pendingRef, loginCodeExpiration)
+ if keyLoginID != lookupLoginID {
+ _ = h.RedisService.Set(prefixDrySend+lookupLoginID, pendingRef, loginCodeExpiration)
+ }
+ }
if !strings.Contains(loginID, "@") && keyLoginID != lookupLoginID {
_ = h.RedisService.Set(prefixLoginCodeSmsTarget+keyLoginID, lookupLoginID, loginCodeExpiration)
_ = h.RedisService.Set(prefixLoginCodeSmsLookup+lookupLoginID, keyLoginID, loginCodeExpiration)
@@ -698,6 +711,9 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
// Store in Redis
h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), defaultExpiration)
h.RedisService.Set(prefixToken+token, fmt.Sprintf(`{"pendingRef":"%s","loginId":"%s"}`, pendingRef, lookupLoginID), defaultExpiration)
+ if drySend {
+ _ = h.RedisService.Set(prefixDrySend+lookupLoginID, pendingRef, defaultExpiration)
+ }
// Generate Link
slog.Info("[Enchanted] Read USERFRONT_URL", "url", userfrontURL)
@@ -706,7 +722,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
// Route based on LoginID type
if strings.Contains(loginID, "@") {
// Send Email
- if h.EmailService == nil {
+ if !drySend && h.EmailService == nil {
slog.Error("[Enchanted] Email Service not configured")
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"})
}
@@ -725,19 +741,26 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
`, link, userCode)
- slog.Info("[Enchanted] Sending Email via AWS SES", "loginID", loginID)
- if err := h.EmailService.SendEmail(loginID, subject, body); err != nil {
- slog.Error("[Enchanted] Email Failed", "error", err)
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send Email"})
+ if drySend {
+ slog.Info("[Enchanted][DrySend] Email send skipped", "loginID", loginID, "link", link, "userCode", userCode)
+ } else {
+ slog.Info("[Enchanted] Sending Email via AWS SES", "loginID", loginID)
+ if err := h.EmailService.SendEmail(loginID, subject, body); err != nil {
+ slog.Error("[Enchanted] Email Failed", "error", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send Email"})
+ }
}
} else {
// Send SMS
content := fmt.Sprintf("[Baron 통합로그인] 로그인 링크: %s | 코드: %s", link, userCode)
- slog.Info("[Enchanted] Sending SMS via Naver Cloud", "loginID", loginID)
-
- if err := h.SmsService.SendSms(loginID, content); err != nil {
- slog.Error("[Enchanted] SMS Failed", "error", err)
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
+ if drySend {
+ slog.Info("[Enchanted][DrySend] SMS send skipped", "loginID", loginID, "content", content)
+ } else {
+ slog.Info("[Enchanted] Sending SMS via Naver Cloud", "loginID", loginID)
+ if err := h.SmsService.SendSms(loginID, content); err != nil {
+ slog.Error("[Enchanted] SMS Failed", "error", err)
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
+ }
}
}
@@ -1141,9 +1164,13 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
ale.RedirectTo = resetLink
ale.Operation = "SendPasswordReset"
ale.Log(slog.LevelInfo, "Initiating password reset via internal token")
+ drySend := (req.DrySend || req.DryRun) && service.IsDryRunAllowed()
+ if (req.DrySend || req.DryRun) && !service.IsDryRunAllowed() {
+ ale.Log(slog.LevelWarn, "DrySend ignored in production")
+ }
if strings.Contains(loginID, "@") {
- if h.EmailService == nil {
+ if !drySend && h.EmailService == nil {
ale.Status = fiber.StatusInternalServerError
ale.LatencyMs = time.Since(startTime)
ale.DescopeError = "Email service not configured"
@@ -1161,20 +1188,29 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
요청하지 않았다면 이 메일을 무시하세요.
`, resetLink)
- if err := h.EmailService.SendEmail(loginID, subject, body); err != nil {
- ale.Status = fiber.StatusInternalServerError
- ale.LatencyMs = time.Since(startTime)
- ale.DescopeError = err.Error()
- ale.Log(slog.LevelError, "Failed to send reset email", slog.String("loginId", loginID))
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send reset email"})
+ if drySend {
+ ale.Log(slog.LevelInfo, "Email send skipped (dry-send)", slog.String("loginId", loginID), slog.String("link", resetLink))
+ } else {
+ if err := h.EmailService.SendEmail(loginID, subject, body); err != nil {
+ ale.Status = fiber.StatusInternalServerError
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = err.Error()
+ ale.Log(slog.LevelError, "Failed to send reset email", slog.String("loginId", loginID))
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send reset email"})
+ }
}
} else {
- if err := h.SmsService.SendSms(loginID, fmt.Sprintf("[Baron 통합로그인] 비밀번호 재설정 링크: %s", resetLink)); err != nil {
- ale.Status = fiber.StatusInternalServerError
- ale.LatencyMs = time.Since(startTime)
- ale.DescopeError = err.Error()
- ale.Log(slog.LevelError, "Failed to send reset SMS", slog.String("loginId", loginID))
- return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send reset SMS"})
+ resetSms := fmt.Sprintf("[Baron 통합로그인] 비밀번호 재설정 링크: %s", resetLink)
+ if drySend {
+ ale.Log(slog.LevelInfo, "SMS send skipped (dry-send)", slog.String("loginId", loginID), slog.String("content", resetSms))
+ } else {
+ if err := h.SmsService.SendSms(loginID, resetSms); err != nil {
+ ale.Status = fiber.StatusInternalServerError
+ ale.LatencyMs = time.Since(startTime)
+ ale.DescopeError = err.Error()
+ ale.Log(slog.LevelError, "Failed to send reset SMS", slog.String("loginId", loginID))
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send reset SMS"})
+ }
}
}
@@ -1548,6 +1584,14 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
if !strings.Contains(loginID, "@") {
loginID = normalizePhoneForLoginID(loginID)
}
+ drySend := false
+ if service.IsDryRunAllowed() {
+ if val, _ := h.RedisService.Get(prefixDrySend + loginID); val != "" {
+ if pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID); pendingRef != "" && pendingRef == val {
+ drySend = true
+ }
+ }
+ }
if pendingRef, _ := h.RedisService.Get(prefixLoginCodeQrPending + loginID); pendingRef != "" {
code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code"))
if code == "" {
@@ -1617,6 +1661,10 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
if smsBody == "" {
smsBody = body
}
+ if drySend {
+ slog.Info("[Kratos Courier][DrySend] SMS send skipped (email relay)", "to", phone, "template", req.TemplateType, "content", smsBody)
+ return c.JSON(fiber.Map{"status": "ok"})
+ }
if err := h.SmsService.SendSms(phone, smsBody); err != nil {
slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
@@ -1627,13 +1675,17 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
}
if strings.Contains(req.Recipient, "@") {
- if h.EmailService == nil {
+ if !drySend && h.EmailService == nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"})
}
if shortSubject, shortBody := h.buildKratosShortEmailBody(&req, req.Recipient); shortBody != "" {
subject = shortSubject
body = shortBody
}
+ if drySend {
+ slog.Info("[Kratos Courier][DrySend] Email send skipped", "to", req.Recipient, "template", req.TemplateType, "subject", subject)
+ return c.JSON(fiber.Map{"status": "ok"})
+ }
if err := h.EmailService.SendEmail(req.Recipient, subject, body); err != nil {
slog.Error("[Kratos Courier] Email send failed", "to", req.Recipient, "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send email"})
@@ -1642,7 +1694,7 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok"})
}
- if h.SmsService == nil {
+ if !drySend && h.SmsService == nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "SMS service not configured"})
}
phone := sanitizePhoneForSms(req.Recipient)
@@ -1659,6 +1711,10 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
if smsBody == "" {
smsBody = body
}
+ if drySend {
+ slog.Info("[Kratos Courier][DrySend] SMS send skipped", "to", phone, "template", req.TemplateType, "content", smsBody)
+ return c.JSON(fiber.Map{"status": "ok"})
+ }
if err := h.SmsService.SendSms(phone, smsBody); err != nil {
slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
@@ -2027,6 +2083,179 @@ func looksLikeJWT(token string) bool {
return strings.Count(token, ".") == 2
}
+func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
+ if h.AuditRepo == nil {
+ return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Audit service unavailable"})
+ }
+
+ limit := c.QueryInt("limit", 20)
+ if limit <= 0 {
+ limit = 20
+ }
+ if limit > 100 {
+ limit = 100
+ }
+
+ profile, err := h.resolveCurrentProfile(c)
+ if err != nil {
+ return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
+ }
+
+ candidates := buildLoginCandidates(profile)
+ fetchLimit := limit * 10
+ if fetchLimit < limit {
+ fetchLimit = limit
+ }
+ if fetchLimit > 500 {
+ fetchLimit = 500
+ }
+
+ logs, err := h.AuditRepo.FindPage(c.Context(), fetchLimit, nil)
+ if err != nil {
+ return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to retrieve audit logs"})
+ }
+
+ items := make([]domain.AuditLog, 0, limit)
+ for _, log := range logs {
+ if !isAuthEventType(log.EventType) {
+ continue
+ }
+ if !matchesAuthTimelineUser(log, profile, candidates) {
+ continue
+ }
+ if log.UserID == "" {
+ log.UserID = profile.ID
+ }
+ items = append(items, log)
+ if len(items) >= limit {
+ break
+ }
+ }
+
+ return c.JSON(fiber.Map{
+ "items": items,
+ "limit": limit,
+ })
+}
+
+func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
+ token := h.getBearerToken(c)
+ if token != "" {
+ if looksLikeJWT(token) && h.DescopeClient != nil {
+ authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
+ if err == nil && authorized {
+ userResponse, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID)
+ if err != nil {
+ return nil, err
+ }
+ dept, _ := userResponse.CustomAttributes["department"].(string)
+ affType, _ := userResponse.CustomAttributes["affiliationType"].(string)
+ compCode, _ := userResponse.CustomAttributes["companyCode"].(string)
+ return &domain.UserProfileResponse{
+ ID: userResponse.UserID,
+ Email: userResponse.Email,
+ Name: userResponse.Name,
+ Phone: h.formatPhoneForDisplay(userResponse.Phone),
+ Department: dept,
+ AffiliationType: affType,
+ CompanyCode: compCode,
+ }, nil
+ }
+ }
+
+ profile, err := h.getKratosProfile(token)
+ if err != nil {
+ return nil, err
+ }
+ return profile, nil
+ }
+
+ cookie := c.Get("Cookie")
+ if cookie == "" {
+ return nil, fmt.Errorf("missing authorization token")
+ }
+ return h.getKratosProfileWithCookie(cookie)
+}
+
+func isAuthEventType(eventType string) bool {
+ normalized := strings.ToLower(eventType)
+ return strings.Contains(normalized, " /api/v1/auth/")
+}
+
+func buildLoginCandidates(profile *domain.UserProfileResponse) map[string]struct{} {
+ candidates := make(map[string]struct{})
+ if profile == nil {
+ return candidates
+ }
+ for _, raw := range []string{profile.Email, profile.Phone, normalizePhoneForLoginID(profile.Phone)} {
+ if normalized := normalizeLoginIdentifier(raw); normalized != "" {
+ candidates[normalized] = struct{}{}
+ }
+ }
+ return candidates
+}
+
+func normalizeLoginIdentifier(value string) string {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ return ""
+ }
+ if strings.Contains(trimmed, "@") {
+ return strings.ToLower(trimmed)
+ }
+ return normalizePhoneForLoginID(trimmed)
+}
+
+func matchesAuthTimelineUser(log domain.AuditLog, profile *domain.UserProfileResponse, candidates map[string]struct{}) bool {
+ if profile == nil {
+ return false
+ }
+ if profile.ID != "" && log.UserID == profile.ID {
+ return true
+ }
+ loginID := extractLoginIDFromAuditDetails(log.Details)
+ normalized := normalizeLoginIdentifier(loginID)
+ if normalized == "" {
+ return false
+ }
+ _, ok := candidates[normalized]
+ return ok
+}
+
+func extractLoginIDFromAuditDetails(details string) string {
+ if details == "" {
+ return ""
+ }
+ var payload map[string]any
+ if err := json.Unmarshal([]byte(details), &payload); err != nil {
+ return ""
+ }
+ if raw, ok := payload["login_id"]; ok {
+ if value, ok := raw.(string); ok && value != "" {
+ return value
+ }
+ }
+ if raw, ok := payload["loginId"]; ok {
+ if value, ok := raw.(string); ok && value != "" {
+ return value
+ }
+ }
+ if raw, ok := payload["request_body"]; ok {
+ if value, ok := raw.(string); ok && value != "" {
+ var body map[string]any
+ if err := json.Unmarshal([]byte(value), &body); err == nil {
+ if loginID := extractLoginIDFromClaims(body); loginID != "" {
+ return loginID
+ }
+ if target, ok := body["target"].(string); ok && target != "" {
+ return target
+ }
+ }
+ }
+ }
+ return ""
+}
+
func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, error) {
if looksLikeJWT(token) && h.DescopeClient != nil {
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart
index 2fb3897b..a609298b 100644
--- a/userfront/lib/core/services/auth_proxy_service.dart
+++ b/userfront/lib/core/services/auth_proxy_service.dart
@@ -12,6 +12,17 @@ class AuthProxyService {
}
static String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
+ static bool get _isProd {
+ final env = _envOrDefault('APP_ENV', 'dev').toLowerCase();
+ return env == 'prod' || env == 'production';
+ }
+ static bool get isProdEnv => _isProd;
+ static bool _shouldSendDrySend(bool? drySend) {
+ if (_isProd) {
+ return false;
+ }
+ return drySend == true;
+ }
static Future