1
0
forked from baron/baron-sso

이메일/비밀번호 로그인 기능 구현

This commit is contained in:
2026-01-23 15:59:08 +09:00
parent 4c608c6c3c
commit 939d8ee911
4 changed files with 162 additions and 67 deletions

View File

@@ -228,6 +228,7 @@ func main() {
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink)
auth.Post("/magic-link/verify", authHandler.VerifyMagicLink)
auth.Post("/password/login", authHandler.PasswordLogin)
auth.Post("/sms", authHandler.SendSms)
auth.Post("/verify-sms", authHandler.VerifySms)
auth.Post("/qr/init", authHandler.InitQRLogin)

View File

@@ -667,6 +667,42 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
})
}
// PasswordLogin - Authenticate a user with login ID and password.
func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
var req struct {
LoginID string `json:"loginId"`
Password string `json:"password"`
}
if err := c.BodyParser(&req); err != nil {
slog.Error("[PasswordLogin] Body parse error", "error", err)
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
slog.Info("[PasswordLogin] Attempting to login", "loginID", req.LoginID)
if h.DescopeClient == nil {
slog.Error("[PasswordLogin] Descope Client is nil!")
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Authentication service not configured"})
}
// Sign in using Descope
authInfo, err := h.DescopeClient.Auth.Password().SignIn(context.Background(), req.LoginID, req.Password, nil)
if err != nil {
slog.Warn("[PasswordLogin] Descope sign-in failed", "loginID", req.LoginID, "error", err)
// It's good practice to return a generic error message for security.
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
}
slog.Info("[PasswordLogin] Success", "loginID", req.LoginID)
return c.JSON(fiber.Map{
"sessionJwt": authInfo.SessionToken.JWT,
"status": "ok",
})
}
// InitQRLogin - Step 1: Web 패널에서 QR 로그인 세션을 생성합니다.
func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error {
pendingRef := GenerateSecureToken(16)

View File

@@ -66,6 +66,26 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> loginWithPassword(String loginId, String password) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/password/login');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'loginId': loginId,
'password': password,
}),
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
final errorBody = jsonDecode(response.body);
throw Exception(errorBody['error'] ?? 'Failed to login');
}
}
static Future<void> sendSms(String phoneNumber) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/sms');

View File

