|
|
|
|
@@ -39,6 +39,12 @@ const (
|
|
|
|
|
prefixLoginCodeSmsTarget = "login_code_sms_target:"
|
|
|
|
|
prefixLoginCodeSmsLookup = "login_code_sms_lookup:"
|
|
|
|
|
prefixLoginCodeShort = "login_code_short:"
|
|
|
|
|
prefixLoginCodeValue = "login_code_value:"
|
|
|
|
|
prefixLoginIDRaw = "login_id_raw:"
|
|
|
|
|
prefixLoginMethod = "login_method:"
|
|
|
|
|
prefixLoginFlow = "login_flow:"
|
|
|
|
|
prefixLoginStrategy = "login_strategy:"
|
|
|
|
|
prefixLoginApproverMeta = "login_approver_meta:"
|
|
|
|
|
prefixLoginCodeSmsOnly = "login_code_sms_only:"
|
|
|
|
|
prefixLoginCodeQrPending = "login_code_qr_pending:"
|
|
|
|
|
prefixLoginCodeQr = "login_code_qr:"
|
|
|
|
|
@@ -54,6 +60,10 @@ const (
|
|
|
|
|
statusPending = "pending"
|
|
|
|
|
statusSuccess = "success"
|
|
|
|
|
|
|
|
|
|
// Login Flows
|
|
|
|
|
loginFlowCode = "code"
|
|
|
|
|
loginFlowLink = "link"
|
|
|
|
|
|
|
|
|
|
// Durations
|
|
|
|
|
defaultExpiration = 5 * time.Minute
|
|
|
|
|
signupStateExpiration = 10 * time.Minute
|
|
|
|
|
@@ -682,7 +692,16 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
|
|
|
|
|
_ = h.RedisService.Set(prefixLoginCode+keyLoginID, init.FlowID, loginCodeExpiration)
|
|
|
|
|
}
|
|
|
|
|
pendingRef := GenerateSecureToken(3)
|
|
|
|
|
h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), loginCodeExpiration)
|
|
|
|
|
sessionData, _ := json.Marshal(map[string]string{
|
|
|
|
|
"status": statusPending,
|
|
|
|
|
"loginId": keyLoginID,
|
|
|
|
|
})
|
|
|
|
|
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
|
|
|
|
|
intent := loginFlowLink
|
|
|
|
|
if req.CodeOnly {
|
|
|
|
|
intent = loginFlowCode
|
|
|
|
|
}
|
|
|
|
|
h.storeLoginMeta(pendingRef, loginID, req.Method, intent, loginFlowCode, loginCodeExpiration)
|
|
|
|
|
_ = h.RedisService.Set(prefixLoginCodePending+keyLoginID, pendingRef, loginCodeExpiration)
|
|
|
|
|
if drySend {
|
|
|
|
|
_ = h.RedisService.Set(prefixDrySend+keyLoginID, pendingRef, loginCodeExpiration)
|
|
|
|
|
@@ -724,11 +743,20 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
|
|
|
|
|
slog.Info("[Enchanted] Initiating enchanted link", "loginID", loginID, "token", token, "pendingRef", pendingRef)
|
|
|
|
|
|
|
|
|
|
// Store in Redis
|
|
|
|
|
h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), defaultExpiration)
|
|
|
|
|
sessionData, _ := json.Marshal(map[string]string{
|
|
|
|
|
"status": statusPending,
|
|
|
|
|
"loginId": lookupLoginID,
|
|
|
|
|
})
|
|
|
|
|
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), defaultExpiration)
|
|
|
|
|
h.RedisService.Set(prefixToken+token, fmt.Sprintf(`{"pendingRef":"%s","loginId":"%s"}`, pendingRef, lookupLoginID), defaultExpiration)
|
|
|
|
|
if drySend {
|
|
|
|
|
_ = h.RedisService.Set(prefixDrySend+lookupLoginID, pendingRef, defaultExpiration)
|
|
|
|
|
}
|
|
|
|
|
intent := loginFlowLink
|
|
|
|
|
if req.CodeOnly {
|
|
|
|
|
intent = loginFlowCode
|
|
|
|
|
}
|
|
|
|
|
h.storeLoginMeta(pendingRef, loginID, req.Method, intent, loginFlowLink, defaultExpiration)
|
|
|
|
|
|
|
|
|
|
// Generate Link
|
|
|
|
|
slog.Info("[Enchanted] Read USERFRONT_URL", "url", userfrontURL)
|
|
|
|
|
@@ -821,6 +849,96 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error {
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if data["status"] == "approved" {
|
|
|
|
|
loginID := data["loginId"]
|
|
|
|
|
if loginID == "" {
|
|
|
|
|
loginID = data["login_id"]
|
|
|
|
|
}
|
|
|
|
|
if loginID == "" {
|
|
|
|
|
slog.Warn("[Poll] Approved but missing loginId", "pendingRef", req.PendingRef)
|
|
|
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"})
|
|
|
|
|
}
|
|
|
|
|
if h.IdpProvider == nil {
|
|
|
|
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loginStrategy := h.loadLoginStrategy(req.PendingRef)
|
|
|
|
|
if loginStrategy == "" {
|
|
|
|
|
loginStrategy = loginFlowLink
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var authInfo *domain.AuthInfo
|
|
|
|
|
var err error
|
|
|
|
|
if loginStrategy == loginFlowCode {
|
|
|
|
|
code, _ := h.RedisService.Get(prefixLoginCodeValue + req.PendingRef)
|
|
|
|
|
code = normalizeLoginCode(code)
|
|
|
|
|
if code == "" {
|
|
|
|
|
slog.Warn("[Poll] Missing login code for approved flow", "pendingRef", req.PendingRef)
|
|
|
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Login code expired"})
|
|
|
|
|
}
|
|
|
|
|
flowID, _ := h.RedisService.Get(prefixLoginCode + loginID)
|
|
|
|
|
if flowID == "" {
|
|
|
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Login flow expired"})
|
|
|
|
|
}
|
|
|
|
|
authInfo, err = h.IdpProvider.VerifyLoginCode(loginID, flowID, code)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, domain.ErrNotSupported) {
|
|
|
|
|
return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"})
|
|
|
|
|
}
|
|
|
|
|
slog.Error("[Poll] IDP code verify failed", "error", err)
|
|
|
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to verify login code"})
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
authInfo, err = h.IdpProvider.IssueSession(loginID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, domain.ErrNotSupported) {
|
|
|
|
|
return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"})
|
|
|
|
|
}
|
|
|
|
|
slog.Error("[Poll] IDP session issue failed", "error", err)
|
|
|
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
|
|
|
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.Locals("login_id", loginID)
|
|
|
|
|
setSessionIDLocal(c, authInfo.SessionToken)
|
|
|
|
|
sessionID := extractSessionIDFromToken(authInfo.SessionToken)
|
|
|
|
|
if sessionID == "" && authInfo.SessionToken != nil && authInfo.SessionToken.JWT != "" {
|
|
|
|
|
if resolved, err := h.getKratosSessionID(authInfo.SessionToken.JWT); err == nil && resolved != "" {
|
|
|
|
|
sessionID = resolved
|
|
|
|
|
authInfo.SessionToken.SessionID = resolved
|
|
|
|
|
setSessionIDLocal(c, authInfo.SessionToken)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sessionData := map[string]string{
|
|
|
|
|
"status": statusSuccess,
|
|
|
|
|
"jwt": authInfo.SessionToken.JWT,
|
|
|
|
|
}
|
|
|
|
|
if sessionID != "" {
|
|
|
|
|
sessionData["session_id"] = sessionID
|
|
|
|
|
}
|
|
|
|
|
sessionDataJSON, _ := json.Marshal(sessionData)
|
|
|
|
|
h.RedisService.Set(prefixSession+req.PendingRef, string(sessionDataJSON), defaultExpiration)
|
|
|
|
|
|
|
|
|
|
h.writeLinkAuditLog(loginID, req.PendingRef, authInfo.SessionToken, c)
|
|
|
|
|
h.clearLoginMeta(req.PendingRef)
|
|
|
|
|
if loginStrategy == loginFlowCode {
|
|
|
|
|
h.RedisService.Delete(prefixLoginCode + loginID)
|
|
|
|
|
h.RedisService.Delete(prefixLoginCodePending + loginID)
|
|
|
|
|
h.RedisService.Delete(prefixLoginCodeSmsTarget + loginID)
|
|
|
|
|
h.RedisService.Delete(prefixLoginCodeSmsLookup + loginID)
|
|
|
|
|
h.RedisService.Delete(prefixLoginCodeValue + req.PendingRef)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return c.JSON(fiber.Map{
|
|
|
|
|
"sessionJwt": authInfo.SessionToken.JWT,
|
|
|
|
|
"status": "ok",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return c.JSON(fiber.Map{
|
|
|
|
|
"error": "authorization_pending",
|
|
|
|
|
"interval": int(minPollInterval.Seconds()),
|
|
|
|
|
@@ -851,6 +969,29 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
|
|
|
|
|
|
|
|
|
|
slog.Info("[Verify] Token valid", "loginID", loginID, "pendingRef", pendingRef)
|
|
|
|
|
|
|
|
|
|
if req.VerifyOnly {
|
|
|
|
|
c.Locals("auth_timeline_skip", true)
|
|
|
|
|
if pendingRef == "" || loginID == "" {
|
|
|
|
|
slog.Warn("[Verify] Missing pendingRef/loginID for verify-only", "token", req.Token)
|
|
|
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h.storeLoginApproverMeta(pendingRef, c, defaultExpiration)
|
|
|
|
|
|
|
|
|
|
// 승인 전용: 세션 발급 없이 승인 상태만 기록
|
|
|
|
|
sessionData, _ := json.Marshal(map[string]string{
|
|
|
|
|
"status": "approved",
|
|
|
|
|
"loginId": loginID,
|
|
|
|
|
})
|
|
|
|
|
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), defaultExpiration)
|
|
|
|
|
|
|
|
|
|
return c.JSON(fiber.Map{
|
|
|
|
|
"status": "approved",
|
|
|
|
|
"pendingRef": pendingRef,
|
|
|
|
|
"message": "Login approved",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if h.IdpProvider == nil {
|
|
|
|
|
slog.Error("[Verify] IDP Provider is nil")
|
|
|
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
|
|
|
|
|
@@ -899,6 +1040,7 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
|
|
|
|
|
LoginID string `json:"loginId"`
|
|
|
|
|
Code string `json:"code"`
|
|
|
|
|
PendingRef string `json:"pendingRef"`
|
|
|
|
|
VerifyOnly bool `json:"verifyOnly,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
if err := c.BodyParser(&req); err != nil {
|
|
|
|
|
slog.Error("[LoginCode] Body parse error", "error", err)
|
|
|
|
|
@@ -925,6 +1067,43 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
|
|
|
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Login flow expired"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if req.VerifyOnly {
|
|
|
|
|
c.Locals("auth_timeline_skip", true)
|
|
|
|
|
effectiveLoginID := lookupLoginID
|
|
|
|
|
if !strings.Contains(loginID, "@") {
|
|
|
|
|
if mapped, _ := h.RedisService.Get(prefixLoginCodeSmsLookup + lookupLoginID); mapped != "" {
|
|
|
|
|
effectiveLoginID = mapped
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
pendingRef := strings.TrimSpace(req.PendingRef)
|
|
|
|
|
storedRef, _ := h.RedisService.Get(prefixLoginCodePending + lookupLoginID)
|
|
|
|
|
if pendingRef == "" {
|
|
|
|
|
pendingRef = storedRef
|
|
|
|
|
} else if storedRef != "" && pendingRef != storedRef {
|
|
|
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"})
|
|
|
|
|
}
|
|
|
|
|
if pendingRef == "" {
|
|
|
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"})
|
|
|
|
|
}
|
|
|
|
|
expectedCode, _ := h.RedisService.Get(prefixLoginCodeValue + pendingRef)
|
|
|
|
|
expectedCode = normalizeLoginCode(expectedCode)
|
|
|
|
|
inputCode := normalizeLoginCode(req.Code)
|
|
|
|
|
if expectedCode == "" || inputCode == "" || inputCode != expectedCode {
|
|
|
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid code"})
|
|
|
|
|
}
|
|
|
|
|
h.storeLoginApproverMeta(pendingRef, c, loginCodeExpiration)
|
|
|
|
|
sessionData, _ := json.Marshal(map[string]string{
|
|
|
|
|
"status": "approved",
|
|
|
|
|
"loginId": effectiveLoginID,
|
|
|
|
|
})
|
|
|
|
|
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
|
|
|
|
|
return c.JSON(fiber.Map{
|
|
|
|
|
"status": "approved",
|
|
|
|
|
"pendingRef": pendingRef,
|
|
|
|
|
"message": "Login approved",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
authInfo, err := h.IdpProvider.VerifyLoginCode(lookupLoginID, flowID, req.Code)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if errors.Is(err, domain.ErrNotSupported) {
|
|
|
|
|
@@ -977,6 +1156,7 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
|
|
|
|
|
func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error {
|
|
|
|
|
var req struct {
|
|
|
|
|
ShortCode string `json:"shortCode"`
|
|
|
|
|
VerifyOnly bool `json:"verifyOnly,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
if err := c.BodyParser(&req); err != nil {
|
|
|
|
|
slog.Error("[LoginShortCode] Body parse error", "error", err)
|
|
|
|
|
@@ -1001,6 +1181,29 @@ func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error {
|
|
|
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired code"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if req.VerifyOnly {
|
|
|
|
|
c.Locals("auth_timeline_skip", true)
|
|
|
|
|
if payload.PendingRef == "" {
|
|
|
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid session reference"})
|
|
|
|
|
}
|
|
|
|
|
normalizedCode := normalizeLoginCode(payload.Code)
|
|
|
|
|
if normalizedCode != "" {
|
|
|
|
|
h.RedisService.Set(prefixLoginCodeValue+payload.PendingRef, normalizedCode, loginCodeExpiration)
|
|
|
|
|
}
|
|
|
|
|
h.storeLoginApproverMeta(payload.PendingRef, c, loginCodeExpiration)
|
|
|
|
|
sessionData, _ := json.Marshal(map[string]string{
|
|
|
|
|
"status": "approved",
|
|
|
|
|
"loginId": payload.LoginID,
|
|
|
|
|
})
|
|
|
|
|
h.RedisService.Set(prefixSession+payload.PendingRef, string(sessionData), loginCodeExpiration)
|
|
|
|
|
h.RedisService.Delete(prefixLoginCodeShort + shortCode)
|
|
|
|
|
return c.JSON(fiber.Map{
|
|
|
|
|
"status": "approved",
|
|
|
|
|
"pendingRef": payload.PendingRef,
|
|
|
|
|
"message": "Login approved",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if h.IdpProvider == nil {
|
|
|
|
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
|
|
|
|
|
}
|
|
|
|
|
@@ -1805,6 +2008,20 @@ func (h *AuthHandler) buildKratosCourierMessage(req *kratosCourierRequest) (stri
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if loginCode != "" && label == "로그인" {
|
|
|
|
|
loginID := req.Recipient
|
|
|
|
|
if !strings.Contains(loginID, "@") {
|
|
|
|
|
loginID = normalizePhoneForLoginID(loginID)
|
|
|
|
|
}
|
|
|
|
|
pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID)
|
|
|
|
|
if pendingRef != "" {
|
|
|
|
|
normalizedCode := normalizeLoginCode(loginCode)
|
|
|
|
|
if normalizedCode != "" {
|
|
|
|
|
_ = h.RedisService.Set(prefixLoginCodeValue+pendingRef, normalizedCode, loginCodeExpiration)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if code == "" {
|
|
|
|
|
return subject, fmt.Sprintf("[Baron 통합로그인] %s 요청이 도착했습니다", label)
|
|
|
|
|
}
|
|
|
|
|
@@ -2228,6 +2445,39 @@ func (h *AuthHandler) loadQrMeta(pendingRef string) (qrMeta, bool) {
|
|
|
|
|
return meta, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *AuthHandler) storeLoginApproverMeta(pendingRef string, c *fiber.Ctx, ttl time.Duration) {
|
|
|
|
|
if h.RedisService == nil || pendingRef == "" || c == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
meta := qrMeta{
|
|
|
|
|
IPAddress: extractClientIPFromHeaders(c),
|
|
|
|
|
UserAgent: c.Get("User-Agent"),
|
|
|
|
|
}
|
|
|
|
|
raw, err := json.Marshal(meta)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
_ = h.RedisService.Set(prefixLoginApproverMeta+pendingRef, string(raw), ttl)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *AuthHandler) loadLoginApproverMeta(pendingRef string) (qrMeta, bool) {
|
|
|
|
|
if h.RedisService == nil || pendingRef == "" {
|
|
|
|
|
return qrMeta{}, false
|
|
|
|
|
}
|
|
|
|
|
val, err := h.RedisService.Get(prefixLoginApproverMeta + pendingRef)
|
|
|
|
|
if err != nil || val == "" {
|
|
|
|
|
return qrMeta{}, false
|
|
|
|
|
}
|
|
|
|
|
var meta qrMeta
|
|
|
|
|
if err := json.Unmarshal([]byte(val), &meta); err != nil {
|
|
|
|
|
return qrMeta{}, false
|
|
|
|
|
}
|
|
|
|
|
if meta.IPAddress == "" && meta.UserAgent == "" {
|
|
|
|
|
return qrMeta{}, false
|
|
|
|
|
}
|
|
|
|
|
return meta, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *AuthHandler) storeQrApproverSessionID(pendingRef, sessionID string) {
|
|
|
|
|
if h.RedisService == nil || pendingRef == "" || sessionID == "" {
|
|
|
|
|
return
|
|
|
|
|
@@ -2246,6 +2496,57 @@ func (h *AuthHandler) loadQrApproverSessionID(pendingRef string) string {
|
|
|
|
|
return strings.TrimSpace(val)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *AuthHandler) storeLoginMeta(pendingRef, loginID, rawMethod, flow, strategy string, ttl time.Duration) {
|
|
|
|
|
if h.RedisService == nil || pendingRef == "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
method := resolveLoginMethod(rawMethod, loginID)
|
|
|
|
|
if method != "" {
|
|
|
|
|
_ = h.RedisService.Set(prefixLoginMethod+pendingRef, method, ttl)
|
|
|
|
|
}
|
|
|
|
|
if flow != "" {
|
|
|
|
|
_ = h.RedisService.Set(prefixLoginFlow+pendingRef, flow, ttl)
|
|
|
|
|
}
|
|
|
|
|
if strategy != "" {
|
|
|
|
|
_ = h.RedisService.Set(prefixLoginStrategy+pendingRef, strategy, ttl)
|
|
|
|
|
}
|
|
|
|
|
if strings.TrimSpace(loginID) != "" {
|
|
|
|
|
_ = h.RedisService.Set(prefixLoginIDRaw+pendingRef, loginID, ttl)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *AuthHandler) loadLoginMeta(pendingRef string) (string, string, string, string) {
|
|
|
|
|
if h.RedisService == nil || pendingRef == "" {
|
|
|
|
|
return "", "", "", ""
|
|
|
|
|
}
|
|
|
|
|
method, _ := h.RedisService.Get(prefixLoginMethod + pendingRef)
|
|
|
|
|
flow, _ := h.RedisService.Get(prefixLoginFlow + pendingRef)
|
|
|
|
|
strategy, _ := h.RedisService.Get(prefixLoginStrategy + pendingRef)
|
|
|
|
|
rawLoginID, _ := h.RedisService.Get(prefixLoginIDRaw + pendingRef)
|
|
|
|
|
return strings.TrimSpace(method), strings.TrimSpace(flow), strings.TrimSpace(strategy), strings.TrimSpace(rawLoginID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *AuthHandler) loadLoginFlow(pendingRef string) string {
|
|
|
|
|
_, flow, _, _ := h.loadLoginMeta(pendingRef)
|
|
|
|
|
return flow
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *AuthHandler) loadLoginStrategy(pendingRef string) string {
|
|
|
|
|
_, _, strategy, _ := h.loadLoginMeta(pendingRef)
|
|
|
|
|
return strategy
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *AuthHandler) clearLoginMeta(pendingRef string) {
|
|
|
|
|
if h.RedisService == nil || pendingRef == "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
_ = h.RedisService.Delete(prefixLoginMethod + pendingRef)
|
|
|
|
|
_ = h.RedisService.Delete(prefixLoginFlow + pendingRef)
|
|
|
|
|
_ = h.RedisService.Delete(prefixLoginStrategy + pendingRef)
|
|
|
|
|
_ = h.RedisService.Delete(prefixLoginIDRaw + pendingRef)
|
|
|
|
|
_ = h.RedisService.Delete(prefixLoginApproverMeta + pendingRef)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *AuthHandler) writeQrAuditLog(loginID, pendingRef string, sessionToken *domain.Token, approvedSessionID string) {
|
|
|
|
|
if h.AuditRepo == nil || pendingRef == "" {
|
|
|
|
|
return
|
|
|
|
|
@@ -2301,14 +2602,53 @@ func (h *AuthHandler) writeLinkAuditLog(loginID, pendingRef string, sessionToken
|
|
|
|
|
meta.UserAgent = c.Get("User-Agent")
|
|
|
|
|
}
|
|
|
|
|
sessionID := extractSessionIDFromToken(sessionToken)
|
|
|
|
|
loginMethod, loginFlow, loginStrategy, rawLoginID := h.loadLoginMeta(pendingRef)
|
|
|
|
|
path := "/api/v1/auth/magic-link/verify"
|
|
|
|
|
authLabel := "링크"
|
|
|
|
|
if loginStrategy == loginFlowCode {
|
|
|
|
|
path = "/api/v1/auth/login/code/verify"
|
|
|
|
|
}
|
|
|
|
|
displayFlow := loginFlow
|
|
|
|
|
if displayFlow == "" {
|
|
|
|
|
displayFlow = loginStrategy
|
|
|
|
|
}
|
|
|
|
|
if displayFlow == loginFlowCode {
|
|
|
|
|
authLabel = "코드"
|
|
|
|
|
} else if displayFlow == loginFlowLink {
|
|
|
|
|
authLabel = "링크"
|
|
|
|
|
}
|
|
|
|
|
logLoginID := loginID
|
|
|
|
|
if rawLoginID != "" {
|
|
|
|
|
logLoginID = rawLoginID
|
|
|
|
|
}
|
|
|
|
|
details := map[string]any{
|
|
|
|
|
"path": "/api/v1/auth/magic-link/verify",
|
|
|
|
|
"login_id": loginID,
|
|
|
|
|
"path": path,
|
|
|
|
|
"login_id": logLoginID,
|
|
|
|
|
"pending_ref": pendingRef,
|
|
|
|
|
}
|
|
|
|
|
if sessionID != "" {
|
|
|
|
|
details["session_id"] = sessionID
|
|
|
|
|
}
|
|
|
|
|
if loginMethod != "" {
|
|
|
|
|
details["login_method"] = loginMethod
|
|
|
|
|
}
|
|
|
|
|
if loginFlow != "" {
|
|
|
|
|
details["login_flow"] = loginFlow
|
|
|
|
|
}
|
|
|
|
|
if loginStrategy != "" {
|
|
|
|
|
details["login_strategy"] = loginStrategy
|
|
|
|
|
}
|
|
|
|
|
if rawLoginID != "" && rawLoginID != loginID {
|
|
|
|
|
details["login_id_effective"] = loginID
|
|
|
|
|
}
|
|
|
|
|
if approverMeta, ok := h.loadLoginApproverMeta(pendingRef); ok {
|
|
|
|
|
if approverMeta.IPAddress != "" {
|
|
|
|
|
details["approved_ip"] = approverMeta.IPAddress
|
|
|
|
|
}
|
|
|
|
|
if approverMeta.UserAgent != "" {
|
|
|
|
|
details["approved_user_agent"] = approverMeta.UserAgent
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
detailsJSON, _ := json.Marshal(details)
|
|
|
|
|
|
|
|
|
|
log := &domain.AuditLog{
|
|
|
|
|
@@ -2316,12 +2656,12 @@ func (h *AuthHandler) writeLinkAuditLog(loginID, pendingRef string, sessionToken
|
|
|
|
|
Timestamp: time.Now(),
|
|
|
|
|
UserID: "",
|
|
|
|
|
SessionID: sessionID,
|
|
|
|
|
EventType: "POST /api/v1/auth/magic-link/verify",
|
|
|
|
|
EventType: fmt.Sprintf("POST %s", path),
|
|
|
|
|
Status: "success",
|
|
|
|
|
IPAddress: meta.IPAddress,
|
|
|
|
|
UserAgent: meta.UserAgent,
|
|
|
|
|
Details: string(detailsJSON),
|
|
|
|
|
AuthMethod: "링크",
|
|
|
|
|
AuthMethod: authLabel,
|
|
|
|
|
}
|
|
|
|
|
_ = h.AuditRepo.Create(log)
|
|
|
|
|
}
|
|
|
|
|
@@ -2357,6 +2697,16 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
|
|
|
|
|
limit = 100
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cursorRaw := strings.TrimSpace(c.Query("cursor"))
|
|
|
|
|
var cursor *domain.AuditCursor
|
|
|
|
|
if cursorRaw != "" {
|
|
|
|
|
var err error
|
|
|
|
|
cursor, err = parseAuditCursor(cursorRaw)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid cursor"})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
profile, err := h.resolveCurrentProfile(c)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
|
|
|
|
@@ -2371,38 +2721,76 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
|
|
|
|
|
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
|
|
|
|
|
nextCursor := ""
|
|
|
|
|
currentCursor := cursor
|
|
|
|
|
const maxBatches = 10
|
|
|
|
|
for batch := 0; batch < maxBatches && len(items) < limit; batch++ {
|
|
|
|
|
logs, err := h.AuditRepo.FindPage(c.Context(), fetchLimit, currentCursor)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to retrieve audit logs"})
|
|
|
|
|
}
|
|
|
|
|
if !matchesAuthTimelineUser(log, profile, candidates) {
|
|
|
|
|
continue
|
|
|
|
|
if len(logs) == 0 {
|
|
|
|
|
nextCursor = ""
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
if log.UserID == "" {
|
|
|
|
|
log.UserID = profile.ID
|
|
|
|
|
|
|
|
|
|
var lastScanned *domain.AuditLog
|
|
|
|
|
for i := range logs {
|
|
|
|
|
log := logs[i]
|
|
|
|
|
lastScanned = &log
|
|
|
|
|
if !isAuthEventType(log.EventType) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if !matchesAuthTimelineUser(log, profile, candidates) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if shouldSkipAuthTimeline(log) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if log.UserID == "" {
|
|
|
|
|
log.UserID = profile.ID
|
|
|
|
|
}
|
|
|
|
|
log.AuthMethod = deriveAuthMethod(log)
|
|
|
|
|
if log.AuthMethod == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if log.SessionID == "" {
|
|
|
|
|
log.SessionID = extractSessionIDFromAuditDetails(log.Details)
|
|
|
|
|
}
|
|
|
|
|
items = append(items, log)
|
|
|
|
|
if len(items) >= limit {
|
|
|
|
|
nextCursor = encodeAuditCursor(log)
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
log.AuthMethod = deriveAuthMethod(log)
|
|
|
|
|
if log.AuthMethod == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if log.SessionID == "" {
|
|
|
|
|
log.SessionID = extractSessionIDFromAuditDetails(log.Details)
|
|
|
|
|
}
|
|
|
|
|
items = append(items, log)
|
|
|
|
|
|
|
|
|
|
if len(items) >= limit {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(logs) < fetchLimit {
|
|
|
|
|
nextCursor = ""
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if lastScanned == nil {
|
|
|
|
|
nextCursor = ""
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
currentCursor = &domain.AuditCursor{
|
|
|
|
|
Timestamp: lastScanned.Timestamp,
|
|
|
|
|
EventID: lastScanned.EventID,
|
|
|
|
|
}
|
|
|
|
|
nextCursor = encodeAuditCursor(*lastScanned)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return c.JSON(fiber.Map{
|
|
|
|
|
"items": items,
|
|
|
|
|
"limit": limit,
|
|
|
|
|
"items": items,
|
|
|
|
|
"limit": limit,
|
|
|
|
|
"cursor": cursorRaw,
|
|
|
|
|
"next_cursor": nextCursor,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -2627,6 +3015,54 @@ func extractRequestBody(details map[string]any) map[string]any {
|
|
|
|
|
return body
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func shouldSkipAuthTimeline(log domain.AuditLog) bool {
|
|
|
|
|
details, _ := parseAuditDetails(log.Details)
|
|
|
|
|
path := strings.ToLower(extractAuditPath(log))
|
|
|
|
|
if path != "" && strings.Contains(path, "/api/v1/auth/enchanted-link/init") {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
if path != "" && (strings.Contains(path, "/api/v1/auth/magic-link/verify") ||
|
|
|
|
|
strings.Contains(path, "/api/v1/auth/login/code/verify")) {
|
|
|
|
|
sessionID := log.SessionID
|
|
|
|
|
if sessionID == "" {
|
|
|
|
|
sessionID = extractSessionIDFromAuditDetails(log.Details)
|
|
|
|
|
}
|
|
|
|
|
if sessionID == "" {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if details != nil {
|
|
|
|
|
if raw, ok := details["auth_timeline_skip"]; ok {
|
|
|
|
|
switch value := raw.(type) {
|
|
|
|
|
case bool:
|
|
|
|
|
if value {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
case string:
|
|
|
|
|
if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
requestBody := extractRequestBody(details)
|
|
|
|
|
if requestBody != nil {
|
|
|
|
|
if raw, ok := requestBody["verifyOnly"]; ok {
|
|
|
|
|
switch value := raw.(type) {
|
|
|
|
|
case bool:
|
|
|
|
|
if value {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
case string:
|
|
|
|
|
if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func loginIDKind(loginID string) string {
|
|
|
|
|
normalized := strings.TrimSpace(loginID)
|
|
|
|
|
if normalized == "" {
|
|
|
|
|
@@ -2638,6 +3074,31 @@ func loginIDKind(loginID string) string {
|
|
|
|
|
return "phone"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func resolveLoginMethod(rawMethod, loginID string) string {
|
|
|
|
|
method := strings.ToLower(strings.TrimSpace(rawMethod))
|
|
|
|
|
if method == "sms" || method == "email" {
|
|
|
|
|
return method
|
|
|
|
|
}
|
|
|
|
|
if strings.TrimSpace(loginID) == "" {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(loginID, "@") {
|
|
|
|
|
return "email"
|
|
|
|
|
}
|
|
|
|
|
return "sms"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func loginMethodLabel(method string) string {
|
|
|
|
|
switch strings.ToLower(strings.TrimSpace(method)) {
|
|
|
|
|
case "sms":
|
|
|
|
|
return "SMS"
|
|
|
|
|
case "email":
|
|
|
|
|
return "Email"
|
|
|
|
|
default:
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func deriveAuthMethod(log domain.AuditLog) string {
|
|
|
|
|
path := strings.ToLower(extractAuditPath(log))
|
|
|
|
|
if path == "" {
|
|
|
|
|
@@ -2648,6 +3109,57 @@ func deriveAuthMethod(log domain.AuditLog) string {
|
|
|
|
|
kind := loginIDKind(loginID)
|
|
|
|
|
details, _ := parseAuditDetails(log.Details)
|
|
|
|
|
requestBody := extractRequestBody(details)
|
|
|
|
|
if details != nil {
|
|
|
|
|
if raw, ok := details["auth_timeline_skip"]; ok {
|
|
|
|
|
switch value := raw.(type) {
|
|
|
|
|
case bool:
|
|
|
|
|
if value {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
case string:
|
|
|
|
|
if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if requestBody != nil {
|
|
|
|
|
if raw, ok := requestBody["verifyOnly"]; ok {
|
|
|
|
|
switch value := raw.(type) {
|
|
|
|
|
case bool:
|
|
|
|
|
if value {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
case string:
|
|
|
|
|
if strings.EqualFold(strings.TrimSpace(value), "true") || value == "1" {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if path != "" && (strings.Contains(path, "/api/v1/auth/qr/init") ||
|
|
|
|
|
strings.Contains(path, "/api/v1/auth/qr/poll") ||
|
|
|
|
|
strings.Contains(path, "/api/v1/auth/qr/approve")) {
|
|
|
|
|
return "QR"
|
|
|
|
|
}
|
|
|
|
|
if details != nil {
|
|
|
|
|
rawFlow, _ := details["login_flow"].(string)
|
|
|
|
|
rawMethod, _ := details["login_method"].(string)
|
|
|
|
|
flow := strings.ToLower(strings.TrimSpace(rawFlow))
|
|
|
|
|
methodLabel := loginMethodLabel(rawMethod)
|
|
|
|
|
switch flow {
|
|
|
|
|
case loginFlowCode:
|
|
|
|
|
if methodLabel != "" {
|
|
|
|
|
return fmt.Sprintf("코드(%s)", methodLabel)
|
|
|
|
|
}
|
|
|
|
|
return "코드"
|
|
|
|
|
case loginFlowLink:
|
|
|
|
|
if methodLabel != "" {
|
|
|
|
|
return fmt.Sprintf("링크(%s)", methodLabel)
|
|
|
|
|
}
|
|
|
|
|
return "링크"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch {
|
|
|
|
|
case strings.Contains(path, "/api/v1/auth/password/login"):
|
|
|
|
|
|