forked from baron/baron-sso
기본 발송 중간
This commit is contained in:
@@ -410,6 +410,7 @@ func main() {
|
|||||||
auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink)
|
auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink)
|
||||||
auth.Post("/magic-link/verify", authHandler.VerifyMagicLink)
|
auth.Post("/magic-link/verify", authHandler.VerifyMagicLink)
|
||||||
auth.Post("/login/code/verify", authHandler.VerifyLoginCode)
|
auth.Post("/login/code/verify", authHandler.VerifyLoginCode)
|
||||||
|
auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode)
|
||||||
auth.Post("/password/login", authHandler.PasswordLogin)
|
auth.Post("/password/login", authHandler.PasswordLogin)
|
||||||
auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset)
|
auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset)
|
||||||
// [Changed] Use Interstitial Page for GET to prevent Scanner consumption
|
// [Changed] Use Interstitial Page for GET to prevent Scanner consumption
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ type LinkLoginInit struct {
|
|||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
// Mode는 링크 로그인 완료 후 세션 처리 방식입니다. (예: "cookie")
|
// Mode는 링크 로그인 완료 후 세션 처리 방식입니다. (예: "cookie")
|
||||||
Mode string
|
Mode string
|
||||||
|
// LoginID는 IDP에 실제 전달된 식별자입니다.
|
||||||
|
LoginID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// IdentityProvider is the interface that all IDP adapters must implement.
|
// IdentityProvider is the interface that all IDP adapters must implement.
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ const (
|
|||||||
prefixSession = "enchanted_session:"
|
prefixSession = "enchanted_session:"
|
||||||
prefixToken = "enchanted_token:"
|
prefixToken = "enchanted_token:"
|
||||||
prefixLoginCode = "login_code_flow:"
|
prefixLoginCode = "login_code_flow:"
|
||||||
|
prefixLoginCodePending = "login_code_pending:"
|
||||||
|
prefixLoginCodeSmsTarget = "login_code_sms_target:"
|
||||||
|
prefixLoginCodeSmsLookup = "login_code_sms_lookup:"
|
||||||
|
prefixLoginCodeShort = "login_code_short:"
|
||||||
prefixPollMeta = "poll_meta:"
|
prefixPollMeta = "poll_meta:"
|
||||||
prefixSignupEmail = "signup:email:"
|
prefixSignupEmail = "signup:email:"
|
||||||
prefixSignupPhone = "signup:phone:"
|
prefixSignupPhone = "signup:phone:"
|
||||||
@@ -202,7 +206,7 @@ func (h *AuthHandler) SendSignupEmailCode(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"})
|
||||||
}
|
}
|
||||||
|
|
||||||
subject := "[Baron SSO] 회원가입 인증코드"
|
subject := "[Baron 통합로그인] 회원가입 인증코드"
|
||||||
body := fmt.Sprintf(`
|
body := fmt.Sprintf(`
|
||||||
<div style="padding: 20px; font-family: sans-serif;">
|
<div style="padding: 20px; font-family: sans-serif;">
|
||||||
<h2>이메일 인증</h2>
|
<h2>이메일 인증</h2>
|
||||||
@@ -252,7 +256,7 @@ func (h *AuthHandler) SendSignupSmsCode(c *fiber.Ctx) error {
|
|||||||
h.saveSignupState(key, newState, signupStateExpiration)
|
h.saveSignupState(key, newState, signupStateExpiration)
|
||||||
|
|
||||||
// 4. Send SMS
|
// 4. Send SMS
|
||||||
content := fmt.Sprintf("[Baron SSO] 인증번호 [%s]를 입력해주세요.", code)
|
content := fmt.Sprintf("[Baron 통합로그인] 인증번호 [%s]를 입력해주세요.", code)
|
||||||
go h.SmsService.SendSms(phone, content)
|
go h.SmsService.SendSms(phone, content)
|
||||||
|
|
||||||
return c.JSON(fiber.Map{"message": "Verification code sent"})
|
return c.JSON(fiber.Map{"message": "Verification code sent"})
|
||||||
@@ -535,7 +539,7 @@ func (h *AuthHandler) SendSms(c *fiber.Ctx) error {
|
|||||||
sanitizedPhone := strings.ReplaceAll(req.PhoneNumber, "-", "")
|
sanitizedPhone := strings.ReplaceAll(req.PhoneNumber, "-", "")
|
||||||
rand.Seed(time.Now().UnixNano())
|
rand.Seed(time.Now().UnixNano())
|
||||||
code := fmt.Sprintf("%06d", rand.Intn(1000000))
|
code := fmt.Sprintf("%06d", rand.Intn(1000000))
|
||||||
content := fmt.Sprintf("[Baron SSO] 인증번호: %s", code)
|
content := fmt.Sprintf("[Baron 통합로그인] 인증번호: %s", code)
|
||||||
|
|
||||||
h.RedisService.StoreVerificationCode(sanitizedPhone, code)
|
h.RedisService.StoreVerificationCode(sanitizedPhone, code)
|
||||||
if err := h.SmsService.SendSms(sanitizedPhone, content); err != nil {
|
if err := h.SmsService.SendSms(sanitizedPhone, content); err != nil {
|
||||||
@@ -621,8 +625,19 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if init, err := h.IdpProvider.InitiateLinkLogin(lookupLoginID, userfrontURL); err == nil && init != nil && init.Mode != "" {
|
if init, err := h.IdpProvider.InitiateLinkLogin(lookupLoginID, userfrontURL); err == nil && init != nil && init.Mode != "" {
|
||||||
|
keyLoginID := lookupLoginID
|
||||||
|
if init.LoginID != "" {
|
||||||
|
keyLoginID = init.LoginID
|
||||||
|
}
|
||||||
if init.FlowID != "" {
|
if init.FlowID != "" {
|
||||||
_ = h.RedisService.Set(prefixLoginCode+lookupLoginID, init.FlowID, loginCodeExpiration)
|
_ = h.RedisService.Set(prefixLoginCode+keyLoginID, init.FlowID, loginCodeExpiration)
|
||||||
|
}
|
||||||
|
pendingRef := GenerateSecureToken(3)
|
||||||
|
h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), loginCodeExpiration)
|
||||||
|
_ = h.RedisService.Set(prefixLoginCodePending+keyLoginID, pendingRef, loginCodeExpiration)
|
||||||
|
if !strings.Contains(loginID, "@") && keyLoginID != lookupLoginID {
|
||||||
|
_ = h.RedisService.Set(prefixLoginCodeSmsTarget+keyLoginID, lookupLoginID, loginCodeExpiration)
|
||||||
|
_ = h.RedisService.Set(prefixLoginCodeSmsLookup+lookupLoginID, keyLoginID, loginCodeExpiration)
|
||||||
}
|
}
|
||||||
expiresIn := 0
|
expiresIn := 0
|
||||||
if !init.ExpiresAt.IsZero() {
|
if !init.ExpiresAt.IsZero() {
|
||||||
@@ -630,7 +645,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"linkId": "Sent",
|
"linkId": "Sent",
|
||||||
"pendingRef": init.FlowID,
|
"pendingRef": pendingRef,
|
||||||
"maskedEmail": loginID,
|
"maskedEmail": loginID,
|
||||||
"mode": init.Mode,
|
"mode": init.Mode,
|
||||||
"provider": h.IdpProvider.Name(),
|
"provider": h.IdpProvider.Name(),
|
||||||
@@ -665,7 +680,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"})
|
||||||
}
|
}
|
||||||
|
|
||||||
subject := "[Baron SSO] 로그인 링크"
|
subject := "[Baron 통합로그인] 링크"
|
||||||
body := fmt.Sprintf(`
|
body := fmt.Sprintf(`
|
||||||
<div style="font-family: sans-serif; padding: 20px; border: 1px solid #eee; border-radius: 10px; max-width: 500px;">
|
<div style="font-family: sans-serif; padding: 20px; border: 1px solid #eee; border-radius: 10px; max-width: 500px;">
|
||||||
<h2 style="color: #1A1F2C;">Baron SSO 로그인</h2>
|
<h2 style="color: #1A1F2C;">Baron SSO 로그인</h2>
|
||||||
@@ -686,7 +701,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Send SMS
|
// Send SMS
|
||||||
content := fmt.Sprintf("[Baron SSO] 로그인 링크: %s | 코드: %s", link, userCode)
|
content := fmt.Sprintf("[Baron 통합로그인] 로그인 링크: %s | 코드: %s", link, userCode)
|
||||||
slog.Info("[Enchanted] Sending SMS via Naver Cloud", "loginID", loginID)
|
slog.Info("[Enchanted] Sending SMS via Naver Cloud", "loginID", loginID)
|
||||||
|
|
||||||
if err := h.SmsService.SendSms(loginID, content); err != nil {
|
if err := h.SmsService.SendSms(loginID, content); err != nil {
|
||||||
@@ -714,7 +729,7 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
pollKey := prefixPollMeta + "enchanted:" + req.PendingRef
|
pollKey := prefixPollMeta + "enchanted:" + req.PendingRef
|
||||||
if slowDown, interval := checkPollInterval(h.RedisService, pollKey, minPollInterval); slowDown {
|
if slowDown, interval := checkPollInterval(h.RedisService, pollKey, minPollInterval); slowDown {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"error": "slow_down",
|
"error": "slow_down",
|
||||||
"interval": interval,
|
"interval": interval,
|
||||||
})
|
})
|
||||||
@@ -722,7 +737,7 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
val, err := h.RedisService.Get(prefixSession + req.PendingRef)
|
val, err := h.RedisService.Get(prefixSession + req.PendingRef)
|
||||||
if err != nil || val == "" {
|
if err != nil || val == "" {
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "expired_token"})
|
return c.JSON(fiber.Map{"error": "expired_token"})
|
||||||
}
|
}
|
||||||
|
|
||||||
var data map[string]string
|
var data map[string]string
|
||||||
@@ -736,7 +751,7 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"error": "authorization_pending",
|
"error": "authorization_pending",
|
||||||
"interval": int(minPollInterval.Seconds()),
|
"interval": int(minPollInterval.Seconds()),
|
||||||
})
|
})
|
||||||
@@ -802,8 +817,9 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
|
|||||||
// VerifyLoginCode - Verify Kratos login code and issue session.
|
// VerifyLoginCode - Verify Kratos login code and issue session.
|
||||||
func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
|
func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
|
||||||
var req struct {
|
var req struct {
|
||||||
LoginID string `json:"loginId"`
|
LoginID string `json:"loginId"`
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
|
PendingRef string `json:"pendingRef"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
slog.Error("[LoginCode] Body parse error", "error", err)
|
slog.Error("[LoginCode] Body parse error", "error", err)
|
||||||
@@ -843,6 +859,110 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.RedisService.Delete(prefixLoginCode + lookupLoginID)
|
h.RedisService.Delete(prefixLoginCode + lookupLoginID)
|
||||||
|
h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID)
|
||||||
|
|
||||||
|
pendingRef := strings.TrimSpace(req.PendingRef)
|
||||||
|
if pendingRef == "" {
|
||||||
|
storedRef, _ := h.RedisService.Get(prefixLoginCodePending + lookupLoginID)
|
||||||
|
pendingRef = storedRef
|
||||||
|
}
|
||||||
|
if pendingRef != "" {
|
||||||
|
sessionData, _ := json.Marshal(map[string]string{
|
||||||
|
"status": statusSuccess,
|
||||||
|
"jwt": authInfo.SessionToken.JWT,
|
||||||
|
})
|
||||||
|
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
|
||||||
|
h.RedisService.Delete(prefixLoginCodePending + lookupLoginID)
|
||||||
|
h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID)
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"status": "approved",
|
||||||
|
"pendingRef": pendingRef,
|
||||||
|
"provider": h.IdpProvider.Name(),
|
||||||
|
"subject": authInfo.Subject,
|
||||||
|
"message": "Login approved",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"token": authInfo.SessionToken.JWT,
|
||||||
|
"sessionJwt": authInfo.SessionToken.JWT,
|
||||||
|
"provider": h.IdpProvider.Name(),
|
||||||
|
"subject": authInfo.Subject,
|
||||||
|
"message": "Login successful",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyLoginShortCode - Verify short code (2 letters + 6 digits) and issue/approve session.
|
||||||
|
func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
ShortCode string `json:"shortCode"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
slog.Error("[LoginShortCode] Body parse error", "error", err)
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
|
||||||
|
}
|
||||||
|
|
||||||
|
shortCode := strings.ToUpper(strings.TrimSpace(req.ShortCode))
|
||||||
|
if shortCode == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "shortCode is required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
val, _ := h.RedisService.Get(prefixLoginCodeShort + shortCode)
|
||||||
|
if val == "" {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired code"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload shortLoginCodePayload
|
||||||
|
if err := json.Unmarshal([]byte(val), &payload); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Invalid code payload"})
|
||||||
|
}
|
||||||
|
if payload.LoginID == "" || payload.Code == "" {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired code"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.IdpProvider == nil {
|
||||||
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
|
||||||
|
}
|
||||||
|
|
||||||
|
flowID, err := h.RedisService.Get(prefixLoginCode + payload.LoginID)
|
||||||
|
if err != nil || flowID == "" {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Login flow expired"})
|
||||||
|
}
|
||||||
|
|
||||||
|
authInfo, err := h.IdpProvider.VerifyLoginCode(payload.LoginID, flowID, payload.Code)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrNotSupported) {
|
||||||
|
return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "Login method not supported"})
|
||||||
|
}
|
||||||
|
slog.Error("[LoginShortCode] Verify failed", "loginID", payload.LoginID, "error", err)
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid code"})
|
||||||
|
}
|
||||||
|
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
|
||||||
|
}
|
||||||
|
|
||||||
|
h.RedisService.Delete(prefixLoginCode + payload.LoginID)
|
||||||
|
h.RedisService.Delete(prefixLoginCodeShort + shortCode)
|
||||||
|
h.RedisService.Delete(prefixLoginCodeSmsTarget + payload.LoginID)
|
||||||
|
h.RedisService.Delete(prefixLoginCodeSmsLookup + payload.LoginID)
|
||||||
|
|
||||||
|
if payload.PendingRef != "" {
|
||||||
|
sessionData, _ := json.Marshal(map[string]string{
|
||||||
|
"status": statusSuccess,
|
||||||
|
"jwt": authInfo.SessionToken.JWT,
|
||||||
|
})
|
||||||
|
h.RedisService.Set(prefixSession+payload.PendingRef, string(sessionData), loginCodeExpiration)
|
||||||
|
h.RedisService.Delete(prefixLoginCodePending + payload.LoginID)
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"status": "approved",
|
||||||
|
"pendingRef": payload.PendingRef,
|
||||||
|
"token": authInfo.SessionToken.JWT,
|
||||||
|
"sessionJwt": authInfo.SessionToken.JWT,
|
||||||
|
"provider": h.IdpProvider.Name(),
|
||||||
|
"subject": authInfo.Subject,
|
||||||
|
"message": "Login approved",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"token": authInfo.SessionToken.JWT,
|
"token": authInfo.SessionToken.JWT,
|
||||||
@@ -998,7 +1118,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
|
|||||||
ale.Log(slog.LevelError, "Email service not configured")
|
ale.Log(slog.LevelError, "Email service not configured")
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"})
|
||||||
}
|
}
|
||||||
subject := "[Baron SSO] 비밀번호 재설정"
|
subject := "[Baron 통합로그인] 비밀번호 재설정"
|
||||||
body := fmt.Sprintf(`
|
body := fmt.Sprintf(`
|
||||||
<div style="font-family: sans-serif; padding: 20px; border: 1px solid #eee; border-radius: 10px; max-width: 500px;">
|
<div style="font-family: sans-serif; padding: 20px; border: 1px solid #eee; border-radius: 10px; max-width: 500px;">
|
||||||
<h2 style="color: #1A1F2C;">Baron SSO 비밀번호 재설정</h2>
|
<h2 style="color: #1A1F2C;">Baron SSO 비밀번호 재설정</h2>
|
||||||
@@ -1017,7 +1137,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send reset email"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send reset email"})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if err := h.SmsService.SendSms(loginID, fmt.Sprintf("[Baron SSO] 비밀번호 재설정 링크: %s", resetLink)); err != nil {
|
if err := h.SmsService.SendSms(loginID, fmt.Sprintf("[Baron 통합로그인] 비밀번호 재설정 링크: %s", resetLink)); err != nil {
|
||||||
ale.Status = fiber.StatusInternalServerError
|
ale.Status = fiber.StatusInternalServerError
|
||||||
ale.LatencyMs = time.Since(startTime)
|
ale.LatencyMs = time.Since(startTime)
|
||||||
ale.DescopeError = err.Error()
|
ale.DescopeError = err.Error()
|
||||||
@@ -1350,6 +1470,22 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Empty message"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Empty message"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.Contains(req.Recipient, "@") {
|
||||||
|
if target, _ := h.RedisService.Get(prefixLoginCodeSmsTarget + req.Recipient); target != "" {
|
||||||
|
phone := sanitizePhoneForSms(target)
|
||||||
|
smsBody := h.buildKratosShortSmsBody(&req, req.Recipient, phone)
|
||||||
|
if smsBody == "" {
|
||||||
|
smsBody = body
|
||||||
|
}
|
||||||
|
if err := h.SmsService.SendSms(phone, smsBody); err != nil {
|
||||||
|
slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
|
||||||
|
}
|
||||||
|
slog.Info("[Kratos Courier] SMS sent (email relay)", "to", phone, "template", req.TemplateType)
|
||||||
|
return c.JSON(fiber.Map{"status": "ok"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if strings.Contains(req.Recipient, "@") {
|
if strings.Contains(req.Recipient, "@") {
|
||||||
if h.EmailService == nil {
|
if h.EmailService == nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"})
|
||||||
@@ -1366,7 +1502,20 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "SMS service not configured"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "SMS service not configured"})
|
||||||
}
|
}
|
||||||
phone := sanitizePhoneForSms(req.Recipient)
|
phone := sanitizePhoneForSms(req.Recipient)
|
||||||
if err := h.SmsService.SendSms(phone, body); err != nil {
|
loginID := req.Recipient
|
||||||
|
if !strings.Contains(loginID, "@") {
|
||||||
|
lookup := normalizePhoneForLoginID(loginID)
|
||||||
|
if email, _ := h.RedisService.Get(prefixLoginCodeSmsLookup + lookup); email != "" {
|
||||||
|
loginID = email
|
||||||
|
} else {
|
||||||
|
loginID = lookup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
smsBody := h.buildKratosShortSmsBody(&req, loginID, phone)
|
||||||
|
if smsBody == "" {
|
||||||
|
smsBody = body
|
||||||
|
}
|
||||||
|
if err := h.SmsService.SendSms(phone, smsBody); err != nil {
|
||||||
slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err)
|
slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
|
||||||
}
|
}
|
||||||
@@ -1379,7 +1528,7 @@ func (h *AuthHandler) buildKratosCourierMessage(req *kratosCourierRequest) (stri
|
|||||||
body := strings.TrimSpace(req.Body)
|
body := strings.TrimSpace(req.Body)
|
||||||
if body != "" || subject != "" {
|
if body != "" || subject != "" {
|
||||||
if subject == "" {
|
if subject == "" {
|
||||||
subject = "[Baron SSO] 알림"
|
subject = "[Baron 통합로그인] 알림"
|
||||||
}
|
}
|
||||||
return subject, body
|
return subject, body
|
||||||
}
|
}
|
||||||
@@ -1403,23 +1552,38 @@ func (h *AuthHandler) buildKratosCourierMessage(req *kratosCourierRequest) (stri
|
|||||||
|
|
||||||
if subject == "" {
|
if subject == "" {
|
||||||
if label == "알림" {
|
if label == "알림" {
|
||||||
subject = "[Baron SSO] 알림"
|
subject = "[Baron 통합로그인] 알림"
|
||||||
} else {
|
} else {
|
||||||
subject = fmt.Sprintf("[Baron SSO] %s 코드", label)
|
subject = fmt.Sprintf("[Baron 통합로그인] %s 코드", label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if code == "" {
|
if code == "" {
|
||||||
return subject, fmt.Sprintf("[Baron SSO] %s 요청이 도착했습니다", label)
|
return subject, fmt.Sprintf("[Baron 통합로그인] %s 요청이 도착했습니다", label)
|
||||||
}
|
}
|
||||||
|
|
||||||
message := fmt.Sprintf("[Baron SSO] %s 코드: %s", label, code)
|
message := fmt.Sprintf("[Baron 통합로그인] %s 코드: %s", label, code)
|
||||||
if label == "로그인" {
|
if label == "로그인" {
|
||||||
baseURL := os.Getenv("USERFRONT_URL")
|
baseURL := os.Getenv("USERFRONT_URL")
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
baseURL = "http://localhost:5000"
|
baseURL = "http://localhost:5000"
|
||||||
}
|
}
|
||||||
baseURL = strings.TrimRight(baseURL, "/")
|
baseURL = strings.TrimRight(baseURL, "/")
|
||||||
|
loginID := req.Recipient
|
||||||
|
if !strings.Contains(loginID, "@") {
|
||||||
|
loginID = normalizePhoneForLoginID(loginID)
|
||||||
|
}
|
||||||
|
pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID)
|
||||||
|
if pendingRef != "" {
|
||||||
|
message = fmt.Sprintf("%s | 링크: %s/verify?loginId=%s&code=%s&pendingRef=%s",
|
||||||
|
message,
|
||||||
|
baseURL,
|
||||||
|
url.QueryEscape(req.Recipient),
|
||||||
|
url.QueryEscape(code),
|
||||||
|
url.QueryEscape(pendingRef),
|
||||||
|
)
|
||||||
|
return subject, message
|
||||||
|
}
|
||||||
link := fmt.Sprintf("%s/verify?loginId=%s&code=%s",
|
link := fmt.Sprintf("%s/verify?loginId=%s&code=%s",
|
||||||
baseURL,
|
baseURL,
|
||||||
url.QueryEscape(req.Recipient),
|
url.QueryEscape(req.Recipient),
|
||||||
@@ -1431,6 +1595,78 @@ func (h *AuthHandler) buildKratosCourierMessage(req *kratosCourierRequest) (stri
|
|||||||
return subject, message
|
return subject, message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type shortLoginCodePayload struct {
|
||||||
|
LoginID string `json:"loginId"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
PendingRef string `json:"pendingRef"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) buildKratosShortSmsBody(req *kratosCourierRequest, loginID, phone string) string {
|
||||||
|
if req == nil || loginID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code"))
|
||||||
|
if code == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
shortCode := h.generateShortCode(code)
|
||||||
|
if shortCode == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID)
|
||||||
|
payload := shortLoginCodePayload{
|
||||||
|
LoginID: loginID,
|
||||||
|
Code: code,
|
||||||
|
PendingRef: pendingRef,
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(payload)
|
||||||
|
_ = h.RedisService.Set(prefixLoginCodeShort+shortCode, string(raw), loginCodeExpiration)
|
||||||
|
|
||||||
|
baseURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "http://localhost:5000"
|
||||||
|
}
|
||||||
|
|
||||||
|
link := fmt.Sprintf("%s/l/%s", baseURL, shortCode)
|
||||||
|
return fmt.Sprintf("[Baron 통합로그인] %s", link)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) generateShortCode(code string) string {
|
||||||
|
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
b := make([]byte, 2)
|
||||||
|
if _, err := crand.Read(b); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
prefix := string(letters[int(b[0])%len(letters)]) + string(letters[int(b[1])%len(letters)])
|
||||||
|
shortCode := prefix + code
|
||||||
|
if val, _ := h.RedisService.Get(prefixLoginCodeShort + shortCode); val == "" {
|
||||||
|
return shortCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLoginCode(code string) string {
|
||||||
|
if code == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
digits := make([]rune, 0, len(code))
|
||||||
|
for _, ch := range code {
|
||||||
|
if ch >= '0' && ch <= '9' {
|
||||||
|
digits = append(digits, ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(digits) < 6 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(digits) > 6 {
|
||||||
|
digits = digits[:6]
|
||||||
|
}
|
||||||
|
return string(digits)
|
||||||
|
}
|
||||||
|
|
||||||
func firstNonEmpty(values ...string) string {
|
func firstNonEmpty(values ...string) string {
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
if value != "" {
|
if value != "" {
|
||||||
@@ -1941,7 +2177,7 @@ func (h *AuthHandler) SendUpdateCode(c *fiber.Ctx) error {
|
|||||||
h.RedisService.Set(key, code, 5*time.Minute)
|
h.RedisService.Set(key, code, 5*time.Minute)
|
||||||
|
|
||||||
// Send SMS
|
// Send SMS
|
||||||
content := fmt.Sprintf("[Baron SSO] 정보 수정 인증번호: [%s]", code)
|
content := fmt.Sprintf("[Baron 통합로그인] 정보 수정 인증번호: [%s]", code)
|
||||||
go h.SmsService.SendSms(phone, content)
|
go h.SmsService.SendSms(phone, content)
|
||||||
|
|
||||||
return c.JSON(fiber.Map{"message": "인증번호가 전송되었습니다."})
|
return c.JSON(fiber.Map{"message": "인증번호가 전송되었습니다."})
|
||||||
|
|||||||
@@ -232,22 +232,64 @@ func (o *OryProvider) InitiateLinkLogin(loginID, returnTo string) (*domain.LinkL
|
|||||||
return nil, fmt.Errorf("ory provider: loginID is required")
|
return nil, fmt.Errorf("ory provider: loginID is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
init, err := o.submitLoginCodeInit(loginID, returnTo)
|
effectiveLoginID, err := o.resolveEffectiveLoginID(loginID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := o.ensureCodeLoginIdentifier(effectiveLoginID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
init, err := o.submitLoginCodeInit(effectiveLoginID, returnTo)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
init.LoginID = effectiveLoginID
|
||||||
return init, nil
|
return init, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if shouldBootstrapCodeLogin(err) {
|
if shouldBootstrapCodeLogin(err) {
|
||||||
if ensureErr := o.ensureCodeLoginIdentifier(loginID); ensureErr == nil {
|
if ensureErr := o.ensureCodeLoginIdentifier(effectiveLoginID); ensureErr == nil {
|
||||||
return o.submitLoginCodeInit(loginID, returnTo)
|
init, initErr := o.submitLoginCodeInit(effectiveLoginID, returnTo)
|
||||||
|
if initErr == nil {
|
||||||
|
init.LoginID = effectiveLoginID
|
||||||
|
}
|
||||||
|
return init, initErr
|
||||||
} else {
|
} else {
|
||||||
slog.Warn("Ory code login bootstrap failed", "loginID", loginID, "error", ensureErr)
|
slog.Warn("Ory code login bootstrap failed", "loginID", effectiveLoginID, "error", ensureErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (o *OryProvider) resolveEffectiveLoginID(loginID string) (string, error) {
|
||||||
|
if strings.Contains(loginID, "@") {
|
||||||
|
return loginID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
identityID, err := o.findIdentityID(loginID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if identityID == "" {
|
||||||
|
return "", fmt.Errorf("ory provider: identity not found for loginID=%s", loginID)
|
||||||
|
}
|
||||||
|
|
||||||
|
fullIdentity, err := o.fetchIdentityFull(identityID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if fullIdentity != nil {
|
||||||
|
if emailRaw, ok := fullIdentity.Traits["email"]; ok {
|
||||||
|
if email, ok := emailRaw.(string); ok && email != "" {
|
||||||
|
return email, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("ory provider: email trait missing for loginID=%s", loginID)
|
||||||
|
}
|
||||||
|
|
||||||
func (o *OryProvider) submitLoginCodeInit(loginID, returnTo string) (*domain.LinkLoginInit, error) {
|
func (o *OryProvider) submitLoginCodeInit(loginID, returnTo string) (*domain.LinkLoginInit, error) {
|
||||||
flowID, err := o.startLoginFlow(returnTo)
|
flowID, err := o.startLoginFlow(returnTo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -404,13 +446,83 @@ func (o *OryProvider) ensureCodeLoginIdentifier(loginID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return o.patchIdentity(identityID, ops)
|
if err := o.patchIdentity(identityID, ops); err != nil {
|
||||||
|
slog.Warn("Ory identity patch failed, trying full update", "identity_id", identityID, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fullIdentity, err := o.fetchIdentityFull(identityID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
addresses = make([]kratosVerifiableAddress, 0, len(fullIdentity.VerifiableAddresses)+1)
|
||||||
|
found := false
|
||||||
|
for _, addr := range fullIdentity.VerifiableAddresses {
|
||||||
|
addresses = append(addresses, kratosVerifiableAddress{
|
||||||
|
Value: addr.Value,
|
||||||
|
Via: addr.Via,
|
||||||
|
Verified: addr.Verified,
|
||||||
|
Status: addr.Status,
|
||||||
|
})
|
||||||
|
if addr.Value == loginID && addr.Via == via {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
addresses = append(addresses, kratosVerifiableAddress{
|
||||||
|
Value: loginID,
|
||||||
|
Via: via,
|
||||||
|
Verified: true,
|
||||||
|
Status: "completed",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"schema_id": fullIdentity.SchemaID,
|
||||||
|
"traits": fullIdentity.Traits,
|
||||||
|
"verifiable_addresses": addresses,
|
||||||
|
}
|
||||||
|
if len(fullIdentity.RecoveryAddresses) > 0 {
|
||||||
|
payload["recovery_addresses"] = fullIdentity.RecoveryAddresses
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPut, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ory provider: build identity update failed: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := o.httpClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ory provider: identity update failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||||
|
return fmt.Errorf("ory provider: identity update failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Ory identity updated with verifiable address", "identity_id", identityID, "loginID", loginID, "via", via)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type kratosIdentity struct {
|
type kratosIdentity struct {
|
||||||
VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"`
|
VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type kratosRecoveryAddress struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Via string `json:"via"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type kratosIdentityFull struct {
|
||||||
|
SchemaID string `json:"schema_id"`
|
||||||
|
Traits map[string]interface{} `json:"traits"`
|
||||||
|
VerifiableAddresses []kratosVerifiableAddress `json:"verifiable_addresses"`
|
||||||
|
RecoveryAddresses []kratosRecoveryAddress `json:"recovery_addresses"`
|
||||||
|
}
|
||||||
|
|
||||||
func (o *OryProvider) patchIdentity(identityID string, ops []map[string]interface{}) error {
|
func (o *OryProvider) patchIdentity(identityID string, ops []map[string]interface{}) error {
|
||||||
body, _ := json.Marshal(ops)
|
body, _ := json.Marshal(ops)
|
||||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), bytes.NewReader(body))
|
||||||
@@ -457,6 +569,30 @@ func (o *OryProvider) fetchIdentity(identityID string) (*kratosIdentity, error)
|
|||||||
return &identity, nil
|
return &identity, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (o *OryProvider) fetchIdentityFull(identityID string) (*kratosIdentityFull, error) {
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf("%s/admin/identities/%s", o.KratosAdminURL, identityID), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ory provider: build identity get failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := o.httpClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ory provider: identity get failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||||
|
return nil, fmt.Errorf("ory provider: identity get failed status=%d body=%s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity kratosIdentityFull
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil {
|
||||||
|
return nil, fmt.Errorf("ory provider: decode identity failed: %w", err)
|
||||||
|
}
|
||||||
|
return &identity, nil
|
||||||
|
}
|
||||||
|
|
||||||
// VerifyLoginCode는 Kratos 로그인 코드 제출로 세션을 발급합니다.
|
// VerifyLoginCode는 Kratos 로그인 코드 제출로 세션을 발급합니다.
|
||||||
func (o *OryProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
|
func (o *OryProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.AuthInfo, error) {
|
||||||
if loginID == "" || flowID == "" || code == "" {
|
if loginID == "" || flowID == "" || code == "" {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ services:
|
|||||||
clickhouse:
|
clickhouse:
|
||||||
image: clickhouse/clickhouse-server:latest
|
image: clickhouse/clickhouse-server:latest
|
||||||
container_name: baron_clickhouse
|
container_name: baron_clickhouse
|
||||||
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-baron}
|
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-baron}
|
||||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-password}
|
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-password}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
[Baron SSO] 로그인 링크
|
[Baron 통합로그인] 로그인 링크
|
||||||
# 운영 링크: https://app.brsw.kr/verify?loginId={{ .To }}&code={{ .LoginCode }}
|
# 운영 링크: https://app.brsw.kr/verify?loginId={{ .To }}&code={{ .LoginCode }}
|
||||||
http://localhost:5000/verify?loginId={{ .To }}&code={{ .LoginCode }}
|
http://localhost:5000/verify?loginId={{ .To }}&code={{ .LoginCode }}
|
||||||
코드: {{ .LoginCode }}
|
코드: {{ .LoginCode }}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ def main():
|
|||||||
"contentType": "COMM",
|
"contentType": "COMM",
|
||||||
"countryCode": "82",
|
"countryCode": "82",
|
||||||
"from": sender_phone,
|
"from": sender_phone,
|
||||||
"content": "[Baron SSO] Test message from Python script.",
|
"content": "[Baron 통합로그인] Test message from Python script.",
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"to": recipient_phone
|
"to": recipient_phone
|
||||||
|
|||||||
@@ -104,15 +104,38 @@ class AuthProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> verifyLoginCode(String loginId, String code) async {
|
static Future<Map<String, dynamic>> verifyLoginCode(String loginId, String code, {String? pendingRef}) async {
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/login/code/verify');
|
final url = Uri.parse('$_baseUrl/api/v1/auth/login/code/verify');
|
||||||
|
|
||||||
|
final payload = {
|
||||||
|
'loginId': loginId,
|
||||||
|
'code': code,
|
||||||
|
};
|
||||||
|
if (pendingRef != null && pendingRef.isNotEmpty) {
|
||||||
|
payload['pendingRef'] = pendingRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode(payload),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return jsonDecode(response.body);
|
||||||
|
} else {
|
||||||
|
throw Exception('Verification failed: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> verifyLoginShortCode(String shortCode) async {
|
||||||
|
final url = Uri.parse('$_baseUrl/api/v1/auth/login/code/verify-short');
|
||||||
|
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: jsonEncode({
|
body: jsonEncode({
|
||||||
'loginId': loginId,
|
'shortCode': shortCode,
|
||||||
'code': code,
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
int _qrRemainingSeconds = 0;
|
int _qrRemainingSeconds = 0;
|
||||||
Timer? _qrCountdownTimer;
|
Timer? _qrCountdownTimer;
|
||||||
int _qrPollIntervalMs = 2000;
|
int _qrPollIntervalMs = 2000;
|
||||||
|
final TextEditingController _shortCodePrefixController = TextEditingController();
|
||||||
|
final TextEditingController _shortCodeDigitsController = TextEditingController();
|
||||||
|
String? _linkPendingRef;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -52,8 +55,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final uri = Uri.base;
|
final uri = Uri.base;
|
||||||
final loginIdParam = uri.queryParameters['loginId'];
|
final loginIdParam = uri.queryParameters['loginId'];
|
||||||
final codeParam = uri.queryParameters['code'];
|
final codeParam = uri.queryParameters['code'];
|
||||||
|
final pendingRefParam = uri.queryParameters['pendingRef'];
|
||||||
|
if (uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l') {
|
||||||
|
final shortCode = uri.pathSegments[1];
|
||||||
|
_verifyShortCode(shortCode);
|
||||||
|
}
|
||||||
if (loginIdParam != null && codeParam != null) {
|
if (loginIdParam != null && codeParam != null) {
|
||||||
_verifyLoginCode(loginIdParam, codeParam);
|
_verifyLoginCode(loginIdParam, codeParam, pendingRef: pendingRefParam);
|
||||||
} else if (widget.verificationToken != null) {
|
} else if (widget.verificationToken != null) {
|
||||||
_verifyToken(widget.verificationToken!);
|
_verifyToken(widget.verificationToken!);
|
||||||
} else if (uri.queryParameters.containsKey('t')) {
|
} else if (uri.queryParameters.containsKey('t')) {
|
||||||
@@ -101,6 +109,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _resetLinkLoginState() {
|
||||||
|
_linkPendingRef = null;
|
||||||
|
_shortCodePrefixController.clear();
|
||||||
|
_shortCodeDigitsController.clear();
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to decode JWT and get loginId
|
// Helper to decode JWT and get loginId
|
||||||
String _getLoginIdFromJwt(String jwt) {
|
String _getLoginIdFromJwt(String jwt) {
|
||||||
try {
|
try {
|
||||||
@@ -306,14 +320,26 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _verifyLoginCode(String loginId, String code) async {
|
Future<void> _verifyLoginCode(String loginId, String code, {String? pendingRef}) async {
|
||||||
final sanitizedLoginId = loginId.replaceAll(' ', '+');
|
final sanitizedLoginId = loginId.replaceAll(' ', '+');
|
||||||
debugPrint("[Auth] Starting code verification for loginId: $sanitizedLoginId");
|
debugPrint("[Auth] Starting code verification for loginId: $sanitizedLoginId");
|
||||||
try {
|
try {
|
||||||
final res = await AuthProxyService.verifyLoginCode(sanitizedLoginId, code);
|
final res = await AuthProxyService.verifyLoginCode(
|
||||||
|
sanitizedLoginId,
|
||||||
|
code,
|
||||||
|
pendingRef: pendingRef,
|
||||||
|
);
|
||||||
final jwt = res['sessionJwt'] ?? res['token'];
|
final jwt = res['sessionJwt'] ?? res['token'];
|
||||||
|
final status = res['status']?.toString();
|
||||||
debugPrint("[Auth] Code verification successful for loginId: $sanitizedLoginId");
|
debugPrint("[Auth] Code verification successful for loginId: $sanitizedLoginId");
|
||||||
|
|
||||||
|
if (jwt == null && status == 'approved') {
|
||||||
|
if (mounted) {
|
||||||
|
_showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (jwt != null && mounted) {
|
if (jwt != null && mounted) {
|
||||||
final isJwt = (jwt as String).split('.').length == 3;
|
final isJwt = (jwt as String).split('.').length == 3;
|
||||||
if (isJwt) {
|
if (isJwt) {
|
||||||
@@ -334,6 +360,43 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _verifyShortCode(String shortCode) async {
|
||||||
|
final sanitized = shortCode.trim().toUpperCase();
|
||||||
|
if (sanitized.isEmpty) return;
|
||||||
|
debugPrint("[Auth] Starting short code verification for code: $sanitized");
|
||||||
|
try {
|
||||||
|
final res = await AuthProxyService.verifyLoginShortCode(sanitized);
|
||||||
|
final jwt = res['sessionJwt'] ?? res['token'];
|
||||||
|
final status = res['status']?.toString();
|
||||||
|
debugPrint("[Auth] Short code verification successful");
|
||||||
|
|
||||||
|
if (jwt == null && status == 'approved') {
|
||||||
|
if (mounted) {
|
||||||
|
_showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jwt != null && mounted) {
|
||||||
|
final isJwt = (jwt as String).split('.').length == 3;
|
||||||
|
if (isJwt) {
|
||||||
|
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, provider: res['provider'] as String?);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("[Auth] Short code verification FAILED. Error: $e");
|
||||||
|
if (mounted) {
|
||||||
|
_showError("Verification failed: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_stopQrPolling();
|
_stopQrPolling();
|
||||||
@@ -341,6 +404,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_linkIdController.dispose();
|
_linkIdController.dispose();
|
||||||
_passwordLoginIdController.dispose();
|
_passwordLoginIdController.dispose();
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
|
_shortCodePrefixController.dispose();
|
||||||
|
_shortCodeDigitsController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,61 +499,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
debugPrint("[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider");
|
debugPrint("[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider");
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_linkPendingRef = pendingRef?.toString();
|
||||||
|
});
|
||||||
Navigator.of(context).pop(); // Close Loading
|
Navigator.of(context).pop(); // Close Loading
|
||||||
|
|
||||||
if (mode == 'link' || provider.toLowerCase().contains('ory')) {
|
_showInfo(isEmail
|
||||||
showDialog(
|
? "입력하신 이메일로 로그인 링크를 보냈습니다."
|
||||||
context: context,
|
: "입력하신 번호로 로그인 링크를 보냈습니다.");
|
||||||
barrierDismissible: false,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: Text(isEmail ? "이메일 전송됨" : "SMS 전송됨"),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(isEmail
|
|
||||||
? "입력하신 이메일로 로그인 링크를 보냈습니다."
|
|
||||||
: "입력하신 번호로 로그인 링크를 보냈습니다."),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
const Text("메일/문자 링크를 열면 이 탭에서 자동으로 로그인됩니다."),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
child: const Text("닫기"),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
barrierDismissible: false,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: Text(isEmail ? "이메일 전송됨" : "SMS 전송됨"),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(isEmail
|
|
||||||
? "입력하신 이메일로 로그인 링크를 보냈습니다."
|
|
||||||
: "입력하신 번호로 로그인 링크를 보냈습니다."),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const LinearProgressIndicator(),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
debugPrint("[Auth] Polling canceled by user");
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
child: const Text("취소"),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. Poll Backend manually
|
// 2. Poll Backend manually
|
||||||
final initialInterval = (interval is int && interval > 0)
|
final initialInterval = (interval is int && interval > 0)
|
||||||
@@ -499,6 +517,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("[Auth] Initialization failed: $e");
|
debugPrint("[Auth] Initialization failed: $e");
|
||||||
if (mounted && Navigator.canPop(context)) Navigator.of(context).pop();
|
if (mounted && Navigator.canPop(context)) Navigator.of(context).pop();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_linkPendingRef = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
if (e.toString().contains("User not registered")) {
|
if (e.toString().contains("User not registered")) {
|
||||||
_showUnregisteredDialog();
|
_showUnregisteredDialog();
|
||||||
} else {
|
} else {
|
||||||
@@ -584,6 +607,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showInfo(String message) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message), backgroundColor: Colors.green),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _logTokenDetails(String jwt) {
|
void _logTokenDetails(String jwt) {
|
||||||
try {
|
try {
|
||||||
final parts = jwt.split('.');
|
final parts = jwt.split('.');
|
||||||
@@ -682,6 +712,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
|
_resetLinkLoginState();
|
||||||
context.push('/signup');
|
context.push('/signup');
|
||||||
},
|
},
|
||||||
child: const Text("회원가입 하기"),
|
child: const Text("회원가입 하기"),
|
||||||
@@ -769,35 +800,90 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 2. 로그인 링크 전송 폼
|
// 2. 로그인 링크 전송 -> 전송 후 코드 입력으로 전환
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 16.0),
|
padding: const EdgeInsets.only(top: 16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
if (_linkPendingRef == null) ...[
|
||||||
controller: _linkIdController,
|
TextField(
|
||||||
decoration: const InputDecoration(
|
controller: _linkIdController,
|
||||||
labelText: "이메일 또는 휴대폰 번호",
|
decoration: const InputDecoration(
|
||||||
hintText: "",
|
labelText: "이메일 또는 휴대폰 번호",
|
||||||
border: OutlineInputBorder(),
|
hintText: "",
|
||||||
prefixIcon: Icon(Icons.person_outline),
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.person_outline),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _handleLinkLogin(),
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => _handleLinkLogin(),
|
const SizedBox(height: 24),
|
||||||
),
|
FilledButton(
|
||||||
const SizedBox(height: 24),
|
onPressed: _handleLinkLogin,
|
||||||
FilledButton(
|
style: FilledButton.styleFrom(
|
||||||
onPressed: _handleLinkLogin,
|
minimumSize: const Size.fromHeight(50),
|
||||||
style: FilledButton.styleFrom(
|
),
|
||||||
minimumSize: const Size.fromHeight(50),
|
child: const Text("로그인 링크 전송"),
|
||||||
),
|
),
|
||||||
child: const Text("로그인 링크 전송"),
|
const SizedBox(height: 24),
|
||||||
),
|
const Text(
|
||||||
const SizedBox(height: 24),
|
"입력하신 정보로 로그인 링크를 전송합니다.",
|
||||||
const Text(
|
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||||
"입력하신 정보로 로그인 링크를 전송합니다.",
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
),
|
||||||
textAlign: TextAlign.center,
|
],
|
||||||
),
|
if (_linkPendingRef != null) ...[
|
||||||
|
const Text(
|
||||||
|
"링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.",
|
||||||
|
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: TextField(
|
||||||
|
controller: _shortCodePrefixController,
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: "AA",
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
maxLength: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
flex: 4,
|
||||||
|
child: TextField(
|
||||||
|
controller: _shortCodeDigitsController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: "000000",
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
maxLength: 6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final prefix = _shortCodePrefixController.text.trim().toUpperCase();
|
||||||
|
final digits = _shortCodeDigitsController.text.trim();
|
||||||
|
if (prefix.length != 2 || digits.length != 6) {
|
||||||
|
_showError("문자 2개와 숫자 6자리를 입력해 주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_verifyShortCode(prefix + digits);
|
||||||
|
},
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(45),
|
||||||
|
),
|
||||||
|
child: const Text("코드로 로그인"),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -124,6 +124,14 @@ final _router = GoRouter(
|
|||||||
return LoginScreen(verificationToken: token);
|
return LoginScreen(verificationToken: token);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/l/:shortCode',
|
||||||
|
builder: (context, state) {
|
||||||
|
final shortCode = state.pathParameters['shortCode'];
|
||||||
|
_routerLogger.info("Navigating to /l with code: $shortCode");
|
||||||
|
return const LoginScreen();
|
||||||
|
},
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/forgot-password',
|
path: '/forgot-password',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
|
|||||||
Reference in New Issue
Block a user