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

@@ -132,6 +132,9 @@ func main() {
auth.Post("/magic-link/verify", authHandler.VerifyMagicLink)
auth.Post("/sms", authHandler.SendSms)
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 := api.Group("/admin")

View File

@@ -24,4 +24,10 @@ type EnchantedLinkPollResponse struct {
type MagicLinkVerifyRequest struct {
Token string `json:"token"`
}
type QRInitResponse struct {
QRCode string `json:"qrCode"` // Base64 or URL
PendingRef string `json:"pendingRef"`
ExpiresIn int `json:"expiresIn"`
}

View File

@@ -132,7 +132,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
loginID := strings.ReplaceAll(req.LoginID, "-", "")
loginID = strings.ReplaceAll(loginID, " ", "")
// Generate secure tokens
token := GenerateSecureToken(3)
pendingRef := GenerateSecureToken(3)
@@ -150,7 +150,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
}
link := fmt.Sprintf("%s/verify/%s", frontendURL, token)
content := fmt.Sprintf("[Baron SSO] 로그인 링크: %s", link)
log.Printf("[Enchanted] Sending SMS to %s via Naver Cloud", loginID)
if err := h.SmsService.SendSms(loginID, content); err != nil {
@@ -230,8 +230,8 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
if strings.HasPrefix(searchPhone, "010") {
searchPhone = "+82" + searchPhone[1:]
} else if strings.HasPrefix(searchPhone, "82") {
searchPhone = "+" + searchPhone
}
searchPhone = "+" + searchPhone
}
}
log.Printf("[Verify] Searching for user with phone: %s", searchPhone)
@@ -239,10 +239,10 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
Phones: []string{searchPhone},
Limit: 1,
}
var targetLoginID string
users, _, errSearch := h.DescopeClient.Management.User().SearchAll(context.Background(), searchOptions)
if errSearch == nil && len(users) > 0 {
if len(users[0].LoginIDs) > 0 {
targetLoginID = users[0].LoginIDs[0]
@@ -264,7 +264,7 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
if err != nil {
if strings.Contains(err.Error(), "User not found") || strings.Contains(err.Error(), "E062108") {
log.Printf("[Verify] User %s not found. Creating...", targetLoginID)
// Create User with Explicit Phone Attribute
userObj := &descope.UserRequest{}
if strings.Contains(targetLoginID, "@") {
@@ -278,7 +278,7 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
log.Printf("[Verify] Failed to create user: %v", errCreate)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create new user"})
}
embeddedToken, err = h.DescopeClient.Management.User().GenerateEmbeddedLink(context.Background(), targetLoginID, nil, 0)
if err != nil {
log.Printf("[Verify] Failed to generate token after creation: %v", err)
@@ -304,12 +304,93 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
"jwt": sessionToken,
})
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), defaultExpiration)
return c.JSON(fiber.Map{
"token": sessionToken,
"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)
func (h *AuthHandler) ProxyToDescope(c *fiber.Ctx, path string, payload interface{}) error {
return c.Status(501).SendString("Descope Proxy Disabled")

View File

@@ -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 {
final url = Uri.parse('$_baseUrl/api/v1/admin/check');
try {

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

View File

@@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:flutter_web_plugins/url_strategy.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/admin/presentation/user_management_screen.dart';
import 'core/services/auth_proxy_service.dart';
@@ -79,6 +80,14 @@ final _router = GoRouter(
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(
path: '/dashboard',
builder: (context, state) {
@@ -98,7 +107,7 @@ final _router = GoRouter(
final isLoggedIn =
Descope.sessionManager.session?.refreshToken.isExpired == false;
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");

View File

@@ -43,6 +43,7 @@ dependencies:
url_launcher: ^6.3.2
logging: ^1.2.0
logger: ^2.0.0
qr_flutter: ^4.1.0
dev_dependencies:
flutter_test: