1
0
forked from baron/baron-sso

userfront 이력 session ID기반 작업 완료.

This commit is contained in:
Lectom C Han
2026-01-30 11:16:09 +09:00
parent c58572b7cd
commit 60df7ba904
12 changed files with 1389 additions and 398 deletions

View File

@@ -7,6 +7,7 @@ import (
"bytes"
"context"
crand "crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
@@ -41,6 +42,8 @@ const (
prefixLoginCodeQr = "login_code_qr:"
prefixPollMeta = "poll_meta:"
prefixQrRef = "qr_ref:"
prefixQrMeta = "qr_meta:"
prefixQrApproverSession = "qr_approver_session:"
prefixQrPending = "qr_pending:"
prefixSignupEmail = "signup:email:"
prefixSignupPhone = "signup:phone:"
@@ -605,6 +608,8 @@ func (h *AuthHandler) VerifySms(c *fiber.Ctx) error {
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)
return c.JSON(fiber.Map{
"token": authInfo.SessionToken.JWT,
@@ -855,6 +860,8 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
}
sessionToken := authInfo.SessionToken.JWT
c.Locals("login_id", loginID)
setSessionIDLocal(c, authInfo.SessionToken)
slog.Info("[Verify] Success! Updating Redis session", "pendingRef", pendingRef)
sessionData, _ := json.Marshal(map[string]string{
@@ -912,6 +919,8 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
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", lookupLoginID)
setSessionIDLocal(c, authInfo.SessionToken)
h.RedisService.Delete(prefixLoginCode + lookupLoginID)
h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID)
@@ -995,6 +1004,8 @@ func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error {
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", payload.LoginID)
setSessionIDLocal(c, authInfo.SessionToken)
h.RedisService.Delete(prefixLoginCode + payload.LoginID)
h.RedisService.Delete(prefixLoginCodeShort + shortCode)
@@ -1080,6 +1091,7 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
ale.Status = fiber.StatusOK
ale.LatencyMs = time.Since(startTime)
ale.SessionJwt = authInfo.SessionToken.JWT
setSessionIDLocal(c, authInfo.SessionToken)
ale.Log(slog.LevelInfo, "Login successful", slog.String("provider", h.IdpProvider.Name()), slog.String("subject", authInfo.Subject))
resp := fiber.Map{
@@ -1430,6 +1442,7 @@ func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error {
// Redis에 초기 상태 저장 (5분 만료)
h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), 5*time.Minute)
h.RedisService.Set(prefixQrRef+qrRef, pendingRef, 5*time.Minute)
h.storeQrMeta(pendingRef, c)
return c.JSON(fiber.Map{
"qrCode": qrPayload, // 프론트엔드에서 이 텍스트로 QR을 생성하거나, 이미지를 반환
@@ -1514,6 +1527,9 @@ func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error {
slog.Warn("[QR] Cookie session invalid", "error", err)
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
}
if sessionID, err := h.getKratosSessionIDWithCookie(cookie); err == nil && sessionID != "" {
h.storeQrApproverSessionID(pendingRef, sessionID)
}
loginID := pickLoginIDFromTraits(traits)
if loginID == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
@@ -1529,18 +1545,30 @@ func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error {
}
// 2. 모바일 토큰은 승인 검증용으로만 사용하고, 웹 전용 세션을 새로 발급
if sessionToken, err := h.tryIssueDescopeQrSession(c, req.Token); err != nil {
if sessionToken, loginID, approvedSessionID, err := h.tryIssueDescopeQrSession(c, req.Token); err != nil {
slog.Error("[QR] Issue web session failed", "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue web session"})
} else if sessionToken != "" {
} else if sessionToken != nil && sessionToken.JWT != "" {
h.storeQrApproverSessionID(pendingRef, approvedSessionID)
h.writeQrAuditLog(loginID, pendingRef, sessionToken, approvedSessionID)
sessionData, _ := json.Marshal(map[string]string{
"status": statusSuccess,
"jwt": sessionToken,
"jwt": sessionToken.JWT,
})
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), 5*time.Minute)
return c.JSON(fiber.Map{"message": "QR Login Approved"})
}
approvedSessionID := ""
if req.Token != "" {
if sessionID, err := h.getKratosSessionID(req.Token); err == nil {
approvedSessionID = sessionID
}
}
if approvedSessionID != "" {
h.storeQrApproverSessionID(pendingRef, approvedSessionID)
}
loginID, err := h.resolveKratosLoginID(req.Token)
if err != nil {
slog.Warn("[QR] Invalid token", "error", err)
@@ -1613,6 +1641,7 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
"jwt": authInfo.SessionToken.JWT,
})
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
h.writeQrAuditLog(loginID, pendingRef, authInfo.SessionToken, "")
h.RedisService.Delete(prefixLoginCodeQrPending + loginID)
h.RedisService.Delete(prefixLoginCode + loginID)
h.RedisService.Delete(prefixLoginCodeQr + pendingRef)
@@ -1640,6 +1669,7 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
"jwt": authInfo.SessionToken.JWT,
})
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
h.writeQrAuditLog(loginID, pendingRef, authInfo.SessionToken, "")
h.RedisService.Delete(prefixQrPending + loginID)
h.RedisService.Delete(prefixLoginCode + loginID)
h.RedisService.Delete(prefixLoginCodeSmsTarget + loginID)
@@ -2083,6 +2113,176 @@ func looksLikeJWT(token string) bool {
return strings.Count(token, ".") == 2
}
func setSessionIDLocal(c *fiber.Ctx, token *domain.Token) {
if c == nil || token == nil {
return
}
if sessionID := extractSessionIDFromToken(token); sessionID != "" {
c.Locals("session_id", sessionID)
}
}
func extractSessionIDFromToken(token *domain.Token) string {
if token == nil {
return ""
}
if token.SessionID != "" {
return token.SessionID
}
if token.JWT != "" {
return extractSessionIDFromJWT(token.JWT)
}
return ""
}
func extractSessionIDFromJWT(token string) string {
if !looksLikeJWT(token) {
return ""
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
return ""
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
payload, err = base64.URLEncoding.DecodeString(parts[1])
if err != nil {
return ""
}
}
var claims map[string]any
if err := json.Unmarshal(payload, &claims); err != nil {
return ""
}
for _, key := range []string{"sid", "session_id", "sessionId", "jti"} {
if raw, ok := claims[key]; ok {
switch value := raw.(type) {
case string:
if value != "" {
return value
}
default:
return fmt.Sprint(value)
}
}
}
return ""
}
type qrMeta struct {
IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
}
func (h *AuthHandler) storeQrMeta(pendingRef string, c *fiber.Ctx) {
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(prefixQrMeta+pendingRef, string(raw), 5*time.Minute)
}
func (h *AuthHandler) loadQrMeta(pendingRef string) (qrMeta, bool) {
if h.RedisService == nil || pendingRef == "" {
return qrMeta{}, false
}
val, err := h.RedisService.Get(prefixQrMeta + pendingRef)
if err != nil || val == "" {
return qrMeta{}, false
}
var meta qrMeta
if err := json.Unmarshal([]byte(val), &meta); err != nil {
return qrMeta{}, false
}
return meta, true
}
func (h *AuthHandler) storeQrApproverSessionID(pendingRef, sessionID string) {
if h.RedisService == nil || pendingRef == "" || sessionID == "" {
return
}
_ = h.RedisService.Set(prefixQrApproverSession+pendingRef, sessionID, loginCodeExpiration)
}
func (h *AuthHandler) loadQrApproverSessionID(pendingRef string) string {
if h.RedisService == nil || pendingRef == "" {
return ""
}
val, err := h.RedisService.Get(prefixQrApproverSession + pendingRef)
if err != nil {
return ""
}
return strings.TrimSpace(val)
}
func (h *AuthHandler) writeQrAuditLog(loginID, pendingRef string, sessionToken *domain.Token, approvedSessionID string) {
if h.AuditRepo == nil || pendingRef == "" {
return
}
meta, ok := h.loadQrMeta(pendingRef)
if !ok {
meta = qrMeta{
IPAddress: "",
UserAgent: "",
}
}
if approvedSessionID == "" {
approvedSessionID = h.loadQrApproverSessionID(pendingRef)
}
sessionID := extractSessionIDFromToken(sessionToken)
details := map[string]any{
"path": "/api/v1/auth/qr/approve",
"login_id": loginID,
"pending_ref": pendingRef,
}
if sessionID != "" {
details["session_id"] = sessionID
}
if approvedSessionID != "" {
details["approved_session_id"] = approvedSessionID
}
detailsJSON, _ := json.Marshal(details)
log := &domain.AuditLog{
EventID: GenerateSecureToken(16),
Timestamp: time.Now(),
UserID: "",
SessionID: sessionID,
EventType: "POST /api/v1/auth/qr/approve",
Status: "success",
IPAddress: meta.IPAddress,
UserAgent: meta.UserAgent,
Details: string(detailsJSON),
AuthMethod: "QR",
}
_ = h.AuditRepo.Create(log)
}
func extractClientIPFromHeaders(c *fiber.Ctx) string {
if c == nil {
return ""
}
if forwarded := c.Get("X-Forwarded-For"); forwarded != "" {
parts := strings.Split(forwarded, ",")
if len(parts) > 0 {
if ip := strings.TrimSpace(parts[0]); ip != "" {
return ip
}
}
}
if realIP := strings.TrimSpace(c.Get("X-Real-IP")); realIP != "" {
return realIP
}
return c.IP()
}
func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
if h.AuditRepo == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Audit service unavailable"})
@@ -2126,6 +2326,13 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
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 {
break
@@ -2182,6 +2389,141 @@ func isAuthEventType(eventType string) bool {
return strings.Contains(normalized, " /api/v1/auth/")
}
func extractAuditPath(log domain.AuditLog) string {
if log.Details != "" {
if payload, err := parseAuditDetails(log.Details); err == nil {
if path, ok := payload["path"].(string); ok && path != "" {
return path
}
}
}
parts := strings.SplitN(log.EventType, " ", 2)
if len(parts) == 2 {
return strings.TrimSpace(parts[1])
}
return ""
}
func parseAuditDetails(details string) (map[string]any, error) {
var payload map[string]any
if details == "" {
return nil, fmt.Errorf("empty details")
}
if err := json.Unmarshal([]byte(details), &payload); err != nil {
return nil, err
}
return payload, nil
}
func extractRequestBody(details map[string]any) map[string]any {
if details == nil {
return nil
}
raw, ok := details["request_body"].(string)
if !ok || raw == "" {
return nil
}
var body map[string]any
if err := json.Unmarshal([]byte(raw), &body); err != nil {
return nil
}
return body
}
func loginIDKind(loginID string) string {
normalized := strings.TrimSpace(loginID)
if normalized == "" {
return ""
}
if strings.Contains(normalized, "@") {
return "email"
}
return "phone"
}
func deriveAuthMethod(log domain.AuditLog) string {
path := strings.ToLower(extractAuditPath(log))
if path == "" {
return ""
}
loginID := extractLoginIDFromAuditDetails(log.Details)
kind := loginIDKind(loginID)
details, _ := parseAuditDetails(log.Details)
requestBody := extractRequestBody(details)
switch {
case strings.Contains(path, "/api/v1/auth/password/login"):
if kind == "email" {
return "비밀번호(Email)"
}
if kind == "phone" {
return "비밀번호(전화번호)"
}
return "비밀번호"
case strings.Contains(path, "/api/v1/auth/enchanted-link/init"):
if requestBody != nil {
if raw, ok := requestBody["codeOnly"]; ok {
if value, ok := raw.(bool); ok && value {
if kind == "phone" {
return "코드(SMS)"
}
if kind == "email" {
return "코드(Email)"
}
return "코드"
}
}
}
if requestBody != nil {
if raw, ok := requestBody["method"].(string); ok {
method := strings.ToLower(strings.TrimSpace(raw))
if method == "sms" {
return "링크(SMS)"
}
if method == "email" {
return "링크(Email)"
}
}
}
if kind == "phone" {
return "링크(SMS)"
}
if kind == "email" {
return "링크(Email)"
}
return "링크"
case strings.Contains(path, "/api/v1/auth/magic-link/verify"):
if kind == "phone" {
return "링크(SMS)"
}
if kind == "email" {
return "링크(Email)"
}
return "링크"
case strings.Contains(path, "/api/v1/auth/login/code/verify"):
if kind == "phone" {
return "코드(SMS)"
}
if kind == "email" {
return "코드(Email)"
}
return "코드"
case strings.Contains(path, "/api/v1/auth/login/code/verify-short"):
return "코드(간편)"
case strings.Contains(path, "/api/v1/auth/verify-sms"):
return "코드(SMS)"
case strings.Contains(path, "/api/v1/auth/qr/approve"):
return "QR"
case strings.Contains(path, "/api/v1/auth/qr/init"):
return "QR"
case strings.Contains(path, "/api/v1/auth/qr/poll"):
return "QR"
default:
return ""
}
}
func buildLoginCandidates(profile *domain.UserProfileResponse) map[string]struct{} {
candidates := make(map[string]struct{})
if profile == nil {
@@ -2256,6 +2598,25 @@ func extractLoginIDFromAuditDetails(details string) string {
return ""
}
func extractSessionIDFromAuditDetails(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["session_id"]; ok {
switch value := raw.(type) {
case string:
return value
default:
return fmt.Sprint(value)
}
}
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)
@@ -2267,26 +2628,26 @@ func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, err
return id, err
}
func (h *AuthHandler) tryIssueDescopeQrSession(c *fiber.Ctx, token string) (string, error) {
func (h *AuthHandler) tryIssueDescopeQrSession(c *fiber.Ctx, token string) (*domain.Token, string, string, error) {
if !looksLikeJWT(token) || h.DescopeClient == nil {
return "", nil
return nil, "", "", nil
}
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
if err != nil || !authorized {
return "", nil
return nil, "", "", nil
}
loginID, err := h.resolveDescopeLoginID(c.Context(), userToken)
if err != nil {
return "", err
return nil, "", "", err
}
authInfo, err := h.IdpProvider.IssueSession(loginID)
if err != nil {
return "", err
return nil, "", "", err
}
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
return "", fmt.Errorf("descope issue session returned empty token")
return nil, "", "", fmt.Errorf("descope issue session returned empty token")
}
return authInfo.SessionToken.JWT, nil
return authInfo.SessionToken, loginID, userToken.ID, nil
}
func (h *AuthHandler) resolveKratosLoginID(token string) (string, error) {
@@ -2544,6 +2905,36 @@ func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string
return result.Identity.ID, result.Identity.Traits, nil
}
func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) {
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
if kratosURL == "" {
kratosURL = "http://kratos:4433"
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
if err != nil {
return "", err
}
req.Header.Set("X-Session-Token", sessionToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
}
var result struct {
ID string `json:"id"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.ID, nil
}
func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string) (string, error) {
if identityID == "" {
return "", fmt.Errorf("kratos identity id is empty")
@@ -2621,6 +3012,36 @@ func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[st
return result.Identity.ID, result.Identity.Traits, nil
}
func (h *AuthHandler) getKratosSessionIDWithCookie(cookie string) (string, error) {
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
if kratosURL == "" {
kratosURL = "http://kratos:4433"
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
if err != nil {
return "", err
}
req.Header.Set("Cookie", cookie)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
}
var result struct {
ID string `json:"id"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.ID, nil
}
func (h *AuthHandler) updateKratosIdentity(identityID string, traits map[string]interface{}) error {
kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/")
if kratosAdminURL == "" {