forked from baron/baron-sso
audit 로그 개선. kratos 코드발급 링크로 전송까지 진행 완료 #104
This commit is contained in:
@@ -29,7 +29,6 @@ class AuditService {
|
||||
'event_type': eventType,
|
||||
'status': status,
|
||||
'details': details,
|
||||
'timestamp': DateTime.now().toIso8601String(),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'http_client.dart';
|
||||
|
||||
class AuthProxyService {
|
||||
static String _envOrDefault(String key, String fallback) {
|
||||
@@ -22,6 +23,24 @@ class AuthProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> checkCookieSession() async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me');
|
||||
final client = createHttpClient(withCredentials: true);
|
||||
try {
|
||||
final response = await client.get(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
}
|
||||
throw Exception('Failed to load profile: ${response.body}');
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> initEnchantedLink(String loginId, {String? method}) async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init');
|
||||
final userfrontUrl = _envOrDefault('USERFRONT_URL', 'http://sso.hmac.kr');
|
||||
@@ -60,9 +79,11 @@ class AuthProxyService {
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
} else {
|
||||
throw Exception('Polling failed: ${response.body}');
|
||||
}
|
||||
if (response.statusCode == 400) {
|
||||
return jsonDecode(response.body);
|
||||
}
|
||||
throw Exception('Polling failed: ${response.body}');
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> verifyMagicLink(String token) async {
|
||||
@@ -83,6 +104,25 @@ class AuthProxyService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> verifyLoginCode(String loginId, String code) async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/auth/login/code/verify');
|
||||
|
||||
final response = await http.post(
|
||||
url,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonEncode({
|
||||
'loginId': loginId,
|
||||
'code': code,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
} else {
|
||||
throw Exception('Verification failed: ${response.body}');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> loginWithPassword(String loginId, String password) async {
|
||||
final url = Uri.parse('$_baseUrl/api/v1/auth/password/login');
|
||||
|
||||
@@ -205,9 +245,11 @@ class AuthProxyService {
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return jsonDecode(response.body);
|
||||
} else {
|
||||
throw Exception('QR Polling failed: ${response.body}');
|
||||
}
|
||||
if (response.statusCode == 400) {
|
||||
return jsonDecode(response.body);
|
||||
}
|
||||
throw Exception('QR Polling failed: ${response.body}');
|
||||
}
|
||||
|
||||
static Future<void> approveQrLogin(String pendingRef, String token) async {
|
||||
|
||||
32
userfront/lib/core/services/auth_token_store.dart
Normal file
32
userfront/lib/core/services/auth_token_store.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'auth_token_store_stub.dart'
|
||||
if (dart.library.html) 'auth_token_store_web.dart';
|
||||
|
||||
class AuthTokenStore {
|
||||
static String? getToken() => authTokenStore.getToken();
|
||||
|
||||
static String? getProvider() => authTokenStore.getProvider();
|
||||
|
||||
static bool usesCookie() => authTokenStore.usesCookie();
|
||||
|
||||
static void setToken(String token, {String? provider}) {
|
||||
authTokenStore.setToken(token, provider: provider);
|
||||
}
|
||||
|
||||
static void setCookieMode({String? provider}) {
|
||||
authTokenStore.setCookieMode(provider: provider);
|
||||
}
|
||||
|
||||
static String? getPendingProvider() => authTokenStore.getPendingProvider();
|
||||
|
||||
static void setPendingProvider(String? provider) {
|
||||
authTokenStore.setPendingProvider(provider);
|
||||
}
|
||||
|
||||
static void clearPendingProvider() {
|
||||
authTokenStore.setPendingProvider(null);
|
||||
}
|
||||
|
||||
static void clear() {
|
||||
authTokenStore.clear();
|
||||
}
|
||||
}
|
||||
41
userfront/lib/core/services/auth_token_store_stub.dart
Normal file
41
userfront/lib/core/services/auth_token_store_stub.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
class AuthTokenStore {
|
||||
String? _token;
|
||||
String? _provider;
|
||||
bool _cookieMode = false;
|
||||
String? _pendingProvider;
|
||||
|
||||
String? getToken() => _token;
|
||||
|
||||
String? getProvider() => _provider;
|
||||
|
||||
bool usesCookie() => _cookieMode;
|
||||
|
||||
void setToken(String token, {String? provider}) {
|
||||
_token = token;
|
||||
_cookieMode = false;
|
||||
_provider = provider;
|
||||
}
|
||||
|
||||
void setCookieMode({String? provider}) {
|
||||
_cookieMode = true;
|
||||
_token = null;
|
||||
if (provider != null) {
|
||||
_provider = provider;
|
||||
}
|
||||
}
|
||||
|
||||
String? getPendingProvider() => _pendingProvider;
|
||||
|
||||
void setPendingProvider(String? provider) {
|
||||
_pendingProvider = provider;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_token = null;
|
||||
_provider = null;
|
||||
_cookieMode = false;
|
||||
_pendingProvider = null;
|
||||
}
|
||||
}
|
||||
|
||||
final authTokenStore = AuthTokenStore();
|
||||
49
userfront/lib/core/services/auth_token_store_web.dart
Normal file
49
userfront/lib/core/services/auth_token_store_web.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import 'dart:html' as html;
|
||||
|
||||
class AuthTokenStore {
|
||||
static const _tokenKey = 'baron_auth_token';
|
||||
static const _providerKey = 'baron_auth_provider';
|
||||
static const _cookieModeKey = 'baron_auth_cookie_mode';
|
||||
static const _pendingProviderKey = 'baron_auth_pending_provider';
|
||||
|
||||
String? getToken() => html.window.localStorage[_tokenKey];
|
||||
|
||||
String? getProvider() => html.window.localStorage[_providerKey];
|
||||
|
||||
bool usesCookie() => html.window.localStorage[_cookieModeKey] == '1';
|
||||
|
||||
void setToken(String token, {String? provider}) {
|
||||
html.window.localStorage[_tokenKey] = token;
|
||||
html.window.localStorage.remove(_cookieModeKey);
|
||||
if (provider != null) {
|
||||
html.window.localStorage[_providerKey] = provider;
|
||||
}
|
||||
}
|
||||
|
||||
void setCookieMode({String? provider}) {
|
||||
html.window.localStorage[_cookieModeKey] = '1';
|
||||
html.window.localStorage.remove(_tokenKey);
|
||||
if (provider != null) {
|
||||
html.window.localStorage[_providerKey] = provider;
|
||||
}
|
||||
}
|
||||
|
||||
String? getPendingProvider() => html.window.localStorage[_pendingProviderKey];
|
||||
|
||||
void setPendingProvider(String? provider) {
|
||||
if (provider == null || provider.isEmpty) {
|
||||
html.window.localStorage.remove(_pendingProviderKey);
|
||||
return;
|
||||
}
|
||||
html.window.localStorage[_pendingProviderKey] = provider;
|
||||
}
|
||||
|
||||
void clear() {
|
||||
html.window.localStorage.remove(_tokenKey);
|
||||
html.window.localStorage.remove(_providerKey);
|
||||
html.window.localStorage.remove(_cookieModeKey);
|
||||
html.window.localStorage.remove(_pendingProviderKey);
|
||||
}
|
||||
}
|
||||
|
||||
final authTokenStore = AuthTokenStore();
|
||||
7
userfront/lib/core/services/http_client.dart
Normal file
7
userfront/lib/core/services/http_client.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'http_client_stub.dart'
|
||||
if (dart.library.html) 'http_client_web.dart';
|
||||
|
||||
http.Client createHttpClient({bool withCredentials = false}) {
|
||||
return httpClientFactory.create(withCredentials: withCredentials);
|
||||
}
|
||||
9
userfront/lib/core/services/http_client_stub.dart
Normal file
9
userfront/lib/core/services/http_client_stub.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class HttpClientFactory {
|
||||
http.Client create({bool withCredentials = false}) {
|
||||
return http.Client();
|
||||
}
|
||||
}
|
||||
|
||||
final httpClientFactory = HttpClientFactory();
|
||||
12
userfront/lib/core/services/http_client_web.dart
Normal file
12
userfront/lib/core/services/http_client_web.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:http/browser_client.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class HttpClientFactory {
|
||||
http.Client create({bool withCredentials = false}) {
|
||||
final client = BrowserClient();
|
||||
client.withCredentials = withCredentials;
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
final httpClientFactory = HttpClientFactory();
|
||||
@@ -8,9 +8,9 @@ import 'package:descope/descope.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import '../../../core/services/audit_service.dart';
|
||||
import '../../../core/services/web_auth_integration.dart';
|
||||
import '../../../core/services/auth_proxy_service.dart';
|
||||
import '../../../core/services/auth_token_store.dart';
|
||||
import '../../../core/notifiers/auth_notifier.dart';
|
||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||
|
||||
@@ -33,10 +33,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
// QR Login Variables
|
||||
String? _qrImageBase64;
|
||||
String? _qrPendingRef;
|
||||
String? _qrUserCode;
|
||||
bool _isQrLoading = false;
|
||||
Timer? _qrPollingTimer;
|
||||
int _qrRemainingSeconds = 0;
|
||||
Timer? _qrCountdownTimer;
|
||||
int _qrPollIntervalMs = 2000;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -47,22 +49,58 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
// Check for tokens (Path Parameter or Legacy Query Parameter)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (widget.verificationToken != null) {
|
||||
final uri = Uri.base;
|
||||
final loginIdParam = uri.queryParameters['loginId'];
|
||||
final codeParam = uri.queryParameters['code'];
|
||||
if (loginIdParam != null && codeParam != null) {
|
||||
_verifyLoginCode(loginIdParam, codeParam);
|
||||
} else if (widget.verificationToken != null) {
|
||||
_verifyToken(widget.verificationToken!);
|
||||
} else {
|
||||
final uri = Uri.base;
|
||||
if (uri.queryParameters.containsKey('t')) {
|
||||
_verifyToken(uri.queryParameters['t']!);
|
||||
}
|
||||
} else if (uri.queryParameters.containsKey('t')) {
|
||||
_verifyToken(uri.queryParameters['t']!);
|
||||
}
|
||||
|
||||
final uri = Uri.base;
|
||||
_tryCookieSession();
|
||||
|
||||
if (uri.queryParameters.containsKey('redirect_url')) {
|
||||
_redirectUrl = uri.queryParameters['redirect_url'];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _tryCookieSession({bool silent = true}) async {
|
||||
if (AuthTokenStore.getToken() != null) {
|
||||
return;
|
||||
}
|
||||
final pendingProvider = AuthTokenStore.getPendingProvider();
|
||||
final provider = pendingProvider ?? AuthTokenStore.getProvider();
|
||||
if (provider == null || !provider.toLowerCase().contains('ory')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await AuthProxyService.checkCookieSession();
|
||||
AuthTokenStore.setCookieMode(provider: provider);
|
||||
AuthTokenStore.clearPendingProvider();
|
||||
if (mounted) {
|
||||
await ref.read(profileProvider.notifier).loadProfile();
|
||||
_onCookieLoginSuccess(provider);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!silent) {
|
||||
_showError("로그인 확인 실패: ${e.toString().replaceFirst("Exception: ", "")}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onCookieLoginSuccess(String provider) {
|
||||
debugPrint("[Auth] Cookie-based login success. Provider: $provider");
|
||||
AuthNotifier.instance.notify();
|
||||
if (mounted) {
|
||||
context.go('/');
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to decode JWT and get loginId
|
||||
String _getLoginIdFromJwt(String jwt) {
|
||||
try {
|
||||
@@ -107,6 +145,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
setState(() {
|
||||
_isQrLoading = true;
|
||||
_qrImageBase64 = null;
|
||||
_qrUserCode = null;
|
||||
_qrRemainingSeconds = 0;
|
||||
});
|
||||
|
||||
@@ -117,6 +156,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
_qrImageBase64 = res['qrCode'];
|
||||
_qrPendingRef = res['pendingRef'];
|
||||
_qrRemainingSeconds = res['expiresIn'] ?? 300;
|
||||
_qrUserCode = res['userCode']?.toString();
|
||||
final interval = res['interval'];
|
||||
if (interval is int && interval > 0) {
|
||||
_qrPollIntervalMs = interval * 1000;
|
||||
} else {
|
||||
_qrPollIntervalMs = 2000;
|
||||
}
|
||||
_isQrLoading = false;
|
||||
});
|
||||
_startQrPolling();
|
||||
@@ -144,7 +190,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
void _startQrPolling() {
|
||||
_qrPollingTimer?.cancel();
|
||||
_qrPollingTimer = Timer.periodic(const Duration(milliseconds: 1500), (timer) async {
|
||||
_qrPollingTimer = Timer.periodic(Duration(milliseconds: _qrPollIntervalMs), (timer) async {
|
||||
if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) {
|
||||
timer.cancel();
|
||||
return;
|
||||
@@ -152,6 +198,33 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
try {
|
||||
final res = await AuthProxyService.pollQrStatus(_qrPendingRef!);
|
||||
if (res['error'] == 'slow_down') {
|
||||
final interval = res['interval'];
|
||||
if (interval is int && interval > 0) {
|
||||
final nextIntervalMs = interval * 1000;
|
||||
if (nextIntervalMs != _qrPollIntervalMs) {
|
||||
_qrPollIntervalMs = nextIntervalMs;
|
||||
timer.cancel();
|
||||
_startQrPolling();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
_qrPollIntervalMs += 500;
|
||||
timer.cancel();
|
||||
_startQrPolling();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (res['error'] == 'authorization_pending') {
|
||||
return;
|
||||
}
|
||||
if (res['error'] == 'expired_token') {
|
||||
timer.cancel();
|
||||
_qrCountdownTimer?.cancel();
|
||||
_showError("QR 세션이 만료되었습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (res['status'] == 'ok' && res['sessionJwt'] != null) {
|
||||
timer.cancel();
|
||||
_qrCountdownTimer?.cancel();
|
||||
@@ -233,6 +306,34 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _verifyLoginCode(String loginId, String code) async {
|
||||
final sanitizedLoginId = loginId.replaceAll(' ', '+');
|
||||
debugPrint("[Auth] Starting code verification for loginId: $sanitizedLoginId");
|
||||
try {
|
||||
final res = await AuthProxyService.verifyLoginCode(sanitizedLoginId, code);
|
||||
final jwt = res['sessionJwt'] ?? res['token'];
|
||||
debugPrint("[Auth] Code verification successful for loginId: $sanitizedLoginId");
|
||||
|
||||
if (jwt != null && mounted) {
|
||||
final isJwt = (jwt as String).split('.').length == 3;
|
||||
if (isJwt) {
|
||||
final displayName = _getLoginIdFromJwt(jwt);
|
||||
final dummyUser = DescopeUser(
|
||||
'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [],
|
||||
);
|
||||
final session = DescopeSession.fromJwt(jwt, jwt, dummyUser);
|
||||
Descope.sessionManager.manageSession(session);
|
||||
}
|
||||
_onLoginSuccess(jwt, provider: res['provider'] as String?);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e");
|
||||
if (mounted) {
|
||||
_showError("Verification failed: $e");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopQrPolling();
|
||||
@@ -271,9 +372,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
try {
|
||||
final res = await AuthProxyService.loginWithPassword(loginId, password);
|
||||
final jwt = res['sessionJwt'];
|
||||
final provider = res['provider'] as String?;
|
||||
if (jwt != null && mounted) {
|
||||
Navigator.of(context).pop(); // 로딩 닫기
|
||||
_onLoginSuccess(jwt);
|
||||
_onLoginSuccess(jwt, provider: provider);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) Navigator.of(context).pop(); // 로딩 닫기
|
||||
@@ -326,11 +428,42 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
// 1. Init via Backend API
|
||||
final initResponse = await AuthProxyService.initEnchantedLink(loginId);
|
||||
final pendingRef = initResponse['pendingRef'];
|
||||
debugPrint("[Auth] Link Sent. PendingRef: $pendingRef");
|
||||
final mode = (initResponse['mode'] ?? '').toString();
|
||||
final provider = (initResponse['provider'] ?? '').toString();
|
||||
final interval = initResponse['interval'];
|
||||
debugPrint("[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider");
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(); // Close Loading
|
||||
|
||||
if (mode == 'link' || provider.toLowerCase().contains('ory')) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(isEmail ? "이메일 전송됨" : "SMS 전송됨"),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(isEmail
|
||||
? "입력하신 이메일로 로그인 링크를 보냈습니다."
|
||||
: "입력하신 번호로 로그인 링크를 보냈습니다."),
|
||||
const SizedBox(height: 12),
|
||||
const Text("메일/문자 링크를 열면 이 탭에서 자동으로 로그인됩니다."),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text("닫기"),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
@@ -339,9 +472,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(isEmail
|
||||
? "입력하신 이메일로 로그인 링크를 보냈습니다."
|
||||
: "입력하신 번호로 로그인 링크를 보냈습니다."),
|
||||
Text(isEmail
|
||||
? "입력하신 이메일로 로그인 링크를 보냈습니다."
|
||||
: "입력하신 번호로 로그인 링크를 보냈습니다."),
|
||||
const SizedBox(height: 16),
|
||||
const LinearProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
@@ -349,8 +482,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
onPressed: () {
|
||||
debugPrint("[Auth] Polling canceled by user");
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text("취소")
|
||||
},
|
||||
child: const Text("취소"),
|
||||
)
|
||||
],
|
||||
),
|
||||
@@ -358,7 +491,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
);
|
||||
|
||||
// 2. Poll Backend manually
|
||||
_pollForSession(pendingRef);
|
||||
final initialInterval = (interval is int && interval > 0)
|
||||
? Duration(seconds: interval)
|
||||
: const Duration(seconds: 2);
|
||||
_pollForSession(pendingRef, initialInterval: initialInterval);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[Auth] Initialization failed: $e");
|
||||
@@ -371,18 +507,39 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pollForSession(String pendingRef) async {
|
||||
Future<void> _pollForSession(String pendingRef, {Duration? initialInterval}) async {
|
||||
int attempts = 0;
|
||||
const maxAttempts = 60; // 2 minutes
|
||||
var pollInterval = initialInterval ?? const Duration(seconds: 2);
|
||||
debugPrint("[Auth] Starting poll for ref: $pendingRef");
|
||||
|
||||
while (attempts < maxAttempts && mounted) {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
await Future.delayed(pollInterval);
|
||||
attempts++;
|
||||
|
||||
try {
|
||||
final result = await AuthProxyService.pollEnchantedLink(pendingRef);
|
||||
|
||||
if (result['error'] == 'slow_down') {
|
||||
final interval = result['interval'];
|
||||
if (interval is int && interval > 0) {
|
||||
pollInterval = Duration(seconds: interval);
|
||||
} else {
|
||||
pollInterval += const Duration(seconds: 1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (result['error'] == 'authorization_pending') {
|
||||
continue;
|
||||
}
|
||||
if (result['error'] == 'expired_token') {
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(); // Close Polling Dialog
|
||||
_showError("Login timed out.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (result['status'] == 'ok') {
|
||||
final jwt = result['sessionJwt'];
|
||||
if (jwt != null) {
|
||||
@@ -452,23 +609,31 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
}
|
||||
}
|
||||
|
||||
void _onLoginSuccess(String token) async {
|
||||
void _onLoginSuccess(String token, {String? provider}) async {
|
||||
if (!mounted) return;
|
||||
|
||||
_logTokenDetails(token);
|
||||
|
||||
final userId = _getUserIdFromJwt(token);
|
||||
final providerName = provider ?? AuthTokenStore.getProvider();
|
||||
final isJwt = token.split('.').length == 3;
|
||||
final isOry = (providerName ?? '').toLowerCase().contains('ory') || !isJwt;
|
||||
|
||||
AuthTokenStore.setToken(token, provider: providerName);
|
||||
AuthTokenStore.clearPendingProvider();
|
||||
|
||||
// [New] 로그인 성공 직후 백엔드에서 전체 프로필 정보를 가져와 세션 업데이트
|
||||
try {
|
||||
// 임시 세션 생성 (API 호출을 위해)
|
||||
final tempUser = DescopeUser(userId, [], 0, 'User', null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], []);
|
||||
final tempSession = DescopeSession.fromJwt(token, token, tempUser);
|
||||
Descope.sessionManager.manageSession(tempSession);
|
||||
if (!isOry) {
|
||||
// 임시 세션 생성 (API 호출을 위해)
|
||||
final tempUser = DescopeUser(userId, [], 0, 'User', null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], []);
|
||||
final tempSession = DescopeSession.fromJwt(token, token, tempUser);
|
||||
Descope.sessionManager.manageSession(tempSession);
|
||||
}
|
||||
|
||||
// 백엔드 GetMe 호출 (프로필 노티파이어 사용)
|
||||
final profile = await ref.read(profileProvider.notifier).loadProfile();
|
||||
if (profile != null) {
|
||||
if (profile != null && !isOry) {
|
||||
// 실제 정보로 세션 유저 정보 교체
|
||||
final realUser = DescopeUser(
|
||||
userId, [], 0, profile.name, null, profile.email, false, profile.phone, false, {}, '', '', '', false, 'enabled', [], [], [],
|
||||
@@ -480,14 +645,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
debugPrint("[Auth] Failed to pre-fetch profile: $e");
|
||||
}
|
||||
|
||||
// Record Audit Log
|
||||
AuditService.logEvent(
|
||||
userId: userId,
|
||||
eventType: "LOGIN_SUCCESS",
|
||||
status: "SUCCESS",
|
||||
details: "User logged in via Baron SSO",
|
||||
);
|
||||
|
||||
// 1. Handle Popup Flow
|
||||
if (WebAuthIntegration.isPopup()) {
|
||||
debugPrint("[Auth] Popup detected. Notifying opener and attempting to close.");
|
||||
@@ -680,6 +837,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_qrUserCode != null) ...[
|
||||
Text(
|
||||
"코드: $_qrUserCode",
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
const Text(
|
||||
"모바일 앱으로 스캔하세요",
|
||||
textAlign: TextAlign.center,
|
||||
@@ -727,4 +892,4 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,13 +110,17 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
if (_isPolicyLoading) {
|
||||
return "비밀번호 정책을 불러오는 중입니다...";
|
||||
}
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 8;
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 12;
|
||||
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
|
||||
final requiresLower = _policy?['lowercase'] ?? true;
|
||||
final requiresUpper = _policy?['uppercase'] ?? true;
|
||||
final requiresUpper = _policy?['uppercase'] ?? false;
|
||||
final requiresNumber = _policy?['number'] ?? true;
|
||||
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
|
||||
|
||||
final parts = <String>["최소 ${minLength}자 이상"];
|
||||
if (minTypes > 0) {
|
||||
parts.add("영문 대/소문자/숫자/특수문자 중 ${minTypes}가지 이상");
|
||||
}
|
||||
if (requiresLower) parts.add("소문자 1개 이상");
|
||||
if (requiresUpper) parts.add("대문자 1개 이상");
|
||||
if (requiresNumber) parts.add("숫자 1개 이상");
|
||||
@@ -182,20 +186,35 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
if (val.isEmpty) {
|
||||
return '비밀번호를 입력해주세요.';
|
||||
}
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 8;
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 12;
|
||||
if (val.length < minLength) {
|
||||
return '비밀번호는 최소 $minLength자 이상이어야 합니다.';
|
||||
}
|
||||
if ((_policy?['lowercase'] ?? true) && !RegExp(r'(?=.*[a-z])').hasMatch(val)) {
|
||||
final hasLower = RegExp(r'[a-z]').hasMatch(val);
|
||||
final hasUpper = RegExp(r'[A-Z]').hasMatch(val);
|
||||
final hasNumber = RegExp(r'[0-9]').hasMatch(val);
|
||||
final hasSymbol = RegExp(r'[\W_]').hasMatch(val);
|
||||
int typeCount = 0;
|
||||
if (hasLower) typeCount++;
|
||||
if (hasUpper) typeCount++;
|
||||
if (hasNumber) typeCount++;
|
||||
if (hasSymbol) typeCount++;
|
||||
|
||||
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
|
||||
if (minTypes > 0 && typeCount < minTypes) {
|
||||
return '비밀번호는 영문 대/소문자/숫자/특수문자 중 $minTypes가지 이상 포함해야 합니다.';
|
||||
}
|
||||
|
||||
if ((_policy?['lowercase'] ?? true) && !hasLower) {
|
||||
return '최소 1개 이상의 소문자를 포함해야 합니다.';
|
||||
}
|
||||
if ((_policy?['uppercase'] ?? true) && !RegExp(r'(?=.*[A-Z])').hasMatch(val)) {
|
||||
if ((_policy?['uppercase'] ?? false) && !hasUpper) {
|
||||
return '최소 1개 이상의 대문자를 포함해야 합니다.';
|
||||
}
|
||||
if ((_policy?['number'] ?? true) && !RegExp(r'(?=.*\d)').hasMatch(val)) {
|
||||
if ((_policy?['number'] ?? true) && !hasNumber) {
|
||||
return '최소 1개 이상의 숫자를 포함해야 합니다.';
|
||||
}
|
||||
if ((_policy?['nonAlphanumeric'] ?? true) && !RegExp(r'(?=.*[\W_])').hasMatch(val)) {
|
||||
if ((_policy?['nonAlphanumeric'] ?? true) && !hasSymbol) {
|
||||
return '최소 1개 이상의 특수문자를 포함해야 합니다.';
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -781,36 +781,47 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
if (_isPolicyLoading) {
|
||||
return "비밀번호 정책을 불러오는 중입니다...";
|
||||
}
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 8;
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 12;
|
||||
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
|
||||
final requiresLower = _policy?['lowercase'] ?? true;
|
||||
final requiresUpper = _policy?['uppercase'] ?? true;
|
||||
final requiresUpper = _policy?['uppercase'] ?? false;
|
||||
final requiresNumber = _policy?['number'] ?? true;
|
||||
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
|
||||
|
||||
final parts = <String>["최소 $minLength자 이상"];
|
||||
if (minTypes > 0) {
|
||||
parts.add("영문 대/소문자/숫자/특수문자 중 ${minTypes}가지 이상");
|
||||
}
|
||||
if (requiresUpper) parts.add("대문자");
|
||||
if (requiresLower) parts.add("소문자");
|
||||
if (requiresNumber) parts.add("숫자");
|
||||
if (requiresSymbol) parts.add("특수문자");
|
||||
|
||||
return "보안 정책: ${parts.join(', ')}를 각각 최소 1자 이상 포함해야 합니다.";
|
||||
return "보안 정책: ${parts.join(', ')}";
|
||||
}
|
||||
|
||||
Widget _buildStepPassword() {
|
||||
String p = _passwordController.text;
|
||||
|
||||
// Default Policy Fallback
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 8;
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 12;
|
||||
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
|
||||
final requiresLower = _policy?['lowercase'] ?? true;
|
||||
final requiresUpper = _policy?['uppercase'] ?? true;
|
||||
final requiresUpper = _policy?['uppercase'] ?? false;
|
||||
final requiresNumber = _policy?['number'] ?? true;
|
||||
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
|
||||
|
||||
bool hasLength = p.length >= minLength;
|
||||
bool hasUpper = !requiresUpper || p.contains(RegExp(r'[A-Z]'));
|
||||
bool hasLower = !requiresLower || p.contains(RegExp(r'[a-z]'));
|
||||
bool hasDigit = !requiresNumber || p.contains(RegExp(r'[0-9]'));
|
||||
bool hasSpecial = !requiresSymbol || p.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]'));
|
||||
bool hasUpper = p.contains(RegExp(r'[A-Z]'));
|
||||
bool hasLower = p.contains(RegExp(r'[a-z]'));
|
||||
bool hasDigit = p.contains(RegExp(r'[0-9]'));
|
||||
bool hasSpecial = p.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]'));
|
||||
int typeCount = 0;
|
||||
if (hasUpper) typeCount++;
|
||||
if (hasLower) typeCount++;
|
||||
if (hasDigit) typeCount++;
|
||||
if (hasSpecial) typeCount++;
|
||||
bool hasTypeCount = minTypes <= 0 || typeCount >= minTypes;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@@ -850,6 +861,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
spacing: 10,
|
||||
children: [
|
||||
_cryptoCheck('$minLength자 이상', hasLength),
|
||||
if (minTypes > 0) _cryptoCheck('문자 유형 ${minTypes}가지 이상', hasTypeCount),
|
||||
if (requiresUpper) _cryptoCheck('대문자', hasUpper),
|
||||
if (requiresLower) _cryptoCheck('소문자', hasLower),
|
||||
if (requiresNumber) _cryptoCheck('숫자', hasDigit),
|
||||
@@ -967,4 +979,4 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:descope/descope.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import '../../../../core/notifiers/auth_notifier.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||
|
||||
class DashboardScreen extends StatelessWidget {
|
||||
class DashboardScreen extends ConsumerWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
Future<void> _logout(BuildContext context) async {
|
||||
// ignore: use_build_context_synchronously
|
||||
Descope.sessionManager.clearSession();
|
||||
AuthTokenStore.clear();
|
||||
AuthNotifier.instance.notify();
|
||||
}
|
||||
|
||||
@@ -18,9 +22,16 @@ class DashboardScreen extends StatelessWidget {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final profile = ref.watch(profileProvider).value;
|
||||
final user = Descope.sessionManager.session?.user;
|
||||
final userName = user?.name ?? user?.email ?? user?.phone ?? 'User';
|
||||
final userName = user?.name ??
|
||||
user?.email ??
|
||||
user?.phone ??
|
||||
profile?.name ??
|
||||
profile?.email ??
|
||||
profile?.phone ??
|
||||
'User';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey[50],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import '../models/user_profile_model.dart';
|
||||
import 'package:descope/descope.dart';
|
||||
import '../../../../core/services/auth_token_store.dart';
|
||||
import '../../../../core/services/http_client.dart';
|
||||
|
||||
class ProfileRepository {
|
||||
static String _envOrDefault(String key, String fallback) {
|
||||
@@ -16,22 +16,26 @@ class ProfileRepository {
|
||||
|
||||
// Helper to get session token
|
||||
static Future<String?> _getToken() async {
|
||||
final session = await Descope.sessionManager.session;
|
||||
return session?.sessionToken.jwt;
|
||||
return AuthTokenStore.getToken();
|
||||
}
|
||||
|
||||
Future<UserProfile> getMyProfile() async {
|
||||
final token = await _getToken();
|
||||
if (token == null) throw Exception('No active session');
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
if (token == null && !useCookie) {
|
||||
throw Exception('No active session');
|
||||
}
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me');
|
||||
final response = await http.get(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
);
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final response = await client.get(url, headers: headers);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return UserProfile.fromJson(jsonDecode(response.body));
|
||||
@@ -46,21 +50,27 @@ class ProfileRepository {
|
||||
required String department,
|
||||
}) async {
|
||||
final token = await _getToken();
|
||||
if (token == null) throw Exception('No active session');
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
if (token == null && !useCookie) throw Exception('No active session');
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me');
|
||||
final response = await http.put(
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final response = await client.put(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
headers: headers,
|
||||
body: jsonEncode({
|
||||
'name': name,
|
||||
'phone': phone,
|
||||
'department': department,
|
||||
}),
|
||||
);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Failed to update profile: ${response.body}');
|
||||
@@ -69,17 +79,23 @@ class ProfileRepository {
|
||||
|
||||
Future<void> sendUpdateCode(String phone) async {
|
||||
final token = await _getToken();
|
||||
if (token == null) throw Exception('No active session');
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
if (token == null && !useCookie) throw Exception('No active session');
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me/send-code');
|
||||
final response = await http.post(
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final response = await client.post(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
headers: headers,
|
||||
body: jsonEncode({'phone': phone}),
|
||||
);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('인증번호 전송 실패: ${response.body}');
|
||||
@@ -88,17 +104,23 @@ class ProfileRepository {
|
||||
|
||||
Future<void> verifyUpdateCode(String phone, String code) async {
|
||||
final token = await _getToken();
|
||||
if (token == null) throw Exception('No active session');
|
||||
final useCookie = AuthTokenStore.usesCookie();
|
||||
if (token == null && !useCookie) throw Exception('No active session');
|
||||
|
||||
final url = Uri.parse('$_baseUrl/api/v1/user/me/verify-code');
|
||||
final response = await http.post(
|
||||
final client = createHttpClient(withCredentials: useCookie);
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (!useCookie && token != null) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
final response = await client.post(
|
||||
url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
headers: headers,
|
||||
body: jsonEncode({'phone': phone, 'code': code}),
|
||||
);
|
||||
client.close();
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('인증 실패: ${response.body}');
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'features/admin/presentation/user_management_screen.dart';
|
||||
import 'features/profile/presentation/pages/profile_page.dart';
|
||||
import 'features/profile/presentation/pages/edit_profile_page.dart';
|
||||
import 'core/services/auth_proxy_service.dart';
|
||||
import 'core/services/auth_token_store.dart';
|
||||
import 'core/services/logger_service.dart';
|
||||
import 'core/notifiers/auth_notifier.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
@@ -108,6 +109,13 @@ final _router = GoRouter(
|
||||
return const SignupScreen();
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/verify',
|
||||
builder: (context, state) {
|
||||
_routerLogger.info("Navigating to /verify (query)");
|
||||
return const LoginScreen();
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/verify/:token',
|
||||
builder: (context, state) {
|
||||
@@ -157,13 +165,17 @@ final _router = GoRouter(
|
||||
),
|
||||
],
|
||||
redirect: (context, state) {
|
||||
final isLoggedIn =
|
||||
final hasDescopeSession =
|
||||
Descope.sessionManager.session?.refreshToken?.isExpired == false;
|
||||
final hasStoredToken = AuthTokenStore.getToken() != null;
|
||||
final hasCookieSession = AuthTokenStore.usesCookie();
|
||||
final isLoggedIn = hasDescopeSession || hasStoredToken || hasCookieSession;
|
||||
final path = state.uri.path;
|
||||
|
||||
// Public paths that don't require login
|
||||
final isPublicPath = path == '/signin' ||
|
||||
path == '/signup' ||
|
||||
path == '/verify' ||
|
||||
path.startsWith('/verify/') ||
|
||||
path == '/approve' ||
|
||||
path == '/forgot-password' ||
|
||||
|
||||
Reference in New Issue
Block a user