forked from baron/baron-sso
fix: verify-only magic link approval flow
This commit is contained in:
@@ -970,12 +970,18 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
token:
|
token:
|
||||||
type: string
|
type: string
|
||||||
|
verifyOnly:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
MagicLinkVerifyResponse:
|
MagicLinkVerifyResponse:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
token:
|
token:
|
||||||
type: string
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
pendingRef:
|
||||||
|
type: string
|
||||||
message:
|
message:
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ type EnchantedLinkPollResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MagicLinkVerifyRequest struct {
|
type MagicLinkVerifyRequest struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
|
VerifyOnly bool `json:"verifyOnly,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type QRInitResponse struct {
|
type QRInitResponse struct {
|
||||||
|
|||||||
@@ -774,6 +774,53 @@ 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"})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"sessionJwt": authInfo.SessionToken.JWT,
|
||||||
|
"status": "ok",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"error": "authorization_pending",
|
"error": "authorization_pending",
|
||||||
"interval": int(minPollInterval.Seconds()),
|
"interval": int(minPollInterval.Seconds()),
|
||||||
@@ -804,6 +851,26 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
slog.Info("[Verify] Token valid", "loginID", loginID, "pendingRef", pendingRef)
|
slog.Info("[Verify] Token valid", "loginID", loginID, "pendingRef", pendingRef)
|
||||||
|
|
||||||
|
if req.VerifyOnly {
|
||||||
|
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"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 승인 전용: 세션 발급 없이 승인 상태만 기록
|
||||||
|
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 {
|
if h.IdpProvider == nil {
|
||||||
slog.Error("[Verify] IDP Provider is nil")
|
slog.Error("[Verify] IDP Provider is nil")
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ class AuthProxyService {
|
|||||||
throw Exception('Polling failed: ${response.body}');
|
throw Exception('Polling failed: ${response.body}');
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> verifyMagicLink(String token) async {
|
static Future<Map<String, dynamic>> verifyMagicLink(String token, {bool verifyOnly = false}) async {
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/magic-link/verify');
|
final url = Uri.parse('$_baseUrl/api/v1/auth/magic-link/verify');
|
||||||
|
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
@@ -133,6 +133,7 @@ class AuthProxyService {
|
|||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: jsonEncode({
|
body: jsonEncode({
|
||||||
'token': token,
|
'token': token,
|
||||||
|
'verifyOnly': verifyOnly,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -430,11 +430,22 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
debugPrint("[Auth] Starting verification for token: $token");
|
debugPrint("[Auth] Starting verification for token: $token");
|
||||||
try {
|
try {
|
||||||
// Use Backend to verify the token (Backend-Driven Flow)
|
// Use Backend to verify the token (Backend-Driven Flow)
|
||||||
final res = await AuthProxyService.verifyMagicLink(token);
|
final res = await AuthProxyService.verifyMagicLink(
|
||||||
|
token,
|
||||||
|
verifyOnly: _verificationOnly,
|
||||||
|
);
|
||||||
debugPrint("[Auth] Verification successful for token: $token");
|
debugPrint("[Auth] Verification successful for token: $token");
|
||||||
final jwt = res['token'] ?? res['sessionJwt'];
|
final jwt = res['token'] ?? res['sessionJwt'];
|
||||||
|
final status = res['status']?.toString();
|
||||||
final hasLocalSession = await _hasValidLocalSession();
|
final hasLocalSession = await _hasValidLocalSession();
|
||||||
|
|
||||||
|
if (status == 'approved' || (jwt == null && _verificationOnly)) {
|
||||||
|
if (mounted) {
|
||||||
|
_markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (jwt is String && jwt.isNotEmpty) {
|
if (jwt is String && jwt.isNotEmpty) {
|
||||||
if (hasLocalSession) {
|
if (hasLocalSession) {
|
||||||
_markVerificationApproved(
|
_markVerificationApproved(
|
||||||
@@ -442,14 +453,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_markVerificationApproved(
|
_markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
|
||||||
"링크로 로그인 되었습니다. 잠시 후 로그인 화면으로 이동합니다.",
|
|
||||||
title: '링크 로그인 완료',
|
|
||||||
pageTitle: '링크 로그인',
|
|
||||||
actionLabel: '로그인 화면으로 이동',
|
|
||||||
actionPath: '/signin',
|
|
||||||
autoRedirect: true,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,8 +496,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_markVerificationApproved(
|
if (_verificationOnly) {
|
||||||
"링크로 로그인 되었습니다. 잠시 후 로그인 화면으로 이동합니다.",
|
_markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_markVerificationApproved("링크로 로그인 되었습니다. 잠시 후 로그인 화면으로 이동합니다.",
|
||||||
title: '링크 로그인 완료',
|
title: '링크 로그인 완료',
|
||||||
pageTitle: '링크 로그인',
|
pageTitle: '링크 로그인',
|
||||||
actionLabel: '로그인 화면으로 이동',
|
actionLabel: '로그인 화면으로 이동',
|
||||||
@@ -539,6 +546,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (_verificationOnly) {
|
||||||
|
_markVerificationApproved("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
_completeLoginFromToken(jwt, provider: res['provider'] as String?);
|
_completeLoginFromToken(jwt, provider: res['provider'] as String?);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user