1
0
forked from baron/baron-sso

리다이렉트 후속 로직 업데이트

This commit is contained in:
Lectom C Han
2026-02-19 12:40:56 +09:00
parent 1a5b04d688
commit 6fd0e5c800
6 changed files with 228 additions and 200 deletions

View File

@@ -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)
} }

View File

@@ -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"])
} }
} }

View File

@@ -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 {

View 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'),
);
}

View File

@@ -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')),
), ),
@@ -1458,15 +1393,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
), ),
suffixText: suffixText:
_linkExpireSeconds > 0 _linkExpireSeconds > 0
? tr( ? tr(
'ui.userfront.login.short_code.expire_time', 'ui.userfront.login.short_code.expire_time',
params: { params: {
'time': _formatTime( 'time': _formatTime(
_linkExpireSeconds, _linkExpireSeconds,
), ),
}, },
) )
: null, : null,
), ),
maxLength: 6, maxLength: 6,
), ),
@@ -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',
),
),
), ),
], ],
), ),

View 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');
});
});
}