feat: complete registration invitation and auth redesign
This commit is contained in:
+26
-6
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user