feat: merge registration invite and verification input

This commit is contained in:
richjohnrambo
2026-09-16 14:29:59 +08:00
parent 370e9878ed
commit 7461216c03
8 changed files with 157 additions and 29 deletions
+2 -1
View File
@@ -150,7 +150,8 @@ type MsgClientAcc struct {
Username string `json:"username,omitempty"`
// Registration mobile number. An 11-digit mainland China number takes precedence over Username.
Phone string `json:"phone,omitempty"`
// Optional case-sensitive registration invitation code.
// Deprecated compatibility field. New clients put a five-letter invitation
// code in the shared verification input and submit it as the tel cred response.
InviteCode string `json:"invite_code,omitempty"`
// Authenticate session with the newly created account
Login bool `json:"login,omitempty"`
+26 -1
View File
@@ -1073,7 +1073,8 @@ func serveIMVerificationCodeVerify(wrt http.ResponseWriter, req *http.Request) {
return
}
code := strings.TrimSpace(body.Code)
if len(code) != imVerificationCodeLength {
inviteCandidate := isValidIMInviteCode(code)
if len(code) != imVerificationCodeLength && !inviteCandidate {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "code"})
return
}
@@ -1102,6 +1103,30 @@ func serveIMVerificationCodeVerify(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
if inviteCandidate {
if vc.Scene != "register" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "code"})
return
}
if err := validateRegistrationInvite(tenant.ID, code, now); err != nil {
nextState := types.VerificationStatePending
if vc.Attempts+1 >= vc.MaxAttempts {
nextState = types.VerificationStateLocked
}
_ = store.Verifications.Fail(tenant.ID, challengeID, nextState)
if nextState == types.VerificationStateLocked {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
return
}
writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{
"verification_mode": "invite_code",
"invite_accepted": true,
})
return
}
if vc.CodeHash != hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, code) {
nextState := types.VerificationStatePending
if vc.Attempts+1 >= vc.MaxAttempts {
+41 -1
View File
@@ -2299,6 +2299,7 @@ func TestServeIMVerificationCodesRateLimited(t *testing.T) {
func TestServeIMVerificationCodeVerifySuccess(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
apiKey := makeTestAPIKey(t)
store.Tenants = testTenantStore{tenant: &types.Tenant{
ID: 7,
Code: "acme",
@@ -2324,7 +2325,7 @@ func TestServeIMVerificationCodeVerifySuccess(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify",
bytes.NewBufferString(`{"code":"123456"}`))
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-MeetingIM-APIKey", apiKey)
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
@@ -2343,6 +2344,45 @@ func TestServeIMVerificationCodeVerifySuccess(t *testing.T) {
}
}
func TestServeIMVerificationCodeVerifyAcceptsRegistrationInvite(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
oldInvites := store.RegistrationInvites
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", Name: "Acme Corp", State: types.TenantStateActive}}
verifications := &testVerificationStore{get: &types.VerificationCode{
TenantID: 7, ChallengeID: "challenge-1", Scene: "register", PhoneHash: "phone-hash",
State: types.VerificationStatePending, MaxAttempts: 5, ExpiresAt: time.Now().UTC().Add(time.Minute),
}}
store.Verifications = verifications
store.RegistrationInvites = &testRegistrationInviteStore{invite: &types.RegistrationInviteCode{
Code: "AbCdE", Status: types.RegistrationInviteActive, ExpiresAt: time.Now().UTC().Add(time.Hour),
}}
defer func() {
store.Tenants = oldTenants
store.Verifications = oldVerifications
store.RegistrationInvites = oldInvites
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify",
bytes.NewBufferString(`{"code":"AbCdE"}`))
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
serveIMVerificationCodeVerify(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["verification_mode"] != "invite_code" || data["invite_accepted"] != true {
t.Fatalf("unexpected invite response: %#v", data)
}
if verifications.verified {
t.Fatal("invite code must not mark the phone challenge as SMS verified")
}
}
func TestServeIMVerificationCodeVerifyInvalidCode(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
+20 -7
View File
@@ -1321,6 +1321,8 @@ func TestDispatchAccNew(t *testing.T) {
oldSecurity := store.Security
oldVerifications := store.Verifications
oldInvites := store.RegistrationInvites
oldValidators := globals.validators
globals.validators = map[string]credValidator{"tel": {}}
store.Store = ss
store.Users = uu
store.Security = &testSecurityStore{version: 1}
@@ -1329,12 +1331,15 @@ func TestDispatchAccNew(t *testing.T) {
Code: "AbCdE", Status: types.RegistrationInviteActive, ExpiresAt: time.Now().Add(time.Hour),
}}
store.RegistrationInvites = invites
setRegistrationSMSConfigForTest(true)
defer func() {
store.Store = nil
store.Users = nil
store.Security = oldSecurity
store.Verifications = oldVerifications
store.RegistrationInvites = oldInvites
globals.validators = oldValidators
resetRegistrationSMSConfigForTest()
ctrl.Finish()
}()
@@ -1347,10 +1352,14 @@ func TestDispatchAccNew(t *testing.T) {
State: types.StateOK,
}
ss.EXPECT().GetLogicalAuthHandler("basic").Return(aa)
ss.EXPECT().GetValidator("tel").Return(registrationTestValidator{})
// This login is available.
aa.EXPECT().IsUnique(auth.AuthContext{TenantID: 1, RemoteAddr: remoteAddr}, []byte(secret)).Return(true, nil)
uu.EXPECT().Create(types.TenantID(1), gomock.Any(), gomock.Any()).DoAndReturn(
func(_ types.TenantID, user *types.User, private any) (*types.User, error) {
if user.Phone != "+8613800138000" {
t.Fatalf("unexpected registration phone: %q", user.Phone)
}
user.SetUid(uid)
return user, nil
})
@@ -1378,13 +1387,17 @@ func TestDispatchAccNew(t *testing.T) {
public := "public name"
msg := &ClientComMessage{
Acc: &MsgClientAcc{
Id: "123",
User: "newXYZ",
Scheme: "basic",
Username: "alice",
InviteCode: "AbCdE",
Secret: []byte(secret),
Desc: &MsgSetDesc{Public: public},
Id: "123",
User: "newXYZ",
Scheme: "basic",
Username: "alice",
Phone: "13800138000",
Secret: []byte(secret),
Cred: []MsgCredClient{{
Method: "tel", Value: "+8613800138000", Response: "AbCdE",
Params: map[string]any{"challenge_id": "challenge-1"},
}},
Desc: &MsgSetDesc{Public: public},
},
}
+25 -9
View File
@@ -118,11 +118,12 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
// Pre-check credentials for validity. We don't know user's access level
// consequently cannot check presence of required credentials. Must do that later.
creds := normalizeCredentials(msg.Acc.Cred, true)
rawCreds := normalizeCredentials(msg.Acc.Cred, true)
creds := make([]MsgCredClient, 0, len(rawCreds))
var registrationPhone string
var registrationTelCredential bool
for i := range creds {
cr := &creds[i]
for i := range rawCreds {
cr := &rawCreds[i]
vld := store.Store.GetValidator(cr.Method)
if vld == nil || !vld.IsInitialized() {
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
@@ -138,6 +139,16 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
}
if cr.Method == "tel" {
phone := strings.TrimSpace(strings.TrimPrefix(normalized, "tel:"))
if credentialInviteCode := strings.TrimSpace(cr.Response); isValidIMInviteCode(credentialInviteCode) {
if inviteCode != "" && inviteCode != credentialInviteCode {
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"what": "invite_code", "reason": "INVITE_CODE_INVALID"}))
return
}
inviteCode = credentialInviteCode
registrationPhone = phone
continue
}
challengeID, _ := cr.Params["challenge_id"].(string)
if phone == "" || strings.TrimSpace(challengeID) == "" || strings.TrimSpace(cr.Response) == "" ||
(registrationPhone != "" && registrationPhone != phone) {
@@ -148,6 +159,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
registrationPhone = phone
registrationTelCredential = true
}
creds = append(creds, *cr)
}
if !phoneRegistration && registrationTelCredential {
logs.Warn.Println("create user: username registration cannot attach a phone credential, sid=", s.sid)
@@ -155,13 +167,13 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_NOT_SELECTED"}))
return
}
if phoneRegistration && registrationTelCredential && registrationPhone != phone {
if phoneRegistration && registrationPhone != "" && registrationPhone != phone {
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_MISMATCH"}))
return
}
smsEnabled := registrationSMSEnabled(time.Now().UTC())
if phoneRegistration && smsEnabled && !registrationTelCredential {
if phoneRegistration && smsEnabled && !registrationTelCredential && inviteCode == "" {
logs.Warn.Println("create user: verified phone is required while registration SMS is enabled, sid=", s.sid)
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_VERIFICATION_REQUIRED"}))
@@ -240,10 +252,14 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
}
// When creating an account, the user must provide all required credentials.
// If any are missing, reject the request.
if len(creds) < len(globals.authValidators[rec.AuthLevel]) {
requiredValidators := globals.authValidators[rec.AuthLevel]
if inviteCode != "" {
requiredValidators = slices.DeleteFunc(slices.Clone(requiredValidators), func(method string) bool { return method == "tel" })
}
if len(creds) < len(requiredValidators) {
logs.Warn.Println("create user: missing credentials; have:", creds, "want:",
globals.authValidators[rec.AuthLevel], s.sid)
_, missing, _ := stringSliceDelta(globals.authValidators[rec.AuthLevel], credentialMethods(creds))
requiredValidators, s.sid)
_, missing, _ := stringSliceDelta(requiredValidators, credentialMethods(creds))
s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp,
map[string]any{"creds": missing}))
@@ -310,7 +326,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
var reply *ServerComMessage
if msg.Acc.Login {
// Process user's login request.
_, missing, _ := stringSliceDelta(globals.authValidators[rec.AuthLevel], validated)
_, missing, _ := stringSliceDelta(requiredValidators, validated)
reply = s.onLogin(msg.Id, msg.Timestamp, rec, missing, msg.Acc.Scheme, account)
} else {
// Not using the new account for logging in.
+20
View File
@@ -9,6 +9,26 @@ import (
"github.com/tinode/chat/server/store/types"
)
type registrationTestValidator struct{}
func (registrationTestValidator) Init(string) error { return nil }
func (registrationTestValidator) IsInitialized() bool { return true }
func (registrationTestValidator) PreCheck(value string, _ map[string]any) (string, error) {
return "tel:" + value, nil
}
func (registrationTestValidator) Request(types.TenantID, types.Uid, string, string, string, []byte) (bool, error) {
return true, nil
}
func (registrationTestValidator) ResetSecret(types.TenantID, string, string, string, []byte, map[string]any) error {
return nil
}
func (registrationTestValidator) Check(types.TenantID, types.Uid, string) (string, error) {
return "", nil
}
func (registrationTestValidator) Remove(types.TenantID, types.Uid, string) error { return nil }
func (registrationTestValidator) Delete(types.TenantID, types.Uid) error { return nil }
func (registrationTestValidator) TempAuthScheme() (string, error) { return "code", nil }
func TestResolveRegistrationAccount(t *testing.T) {
tests := []struct {
name string