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
+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"