forked from baron/baron-sso
로그인 이력확인
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user