1
0
forked from baron/baron-sso

qr 로그인

This commit is contained in:
2026-01-16 17:42:59 +09:00
parent 50385d510b
commit b65ecc1b24
8 changed files with 446 additions and 14 deletions

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

View File

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