1
0
forked from baron/baron-sso

링크로 로그인하기 구현 완료

This commit is contained in:
Lectom C Han
2026-01-29 09:57:36 +09:00
parent 8faa08e377
commit 209314fea7
4 changed files with 107 additions and 17 deletions

View File

@@ -4,6 +4,7 @@ type EnchantedLinkInitRequest struct {
LoginID string `json:"loginId"`
URI string `json:"uri,omitempty"` // Redirect URI (optional for polling flow)
Method string `json:"method,omitempty"` // "email" or "sms"
CodeOnly bool `json:"codeOnly,omitempty"`
}
type EnchantedLinkInitResponse struct {

View File

@@ -35,6 +35,7 @@ const (
prefixLoginCodeSmsTarget = "login_code_sms_target:"
prefixLoginCodeSmsLookup = "login_code_sms_lookup:"
prefixLoginCodeShort = "login_code_short:"
prefixLoginCodeSmsOnly = "login_code_sms_only:"
prefixPollMeta = "poll_meta:"
prefixSignupEmail = "signup:email:"
prefixSignupPhone = "signup:phone:"
@@ -630,6 +631,11 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
if init.LoginID != "" {
keyLoginID = init.LoginID
}
if !strings.Contains(loginID, "@") && req.CodeOnly {
_ = h.RedisService.Set(prefixLoginCodeSmsOnly+keyLoginID, "1", loginCodeExpiration)
} else {
_ = h.RedisService.Delete(prefixLoginCodeSmsOnly + keyLoginID)
}
if init.FlowID != "" {
_ = h.RedisService.Set(prefixLoginCode+keyLoginID, init.FlowID, loginCodeExpiration)
}
@@ -644,6 +650,9 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
if !init.ExpiresAt.IsZero() {
expiresIn = int(time.Until(init.ExpiresAt).Seconds())
}
if expiresIn <= 0 {
expiresIn = int(loginCodeExpiration.Seconds())
}
return c.JSON(fiber.Map{
"linkId": "Sent",
"pendingRef": pendingRef,
@@ -1609,10 +1618,13 @@ type shortLoginCodePayload struct {
}
func (h *AuthHandler) buildKratosShortSmsBody(req *kratosCourierRequest, loginID, phone string) string {
_, link, ok := h.prepareKratosShortLogin(req, loginID)
shortCode, link, ok := h.prepareKratosShortLogin(req, loginID)
if !ok {
return ""
}
if h.isSmsCodeOnly(loginID) {
return fmt.Sprintf("[Baron 통합로그인] 로그인 코드: %s", shortCode)
}
return fmt.Sprintf("[Baron 통합로그인] %s", link)
}
@@ -1667,6 +1679,14 @@ func (h *AuthHandler) prepareKratosShortLogin(req *kratosCourierRequest, loginID
return shortCode, link, true
}
func (h *AuthHandler) isSmsCodeOnly(loginID string) bool {
if loginID == "" {
return false
}
val, _ := h.RedisService.Get(prefixLoginCodeSmsOnly + loginID)
return val != ""
}
func (h *AuthHandler) generateShortCode(code string) string {
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
for i := 0; i < 10; i++ {

View File

@@ -41,17 +41,24 @@ class AuthProxyService {
}
}
static Future<Map<String, dynamic>> initEnchantedLink(String loginId, {String? method}) async {
static Future<Map<String, dynamic>> initEnchantedLink(
String loginId, {
String? method,
bool? codeOnly,
}) async {
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init');
final userfrontUrl = _envOrDefault('USERFRONT_URL', 'http://sso.hmac.kr');
final body = {
final body = <String, dynamic>{
'loginId': loginId,
'uri': userfrontUrl,
};
if (method != null) {
body['method'] = method;
}
if (codeOnly == true) {
body['codeOnly'] = true;
}
final response = await http.post(
url,

View File

@@ -46,6 +46,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
bool _lastLinkIsEmail = true;
int _linkResendSeconds = 0;
Timer? _linkResendTimer;
int _linkExpireSeconds = 0;
Timer? _linkExpireTimer;
@override
void initState() {
@@ -120,6 +122,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_linkResendTimer?.cancel();
_linkResendTimer = null;
_linkResendSeconds = 0;
_linkExpireTimer?.cancel();
_linkExpireTimer = null;
_linkExpireSeconds = 0;
_shortCodePrefixController.clear();
_shortCodeDigitsController.clear();
}
@@ -139,6 +144,25 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
});
}
void _startLinkExpireTimer(int seconds) {
_linkExpireSeconds = seconds;
_linkExpireTimer?.cancel();
_linkExpireTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) return;
if (_linkExpireSeconds > 0) {
setState(() {
_linkExpireSeconds--;
});
return;
}
timer.cancel();
if (mounted) {
setState(_resetLinkLoginState);
context.go('/signin');
}
});
}
// Helper to decode JWT and get loginId
String _getLoginIdFromJwt(String jwt) {
try {
@@ -505,7 +529,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
}
Future<void> _startEnchantedFlow(String loginId, {required bool isEmail}) async {
Future<void> _startEnchantedFlow(String loginId, {required bool isEmail, bool codeOnly = false}) async {
try {
if (mounted) {
showDialog(
@@ -516,12 +540,16 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
// 1. Init via Backend API
final initResponse = await AuthProxyService.initEnchantedLink(loginId);
final initResponse = await AuthProxyService.initEnchantedLink(
loginId,
codeOnly: codeOnly,
);
final pendingRef = initResponse['pendingRef'];
final mode = (initResponse['mode'] ?? '').toString();
final provider = (initResponse['provider'] ?? '').toString();
final interval = initResponse['interval'];
final resendAfter = initResponse['resendAfter'];
final expiresIn = initResponse['expiresIn'];
debugPrint("[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider");
if (mounted) {
@@ -543,6 +571,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (resendAfter is int && resendAfter > 0) {
_startLinkResendTimer(resendAfter);
}
if (expiresIn is int && expiresIn > 0) {
_startLinkExpireTimer(expiresIn);
}
_pollForSession(pendingRef, initialInterval: initialInterval);
}
} catch (e) {
@@ -890,9 +921,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
child: TextField(
controller: _shortCodeDigitsController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
decoration: InputDecoration(
labelText: "000000",
border: OutlineInputBorder(),
border: const OutlineInputBorder(),
hintText: _linkExpireSeconds > 0
? "유효시간 ${_formatTime(_linkExpireSeconds)}"
: "000000",
),
maxLength: 6,
),
@@ -917,22 +951,50 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
),
const SizedBox(height: 12),
TextButton(
onPressed: _linkResendSeconds > 0
? null
: () {
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
if (loginId.isEmpty) {
_showError("이메일 또는 휴대폰 번호를 입력해 주세요.");
return;
}
_startEnchantedFlow(loginId, isEmail: _lastLinkIsEmail || loginId.contains('@'));
},
onPressed: () {
if (_linkResendSeconds > 0) {
_showInfo("재발송은 ${_formatTime(_linkResendSeconds)} 후 가능합니다.");
return;
}
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
if (loginId.isEmpty) {
_showError("이메일 또는 휴대폰 번호를 입력해 주세요.");
return;
}
_startEnchantedFlow(
loginId,
isEmail: _lastLinkIsEmail || loginId.contains('@'),
codeOnly: false,
);
},
child: Text(
_linkResendSeconds > 0
? "재발송 (${_formatTime(_linkResendSeconds)})"
: "재발송",
),
),
if (!_lastLinkIsEmail) ...[
const SizedBox(height: 4),
TextButton(
onPressed: () {
if (_linkResendSeconds > 0) {
_showInfo("재발송은 ${_formatTime(_linkResendSeconds)} 후 가능합니다.");
return;
}
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
if (loginId.isEmpty) {
_showError("휴대폰 번호를 입력해 주세요.");
return;
}
_startEnchantedFlow(
loginId,
isEmail: false,
codeOnly: true,
);
},
child: const Text("코드만 받기(${_formatTime(_linkResendSeconds)})"),
),
],
],
],
),