forked from baron/baron-sso
리다이렉트 후속 로직 업데이트
This commit is contained in:
@@ -1504,6 +1504,30 @@ func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PasswordLogin - Authenticate a user with login ID and password.
|
// PasswordLogin - Authenticate a user with login ID and password.
|
||||||
|
func logOidcRedirectSummary(source, redirectTo string) {
|
||||||
|
parsed, err := url.Parse(redirectTo)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn(
|
||||||
|
"OIDC redirect parse failed",
|
||||||
|
"source", source,
|
||||||
|
"redirectToLength", len(redirectTo),
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := parsed.Query()
|
||||||
|
slog.Info(
|
||||||
|
"OIDC redirect summary",
|
||||||
|
"source", source,
|
||||||
|
"redirectToLength", len(redirectTo),
|
||||||
|
"redirectToHost", parsed.Host,
|
||||||
|
"redirectToPath", parsed.Path,
|
||||||
|
"redirectHasLoginVerifier", query.Has("login_verifier"),
|
||||||
|
"redirectHasRedirectURI", query.Has("redirect_uri"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
|
func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
ale := logger.NewAuditLogEntry(c, "login")
|
ale := logger.NewAuditLogEntry(c, "login")
|
||||||
@@ -1586,7 +1610,7 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
|
|||||||
slog.Error("failed to accept hydra login request", "error", err)
|
slog.Error("failed to accept hydra login request", "error", err)
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
|
||||||
}
|
}
|
||||||
slog.Info("Hydra login request accepted", "redirectTo", acceptResp.RedirectTo)
|
logOidcRedirectSummary("password_login", acceptResp.RedirectTo)
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"redirectTo": acceptResp.RedirectTo,
|
"redirectTo": acceptResp.RedirectTo,
|
||||||
})
|
})
|
||||||
@@ -3841,6 +3865,7 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error {
|
|||||||
slog.Error("failed to accept hydra login request", "error", err)
|
slog.Error("failed to accept hydra login request", "error", err)
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
|
||||||
}
|
}
|
||||||
|
logOidcRedirectSummary("accept_oidc_login_request", acceptResp.RedirectTo)
|
||||||
|
|
||||||
return c.JSON(acceptResp)
|
return c.JSON(acceptResp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,10 +188,13 @@ func TestPasswordLogin_OIDC_Success(t *testing.T) {
|
|||||||
t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes))
|
t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
var got map[string]string
|
var got map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&got)
|
json.NewDecoder(resp.Body).Decode(&got)
|
||||||
if got["redirectTo"] != "http://rp/cb" {
|
if got["redirectTo"] != "http://rp/cb" {
|
||||||
t.Errorf("expected redirectTo http://rp/cb, got %s", got["redirectTo"])
|
t.Errorf("expected redirectTo http://rp/cb, got %v", got["redirectTo"])
|
||||||
|
}
|
||||||
|
if _, ok := got["sessionJwt"]; ok {
|
||||||
|
t.Errorf("expected OIDC response to omit sessionJwt, got %v", got["sessionJwt"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'package:http/http.dart' as http;
|
|||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'package:userfront/i18n.dart';
|
import 'package:userfront/i18n.dart';
|
||||||
import 'http_client.dart';
|
import 'http_client.dart';
|
||||||
import 'web_window.dart';
|
|
||||||
import 'auth_token_store.dart';
|
import 'auth_token_store.dart';
|
||||||
|
|
||||||
class AuthProxyService {
|
class AuthProxyService {
|
||||||
@@ -273,17 +272,11 @@ class AuthProxyService {
|
|||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
if (data['redirectTo'] != null && data['redirectTo'].isNotEmpty) {
|
|
||||||
webWindow.redirectTo(data['redirectTo']);
|
|
||||||
}
|
|
||||||
return data;
|
return data;
|
||||||
} else {
|
} else {
|
||||||
final errorBody = jsonDecode(response.body);
|
final errorBody = jsonDecode(response.body);
|
||||||
throw Exception(
|
throw Exception(
|
||||||
errorBody['error'] ??
|
errorBody['error'] ?? tr('err.userfront.auth_proxy.login_failed'),
|
||||||
tr(
|
|
||||||
'err.userfront.auth_proxy.login_failed',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -304,10 +297,7 @@ class AuthProxyService {
|
|||||||
} else {
|
} else {
|
||||||
final errorBody = jsonDecode(response.body);
|
final errorBody = jsonDecode(response.body);
|
||||||
throw Exception(
|
throw Exception(
|
||||||
errorBody['error'] ??
|
errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_fetch'),
|
||||||
tr(
|
|
||||||
'err.userfront.auth_proxy.consent_fetch',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,10 +323,7 @@ class AuthProxyService {
|
|||||||
} else {
|
} else {
|
||||||
final errorBody = jsonDecode(response.body);
|
final errorBody = jsonDecode(response.body);
|
||||||
throw Exception(
|
throw Exception(
|
||||||
errorBody['error'] ??
|
errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_accept'),
|
||||||
tr(
|
|
||||||
'err.userfront.auth_proxy.consent_accept',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,10 +345,7 @@ class AuthProxyService {
|
|||||||
} else {
|
} else {
|
||||||
final errorBody = jsonDecode(response.body);
|
final errorBody = jsonDecode(response.body);
|
||||||
throw Exception(
|
throw Exception(
|
||||||
errorBody['error'] ??
|
errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_reject'),
|
||||||
tr(
|
|
||||||
'err.userfront.auth_proxy.consent_reject',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -388,10 +372,7 @@ class AuthProxyService {
|
|||||||
} else {
|
} else {
|
||||||
final errorBody = jsonDecode(response.body);
|
final errorBody = jsonDecode(response.body);
|
||||||
throw Exception(
|
throw Exception(
|
||||||
errorBody['error'] ??
|
errorBody['error'] ?? tr('err.userfront.auth_proxy.oidc_accept'),
|
||||||
tr(
|
|
||||||
'err.userfront.auth_proxy.oidc_accept',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -419,9 +400,7 @@ class AuthProxyService {
|
|||||||
final errorBody = jsonDecode(response.body);
|
final errorBody = jsonDecode(response.body);
|
||||||
throw Exception(
|
throw Exception(
|
||||||
errorBody['error'] ??
|
errorBody['error'] ??
|
||||||
tr(
|
tr('err.userfront.auth_proxy.password_reset_init'),
|
||||||
'err.userfront.auth_proxy.password_reset_init',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -453,9 +432,7 @@ class AuthProxyService {
|
|||||||
final errorBody = jsonDecode(response.body);
|
final errorBody = jsonDecode(response.body);
|
||||||
throw Exception(
|
throw Exception(
|
||||||
errorBody['error'] ??
|
errorBody['error'] ??
|
||||||
tr(
|
tr('err.userfront.auth_proxy.password_reset_complete'),
|
||||||
'err.userfront.auth_proxy.password_reset_complete',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -785,9 +762,7 @@ class AuthProxyService {
|
|||||||
final errorBody = jsonDecode(response.body);
|
final errorBody = jsonDecode(response.body);
|
||||||
throw Exception(
|
throw Exception(
|
||||||
errorBody['error'] ??
|
errorBody['error'] ??
|
||||||
tr(
|
tr('err.userfront.auth_proxy.linked_app_revoke'),
|
||||||
'err.userfront.auth_proxy.linked_app_revoke',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
68
userfront/lib/core/services/oidc_redirect_guard.dart
Normal file
68
userfront/lib/core/services/oidc_redirect_guard.dart
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
class OidcRedirectCheckResult {
|
||||||
|
final Uri? uri;
|
||||||
|
final bool isValid;
|
||||||
|
final String reason;
|
||||||
|
final int length;
|
||||||
|
final String host;
|
||||||
|
final String path;
|
||||||
|
final bool hasLoginVerifier;
|
||||||
|
|
||||||
|
const OidcRedirectCheckResult({
|
||||||
|
required this.uri,
|
||||||
|
required this.isValid,
|
||||||
|
required this.reason,
|
||||||
|
required this.length,
|
||||||
|
required this.host,
|
||||||
|
required this.path,
|
||||||
|
required this.hasLoginVerifier,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
OidcRedirectCheckResult validateOidcRedirectTarget(String redirectTo) {
|
||||||
|
final trimmed = redirectTo.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
return const OidcRedirectCheckResult(
|
||||||
|
uri: null,
|
||||||
|
isValid: false,
|
||||||
|
reason: 'empty',
|
||||||
|
length: 0,
|
||||||
|
host: '',
|
||||||
|
path: '',
|
||||||
|
hasLoginVerifier: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri parsed;
|
||||||
|
try {
|
||||||
|
parsed = Uri.parse(trimmed);
|
||||||
|
} catch (_) {
|
||||||
|
return OidcRedirectCheckResult(
|
||||||
|
uri: null,
|
||||||
|
isValid: false,
|
||||||
|
reason: 'parse_error',
|
||||||
|
length: trimmed.length,
|
||||||
|
host: '',
|
||||||
|
path: '',
|
||||||
|
hasLoginVerifier: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final scheme = parsed.scheme.toLowerCase();
|
||||||
|
final isHttpScheme = scheme == 'http' || scheme == 'https';
|
||||||
|
final isAbsolute = parsed.hasScheme && parsed.host.isNotEmpty;
|
||||||
|
final isValid = isHttpScheme && isAbsolute;
|
||||||
|
|
||||||
|
final reason = isValid
|
||||||
|
? 'ok'
|
||||||
|
: (isAbsolute ? 'unsupported_scheme' : 'not_absolute');
|
||||||
|
|
||||||
|
return OidcRedirectCheckResult(
|
||||||
|
uri: isValid ? parsed : null,
|
||||||
|
isValid: isValid,
|
||||||
|
reason: reason,
|
||||||
|
length: trimmed.length,
|
||||||
|
host: parsed.host,
|
||||||
|
path: parsed.path,
|
||||||
|
hasLoginVerifier: parsed.queryParameters.containsKey('login_verifier'),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,13 +3,13 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
|
||||||
import 'package:qr_flutter/qr_flutter.dart';
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
import 'package:userfront/i18n.dart';
|
import 'package:userfront/i18n.dart';
|
||||||
import '../../../core/widgets/language_selector.dart';
|
import '../../../core/widgets/language_selector.dart';
|
||||||
import '../../../core/services/web_auth_integration.dart';
|
import '../../../core/services/web_auth_integration.dart';
|
||||||
import '../../../core/services/auth_proxy_service.dart';
|
import '../../../core/services/auth_proxy_service.dart';
|
||||||
import '../../../core/services/auth_token_store.dart';
|
import '../../../core/services/auth_token_store.dart';
|
||||||
|
import '../../../core/services/oidc_redirect_guard.dart';
|
||||||
import '../../../core/notifiers/auth_notifier.dart';
|
import '../../../core/notifiers/auth_notifier.dart';
|
||||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||||
import '../../../core/services/web_window.dart';
|
import '../../../core/services/web_window.dart';
|
||||||
@@ -65,9 +65,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
bool _verificationApproved = false;
|
bool _verificationApproved = false;
|
||||||
bool _dismissedOverlays = false;
|
bool _dismissedOverlays = false;
|
||||||
String _verificationMessage = '';
|
String _verificationMessage = '';
|
||||||
String _verificationTitle = tr(
|
String _verificationTitle = tr('ui.userfront.login.verification.title');
|
||||||
'ui.userfront.login.verification.title',
|
|
||||||
);
|
|
||||||
String _verificationPageTitle = tr(
|
String _verificationPageTitle = tr(
|
||||||
'ui.userfront.login.verification.page_title',
|
'ui.userfront.login.verification.page_title',
|
||||||
);
|
);
|
||||||
@@ -127,11 +125,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
if (!_noticeHandled && notice == 'qr_login_required') {
|
if (!_noticeHandled && notice == 'qr_login_required') {
|
||||||
_noticeHandled = true;
|
_noticeHandled = true;
|
||||||
_showInfo(
|
_showInfo(tr('msg.userfront.login.qr_login_required'));
|
||||||
tr(
|
|
||||||
'msg.userfront.login.qr_login_required',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_verificationOnly) {
|
if (!_verificationOnly) {
|
||||||
@@ -234,9 +228,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
);
|
);
|
||||||
final redirectTo = res['redirectTo'] as String?;
|
final redirectTo = res['redirectTo'] as String?;
|
||||||
if (redirectTo != null && redirectTo.isNotEmpty) {
|
if (redirectTo != null && redirectTo.isNotEmpty) {
|
||||||
debugPrint("[Auth] OIDC login accepted. Redirecting to: $redirectTo");
|
return _redirectToOidcTarget(redirectTo, source: 'accept_oidc_login');
|
||||||
webWindow.redirectTo(redirectTo);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("[Auth] OIDC login auto-accept failed: $e");
|
debugPrint("[Auth] OIDC login auto-accept failed: $e");
|
||||||
@@ -244,6 +236,23 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _redirectToOidcTarget(String redirectTo, {required String source}) {
|
||||||
|
final checked = validateOidcRedirectTarget(redirectTo);
|
||||||
|
debugPrint(
|
||||||
|
"[Auth] OIDC redirect check ($source): valid=${checked.isValid}, reason=${checked.reason}, len=${checked.length}, host=${checked.host}, path=${checked.path}, has_login_verifier=${checked.hasLoginVerifier}",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!checked.isValid || checked.uri == null) {
|
||||||
|
if (mounted) {
|
||||||
|
_showError(tr('msg.userfront.login.oidc_failed'));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
webWindow.redirectTo(checked.uri.toString());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void _resetLinkLoginState() {
|
void _resetLinkLoginState() {
|
||||||
_linkPendingRef = null;
|
_linkPendingRef = null;
|
||||||
_lastLinkLoginId = null;
|
_lastLinkLoginId = null;
|
||||||
@@ -307,9 +316,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_linkExpired = true;
|
_linkExpired = true;
|
||||||
});
|
});
|
||||||
_showInfo(
|
_showInfo(tr('msg.userfront.login.link_timeout'));
|
||||||
tr('msg.userfront.login.link_timeout'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -387,9 +394,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_qrExpired = true;
|
_qrExpired = true;
|
||||||
});
|
});
|
||||||
_showInfo(
|
_showInfo(tr('msg.userfront.login.qr_expired'));
|
||||||
tr('msg.userfront.login.qr_expired'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -440,9 +445,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_qrExpired = true;
|
_qrExpired = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
_showError(
|
_showError(tr('msg.userfront.login.qr_expired'));
|
||||||
tr('msg.userfront.login.qr_expired'),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,11 +457,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
if (token is String && token.isNotEmpty) {
|
if (token is String && token.isNotEmpty) {
|
||||||
_completeLoginFromToken(token);
|
_completeLoginFromToken(token);
|
||||||
} else {
|
} else {
|
||||||
_showError(
|
_showError(tr('msg.userfront.login.token_missing'));
|
||||||
tr(
|
|
||||||
'msg.userfront.login.token_missing',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -533,14 +532,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
Duration redirectDelay = const Duration(seconds: 2),
|
Duration redirectDelay = const Duration(seconds: 2),
|
||||||
}) {
|
}) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final resolvedTitle =
|
final resolvedTitle = title ?? tr('ui.userfront.login.verification.title');
|
||||||
title ?? tr('ui.userfront.login.verification.title');
|
|
||||||
final resolvedPageTitle =
|
final resolvedPageTitle =
|
||||||
pageTitle ??
|
pageTitle ?? tr('ui.userfront.login.verification.page_title');
|
||||||
tr('ui.userfront.login.verification.page_title');
|
|
||||||
final resolvedActionLabel =
|
final resolvedActionLabel =
|
||||||
actionLabel ??
|
actionLabel ?? tr('ui.userfront.login.verification.action_label');
|
||||||
tr('ui.userfront.login.verification.action_label');
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_verificationApproved = true;
|
_verificationApproved = true;
|
||||||
_verificationMessage = message;
|
_verificationMessage = message;
|
||||||
@@ -581,9 +577,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
_verificationMessage.isEmpty
|
_verificationMessage.isEmpty
|
||||||
? tr(
|
? tr('msg.userfront.login.verification.success')
|
||||||
'msg.userfront.login.verification.success',
|
|
||||||
)
|
|
||||||
: _verificationMessage,
|
: _verificationMessage,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(color: Colors.black54),
|
style: const TextStyle(color: Colors.black54),
|
||||||
@@ -613,9 +607,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
Future<void> _verifyToken(String token) async {
|
Future<void> _verifyToken(String token) async {
|
||||||
debugPrint("[Auth] Starting verification for token: $token");
|
debugPrint("[Auth] Starting verification for token: $token");
|
||||||
final approvedMessage = tr(
|
final approvedMessage = tr('msg.userfront.login.verification.approved');
|
||||||
'msg.userfront.login.verification.approved',
|
|
||||||
);
|
|
||||||
final localSessionMessage = tr(
|
final localSessionMessage = tr(
|
||||||
'msg.userfront.login.verification.approved_local',
|
'msg.userfront.login.verification.approved_local',
|
||||||
);
|
);
|
||||||
@@ -675,15 +667,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
debugPrint(
|
debugPrint(
|
||||||
"[Auth] Starting code verification for loginId: $sanitizedLoginId",
|
"[Auth] Starting code verification for loginId: $sanitizedLoginId",
|
||||||
);
|
);
|
||||||
final approvedMessage = tr(
|
final approvedMessage = tr('msg.userfront.login.verification.approved');
|
||||||
'msg.userfront.login.verification.approved',
|
|
||||||
);
|
|
||||||
final localSessionMessage = tr(
|
final localSessionMessage = tr(
|
||||||
'msg.userfront.login.verification.approved_local',
|
'msg.userfront.login.verification.approved_local',
|
||||||
);
|
);
|
||||||
final linkLoginMessage = tr(
|
final linkLoginMessage = tr('msg.userfront.login.link.approved');
|
||||||
'msg.userfront.login.link.approved',
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
final res = await AuthProxyService.verifyLoginCode(
|
final res = await AuthProxyService.verifyLoginCode(
|
||||||
sanitizedLoginId,
|
sanitizedLoginId,
|
||||||
@@ -721,12 +709,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_markVerificationApproved(
|
_markVerificationApproved(
|
||||||
linkLoginMessage,
|
linkLoginMessage,
|
||||||
title: tr('ui.userfront.login.link.title'),
|
title: tr('ui.userfront.login.link.title'),
|
||||||
pageTitle: tr(
|
pageTitle: tr('ui.userfront.login.link.page_title'),
|
||||||
'ui.userfront.login.link.page_title',
|
actionLabel: tr('ui.userfront.login.link.action_label'),
|
||||||
),
|
|
||||||
actionLabel: tr(
|
|
||||||
'ui.userfront.login.link.action_label',
|
|
||||||
),
|
|
||||||
actionPath: '/signin',
|
actionPath: '/signin',
|
||||||
autoRedirect: true,
|
autoRedirect: true,
|
||||||
);
|
);
|
||||||
@@ -755,9 +739,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final sanitized = shortCode.trim().toUpperCase();
|
final sanitized = shortCode.trim().toUpperCase();
|
||||||
if (sanitized.isEmpty) return;
|
if (sanitized.isEmpty) return;
|
||||||
debugPrint("[Auth] Starting short code verification for code: $sanitized");
|
debugPrint("[Auth] Starting short code verification for code: $sanitized");
|
||||||
final approvedMessage = tr(
|
final approvedMessage = tr('msg.userfront.login.verification.approved');
|
||||||
'msg.userfront.login.verification.approved',
|
|
||||||
);
|
|
||||||
final localSessionMessage = tr(
|
final localSessionMessage = tr(
|
||||||
'msg.userfront.login.verification.approved_local',
|
'msg.userfront.login.verification.approved_local',
|
||||||
);
|
);
|
||||||
@@ -829,11 +811,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final input = _passwordLoginIdController.text.trim();
|
final input = _passwordLoginIdController.text.trim();
|
||||||
final password = _passwordController.text.trim();
|
final password = _passwordController.text.trim();
|
||||||
if (input.isEmpty || password.isEmpty) {
|
if (input.isEmpty || password.isEmpty) {
|
||||||
_showError(
|
_showError(tr('msg.userfront.login.password.missing_credentials'));
|
||||||
tr(
|
|
||||||
'msg.userfront.login.password.missing_credentials',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,7 +834,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final redirectTo = res['redirectTo'] as String?;
|
final redirectTo = res['redirectTo'] as String?;
|
||||||
|
|
||||||
if (redirectTo != null && redirectTo.isNotEmpty) {
|
if (redirectTo != null && redirectTo.isNotEmpty) {
|
||||||
webWindow.redirectTo(redirectTo);
|
_redirectToOidcTarget(redirectTo, source: 'password_login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -938,12 +916,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_dismissOverlays();
|
_dismissOverlays();
|
||||||
_showInfo(
|
_showInfo(
|
||||||
isEmail
|
isEmail
|
||||||
? tr(
|
? tr('msg.userfront.login.link_sent_email')
|
||||||
'msg.userfront.login.link_sent_email',
|
: tr('msg.userfront.login.link_sent_phone'),
|
||||||
)
|
|
||||||
: tr(
|
|
||||||
'msg.userfront.login.link_sent_phone',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final initialInterval = (interval is int && interval > 0)
|
final initialInterval = (interval is int && interval > 0)
|
||||||
@@ -1009,11 +983,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
if (result['error'] == 'expired_token') {
|
if (result['error'] == 'expired_token') {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
_showError(
|
_showError(tr('msg.userfront.login.link_timeout'));
|
||||||
tr(
|
|
||||||
'msg.userfront.login.link_timeout',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1034,11 +1004,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
if (mounted && Navigator.canPop(context)) {
|
if (mounted && Navigator.canPop(context)) {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
_showError(
|
_showError(tr('msg.userfront.login.token_missing'));
|
||||||
tr(
|
|
||||||
'msg.userfront.login.token_missing',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1049,9 +1015,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
debugPrint("[Auth] Polling timed out for ref: $pendingRef");
|
debugPrint("[Auth] Polling timed out for ref: $pendingRef");
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
_showError(
|
_showError(tr('msg.userfront.login.link_timeout'));
|
||||||
tr('msg.userfront.login.link_timeout'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1124,25 +1088,23 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
);
|
);
|
||||||
final redirectTo = res['redirectTo'] as String?;
|
final redirectTo = res['redirectTo'] as String?;
|
||||||
if (redirectTo != null && redirectTo.isNotEmpty) {
|
if (redirectTo != null && redirectTo.isNotEmpty) {
|
||||||
debugPrint("[Auth] OIDC login accepted. Redirecting to: $redirectTo");
|
_redirectToOidcTarget(
|
||||||
webWindow.redirectTo(redirectTo);
|
redirectTo,
|
||||||
|
source: 'on_login_success_accept_oidc',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_showError(
|
_showError(tr('msg.userfront.login.oidc_failed'));
|
||||||
tr(
|
|
||||||
'msg.userfront.login.oidc_failed',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final uri = Uri.base;
|
final uri = Uri.base;
|
||||||
final redirectParam =
|
final redirectParam =
|
||||||
uri.queryParameters['redirect_uri'] ?? uri.queryParameters['redirect_url'];
|
uri.queryParameters['redirect_uri'] ??
|
||||||
final hasRedirectParam =
|
uri.queryParameters['redirect_url'];
|
||||||
redirectParam != null && redirectParam.isNotEmpty;
|
final hasRedirectParam = redirectParam != null && redirectParam.isNotEmpty;
|
||||||
|
|
||||||
if (WebAuthIntegration.isPopup() || hasRedirectParam) {
|
if (WebAuthIntegration.isPopup() || hasRedirectParam) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -1163,14 +1125,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text(
|
title: Text(tr('ui.userfront.login.unregistered.title')),
|
||||||
tr('ui.userfront.login.unregistered.title'),
|
content: Text(tr('msg.userfront.login.unregistered.body')),
|
||||||
),
|
|
||||||
content: Text(
|
|
||||||
tr(
|
|
||||||
'msg.userfront.login.unregistered.body',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
@@ -1182,9 +1138,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_resetLinkLoginState();
|
_resetLinkLoginState();
|
||||||
context.push('/signup');
|
context.push('/signup');
|
||||||
},
|
},
|
||||||
child: Text(
|
child: Text(tr('ui.userfront.login.unregistered.action')),
|
||||||
tr('ui.userfront.login.unregistered.action'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1249,9 +1203,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
tr(
|
tr('msg.userfront.login.dry_send'),
|
||||||
'msg.userfront.login.dry_send',
|
|
||||||
),
|
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Color(0xFF8A6D3B),
|
color: Color(0xFF8A6D3B),
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -1267,21 +1219,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
TabBar(
|
TabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
tabs: [
|
tabs: [
|
||||||
Tab(
|
Tab(text: tr('ui.userfront.login.tabs.password')),
|
||||||
text: tr(
|
Tab(text: tr('ui.userfront.login.tabs.link')),
|
||||||
'ui.userfront.login.tabs.password',
|
Tab(text: tr('ui.userfront.login.tabs.qr')),
|
||||||
),
|
|
||||||
),
|
|
||||||
Tab(
|
|
||||||
text: tr(
|
|
||||||
'ui.userfront.login.tabs.link',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Tab(
|
|
||||||
text: tr(
|
|
||||||
'ui.userfront.login.tabs.qr',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
@@ -1330,9 +1270,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
minimumSize: const Size.fromHeight(50),
|
minimumSize: const Size.fromHeight(50),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
tr(
|
tr('ui.userfront.login.action.submit'),
|
||||||
'ui.userfront.login.action.submit',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1365,16 +1303,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
minimumSize: const Size.fromHeight(50),
|
minimumSize: const Size.fromHeight(50),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
tr(
|
tr('ui.userfront.login.link.send'),
|
||||||
'ui.userfront.login.link.send',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text(
|
Text(
|
||||||
tr(
|
tr('msg.userfront.login.link.helper'),
|
||||||
'msg.userfront.login.link.helper',
|
|
||||||
),
|
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -1398,8 +1332,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
setState(_resetLinkLoginState);
|
setState(_resetLinkLoginState);
|
||||||
},
|
},
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
minimumSize:
|
minimumSize: const Size.fromHeight(
|
||||||
const Size.fromHeight(45),
|
45,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Text(tr('ui.common.refresh')),
|
child: Text(tr('ui.common.refresh')),
|
||||||
),
|
),
|
||||||
@@ -1495,8 +1430,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_verifyShortCode(prefix + digits);
|
_verifyShortCode(prefix + digits);
|
||||||
},
|
},
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
minimumSize:
|
minimumSize: const Size.fromHeight(
|
||||||
const Size.fromHeight(45),
|
45,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
tr(
|
tr(
|
||||||
@@ -1549,9 +1485,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: tr(
|
: tr('ui.common.resend'),
|
||||||
'ui.common.resend',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!_lastLinkIsEmail) ...[
|
if (!_lastLinkIsEmail) ...[
|
||||||
@@ -1627,8 +1561,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: _startQrFlow,
|
onPressed: _startQrFlow,
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
minimumSize:
|
minimumSize: const Size.fromHeight(
|
||||||
const Size.fromHeight(45),
|
45,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Text(tr('ui.common.refresh')),
|
child: Text(tr('ui.common.refresh')),
|
||||||
),
|
),
|
||||||
@@ -1679,9 +1614,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
tr(
|
tr('msg.userfront.login.qr.scan_hint'),
|
||||||
'msg.userfront.login.qr.scan_hint',
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
@@ -1691,18 +1624,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _startQrFlow,
|
onPressed: _startQrFlow,
|
||||||
child: Text(
|
child: Text(
|
||||||
tr(
|
tr('ui.userfront.login.qr.refresh'),
|
||||||
'ui.userfront.login.qr.refresh',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Text(
|
Text(
|
||||||
tr(
|
tr('msg.userfront.login.qr.load_failed'),
|
||||||
'msg.userfront.login.qr.load_failed',
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1716,18 +1645,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => context.push('/forgot-password'),
|
onPressed: () => context.push('/forgot-password'),
|
||||||
child: Text(
|
child: Text(
|
||||||
tr(
|
tr('ui.userfront.login.forgot_password'),
|
||||||
'ui.userfront.login.forgot_password',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
tr(
|
tr('msg.userfront.login.no_account'),
|
||||||
'msg.userfront.login.no_account',
|
|
||||||
),
|
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@@ -1735,11 +1660,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => context.push('/signup'),
|
onPressed: () => context.push('/signup'),
|
||||||
child: Text(
|
child: Text(tr('ui.userfront.login.signup')),
|
||||||
tr(
|
|
||||||
'ui.userfront.login.signup',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
36
userfront/test/oidc_redirect_guard_test.dart
Normal file
36
userfront/test/oidc_redirect_guard_test.dart
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:userfront/core/services/oidc_redirect_guard.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('oidc_redirect_guard', () {
|
||||||
|
test('http/https 절대 URL만 허용', () {
|
||||||
|
final ok = validateOidcRedirectTarget(
|
||||||
|
'https://sso-test.hmac.kr/oidc/oauth2/auth?client_id=devfront&login_verifier=abc',
|
||||||
|
);
|
||||||
|
expect(ok.isValid, isTrue);
|
||||||
|
expect(ok.reason, 'ok');
|
||||||
|
expect(ok.host, 'sso-test.hmac.kr');
|
||||||
|
expect(ok.path, '/oidc/oauth2/auth');
|
||||||
|
expect(ok.hasLoginVerifier, isTrue);
|
||||||
|
|
||||||
|
final relative = validateOidcRedirectTarget('/oidc/oauth2/auth');
|
||||||
|
expect(relative.isValid, isFalse);
|
||||||
|
expect(relative.reason, 'not_absolute');
|
||||||
|
|
||||||
|
final js = validateOidcRedirectTarget('javascript:alert(1)');
|
||||||
|
expect(js.isValid, isFalse);
|
||||||
|
expect(js.reason, 'not_absolute');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('빈 문자열과 파싱 실패를 차단', () {
|
||||||
|
final empty = validateOidcRedirectTarget(' ');
|
||||||
|
expect(empty.isValid, isFalse);
|
||||||
|
expect(empty.reason, 'empty');
|
||||||
|
expect(empty.length, 0);
|
||||||
|
|
||||||
|
final malformed = validateOidcRedirectTarget('https://[broken');
|
||||||
|
expect(malformed.isValid, isFalse);
|
||||||
|
expect(malformed.reason, 'parse_error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user