forked from baron/baron-sso
링크로 로그인하기 구현 완료
This commit is contained in:
@@ -4,6 +4,7 @@ type EnchantedLinkInitRequest struct {
|
|||||||
LoginID string `json:"loginId"`
|
LoginID string `json:"loginId"`
|
||||||
URI string `json:"uri,omitempty"` // Redirect URI (optional for polling flow)
|
URI string `json:"uri,omitempty"` // Redirect URI (optional for polling flow)
|
||||||
Method string `json:"method,omitempty"` // "email" or "sms"
|
Method string `json:"method,omitempty"` // "email" or "sms"
|
||||||
|
CodeOnly bool `json:"codeOnly,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type EnchantedLinkInitResponse struct {
|
type EnchantedLinkInitResponse struct {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const (
|
|||||||
prefixLoginCodeSmsTarget = "login_code_sms_target:"
|
prefixLoginCodeSmsTarget = "login_code_sms_target:"
|
||||||
prefixLoginCodeSmsLookup = "login_code_sms_lookup:"
|
prefixLoginCodeSmsLookup = "login_code_sms_lookup:"
|
||||||
prefixLoginCodeShort = "login_code_short:"
|
prefixLoginCodeShort = "login_code_short:"
|
||||||
|
prefixLoginCodeSmsOnly = "login_code_sms_only:"
|
||||||
prefixPollMeta = "poll_meta:"
|
prefixPollMeta = "poll_meta:"
|
||||||
prefixSignupEmail = "signup:email:"
|
prefixSignupEmail = "signup:email:"
|
||||||
prefixSignupPhone = "signup:phone:"
|
prefixSignupPhone = "signup:phone:"
|
||||||
@@ -630,6 +631,11 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
|
|||||||
if init.LoginID != "" {
|
if init.LoginID != "" {
|
||||||
keyLoginID = 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 != "" {
|
if init.FlowID != "" {
|
||||||
_ = h.RedisService.Set(prefixLoginCode+keyLoginID, init.FlowID, loginCodeExpiration)
|
_ = h.RedisService.Set(prefixLoginCode+keyLoginID, init.FlowID, loginCodeExpiration)
|
||||||
}
|
}
|
||||||
@@ -644,6 +650,9 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
|
|||||||
if !init.ExpiresAt.IsZero() {
|
if !init.ExpiresAt.IsZero() {
|
||||||
expiresIn = int(time.Until(init.ExpiresAt).Seconds())
|
expiresIn = int(time.Until(init.ExpiresAt).Seconds())
|
||||||
}
|
}
|
||||||
|
if expiresIn <= 0 {
|
||||||
|
expiresIn = int(loginCodeExpiration.Seconds())
|
||||||
|
}
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"linkId": "Sent",
|
"linkId": "Sent",
|
||||||
"pendingRef": pendingRef,
|
"pendingRef": pendingRef,
|
||||||
@@ -1609,10 +1618,13 @@ type shortLoginCodePayload struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) buildKratosShortSmsBody(req *kratosCourierRequest, loginID, phone string) string {
|
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 {
|
if !ok {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
if h.isSmsCodeOnly(loginID) {
|
||||||
|
return fmt.Sprintf("[Baron 통합로그인] 로그인 코드: %s", shortCode)
|
||||||
|
}
|
||||||
return fmt.Sprintf("[Baron 통합로그인] %s", link)
|
return fmt.Sprintf("[Baron 통합로그인] %s", link)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1667,6 +1679,14 @@ func (h *AuthHandler) prepareKratosShortLogin(req *kratosCourierRequest, loginID
|
|||||||
return shortCode, link, true
|
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 {
|
func (h *AuthHandler) generateShortCode(code string) string {
|
||||||
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
for i := 0; i < 10; i++ {
|
for i := 0; i < 10; i++ {
|
||||||
|
|||||||
@@ -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 url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init');
|
||||||
final userfrontUrl = _envOrDefault('USERFRONT_URL', 'http://sso.hmac.kr');
|
final userfrontUrl = _envOrDefault('USERFRONT_URL', 'http://sso.hmac.kr');
|
||||||
|
|
||||||
final body = {
|
final body = <String, dynamic>{
|
||||||
'loginId': loginId,
|
'loginId': loginId,
|
||||||
'uri': userfrontUrl,
|
'uri': userfrontUrl,
|
||||||
};
|
};
|
||||||
if (method != null) {
|
if (method != null) {
|
||||||
body['method'] = method;
|
body['method'] = method;
|
||||||
}
|
}
|
||||||
|
if (codeOnly == true) {
|
||||||
|
body['codeOnly'] = true;
|
||||||
|
}
|
||||||
|
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
bool _lastLinkIsEmail = true;
|
bool _lastLinkIsEmail = true;
|
||||||
int _linkResendSeconds = 0;
|
int _linkResendSeconds = 0;
|
||||||
Timer? _linkResendTimer;
|
Timer? _linkResendTimer;
|
||||||
|
int _linkExpireSeconds = 0;
|
||||||
|
Timer? _linkExpireTimer;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -120,6 +122,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_linkResendTimer?.cancel();
|
_linkResendTimer?.cancel();
|
||||||
_linkResendTimer = null;
|
_linkResendTimer = null;
|
||||||
_linkResendSeconds = 0;
|
_linkResendSeconds = 0;
|
||||||
|
_linkExpireTimer?.cancel();
|
||||||
|
_linkExpireTimer = null;
|
||||||
|
_linkExpireSeconds = 0;
|
||||||
_shortCodePrefixController.clear();
|
_shortCodePrefixController.clear();
|
||||||
_shortCodeDigitsController.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
|
// Helper to decode JWT and get loginId
|
||||||
String _getLoginIdFromJwt(String jwt) {
|
String _getLoginIdFromJwt(String jwt) {
|
||||||
try {
|
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 {
|
try {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
showDialog(
|
showDialog(
|
||||||
@@ -516,12 +540,16 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Init via Backend API
|
// 1. Init via Backend API
|
||||||
final initResponse = await AuthProxyService.initEnchantedLink(loginId);
|
final initResponse = await AuthProxyService.initEnchantedLink(
|
||||||
|
loginId,
|
||||||
|
codeOnly: codeOnly,
|
||||||
|
);
|
||||||
final pendingRef = initResponse['pendingRef'];
|
final pendingRef = initResponse['pendingRef'];
|
||||||
final mode = (initResponse['mode'] ?? '').toString();
|
final mode = (initResponse['mode'] ?? '').toString();
|
||||||
final provider = (initResponse['provider'] ?? '').toString();
|
final provider = (initResponse['provider'] ?? '').toString();
|
||||||
final interval = initResponse['interval'];
|
final interval = initResponse['interval'];
|
||||||
final resendAfter = initResponse['resendAfter'];
|
final resendAfter = initResponse['resendAfter'];
|
||||||
|
final expiresIn = initResponse['expiresIn'];
|
||||||
debugPrint("[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider");
|
debugPrint("[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider");
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -543,6 +571,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
if (resendAfter is int && resendAfter > 0) {
|
if (resendAfter is int && resendAfter > 0) {
|
||||||
_startLinkResendTimer(resendAfter);
|
_startLinkResendTimer(resendAfter);
|
||||||
}
|
}
|
||||||
|
if (expiresIn is int && expiresIn > 0) {
|
||||||
|
_startLinkExpireTimer(expiresIn);
|
||||||
|
}
|
||||||
_pollForSession(pendingRef, initialInterval: initialInterval);
|
_pollForSession(pendingRef, initialInterval: initialInterval);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -890,9 +921,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _shortCodeDigitsController,
|
controller: _shortCodeDigitsController,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: "000000",
|
labelText: "000000",
|
||||||
border: OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
|
hintText: _linkExpireSeconds > 0
|
||||||
|
? "유효시간 ${_formatTime(_linkExpireSeconds)}"
|
||||||
|
: "000000",
|
||||||
),
|
),
|
||||||
maxLength: 6,
|
maxLength: 6,
|
||||||
),
|
),
|
||||||
@@ -917,15 +951,21 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _linkResendSeconds > 0
|
onPressed: () {
|
||||||
? null
|
if (_linkResendSeconds > 0) {
|
||||||
: () {
|
_showInfo("재발송은 ${_formatTime(_linkResendSeconds)} 후 가능합니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
|
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
|
||||||
if (loginId.isEmpty) {
|
if (loginId.isEmpty) {
|
||||||
_showError("이메일 또는 휴대폰 번호를 입력해 주세요.");
|
_showError("이메일 또는 휴대폰 번호를 입력해 주세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_startEnchantedFlow(loginId, isEmail: _lastLinkIsEmail || loginId.contains('@'));
|
_startEnchantedFlow(
|
||||||
|
loginId,
|
||||||
|
isEmail: _lastLinkIsEmail || loginId.contains('@'),
|
||||||
|
codeOnly: false,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: Text(
|
child: Text(
|
||||||
_linkResendSeconds > 0
|
_linkResendSeconds > 0
|
||||||
@@ -933,6 +973,28 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
: "재발송",
|
: "재발송",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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)})"),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user