fix: adjust phone registration username flow
This commit is contained in:
@@ -491,11 +491,6 @@ func main() {
|
||||
globals.immutableTagNS[name] = true
|
||||
}
|
||||
|
||||
if len(vconf.Required) == 0 {
|
||||
// Skip disabled validator.
|
||||
continue
|
||||
}
|
||||
|
||||
var reqLevels []auth.Level
|
||||
for _, req := range vconf.Required {
|
||||
lvl := auth.ParseAuthLevel(req)
|
||||
|
||||
@@ -1336,8 +1336,11 @@ func TestDispatchAccNew(t *testing.T) {
|
||||
if lvl := p["authlvl"].(string); lvl != auth.LevelAuth.String() {
|
||||
t.Errorf("Auth level: expected '%s', found '%s'.", auth.LevelAuth.String(), lvl)
|
||||
}
|
||||
if desc := p["desc"].(*MsgTopicDesc); desc.Public.(string) != public {
|
||||
t.Errorf("Public: expected '%s', found '%s'.", public, desc.Public.(string))
|
||||
if username := p["username"].(string); username != "alice" {
|
||||
t.Errorf("Username: expected 'alice', found '%s'.", username)
|
||||
}
|
||||
if desc := p["desc"].(*MsgTopicDesc); desc.Public.(map[string]any)["fn"] != "alice" {
|
||||
t.Errorf("Public fn: expected 'alice', found '%v'.", desc.Public)
|
||||
}
|
||||
} else {
|
||||
t.Error("Response must contain a ctrl message.")
|
||||
|
||||
@@ -84,6 +84,9 @@
|
||||
"required": [],
|
||||
"config": {
|
||||
"host_url": "http://64.118.137.182:6060/",
|
||||
"sender": "Tinode <noreply@118im.com>",
|
||||
"validation_templ": "server/templ/email-validation-en.templ",
|
||||
"reset_secret_templ": "server/templ/email-password-reset-en.templ",
|
||||
"debug_response": "123456",
|
||||
"max_retries": 3
|
||||
}
|
||||
@@ -94,6 +97,7 @@
|
||||
"config": {
|
||||
"host_url": "http://64.118.137.182:6060/",
|
||||
"sender": "Tinode",
|
||||
"universal_templ": "server/templ/sms-universal-en.templ",
|
||||
"debug_response": "123456",
|
||||
"max_retries": 3
|
||||
}
|
||||
|
||||
+91
-15
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"container/heap"
|
||||
crand "crypto/rand"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -41,13 +42,17 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
||||
return
|
||||
}
|
||||
|
||||
account, phone, phoneRegistration, err := resolveRegistrationAccount(msg.Acc.Username, msg.Acc.Phone)
|
||||
if err != nil || !registrationSecretMatches(msg.Acc.Secret, account) {
|
||||
account, phone, secretAccount, phoneRegistration, err := resolveRegistrationAccount(msg.Acc.Username, msg.Acc.Phone)
|
||||
if err != nil || !registrationSecretMatches(msg.Acc.Secret, secretAccount) {
|
||||
logs.Warn.Println("create user: invalid registration account fields, sid=", s.sid)
|
||||
s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp,
|
||||
map[string]any{"what": "account", "reason": "REGISTRATION_ACCOUNT_INVALID"}))
|
||||
return
|
||||
}
|
||||
authSecret := msg.Acc.Secret
|
||||
if account != secretAccount {
|
||||
authSecret = registrationSecretForAccount(msg.Acc.Secret, account)
|
||||
}
|
||||
inviteCode := strings.TrimSpace(msg.Acc.InviteCode)
|
||||
if msg.Acc.InviteCode != inviteCode || (inviteCode != "" && !isValidIMInviteCode(inviteCode)) {
|
||||
s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp,
|
||||
@@ -57,11 +62,24 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
||||
|
||||
// Check if login is unique and compliance with the policy (not too long or too short).
|
||||
authCtx := auth.AuthContext{TenantID: s.tenantID, RemoteAddr: s.remoteAddr}
|
||||
if ok, err := authhdl.IsUnique(authCtx, msg.Acc.Secret); !ok {
|
||||
logs.Warn.Println("create user: auth secret is not compliant", err, "sid=", s.sid)
|
||||
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp,
|
||||
map[string]any{"what": "auth"}))
|
||||
return
|
||||
for attempts := 0; ; attempts++ {
|
||||
if ok, err := authhdl.IsUnique(authCtx, authSecret); ok {
|
||||
break
|
||||
} else if account != secretAccount && err == types.ErrDuplicate && attempts < 8 {
|
||||
account, err = randomRegistrationUsername()
|
||||
if err != nil {
|
||||
logs.Warn.Println("create user: failed to generate username", err, "sid=", s.sid)
|
||||
s.queueOut(ErrUnknown(msg.Id, "", msg.Timestamp))
|
||||
return
|
||||
}
|
||||
authSecret = registrationSecretForAccount(msg.Acc.Secret, account)
|
||||
continue
|
||||
} else {
|
||||
logs.Warn.Println("create user: auth secret is not compliant", err, "sid=", s.sid)
|
||||
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp,
|
||||
map[string]any{"what": "auth"}))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var user types.User
|
||||
@@ -193,6 +211,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
||||
private = msg.Acc.Desc.Private
|
||||
}
|
||||
}
|
||||
user.Public = registrationPublicWithUsername(user.Public, user.Username)
|
||||
|
||||
// Create user record in the database.
|
||||
if _, err := store.Users.Create(s.tenantID, &user, private); err != nil {
|
||||
@@ -203,7 +222,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
||||
|
||||
// Add authentication record.
|
||||
rec, err = authhdl.AddRecord(authCtx,
|
||||
&auth.Rec{TenantID: s.tenantID, Uid: user.Uid()}, msg.Acc.Secret)
|
||||
&auth.Rec{TenantID: s.tenantID, Uid: user.Uid()}, authSecret)
|
||||
if err != nil {
|
||||
logs.Warn.Println("create user: add auth record failed", err, "sid=", s.sid)
|
||||
s.queueOut(decodeStoreError(err, msg.Id, msg.Timestamp, nil))
|
||||
@@ -299,6 +318,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
||||
}
|
||||
|
||||
params := reply.Ctrl.Params.(map[string]any)
|
||||
params["username"] = user.Username
|
||||
params["desc"] = &MsgTopicDesc{
|
||||
CreatedAt: &user.CreatedAt,
|
||||
UpdatedAt: &user.UpdatedAt,
|
||||
@@ -315,26 +335,43 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
||||
pluginAccount(&user, plgActCreate)
|
||||
}
|
||||
|
||||
func resolveRegistrationAccount(username, phone string) (account, canonicalPhone string, phoneRegistration bool, err error) {
|
||||
func resolveRegistrationAccount(username, phone string) (account, canonicalPhone, secretAccount string, phoneRegistration bool, err error) {
|
||||
if phone != "" {
|
||||
if len(phone) != 11 || !asciiRegistrationDigits(phone) {
|
||||
return "", "", false, types.ErrPolicy
|
||||
return "", "", "", false, types.ErrPolicy
|
||||
}
|
||||
canonicalPhone, err = normalizeIMPhone(phone, "CN")
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
return "", "", "", false, err
|
||||
}
|
||||
return canonicalPhone, canonicalPhone, true, nil
|
||||
if username == "" {
|
||||
account, err = randomRegistrationUsername()
|
||||
if err != nil {
|
||||
return "", "", "", false, err
|
||||
}
|
||||
return account, canonicalPhone, canonicalPhone, true, nil
|
||||
}
|
||||
if err = validateRegistrationUsername(username); err != nil {
|
||||
return "", "", "", false, err
|
||||
}
|
||||
return username, canonicalPhone, username, true, nil
|
||||
}
|
||||
if err = validateRegistrationUsername(username); err != nil {
|
||||
return "", "", "", false, err
|
||||
}
|
||||
return username, "", username, false, nil
|
||||
}
|
||||
|
||||
func validateRegistrationUsername(username string) error {
|
||||
if username == "" || len(username) > 10 {
|
||||
return "", "", false, types.ErrPolicy
|
||||
return types.ErrPolicy
|
||||
}
|
||||
for i := range username {
|
||||
if (username[i] < 'a' || username[i] > 'z') && (username[i] < '0' || username[i] > '9') {
|
||||
return "", "", false, types.ErrPolicy
|
||||
return types.ErrPolicy
|
||||
}
|
||||
}
|
||||
return username, "", false, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func asciiRegistrationDigits(value string) bool {
|
||||
@@ -351,6 +388,45 @@ func registrationSecretMatches(secret []byte, account string) bool {
|
||||
return separator > 0 && string(secret[:separator]) == account
|
||||
}
|
||||
|
||||
func registrationSecretForAccount(secret []byte, account string) []byte {
|
||||
separator := strings.IndexByte(string(secret), ':')
|
||||
if separator < 0 {
|
||||
return secret
|
||||
}
|
||||
return []byte(account + string(secret[separator:]))
|
||||
}
|
||||
|
||||
func randomRegistrationUsername() (string, error) {
|
||||
const letters = "abcdefghijklmnopqrstuvwxyz"
|
||||
const digits = "0123456789"
|
||||
var raw [9]byte
|
||||
if _, err := crand.Read(raw[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
out := make([]byte, 9)
|
||||
for i := 0; i < 3; i++ {
|
||||
out[i] = letters[int(raw[i])%len(letters)]
|
||||
}
|
||||
for i := 3; i < 9; i++ {
|
||||
out[i] = digits[int(raw[i])%len(digits)]
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func registrationPublicWithUsername(public any, username string) any {
|
||||
if username == "" {
|
||||
return public
|
||||
}
|
||||
if public == nil {
|
||||
return map[string]any{"fn": username}
|
||||
}
|
||||
if values, ok := public.(map[string]any); ok {
|
||||
values["fn"] = username
|
||||
return values
|
||||
}
|
||||
return map[string]any{"fn": username}
|
||||
}
|
||||
|
||||
func validateRegistrationInvite(tenantID types.TenantID, code string, now time.Time) error {
|
||||
if store.RegistrationInvites == nil {
|
||||
return types.ErrInternal
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -15,12 +16,16 @@ func TestResolveRegistrationAccount(t *testing.T) {
|
||||
phone string
|
||||
wantAccount string
|
||||
wantPhone string
|
||||
wantSecret string
|
||||
wantPhoneFlow bool
|
||||
wantGenerated bool
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "username", username: "alice1", wantAccount: "alice1"},
|
||||
{name: "numeric username", username: "12345", wantAccount: "12345"},
|
||||
{name: "phone wins", username: "INVALID", phone: "13800138000", wantAccount: "+8613800138000", wantPhone: "+8613800138000", wantPhoneFlow: true},
|
||||
{name: "username", username: "alice1", wantAccount: "alice1", wantSecret: "alice1"},
|
||||
{name: "numeric username", username: "12345", wantAccount: "12345", wantSecret: "12345"},
|
||||
{name: "username and phone", username: "alice1", phone: "13800138000", wantAccount: "alice1", wantPhone: "+8613800138000", wantSecret: "alice1", wantPhoneFlow: true},
|
||||
{name: "phone only", phone: "13800138000", wantPhone: "+8613800138000", wantSecret: "+8613800138000", wantPhoneFlow: true, wantGenerated: true},
|
||||
{name: "uppercase with phone rejected", username: "Alice", phone: "13800138000", wantErr: true},
|
||||
{name: "uppercase rejected", username: "Alice", wantErr: true},
|
||||
{name: "long username rejected", username: "abcdefghijk", wantErr: true},
|
||||
{name: "malformed phone does not fall back", username: "alice", phone: "1380013800", wantErr: true},
|
||||
@@ -28,9 +33,16 @@ func TestResolveRegistrationAccount(t *testing.T) {
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
account, phone, phoneFlow, err := resolveRegistrationAccount(tc.username, tc.phone)
|
||||
if (err != nil) != tc.wantErr || account != tc.wantAccount || phone != tc.wantPhone || phoneFlow != tc.wantPhoneFlow {
|
||||
t.Fatalf("got account=%q phone=%q phoneFlow=%v err=%v", account, phone, phoneFlow, err)
|
||||
account, phone, secretAccount, phoneFlow, err := resolveRegistrationAccount(tc.username, tc.phone)
|
||||
if (err != nil) != tc.wantErr || phone != tc.wantPhone || secretAccount != tc.wantSecret || phoneFlow != tc.wantPhoneFlow {
|
||||
t.Fatalf("got account=%q phone=%q secret=%q phoneFlow=%v err=%v", account, phone, secretAccount, phoneFlow, err)
|
||||
}
|
||||
if tc.wantGenerated {
|
||||
if !regexp.MustCompile(`^[a-z]{3}[0-9]{6}$`).MatchString(account) {
|
||||
t.Fatalf("generated account %q does not match expected format", account)
|
||||
}
|
||||
} else if account != tc.wantAccount {
|
||||
t.Fatalf("got account=%q want %q", account, tc.wantAccount)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -45,6 +57,23 @@ func TestRegistrationSecretMatchesSelectedAccount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationSecretForAccount(t *testing.T) {
|
||||
if got := string(registrationSecretForAccount([]byte("+8613800138000:secret"), "abcxyz123456")); got != "abcxyz123456:secret" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationPublicWithUsername(t *testing.T) {
|
||||
if got := registrationPublicWithUsername(nil, "alice").(map[string]any)["fn"]; got != "alice" {
|
||||
t.Fatalf("got fn=%v", got)
|
||||
}
|
||||
public := map[string]any{"fn": "old", "photo": "file1"}
|
||||
got := registrationPublicWithUsername(public, "bob").(map[string]any)
|
||||
if got["fn"] != "bob" || got["photo"] != "file1" {
|
||||
t.Fatalf("got public=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRegistrationInviteStates(t *testing.T) {
|
||||
oldInvites := store.RegistrationInvites
|
||||
defer func() { store.RegistrationInvites = oldInvites }()
|
||||
|
||||
Reference in New Issue
Block a user