forked from baron/baron-sso
qr 로그인
This commit is contained in:
117
frontend/lib/features/auth/presentation/approve_qr_screen.dart
Normal file
117
frontend/lib/features/auth/presentation/approve_qr_screen.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:descope/descope.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/services/auth_proxy_service.dart';
|
||||
|
||||
class ApproveQrScreen extends StatefulWidget {
|
||||
final String? pendingRef;
|
||||
const ApproveQrScreen({super.key, this.pendingRef});
|
||||
|
||||
@override
|
||||
State<ApproveQrScreen> createState() => _ApproveQrScreenState();
|
||||
}
|
||||
|
||||
class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
||||
bool _isLoading = false;
|
||||
String? _message;
|
||||
bool _success = false;
|
||||
|
||||
Future<void> _handleApprove() async {
|
||||
if (widget.pendingRef == null) return;
|
||||
|
||||
final session = Descope.sessionManager.session;
|
||||
if (session == null || session.refreshToken.isExpired) {
|
||||
setState(() => _message = "Please log in on your phone first.");
|
||||
context.go('/'); // Redirect to login
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_message = null;
|
||||
});
|
||||
// jwt 유효성 확인
|
||||
try {
|
||||
await AuthProxyService.approveQrLogin(
|
||||
widget.pendingRef!,
|
||||
session.sessionToken.jwt,
|
||||
);
|
||||
setState(() {
|
||||
_success = true;
|
||||
_message = "Login Approved! Your browser should now be logged in.";
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _message = "Error: $e");
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isLoggedIn = Descope.sessionManager.session?.refreshToken.isExpired == false;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("QR Login Approval")),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.phonelink_lock, size: 80, color: Colors.blue),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
"Web Login Request",
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
"A computer is trying to log in using this QR code.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
if (_message != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Text(
|
||||
_message!,
|
||||
style: TextStyle(color: _success ? Colors.green : Colors.red, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
if (!_success)
|
||||
FilledButton.icon(
|
||||
onPressed: _isLoading || !isLoggedIn ? null : _handleApprove,
|
||||
icon: const Icon(Icons.check_circle),
|
||||
label: const Text("Approve Login"),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(60),
|
||||
backgroundColor: Colors.blue,
|
||||
),
|
||||
),
|
||||
|
||||
if (!isLoggedIn && !_success)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: TextButton(
|
||||
onPressed: () => context.go('/'),
|
||||
child: const Text("Login on this device first"),
|
||||
),
|
||||
),
|
||||
|
||||
if (_success)
|
||||
FilledButton(
|
||||
onPressed: () => context.go('/dashboard'),
|
||||
child: const Text("Go to My Dashboard"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
@@ -5,6 +6,7 @@ import 'package:google_fonts/google_fonts.dart';
|
||||
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';
|
||||
@@ -27,10 +29,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
bool _smsSent = false;
|
||||
String? _redirectUrl;
|
||||
|
||||
// QR Login Variables
|
||||
String? _qrImageBase64;
|
||||
String? _qrPendingRef;
|
||||
bool _isQrLoading = false;
|
||||
Timer? _qrPollingTimer;
|
||||
int _qrRemainingSeconds = 0;
|
||||
Timer? _qrCountdownTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this, initialIndex: 1);
|
||||
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
|
||||
_tabController.addListener(_handleTabSelection);
|
||||
|
||||
// Check for tokens (Path Parameter or Legacy Query Parameter)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -50,6 +61,89 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
});
|
||||
}
|
||||
|
||||
void _handleTabSelection() {
|
||||
if (_tabController.index == 2 && _qrPendingRef == null) {
|
||||
_startQrFlow();
|
||||
} else if (_tabController.index != 2) {
|
||||
_stopQrPolling();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startQrFlow() async {
|
||||
if (_isQrLoading) return;
|
||||
setState(() {
|
||||
_isQrLoading = true;
|
||||
_qrImageBase64 = null;
|
||||
_qrRemainingSeconds = 0;
|
||||
});
|
||||
|
||||
try {
|
||||
final res = await AuthProxyService.initQrLogin();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_qrImageBase64 = res['qrCode'];
|
||||
_qrPendingRef = res['pendingRef'];
|
||||
_qrRemainingSeconds = res['expiresIn'] ?? 300;
|
||||
_isQrLoading = false;
|
||||
});
|
||||
_startQrPolling();
|
||||
_startCountdown();
|
||||
}
|
||||
} catch (e) {
|
||||
_showError("Failed to init QR: $e");
|
||||
if (mounted) setState(() => _isQrLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _startCountdown() {
|
||||
_qrCountdownTimer?.cancel();
|
||||
_qrCountdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (!mounted || _qrRemainingSeconds <= 0) {
|
||||
timer.cancel();
|
||||
if (_qrRemainingSeconds <= 0) _stopQrPolling();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_qrRemainingSeconds--;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _startQrPolling() {
|
||||
_qrPollingTimer?.cancel();
|
||||
_qrPollingTimer = Timer.periodic(const Duration(milliseconds: 1500), (timer) async {
|
||||
if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final res = await AuthProxyService.pollQrStatus(_qrPendingRef!);
|
||||
if (res['status'] == 'ok' && res['sessionJwt'] != null) {
|
||||
timer.cancel();
|
||||
_qrCountdownTimer?.cancel();
|
||||
_onLoginSuccess(res['sessionJwt']);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[QR] Polling error: $e");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _stopQrPolling() {
|
||||
_qrPollingTimer?.cancel();
|
||||
_qrPollingTimer = null;
|
||||
_qrCountdownTimer?.cancel();
|
||||
_qrCountdownTimer = null;
|
||||
_qrPendingRef = null;
|
||||
}
|
||||
|
||||
String _formatTime(int seconds) {
|
||||
final m = seconds ~/ 60;
|
||||
final s = seconds % 60;
|
||||
return "${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}";
|
||||
}
|
||||
|
||||
Future<void> _verifyToken(String token) async {
|
||||
debugPrint("[Auth] Starting verification for token: $token");
|
||||
try {
|
||||
@@ -81,6 +175,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopQrPolling();
|
||||
_tabController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
@@ -192,6 +287,32 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
final jwt = result['sessionJwt'];
|
||||
if (jwt != null) {
|
||||
debugPrint("[Auth] Polling SUCCESS. Token received.");
|
||||
|
||||
// Descope SDK 세션 강제 주입
|
||||
// Note: DescopeUser in 0.9.11 requires 18 positional arguments.
|
||||
final dummyUser = DescopeUser(
|
||||
'unknown', // userId
|
||||
[], // loginIds
|
||||
0, // createdAt
|
||||
'User', // name
|
||||
null, // picture (Uri?)
|
||||
'', // email
|
||||
false, // isVerifiedEmail
|
||||
'', // phone
|
||||
false, // isVerifiedPhone
|
||||
{}, // customAttributes
|
||||
'', // givenName
|
||||
'', // middleName
|
||||
'', // familyName
|
||||
false, // hasPassword
|
||||
'enabled', // status
|
||||
[], // roleNames
|
||||
[], // ssoAppIds
|
||||
[], // oauthProviders (List<String>)
|
||||
);
|
||||
final session = DescopeSession.fromJwt(jwt, jwt, dummyUser);
|
||||
Descope.sessionManager.manageSession(session);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(); // Close Polling Dialog
|
||||
_onLoginSuccess(jwt);
|
||||
@@ -287,11 +408,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
if (WebAuthIntegration.isPopup()) {
|
||||
WebAuthIntegration.sendLoginSuccess(token);
|
||||
_showError("Login Successful! You can close this window.");
|
||||
} else if (_redirectUrl != null) {
|
||||
} else if (_redirectUrl != null && _redirectUrl!.isNotEmpty) {
|
||||
final target = "$_redirectUrl?token=$token";
|
||||
launchUrlString(target, webOnlyWindowName: '_self');
|
||||
} else {
|
||||
_showError("Login Successful (Token Received)");
|
||||
// Standalone mode: Go to dashboard to act as an auth platform
|
||||
if (mounted) context.go('/dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,12 +455,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
tabs: const [
|
||||
Tab(text: "Email"),
|
||||
Tab(text: "Phone (SMS)"),
|
||||
Tab(text: "QR"),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
SizedBox(
|
||||
height: 300,
|
||||
height: 350,
|
||||
child: TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
@@ -402,6 +525,53 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// QR Login View
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (_isQrLoading)
|
||||
const CircularProgressIndicator()
|
||||
else if (_qrImageBase64 != null)
|
||||
Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: QrImageView(
|
||||
data: _qrImageBase64!,
|
||||
version: QrVersions.auto,
|
||||
size: 200.0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_qrRemainingSeconds > 0
|
||||
? "Remaining Time: ${_formatTime(_qrRemainingSeconds)}"
|
||||
: "QR Code Expired",
|
||||
style: TextStyle(
|
||||
color: _qrRemainingSeconds > 30 ? Colors.blue : Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
"Scan with your mobile app",
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _startQrFlow,
|
||||
child: const Text("Refresh QR")
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
const Text("Failed to load QR code."),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user