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.
|
||||
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 {
|
||||
startTime := time.Now()
|
||||
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)
|
||||
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{
|
||||
"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)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request")
|
||||
}
|
||||
logOidcRedirectSummary("accept_oidc_login_request", acceptResp.RedirectTo)
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
var got map[string]string
|
||||
var got map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&got)
|
||||
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:userfront/i18n.dart';
|
||||
import 'http_client.dart';
|
||||
import 'web_window.dart';
|
||||
import 'auth_token_store.dart';
|
||||
|
||||
class AuthProxyService {
|
||||
@@ -273,17 +272,11 @@ class AuthProxyService {
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body);
|
||||
if (data['redirectTo'] != null && data['redirectTo'].isNotEmpty) {
|
||||
webWindow.redirectTo(data['redirectTo']);
|
||||
}
|
||||
return data;
|
||||
} else {
|
||||
final errorBody = jsonDecode(response.body);
|
||||
throw Exception(
|
||||
errorBody['error'] ??
|
||||
tr(
|
||||
'err.userfront.auth_proxy.login_failed',
|
||||
),
|
||||
errorBody['error'] ?? tr('err.userfront.auth_proxy.login_failed'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -304,10 +297,7 @@ class AuthProxyService {
|
||||
} else {
|
||||
final errorBody = jsonDecode(response.body);
|
||||
throw Exception(
|
||||
errorBody['error'] ??
|
||||
tr(
|
||||
'err.userfront.auth_proxy.consent_fetch',
|
||||
),
|
||||
errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_fetch'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -333,10 +323,7 @@ class AuthProxyService {
|
||||
} else {
|
||||
final errorBody = jsonDecode(response.body);
|
||||
throw Exception(
|
||||
errorBody['error'] ??
|
||||
tr(
|
||||
'err.userfront.auth_proxy.consent_accept',
|
||||
),
|
||||
errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_accept'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -358,10 +345,7 @@ class AuthProxyService {
|
||||
} else {
|
||||
final errorBody = jsonDecode(response.body);
|
||||
throw Exception(
|
||||
errorBody['error'] ??
|
||||
tr(
|
||||
'err.userfront.auth_proxy.consent_reject',
|
||||
),
|
||||
errorBody['error'] ?? tr('err.userfront.auth_proxy.consent_reject'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -388,10 +372,7 @@ class AuthProxyService {
|
||||
} else {
|
||||
final errorBody = jsonDecode(response.body);
|
||||
throw Exception(
|
||||
errorBody['error'] ??
|
||||
tr(
|
||||
'err.userfront.auth_proxy.oidc_accept',
|
||||
),
|
||||
errorBody['error'] ?? tr('err.userfront.auth_proxy.oidc_accept'),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -419,9 +400,7 @@ class AuthProxyService {
|
||||
final errorBody = jsonDecode(response.body);
|
||||
throw Exception(
|
||||
errorBody['error'] ??
|
||||
tr(
|
||||
'err.userfront.auth_proxy.password_reset_init',
|
||||
),
|
||||
tr('err.userfront.auth_proxy.password_reset_init'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -453,9 +432,7 @@ class AuthProxyService {
|
||||
final errorBody = jsonDecode(response.body);
|
||||
throw Exception(
|
||||
errorBody['error'] ??
|
||||
tr(
|
||||
'err.userfront.auth_proxy.password_reset_complete',
|
||||
),
|
||||
tr('err.userfront.auth_proxy.password_reset_complete'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -785,9 +762,7 @@ class AuthProxyService {
|
||||
final errorBody = jsonDecode(response.body);
|
||||
throw Exception(
|
||||
errorBody['error'] ??
|
||||
tr(
|
||||
'err.userfront.auth_proxy.linked_app_revoke',
|
||||
),
|
||||
tr('err.userfront.auth_proxy.linked_app_revoke'),
|
||||
);
|
||||
}
|
||||
} 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_riverpod/flutter_riverpod.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:userfront/i18n.dart';
|
||||
import '../../../core/widgets/language_selector.dart';
|
||||
import '../../../core/services/web_auth_integration.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../core/services/auth_token_store.dart';
|
||||
import '../../../core/services/oidc_redirect_guard.dart';
|
||||
import '../../../core/notifiers/auth_notifier.dart';
|
||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||
import '../../../core/services/web_window.dart';
|
||||
@@ -65,9 +65,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
bool _verificationApproved = false;
|
||||
bool _dismissedOverlays = false;
|
||||
String _verificationMessage = '';
|
||||
String _verificationTitle = tr(
|
||||
'ui.userfront.login.verification.title',
|
||||
);
|
||||
String _verificationTitle = tr('ui.userfront.login.verification.title');
|
||||
String _verificationPageTitle = tr(
|
||||
'ui.userfront.login.verification.page_title',
|
||||
);
|
||||
@@ -127,11 +125,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
if (!_noticeHandled && notice == 'qr_login_required') {
|
||||
_noticeHandled = true;
|
||||
_showInfo(
|
||||
tr(
|
||||
'msg.userfront.login.qr_login_required',
|
||||
),
|
||||
);
|
||||
_showInfo(tr('msg.userfront.login.qr_login_required'));
|
||||
}
|
||||
|
||||
if (!_verificationOnly) {
|
||||
@@ -234,9 +228,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
);
|
||||
final redirectTo = res['redirectTo'] as String?;
|
||||
if (redirectTo != null && redirectTo.isNotEmpty) {
|
||||
debugPrint("[Auth] OIDC login accepted. Redirecting to: $redirectTo");
|
||||
webWindow.redirectTo(redirectTo);
|
||||
return true;
|
||||
return _redirectToOidcTarget(redirectTo, source: 'accept_oidc_login');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[Auth] OIDC login auto-accept failed: $e");
|
||||
@@ -244,6 +236,23 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
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() {
|
||||
_linkPendingRef = null;
|
||||
_lastLinkLoginId = null;
|
||||
@@ -307,9 +316,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
setState(() {
|
||||
_linkExpired = true;
|
||||
});
|
||||
_showInfo(
|
||||
tr('msg.userfront.login.link_timeout'),
|
||||
);
|
||||
_showInfo(tr('msg.userfront.login.link_timeout'));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -387,9 +394,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
setState(() {
|
||||
_qrExpired = true;
|
||||
});
|
||||
_showInfo(
|
||||
tr('msg.userfront.login.qr_expired'),
|
||||
);
|
||||
_showInfo(tr('msg.userfront.login.qr_expired'));
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -440,9 +445,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
_qrExpired = true;
|
||||
});
|
||||
}
|
||||
_showError(
|
||||
tr('msg.userfront.login.qr_expired'),
|
||||
);
|
||||
_showError(tr('msg.userfront.login.qr_expired'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -454,11 +457,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
if (token is String && token.isNotEmpty) {
|
||||
_completeLoginFromToken(token);
|
||||
} else {
|
||||
_showError(
|
||||
tr(
|
||||
'msg.userfront.login.token_missing',
|
||||
),
|
||||
);
|
||||
_showError(tr('msg.userfront.login.token_missing'));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -533,14 +532,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
Duration redirectDelay = const Duration(seconds: 2),
|
||||
}) {
|
||||
if (!mounted) return;
|
||||
final resolvedTitle =
|
||||
title ?? tr('ui.userfront.login.verification.title');
|
||||
final resolvedTitle = title ?? tr('ui.userfront.login.verification.title');
|
||||
final resolvedPageTitle =
|
||||
pageTitle ??
|
||||
tr('ui.userfront.login.verification.page_title');
|
||||
pageTitle ?? tr('ui.userfront.login.verification.page_title');
|
||||
final resolvedActionLabel =
|
||||
actionLabel ??
|
||||
tr('ui.userfront.login.verification.action_label');
|
||||
actionLabel ?? tr('ui.userfront.login.verification.action_label');
|
||||
setState(() {
|
||||
_verificationApproved = true;
|
||||
_verificationMessage = message;
|
||||
@@ -581,9 +577,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_verificationMessage.isEmpty
|
||||
? tr(
|
||||
'msg.userfront.login.verification.success',
|
||||
)
|
||||
? tr('msg.userfront.login.verification.success')
|
||||
: _verificationMessage,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.black54),
|
||||
@@ -613,9 +607,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
Future<void> _verifyToken(String token) async {
|
||||
debugPrint("[Auth] Starting verification for token: $token");
|
||||
final approvedMessage = tr(
|
||||
'msg.userfront.login.verification.approved',
|
||||
);
|
||||
final approvedMessage = tr('msg.userfront.login.verification.approved');
|
||||
final localSessionMessage = tr(
|
||||
'msg.userfront.login.verification.approved_local',
|
||||
);
|
||||
@@ -675,15 +667,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
debugPrint(
|
||||
"[Auth] Starting code verification for loginId: $sanitizedLoginId",
|
||||
);
|
||||
final approvedMessage = tr(
|
||||
'msg.userfront.login.verification.approved',
|
||||
);
|
||||
final approvedMessage = tr('msg.userfront.login.verification.approved');
|
||||
final localSessionMessage = tr(
|
||||
'msg.userfront.login.verification.approved_local',
|
||||
);
|
||||
final linkLoginMessage = tr(
|
||||
'msg.userfront.login.link.approved',
|
||||
);
|
||||
final linkLoginMessage = tr('msg.userfront.login.link.approved');
|
||||
try {
|
||||
final res = await AuthProxyService.verifyLoginCode(
|
||||
sanitizedLoginId,
|
||||
@@ -721,12 +709,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
_markVerificationApproved(
|
||||
linkLoginMessage,
|
||||
title: tr('ui.userfront.login.link.title'),
|
||||
pageTitle: tr(
|
||||
'ui.userfront.login.link.page_title',
|
||||
),
|
||||
actionLabel: tr(
|
||||
'ui.userfront.login.link.action_label',
|
||||
),
|
||||
pageTitle: tr('ui.userfront.login.link.page_title'),
|
||||
actionLabel: tr('ui.userfront.login.link.action_label'),
|
||||
actionPath: '/signin',
|
||||
autoRedirect: true,
|
||||
);
|
||||
@@ -755,9 +739,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
final sanitized = shortCode.trim().toUpperCase();
|
||||
if (sanitized.isEmpty) return;
|
||||
debugPrint("[Auth] Starting short code verification for code: $sanitized");
|
||||
final approvedMessage = tr(
|
||||
'msg.userfront.login.verification.approved',
|
||||
);
|
||||
final approvedMessage = tr('msg.userfront.login.verification.approved');
|
||||
final localSessionMessage = tr(
|
||||
'msg.userfront.login.verification.approved_local',
|
||||
);
|
||||
@@ -829,11 +811,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
final input = _passwordLoginIdController.text.trim();
|
||||
final password = _passwordController.text.trim();
|
||||
if (input.isEmpty || password.isEmpty) {
|
||||
_showError(
|
||||
tr(
|
||||
'msg.userfront.login.password.missing_credentials',
|
||||
),
|
||||
);
|
||||
_showError(tr('msg.userfront.login.password.missing_credentials'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -856,7 +834,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
final redirectTo = res['redirectTo'] as String?;
|
||||
|
||||
if (redirectTo != null && redirectTo.isNotEmpty) {
|
||||
webWindow.redirectTo(redirectTo);
|
||||
_redirectToOidcTarget(redirectTo, source: 'password_login');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -938,12 +916,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
_dismissOverlays();
|
||||
_showInfo(
|
||||
isEmail
|
||||
? tr(
|
||||
'msg.userfront.login.link_sent_email',
|
||||
)
|
||||
: tr(
|
||||
'msg.userfront.login.link_sent_phone',
|
||||
),
|
||||
? tr('msg.userfront.login.link_sent_email')
|
||||
: tr('msg.userfront.login.link_sent_phone'),
|
||||
);
|
||||
|
||||
final initialInterval = (interval is int && interval > 0)
|
||||
@@ -1009,11 +983,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
if (result['error'] == 'expired_token') {
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
_showError(
|
||||
tr(
|
||||
'msg.userfront.login.link_timeout',
|
||||
),
|
||||
);
|
||||
_showError(tr('msg.userfront.login.link_timeout'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1034,11 +1004,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
if (mounted && Navigator.canPop(context)) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
_showError(
|
||||
tr(
|
||||
'msg.userfront.login.token_missing',
|
||||
),
|
||||
);
|
||||
_showError(tr('msg.userfront.login.token_missing'));
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1049,9 +1015,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
if (mounted) {
|
||||
debugPrint("[Auth] Polling timed out for ref: $pendingRef");
|
||||
Navigator.of(context).pop();
|
||||
_showError(
|
||||
tr('msg.userfront.login.link_timeout'),
|
||||
);
|
||||
_showError(tr('msg.userfront.login.link_timeout'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1124,25 +1088,23 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
);
|
||||
final redirectTo = res['redirectTo'] as String?;
|
||||
if (redirectTo != null && redirectTo.isNotEmpty) {
|
||||
debugPrint("[Auth] OIDC login accepted. Redirecting to: $redirectTo");
|
||||
webWindow.redirectTo(redirectTo);
|
||||
_redirectToOidcTarget(
|
||||
redirectTo,
|
||||
source: 'on_login_success_accept_oidc',
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
_showError(
|
||||
tr(
|
||||
'msg.userfront.login.oidc_failed',
|
||||
),
|
||||
);
|
||||
_showError(tr('msg.userfront.login.oidc_failed'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final uri = Uri.base;
|
||||
final redirectParam =
|
||||
uri.queryParameters['redirect_uri'] ?? uri.queryParameters['redirect_url'];
|
||||
final hasRedirectParam =
|
||||
redirectParam != null && redirectParam.isNotEmpty;
|
||||
uri.queryParameters['redirect_uri'] ??
|
||||
uri.queryParameters['redirect_url'];
|
||||
final hasRedirectParam = redirectParam != null && redirectParam.isNotEmpty;
|
||||
|
||||
if (WebAuthIntegration.isPopup() || hasRedirectParam) {
|
||||
debugPrint(
|
||||
@@ -1163,14 +1125,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(
|
||||
tr('ui.userfront.login.unregistered.title'),
|
||||
),
|
||||
content: Text(
|
||||
tr(
|
||||
'msg.userfront.login.unregistered.body',
|
||||
),
|
||||
),
|
||||
title: Text(tr('ui.userfront.login.unregistered.title')),
|
||||
content: Text(tr('msg.userfront.login.unregistered.body')),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
@@ -1182,9 +1138,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
_resetLinkLoginState();
|
||||
context.push('/signup');
|
||||
},
|
||||
child: Text(
|
||||
tr('ui.userfront.login.unregistered.action'),
|
||||
),
|
||||
child: Text(tr('ui.userfront.login.unregistered.action')),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1249,9 +1203,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
tr(
|
||||
'msg.userfront.login.dry_send',
|
||||
),
|
||||
tr('msg.userfront.login.dry_send'),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF8A6D3B),
|
||||
fontSize: 12,
|
||||
@@ -1267,21 +1219,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
TabBar(
|
||||
controller: _tabController,
|
||||
tabs: [
|
||||
Tab(
|
||||
text: tr(
|
||||
'ui.userfront.login.tabs.password',
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
text: tr(
|
||||
'ui.userfront.login.tabs.link',
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
text: tr(
|
||||
'ui.userfront.login.tabs.qr',
|
||||
),
|
||||
),
|
||||
Tab(text: tr('ui.userfront.login.tabs.password')),
|
||||
Tab(text: tr('ui.userfront.login.tabs.link')),
|
||||
Tab(text: tr('ui.userfront.login.tabs.qr')),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
@@ -1330,9 +1270,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
child: Text(
|
||||
tr(
|
||||
'ui.userfront.login.action.submit',
|
||||
),
|
||||
tr('ui.userfront.login.action.submit'),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1365,16 +1303,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
child: Text(
|
||||
tr(
|
||||
'ui.userfront.login.link.send',
|
||||
),
|
||||
tr('ui.userfront.login.link.send'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.login.link.helper',
|
||||
),
|
||||
tr('msg.userfront.login.link.helper'),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
@@ -1398,8 +1332,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
setState(_resetLinkLoginState);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize:
|
||||
const Size.fromHeight(45),
|
||||
minimumSize: const Size.fromHeight(
|
||||
45,
|
||||
),
|
||||
),
|
||||
child: Text(tr('ui.common.refresh')),
|
||||
),
|
||||
@@ -1458,15 +1393,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
),
|
||||
suffixText:
|
||||
_linkExpireSeconds > 0
|
||||
? tr(
|
||||
'ui.userfront.login.short_code.expire_time',
|
||||
params: {
|
||||
'time': _formatTime(
|
||||
_linkExpireSeconds,
|
||||
),
|
||||
},
|
||||
)
|
||||
: null,
|
||||
? tr(
|
||||
'ui.userfront.login.short_code.expire_time',
|
||||
params: {
|
||||
'time': _formatTime(
|
||||
_linkExpireSeconds,
|
||||
),
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
maxLength: 6,
|
||||
),
|
||||
@@ -1495,8 +1430,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
_verifyShortCode(prefix + digits);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize:
|
||||
const Size.fromHeight(45),
|
||||
minimumSize: const Size.fromHeight(
|
||||
45,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
tr(
|
||||
@@ -1549,9 +1485,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
),
|
||||
},
|
||||
)
|
||||
: tr(
|
||||
'ui.common.resend',
|
||||
),
|
||||
: tr('ui.common.resend'),
|
||||
),
|
||||
),
|
||||
if (!_lastLinkIsEmail) ...[
|
||||
@@ -1627,8 +1561,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
FilledButton(
|
||||
onPressed: _startQrFlow,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize:
|
||||
const Size.fromHeight(45),
|
||||
minimumSize: const Size.fromHeight(
|
||||
45,
|
||||
),
|
||||
),
|
||||
child: Text(tr('ui.common.refresh')),
|
||||
),
|
||||
@@ -1679,9 +1614,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.login.qr.scan_hint',
|
||||
),
|
||||
tr('msg.userfront.login.qr.scan_hint'),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
@@ -1691,18 +1624,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
TextButton(
|
||||
onPressed: _startQrFlow,
|
||||
child: Text(
|
||||
tr(
|
||||
'ui.userfront.login.qr.refresh',
|
||||
),
|
||||
tr('ui.userfront.login.qr.refresh'),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.login.qr.load_failed',
|
||||
),
|
||||
tr('msg.userfront.login.qr.load_failed'),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
@@ -1716,18 +1645,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
TextButton(
|
||||
onPressed: () => context.push('/forgot-password'),
|
||||
child: Text(
|
||||
tr(
|
||||
'ui.userfront.login.forgot_password',
|
||||
),
|
||||
tr('ui.userfront.login.forgot_password'),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.login.no_account',
|
||||
),
|
||||
tr('msg.userfront.login.no_account'),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 14,
|
||||
@@ -1735,11 +1660,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.push('/signup'),
|
||||
child: Text(
|
||||
tr(
|
||||
'ui.userfront.login.signup',
|
||||
),
|
||||
),
|
||||
child: Text(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