forked from baron/baron-sso
이메일/비밀번호 로그인 기능 구현
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user