feat: merge registration invite and verification input
This commit is contained in:
+2
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user