@@ -24,9 +24,9 @@ class LoginScreen extends ConsumerStatefulWidget {
class _LoginScreenState extends ConsumerState<LoginScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
final TextEditingController _idController = TextEditingController();
final TextEditingController _smsCodeController = TextEditingController(); // Keep if needed for verification inputs later? Actually not used in link flow.
bool _smsSent = false;
final TextEditingController _linkIdController = TextEditingController();
final TextEditingController _passwordLoginIdController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
String? _redirectUrl;
// QR Login Variables
@@ -40,7 +40,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
// 탭 컨트롤러: 3개 탭, 기본 선택은 두 번째 탭("로그인 링크")
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
_tabController.addListener(_handleTabSelection);
// Check for tokens (Path Parameter or Legacy Query Parameter)
@@ -92,9 +93,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
void _handleTabSelection() {
if (_tabController.index == 1 && _qrPendingRef == null) {
// QR 탭 (세 번째 탭, index 2)이 선택되었을 때 QR 플로우 시작
if (_tabController.index == 2 && _qrPendingRef == null) {
_startQrFlow();
} else if (_tabController.index != 1) {
} else if (_tabController.index != 2) {
_stopQrPolling();
}
}
@@ -230,28 +232,56 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
}
void _showSuccessDialog() {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const AlertDialog(
title: Text("Authentication Successful"),
content: Text("You can close this tab and return to the application."),
),
);
}
@override
void dispose() {
_stopQrPolling();
_tabController.dispose();
_idController.dispose();
_smsCodeController.dispose();
_linkIdController.dispose();
_passwordLoginIdController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _handleLogin() async {
final input = _idController.text.trim();
// 이메일/비밀번호 로그인 처리
Future<void> _handlePasswordLogin() async {
final loginId = _passwordLoginIdController.text.trim();
final password = _passwordController.text.trim();
if (loginId.isEmpty || password.isEmpty) {
_showError("이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요.");
return;
}
// 로딩 인디케이터 표시
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(child: CircularProgressIndicator()),
);
try {
final res = await AuthProxyService.loginWithPassword(loginId, password);
final jwt = res['sessionJwt'];
if (jwt != null && mounted) {
Navigator.of(context).pop(); // 로딩 닫기
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);
}
} catch (e) {
if (mounted) Navigator.of(context).pop(); // 로딩 닫기
_showError("로그인 실패: ${e.toString().replaceFirst("Exception: ", "")}");
}
}
// 로그인 링크 전송 처리
Future<void> _handleLinkLogin() async {
final input = _linkIdController.text.trim();
if (input.isEmpty) return;
String loginId = input;
@@ -340,26 +370,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final displayName = _getLoginIdFromJwt(jwt);
// Descope SDK 세션 강제 주입
// Note: DescopeUser in 0.9.11 requires 18 positional arguments.
final dummyUser = DescopeUser(
'unknown', // userId
[], // loginIds
0, // createdAt
displayName, // name
null, // picture (Uri?)
'', // email
false, // isVerifiedEmail
'', // phone
false, // isVerifiedPhone
{}, // customAttributes
'', // givenName
'', // middleName
'', // familyName
false, // hasPassword
'enabled', // status
[], // roleNames
[], // ssoAppIds
[], // oauthProviders (List<String>)
'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [],
);
final session = DescopeSession.fromJwt(jwt, jwt, dummyUser);
Descope.sessionManager.manageSession(session);
@@ -397,38 +409,25 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
void _logTokenDetails(String jwt) {
try {
// JWT는 세 부분(Header, Payload, Signature)이 '.'으로 구분된 문자열입니다. 이를 분리합니다.
final parts = jwt.split('.');
// 세 부분으로 정확히 나뉘지 않았다면 유효한 JWT가 아니므로 중단합니다.
if (parts.length != 3) return;
// JWT의 두 번째 부분(Payload)은 Base64Url로 인코딩된 JSON 데이터입니다.
// 1. Base64Url 문자열을 디코딩하여 바이트 배열로 변환합니다.
// normalize()는 Base64 패딩(=) 문제를 처리해줍니다.
final decodedPayload = base64Url.decode(base64Url.normalize(parts[1]));
// 2. 바이트 배열을 UTF-8 형식의 일반 문자열(JSON)로 변환합니다.
final payloadJson = utf8.decode(decodedPayload);
// 3. JSON 문자열을 Dart에서 사용할 수 있는 Map 객체로 변환합니다.
final data = json.decode(payloadJson) as Map<String, dynamic>;
// [FIX] 'exp'는 int 또는 double일 수 있으므로, 안전하게 num으로 처리합니다.
final accessExpValue = data['exp'] as num?;
// 'exp' (Expiration Time) 필드는 Access Token의 만료 시간을 나타냅니다. Unix 타임스탬프(초 단위) 값입니다.
// 이 값을 Dart의 DateTime 객체로 변환합니다. (1000을 곱해 밀리초 단위로 만듦)
final accessExp = accessExpValue != null
? DateTime.fromMillisecondsSinceEpoch(accessExpValue.toInt() * 1000)
: 'N/A';
// 'rexp' (Refresh Expiration) 필드는 Descope가 사용하는 커스텀 필드로, Refresh Token의 만료 시간을 ISO 8601 형식의 문자열로 나타냅니다.
final refreshExp = data['rexp'] ?? 'N/A';
// 확인된 만료 시간 정보들을 디버그 콘솔에 출력합니다.
debugPrint("""
[Auth] Session Token Details ---
- Access Token Expires: $accessExp
- Refresh Token Expires: $refreshExp
""");
} catch (e) {
// JWT를 해석하는 과정에서 오류가 발생하면 콘솔에 에러를 출력합니다.
debugPrint("[Auth] Failed to decode or log token details: $e");
}
}
@@ -438,7 +437,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_logTokenDetails(token);
// [FIX] 감사 로그에 실제 사용자 ID를 전송하기 위해 토큰에서 ID를 추출합니다.
final userId = _getUserIdFromJwt(token);
// Record Audit Log
@@ -449,16 +447,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
details: "User logged in via Baron SSO",
);
// 1. Handle Popup Flow (Highest Priority for child windows)
// If opened as a popup (has opener), we notify and try to close.
// 1. Handle Popup Flow
if (WebAuthIntegration.isPopup()) {
debugPrint("[Auth] Popup detected. Notifying opener and attempting to close.");
WebAuthIntegration.sendLoginSuccess(token);
// We don't 'return' here to allow a fallback if window.close() is blocked,
// but in most cases WebAuthIntegration.sendLoginSuccess will close the window.
} else {
// 2. Handle Redirect Flow (Only if NOT a popup)
// 2. Handle Redirect Flow
if (_redirectUrl != null && _redirectUrl!.isNotEmpty) {
debugPrint("[Auth] Redirecting standalone window to: $_redirectUrl");
final target = "$_redirectUrl?token=$token";
@@ -468,7 +462,6 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
// 3. Standalone mode / Fallback
// If it's a standard login, or if a popup's window.close() was blocked by the browser.
debugPrint("[Auth] Login success. Navigating to root.");
AuthNotifier.instance.notify();
if (mounted) {
@@ -505,7 +498,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
TabBar(
controller: _tabController,
tabs: const [
Tab(text: "로그인"),
Tab(text: "이메일/전화번호"),
Tab(text: "로그인 링크"),
Tab(text: "QR 코드"),
],
),
@@ -516,24 +510,68 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
child: TabBarView(
controller: _tabController,
children: [
// Unified Login Form
// 1. 이메일/비밀번호 로그인 폼
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Column(
children: [
TextField(
controller: _idController,
controller: _passwordLoginIdController,
decoration: const InputDecoration(
labelText: "이메일 또는 휴대폰 번호",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person_outline),
),
onSubmitted: (_) => _handlePasswordLogin(),
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: "비밀번호",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.lock_outline),
),
onSubmitted: (_) => _handlePasswordLogin(),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _handlePasswordLogin,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
child: const Text("로그인"),
),
const SizedBox(height: 16),
TextButton(
onPressed: () {
_showError("비밀번호 재설정은 아직 구현되지 않았습니다.");
},
child: const Text("비밀번호를 잊으셨나요?"),
)
],
),
),
// 2. 로그인 링크 전송 폼
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Column(
children: [
TextField(
controller: _linkIdController,
decoration: const InputDecoration(
labelText: "이메일 또는 휴대폰 번호",
hintText: "",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person_outline),
),
onSubmitted: (_) => _handleLogin(),
onSubmitted: (_) => _handleLinkLogin(),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _handleLogin,
onPressed: _handleLinkLogin,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
@@ -560,7 +598,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
),
),
// QR Login View
// 3. QR 로그인 뷰
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,