feat: complete registration invitation and auth redesign

This commit is contained in:
richjohnrambo
2026-08-19 00:30:11 +08:00
parent e063e7879e
commit 219ecadf4c
47 changed files with 9014 additions and 224 deletions
+1 -1
View File
@@ -278,7 +278,7 @@ type AuthHandler interface {
// continue the authentication process to the next step, or return an error code.
// The remoteAddr (i.e. the IP address of the client) can be used by custom authenticators for
// additional validation. The stock authenticators don't use it.
// store.Users.GetAuthRecord("scheme", "unique")
// The selected handler resolves its account through the tenant-scoped auth store.
// Returns: user auth record, challenge, error.
Authenticate(ctx AuthContext, secret []byte) (*Rec, []byte, error)
+10 -10
View File
@@ -17,7 +17,7 @@ import (
// Define default constraints on login and password
const (
defaultMinLoginLength = 2
defaultMinLoginLength = 1
defaultMaxLoginLength = 32
defaultMinPasswordLength = 3
@@ -26,7 +26,7 @@ const (
// Token suitable as a login: either an E.164 phone number or a name which starts
// and ends with a Unicode letter (class L) or number (class N), and contains
// Unicode letters, numbers, dot and underscore.
var loginPattern = regexp.MustCompile(`^(\+[\pN]+|[\pL\pN][_.\pL\pN]*[\pL\pN]+)$`)
var loginPattern = regexp.MustCompile(`^(\+[0-9]+|[\pL\pN](?:[_.\pL\pN]*[\pL\pN])?)$`)
// authenticator is the type to map authentication methods to.
type authenticator struct {
@@ -143,7 +143,7 @@ func (a *authenticator) AddRecord(ctx auth.AuthContext, rec *auth.Rec, secret []
authLevel = auth.LevelAuth
}
err = store.Users.AddAuthRecord(ctx.TenantID, rec.Uid, authLevel, a.name, uname, passhash, expires)
err = store.Users.AddAuthRecord(ctx.TenantID, rec.Uid, authLevel, passhash, expires)
if err != nil {
return nil, err
}
@@ -165,7 +165,7 @@ func (a *authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret
return nil, err
}
login, authLevel, _, _, err := store.Users.GetAuthRecord(ctx.TenantID, rec.Uid, a.name)
login, authLevel, _, _, err := store.Users.GetAuthRecord(ctx.TenantID, rec.Uid)
if err != nil {
return nil, err
}
@@ -179,7 +179,7 @@ func (a *authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret
uname = login
} else if err = a.checkLoginPolicy(uname); err != nil {
return nil, err
} else if uid, _, _, _, err := store.Users.GetAuthUniqueRecord(ctx.TenantID, a.name, uname); err != nil {
} else if uid, _, _, _, err := store.Users.GetAuthByLogin(ctx.TenantID, uname); err != nil {
return nil, err
} else if !uid.IsZero() {
// The (new) user name already exists. Report an error.
@@ -198,7 +198,7 @@ func (a *authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret
if rec.Lifetime > 0 {
expires = types.TimeNow().Add(time.Duration(rec.Lifetime))
}
err = store.Users.UpdateAuthRecord(ctx.TenantID, rec.Uid, authLevel, a.name, uname, passhash, expires)
err = store.Users.UpdateAuthRecord(ctx.TenantID, rec.Uid, authLevel, uname, passhash, expires)
if err != nil {
return nil, err
}
@@ -228,7 +228,7 @@ func (a *authenticator) Authenticate(ctx auth.AuthContext, secret []byte) (*auth
return nil, nil, err
}
uid, authLvl, passhash, expires, err := store.Users.GetAuthUniqueRecord(ctx.TenantID, a.name, uname)
uid, authLvl, passhash, expires, err := store.Users.GetAuthByLogin(ctx.TenantID, uname)
if err != nil {
return nil, nil, err
}
@@ -287,7 +287,7 @@ func (a *authenticator) IsUnique(ctx auth.AuthContext, secret []byte) (bool, err
return false, err
}
uid, _, _, _, err := store.Users.GetAuthUniqueRecord(ctx.TenantID, a.name, uname)
uid, _, _, _, err := store.Users.GetAuthByLogin(ctx.TenantID, uname)
if err != nil {
return false, err
}
@@ -305,7 +305,7 @@ func (authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {
// DelRecords deletes saved authentication records of the given user.
func (a *authenticator) DelRecords(tenantID types.TenantID, uid types.Uid) error {
return store.Users.DelAuthRecords(tenantID, uid, a.name)
return store.Users.DeleteAuthRecord(tenantID, uid)
}
// RestrictedTags returns tag namespaces (prefixes) restricted by this adapter.
@@ -319,7 +319,7 @@ func (a *authenticator) RestrictedTags() ([]string, error) {
// GetResetParams returns authenticator parameters passed to password reset handler.
func (a *authenticator) GetResetParams(tenantID types.TenantID, uid types.Uid) (map[string]any, error) {
login, _, _, _, err := store.Users.GetAuthRecord(tenantID, uid, a.name)
login, _, _, _, err := store.Users.GetAuthRecord(tenantID, uid)
if err != nil {
return nil, err
}
+3 -3
View File
@@ -26,9 +26,9 @@ func TestAuthenticateScopesLoginByTenant(t *testing.T) {
if err != nil {
t.Fatal(err)
}
users.EXPECT().GetAuthUniqueRecord(types.TenantID(7), "basic", "alice").
users.EXPECT().GetAuthByLogin(types.TenantID(7), "alice").
Return(types.Uid(70), auth.LevelAuth, hash, time.Time{}, nil)
users.EXPECT().GetAuthUniqueRecord(types.TenantID(8), "basic", "alice").
users.EXPECT().GetAuthByLogin(types.TenantID(8), "alice").
Return(types.Uid(80), auth.LevelAuth, hash, time.Time{}, nil)
a := &authenticator{name: "basic", minLoginLength: defaultMinLoginLength,
@@ -73,7 +73,7 @@ func TestAddRecordAcceptsE164PhoneLogin(t *testing.T) {
})
users.EXPECT().AddAuthRecord(types.TenantID(7), types.Uid(42), auth.LevelAuth,
"basic", "+8615521120255", gomock.Any(), time.Time{}).Return(nil)
gomock.Any(), time.Time{}).Return(nil)
a := &authenticator{name: "basic", addToTags: true, minLoginLength: defaultMinLoginLength,
minPasswordLength: 6}
+6
View File
@@ -146,6 +146,12 @@ type MsgClientAcc struct {
Scheme string `json:"scheme,omitempty"`
// Shared secret
Secret []byte `json:"secret,omitempty"`
// Registration login name. Ignored when Phone is provided.
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.
InviteCode string `json:"invite_code,omitempty"`
// Authenticate session with the newly created account
Login bool `json:"login,omitempty"`
// Indexable tags for user discovery
+26 -6
View File
@@ -216,6 +216,11 @@ type TenantAdapter interface {
TenantGetByID(id t.TenantID) (*t.Tenant, error)
}
// RegistrationConfigAdapter reads global registration policy from the control database.
type RegistrationConfigAdapter interface {
RegistrationSMSEnabled() (bool, error)
}
// TenantRoutedAdapter can return a tenant-specific adapter, e.g. for the
// "one tenant, one database" deployment model.
type TenantRoutedAdapter interface {
@@ -233,15 +238,29 @@ type TenantHTTPInfrastructureAdapter interface {
TenantBusinessEventMarkDelivered(t.TenantID, string, time.Time) error
}
// TenantRegistrationInviteAdapter stores invitation definitions in the
// registry database and usage details in the selected tenant database.
type TenantRegistrationInviteAdapter interface {
TenantRegistrationInviteCreate(t.TenantID, *t.RegistrationInviteCode) error
TenantRegistrationInviteCreateBatch(t.TenantID, *t.RegistrationInviteBatch, []t.RegistrationInviteCode) error
TenantRegistrationInviteGetByCode(t.TenantID, string) (*t.RegistrationInviteCode, error)
TenantRegistrationInviteList(t.TenantID, t.RegistrationInviteListOptions) (*t.RegistrationInvitePage, error)
TenantRegistrationInviteBatchGet(t.TenantID, int64) (*t.RegistrationInviteBatch, error)
TenantRegistrationInviteBatchGetByIdempotency(t.TenantID, string) (*t.RegistrationInviteBatch, error)
TenantRegistrationInviteRevoke(t.TenantID, int64, time.Time) error
TenantRegistrationInviteBatchRevoke(t.TenantID, int64, time.Time) (int, error)
TenantRegistrationInviteRecordUse(t.TenantID, string, t.Uid, string, time.Time) error
TenantRegistrationInviteSyncUsage(t.TenantID, int64) error
}
// TenantAuthAdapter is the tenant-aware authentication and credential capability.
// It is separate from Adapter while the remaining persistence domains are migrated.
type TenantAuthAdapter interface {
TenantAuthGetUniqueRecord(tenantID t.TenantID, unique string) (t.Uid, auth.Level, []byte, time.Time, error)
TenantAuthGetRecord(tenantID t.TenantID, user t.Uid, scheme string) (string, auth.Level, []byte, time.Time, error)
TenantAuthAddRecord(tenantID t.TenantID, user t.Uid, scheme, unique string, authLvl auth.Level,
secret []byte, expires time.Time) error
TenantAuthDelScheme(tenantID t.TenantID, user t.Uid, scheme string) error
TenantAuthUpdRecord(tenantID t.TenantID, user t.Uid, scheme, unique string, authLvl auth.Level,
TenantAuthGetByLogin(tenantID t.TenantID, login string) (t.Uid, auth.Level, []byte, time.Time, error)
TenantAuthGetRecord(tenantID t.TenantID, user t.Uid) (string, auth.Level, []byte, time.Time, error)
TenantAuthAddRecord(tenantID t.TenantID, user t.Uid, authLvl auth.Level, secret []byte, expires time.Time) error
TenantAuthDeleteRecord(tenantID t.TenantID, user t.Uid) error
TenantAuthUpdateRecord(tenantID t.TenantID, user t.Uid, username string, authLvl auth.Level,
secret []byte, expires time.Time) error
TenantUserGetByCred(tenantID t.TenantID, method, value string) (t.Uid, error)
TenantCredUpsert(tenantID t.TenantID, cred *t.Credential) (bool, error)
@@ -263,6 +282,7 @@ type TenantBusinessAdapter interface {
TenantUserCreate(t.TenantID, *t.User) error
TenantUserGet(t.TenantID, t.Uid) (*t.User, error)
TenantUserGetAll(t.TenantID, ...t.Uid) ([]t.User, error)
TenantUserGetByPhone(t.TenantID, string) (t.Uid, error)
TenantUserDelete(t.TenantID, t.Uid, bool) error
TenantUserUpdate(t.TenantID, t.Uid, map[string]any) error
TenantUserUpdateTags(t.TenantID, t.Uid, []string, []string, []string) ([]string, error)
+3
View File
@@ -30,6 +30,7 @@ func initUsers(now time.Time) []*types.User {
Id: "3ysxkod5hNM",
},
UserAgent: "SomeAgent v1.2.3",
Username: "alice",
Tags: []string{"alice"},
})
users = append(users, &types.User{
@@ -37,6 +38,7 @@ func initUsers(now time.Time) []*types.User {
Id: "9AVDamaNCRY",
},
UserAgent: "Tinode Web v111.222.333",
Username: "bob",
Tags: []string{"bob"},
})
users = append(users, &types.User{
@@ -44,6 +46,7 @@ func initUsers(now time.Time) []*types.User {
Id: "0QLrX3WPS2o",
},
UserAgent: "Tindroid v1.2.3",
Username: "carol",
Tags: []string{"carol"},
})
for _, user := range users {
+129 -53
View File
@@ -239,6 +239,15 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
return errors.New("mysql adapter failed to open tenant registry database: " + err.Error())
}
}
if a.registryDB != nil || !databaseMissing {
controlDB := a.db
if a.registryDB != nil {
controlDB = a.registryDB
}
if err = ensureIMRegistrationInviteControlTables(controlDB); err != nil {
return errors.New("mysql adapter failed to ensure registration invite control schema: " + err.Error())
}
}
if !databaseMissing {
if err = ensureIMGroupBusinessTables(a.db); err != nil {
return errors.New("mysql adapter failed to ensure group business schema: " + err.Error())
@@ -252,6 +261,9 @@ func (a *adapter) Open(jsonconfig json.RawMessage) error {
if err = ensureUserIdentitySchema(a.db); err != nil {
return errors.New("mysql adapter failed to ensure user identity schema: " + err.Error())
}
if err = ensureIMRegistrationInviteUseTable(a.db); err != nil {
return errors.New("mysql adapter failed to ensure registration invite usage schema: " + err.Error())
}
}
}
return err
@@ -454,8 +466,17 @@ func (a *adapter) TenantAdapterFor(tenantID t.TenantID) (dbadapter.TenantBusines
db.Close()
return nil, nil, err
}
if err = ensureIMRegistrationInviteUseTable(db); err != nil {
db.Close()
return nil, nil, err
}
registryDB := a.registryDB
if registryDB == nil {
registryDB = a.db
}
tenantAdp := &adapter{
db: db,
registryDB: registryDB,
dsn: mysqlDefaultString(dsn, a.dsn),
dbName: resolvedDBName,
maxResults: a.maxResults,
@@ -629,6 +650,7 @@ func (a *adapter) CreateDb(reset bool) error {
if _, err = tx.Exec(
`CREATE TABLE im_users(
id BIGINT NOT NULL,
username VARCHAR(32) NOT NULL,
createdat DATETIME(3) NOT NULL,
updatedat DATETIME(3) NOT NULL,
state SMALLINT NOT NULL DEFAULT 0,
@@ -640,6 +662,7 @@ func (a *adapter) CreateDb(reset bool) error {
public JSON,
trusted JSON,
PRIMARY KEY(id),
UNIQUE INDEX users_username(username),
UNIQUE INDEX users_phone(phone),
INDEX users_state_stateat(state, stateat),
INDEX users_lastseen_updatedat(lastseen, updatedat)
@@ -667,6 +690,14 @@ func (a *adapter) CreateDb(reset bool) error {
if err = createIMNotificationTables(tx); err != nil {
return err
}
if err = ensureIMRegistrationInviteUseTable(tx); err != nil {
return err
}
if a.registryDB == nil {
if err = ensureIMRegistrationInviteControlTables(tx); err != nil {
return err
}
}
// Indexed devices. Normalized into a separate table.
if _, err = tx.Exec(
@@ -689,16 +720,13 @@ func (a *adapter) CreateDb(reset bool) error {
if _, err = tx.Exec(
`CREATE TABLE im_auth(
id INT NOT NULL AUTO_INCREMENT,
uname VARCHAR(32) NOT NULL,
userid BIGINT NOT NULL,
scheme VARCHAR(16) NOT NULL,
authlvl INT NOT NULL,
secret VARCHAR(255) NOT NULL,
expires DATETIME,
PRIMARY KEY(id),
FOREIGN KEY(userid) REFERENCES im_users(id),
UNIQUE INDEX auth_uname(uname),
UNIQUE INDEX auth_userid_scheme(userid, scheme)
UNIQUE INDEX auth_userid(userid)
)`); err != nil {
return err
}
@@ -1659,7 +1687,7 @@ func removeTags(tx *sqlx.Tx, table, keyName string, keyVal any, tags []string) e
// UserCreate creates a new user. Returns error and true if error is due to duplicate user name,
// false for any other error
func (a *adapter) UserCreate(user *t.User) error {
if user == nil {
if user == nil || strings.TrimSpace(user.Username) == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContextForTx()
@@ -1691,8 +1719,9 @@ func (a *adapter) UserCreate(user *t.User) error {
user.Phone = canonicalPhone
phone = canonicalPhone
}
if _, err = tx.Exec("INSERT INTO im_users(id,createdat,updatedat,state,access,phone,public,trusted) VALUES(?,?,?,?,?,?,?,?)",
if _, err = tx.Exec("INSERT INTO im_users(id,username,createdat,updatedat,state,access,phone,public,trusted) VALUES(?,?,?,?,?,?,?,?,?)",
decoded_uid,
user.Username,
user.CreatedAt,
user.UpdatedAt,
user.State,
@@ -1700,6 +1729,9 @@ func (a *adapter) UserCreate(user *t.User) error {
phone,
common.ToJSON(user.Public),
common.ToJSON(user.Trusted)); err != nil {
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
@@ -1712,46 +1744,57 @@ func (a *adapter) UserCreate(user *t.User) error {
return tx.Commit()
}
// TenantAuthAddRecord adds an authentication record scoped to a tenant.
func (a *adapter) TenantAuthAddRecord(tenantID t.TenantID, uid t.Uid, scheme, unique string,
// TenantAuthAddRecord adds the user's Basic authentication record.
func (a *adapter) TenantAuthAddRecord(tenantID t.TenantID, uid t.Uid,
authLvl auth.Level, secret []byte, expires time.Time) error {
tenantAdp, err := a.routedTenantAdapter(tenantID)
if err != nil {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.AuthAddRecord(uid, scheme, unique, authLvl, secret, expires)
return tenantAdp.TenantAuthAddRecord(tenantID, uid, authLvl, secret, expires)
}
return a.AuthAddRecord(uid, scheme, unique, authLvl, secret, expires)
var username string
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
if err = a.db.GetContext(ctx, &username, "SELECT username FROM im_users WHERE id=?", store.DecodeUid(uid)); err != nil {
if err == sql.ErrNoRows {
return t.ErrNotFound
}
return err
}
return a.AuthAddRecord(uid, "basic", username, authLvl, secret, expires)
}
// TenantAuthDelScheme deletes an authentication scheme only in the selected tenant.
func (a *adapter) TenantAuthDelScheme(tenantID t.TenantID, uid t.Uid, scheme string) error {
// TenantAuthDeleteRecord deletes the Basic authentication record.
func (a *adapter) TenantAuthDeleteRecord(tenantID t.TenantID, uid t.Uid) error {
tenantAdp, err := a.routedTenantAdapter(tenantID)
if err != nil {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.AuthDelScheme(uid, scheme)
return tenantAdp.TenantAuthDeleteRecord(tenantID, uid)
}
return a.AuthDelScheme(uid, scheme)
return a.AuthDelScheme(uid, "basic")
}
// TenantAuthUpdRecord updates an authentication record only in the selected tenant.
func (a *adapter) TenantAuthUpdRecord(tenantID t.TenantID, uid t.Uid, scheme, unique string,
// TenantAuthUpdateRecord updates the Basic authentication record and optional username.
func (a *adapter) TenantAuthUpdateRecord(tenantID t.TenantID, uid t.Uid, username string,
authLvl auth.Level, secret []byte, expires time.Time) error {
tenantAdp, err := a.routedTenantAdapter(tenantID)
if err != nil {
return t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.AuthUpdRecord(uid, scheme, unique, authLvl, secret, expires)
return tenantAdp.TenantAuthUpdateRecord(tenantID, uid, username, authLvl, secret, expires)
}
return a.AuthUpdRecord(uid, scheme, unique, authLvl, secret, expires)
return a.AuthUpdRecord(uid, "basic", username, authLvl, secret, expires)
}
// TenantAuthGetRecord retrieves a user's authentication record in one tenant.
func (a *adapter) TenantAuthGetRecord(tenantID t.TenantID, uid t.Uid, scheme string) (string,
// TenantAuthGetRecord retrieves a user's Basic authentication record.
func (a *adapter) TenantAuthGetRecord(tenantID t.TenantID, uid t.Uid) (string,
auth.Level, []byte, time.Time, error) {
var expires time.Time
tenantAdp, err := a.routedTenantAdapter(tenantID)
@@ -1759,13 +1802,13 @@ func (a *adapter) TenantAuthGetRecord(tenantID t.TenantID, uid t.Uid, scheme str
return "", auth.LevelNone, nil, expires, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.AuthGetRecord(uid, scheme)
return tenantAdp.TenantAuthGetRecord(tenantID, uid)
}
return a.AuthGetRecord(uid, scheme)
return a.AuthGetRecord(uid, "basic")
}
// TenantAuthGetUniqueRecord retrieves an authentication record by tenant-local login.
func (a *adapter) TenantAuthGetUniqueRecord(tenantID t.TenantID, unique string) (t.Uid,
// TenantAuthGetByLogin retrieves a Basic authentication record by tenant-local login.
func (a *adapter) TenantAuthGetByLogin(tenantID t.TenantID, login string) (t.Uid,
auth.Level, []byte, time.Time, error) {
var expires time.Time
tenantAdp, err := a.routedTenantAdapter(tenantID)
@@ -1773,9 +1816,9 @@ func (a *adapter) TenantAuthGetUniqueRecord(tenantID t.TenantID, unique string)
return t.ZeroUid, auth.LevelNone, nil, expires, t.ErrMalformed
}
if tenantAdp != a {
return tenantAdp.AuthGetUniqueRecord(unique)
return tenantAdp.TenantAuthGetByLogin(tenantID, login)
}
return a.AuthGetUniqueRecord(unique)
return a.AuthGetUniqueRecord(login)
}
// Add user's authentication record
@@ -1791,8 +1834,17 @@ func (a *adapter) AuthAddRecord(uid t.Uid, scheme, unique string, authLvl auth.L
defer cancel()
}
if _, err := a.db.ExecContext(ctx, "INSERT INTO im_auth(uname,userid,scheme,authLvl,secret,expires) VALUES(?,?,?,?,?,?)",
unique, store.DecodeUid(uid), scheme, authLvl, secret, exp); err != nil {
var storedUsername string
if err := a.db.GetContext(ctx, &storedUsername, "SELECT username FROM im_users WHERE id=?", store.DecodeUid(uid)); err != nil {
if err == sql.ErrNoRows {
return t.ErrNotFound
}
return err
} else if unique != storedUsername {
return t.ErrMalformed
}
if _, err := a.db.ExecContext(ctx, "INSERT INTO im_auth(userid,authLvl,secret,expires) VALUES(?,?,?,?)",
store.DecodeUid(uid), authLvl, secret, exp); err != nil {
if isDupe(err) {
return t.ErrDuplicate
}
@@ -1807,7 +1859,7 @@ func (a *adapter) AuthDelScheme(user t.Uid, scheme string) error {
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx, "DELETE FROM im_auth WHERE userid=? AND scheme=?", store.DecodeUid(user), scheme)
_, err := a.db.ExecContext(ctx, "DELETE FROM im_auth WHERE userid=?", store.DecodeUid(user))
return err
}
@@ -1829,14 +1881,8 @@ func (a *adapter) AuthDelAllRecords(user t.Uid) (int, error) {
// Update user's authentication unique, secret, auth level.
func (a *adapter) AuthUpdRecord(uid t.Uid, scheme, unique string, authLvl auth.Level,
secret []byte, expires time.Time) error {
params := []string{"authLvl=?"}
args := []any{authLvl}
if unique != "" {
params = append(params, "uname=?")
args = append(args, unique)
}
if len(secret) > 0 {
params = append(params, "secret=?")
args = append(args, secret)
@@ -1845,23 +1891,46 @@ func (a *adapter) AuthUpdRecord(uid t.Uid, scheme, unique string, authLvl auth.L
params = append(params, "expires=?")
args = append(args, expires)
}
args = append(args, store.DecodeUid(uid), scheme)
decodedUID := store.DecodeUid(uid)
args = append(args, decodedUID)
ctx, cancel := a.getContext()
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
sql := "UPDATE im_auth SET " + strings.Join(params, ",") + " WHERE userid=? AND scheme=?"
resp, err := a.db.ExecContext(ctx, sql, args...)
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if unique != "" {
if _, err = tx.ExecContext(ctx, "UPDATE im_users SET username=?,updatedat=? WHERE id=?", unique, t.TimeNow(), decodedUID); err != nil {
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
}
query := "UPDATE im_auth SET " + strings.Join(params, ",") + " WHERE userid=?"
resp, err := tx.ExecContext(ctx, query, args...)
if isDupe(err) {
return t.ErrDuplicate
}
if count, _ := resp.RowsAffected(); count <= 0 {
return t.ErrNotFound
if err != nil {
return err
}
return err
if count, _ := resp.RowsAffected(); count <= 0 {
var exists int
if err = tx.GetContext(ctx, &exists, "SELECT COUNT(*) FROM im_auth WHERE userid=?", decodedUID); err != nil {
return err
}
if exists == 0 {
return t.ErrNotFound
}
}
return tx.Commit()
}
// Retrieve user's authentication record
@@ -1869,18 +1938,18 @@ func (a *adapter) AuthGetRecord(uid t.Uid, scheme string) (string, auth.Level, [
var expires time.Time
var record struct {
Uname string
Authlvl auth.Level
Secret []byte
Expires *time.Time
Username string
Authlvl auth.Level
Secret []byte
Expires *time.Time
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
if err := a.db.GetContext(ctx, &record, "SELECT uname,secret,expires,authlvl FROM im_auth WHERE userid=? AND scheme=?",
store.DecodeUid(uid), scheme); err != nil {
if err := a.db.GetContext(ctx, &record, `SELECT u.username,a.secret,a.expires,a.authlvl
FROM im_auth AS a JOIN im_users AS u ON u.id=a.userid WHERE a.userid=?`, store.DecodeUid(uid)); err != nil {
if err == sql.ErrNoRows {
// Nothing found - use standard error.
err = t.ErrNotFound
@@ -1892,7 +1961,7 @@ func (a *adapter) AuthGetRecord(uid t.Uid, scheme string) (string, auth.Level, [
expires = *record.Expires
}
return record.Uname, record.Authlvl, record.Secret, expires, nil
return record.Username, record.Authlvl, record.Secret, expires, nil
}
// Retrieve user's authentication record
@@ -1910,7 +1979,14 @@ func (a *adapter) AuthGetUniqueRecord(unique string) (t.Uid, auth.Level, []byte,
if cancel != nil {
defer cancel()
}
if err := a.db.GetContext(ctx, &record, "SELECT userid,secret,expires,authlvl FROM im_auth WHERE uname=?", unique); err != nil {
lookup := unique
if len(unique) == 11 && asciiDigits(unique) {
lookup = "+86" + unique
}
if err := a.db.GetContext(ctx, &record, `SELECT a.userid,a.secret,a.expires,a.authlvl
FROM im_users AS u JOIN im_auth AS a ON a.userid=u.id
WHERE u.username=? OR (u.phone IS NOT NULL AND u.phone=?) ORDER BY (u.username=?) DESC LIMIT 1`,
unique, lookup, unique); err != nil {
if err == sql.ErrNoRows {
// Nothing found - clear the error
err = nil
@@ -1932,7 +2008,7 @@ func (a *adapter) UserGet(uid t.Uid) (*t.User, error) {
defer cancel()
}
var user t.User
err := a.db.GetContext(ctx, &user, `SELECT id,createdat,updatedat,state,stateat,access,lastseen,useragent,
err := a.db.GetContext(ctx, &user, `SELECT id,username,createdat,updatedat,state,stateat,access,lastseen,useragent,
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE id=? AND state!=?`, store.DecodeUid(uid), t.StateDeleted)
if err == nil {
user.SetUid(uid)
@@ -1963,7 +2039,7 @@ func (a *adapter) UserGetAll(ids ...t.Uid) ([]t.User, error) {
if cancel != nil {
defer cancel()
}
q, uids, _ := sqlx.In(`SELECT id,createdat,updatedat,state,stateat,access,lastseen,useragent,
q, uids, _ := sqlx.In(`SELECT id,username,createdat,updatedat,state,stateat,access,lastseen,useragent,
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE id IN (?) AND state!=?`, uids, t.StateDeleted)
rows, err := a.db.QueryxContext(ctx, a.db.Rebind(q), uids...)
if err != nil {
+48
View File
@@ -0,0 +1,48 @@
//go:build mysql
// +build mysql
package mysql
import (
"database/sql"
"errors"
"strconv"
"strings"
t "github.com/tinode/chat/server/store/types"
)
func (a *adapter) RegistrationSMSEnabled() (bool, error) {
db := a.registryDB
if db == nil {
db = a.db
}
if db == nil {
return true, t.ErrInternal
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var value string
err := db.GetContext(ctx, &value, `SELECT value FROM hg_sys_config
WHERE `+"`group`"+`='im' AND `+"`key`"+`='register_sms_enabled' AND status=1 LIMIT 1`)
if errors.Is(err, sql.ErrNoRows) {
return true, t.ErrNotFound
}
if err != nil {
return true, err
}
value = strings.TrimSpace(strings.ToLower(value))
switch value {
case "1", "true":
return true, nil
case "0", "false":
return false, nil
default:
if parsed, parseErr := strconv.ParseBool(value); parseErr == nil {
return parsed, nil
}
return true, t.ErrMalformed
}
}
@@ -0,0 +1,87 @@
//go:build mysql
// +build mysql
package mysql
import (
"errors"
mysqldriver "github.com/go-sql-driver/mysql"
)
func ensureIMRegistrationInviteControlTables(exec sqlExecutor) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS im_registration_invite_batches(
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
quantity INT UNSIGNED NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
expires_at DATETIME(3) NOT NULL,
created_by BIGINT NOT NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_registration_invite_batch_tenant_idempotency(tenant_id,idempotency_key),
INDEX idx_im_registration_invite_batch_tenant_created(tenant_id,created_at)
)`,
`CREATE TABLE IF NOT EXISTS im_registration_invite_codes(
id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
batch_id BIGINT,
code CHAR(5) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
status VARCHAR(16) NOT NULL,
expires_at DATETIME(3) NOT NULL,
usage_count INT UNSIGNED NOT NULL DEFAULT 0,
last_used_at DATETIME(3) NULL,
created_by BIGINT NOT NULL,
remark VARCHAR(255) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
PRIMARY KEY(id),
UNIQUE INDEX uk_im_registration_invite_code_tenant(tenant_id,code),
INDEX idx_im_registration_invite_tenant_status_expires(tenant_id,status,expires_at),
INDEX idx_im_registration_invite_tenant_batch(tenant_id,batch_id),
INDEX idx_im_registration_invite_tenant_created(tenant_id,created_at)
)`,
}
for _, stmt := range statements {
if _, err := exec.Exec(stmt); err != nil {
return err
}
}
return nil
}
func ensureIMRegistrationInviteUseTable(exec sqlExecutor) error {
if _, err := exec.Exec(`CREATE TABLE IF NOT EXISTS im_registration_invite_uses(
id BIGINT NOT NULL,
invite_code_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
request_id VARCHAR(64) NOT NULL,
used_at DATETIME(3) NOT NULL,
PRIMARY KEY(id),
INDEX idx_im_registration_invite_use_code(invite_code_id),
UNIQUE INDEX uk_im_registration_invite_use_user(user_id),
INDEX idx_im_registration_invite_use_request(request_id)
)`); err != nil {
return err
}
for _, migration := range []struct {
statement string
ignored uint16
}{
{`ALTER TABLE im_registration_invite_uses DROP INDEX uk_im_registration_invite_use_code`, 1091},
{`ALTER TABLE im_registration_invite_uses ADD INDEX idx_im_registration_invite_use_code(invite_code_id)`, 1061},
} {
if _, err := exec.Exec(migration.statement); err != nil && !isMySQLErrorNumber(err, migration.ignored) {
return err
}
}
return nil
}
func isMySQLErrorNumber(err error, number uint16) bool {
var mysqlErr *mysqldriver.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == number
}
+457
View File
@@ -0,0 +1,457 @@
//go:build mysql
// +build mysql
package mysql
import (
"database/sql"
"strings"
"time"
"github.com/jmoiron/sqlx"
"github.com/tinode/chat/server/store"
t "github.com/tinode/chat/server/store/types"
)
func (a *adapter) routedRegistrationInviteAdapter(id t.TenantID) (*adapter, error) {
if err := validTenant(id); err != nil {
return nil, err
}
if a.tenantScopedDB {
return a, nil
}
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
return nil, err
}
return tenantAdp, nil
}
func (a *adapter) registrationInviteControlDB() *sqlx.DB {
if a.registryDB != nil {
return a.registryDB
}
return a.db
}
func (a *adapter) TenantRegistrationInviteCreate(id t.TenantID, code *t.RegistrationInviteCode) error {
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantRegistrationInviteCreate(id, code)
}
if code == nil || len(code.Code) != 5 || code.ExpiresAt.IsZero() || code.CreatedBy <= 0 {
return t.ErrMalformed
}
if code.ID == 0 {
code.ID, err = nextSnowflakeID()
if err != nil {
return err
}
}
now := t.TimeNow()
code.TenantID = id
code.Status = t.RegistrationInviteActive
code.CreatedAt, code.UpdatedAt = now, now
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err = a.registrationInviteControlDB().ExecContext(ctx, `INSERT INTO im_registration_invite_codes
(id,tenant_id,batch_id,code,status,expires_at,usage_count,last_used_at,created_by,remark,created_at,updated_at)
VALUES(?,?,?,?,?,?,0,NULL,?,?,?,?)`, code.ID, id, code.BatchID, code.Code, code.Status, code.ExpiresAt,
code.CreatedBy, code.Remark, code.CreatedAt, code.UpdatedAt)
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
func (a *adapter) TenantRegistrationInviteCreateBatch(id t.TenantID, batch *t.RegistrationInviteBatch, codes []t.RegistrationInviteCode) (err error) {
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantRegistrationInviteCreateBatch(id, batch, codes)
}
if batch == nil || batch.Quantity != len(codes) || len(codes) == 0 || strings.TrimSpace(batch.IdempotencyKey) == "" ||
batch.ExpiresAt.IsZero() || batch.CreatedBy <= 0 {
return t.ErrMalformed
}
if batch.ID == 0 {
batch.ID, err = nextSnowflakeID()
if err != nil {
return err
}
}
now := t.TimeNow()
batch.TenantID = id
batch.CreatedAt, batch.UpdatedAt = now, now
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.registrationInviteControlDB().BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
if _, err = tx.ExecContext(ctx, `INSERT INTO im_registration_invite_batches
(id,tenant_id,quantity,idempotency_key,expires_at,created_by,remark,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`, batch.ID, id, batch.Quantity, batch.IdempotencyKey, batch.ExpiresAt,
batch.CreatedBy, batch.Remark, batch.CreatedAt, batch.UpdatedAt); err != nil {
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
for i := range codes {
if len(codes[i].Code) != 5 {
return t.ErrMalformed
}
codes[i].ID, err = nextSnowflakeID()
if err != nil {
return err
}
codes[i].BatchID = &batch.ID
codes[i].TenantID = id
codes[i].Status = t.RegistrationInviteActive
codes[i].ExpiresAt = batch.ExpiresAt
codes[i].CreatedBy = batch.CreatedBy
codes[i].Remark = batch.Remark
codes[i].CreatedAt, codes[i].UpdatedAt = now, now
if _, err = tx.ExecContext(ctx, `INSERT INTO im_registration_invite_codes
(id,tenant_id,batch_id,code,status,expires_at,usage_count,last_used_at,created_by,remark,created_at,updated_at)
VALUES(?,?,?,?,?,?,0,NULL,?,?,?,?)`, codes[i].ID, id, batch.ID, codes[i].Code, codes[i].Status,
codes[i].ExpiresAt, codes[i].CreatedBy, codes[i].Remark, now, now); err != nil {
if isDupe(err) {
return t.ErrDuplicate
}
return err
}
}
return tx.Commit()
}
func (a *adapter) TenantRegistrationInviteGetByCode(id t.TenantID, value string) (*t.RegistrationInviteCode, error) {
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantRegistrationInviteGetByCode(id, value)
}
if len(value) != 5 {
return nil, t.ErrInviteInvalid
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var code t.RegistrationInviteCode
err = a.registrationInviteControlDB().GetContext(ctx, &code, `SELECT c.id,c.tenant_id,c.batch_id,c.code,c.status,c.expires_at,
c.created_by,c.remark,c.created_at,c.updated_at,c.usage_count,c.last_used_at
FROM im_registration_invite_codes AS c
WHERE c.tenant_id=? AND c.code=? LIMIT 1`, id, value)
if err == sql.ErrNoRows {
return nil, t.ErrInviteInvalid
}
if err != nil {
return nil, err
}
if code.Status == t.RegistrationInviteActive && !t.TimeNow().Before(code.ExpiresAt) {
code.Status = t.RegistrationInviteExpired
}
return &code, nil
}
func (a *adapter) TenantRegistrationInviteList(id t.TenantID, opts t.RegistrationInviteListOptions) (*t.RegistrationInvitePage, error) {
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
return tenantAdp.TenantRegistrationInviteList(id, opts)
}
if opts.Limit <= 0 || opts.Limit > 100 {
opts.Limit = 20
}
if opts.Offset < 0 {
opts.Offset = 0
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
now := t.TimeNow()
controlDB := a.registrationInviteControlDB()
_, _ = controlDB.ExecContext(ctx, `UPDATE im_registration_invite_codes SET status=?,updated_at=?
WHERE tenant_id=? AND status=? AND expires_at<=?`, t.RegistrationInviteExpired, now, id, t.RegistrationInviteActive, now)
where := []string{"c.tenant_id=?"}
args := []any{id}
if opts.Code != "" {
where, args = append(where, "c.code=?"), append(args, opts.Code)
}
if opts.Status != "" {
where, args = append(where, "c.status=?"), append(args, opts.Status)
}
if opts.BatchID > 0 {
where, args = append(where, "c.batch_id=?"), append(args, opts.BatchID)
}
if opts.CreatedBy > 0 {
where, args = append(where, "c.created_by=?"), append(args, opts.CreatedBy)
}
if opts.CreatedAtStart != nil {
where, args = append(where, "c.created_at>=?"), append(args, *opts.CreatedAtStart)
}
if opts.CreatedAtEnd != nil {
where, args = append(where, "c.created_at<=?"), append(args, *opts.CreatedAtEnd)
}
if opts.ExpiresAtStart != nil {
where, args = append(where, "c.expires_at>=?"), append(args, *opts.ExpiresAtStart)
}
if opts.ExpiresAtEnd != nil {
where, args = append(where, "c.expires_at<=?"), append(args, *opts.ExpiresAtEnd)
}
filter := strings.Join(where, " AND ")
var total int
if err = controlDB.GetContext(ctx, &total, "SELECT COUNT(*) FROM im_registration_invite_codes AS c WHERE "+filter, args...); err != nil {
return nil, err
}
queryArgs := append(append([]any{}, args...), opts.Limit, opts.Offset)
var items []t.RegistrationInviteCode
err = controlDB.SelectContext(ctx, &items, `SELECT c.id,c.tenant_id,c.batch_id,c.code,c.status,c.expires_at,
c.created_by,c.remark,c.created_at,c.updated_at,c.usage_count,c.last_used_at
FROM im_registration_invite_codes AS c
WHERE `+filter+` ORDER BY c.created_at DESC,c.id DESC LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return nil, err
}
return &t.RegistrationInvitePage{Items: items, Total: total}, nil
}
func (a *adapter) registrationInviteBatchGet(id t.TenantID, where string, arg any) (*t.RegistrationInviteBatch, error) {
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
if err != nil {
return nil, err
}
if tenantAdp != a {
if where == "b.id=?" {
return tenantAdp.TenantRegistrationInviteBatchGet(id, arg.(int64))
}
return tenantAdp.TenantRegistrationInviteBatchGetByIdempotency(id, arg.(string))
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var batch t.RegistrationInviteBatch
err = a.registrationInviteControlDB().GetContext(ctx, &batch, `SELECT b.id,b.tenant_id,b.quantity,b.idempotency_key,b.expires_at,b.created_by,b.remark,b.created_at,b.updated_at,
COALESCE(SUM(CASE WHEN c.status='active' AND c.expires_at>? THEN 1 ELSE 0 END),0) AS active_count,
COALESCE(SUM(c.usage_count),0) AS usage_count,
COALESCE(SUM(CASE WHEN (c.status='active' AND c.expires_at<=?) OR c.status='expired' THEN 1 ELSE 0 END),0) AS expired_count,
COALESCE(SUM(CASE WHEN c.status='revoked' THEN 1 ELSE 0 END),0) AS revoked_count
FROM im_registration_invite_batches AS b
LEFT JOIN im_registration_invite_codes AS c ON c.tenant_id=b.tenant_id AND c.batch_id=b.id
WHERE b.tenant_id=? AND `+where+` GROUP BY b.id`, t.TimeNow(), t.TimeNow(), id, arg)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
return &batch, err
}
func (a *adapter) TenantRegistrationInviteBatchGet(id t.TenantID, batchID int64) (*t.RegistrationInviteBatch, error) {
if batchID <= 0 {
return nil, t.ErrMalformed
}
return a.registrationInviteBatchGet(id, "b.id=?", batchID)
}
func (a *adapter) TenantRegistrationInviteBatchGetByIdempotency(id t.TenantID, key string) (*t.RegistrationInviteBatch, error) {
if strings.TrimSpace(key) == "" {
return nil, t.ErrMalformed
}
return a.registrationInviteBatchGet(id, "b.idempotency_key=?", key)
}
func (a *adapter) TenantRegistrationInviteRevoke(id t.TenantID, codeID int64, now time.Time) error {
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantRegistrationInviteRevoke(id, codeID, now)
}
if codeID <= 0 || now.IsZero() {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.registrationInviteControlDB().ExecContext(ctx, `UPDATE im_registration_invite_codes SET status=?,updated_at=?
WHERE tenant_id=? AND id=? AND status=? AND expires_at>?`, t.RegistrationInviteRevoked, now,
id, codeID, t.RegistrationInviteActive, now)
if err != nil {
return err
}
if count, _ := res.RowsAffected(); count == 0 {
return t.ErrNotFound
}
return nil
}
func (a *adapter) TenantRegistrationInviteBatchRevoke(id t.TenantID, batchID int64, now time.Time) (int, error) {
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
if err != nil {
return 0, err
}
if tenantAdp != a {
return tenantAdp.TenantRegistrationInviteBatchRevoke(id, batchID, now)
}
if batchID <= 0 || now.IsZero() {
return 0, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.registrationInviteControlDB().ExecContext(ctx, `UPDATE im_registration_invite_codes SET status=?,updated_at=?
WHERE tenant_id=? AND batch_id=? AND status=? AND expires_at>?`, t.RegistrationInviteRevoked,
now, id, batchID, t.RegistrationInviteActive, now)
if err != nil {
return 0, err
}
count, _ := res.RowsAffected()
return int(count), nil
}
func (a *adapter) TenantRegistrationInviteRecordUse(id t.TenantID, value string, uid t.Uid, requestID string, now time.Time) (err error) {
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantRegistrationInviteRecordUse(id, value, uid, requestID, now)
}
if len(value) != 5 || uid.IsZero() || strings.TrimSpace(requestID) == "" || now.IsZero() {
return t.ErrMalformed
}
var row struct {
ID int64 `db:"id"`
Status t.RegistrationInviteStatus `db:"status"`
ExpiresAt time.Time `db:"expires_at"`
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
err = a.registrationInviteControlDB().GetContext(ctx, &row, `SELECT id,status,expires_at
FROM im_registration_invite_codes WHERE tenant_id=? AND code=? LIMIT 1`, id, value)
if err == sql.ErrNoRows {
return t.ErrInviteInvalid
}
if err != nil {
return err
}
if row.Status == t.RegistrationInviteRevoked {
return t.ErrInviteRevoked
}
if row.Status == t.RegistrationInviteExpired || !now.Before(row.ExpiresAt) {
return t.ErrInviteExpired
}
if row.Status != t.RegistrationInviteActive {
return t.ErrInviteInvalid
}
txCtx, txCancel := a.getContextForTx()
if txCancel != nil {
defer txCancel()
}
tx, err := a.db.BeginTxx(txCtx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
useID, err := nextSnowflakeID()
if err != nil {
return err
}
if _, err = tx.ExecContext(txCtx, `INSERT INTO im_registration_invite_uses
(id,invite_code_id,user_id,request_id,used_at) VALUES(?,?,?,?,?)`, useID, row.ID,
store.DecodeUid(uid), requestID, now); err != nil {
if isDupe(err) {
var existingCodeID int64
if lookupErr := tx.GetContext(txCtx, &existingCodeID,
"SELECT invite_code_id FROM im_registration_invite_uses WHERE user_id=?", store.DecodeUid(uid)); lookupErr == nil && existingCodeID == row.ID {
err = nil
} else {
return t.ErrDuplicate
}
} else {
return err
}
}
eventID := t.StableEventID("registration-invite-use", id, row.ID, store.DecodeUid(uid))
if err = insertBusinessEventTx(tx, eventID, "user", uid.UserId(), uid, "registration.invite_used", map[string]any{
"tenant_id": id, "invite_code_id": row.ID, "user_id": store.DecodeUid(uid),
"request_id": requestID, "used_at": now,
}); err != nil {
return err
}
return tx.Commit()
}
// TenantRegistrationInviteSyncUsage projects the tenant usage aggregate to the
// control database. The overwrite is idempotent, so outbox retries are safe.
func (a *adapter) TenantRegistrationInviteSyncUsage(id t.TenantID, codeID int64) error {
tenantAdp, err := a.routedRegistrationInviteAdapter(id)
if err != nil {
return err
}
if tenantAdp != a {
return tenantAdp.TenantRegistrationInviteSyncUsage(id, codeID)
}
if codeID <= 0 {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var aggregate struct {
UsageCount int `db:"usage_count"`
LastUsedAt *time.Time `db:"last_used_at"`
}
if err = a.db.GetContext(ctx, &aggregate, `SELECT COUNT(*) AS usage_count,MAX(used_at) AS last_used_at
FROM im_registration_invite_uses WHERE invite_code_id=?`, codeID); err != nil {
return err
}
res, err := a.registrationInviteControlDB().ExecContext(ctx, `UPDATE im_registration_invite_codes
SET usage_count=?,last_used_at=? WHERE tenant_id=? AND id=?`, aggregate.UsageCount, aggregate.LastUsedAt, id, codeID)
if err != nil {
return err
}
if count, _ := res.RowsAffected(); count == 0 {
var exists int
if lookupErr := a.registrationInviteControlDB().GetContext(ctx, &exists,
"SELECT 1 FROM im_registration_invite_codes WHERE tenant_id=? AND id=?", id, codeID); lookupErr != nil {
if lookupErr == sql.ErrNoRows {
return t.ErrNotFound
}
return lookupErr
}
}
return nil
}
+42 -6
View File
@@ -152,7 +152,7 @@ func (a *adapter) TenantUserGet(id t.TenantID, uid t.Uid) (*t.User, error) {
defer cancel()
}
var user t.User
err = a.db.GetContext(ctx, &user, `SELECT tenant_id,id,createdat,updatedat,state,stateat,access,lastseen,useragent,
err = a.db.GetContext(ctx, &user, `SELECT tenant_id,id,username,createdat,updatedat,state,stateat,access,lastseen,useragent,
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE tenant_id=? AND id=? AND state!=?`,
id, decodeUID(uid), t.StateDeleted)
if err == sql.ErrNoRows {
@@ -165,6 +165,43 @@ func (a *adapter) TenantUserGet(id t.TenantID, uid t.Uid) (*t.User, error) {
return &user, nil
}
func (a *adapter) TenantUserGetByPhone(id t.TenantID, phone string) (t.Uid, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil || phone == "" {
return t.ZeroUid, t.ErrMalformed
}
if tenantAdp != a || a.tenantScopedDB {
ctx, cancel := tenantAdp.getContext()
if cancel != nil {
defer cancel()
}
var decoded int64
err = tenantAdp.db.GetContext(ctx, &decoded,
"SELECT id FROM im_users WHERE phone=? AND state!=?", phone, t.StateDeleted)
if err == sql.ErrNoRows {
return t.ZeroUid, nil
}
if err != nil {
return t.ZeroUid, err
}
return store.EncodeUid(decoded), nil
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var decoded int64
err = a.db.GetContext(ctx, &decoded,
"SELECT id FROM im_users WHERE tenant_id=? AND phone=? AND state!=?", id, phone, t.StateDeleted)
if err == sql.ErrNoRows {
return t.ZeroUid, nil
}
if err != nil {
return t.ZeroUid, err
}
return store.EncodeUid(decoded), nil
}
func (a *adapter) TenantUserGetAll(id t.TenantID, uids ...t.Uid) ([]t.User, error) {
tenantAdp, err := a.routedTenantAdapter(id)
if err != nil {
@@ -201,10 +238,10 @@ func (a *adapter) tenantUserGetAllTx(ctx context.Context, tx *sqlx.Tx, id t.Tena
var q string
var params []any
if a.tenantScopedDB {
q, params, _ = sqlx.In(`SELECT id,createdat,updatedat,state,stateat,access,lastseen,useragent,
q, params, _ = sqlx.In(`SELECT id,username,createdat,updatedat,state,stateat,access,lastseen,useragent,
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE id IN (?) AND state!=?`, args, t.StateDeleted)
} else {
q, params, _ = sqlx.In(`SELECT tenant_id,id,createdat,updatedat,state,stateat,access,lastseen,useragent,
q, params, _ = sqlx.In(`SELECT tenant_id,id,username,createdat,updatedat,state,stateat,access,lastseen,useragent,
COALESCE(phone,'') AS phone,public,trusted FROM im_users WHERE tenant_id=? AND id IN (?) AND state!=?`, id, args, t.StateDeleted)
}
query := a.db.Rebind(q)
@@ -7052,8 +7089,7 @@ func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searc
where = append(where, "(profile.nickname LIKE ? OR profile.public_account LIKE ? OR CAST(u.public AS CHAR) LIKE ? OR u.id=?)")
args = append(args, like, like, like, userIDNumericArg(queryText))
case "exact_account":
joins = append(joins, "LEFT JOIN im_auth AS account_auth ON account_auth.userid=u.id AND account_auth.scheme='basic'")
where = append(where, "(profile.public_account=? OR account_auth.uname=? OR u.id=?)")
where = append(where, "(profile.public_account=? OR u.username=? OR u.id=?)")
args = append(args, queryText, strings.TrimPrefix(queryText, "basic:"), userIDNumericArg(queryText))
case "phone":
addMethod = "phone"
@@ -7072,7 +7108,7 @@ func (a *adapter) TenantUserSearch(id t.TenantID, viewer t.Uid, queryText, searc
if cancel != nil {
defer cancel()
}
rows, err := a.db.QueryxContext(ctx, `SELECT DISTINCT u.id,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
rows, err := a.db.QueryxContext(ctx, `SELECT DISTINCT u.id,u.username,u.createdat,u.updatedat,u.state,u.stateat,u.access,u.lastseen,u.useragent,
COALESCE(u.phone,'') AS phone,u.public,u.trusted FROM im_users AS u `+strings.Join(joins, " ")+`
WHERE `+strings.Join(where, " AND ")+` ORDER BY u.id ASC LIMIT ?`, args...)
if err != nil {
+93 -16
View File
@@ -126,6 +126,20 @@ func TestUserIdentitySchema(t *testing.T) {
if count != 1 {
t.Fatal("im_users.phone is missing")
}
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='im_users' AND column_name='username' AND is_nullable='NO'`); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatal("im_users.username is missing or nullable")
}
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='im_auth' AND column_name IN ('uname','scheme')`); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatal("legacy im_auth.uname or im_auth.scheme still exists")
}
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='im_user_profiles' AND column_name='email'`); err != nil {
t.Fatal(err)
@@ -275,12 +289,12 @@ func TestTenantAuthIsolation(t *testing.T) {
userBID := userAID + 1
for index, userID := range []int64{userAID, userBID} {
tenantDB := quoteIdent(dbNames[index])
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_users(id,createdat,updatedat,state,access,phone,public,trusted) "+
"VALUES(?,?,?,?,?,?,?,?)", userID, now, now, types.StateOK, `{}`, "13800000000", nil, nil); err != nil {
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_users(id,username,createdat,updatedat,state,access,phone,public,trusted) "+
"VALUES(?,?,?,?,?,?,?,?,?)", userID, "alice", now, now, types.StateOK, `{}`, "13800000000", nil, nil); err != nil {
t.Fatal(err)
}
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_auth(uname,userid,scheme,authlvl,secret) VALUES(?,?,?,?,?)",
"basic:alice", userID, "basic", auth.LevelAuth, []byte("hash")); err != nil {
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_auth(userid,authlvl,secret) VALUES(?,?,?)",
userID, auth.LevelAuth, []byte("hash")); err != nil {
t.Fatal(err)
}
if _, err := db.Exec("INSERT INTO "+tenantDB+".im_credentials(createdat,updatedat,method,value,synthetic,userid,resp,done) "+
@@ -289,11 +303,11 @@ func TestTenantAuthIsolation(t *testing.T) {
}
}
uidA, _, _, _, err := tenantAdp.TenantAuthGetUniqueRecord(tenantA, "basic:alice")
uidA, _, _, _, err := tenantAdp.TenantAuthGetByLogin(tenantA, "alice")
if err != nil || uidA != store.EncodeUid(userAID) {
t.Fatalf("tenant A im_auth lookup: uid=%v err=%v", uidA, err)
}
uidB, _, _, _, err := tenantAdp.TenantAuthGetUniqueRecord(tenantB, "basic:alice")
uidB, _, _, _, err := tenantAdp.TenantAuthGetByLogin(tenantB, "alice")
if err != nil || uidB != store.EncodeUid(userBID) {
t.Fatalf("tenant B im_auth lookup: uid=%v err=%v", uidB, err)
}
@@ -306,7 +320,7 @@ func TestTenantAuthIsolation(t *testing.T) {
t.Fatalf("tenant B credential lookup: uid=%v err=%v", credB, err)
}
if err = tenantAdp.TenantAuthUpdRecord(tenantA, uidB, "basic", "basic:changed",
if err = tenantAdp.TenantAuthUpdateRecord(tenantA, uidB, "changed",
auth.LevelAuth, nil, time.Time{}); err != types.ErrNotFound {
t.Fatalf("cross-tenant im_auth update: got %v, want %v", err, types.ErrNotFound)
}
@@ -382,7 +396,7 @@ func TestCredUpsert(t *testing.T) {
func TestAuthAddRecord(t *testing.T) {
for _, rec := range testData.Recs {
err := adp.AuthAddRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, rec.Unique,
err := adp.AuthAddRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, strings.TrimPrefix(rec.Unique, "basic:"),
rec.AuthLvl, rec.Secret, rec.Expires)
if err != nil {
t.Fatal(err)
@@ -390,12 +404,74 @@ func TestAuthAddRecord(t *testing.T) {
}
//Test duplicate
err := adp.AuthAddRecord(types.ParseUserId("usr"+testData.Users[0].Id), testData.Recs[0].Scheme,
testData.Recs[0].Unique, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)
strings.TrimPrefix(testData.Recs[0].Unique, "basic:"), testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)
if err != types.ErrDuplicate {
t.Fatal("Should be duplicate error but got", err)
}
}
func TestRegistrationInviteLifecycle(t *testing.T) {
inviteAdapter, ok := adp.(adapter.TenantRegistrationInviteAdapter)
if !ok {
t.Fatal("MySQL adapter does not implement TenantRegistrationInviteAdapter")
}
var tenantID types.TenantID
if err := db.Get(&tenantID, "SELECT id FROM im_tenant WHERE code='default'"); err != nil {
t.Fatal(err)
}
now := types.TimeNow()
invite := &types.RegistrationInviteCode{
Code: "AbCdE",
ExpiresAt: now.Add(time.Hour),
CreatedBy: 10001,
Remark: "integration test",
}
if err := inviteAdapter.TenantRegistrationInviteCreate(tenantID, invite); err != nil {
t.Fatal(err)
}
stored, err := inviteAdapter.TenantRegistrationInviteGetByCode(tenantID, "AbCdE")
if err != nil || stored.Code != "AbCdE" || stored.Status != types.RegistrationInviteActive {
t.Fatalf("stored invite=%#v err=%v", stored, err)
}
if _, err = inviteAdapter.TenantRegistrationInviteGetByCode(tenantID, "abcde"); err != types.ErrInviteInvalid {
t.Fatalf("case-insensitive invite lookup was accepted: %v", err)
}
firstUID := types.ParseUserId("usr" + testData.Users[0].Id)
if err = inviteAdapter.TenantRegistrationInviteRecordUse(tenantID, "AbCdE", firstUID, "req-invite-test", now); err != nil {
t.Fatal(err)
}
secondUID := types.ParseUserId("usr" + testData.Users[1].Id)
if err = inviteAdapter.TenantRegistrationInviteRecordUse(tenantID, "AbCdE", secondUID, "req-invite-second-user", now); err != nil {
t.Fatalf("second user could not reuse invite: %v", err)
}
if err = inviteAdapter.TenantRegistrationInviteSyncUsage(tenantID, invite.ID); err != nil {
t.Fatalf("invite usage projection failed: %v", err)
}
stored, err = inviteAdapter.TenantRegistrationInviteGetByCode(tenantID, "AbCdE")
if err != nil || stored.Status != types.RegistrationInviteActive || stored.UsageCount != 2 {
t.Fatalf("reusable invite=%#v err=%v", stored, err)
}
}
func TestRegistrationInviteReusableSchema(t *testing.T) {
var count int
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='im_registration_invite_codes' AND column_name IN ('used_by','used_at')`); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("single-use columns still exist: %d", count)
}
if err := db.Get(&count, `SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='im_registration_invite_uses'
AND index_name='idx_im_registration_invite_use_code' AND non_unique=1`); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("reusable invite use index count: got %d, want 1", count)
}
}
func TestTopicCreate(t *testing.T) {
err := adp.TopicCreate(testData.Topics[0])
if err != nil {
@@ -610,7 +686,7 @@ func TestCredGetAll(t *testing.T) {
}
func TestAuthGetUniqueRecord(t *testing.T) {
uid, authLvl, secret, expires, err := adp.AuthGetUniqueRecord("basic:alice")
uid, authLvl, secret, expires, err := adp.AuthGetUniqueRecord("alice")
if err != nil {
t.Fatal(err)
}
@@ -636,13 +712,14 @@ func TestAuthGetRecord(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if recId != testData.Recs[0].Unique ||
wantLogin := strings.TrimPrefix(testData.Recs[0].Unique, "basic:")
if recId != wantLogin ||
authLvl != testData.Recs[0].AuthLvl ||
!reflect.DeepEqual(secret, testData.Recs[0].Secret) ||
expires != testData.Recs[0].Expires {
got := fmt.Sprintf("%v %v %v %v", recId, authLvl, secret, expires)
want := fmt.Sprintf("%v %v %v %v", testData.Recs[0].Unique, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)
want := fmt.Sprintf("%v %v %v %v", wantLogin, testData.Recs[0].AuthLvl, testData.Recs[0].Secret, testData.Recs[0].Expires)
t.Error(mismatchErrorString("Auth record", got, want))
}
@@ -957,13 +1034,13 @@ func TestCredConfirm(t *testing.T) {
func TestAuthUpdRecord(t *testing.T) {
rec := testData.Recs[1]
newSecret := []byte{'s', 'e', 'c', 'r', 'e', 't'}
err := adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, rec.Unique,
err := adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, strings.TrimPrefix(rec.Unique, "basic:"),
rec.AuthLvl, newSecret, rec.Expires)
if err != nil {
t.Fatal(err)
}
var got []byte
err = db.QueryRow("SELECT secret FROM im_auth WHERE uname=?", rec.Unique).Scan(&got)
err = db.QueryRow("SELECT secret FROM im_auth WHERE userid=?", decodeUid(rec.UserId)).Scan(&got)
if err != nil {
t.Fatal(err)
}
@@ -972,7 +1049,7 @@ func TestAuthUpdRecord(t *testing.T) {
}
// Test with auth ID (unique) change
newId := "basic:bob12345"
newId := "bob12345"
err = adp.AuthUpdRecord(types.ParseUserId("usr"+rec.UserId), rec.Scheme, newId,
rec.AuthLvl, newSecret, rec.Expires)
if err != nil {
@@ -980,7 +1057,7 @@ func TestAuthUpdRecord(t *testing.T) {
}
// Test if old ID deleted
var count int
err = db.QueryRow("SELECT COUNT(*) FROM im_auth WHERE uname=?", rec.Unique).Scan(&count)
err = db.QueryRow("SELECT COUNT(*) FROM im_users WHERE id=? AND username=?", decodeUid(rec.UserId), strings.TrimPrefix(rec.Unique, "basic:")).Scan(&count)
if err != nil {
t.Fatal(err)
}
+5 -3
View File
@@ -1,10 +1,12 @@
{
"reset_db_data": true,
"adapters": {
"mysql": {
"dsn": "root@tcp(localhost:3306)/tinode_test?parseTime=true&collation=utf8mb4_unicode_ci",
"mysql": {
"dsn": "root:root@tcp(127.0.0.1:3306)/tinode_test?parseTime=true&collation=utf8mb4_unicode_ci",
// Name of the main database.
"database": "tinode_test"
"database": "tinode_test",
"tenant_registry_db": "tinode_test",
"tenant_db_routing": false
}
}
}
+188
View File
@@ -81,6 +81,13 @@ func ensureUserIdentitySchema(db *sqlx.DB) error {
return err
}
}
if exists, err := identityColumnExists(ctx, conn, "im_users", "username"); err != nil {
return err
} else if !exists {
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users ADD COLUMN username VARCHAR(32) NULL AFTER id"); err != nil {
return err
}
}
if profilesExist {
if exists, err := identityColumnExists(ctx, conn, "im_user_profiles", "email"); err != nil {
return err
@@ -94,6 +101,9 @@ func ensureUserIdentitySchema(db *sqlx.DB) error {
if err = backfillUserPhones(ctx, conn, usersScoped); err != nil {
return err
}
if err = backfillUsernames(ctx, conn, usersScoped); err != nil {
return err
}
if profilesExist {
if err = backfillProfileEmails(ctx, conn, usersScoped); err != nil {
return err
@@ -115,6 +125,25 @@ func ensureUserIdentitySchema(db *sqlx.DB) error {
return fmt.Errorf("create unique phone index: %w", err)
}
}
usernameIndex := "users_username"
usernameColumns := "username"
if usersScoped {
usernameIndex = "uk_im_users_tenant_username"
usernameColumns = "tenant_id,username"
}
if exists, err := identityIndexExists(ctx, conn, "im_users", usernameIndex); err != nil {
return err
} else if !exists {
if _, err = conn.ExecContext(ctx, "CREATE UNIQUE INDEX "+quoteIdent(usernameIndex)+" ON im_users("+usernameColumns+")"); err != nil {
return fmt.Errorf("create unique username index: %w", err)
}
}
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_users MODIFY COLUMN username VARCHAR(32) NOT NULL"); err != nil {
return err
}
if err = migrateBasicAuthSchema(ctx, conn, usersScoped); err != nil {
return err
}
if exists, err := identityTableExists(ctx, conn, "im_usertags"); err != nil {
return err
@@ -133,6 +162,154 @@ func ensureUserIdentitySchema(db *sqlx.DB) error {
return nil
}
func backfillUsernames(ctx context.Context, conn *sqlx.Conn, scoped bool) error {
prefix := "0 AS tenant_id,u.id AS user_id,"
join := "a.userid=u.id"
orderBy := "u.id"
if scoped {
prefix = "u.tenant_id AS tenant_id,u.id AS user_id,"
join = "a.tenant_id=u.tenant_id AND a.userid=u.id"
orderBy = "u.tenant_id,u.id"
}
authExists, err := identityTableExists(ctx, conn, "im_auth")
if err != nil {
return err
}
selectAuth := "NULL"
authJoin := ""
if authExists {
hasUname, err := identityColumnExists(ctx, conn, "im_auth", "uname")
if err != nil {
return err
}
hasScheme, err := identityColumnExists(ctx, conn, "im_auth", "scheme")
if err != nil {
return err
}
if hasUname {
selectAuth = "CASE WHEN a.uname LIKE 'basic:%' THEN SUBSTRING(a.uname,7) ELSE a.uname END"
authJoin = " LEFT JOIN im_auth AS a ON " + join
if hasScheme {
authJoin += " AND a.scheme='basic'"
}
}
}
var rows []identityValue
query := "SELECT " + prefix + "COALESCE(NULLIF(u.username,''),NULLIF(" + selectAuth + ",''),NULLIF(u.phone,''),CONCAT('reserved_',u.id)) AS value FROM im_users AS u" + authJoin + " ORDER BY " + orderBy
if err = conn.SelectContext(ctx, &rows, query); err != nil {
return err
}
seen := make(map[string]bool, len(rows))
for _, row := range rows {
value := strings.TrimSpace(row.Value)
if strings.HasPrefix(value, "+") || (len(value) == 11 && asciiDigits(value)) {
if canonical, normalizeErr := normalizeUserPhone(value); normalizeErr == nil {
value = canonical
}
}
if value == "" || len(value) > 32 || seen[identityUniqueKey(row.TenantID, value, scoped)] {
value = fmt.Sprintf("reserved_%d", row.UserID)
}
seen[identityUniqueKey(row.TenantID, value, scoped)] = true
update := "UPDATE im_users SET username=? WHERE id=?"
args := []any{value, row.UserID}
if scoped {
update = "UPDATE im_users SET username=? WHERE tenant_id=? AND id=?"
args = []any{value, row.TenantID, row.UserID}
}
if _, err = conn.ExecContext(ctx, update, args...); err != nil {
return err
}
}
return nil
}
func migrateBasicAuthSchema(ctx context.Context, conn *sqlx.Conn, scoped bool) error {
exists, err := identityTableExists(ctx, conn, "im_auth")
if err != nil || !exists {
return err
}
if hasScheme, err := identityColumnExists(ctx, conn, "im_auth", "scheme"); err != nil {
return err
} else if hasScheme {
var unsupported int
if err = conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM im_auth WHERE scheme<>'basic'").Scan(&unsupported); err != nil {
return err
}
if unsupported > 0 {
return fmt.Errorf("mysql: im_auth contains %d non-basic records", unsupported)
}
}
legacyComposite, err := identityIndexExists(ctx, conn, "im_auth", "auth_userid_scheme")
if err != nil {
return err
}
if legacyComposite {
// The legacy composite index may currently support the userid foreign key.
// Give the FK a replacement before dropping that index.
if exists, err = identityIndexExists(ctx, conn, "im_auth", "auth_userid_fk"); err != nil {
return err
} else if !exists {
if _, err = conn.ExecContext(ctx, "CREATE INDEX auth_userid_fk ON im_auth(userid)"); err != nil {
return err
}
}
}
for _, index := range []string{"auth_uname", "auth_userid_scheme"} {
if exists, err = identityIndexExists(ctx, conn, "im_auth", index); err != nil {
return err
} else if exists {
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_auth DROP INDEX "+quoteIdent(index)); err != nil {
return err
}
}
}
for _, column := range []string{"uname", "scheme"} {
if exists, err = identityColumnExists(ctx, conn, "im_auth", column); err != nil {
return err
} else if exists {
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_auth DROP COLUMN "+quoteIdent(column)); err != nil {
return err
}
}
}
indexName := "auth_userid"
columns := "userid"
if scoped {
indexName = "auth_tenant_userid"
columns = "tenant_id,userid"
}
if exists, err = identityIndexExists(ctx, conn, "im_auth", indexName); err != nil {
return err
} else if !exists {
if _, err = conn.ExecContext(ctx, "CREATE UNIQUE INDEX "+quoteIdent(indexName)+" ON im_auth("+columns+")"); err != nil {
return err
}
}
if !scoped {
if exists, err = identityIndexExists(ctx, conn, "im_auth", "auth_userid_fk"); err != nil {
return err
} else if exists {
if _, err = conn.ExecContext(ctx, "ALTER TABLE im_auth DROP INDEX auth_userid_fk"); err != nil {
return err
}
}
}
return nil
}
func asciiDigits(value string) bool {
if value == "" {
return false
}
for i := range value {
if value[i] < '0' || value[i] > '9' {
return false
}
}
return true
}
func backfillUserPhones(ctx context.Context, conn *sqlx.Conn, scoped bool) error {
candidates, err := identityCandidates(ctx, conn, scoped, "tel")
if err != nil {
@@ -277,6 +454,17 @@ func identityCandidates(ctx context.Context, conn *sqlx.Conn, scoped bool, metho
if exists, err := identityTableExists(ctx, conn, "im_auth"); err != nil {
return nil, err
} else if exists {
hasUname, err := identityColumnExists(ctx, conn, "im_auth", "uname")
if err != nil {
return nil, err
}
hasScheme, err := identityColumnExists(ctx, conn, "im_auth", "scheme")
if err != nil {
return nil, err
}
if !hasUname || !hasScheme {
return out, nil
}
authJoin := "a.userid=u.id"
if scoped {
authJoin = "a.tenant_id=u.tenant_id AND a.userid=u.id"
+74
View File
@@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"strings"
"sync"
"time"
"github.com/tinode/chat/server/logs"
@@ -10,6 +11,10 @@ import (
"github.com/tinode/chat/server/store/types"
)
const imExternalBusinessEventPollInterval = time.Second
var imBusinessEventDispatchLocks sync.Map
func stableIMEventID(parts ...any) string {
return types.StableEventID(parts...)
}
@@ -87,6 +92,11 @@ func dispatchIMBusinessEvents(tenantID types.TenantID) {
if tenantID.IsZero() || globals.hub == nil || store.HTTPInfrastructure == nil {
return
}
lockValue, _ := imBusinessEventDispatchLocks.LoadOrStore(tenantID, &sync.Mutex{})
lock := lockValue.(*sync.Mutex)
lock.Lock()
defer lock.Unlock()
now := time.Now().UTC().Round(time.Millisecond)
events, err := store.HTTPInfrastructure.ListPendingEvents(tenantID, 100, now)
if err != nil {
@@ -94,6 +104,39 @@ func dispatchIMBusinessEvents(tenantID types.TenantID) {
return
}
for _, event := range events {
if event.EventType == "registration.invite_used" {
var payload struct {
InviteCodeID int64 `json:"invite_code_id"`
}
if err = json.Unmarshal(event.Payload, &payload); err != nil || payload.InviteCodeID <= 0 {
logs.Warn.Printf("business outbox: malformed invite usage event=%s: %v", event.EventID, err)
return
}
if err = store.RegistrationInvites.SyncUsage(tenantID, payload.InviteCodeID); err != nil {
logs.Warn.Printf("business outbox: sync invite usage event=%s: %v", event.EventID, err)
return
}
if err = store.HTTPInfrastructure.MarkEventDelivered(tenantID, event.EventID, now); err != nil {
logs.Warn.Printf("business outbox: mark delivered event=%s: %v", event.EventID, err)
return
}
continue
}
if event.EventType == "security.password_reset" {
var payload struct {
UserID int64 `json:"user_id"`
}
if err = json.Unmarshal(event.Payload, &payload); err != nil || payload.UserID <= 0 {
logs.Warn.Printf("business outbox: malformed password reset event=%s: %v", event.EventID, err)
} else if globals.sessionStore != nil {
globals.sessionStore.EvictUser(store.EncodeUid(payload.UserID), "")
}
if err = store.HTTPInfrastructure.MarkEventDelivered(tenantID, event.EventID, now); err != nil {
logs.Warn.Printf("business outbox: mark delivered event=%s: %v", event.EventID, err)
return
}
continue
}
from := ""
if !event.ActorUserID.IsZero() {
from = event.ActorUserID.UserId()
@@ -118,3 +161,34 @@ func dispatchIMBusinessEvents(tenantID types.TenantID) {
}
}
}
// startIMExternalBusinessEventPoller bridges direct database writers until the
// same event contract is transported by Kafka.
func startIMExternalBusinessEventPoller() func() {
stop := make(chan struct{})
go func() {
ticker := time.NewTicker(imExternalBusinessEventPollInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if globals.sessionStore == nil {
continue
}
tenantIDs := make(map[types.TenantID]struct{})
globals.sessionStore.Range(func(_ string, session *Session) bool {
if session != nil && !session.tenantID.IsZero() {
tenantIDs[session.tenantID] = struct{}{}
}
return true
})
for tenantID := range tenantIDs {
dispatchIMBusinessEvents(tenantID)
}
case <-stop:
return
}
}
}()
return func() { close(stop) }
}
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"testing"
"time"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type passwordResetEventStore struct {
testHTTPInfrastructureStore
delivered []string
}
func (s *passwordResetEventStore) MarkEventDelivered(_ types.TenantID, eventID string, _ time.Time) error {
s.delivered = append(s.delivered, eventID)
return nil
}
func TestDispatchPasswordResetEvent(t *testing.T) {
oldInfrastructure := store.HTTPInfrastructure
oldHub, oldSessions := globals.hub, globals.sessionStore
defer func() {
store.HTTPInfrastructure = oldInfrastructure
globals.hub, globals.sessionStore = oldHub, oldSessions
}()
infrastructure := &passwordResetEventStore{}
infrastructure.events = []types.BusinessEvent{{
EventID: "password-reset-1", EventType: "security.password_reset",
TargetType: "user", Target: "42", Payload: []byte(`{"user_id":42}`),
}}
store.HTTPInfrastructure = infrastructure
globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 1)}
globals.sessionStore = nil
dispatchIMBusinessEvents(types.TenantID(1))
if len(infrastructure.delivered) != 1 || infrastructure.delivered[0] != "password-reset-1" {
t.Fatalf("password reset event was not marked delivered: %v", infrastructure.delivered)
}
if len(globals.hub.routeSrv) != 0 {
t.Fatal("password reset event was routed as a regular client event")
}
}
func TestDispatchRegistrationInviteUsageEvent(t *testing.T) {
oldInfrastructure, oldInvites := store.HTTPInfrastructure, store.RegistrationInvites
oldHub := globals.hub
defer func() {
store.HTTPInfrastructure, store.RegistrationInvites = oldInfrastructure, oldInvites
globals.hub = oldHub
}()
infrastructure := &passwordResetEventStore{}
infrastructure.events = []types.BusinessEvent{{
EventID: "invite-use-1", EventType: "registration.invite_used",
TargetType: "user", Target: "42", Payload: []byte(`{"invite_code_id":99}`),
}}
invites := &testRegistrationInviteStore{}
store.HTTPInfrastructure = infrastructure
store.RegistrationInvites = invites
globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 1)}
dispatchIMBusinessEvents(types.TenantID(1))
if invites.syncedCode != 99 {
t.Fatalf("invite usage projection code=%d, want 99", invites.syncedCode)
}
if len(infrastructure.delivered) != 1 || infrastructure.delivered[0] != "invite-use-1" {
t.Fatalf("invite usage event was not marked delivered: %v", infrastructure.delivered)
}
if len(globals.hub.routeSrv) != 0 {
t.Fatal("invite usage event was routed as a regular client event")
}
}
+117 -15
View File
@@ -37,6 +37,7 @@ const (
imReasonChallengeExpired = "CHALLENGE_EXPIRED"
imReasonCodeInvalid = "CODE_INVALID"
imReasonTooManyAttempts = "TOO_MANY_ATTEMPTS"
imReasonSMSDeliveryFailed = "SMS_DELIVERY_FAILED"
imReasonPasswordPolicy = "PASSWORD_POLICY_VIOLATION"
imReasonAuthRequired = "AUTH_REQUIRED"
imReasonRealNameRequired = "REALNAME_REQUIRED"
@@ -51,7 +52,6 @@ const (
const (
imVerificationCodeLength = 6
imVerificationTestCode = "123456"
imVerificationExpiresInSeconds = 300
imVerificationResendAfterSeconds = 60
imVerificationMaxAttempts = 5
@@ -523,6 +523,7 @@ func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
return
}
endpoints := imPublicEndpoints(req)
writeIMSuccess(wrt, req, map[string]any{
"tenant": map[string]any{
"code": tenant.Code,
@@ -530,11 +531,7 @@ func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
"tenant_desc": tenant.TenantDesc,
"branding": map[string]any{},
},
"endpoints": map[string]any{
"websocket": strings.TrimRight(globals.servingAt, "/") + "/v0/channels",
"api": strings.TrimRight(globals.servingAt, "/") + "/v1/im",
"file": strings.TrimRight(globals.servingAt, "/") + "/v0/file",
},
"endpoints": endpoints,
"auth_methods": []string{"password", "sms"},
"password_policy": map[string]any{
"min_length": 8,
@@ -544,6 +541,75 @@ func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
})
}
func imPublicEndpoints(req *http.Request) map[string]any {
base := imPublicHTTPBase(req)
wsBase := base
if strings.HasPrefix(wsBase, "https://") {
wsBase = "wss://" + strings.TrimPrefix(wsBase, "https://")
} else if strings.HasPrefix(wsBase, "http://") {
wsBase = "ws://" + strings.TrimPrefix(wsBase, "http://")
}
return map[string]any{
"websocket": strings.TrimRight(wsBase, "/") + "/v0/channels",
"api": strings.TrimRight(base, "/") + "/v1/im",
"file": strings.TrimRight(base, "/") + "/v0/file",
}
}
func imPublicHTTPBase(req *http.Request) string {
configured := strings.TrimRight(strings.TrimSpace(globals.servingAt), "/")
parsed, _ := url.Parse(configured)
basePath := ""
if parsed != nil {
basePath = strings.TrimRight(parsed.Path, "/")
if parsed.Scheme != "" && isPublicEndpointHost(parsed.Host) {
return strings.TrimRight(parsed.Scheme+"://"+parsed.Host+basePath, "/")
}
}
if basePath == "" && req != nil && req.URL != nil {
if idx := strings.Index(req.URL.Path, "/v1/im/"); idx > 0 {
basePath = strings.TrimRight(req.URL.Path[:idx], "/")
}
}
scheme := "http"
if req != nil && req.TLS != nil {
scheme = "https"
}
if req != nil {
if forwarded := firstForwardedHeader(req.Header.Get("X-Forwarded-Proto")); forwarded == "http" || forwarded == "https" {
scheme = forwarded
}
}
host := ""
if req != nil {
host = firstForwardedHeader(req.Header.Get("X-Forwarded-Host"))
if host == "" {
host = strings.TrimSpace(req.Host)
}
}
if host == "" && parsed != nil {
host = parsed.Host
}
if host == "" {
return configured
}
return strings.TrimRight(scheme+"://"+host+basePath, "/")
}
func isPublicEndpointHost(host string) bool {
host = strings.TrimSpace(host)
return host != "" && !strings.HasPrefix(host, ":") && !strings.HasPrefix(host, "0.0.0.0:")
}
func firstForwardedHeader(value string) string {
if idx := strings.Index(value, ","); idx >= 0 {
value = value[:idx]
}
return strings.TrimSpace(strings.ToLower(value))
}
func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
@@ -598,8 +664,12 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
// Temporary fixed code for app integration until the SMS provider is connected.
code := imVerificationTestCode
code, err := randomIMNumericCode(imVerificationCodeLength)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
deliveryEnabled := scene != "register" || registrationSMSEnabled(now)
vc := &types.VerificationCode{
TenantID: tenant.ID,
ChallengeID: challengeID,
@@ -613,8 +683,8 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
ResendAfter: now.Add(imVerificationResendAfterSeconds * time.Second),
}
if scene == "reset_password" {
if uid, err := store.Users.GetByCred(tenant.ID, "tel", phone); err != nil {
logs.Warn.Println("im verification create: credential lookup failed", tenant.Code, err)
if uid, err := store.Users.GetByPhone(tenant.ID, phone); err != nil {
logs.Warn.Println("im verification create: phone lookup failed", tenant.Code, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
} else {
@@ -626,8 +696,18 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if deliveryEnabled {
if err := sendIMVerificationCode(tenant.ID, phone, "", scene, code); err != nil {
_ = store.Verifications.Fail(tenant.ID, vc.ChallengeID, types.VerificationStateExpired)
logs.Warn.Println("im verification delivery failed:", tenant.Code, scene, vc.PhoneMask, err)
writeIMError(wrt, req, http.StatusBadGateway, 30033, imReasonSMSDeliveryFailed, "SMS delivery failed", nil)
return
}
} else {
logs.Info.Println("im verification delivery suppressed:", tenant.Code, scene, vc.PhoneMask)
}
logs.Info.Println("im verification code generated:", tenant.Code, scene, vc.PhoneMask)
logs.Info.Println("im verification challenge created:", tenant.Code, scene, vc.PhoneMask)
writeIMSuccess(wrt, req, map[string]any{
"challenge_id": vc.ChallengeID,
"phone_mask": vc.PhoneMask,
@@ -794,7 +874,7 @@ func serveIMPasswordResetConfirm(wrt http.ResponseWriter, req *http.Request) {
writeIMSuccess(wrt, req, map[string]any{"status": "ok"})
return
}
login, authLevel, _, _, err := store.Users.GetAuthRecord(tenant.ID, vc.TargetUser, "basic")
login, authLevel, _, _, err := store.Users.GetAuthRecord(tenant.ID, vc.TargetUser)
if err != nil || login == "" {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
@@ -830,6 +910,19 @@ func serveIMPasswordResetConfirm(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
if account, getErr := store.Users.Get(tenant.ID, vc.TargetUser); getErr == nil && account != nil && account.Phone != "" {
if _, credErr := store.Users.UpsertCred(tenant.ID, &types.Credential{
ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now},
User: vc.TargetUser.String(),
Method: "tel",
Value: account.Phone,
Done: true,
}); credErr != nil {
logs.Warn.Println("im password reset confirm: failed to backfill phone credential", tenant.Code, credErr)
}
} else if getErr != nil {
logs.Warn.Println("im password reset confirm: failed to load account phone", tenant.Code, getErr)
}
if globals.sessionStore != nil {
globals.sessionStore.EvictUser(vc.TargetUser, "")
}
@@ -4892,8 +4985,11 @@ func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, princi
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
// Temporary fixed code for app integration until the SMS provider is connected.
code := imVerificationTestCode
code, err := randomIMNumericCode(imVerificationCodeLength)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
vc := &types.VerificationCode{
TenantID: principal.TenantID,
ChallengeID: challengeID,
@@ -4912,7 +5008,13 @@ func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, princi
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
logs.Info.Println("im security verification code generated:", action, vc.PhoneMask)
if err := sendIMVerificationCode(principal.TenantID, phone, "", "sensitive_action", code); err != nil {
_ = store.Verifications.Fail(principal.TenantID, vc.ChallengeID, types.VerificationStateExpired)
logs.Warn.Println("im security verification delivery failed:", action, vc.PhoneMask, err)
writeIMError(wrt, req, http.StatusBadGateway, 30033, imReasonSMSDeliveryFailed, "SMS delivery failed", nil)
return
}
logs.Info.Println("im security verification challenge created:", action, vc.PhoneMask)
writeIMSuccess(wrt, req, map[string]any{
"challenge_id": vc.ChallengeID,
"phone_mask": vc.PhoneMask,
+162 -1
View File
@@ -1074,11 +1074,48 @@ func TestServeIMTenantResolveSuccessFromHeader(t *testing.T) {
t.Fatalf("tenant payload must not expose internal id: %#v", tenant)
}
endpoints := data["endpoints"].(map[string]any)
if endpoints["websocket"] != "https://im.example.com/v0/channels" {
if endpoints["websocket"] != "wss://im.example.com/v0/channels" {
t.Fatalf("unexpected websocket endpoint: %#v", endpoints)
}
}
func TestServeIMTenantResolveUsesForwardedPublicHost(t *testing.T) {
oldTenants := store.Tenants
oldServingAt := globals.servingAt
store.Tenants = testTenantStore{tenant: &types.Tenant{
ID: 7,
Code: "acme",
Name: "Acme Corp",
State: types.TenantStateActive,
}}
defer func() {
store.Tenants = oldTenants
globals.servingAt = oldServingAt
}()
globals.servingAt = "http://:6060/"
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", nil)
req.Host = "internal:6060"
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "api.118im.com")
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
serveIMTenantResolve(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)
endpoints := data["endpoints"].(map[string]any)
if endpoints["websocket"] != "wss://api.118im.com/v0/channels" ||
endpoints["api"] != "https://api.118im.com/v1/im" ||
endpoints["file"] != "https://api.118im.com/v0/file" {
t.Fatalf("unexpected endpoints: %#v", endpoints)
}
}
func TestServeIMTenantResolveSuccessFromBody(t *testing.T) {
oldTenants := store.Tenants
store.Tenants = testTenantStore{tenant: &types.Tenant{
@@ -1204,6 +1241,7 @@ func TestServeIMTenantResolveHidesMissingTenant(t *testing.T) {
func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
oldSender := sendIMVerificationCode
store.Tenants = testTenantStore{tenant: &types.Tenant{
ID: 7,
Code: "acme",
@@ -1212,9 +1250,20 @@ func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
}}
verifications := &testVerificationStore{}
store.Verifications = verifications
setRegistrationSMSConfigForTest(true)
var deliveredCode string
sendIMVerificationCode = func(_ types.TenantID, recipient, _, scene, code string) error {
if recipient != "+8613800138000" || scene != "register" {
t.Fatalf("unexpected delivery: recipient=%q scene=%q", recipient, scene)
}
deliveredCode = code
return nil
}
defer func() {
store.Tenants = oldTenants
store.Verifications = oldVerifications
sendIMVerificationCode = oldSender
resetRegistrationSMSConfigForTest()
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
@@ -1237,6 +1286,11 @@ func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
if verifications.created.CodeHash == "" || strings.Contains(rec.Body.String(), verifications.created.CodeHash) {
t.Fatalf("code hash missing or leaked: %#v body=%s", verifications.created, rec.Body.String())
}
if len(deliveredCode) != imVerificationCodeLength ||
verifications.created.CodeHash != hashIMSecret(7, "register", verifications.created.ChallengeID,
verifications.created.PhoneHash, deliveredCode) {
t.Fatalf("stored challenge does not match delivered code %q", deliveredCode)
}
resp := decodeIMHTTPResponse(t, rec)
data := resp.Data.(map[string]any)
if data["challenge_id"] == "" || data["expires_in_seconds"].(float64) == 0 {
@@ -1244,6 +1298,113 @@ func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
}
}
func TestServeIMVerificationCodesSkipsDeliveryWhenRegistrationSMSDisabled(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
oldSender := sendIMVerificationCode
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}}
verifications := &testVerificationStore{}
store.Verifications = verifications
setRegistrationSMSConfigForTest(false)
sendIMVerificationCode = func(_ types.TenantID, _, _, _, _ string) error {
t.Fatal("SMS sender must not be called when registration SMS is disabled")
return nil
}
defer func() {
store.Tenants = oldTenants
store.Verifications = oldVerifications
sendIMVerificationCode = oldSender
resetRegistrationSMSConfigForTest()
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
serveIMVerificationCodes(rec, req)
if rec.Code != http.StatusOK || verifications.created == nil {
t.Fatalf("status=%d body=%s created=%#v", rec.Code, rec.Body.String(), verifications.created)
}
}
func TestServeIMPasswordResetCodeFindsPhoneWithoutTelCredential(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldTenants := store.Tenants
oldVerifications := store.Verifications
oldUsers := store.Users
oldSender := sendIMVerificationCode
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}}
verifications := &testVerificationStore{}
store.Verifications = verifications
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
users.EXPECT().GetByPhone(types.TenantID(7), "+8613800138000").Return(types.Uid(42), nil)
store.Users = users
sendIMVerificationCode = func(_ types.TenantID, recipient, _, scene, code string) error {
if recipient != "+8613800138000" || scene != "reset_password" || len(code) != imVerificationCodeLength {
t.Fatalf("unexpected reset delivery: recipient=%q scene=%q code=%q", recipient, scene, code)
}
return nil
}
defer func() {
store.Tenants = oldTenants
store.Verifications = oldVerifications
store.Users = oldUsers
sendIMVerificationCode = oldSender
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"reset_password","phone":"13800138000"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
serveIMVerificationCodes(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if verifications.created == nil || verifications.created.TargetUser != types.Uid(42) {
t.Fatalf("password reset challenge was not bound to phone account: %#v", verifications.created)
}
}
func TestServeIMVerificationCodesExpiresChallengeOnDeliveryFailure(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
oldSender := sendIMVerificationCode
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}}
verifications := &testVerificationStore{}
store.Verifications = verifications
setRegistrationSMSConfigForTest(true)
sendIMVerificationCode = func(_ types.TenantID, _, _, _, _ string) error { return types.ErrInternal }
defer func() {
store.Tenants = oldTenants
store.Verifications = oldVerifications
sendIMVerificationCode = oldSender
resetRegistrationSMSConfigForTest()
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
serveIMVerificationCodes(rec, req)
if rec.Code != http.StatusBadGateway || !verifications.failed {
t.Fatalf("status=%d failed=%v body=%s", rec.Code, verifications.failed, rec.Body.String())
}
if resp := decodeIMHTTPResponse(t, rec); resp.Reason != imReasonSMSDeliveryFailed {
t.Fatalf("reason=%q", resp.Reason)
}
}
func TestServeIMVerificationCodesRequiresTenantCode(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
+2 -1
View File
@@ -450,7 +450,6 @@ func main() {
// API key signing secret
globals.apiKeySalt = config.APIKeySalt
err = store.InitAuthLogicalNames(config.Auth["logical_names"])
if err != nil {
logs.Err.Fatal(err)
@@ -674,6 +673,8 @@ func main() {
globals.sessionStore = NewSessionStore(idleSessionTimeout + 15*time.Second)
// The hub (the main message router)
globals.hub = newHub()
stopExternalBusinessEvents := startIMExternalBusinessEventPoller()
defer stopExternalBusinessEvents()
// Start accepting cluster traffic.
if globals.cluster != nil {
+79
View File
@@ -0,0 +1,79 @@
package main
import (
"fmt"
"sync"
"time"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
"github.com/tinode/chat/server/validate"
)
const registrationSMSConfigCacheTTL = 30 * time.Second
var registrationSMSConfigCache struct {
sync.Mutex
value bool
loaded bool
loadedAt time.Time
loadValue func() (bool, error)
}
func registrationSMSEnabled(now time.Time) bool {
registrationSMSConfigCache.Lock()
defer registrationSMSConfigCache.Unlock()
if registrationSMSConfigCache.loaded && now.Sub(registrationSMSConfigCache.loadedAt) < registrationSMSConfigCacheTTL {
return registrationSMSConfigCache.value
}
loader := registrationSMSConfigCache.loadValue
if loader == nil {
loader = func() (bool, error) {
if store.RegistrationConfig == nil {
return true, types.ErrUnsupported
}
return store.RegistrationConfig.SMSEnabled()
}
}
value, err := loader()
if err != nil {
if registrationSMSConfigCache.loaded {
logs.Warn.Println("registration SMS config read failed; using cached value:", err)
return registrationSMSConfigCache.value
}
logs.Warn.Println("registration SMS config read failed; defaulting to enabled:", err)
return true
}
registrationSMSConfigCache.value = value
registrationSMSConfigCache.loaded = true
registrationSMSConfigCache.loadedAt = now
return value
}
func isValidIMInviteCode(value string) bool {
if len(value) != 5 {
return false
}
for i := range value {
if (value[i] < 'A' || value[i] > 'Z') && (value[i] < 'a' || value[i] > 'z') {
return false
}
}
return true
}
var sendIMVerificationCode = func(tenantID types.TenantID, recipient, lang, scene, code string) error {
if store.Store == nil {
return types.ErrInternal
}
validatorImpl := store.Store.GetValidator("tel")
sender, ok := validatorImpl.(validate.CodeSender)
if !ok || !validatorImpl.IsInitialized() {
return types.ErrUnsupported
}
if err := sender.SendCode(tenantID, recipient, lang, scene, code); err != nil {
return fmt.Errorf("send verification code: %w", err)
}
return nil
}
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"time"
"github.com/tinode/chat/server/store/types"
)
type testRegistrationInviteStore struct {
invite *types.RegistrationInviteCode
created *types.RegistrationInviteCode
page *types.RegistrationInvitePage
batch *types.RegistrationInviteBatch
err error
useRecorded bool
syncedCode int64
}
func (s *testRegistrationInviteStore) Create(_ types.TenantID, code *types.RegistrationInviteCode) error {
s.created = code
return s.err
}
func (s *testRegistrationInviteStore) CreateBatch(types.TenantID, *types.RegistrationInviteBatch, []types.RegistrationInviteCode) error {
return s.err
}
func (s *testRegistrationInviteStore) GetByCode(types.TenantID, string) (*types.RegistrationInviteCode, error) {
return s.invite, s.err
}
func (s *testRegistrationInviteStore) List(types.TenantID, types.RegistrationInviteListOptions) (*types.RegistrationInvitePage, error) {
return s.page, s.err
}
func (s *testRegistrationInviteStore) GetBatch(types.TenantID, int64) (*types.RegistrationInviteBatch, error) {
return s.batch, s.err
}
func (s *testRegistrationInviteStore) GetBatchByIdempotency(types.TenantID, string) (*types.RegistrationInviteBatch, error) {
return s.batch, s.err
}
func (s *testRegistrationInviteStore) Revoke(types.TenantID, int64, time.Time) error { return s.err }
func (s *testRegistrationInviteStore) RevokeBatch(types.TenantID, int64, time.Time) (int, error) {
return 0, s.err
}
func (s *testRegistrationInviteStore) RecordUse(types.TenantID, string, types.Uid, string, time.Time) error {
s.useRecorded = true
return s.err
}
func (s *testRegistrationInviteStore) SyncUsage(_ types.TenantID, codeID int64) error {
s.syncedCode = codeID
return s.err
}
func setRegistrationSMSConfigForTest(enabled bool) {
registrationSMSConfigCache.Lock()
defer registrationSMSConfigCache.Unlock()
registrationSMSConfigCache.value = false
registrationSMSConfigCache.loaded = false
registrationSMSConfigCache.loadedAt = time.Time{}
registrationSMSConfigCache.loadValue = func() (bool, error) { return enabled, nil }
}
func resetRegistrationSMSConfigForTest() {
registrationSMSConfigCache.Lock()
defer registrationSMSConfigCache.Unlock()
registrationSMSConfigCache.value = false
registrationSMSConfigCache.loaded = false
registrationSMSConfigCache.loadedAt = time.Time{}
registrationSMSConfigCache.loadValue = nil
}
+17 -6
View File
@@ -1237,20 +1237,26 @@ func TestDispatchAccNew(t *testing.T) {
uid := types.Uid(1)
oldSecurity := store.Security
oldVerifications := store.Verifications
oldInvites := store.RegistrationInvites
store.Store = ss
store.Users = uu
store.Security = &testSecurityStore{version: 1}
store.Verifications = nil
invites := &testRegistrationInviteStore{invite: &types.RegistrationInviteCode{
Code: "AbCdE", Status: types.RegistrationInviteActive, ExpiresAt: time.Now().Add(time.Hour),
}}
store.RegistrationInvites = invites
defer func() {
store.Store = nil
store.Users = nil
store.Security = oldSecurity
store.Verifications = oldVerifications
store.RegistrationInvites = oldInvites
ctrl.Finish()
}()
remoteAddr := "192.168.0.1"
secret := "<==auth-secret==>"
secret := "alice:password"
authRec := &auth.Rec{
TenantID: 1,
Uid: uid,
@@ -1289,11 +1295,13 @@ func TestDispatchAccNew(t *testing.T) {
public := "public name"
msg := &ClientComMessage{
Acc: &MsgClientAcc{
Id: "123",
User: "newXYZ",
Scheme: "basic",
Secret: []byte(secret),
Desc: &MsgSetDesc{Public: public},
Id: "123",
User: "newXYZ",
Scheme: "basic",
Username: "alice",
InviteCode: "AbCdE",
Secret: []byte(secret),
Desc: &MsgSetDesc{Public: public},
},
}
@@ -1304,6 +1312,9 @@ func TestDispatchAccNew(t *testing.T) {
if len(r.messages) != 1 {
t.Errorf("responses: expected 1, received %d.", len(r.messages))
}
if !invites.useRecorded {
t.Error("registration invite use was not recorded")
}
resp := r.messages[0].(*ServerComMessage)
if resp == nil {
t.Fatal("Response must be ServerComMessage")
+39 -24
View File
@@ -354,17 +354,17 @@ func (m *MockUsersPersistenceInterface) EXPECT() *MockUsersPersistenceInterfaceM
}
// AddAuthRecord mocks base method.
func (m *MockUsersPersistenceInterface) AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error {
func (m *MockUsersPersistenceInterface) AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, secret []byte, expires time.Time) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "AddAuthRecord", tenantID, uid, authLvl, scheme, unique, secret, expires)
ret := m.ctrl.Call(m, "AddAuthRecord", tenantID, uid, authLvl, secret, expires)
ret0, _ := ret[0].(error)
return ret0
}
// AddAuthRecord indicates an expected call of AddAuthRecord.
func (mr *MockUsersPersistenceInterfaceMockRecorder) AddAuthRecord(tenantID, uid, authLvl, scheme, unique, secret, expires interface{}) *gomock.Call {
func (mr *MockUsersPersistenceInterfaceMockRecorder) AddAuthRecord(tenantID, uid, authLvl, secret, expires interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).AddAuthRecord), tenantID, uid, authLvl, scheme, unique, secret, expires)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).AddAuthRecord), tenantID, uid, authLvl, secret, expires)
}
// ConfirmCred mocks base method.
@@ -396,18 +396,18 @@ func (mr *MockUsersPersistenceInterfaceMockRecorder) Create(tenantID, user, priv
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).Create), tenantID, user, private)
}
// DelAuthRecords mocks base method.
func (m *MockUsersPersistenceInterface) DelAuthRecords(tenantID types.TenantID, uid types.Uid, scheme string) error {
// DeleteAuthRecord mocks base method.
func (m *MockUsersPersistenceInterface) DeleteAuthRecord(tenantID types.TenantID, uid types.Uid) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DelAuthRecords", tenantID, uid, scheme)
ret := m.ctrl.Call(m, "DeleteAuthRecord", tenantID, uid)
ret0, _ := ret[0].(error)
return ret0
}
// DelAuthRecords indicates an expected call of DelAuthRecords.
func (mr *MockUsersPersistenceInterfaceMockRecorder) DelAuthRecords(tenantID, uid, scheme interface{}) *gomock.Call {
// DeleteAuthRecord indicates an expected call of DeleteAuthRecord.
func (mr *MockUsersPersistenceInterfaceMockRecorder) DeleteAuthRecord(tenantID, uid interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DelAuthRecords", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).DelAuthRecords), tenantID, uid, scheme)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).DeleteAuthRecord), tenantID, uid)
}
// DelCred mocks base method.
@@ -532,6 +532,21 @@ func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAll(tenantID interface{}
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAll", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAll), varargs...)
}
// GetByPhone mocks base method.
func (m *MockUsersPersistenceInterface) GetByPhone(tenantID types.TenantID, phone string) (types.Uid, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetByPhone", tenantID, phone)
ret0, _ := ret[0].(types.Uid)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetByPhone indicates an expected call of GetByPhone.
func (mr *MockUsersPersistenceInterfaceMockRecorder) GetByPhone(tenantID, phone interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByPhone", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetByPhone), tenantID, phone)
}
// GetAllCreds mocks base method.
func (m *MockUsersPersistenceInterface) GetAllCreds(tenantID types.TenantID, id types.Uid, method string, validatedOnly bool) ([]types.Credential, error) {
m.ctrl.T.Helper()
@@ -548,9 +563,9 @@ func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAllCreds(tenantID, id, m
}
// GetAuthRecord mocks base method.
func (m *MockUsersPersistenceInterface) GetAuthRecord(tenantID types.TenantID, user types.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) {
func (m *MockUsersPersistenceInterface) GetAuthRecord(tenantID types.TenantID, user types.Uid) (string, auth.Level, []byte, time.Time, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAuthRecord", tenantID, user, scheme)
ret := m.ctrl.Call(m, "GetAuthRecord", tenantID, user)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(auth.Level)
ret2, _ := ret[2].([]byte)
@@ -560,15 +575,15 @@ func (m *MockUsersPersistenceInterface) GetAuthRecord(tenantID types.TenantID, u
}
// GetAuthRecord indicates an expected call of GetAuthRecord.
func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthRecord(tenantID, user, scheme interface{}) *gomock.Call {
func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthRecord(tenantID, user interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthRecord), tenantID, user, scheme)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthRecord), tenantID, user)
}
// GetAuthUniqueRecord mocks base method.
func (m *MockUsersPersistenceInterface) GetAuthUniqueRecord(tenantID types.TenantID, scheme, unique string) (types.Uid, auth.Level, []byte, time.Time, error) {
// GetAuthByLogin mocks base method.
func (m *MockUsersPersistenceInterface) GetAuthByLogin(tenantID types.TenantID, login string) (types.Uid, auth.Level, []byte, time.Time, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAuthUniqueRecord", tenantID, scheme, unique)
ret := m.ctrl.Call(m, "GetAuthByLogin", tenantID, login)
ret0, _ := ret[0].(types.Uid)
ret1, _ := ret[1].(auth.Level)
ret2, _ := ret[2].([]byte)
@@ -577,10 +592,10 @@ func (m *MockUsersPersistenceInterface) GetAuthUniqueRecord(tenantID types.Tenan
return ret0, ret1, ret2, ret3, ret4
}
// GetAuthUniqueRecord indicates an expected call of GetAuthUniqueRecord.
func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthUniqueRecord(tenantID, scheme, unique interface{}) *gomock.Call {
// GetAuthByLogin indicates an expected call of GetAuthByLogin.
func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthByLogin(tenantID, login interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthUniqueRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthUniqueRecord), tenantID, scheme, unique)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthByLogin", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthByLogin), tenantID, login)
}
// GetByCred mocks base method.
@@ -723,17 +738,17 @@ func (mr *MockUsersPersistenceInterfaceMockRecorder) Update(tenantID, uid, updat
}
// UpdateAuthRecord mocks base method.
func (m *MockUsersPersistenceInterface) UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error {
func (m *MockUsersPersistenceInterface) UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, username string, secret []byte, expires time.Time) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateAuthRecord", tenantID, uid, authLvl, scheme, unique, secret, expires)
ret := m.ctrl.Call(m, "UpdateAuthRecord", tenantID, uid, authLvl, username, secret, expires)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateAuthRecord indicates an expected call of UpdateAuthRecord.
func (mr *MockUsersPersistenceInterfaceMockRecorder) UpdateAuthRecord(tenantID, uid, authLvl, scheme, unique, secret, expires interface{}) *gomock.Call {
func (mr *MockUsersPersistenceInterfaceMockRecorder) UpdateAuthRecord(tenantID, uid, authLvl, username, secret, expires interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).UpdateAuthRecord), tenantID, uid, authLvl, scheme, unique, secret, expires)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).UpdateAuthRecord), tenantID, uid, authLvl, username, secret, expires)
}
// UpdateLastSeen mocks base method.
+172 -28
View File
@@ -137,6 +137,24 @@ type tenantsMapper struct{}
// Tenants is the tenant registry access object.
var Tenants TenantsPersistenceInterface
// RegistrationConfigPersistenceInterface exposes global registration policy.
type RegistrationConfigPersistenceInterface interface {
SMSEnabled() (bool, error)
}
type registrationConfigMapper struct{}
// RegistrationConfig reads registration policy from the control database.
var RegistrationConfig RegistrationConfigPersistenceInterface
func (registrationConfigMapper) SMSEnabled() (bool, error) {
configAdp, ok := adp.(adapter.RegistrationConfigAdapter)
if !ok {
return true, types.ErrUnsupported
}
return configAdp.RegistrationSMSEnabled()
}
// GetByCode resolves a tenant by its public enterprise code.
func (tenantsMapper) GetByCode(code string) (*types.Tenant, error) {
tenantAdp, ok := adp.(adapter.TenantAdapter)
@@ -368,13 +386,14 @@ func (s storeObj) DbStats() func() any {
// UsersPersistenceInterface is an interface which defines methods for persistent storage of user records.
type UsersPersistenceInterface interface {
Create(tenantID types.TenantID, user *types.User, private any) (*types.User, error)
GetAuthRecord(tenantID types.TenantID, user types.Uid, scheme string) (string, auth.Level, []byte, time.Time, error)
GetAuthUniqueRecord(tenantID types.TenantID, scheme, unique string) (types.Uid, auth.Level, []byte, time.Time, error)
AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error
UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error
DelAuthRecords(tenantID types.TenantID, uid types.Uid, scheme string) error
GetAuthRecord(tenantID types.TenantID, user types.Uid) (string, auth.Level, []byte, time.Time, error)
GetAuthByLogin(tenantID types.TenantID, login string) (types.Uid, auth.Level, []byte, time.Time, error)
AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, secret []byte, expires time.Time) error
UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, username string, secret []byte, expires time.Time) error
DeleteAuthRecord(tenantID types.TenantID, uid types.Uid) error
Get(tenantID types.TenantID, uid types.Uid) (*types.User, error)
GetAll(tenantID types.TenantID, uid ...types.Uid) ([]types.User, error)
GetByPhone(tenantID types.TenantID, phone string) (types.Uid, error)
GetByCred(tenantID types.TenantID, method, value string) (types.Uid, error)
Delete(tenantID types.TenantID, id types.Uid, hard bool) error
UpdateLastSeen(tenantID types.TenantID, uid types.Uid, userAgent string, when time.Time) error
@@ -455,61 +474,50 @@ func (usersMapper) Create(tenantID types.TenantID, user *types.User, private any
// GetAuthRecord takes a user ID and a authentication scheme name, fetches unique scheme-dependent identifier and
// authentication secret.
func (usersMapper) GetAuthRecord(tenantID types.TenantID, user types.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) {
func (usersMapper) GetAuthRecord(tenantID types.TenantID, user types.Uid) (string, auth.Level, []byte, time.Time, error) {
tenantAdp, err := tenantAuthAdapter(tenantID)
if err != nil {
return "", auth.LevelNone, nil, time.Time{}, err
}
unique, authLvl, secret, expires, err := tenantAdp.TenantAuthGetRecord(tenantID, user, scheme)
if err == nil {
parts := strings.Split(unique, ":")
if len(parts) > 1 {
unique = parts[1]
} else {
err = types.ErrInternal
}
}
return unique, authLvl, secret, expires, err
return tenantAdp.TenantAuthGetRecord(tenantID, user)
}
// GetAuthUniqueRecord takes a unique identifier and a authentication scheme name, fetches user ID and
// authentication secret.
func (usersMapper) GetAuthUniqueRecord(tenantID types.TenantID, scheme, unique string) (types.Uid, auth.Level, []byte, time.Time, error) {
// GetAuthByLogin fetches a Basic authentication record by username or phone.
func (usersMapper) GetAuthByLogin(tenantID types.TenantID, login string) (types.Uid, auth.Level, []byte, time.Time, error) {
tenantAdp, err := tenantAuthAdapter(tenantID)
if err != nil {
return types.ZeroUid, auth.LevelNone, nil, time.Time{}, err
}
return tenantAdp.TenantAuthGetUniqueRecord(tenantID, scheme+":"+unique)
return tenantAdp.TenantAuthGetByLogin(tenantID, login)
}
// AddAuthRecord creates a new authentication record for the given user.
func (usersMapper) AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte,
func (usersMapper) AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, secret []byte,
expires time.Time) error {
tenantAdp, err := tenantAuthAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantAuthAddRecord(tenantID, uid, scheme, scheme+":"+unique, authLvl, secret, expires)
return tenantAdp.TenantAuthAddRecord(tenantID, uid, authLvl, secret, expires)
}
// UpdateAuthRecord updates authentication record with a new secret and expiration time.
func (usersMapper) UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string,
func (usersMapper) UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, username string,
secret []byte, expires time.Time) error {
tenantAdp, err := tenantAuthAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantAuthUpdRecord(tenantID, uid, scheme, scheme+":"+unique, authLvl, secret, expires)
return tenantAdp.TenantAuthUpdateRecord(tenantID, uid, username, authLvl, secret, expires)
}
// DelAuthRecords deletes user's auth records of the given scheme.
func (usersMapper) DelAuthRecords(tenantID types.TenantID, uid types.Uid, scheme string) error {
// DeleteAuthRecord deletes the user's Basic authentication record.
func (usersMapper) DeleteAuthRecord(tenantID types.TenantID, uid types.Uid) error {
tenantAdp, err := tenantAuthAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantAuthDelScheme(tenantID, uid, scheme)
return tenantAdp.TenantAuthDeleteRecord(tenantID, uid)
}
// Get returns a user object for the given user ID or nil if the user is not found.
@@ -552,6 +560,16 @@ func (usersMapper) GetByCred(tenantID types.TenantID, method, value string) (typ
return tenantAdp.TenantUserGetByCred(tenantID, method, value)
}
// GetByPhone resolves an account by the phone identity stored on im_users.
// It intentionally does not require a completed tel credential.
func (usersMapper) GetByPhone(tenantID types.TenantID, phone string) (types.Uid, error) {
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return types.ZeroUid, err
}
return tenantAdp.TenantUserGetByPhone(tenantID, phone)
}
// Delete deletes user records.
func (usersMapper) Delete(tenantID types.TenantID, id types.Uid, hard bool) error {
tenantAdp, err := tenantBusinessAdapter(tenantID)
@@ -3062,6 +3080,25 @@ type verificationMapper struct{}
// Verifications is a singleton for product verification challenges.
var Verifications VerificationPersistenceInterface
// RegistrationInvitePersistenceInterface manages tenant-scoped registration invitations.
type RegistrationInvitePersistenceInterface interface {
Create(tenantID types.TenantID, code *types.RegistrationInviteCode) error
CreateBatch(tenantID types.TenantID, batch *types.RegistrationInviteBatch, codes []types.RegistrationInviteCode) error
GetByCode(tenantID types.TenantID, code string) (*types.RegistrationInviteCode, error)
List(tenantID types.TenantID, opts types.RegistrationInviteListOptions) (*types.RegistrationInvitePage, error)
GetBatch(tenantID types.TenantID, batchID int64) (*types.RegistrationInviteBatch, error)
GetBatchByIdempotency(tenantID types.TenantID, key string) (*types.RegistrationInviteBatch, error)
Revoke(tenantID types.TenantID, codeID int64, now time.Time) error
RevokeBatch(tenantID types.TenantID, batchID int64, now time.Time) (int, error)
RecordUse(tenantID types.TenantID, code string, uid types.Uid, requestID string, now time.Time) error
SyncUsage(tenantID types.TenantID, codeID int64) error
}
type registrationInviteMapper struct{}
// RegistrationInvites is the tenant-scoped registration invitation store.
var RegistrationInvites RegistrationInvitePersistenceInterface
// UserSecurityPersistenceInterface defines user security state used to
// invalidate previously issued login tokens.
type UserSecurityPersistenceInterface interface {
@@ -3267,6 +3304,111 @@ func (verificationMapper) Consume(tenantID types.TenantID, challengeID, tokenHas
return tenantAdp.TenantVerificationConsume(tenantID, challengeID, tokenHash, consumedAt)
}
func tenantRegistrationInviteAdapter(tenantID types.TenantID) (adapter.TenantRegistrationInviteAdapter, error) {
if !tenantID.IsValid() {
return nil, types.ErrMalformed
}
if router, ok := adp.(adapter.TenantRoutedAdapter); ok {
business, _, err := router.TenantAdapterFor(tenantID)
if err != nil {
return nil, err
}
if inviteAdp, ok := business.(adapter.TenantRegistrationInviteAdapter); ok {
return inviteAdp, nil
}
}
if inviteAdp, ok := adp.(adapter.TenantRegistrationInviteAdapter); ok {
return inviteAdp, nil
}
return nil, types.ErrUnsupported
}
func (registrationInviteMapper) Create(tenantID types.TenantID, code *types.RegistrationInviteCode) error {
if code == nil {
return types.ErrMalformed
}
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
if err != nil {
return err
}
return inviteAdp.TenantRegistrationInviteCreate(tenantID, code)
}
func (registrationInviteMapper) CreateBatch(tenantID types.TenantID, batch *types.RegistrationInviteBatch, codes []types.RegistrationInviteCode) error {
if batch == nil || len(codes) == 0 {
return types.ErrMalformed
}
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
if err != nil {
return err
}
return inviteAdp.TenantRegistrationInviteCreateBatch(tenantID, batch, codes)
}
func (registrationInviteMapper) GetByCode(tenantID types.TenantID, code string) (*types.RegistrationInviteCode, error) {
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
if err != nil {
return nil, err
}
return inviteAdp.TenantRegistrationInviteGetByCode(tenantID, code)
}
func (registrationInviteMapper) List(tenantID types.TenantID, opts types.RegistrationInviteListOptions) (*types.RegistrationInvitePage, error) {
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
if err != nil {
return nil, err
}
return inviteAdp.TenantRegistrationInviteList(tenantID, opts)
}
func (registrationInviteMapper) GetBatch(tenantID types.TenantID, batchID int64) (*types.RegistrationInviteBatch, error) {
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
if err != nil {
return nil, err
}
return inviteAdp.TenantRegistrationInviteBatchGet(tenantID, batchID)
}
func (registrationInviteMapper) GetBatchByIdempotency(tenantID types.TenantID, key string) (*types.RegistrationInviteBatch, error) {
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
if err != nil {
return nil, err
}
return inviteAdp.TenantRegistrationInviteBatchGetByIdempotency(tenantID, key)
}
func (registrationInviteMapper) Revoke(tenantID types.TenantID, codeID int64, now time.Time) error {
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
if err != nil {
return err
}
return inviteAdp.TenantRegistrationInviteRevoke(tenantID, codeID, now)
}
func (registrationInviteMapper) RevokeBatch(tenantID types.TenantID, batchID int64, now time.Time) (int, error) {
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
if err != nil {
return 0, err
}
return inviteAdp.TenantRegistrationInviteBatchRevoke(tenantID, batchID, now)
}
func (registrationInviteMapper) RecordUse(tenantID types.TenantID, code string, uid types.Uid, requestID string, now time.Time) error {
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
if err != nil {
return err
}
return inviteAdp.TenantRegistrationInviteRecordUse(tenantID, code, uid, requestID, now)
}
func (registrationInviteMapper) SyncUsage(tenantID types.TenantID, codeID int64) error {
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
if err != nil {
return err
}
return inviteAdp.TenantRegistrationInviteSyncUsage(tenantID, codeID)
}
// Get reads a persistent cache entry.
func (pcacheMapper) Get(tenantID types.TenantID, key string) (string, error) {
tenantAdp, err := tenantBusinessAdapter(tenantID)
@@ -3310,6 +3452,7 @@ func SetTestUidGenerator(g types.UidGenerator) {
func init() {
Store = storeObj{}
Tenants = tenantsMapper{}
RegistrationConfig = registrationConfigMapper{}
Users = usersMapper{}
Topics = topicsMapper{}
Subs = subsMapper{}
@@ -3330,6 +3473,7 @@ func init() {
Files = fileMapper{}
PCache = pcacheMapper{}
Verifications = verificationMapper{}
RegistrationInvites = registrationInviteMapper{}
Security = userSecurityMapper{}
Relations = relationsMapper{}
Favorites = favoritesMapper{}
+70
View File
@@ -0,0 +1,70 @@
package types
import (
"errors"
"time"
)
var (
ErrInviteInvalid = errors.New("registration invite invalid")
ErrInviteExpired = errors.New("registration invite expired")
ErrInviteRevoked = errors.New("registration invite revoked")
ErrInviteIdempotencyConflict = errors.New("registration invite idempotency conflict")
)
type RegistrationInviteStatus string
const (
RegistrationInviteActive RegistrationInviteStatus = "active"
RegistrationInviteExpired RegistrationInviteStatus = "expired"
RegistrationInviteRevoked RegistrationInviteStatus = "revoked"
)
type RegistrationInviteBatch struct {
ID int64 `db:"id" json:"batch_id"`
TenantID TenantID `db:"tenant_id" json:"tenant_id"`
Quantity int `db:"quantity" json:"quantity"`
IdempotencyKey string `db:"idempotency_key" json:"-"`
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
CreatedBy int64 `db:"created_by" json:"created_by"`
Remark string `db:"remark" json:"remark,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ActiveCount int `db:"active_count" json:"active_count"`
UsageCount int `db:"usage_count" json:"usage_count"`
ExpiredCount int `db:"expired_count" json:"expired_count"`
RevokedCount int `db:"revoked_count" json:"revoked_count"`
}
type RegistrationInviteCode struct {
ID int64 `db:"id" json:"id"`
TenantID TenantID `db:"tenant_id" json:"tenant_id"`
BatchID *int64 `db:"batch_id" json:"batch_id,omitempty"`
Code string `db:"code" json:"code"`
Status RegistrationInviteStatus `db:"status" json:"status"`
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
UsageCount int `db:"usage_count" json:"usage_count"`
LastUsedAt *time.Time `db:"last_used_at" json:"last_used_at,omitempty"`
CreatedBy int64 `db:"created_by" json:"created_by"`
Remark string `db:"remark" json:"remark,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
type RegistrationInviteListOptions struct {
Code string
Status RegistrationInviteStatus
BatchID int64
CreatedBy int64
CreatedAtStart *time.Time
CreatedAtEnd *time.Time
ExpiresAtStart *time.Time
ExpiresAtEnd *time.Time
Offset int
Limit int
}
type RegistrationInvitePage struct {
Items []RegistrationInviteCode
Total int
}
+2
View File
@@ -521,6 +521,8 @@ type User struct {
UserAgent string
// Canonical mobile number used for registration and account recovery.
Phone string
// Primary login account. Ordinary accounts are lowercase ASCII names; phone accounts use E.164.
Username string
Public any
Trusted any
+1 -1
View File
@@ -46,7 +46,7 @@
"logical_names": [],
"basic": {
"add_to_tags": false,
"min_login_length": 4,
"min_login_length": 1,
"min_password_length": 6
},
"token": {
+154 -6
View File
@@ -34,13 +34,27 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
// Find authenticator for the requested scheme.
authhdl := store.Store.GetLogicalAuthHandler(msg.Acc.Scheme)
if authhdl == nil {
if authhdl == nil || msg.Acc.Scheme != "basic" {
// New accounts must have an authentication scheme
s.queueOut(ErrMalformed(msg.Id, "", msg.Timestamp))
logs.Warn.Println("create user: unknown auth handler, sid=", s.sid)
return
}
account, phone, phoneRegistration, err := resolveRegistrationAccount(msg.Acc.Username, msg.Acc.Phone)
if err != nil || !registrationSecretMatches(msg.Acc.Secret, account) {
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
}
inviteCode := strings.TrimSpace(msg.Acc.InviteCode)
if msg.Acc.InviteCode != inviteCode || (inviteCode != "" && !isValidIMInviteCode(inviteCode)) {
s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp,
map[string]any{"what": "invite_code", "reason": "INVITE_CODE_INVALID"}))
return
}
// 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 {
@@ -52,6 +66,8 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
var user types.User
user.TenantID = s.tenantID
user.Username = account
user.Phone = phone
var private any
// If account state is being assigned, make sure the sender is a root user.
@@ -85,9 +101,15 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
// consequently cannot check presence of required credentials. Must do that later.
creds := normalizeCredentials(msg.Acc.Cred, true)
var registrationPhone string
var registrationTelCredential bool
for i := range creds {
cr := &creds[i]
vld := store.Store.GetValidator(cr.Method)
if vld == nil || !vld.IsInitialized() {
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"what": cr.Method}))
return
}
normalized, err := vld.PreCheck(cr.Value, cr.Params)
if err != nil {
logs.Warn.Println("create user: failed credential pre-check", cr, err, "sid=", s.sid)
@@ -105,15 +127,40 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
return
}
registrationPhone = phone
registrationTelCredential = true
}
}
if registrationPhone == "" && store.Verifications != nil {
logs.Warn.Println("create user: verified phone is required, sid=", s.sid)
if !phoneRegistration && registrationTelCredential {
logs.Warn.Println("create user: username registration cannot attach a phone credential, sid=", s.sid)
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"creds": []string{"tel"}}))
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_NOT_SELECTED"}))
return
}
user.Phone = registrationPhone
if phoneRegistration && registrationTelCredential && 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 {
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"}))
return
}
inviteRequired := !phoneRegistration || (!smsEnabled && !registrationTelCredential)
if inviteRequired && inviteCode == "" {
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"what": "invite_code", "reason": "INVITE_CODE_REQUIRED"}))
return
}
if inviteCode != "" {
if err := validateRegistrationInvite(s.tenantID, inviteCode, time.Now().UTC()); err != nil {
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"what": "invite_code", "reason": registrationInviteReason(err)}))
return
}
}
// Assign default access values in case the acc creator has not provided them
user.Access.Auth = getDefaultAccess(types.TopicCatP2P, true, false) |
@@ -155,7 +202,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
}
// Add authentication record.
rec, err := authhdl.AddRecord(authCtx,
rec, err = authhdl.AddRecord(authCtx,
&auth.Rec{TenantID: s.tenantID, Uid: user.Uid()}, msg.Acc.Secret)
if err != nil {
logs.Warn.Println("create user: add auth record failed", err, "sid=", s.sid)
@@ -201,6 +248,34 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
}
return
}
if registrationTelCredential && !slices.Contains(validated, "tel") {
logs.Warn.Println("create user: phone credential was not validated, sid=", s.sid)
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_VERIFICATION_REQUIRED"}))
if err = store.Users.Delete(s.tenantID, user.Uid(), true); err != nil {
logs.Warn.Println("create user: failed to delete incomplete user record", err, "sid=", s.sid)
}
return
}
if inviteCode != "" {
requestID := strings.TrimSpace(msg.Id)
if requestID == "" {
requestID = "acc:" + s.sid
}
if len(requestID) > 64 {
requestID = requestID[:64]
}
if err = store.RegistrationInvites.RecordUse(s.tenantID, inviteCode, user.Uid(), requestID, time.Now().UTC()); err != nil {
logs.Warn.Println("create user: record invite use failed", err, "sid=", s.sid)
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
map[string]any{"what": "invite_code", "reason": registrationInviteReason(err)}))
if deleteErr := store.Users.Delete(s.tenantID, user.Uid(), true); deleteErr != nil {
logs.Warn.Println("create user: failed to delete incomplete user record", deleteErr, "sid=", s.sid)
}
return
}
dispatchIMBusinessEvents(s.tenantID)
}
if msg.Extra != nil && len(msg.Extra.Attachments) > 0 {
if err := store.Files.LinkAttachments(s.tenantID, user.Uid().UserId(), types.ZeroUid, msg.Extra.Attachments); err != nil {
@@ -240,6 +315,79 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
pluginAccount(&user, plgActCreate)
}
func resolveRegistrationAccount(username, phone string) (account, canonicalPhone string, phoneRegistration bool, err error) {
if phone != "" {
if len(phone) != 11 || !asciiRegistrationDigits(phone) {
return "", "", false, types.ErrPolicy
}
canonicalPhone, err = normalizeIMPhone(phone, "CN")
if err != nil {
return "", "", false, err
}
return canonicalPhone, canonicalPhone, true, nil
}
if username == "" || len(username) > 10 {
return "", "", false, types.ErrPolicy
}
for i := range username {
if (username[i] < 'a' || username[i] > 'z') && (username[i] < '0' || username[i] > '9') {
return "", "", false, types.ErrPolicy
}
}
return username, "", false, nil
}
func asciiRegistrationDigits(value string) bool {
for i := range value {
if value[i] < '0' || value[i] > '9' {
return false
}
}
return value != ""
}
func registrationSecretMatches(secret []byte, account string) bool {
separator := strings.IndexByte(string(secret), ':')
return separator > 0 && string(secret[:separator]) == account
}
func validateRegistrationInvite(tenantID types.TenantID, code string, now time.Time) error {
if store.RegistrationInvites == nil {
return types.ErrInternal
}
invite, err := store.RegistrationInvites.GetByCode(tenantID, code)
if err != nil {
return err
}
if invite == nil {
return types.ErrInviteInvalid
}
switch invite.Status {
case types.RegistrationInviteRevoked:
return types.ErrInviteRevoked
case types.RegistrationInviteExpired:
return types.ErrInviteExpired
case types.RegistrationInviteActive:
if !now.Before(invite.ExpiresAt) {
return types.ErrInviteExpired
}
return nil
default:
return types.ErrInviteInvalid
}
}
func registrationInviteReason(err error) string {
switch err {
case types.ErrInviteExpired:
return "INVITE_CODE_EXPIRED"
case types.ErrInviteRevoked:
return "INVITE_CODE_REVOKED"
default:
return "INVITE_CODE_INVALID"
}
}
// Process update to an account:
// * Authentication update, i.e. login/password change
// * Credentials update
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"testing"
"time"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
func TestResolveRegistrationAccount(t *testing.T) {
tests := []struct {
name string
username string
phone string
wantAccount string
wantPhone string
wantPhoneFlow 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: "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},
{name: "phone whitespace rejected", username: "alice", phone: " 13800138000", wantErr: true},
}
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)
}
})
}
}
func TestRegistrationSecretMatchesSelectedAccount(t *testing.T) {
if !registrationSecretMatches([]byte("alice:password:with-colon"), "alice") {
t.Fatal("matching account was rejected")
}
if registrationSecretMatches([]byte("bob:password"), "alice") || registrationSecretMatches([]byte("password"), "alice") {
t.Fatal("mismatched or malformed secret was accepted")
}
}
func TestValidateRegistrationInviteStates(t *testing.T) {
oldInvites := store.RegistrationInvites
defer func() { store.RegistrationInvites = oldInvites }()
now := time.Now().UTC()
tests := []struct {
status types.RegistrationInviteStatus
expires time.Time
want error
}{
{types.RegistrationInviteActive, now.Add(time.Minute), nil},
{types.RegistrationInviteActive, now, types.ErrInviteExpired},
{types.RegistrationInviteRevoked, now.Add(time.Minute), types.ErrInviteRevoked},
{types.RegistrationInviteExpired, now.Add(time.Minute), types.ErrInviteExpired},
}
for _, tc := range tests {
store.RegistrationInvites = &testRegistrationInviteStore{invite: &types.RegistrationInviteCode{Status: tc.status, ExpiresAt: tc.expires}}
if got := validateRegistrationInvite(7, "AbCdE", now); got != tc.want {
t.Fatalf("status=%q: got %v want %v", tc.status, got, tc.want)
}
}
}
+10 -1
View File
@@ -173,8 +173,17 @@ func TestServeIMPasswordResetConfirm(t *testing.T) {
ExpiresAt: time.Now().UTC().Add(time.Minute),
}}
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
users.EXPECT().GetAuthRecord(types.TenantID(7), types.Uid(42), "basic").
users.EXPECT().GetAuthRecord(types.TenantID(7), types.Uid(42)).
Return("alice", auth.LevelAuth, []byte("old"), time.Time{}, nil)
users.EXPECT().Get(types.TenantID(7), types.Uid(42)).
Return(&types.User{Phone: "+8613800138000"}, nil)
users.EXPECT().UpsertCred(types.TenantID(7), gomock.AssignableToTypeOf(&types.Credential{})).
DoAndReturn(func(_ types.TenantID, cred *types.Credential) (bool, error) {
if cred.Method != "tel" || cred.Value != "+8613800138000" || !cred.Done {
t.Fatalf("unexpected reset credential: %#v", cred)
}
return true, nil
})
store.Users = users
basicAuth := mock_auth.NewMockAuthHandler(ctrl)
basicAuth.EXPECT().IsInitialized().Return(true)
+25
View File
@@ -214,6 +214,31 @@ func (v *validator) ResetSecret(tenantID t.TenantID, phone, scheme, lang string,
return nil
}
// SendCode synchronously delivers a product verification code. Unlike the
// legacy credential flow, it fails when no real SMS provider is configured.
func (v *validator) SendCode(_ t.TenantID, phone, lang, scene, code string) error {
if v.Twilio == nil {
return t.ErrUnsupported
}
var template *textt.Template
if v.langMatcher != nil {
_, idx := i18n.MatchStrings(v.langMatcher, lang)
template = v.universalTempl[idx]
} else if len(v.universalTempl) > 0 {
template = v.universalTempl[0]
}
if template == nil {
return t.ErrInternal
}
content, err := validate.ExecuteTemplate(template, nil, map[string]any{
"Code": code, "HostUrl": v.HostUrl, "Scene": scene,
})
if err != nil {
return err
}
return twilioSend(v.Sender, phone, content[""])
}
// Check checks validity of user's response.
func (v *validator) Check(tenantID t.TenantID, user t.Uid, resp string) (string, error) {
cred, err := store.Users.GetActiveCred(tenantID, user, validatorName)
+6
View File
@@ -58,6 +58,12 @@ type Validator interface {
TempAuthScheme() (string, error)
}
// CodeSender is an optional synchronous delivery capability used by product
// verification challenges which exist before a user account is created.
type CodeSender interface {
SendCode(tenantID t.TenantID, recipient, lang, scene, code string) error
}
func ValidateHostURL(origUrl string) (string, error) {
hostUrl, err := url.Parse(origUrl)
if err != nil {