1
0
forked from baron/baron-sso

로그인 이력확인

This commit is contained in:
Lectom C Han
2026-01-30 10:05:38 +09:00
parent 3b0e471471
commit c58572b7cd
10 changed files with 1117 additions and 204 deletions

View File

@@ -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 {
</div>
`, 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 {
<p style="font-size: 12px; color: #888;">요청하지 않았다면 이 메일을 무시하세요.</p>
</div>
`, 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)