1
0
forked from baron/baron-sso

audit 로그 개선. kratos 코드발급 링크로 전송까지 진행 완료 #104

This commit is contained in:
Lectom C Han
2026-01-29 01:20:19 +09:00
parent ff17259117
commit b88de7ec91
46 changed files with 2843 additions and 585 deletions

View File

@@ -29,7 +29,6 @@ class AuditService {
'event_type': eventType,
'status': status,
'details': details,
'timestamp': DateTime.now().toIso8601String(),
}),
);

View File

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

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

View 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();

View 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();

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

View 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();

View 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();

View File

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

View File

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

View File

@@ -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> {
),
);
}
}
}

View File

@@ -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],

View File

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

View File

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