forked from baron/baron-sso
qr 로그인
This commit is contained in:
@@ -132,6 +132,9 @@ func main() {
|
|||||||
auth.Post("/magic-link/verify", authHandler.VerifyMagicLink)
|
auth.Post("/magic-link/verify", authHandler.VerifyMagicLink)
|
||||||
auth.Post("/sms", authHandler.SendSms)
|
auth.Post("/sms", authHandler.SendSms)
|
||||||
auth.Post("/verify-sms", authHandler.VerifySms)
|
auth.Post("/verify-sms", authHandler.VerifySms)
|
||||||
|
auth.Post("/qr/init", authHandler.InitQRLogin)
|
||||||
|
auth.Post("/qr/poll", authHandler.PollQRLogin)
|
||||||
|
auth.Post("/qr/approve", authHandler.ScanQRLogin)
|
||||||
|
|
||||||
// Admin Routes
|
// Admin Routes
|
||||||
admin := api.Group("/admin")
|
admin := api.Group("/admin")
|
||||||
|
|||||||
@@ -25,3 +25,9 @@ type EnchantedLinkPollResponse struct {
|
|||||||
type MagicLinkVerifyRequest struct {
|
type MagicLinkVerifyRequest struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type QRInitResponse struct {
|
||||||
|
QRCode string `json:"qrCode"` // Base64 or URL
|
||||||
|
PendingRef string `json:"pendingRef"`
|
||||||
|
ExpiresIn int `json:"expiresIn"`
|
||||||
|
}
|
||||||
@@ -230,8 +230,8 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
|
|||||||
if strings.HasPrefix(searchPhone, "010") {
|
if strings.HasPrefix(searchPhone, "010") {
|
||||||
searchPhone = "+82" + searchPhone[1:]
|
searchPhone = "+82" + searchPhone[1:]
|
||||||
} else if strings.HasPrefix(searchPhone, "82") {
|
} else if strings.HasPrefix(searchPhone, "82") {
|
||||||
searchPhone = "+" + searchPhone
|
searchPhone = "+" + searchPhone
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[Verify] Searching for user with phone: %s", searchPhone)
|
log.Printf("[Verify] Searching for user with phone: %s", searchPhone)
|
||||||
@@ -310,6 +310,87 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
|
|||||||
"message": "Login successful",
|
"message": "Login successful",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다.
|
||||||
|
func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error {
|
||||||
|
pendingRef := GenerateSecureToken(16)
|
||||||
|
|
||||||
|
// QR 코드 페이로드를 실제 접속 가능한 URL로 변경합니다.
|
||||||
|
frontendURL := os.Getenv("FRONTEND_URL")
|
||||||
|
if frontendURL == "" {
|
||||||
|
frontendURL = "https://ssologin.hmac.kr"
|
||||||
|
}
|
||||||
|
qrPayload := fmt.Sprintf("%s/approve?ref=%s", frontendURL, pendingRef)
|
||||||
|
|
||||||
|
log.Printf("[QR] Init: PendingRef=%s, URL=%s", pendingRef, qrPayload)
|
||||||
|
|
||||||
|
// Redis에 초기 상태 저장 (5분 만료)
|
||||||
|
h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), 5*time.Minute)
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"qrCode": qrPayload, // 프론트엔드에서 이 텍스트로 QR을 생성하거나, 이미지를 반환
|
||||||
|
"pendingRef": pendingRef,
|
||||||
|
"expiresIn": 300,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PollQRLogin - Step 2: 웹에서 승인 여부를 폴링합니다.
|
||||||
|
func (h *AuthHandler) PollQRLogin(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
PendingRef string `json:"pendingRef"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"})
|
||||||
|
}
|
||||||
|
|
||||||
|
val, err := h.RedisService.Get(prefixSession + req.PendingRef)
|
||||||
|
if err != nil || val == "" {
|
||||||
|
return c.JSON(fiber.Map{"status": "expired"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var data map[string]string
|
||||||
|
json.Unmarshal([]byte(val), &data)
|
||||||
|
|
||||||
|
if data["status"] == statusSuccess {
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"status": "ok",
|
||||||
|
"sessionJwt": data["jwt"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"status": statusPending})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanQRLogin - Step 3: 모바일 앱에서 QR 스캔 후 승인할 때 호출합니다.
|
||||||
|
// (이미 로그인된 세션이 필요함)
|
||||||
|
func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
PendingRef string `json:"pendingRef"`
|
||||||
|
Token string `json:"token"` // 모바일 사용자의 세션 토큰 (검증용)
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"})
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[QR] Scan & Approve: PendingRef=%s", req.PendingRef)
|
||||||
|
|
||||||
|
// 1. Redis에서 세션 확인
|
||||||
|
val, err := h.RedisService.Get(prefixSession + req.PendingRef)
|
||||||
|
if err != nil || val == "" {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Session expired or not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 모바일 유저의 토큰으로 새 세션 토큰(웹용)을 발행하거나 그대로 전달
|
||||||
|
|
||||||
|
sessionData, _ := json.Marshal(map[string]string{
|
||||||
|
"status": statusSuccess,
|
||||||
|
"jwt": req.Token,
|
||||||
|
})
|
||||||
|
h.RedisService.Set(prefixSession+req.PendingRef, string(sessionData), 5*time.Minute)
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"message": "QR Login Approved"})
|
||||||
|
}
|
||||||
|
|
||||||
// ProxyToDescope (Placeholder)
|
// ProxyToDescope (Placeholder)
|
||||||
func (h *AuthHandler) ProxyToDescope(c *fiber.Ctx, path string, payload interface{}) error {
|
func (h *AuthHandler) ProxyToDescope(c *fiber.Ctx, path string, payload interface{}) error {
|
||||||
return c.Status(501).SendString("Descope Proxy Disabled")
|
return c.Status(501).SendString("Descope Proxy Disabled")
|
||||||
|
|||||||
@@ -99,6 +99,51 @@ class AuthProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> initQrLogin() async {
|
||||||
|
final url = Uri.parse('$_baseUrl/api/v1/auth/qr/init');
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return jsonDecode(response.body);
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to init QR login: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> pollQrStatus(String pendingRef) async {
|
||||||
|
final url = Uri.parse('$_baseUrl/api/v1/auth/qr/poll');
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({'pendingRef': pendingRef}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return jsonDecode(response.body);
|
||||||
|
} else {
|
||||||
|
throw Exception('QR Polling failed: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> approveQrLogin(String pendingRef, String token) async {
|
||||||
|
final url = Uri.parse('$_baseUrl/api/v1/auth/qr/approve'); // Mapping to ScanQRLogin on backend
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'pendingRef': pendingRef,
|
||||||
|
'token': token,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('QR Approval failed: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static Future<bool> checkAdminAuth(String adminPassword) async {
|
static Future<bool> checkAdminAuth(String adminPassword) async {
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/admin/check');
|
final url = Uri.parse('$_baseUrl/api/v1/admin/check');
|
||||||
try {
|
try {
|
||||||
|
|||||||
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.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:descope/descope.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:url_launcher/url_launcher_string.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/audit_service.dart';
|
||||||
import '../../../core/services/web_auth_integration.dart';
|
import '../../../core/services/web_auth_integration.dart';
|
||||||
import '../../../core/services/auth_proxy_service.dart';
|
import '../../../core/services/auth_proxy_service.dart';
|
||||||
@@ -27,10 +29,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
bool _smsSent = false;
|
bool _smsSent = false;
|
||||||
String? _redirectUrl;
|
String? _redirectUrl;
|
||||||
|
|
||||||
|
// QR Login Variables
|
||||||
|
String? _qrImageBase64;
|
||||||
|
String? _qrPendingRef;
|
||||||
|
bool _isQrLoading = false;
|
||||||
|
Timer? _qrPollingTimer;
|
||||||
|
int _qrRemainingSeconds = 0;
|
||||||
|
Timer? _qrCountdownTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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)
|
// Check for tokens (Path Parameter or Legacy Query Parameter)
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
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 {
|
Future<void> _verifyToken(String token) async {
|
||||||
debugPrint("[Auth] Starting verification for token: $token");
|
debugPrint("[Auth] Starting verification for token: $token");
|
||||||
try {
|
try {
|
||||||
@@ -81,6 +175,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_stopQrPolling();
|
||||||
_tabController.dispose();
|
_tabController.dispose();
|
||||||
_emailController.dispose();
|
_emailController.dispose();
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
@@ -192,6 +287,32 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final jwt = result['sessionJwt'];
|
final jwt = result['sessionJwt'];
|
||||||
if (jwt != null) {
|
if (jwt != null) {
|
||||||
debugPrint("[Auth] Polling SUCCESS. Token received.");
|
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) {
|
if (mounted) {
|
||||||
Navigator.of(context).pop(); // Close Polling Dialog
|
Navigator.of(context).pop(); // Close Polling Dialog
|
||||||
_onLoginSuccess(jwt);
|
_onLoginSuccess(jwt);
|
||||||
@@ -287,11 +408,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
if (WebAuthIntegration.isPopup()) {
|
if (WebAuthIntegration.isPopup()) {
|
||||||
WebAuthIntegration.sendLoginSuccess(token);
|
WebAuthIntegration.sendLoginSuccess(token);
|
||||||
_showError("Login Successful! You can close this window.");
|
_showError("Login Successful! You can close this window.");
|
||||||
} else if (_redirectUrl != null) {
|
} else if (_redirectUrl != null && _redirectUrl!.isNotEmpty) {
|
||||||
final target = "$_redirectUrl?token=$token";
|
final target = "$_redirectUrl?token=$token";
|
||||||
launchUrlString(target, webOnlyWindowName: '_self');
|
launchUrlString(target, webOnlyWindowName: '_self');
|
||||||
} else {
|
} 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 [
|
tabs: const [
|
||||||
Tab(text: "Email"),
|
Tab(text: "Email"),
|
||||||
Tab(text: "Phone (SMS)"),
|
Tab(text: "Phone (SMS)"),
|
||||||
|
Tab(text: "QR"),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 300,
|
height: 350,
|
||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
children: [
|
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."),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:flutter_web_plugins/url_strategy.dart';
|
import 'package:flutter_web_plugins/url_strategy.dart';
|
||||||
import 'features/auth/presentation/login_screen.dart';
|
import 'features/auth/presentation/login_screen.dart';
|
||||||
|
import 'features/auth/presentation/approve_qr_screen.dart';
|
||||||
import 'features/dashboard/presentation/dashboard_screen.dart';
|
import 'features/dashboard/presentation/dashboard_screen.dart';
|
||||||
import 'features/admin/presentation/user_management_screen.dart';
|
import 'features/admin/presentation/user_management_screen.dart';
|
||||||
import 'core/services/auth_proxy_service.dart';
|
import 'core/services/auth_proxy_service.dart';
|
||||||
@@ -79,6 +80,14 @@ final _router = GoRouter(
|
|||||||
return LoginScreen(verificationToken: token);
|
return LoginScreen(verificationToken: token);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/approve',
|
||||||
|
builder: (context, state) {
|
||||||
|
final ref = state.uri.queryParameters['ref'];
|
||||||
|
_routerLogger.info("Navigating to /approve with ref: $ref");
|
||||||
|
return ApproveQrScreen(pendingRef: ref);
|
||||||
|
},
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
@@ -98,7 +107,7 @@ final _router = GoRouter(
|
|||||||
final isLoggedIn =
|
final isLoggedIn =
|
||||||
Descope.sessionManager.session?.refreshToken.isExpired == false;
|
Descope.sessionManager.session?.refreshToken.isExpired == false;
|
||||||
final path = state.uri.path;
|
final path = state.uri.path;
|
||||||
final isLoggingIn = path == '/' || path.startsWith('/verify/') || path.startsWith('/admin/');
|
final isLoggingIn = path == '/' || path.startsWith('/verify/') || path.startsWith('/admin/') || path == '/approve';
|
||||||
|
|
||||||
_routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn");
|
_routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn");
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ dependencies:
|
|||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
logging: ^1.2.0
|
logging: ^1.2.0
|
||||||
logger: ^2.0.0
|
logger: ^2.0.0
|
||||||
|
qr_flutter: ^4.1.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user