1
0
forked from baron/baron-sso

feat: add robust login ID collision prevention and UI validation (#440)

- Add `ValidateLoginID` to enforce ID collision and security rules (prevents phone number collision, email format usage, and reserved words).
- Add `POST /api/v1/auth/signup/check-login-id` endpoint for real-time ID availability checks.
- Add `checkLoginIDAvailability` API call to userfront's `AuthProxyService`.
- Implement "Check Duplication" button and error/success messaging for the Login ID field in the signup screen.
- Add "000000" magic code bypass for `VerifySignupCode` in non-production environments to streamline testing.
This commit is contained in:
2026-03-27 11:19:28 +09:00
parent aa60a22d57
commit 75cc6737bd
10 changed files with 257 additions and 14 deletions

View File

@@ -194,6 +194,35 @@ func (h *AuthHandler) CheckEmail(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"available": true})
}
// CheckLoginID - 로그인 ID 사용 가능 여부를 확인합니다.
func (h *AuthHandler) CheckLoginID(c *fiber.Ctx) error {
var req domain.CheckLoginIDRequest
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "Invalid request")
}
if h.IdpProvider == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
}
// Basic validation via our ValidateLoginID helper (without email/phone since we just check format & collision with reserved words)
if err := domain.ValidateLoginID(req.LoginID, "", ""); err != nil {
return c.JSON(fiber.Map{"available": false, "message": err.Error()})
}
// We don't prepend companyCode to Kratos lookup if traits.id is unique globally
// Assuming Kratos traits.id handles unique constraints per tenant or globally based on schema
exists, err := h.IdpProvider.UserExists(req.LoginID)
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "Identity provider unavailable")
}
if exists {
return c.JSON(fiber.Map{"available": false, "message": "ID already registered"})
}
return c.JSON(fiber.Map{"available": true})
}
// SendSignupEmailCode - Sends verification code to email
func (h *AuthHandler) SendSignupEmailCode(c *fiber.Ctx) error {
var req domain.SendSignupCodeRequest
@@ -329,8 +358,9 @@ func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusTooManyRequests, "Too many failed attempts")
}
// Check Code match
if state.Code != req.Code {
// Check Code match (Allow magic code 000000 in non-production environments)
isMagicCodeAllowed := service.IsDryRunAllowed() && req.Code == "000000"
if state.Code != req.Code && !isMagicCodeAllowed {
state.FailCount++
h.saveSignupState(key, state, signupStateExpiration)
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
@@ -465,9 +495,14 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
}
}
finalLoginID := extractTraitString(attributes, "id")
if err := domain.ValidateLoginID(finalLoginID, req.Email, normalizedPhone); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
brokerUser := &domain.BrokerUser{
Email: req.Email,
LoginID: extractTraitString(attributes, "id"),
LoginID: finalLoginID,
Name: req.Name,
PhoneNumber: normalizedPhone,
Attributes: attributes,
@@ -5315,6 +5350,13 @@ func (h *AuthHandler) UpdateMe(c *fiber.Ctx) error {
}
}
finalLoginID := extractTraitString(traits, "id")
userEmail := extractTraitString(traits, "email")
userPhone := extractTraitString(traits, "phone")
if err := domain.ValidateLoginID(finalLoginID, userEmail, userPhone); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
if err := h.updateKratosIdentity(identityID, traits); err != nil {
slog.Error("Failed to update profile in Kratos", "error", err)
return errorJSON(c, fiber.StatusInternalServerError, "프로필 업데이트에 실패했습니다.")

View File

@@ -99,7 +99,7 @@ func TestVerifySignupCode_Invalid(t *testing.T) {
verifyBody := map[string]string{
"type": "email",
"target": "user@test.com",
"code": "000000", // wrong code
"code": "222222", // wrong code
}
body, _ := json.Marshal(verifyBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/signup/verify", bytes.NewReader(body))

View File

@@ -136,6 +136,7 @@ func TestSignup_CompanyCodeValidation(t *testing.T) {
validTenant := &domain.Tenant{ID: "t1", Slug: "valid-slug", Status: domain.TenantStatusActive}
mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, nil)
mockTenantSvc.On("GetTenantBySlug", mock.Anything, "valid-slug").Return(validTenant, nil)
mockTenantSvc.On("GetTenant", mock.Anything, "t1").Return(validTenant, nil)
mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil)
mockRedis.On("Delete", mock.Anything).Return(nil)

View File

@@ -352,9 +352,14 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
}
}
finalLoginID := extractTraitString(attributes, "id")
if err := domain.ValidateLoginID(finalLoginID, email, normalizePhoneNumber(req.Phone)); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
brokerUser := &domain.BrokerUser{
Email: email,
LoginID: extractTraitString(attributes, "id"),
LoginID: finalLoginID,
Name: name,
PhoneNumber: normalizePhoneNumber(req.Phone),
Attributes: attributes,
@@ -571,11 +576,20 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
}
}
finalLoginID := extractTraitString(attributes, "id")
userEmail := email
userPhone := normalizePhoneNumber(item.Phone)
if err := domain.ValidateLoginID(finalLoginID, userEmail, userPhone); err != nil {
results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()})
continue
}
identityID, err := h.OryProvider.CreateUser(&domain.BrokerUser{
Email: email,
LoginID: extractTraitString(attributes, "id"),
Email: userEmail,
LoginID: finalLoginID,
Name: item.Name,
PhoneNumber: normalizePhoneNumber(item.Phone),
PhoneNumber: userPhone,
Attributes: attributes,
}, password)
if err != nil {
@@ -1189,6 +1203,13 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
}
finalLoginID := extractTraitString(traits, "id")
userEmail := extractTraitString(traits, "email")
userPhone := extractTraitString(traits, "phone")
if err := domain.ValidateLoginID(finalLoginID, userEmail, userPhone); err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
state := normalizeKratosState(req.Status)
slog.Info("[UpdateUser] Calling Kratos UpdateIdentity", "userID", userID, "traits", traits, "state", state)