feat: complete registration invitation and auth redesign
This commit is contained in:
+1
-1
@@ -278,7 +278,7 @@ type AuthHandler interface {
|
||||
// continue the authentication process to the next step, or return an error code.
|
||||
// The remoteAddr (i.e. the IP address of the client) can be used by custom authenticators for
|
||||
// additional validation. The stock authenticators don't use it.
|
||||
// store.Users.GetAuthRecord("scheme", "unique")
|
||||
// The selected handler resolves its account through the tenant-scoped auth store.
|
||||
// Returns: user auth record, challenge, error.
|
||||
Authenticate(ctx AuthContext, secret []byte) (*Rec, []byte, error)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
|
||||
// Define default constraints on login and password
|
||||
const (
|
||||
defaultMinLoginLength = 2
|
||||
defaultMinLoginLength = 1
|
||||
defaultMaxLoginLength = 32
|
||||
|
||||
defaultMinPasswordLength = 3
|
||||
@@ -26,7 +26,7 @@ const (
|
||||
// Token suitable as a login: either an E.164 phone number or a name which starts
|
||||
// and ends with a Unicode letter (class L) or number (class N), and contains
|
||||
// Unicode letters, numbers, dot and underscore.
|
||||
var loginPattern = regexp.MustCompile(`^(\+[\pN]+|[\pL\pN][_.\pL\pN]*[\pL\pN]+)$`)
|
||||
var loginPattern = regexp.MustCompile(`^(\+[0-9]+|[\pL\pN](?:[_.\pL\pN]*[\pL\pN])?)$`)
|
||||
|
||||
// authenticator is the type to map authentication methods to.
|
||||
type authenticator struct {
|
||||
@@ -143,7 +143,7 @@ func (a *authenticator) AddRecord(ctx auth.AuthContext, rec *auth.Rec, secret []
|
||||
authLevel = auth.LevelAuth
|
||||
}
|
||||
|
||||
err = store.Users.AddAuthRecord(ctx.TenantID, rec.Uid, authLevel, a.name, uname, passhash, expires)
|
||||
err = store.Users.AddAuthRecord(ctx.TenantID, rec.Uid, authLevel, passhash, expires)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -165,7 +165,7 @@ func (a *authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret
|
||||
return nil, err
|
||||
}
|
||||
|
||||
login, authLevel, _, _, err := store.Users.GetAuthRecord(ctx.TenantID, rec.Uid, a.name)
|
||||
login, authLevel, _, _, err := store.Users.GetAuthRecord(ctx.TenantID, rec.Uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -179,7 +179,7 @@ func (a *authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret
|
||||
uname = login
|
||||
} else if err = a.checkLoginPolicy(uname); err != nil {
|
||||
return nil, err
|
||||
} else if uid, _, _, _, err := store.Users.GetAuthUniqueRecord(ctx.TenantID, a.name, uname); err != nil {
|
||||
} else if uid, _, _, _, err := store.Users.GetAuthByLogin(ctx.TenantID, uname); err != nil {
|
||||
return nil, err
|
||||
} else if !uid.IsZero() {
|
||||
// The (new) user name already exists. Report an error.
|
||||
@@ -198,7 +198,7 @@ func (a *authenticator) UpdateRecord(ctx auth.AuthContext, rec *auth.Rec, secret
|
||||
if rec.Lifetime > 0 {
|
||||
expires = types.TimeNow().Add(time.Duration(rec.Lifetime))
|
||||
}
|
||||
err = store.Users.UpdateAuthRecord(ctx.TenantID, rec.Uid, authLevel, a.name, uname, passhash, expires)
|
||||
err = store.Users.UpdateAuthRecord(ctx.TenantID, rec.Uid, authLevel, uname, passhash, expires)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -228,7 +228,7 @@ func (a *authenticator) Authenticate(ctx auth.AuthContext, secret []byte) (*auth
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
uid, authLvl, passhash, expires, err := store.Users.GetAuthUniqueRecord(ctx.TenantID, a.name, uname)
|
||||
uid, authLvl, passhash, expires, err := store.Users.GetAuthByLogin(ctx.TenantID, uname)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -287,7 +287,7 @@ func (a *authenticator) IsUnique(ctx auth.AuthContext, secret []byte) (bool, err
|
||||
return false, err
|
||||
}
|
||||
|
||||
uid, _, _, _, err := store.Users.GetAuthUniqueRecord(ctx.TenantID, a.name, uname)
|
||||
uid, _, _, _, err := store.Users.GetAuthByLogin(ctx.TenantID, uname)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -305,7 +305,7 @@ func (authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {
|
||||
|
||||
// DelRecords deletes saved authentication records of the given user.
|
||||
func (a *authenticator) DelRecords(tenantID types.TenantID, uid types.Uid) error {
|
||||
return store.Users.DelAuthRecords(tenantID, uid, a.name)
|
||||
return store.Users.DeleteAuthRecord(tenantID, uid)
|
||||
}
|
||||
|
||||
// RestrictedTags returns tag namespaces (prefixes) restricted by this adapter.
|
||||
@@ -319,7 +319,7 @@ func (a *authenticator) RestrictedTags() ([]string, error) {
|
||||
|
||||
// GetResetParams returns authenticator parameters passed to password reset handler.
|
||||
func (a *authenticator) GetResetParams(tenantID types.TenantID, uid types.Uid) (map[string]any, error) {
|
||||
login, _, _, _, err := store.Users.GetAuthRecord(tenantID, uid, a.name)
|
||||
login, _, _, _, err := store.Users.GetAuthRecord(tenantID, uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -26,9 +26,9 @@ func TestAuthenticateScopesLoginByTenant(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users.EXPECT().GetAuthUniqueRecord(types.TenantID(7), "basic", "alice").
|
||||
users.EXPECT().GetAuthByLogin(types.TenantID(7), "alice").
|
||||
Return(types.Uid(70), auth.LevelAuth, hash, time.Time{}, nil)
|
||||
users.EXPECT().GetAuthUniqueRecord(types.TenantID(8), "basic", "alice").
|
||||
users.EXPECT().GetAuthByLogin(types.TenantID(8), "alice").
|
||||
Return(types.Uid(80), auth.LevelAuth, hash, time.Time{}, nil)
|
||||
|
||||
a := &authenticator{name: "basic", minLoginLength: defaultMinLoginLength,
|
||||
@@ -73,7 +73,7 @@ func TestAddRecordAcceptsE164PhoneLogin(t *testing.T) {
|
||||
})
|
||||
|
||||
users.EXPECT().AddAuthRecord(types.TenantID(7), types.Uid(42), auth.LevelAuth,
|
||||
"basic", "+8615521120255", gomock.Any(), time.Time{}).Return(nil)
|
||||
gomock.Any(), time.Time{}).Return(nil)
|
||||
|
||||
a := &authenticator{name: "basic", addToTags: true, minLoginLength: defaultMinLoginLength,
|
||||
minPasswordLength: 6}
|
||||
|
||||
@@ -146,6 +146,12 @@ type MsgClientAcc struct {
|
||||
Scheme string `json:"scheme,omitempty"`
|
||||
// Shared secret
|
||||
Secret []byte `json:"secret,omitempty"`
|
||||
// Registration login name. Ignored when Phone is provided.
|
||||
Username string `json:"username,omitempty"`
|
||||
// Registration mobile number. An 11-digit mainland China number takes precedence over Username.
|
||||
Phone string `json:"phone,omitempty"`
|
||||
// Optional case-sensitive registration invitation code.
|
||||
InviteCode string `json:"invite_code,omitempty"`
|
||||
// Authenticate session with the newly created account
|
||||
Login bool `json:"login,omitempty"`
|
||||
// Indexable tags for user discovery
|
||||
|
||||
+26
-6
@@ -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"
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tinode/chat/server/logs"
|
||||
@@ -10,6 +11,10 @@ import (
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
const imExternalBusinessEventPollInterval = time.Second
|
||||
|
||||
var imBusinessEventDispatchLocks sync.Map
|
||||
|
||||
func stableIMEventID(parts ...any) string {
|
||||
return types.StableEventID(parts...)
|
||||
}
|
||||
@@ -87,6 +92,11 @@ func dispatchIMBusinessEvents(tenantID types.TenantID) {
|
||||
if tenantID.IsZero() || globals.hub == nil || store.HTTPInfrastructure == nil {
|
||||
return
|
||||
}
|
||||
lockValue, _ := imBusinessEventDispatchLocks.LoadOrStore(tenantID, &sync.Mutex{})
|
||||
lock := lockValue.(*sync.Mutex)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
events, err := store.HTTPInfrastructure.ListPendingEvents(tenantID, 100, now)
|
||||
if err != nil {
|
||||
@@ -94,6 +104,39 @@ func dispatchIMBusinessEvents(tenantID types.TenantID) {
|
||||
return
|
||||
}
|
||||
for _, event := range events {
|
||||
if event.EventType == "registration.invite_used" {
|
||||
var payload struct {
|
||||
InviteCodeID int64 `json:"invite_code_id"`
|
||||
}
|
||||
if err = json.Unmarshal(event.Payload, &payload); err != nil || payload.InviteCodeID <= 0 {
|
||||
logs.Warn.Printf("business outbox: malformed invite usage event=%s: %v", event.EventID, err)
|
||||
return
|
||||
}
|
||||
if err = store.RegistrationInvites.SyncUsage(tenantID, payload.InviteCodeID); err != nil {
|
||||
logs.Warn.Printf("business outbox: sync invite usage event=%s: %v", event.EventID, err)
|
||||
return
|
||||
}
|
||||
if err = store.HTTPInfrastructure.MarkEventDelivered(tenantID, event.EventID, now); err != nil {
|
||||
logs.Warn.Printf("business outbox: mark delivered event=%s: %v", event.EventID, err)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if event.EventType == "security.password_reset" {
|
||||
var payload struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
}
|
||||
if err = json.Unmarshal(event.Payload, &payload); err != nil || payload.UserID <= 0 {
|
||||
logs.Warn.Printf("business outbox: malformed password reset event=%s: %v", event.EventID, err)
|
||||
} else if globals.sessionStore != nil {
|
||||
globals.sessionStore.EvictUser(store.EncodeUid(payload.UserID), "")
|
||||
}
|
||||
if err = store.HTTPInfrastructure.MarkEventDelivered(tenantID, event.EventID, now); err != nil {
|
||||
logs.Warn.Printf("business outbox: mark delivered event=%s: %v", event.EventID, err)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
from := ""
|
||||
if !event.ActorUserID.IsZero() {
|
||||
from = event.ActorUserID.UserId()
|
||||
@@ -118,3 +161,34 @@ func dispatchIMBusinessEvents(tenantID types.TenantID) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// startIMExternalBusinessEventPoller bridges direct database writers until the
|
||||
// same event contract is transported by Kafka.
|
||||
func startIMExternalBusinessEventPoller() func() {
|
||||
stop := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(imExternalBusinessEventPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if globals.sessionStore == nil {
|
||||
continue
|
||||
}
|
||||
tenantIDs := make(map[types.TenantID]struct{})
|
||||
globals.sessionStore.Range(func(_ string, session *Session) bool {
|
||||
if session != nil && !session.tenantID.IsZero() {
|
||||
tenantIDs[session.tenantID] = struct{}{}
|
||||
}
|
||||
return true
|
||||
})
|
||||
for tenantID := range tenantIDs {
|
||||
dispatchIMBusinessEvents(tenantID)
|
||||
}
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() { close(stop) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
type passwordResetEventStore struct {
|
||||
testHTTPInfrastructureStore
|
||||
delivered []string
|
||||
}
|
||||
|
||||
func (s *passwordResetEventStore) MarkEventDelivered(_ types.TenantID, eventID string, _ time.Time) error {
|
||||
s.delivered = append(s.delivered, eventID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDispatchPasswordResetEvent(t *testing.T) {
|
||||
oldInfrastructure := store.HTTPInfrastructure
|
||||
oldHub, oldSessions := globals.hub, globals.sessionStore
|
||||
defer func() {
|
||||
store.HTTPInfrastructure = oldInfrastructure
|
||||
globals.hub, globals.sessionStore = oldHub, oldSessions
|
||||
}()
|
||||
|
||||
infrastructure := &passwordResetEventStore{}
|
||||
infrastructure.events = []types.BusinessEvent{{
|
||||
EventID: "password-reset-1", EventType: "security.password_reset",
|
||||
TargetType: "user", Target: "42", Payload: []byte(`{"user_id":42}`),
|
||||
}}
|
||||
store.HTTPInfrastructure = infrastructure
|
||||
globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 1)}
|
||||
globals.sessionStore = nil
|
||||
|
||||
dispatchIMBusinessEvents(types.TenantID(1))
|
||||
|
||||
if len(infrastructure.delivered) != 1 || infrastructure.delivered[0] != "password-reset-1" {
|
||||
t.Fatalf("password reset event was not marked delivered: %v", infrastructure.delivered)
|
||||
}
|
||||
if len(globals.hub.routeSrv) != 0 {
|
||||
t.Fatal("password reset event was routed as a regular client event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchRegistrationInviteUsageEvent(t *testing.T) {
|
||||
oldInfrastructure, oldInvites := store.HTTPInfrastructure, store.RegistrationInvites
|
||||
oldHub := globals.hub
|
||||
defer func() {
|
||||
store.HTTPInfrastructure, store.RegistrationInvites = oldInfrastructure, oldInvites
|
||||
globals.hub = oldHub
|
||||
}()
|
||||
|
||||
infrastructure := &passwordResetEventStore{}
|
||||
infrastructure.events = []types.BusinessEvent{{
|
||||
EventID: "invite-use-1", EventType: "registration.invite_used",
|
||||
TargetType: "user", Target: "42", Payload: []byte(`{"invite_code_id":99}`),
|
||||
}}
|
||||
invites := &testRegistrationInviteStore{}
|
||||
store.HTTPInfrastructure = infrastructure
|
||||
store.RegistrationInvites = invites
|
||||
globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 1)}
|
||||
|
||||
dispatchIMBusinessEvents(types.TenantID(1))
|
||||
|
||||
if invites.syncedCode != 99 {
|
||||
t.Fatalf("invite usage projection code=%d, want 99", invites.syncedCode)
|
||||
}
|
||||
if len(infrastructure.delivered) != 1 || infrastructure.delivered[0] != "invite-use-1" {
|
||||
t.Fatalf("invite usage event was not marked delivered: %v", infrastructure.delivered)
|
||||
}
|
||||
if len(globals.hub.routeSrv) != 0 {
|
||||
t.Fatal("invite usage event was routed as a regular client event")
|
||||
}
|
||||
}
|
||||
+117
-15
@@ -37,6 +37,7 @@ const (
|
||||
imReasonChallengeExpired = "CHALLENGE_EXPIRED"
|
||||
imReasonCodeInvalid = "CODE_INVALID"
|
||||
imReasonTooManyAttempts = "TOO_MANY_ATTEMPTS"
|
||||
imReasonSMSDeliveryFailed = "SMS_DELIVERY_FAILED"
|
||||
imReasonPasswordPolicy = "PASSWORD_POLICY_VIOLATION"
|
||||
imReasonAuthRequired = "AUTH_REQUIRED"
|
||||
imReasonRealNameRequired = "REALNAME_REQUIRED"
|
||||
@@ -51,7 +52,6 @@ const (
|
||||
|
||||
const (
|
||||
imVerificationCodeLength = 6
|
||||
imVerificationTestCode = "123456"
|
||||
imVerificationExpiresInSeconds = 300
|
||||
imVerificationResendAfterSeconds = 60
|
||||
imVerificationMaxAttempts = 5
|
||||
@@ -523,6 +523,7 @@ func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
endpoints := imPublicEndpoints(req)
|
||||
writeIMSuccess(wrt, req, map[string]any{
|
||||
"tenant": map[string]any{
|
||||
"code": tenant.Code,
|
||||
@@ -530,11 +531,7 @@ func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
|
||||
"tenant_desc": tenant.TenantDesc,
|
||||
"branding": map[string]any{},
|
||||
},
|
||||
"endpoints": map[string]any{
|
||||
"websocket": strings.TrimRight(globals.servingAt, "/") + "/v0/channels",
|
||||
"api": strings.TrimRight(globals.servingAt, "/") + "/v1/im",
|
||||
"file": strings.TrimRight(globals.servingAt, "/") + "/v0/file",
|
||||
},
|
||||
"endpoints": endpoints,
|
||||
"auth_methods": []string{"password", "sms"},
|
||||
"password_policy": map[string]any{
|
||||
"min_length": 8,
|
||||
@@ -544,6 +541,75 @@ func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func imPublicEndpoints(req *http.Request) map[string]any {
|
||||
base := imPublicHTTPBase(req)
|
||||
wsBase := base
|
||||
if strings.HasPrefix(wsBase, "https://") {
|
||||
wsBase = "wss://" + strings.TrimPrefix(wsBase, "https://")
|
||||
} else if strings.HasPrefix(wsBase, "http://") {
|
||||
wsBase = "ws://" + strings.TrimPrefix(wsBase, "http://")
|
||||
}
|
||||
return map[string]any{
|
||||
"websocket": strings.TrimRight(wsBase, "/") + "/v0/channels",
|
||||
"api": strings.TrimRight(base, "/") + "/v1/im",
|
||||
"file": strings.TrimRight(base, "/") + "/v0/file",
|
||||
}
|
||||
}
|
||||
|
||||
func imPublicHTTPBase(req *http.Request) string {
|
||||
configured := strings.TrimRight(strings.TrimSpace(globals.servingAt), "/")
|
||||
parsed, _ := url.Parse(configured)
|
||||
basePath := ""
|
||||
if parsed != nil {
|
||||
basePath = strings.TrimRight(parsed.Path, "/")
|
||||
if parsed.Scheme != "" && isPublicEndpointHost(parsed.Host) {
|
||||
return strings.TrimRight(parsed.Scheme+"://"+parsed.Host+basePath, "/")
|
||||
}
|
||||
}
|
||||
if basePath == "" && req != nil && req.URL != nil {
|
||||
if idx := strings.Index(req.URL.Path, "/v1/im/"); idx > 0 {
|
||||
basePath = strings.TrimRight(req.URL.Path[:idx], "/")
|
||||
}
|
||||
}
|
||||
|
||||
scheme := "http"
|
||||
if req != nil && req.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if req != nil {
|
||||
if forwarded := firstForwardedHeader(req.Header.Get("X-Forwarded-Proto")); forwarded == "http" || forwarded == "https" {
|
||||
scheme = forwarded
|
||||
}
|
||||
}
|
||||
|
||||
host := ""
|
||||
if req != nil {
|
||||
host = firstForwardedHeader(req.Header.Get("X-Forwarded-Host"))
|
||||
if host == "" {
|
||||
host = strings.TrimSpace(req.Host)
|
||||
}
|
||||
}
|
||||
if host == "" && parsed != nil {
|
||||
host = parsed.Host
|
||||
}
|
||||
if host == "" {
|
||||
return configured
|
||||
}
|
||||
return strings.TrimRight(scheme+"://"+host+basePath, "/")
|
||||
}
|
||||
|
||||
func isPublicEndpointHost(host string) bool {
|
||||
host = strings.TrimSpace(host)
|
||||
return host != "" && !strings.HasPrefix(host, ":") && !strings.HasPrefix(host, "0.0.0.0:")
|
||||
}
|
||||
|
||||
func firstForwardedHeader(value string) string {
|
||||
if idx := strings.Index(value, ","); idx >= 0 {
|
||||
value = value[:idx]
|
||||
}
|
||||
return strings.TrimSpace(strings.ToLower(value))
|
||||
}
|
||||
|
||||
func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
@@ -598,8 +664,12 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
}
|
||||
// Temporary fixed code for app integration until the SMS provider is connected.
|
||||
code := imVerificationTestCode
|
||||
code, err := randomIMNumericCode(imVerificationCodeLength)
|
||||
if err != nil {
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
}
|
||||
deliveryEnabled := scene != "register" || registrationSMSEnabled(now)
|
||||
vc := &types.VerificationCode{
|
||||
TenantID: tenant.ID,
|
||||
ChallengeID: challengeID,
|
||||
@@ -613,8 +683,8 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
|
||||
ResendAfter: now.Add(imVerificationResendAfterSeconds * time.Second),
|
||||
}
|
||||
if scene == "reset_password" {
|
||||
if uid, err := store.Users.GetByCred(tenant.ID, "tel", phone); err != nil {
|
||||
logs.Warn.Println("im verification create: credential lookup failed", tenant.Code, err)
|
||||
if uid, err := store.Users.GetByPhone(tenant.ID, phone); err != nil {
|
||||
logs.Warn.Println("im verification create: phone lookup failed", tenant.Code, err)
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
} else {
|
||||
@@ -626,8 +696,18 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
}
|
||||
if deliveryEnabled {
|
||||
if err := sendIMVerificationCode(tenant.ID, phone, "", scene, code); err != nil {
|
||||
_ = store.Verifications.Fail(tenant.ID, vc.ChallengeID, types.VerificationStateExpired)
|
||||
logs.Warn.Println("im verification delivery failed:", tenant.Code, scene, vc.PhoneMask, err)
|
||||
writeIMError(wrt, req, http.StatusBadGateway, 30033, imReasonSMSDeliveryFailed, "SMS delivery failed", nil)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
logs.Info.Println("im verification delivery suppressed:", tenant.Code, scene, vc.PhoneMask)
|
||||
}
|
||||
|
||||
logs.Info.Println("im verification code generated:", tenant.Code, scene, vc.PhoneMask)
|
||||
logs.Info.Println("im verification challenge created:", tenant.Code, scene, vc.PhoneMask)
|
||||
writeIMSuccess(wrt, req, map[string]any{
|
||||
"challenge_id": vc.ChallengeID,
|
||||
"phone_mask": vc.PhoneMask,
|
||||
@@ -794,7 +874,7 @@ func serveIMPasswordResetConfirm(wrt http.ResponseWriter, req *http.Request) {
|
||||
writeIMSuccess(wrt, req, map[string]any{"status": "ok"})
|
||||
return
|
||||
}
|
||||
login, authLevel, _, _, err := store.Users.GetAuthRecord(tenant.ID, vc.TargetUser, "basic")
|
||||
login, authLevel, _, _, err := store.Users.GetAuthRecord(tenant.ID, vc.TargetUser)
|
||||
if err != nil || login == "" {
|
||||
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
|
||||
return
|
||||
@@ -830,6 +910,19 @@ func serveIMPasswordResetConfirm(wrt http.ResponseWriter, req *http.Request) {
|
||||
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
|
||||
return
|
||||
}
|
||||
if account, getErr := store.Users.Get(tenant.ID, vc.TargetUser); getErr == nil && account != nil && account.Phone != "" {
|
||||
if _, credErr := store.Users.UpsertCred(tenant.ID, &types.Credential{
|
||||
ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now},
|
||||
User: vc.TargetUser.String(),
|
||||
Method: "tel",
|
||||
Value: account.Phone,
|
||||
Done: true,
|
||||
}); credErr != nil {
|
||||
logs.Warn.Println("im password reset confirm: failed to backfill phone credential", tenant.Code, credErr)
|
||||
}
|
||||
} else if getErr != nil {
|
||||
logs.Warn.Println("im password reset confirm: failed to load account phone", tenant.Code, getErr)
|
||||
}
|
||||
if globals.sessionStore != nil {
|
||||
globals.sessionStore.EvictUser(vc.TargetUser, "")
|
||||
}
|
||||
@@ -4892,8 +4985,11 @@ func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, princi
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
}
|
||||
// Temporary fixed code for app integration until the SMS provider is connected.
|
||||
code := imVerificationTestCode
|
||||
code, err := randomIMNumericCode(imVerificationCodeLength)
|
||||
if err != nil {
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
}
|
||||
vc := &types.VerificationCode{
|
||||
TenantID: principal.TenantID,
|
||||
ChallengeID: challengeID,
|
||||
@@ -4912,7 +5008,13 @@ func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, princi
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
}
|
||||
logs.Info.Println("im security verification code generated:", action, vc.PhoneMask)
|
||||
if err := sendIMVerificationCode(principal.TenantID, phone, "", "sensitive_action", code); err != nil {
|
||||
_ = store.Verifications.Fail(principal.TenantID, vc.ChallengeID, types.VerificationStateExpired)
|
||||
logs.Warn.Println("im security verification delivery failed:", action, vc.PhoneMask, err)
|
||||
writeIMError(wrt, req, http.StatusBadGateway, 30033, imReasonSMSDeliveryFailed, "SMS delivery failed", nil)
|
||||
return
|
||||
}
|
||||
logs.Info.Println("im security verification challenge created:", action, vc.PhoneMask)
|
||||
writeIMSuccess(wrt, req, map[string]any{
|
||||
"challenge_id": vc.ChallengeID,
|
||||
"phone_mask": vc.PhoneMask,
|
||||
|
||||
+162
-1
@@ -1074,11 +1074,48 @@ func TestServeIMTenantResolveSuccessFromHeader(t *testing.T) {
|
||||
t.Fatalf("tenant payload must not expose internal id: %#v", tenant)
|
||||
}
|
||||
endpoints := data["endpoints"].(map[string]any)
|
||||
if endpoints["websocket"] != "https://im.example.com/v0/channels" {
|
||||
if endpoints["websocket"] != "wss://im.example.com/v0/channels" {
|
||||
t.Fatalf("unexpected websocket endpoint: %#v", endpoints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMTenantResolveUsesForwardedPublicHost(t *testing.T) {
|
||||
oldTenants := store.Tenants
|
||||
oldServingAt := globals.servingAt
|
||||
store.Tenants = testTenantStore{tenant: &types.Tenant{
|
||||
ID: 7,
|
||||
Code: "acme",
|
||||
Name: "Acme Corp",
|
||||
State: types.TenantStateActive,
|
||||
}}
|
||||
defer func() {
|
||||
store.Tenants = oldTenants
|
||||
globals.servingAt = oldServingAt
|
||||
}()
|
||||
globals.servingAt = "http://:6060/"
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", nil)
|
||||
req.Host = "internal:6060"
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
req.Header.Set("X-Forwarded-Host", "api.118im.com")
|
||||
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
|
||||
req.Header.Set("X-Tenant-Code", "acme")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMTenantResolve(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
||||
endpoints := data["endpoints"].(map[string]any)
|
||||
if endpoints["websocket"] != "wss://api.118im.com/v0/channels" ||
|
||||
endpoints["api"] != "https://api.118im.com/v1/im" ||
|
||||
endpoints["file"] != "https://api.118im.com/v0/file" {
|
||||
t.Fatalf("unexpected endpoints: %#v", endpoints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMTenantResolveSuccessFromBody(t *testing.T) {
|
||||
oldTenants := store.Tenants
|
||||
store.Tenants = testTenantStore{tenant: &types.Tenant{
|
||||
@@ -1204,6 +1241,7 @@ func TestServeIMTenantResolveHidesMissingTenant(t *testing.T) {
|
||||
func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
|
||||
oldTenants := store.Tenants
|
||||
oldVerifications := store.Verifications
|
||||
oldSender := sendIMVerificationCode
|
||||
store.Tenants = testTenantStore{tenant: &types.Tenant{
|
||||
ID: 7,
|
||||
Code: "acme",
|
||||
@@ -1212,9 +1250,20 @@ func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
|
||||
}}
|
||||
verifications := &testVerificationStore{}
|
||||
store.Verifications = verifications
|
||||
setRegistrationSMSConfigForTest(true)
|
||||
var deliveredCode string
|
||||
sendIMVerificationCode = func(_ types.TenantID, recipient, _, scene, code string) error {
|
||||
if recipient != "+8613800138000" || scene != "register" {
|
||||
t.Fatalf("unexpected delivery: recipient=%q scene=%q", recipient, scene)
|
||||
}
|
||||
deliveredCode = code
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
store.Tenants = oldTenants
|
||||
store.Verifications = oldVerifications
|
||||
sendIMVerificationCode = oldSender
|
||||
resetRegistrationSMSConfigForTest()
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
||||
@@ -1237,6 +1286,11 @@ func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
|
||||
if verifications.created.CodeHash == "" || strings.Contains(rec.Body.String(), verifications.created.CodeHash) {
|
||||
t.Fatalf("code hash missing or leaked: %#v body=%s", verifications.created, rec.Body.String())
|
||||
}
|
||||
if len(deliveredCode) != imVerificationCodeLength ||
|
||||
verifications.created.CodeHash != hashIMSecret(7, "register", verifications.created.ChallengeID,
|
||||
verifications.created.PhoneHash, deliveredCode) {
|
||||
t.Fatalf("stored challenge does not match delivered code %q", deliveredCode)
|
||||
}
|
||||
resp := decodeIMHTTPResponse(t, rec)
|
||||
data := resp.Data.(map[string]any)
|
||||
if data["challenge_id"] == "" || data["expires_in_seconds"].(float64) == 0 {
|
||||
@@ -1244,6 +1298,113 @@ func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMVerificationCodesSkipsDeliveryWhenRegistrationSMSDisabled(t *testing.T) {
|
||||
oldTenants := store.Tenants
|
||||
oldVerifications := store.Verifications
|
||||
oldSender := sendIMVerificationCode
|
||||
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}}
|
||||
verifications := &testVerificationStore{}
|
||||
store.Verifications = verifications
|
||||
setRegistrationSMSConfigForTest(false)
|
||||
sendIMVerificationCode = func(_ types.TenantID, _, _, _, _ string) error {
|
||||
t.Fatal("SMS sender must not be called when registration SMS is disabled")
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
store.Tenants = oldTenants
|
||||
store.Verifications = oldVerifications
|
||||
sendIMVerificationCode = oldSender
|
||||
resetRegistrationSMSConfigForTest()
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
||||
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
|
||||
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
|
||||
req.Header.Set("X-Tenant-Code", "acme")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMVerificationCodes(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK || verifications.created == nil {
|
||||
t.Fatalf("status=%d body=%s created=%#v", rec.Code, rec.Body.String(), verifications.created)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMPasswordResetCodeFindsPhoneWithoutTelCredential(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
oldTenants := store.Tenants
|
||||
oldVerifications := store.Verifications
|
||||
oldUsers := store.Users
|
||||
oldSender := sendIMVerificationCode
|
||||
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}}
|
||||
verifications := &testVerificationStore{}
|
||||
store.Verifications = verifications
|
||||
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
||||
users.EXPECT().GetByPhone(types.TenantID(7), "+8613800138000").Return(types.Uid(42), nil)
|
||||
store.Users = users
|
||||
sendIMVerificationCode = func(_ types.TenantID, recipient, _, scene, code string) error {
|
||||
if recipient != "+8613800138000" || scene != "reset_password" || len(code) != imVerificationCodeLength {
|
||||
t.Fatalf("unexpected reset delivery: recipient=%q scene=%q code=%q", recipient, scene, code)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
defer func() {
|
||||
store.Tenants = oldTenants
|
||||
store.Verifications = oldVerifications
|
||||
store.Users = oldUsers
|
||||
sendIMVerificationCode = oldSender
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
||||
bytes.NewBufferString(`{"scene":"reset_password","phone":"13800138000"}`))
|
||||
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
|
||||
req.Header.Set("X-Tenant-Code", "acme")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMVerificationCodes(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if verifications.created == nil || verifications.created.TargetUser != types.Uid(42) {
|
||||
t.Fatalf("password reset challenge was not bound to phone account: %#v", verifications.created)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMVerificationCodesExpiresChallengeOnDeliveryFailure(t *testing.T) {
|
||||
oldTenants := store.Tenants
|
||||
oldVerifications := store.Verifications
|
||||
oldSender := sendIMVerificationCode
|
||||
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}}
|
||||
verifications := &testVerificationStore{}
|
||||
store.Verifications = verifications
|
||||
setRegistrationSMSConfigForTest(true)
|
||||
sendIMVerificationCode = func(_ types.TenantID, _, _, _, _ string) error { return types.ErrInternal }
|
||||
defer func() {
|
||||
store.Tenants = oldTenants
|
||||
store.Verifications = oldVerifications
|
||||
sendIMVerificationCode = oldSender
|
||||
resetRegistrationSMSConfigForTest()
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
||||
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
|
||||
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
|
||||
req.Header.Set("X-Tenant-Code", "acme")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
serveIMVerificationCodes(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadGateway || !verifications.failed {
|
||||
t.Fatalf("status=%d failed=%v body=%s", rec.Code, verifications.failed, rec.Body.String())
|
||||
}
|
||||
if resp := decodeIMHTTPResponse(t, rec); resp.Reason != imReasonSMSDeliveryFailed {
|
||||
t.Fatalf("reason=%q", resp.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMVerificationCodesRequiresTenantCode(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
||||
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
|
||||
|
||||
+2
-1
@@ -450,7 +450,6 @@ func main() {
|
||||
|
||||
// API key signing secret
|
||||
globals.apiKeySalt = config.APIKeySalt
|
||||
|
||||
err = store.InitAuthLogicalNames(config.Auth["logical_names"])
|
||||
if err != nil {
|
||||
logs.Err.Fatal(err)
|
||||
@@ -674,6 +673,8 @@ func main() {
|
||||
globals.sessionStore = NewSessionStore(idleSessionTimeout + 15*time.Second)
|
||||
// The hub (the main message router)
|
||||
globals.hub = newHub()
|
||||
stopExternalBusinessEvents := startIMExternalBusinessEventPoller()
|
||||
defer stopExternalBusinessEvents()
|
||||
|
||||
// Start accepting cluster traffic.
|
||||
if globals.cluster != nil {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tinode/chat/server/logs"
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
"github.com/tinode/chat/server/validate"
|
||||
)
|
||||
|
||||
const registrationSMSConfigCacheTTL = 30 * time.Second
|
||||
|
||||
var registrationSMSConfigCache struct {
|
||||
sync.Mutex
|
||||
value bool
|
||||
loaded bool
|
||||
loadedAt time.Time
|
||||
loadValue func() (bool, error)
|
||||
}
|
||||
|
||||
func registrationSMSEnabled(now time.Time) bool {
|
||||
registrationSMSConfigCache.Lock()
|
||||
defer registrationSMSConfigCache.Unlock()
|
||||
if registrationSMSConfigCache.loaded && now.Sub(registrationSMSConfigCache.loadedAt) < registrationSMSConfigCacheTTL {
|
||||
return registrationSMSConfigCache.value
|
||||
}
|
||||
loader := registrationSMSConfigCache.loadValue
|
||||
if loader == nil {
|
||||
loader = func() (bool, error) {
|
||||
if store.RegistrationConfig == nil {
|
||||
return true, types.ErrUnsupported
|
||||
}
|
||||
return store.RegistrationConfig.SMSEnabled()
|
||||
}
|
||||
}
|
||||
value, err := loader()
|
||||
if err != nil {
|
||||
if registrationSMSConfigCache.loaded {
|
||||
logs.Warn.Println("registration SMS config read failed; using cached value:", err)
|
||||
return registrationSMSConfigCache.value
|
||||
}
|
||||
logs.Warn.Println("registration SMS config read failed; defaulting to enabled:", err)
|
||||
return true
|
||||
}
|
||||
registrationSMSConfigCache.value = value
|
||||
registrationSMSConfigCache.loaded = true
|
||||
registrationSMSConfigCache.loadedAt = now
|
||||
return value
|
||||
}
|
||||
|
||||
func isValidIMInviteCode(value string) bool {
|
||||
if len(value) != 5 {
|
||||
return false
|
||||
}
|
||||
for i := range value {
|
||||
if (value[i] < 'A' || value[i] > 'Z') && (value[i] < 'a' || value[i] > 'z') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var sendIMVerificationCode = func(tenantID types.TenantID, recipient, lang, scene, code string) error {
|
||||
if store.Store == nil {
|
||||
return types.ErrInternal
|
||||
}
|
||||
validatorImpl := store.Store.GetValidator("tel")
|
||||
sender, ok := validatorImpl.(validate.CodeSender)
|
||||
if !ok || !validatorImpl.IsInitialized() {
|
||||
return types.ErrUnsupported
|
||||
}
|
||||
if err := sender.SendCode(tenantID, recipient, lang, scene, code); err != nil {
|
||||
return fmt.Errorf("send verification code: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
type testRegistrationInviteStore struct {
|
||||
invite *types.RegistrationInviteCode
|
||||
created *types.RegistrationInviteCode
|
||||
page *types.RegistrationInvitePage
|
||||
batch *types.RegistrationInviteBatch
|
||||
err error
|
||||
useRecorded bool
|
||||
syncedCode int64
|
||||
}
|
||||
|
||||
func (s *testRegistrationInviteStore) Create(_ types.TenantID, code *types.RegistrationInviteCode) error {
|
||||
s.created = code
|
||||
return s.err
|
||||
}
|
||||
func (s *testRegistrationInviteStore) CreateBatch(types.TenantID, *types.RegistrationInviteBatch, []types.RegistrationInviteCode) error {
|
||||
return s.err
|
||||
}
|
||||
func (s *testRegistrationInviteStore) GetByCode(types.TenantID, string) (*types.RegistrationInviteCode, error) {
|
||||
return s.invite, s.err
|
||||
}
|
||||
func (s *testRegistrationInviteStore) List(types.TenantID, types.RegistrationInviteListOptions) (*types.RegistrationInvitePage, error) {
|
||||
return s.page, s.err
|
||||
}
|
||||
func (s *testRegistrationInviteStore) GetBatch(types.TenantID, int64) (*types.RegistrationInviteBatch, error) {
|
||||
return s.batch, s.err
|
||||
}
|
||||
func (s *testRegistrationInviteStore) GetBatchByIdempotency(types.TenantID, string) (*types.RegistrationInviteBatch, error) {
|
||||
return s.batch, s.err
|
||||
}
|
||||
func (s *testRegistrationInviteStore) Revoke(types.TenantID, int64, time.Time) error { return s.err }
|
||||
func (s *testRegistrationInviteStore) RevokeBatch(types.TenantID, int64, time.Time) (int, error) {
|
||||
return 0, s.err
|
||||
}
|
||||
func (s *testRegistrationInviteStore) RecordUse(types.TenantID, string, types.Uid, string, time.Time) error {
|
||||
s.useRecorded = true
|
||||
return s.err
|
||||
}
|
||||
|
||||
func (s *testRegistrationInviteStore) SyncUsage(_ types.TenantID, codeID int64) error {
|
||||
s.syncedCode = codeID
|
||||
return s.err
|
||||
}
|
||||
|
||||
func setRegistrationSMSConfigForTest(enabled bool) {
|
||||
registrationSMSConfigCache.Lock()
|
||||
defer registrationSMSConfigCache.Unlock()
|
||||
registrationSMSConfigCache.value = false
|
||||
registrationSMSConfigCache.loaded = false
|
||||
registrationSMSConfigCache.loadedAt = time.Time{}
|
||||
registrationSMSConfigCache.loadValue = func() (bool, error) { return enabled, nil }
|
||||
}
|
||||
|
||||
func resetRegistrationSMSConfigForTest() {
|
||||
registrationSMSConfigCache.Lock()
|
||||
defer registrationSMSConfigCache.Unlock()
|
||||
registrationSMSConfigCache.value = false
|
||||
registrationSMSConfigCache.loaded = false
|
||||
registrationSMSConfigCache.loadedAt = time.Time{}
|
||||
registrationSMSConfigCache.loadValue = nil
|
||||
}
|
||||
+17
-6
@@ -1237,20 +1237,26 @@ func TestDispatchAccNew(t *testing.T) {
|
||||
uid := types.Uid(1)
|
||||
oldSecurity := store.Security
|
||||
oldVerifications := store.Verifications
|
||||
oldInvites := store.RegistrationInvites
|
||||
store.Store = ss
|
||||
store.Users = uu
|
||||
store.Security = &testSecurityStore{version: 1}
|
||||
store.Verifications = nil
|
||||
invites := &testRegistrationInviteStore{invite: &types.RegistrationInviteCode{
|
||||
Code: "AbCdE", Status: types.RegistrationInviteActive, ExpiresAt: time.Now().Add(time.Hour),
|
||||
}}
|
||||
store.RegistrationInvites = invites
|
||||
defer func() {
|
||||
store.Store = nil
|
||||
store.Users = nil
|
||||
store.Security = oldSecurity
|
||||
store.Verifications = oldVerifications
|
||||
store.RegistrationInvites = oldInvites
|
||||
ctrl.Finish()
|
||||
}()
|
||||
|
||||
remoteAddr := "192.168.0.1"
|
||||
secret := "<==auth-secret==>"
|
||||
secret := "alice:password"
|
||||
authRec := &auth.Rec{
|
||||
TenantID: 1,
|
||||
Uid: uid,
|
||||
@@ -1289,11 +1295,13 @@ func TestDispatchAccNew(t *testing.T) {
|
||||
public := "public name"
|
||||
msg := &ClientComMessage{
|
||||
Acc: &MsgClientAcc{
|
||||
Id: "123",
|
||||
User: "newXYZ",
|
||||
Scheme: "basic",
|
||||
Secret: []byte(secret),
|
||||
Desc: &MsgSetDesc{Public: public},
|
||||
Id: "123",
|
||||
User: "newXYZ",
|
||||
Scheme: "basic",
|
||||
Username: "alice",
|
||||
InviteCode: "AbCdE",
|
||||
Secret: []byte(secret),
|
||||
Desc: &MsgSetDesc{Public: public},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1304,6 +1312,9 @@ func TestDispatchAccNew(t *testing.T) {
|
||||
if len(r.messages) != 1 {
|
||||
t.Errorf("responses: expected 1, received %d.", len(r.messages))
|
||||
}
|
||||
if !invites.useRecorded {
|
||||
t.Error("registration invite use was not recorded")
|
||||
}
|
||||
resp := r.messages[0].(*ServerComMessage)
|
||||
if resp == nil {
|
||||
t.Fatal("Response must be ServerComMessage")
|
||||
|
||||
@@ -354,17 +354,17 @@ func (m *MockUsersPersistenceInterface) EXPECT() *MockUsersPersistenceInterfaceM
|
||||
}
|
||||
|
||||
// AddAuthRecord mocks base method.
|
||||
func (m *MockUsersPersistenceInterface) AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error {
|
||||
func (m *MockUsersPersistenceInterface) AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, secret []byte, expires time.Time) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "AddAuthRecord", tenantID, uid, authLvl, scheme, unique, secret, expires)
|
||||
ret := m.ctrl.Call(m, "AddAuthRecord", tenantID, uid, authLvl, secret, expires)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// AddAuthRecord indicates an expected call of AddAuthRecord.
|
||||
func (mr *MockUsersPersistenceInterfaceMockRecorder) AddAuthRecord(tenantID, uid, authLvl, scheme, unique, secret, expires interface{}) *gomock.Call {
|
||||
func (mr *MockUsersPersistenceInterfaceMockRecorder) AddAuthRecord(tenantID, uid, authLvl, secret, expires interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).AddAuthRecord), tenantID, uid, authLvl, scheme, unique, secret, expires)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).AddAuthRecord), tenantID, uid, authLvl, secret, expires)
|
||||
}
|
||||
|
||||
// ConfirmCred mocks base method.
|
||||
@@ -396,18 +396,18 @@ func (mr *MockUsersPersistenceInterfaceMockRecorder) Create(tenantID, user, priv
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).Create), tenantID, user, private)
|
||||
}
|
||||
|
||||
// DelAuthRecords mocks base method.
|
||||
func (m *MockUsersPersistenceInterface) DelAuthRecords(tenantID types.TenantID, uid types.Uid, scheme string) error {
|
||||
// DeleteAuthRecord mocks base method.
|
||||
func (m *MockUsersPersistenceInterface) DeleteAuthRecord(tenantID types.TenantID, uid types.Uid) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "DelAuthRecords", tenantID, uid, scheme)
|
||||
ret := m.ctrl.Call(m, "DeleteAuthRecord", tenantID, uid)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// DelAuthRecords indicates an expected call of DelAuthRecords.
|
||||
func (mr *MockUsersPersistenceInterfaceMockRecorder) DelAuthRecords(tenantID, uid, scheme interface{}) *gomock.Call {
|
||||
// DeleteAuthRecord indicates an expected call of DeleteAuthRecord.
|
||||
func (mr *MockUsersPersistenceInterfaceMockRecorder) DeleteAuthRecord(tenantID, uid interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DelAuthRecords", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).DelAuthRecords), tenantID, uid, scheme)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).DeleteAuthRecord), tenantID, uid)
|
||||
}
|
||||
|
||||
// DelCred mocks base method.
|
||||
@@ -532,6 +532,21 @@ func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAll(tenantID interface{}
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAll", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAll), varargs...)
|
||||
}
|
||||
|
||||
// GetByPhone mocks base method.
|
||||
func (m *MockUsersPersistenceInterface) GetByPhone(tenantID types.TenantID, phone string) (types.Uid, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetByPhone", tenantID, phone)
|
||||
ret0, _ := ret[0].(types.Uid)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetByPhone indicates an expected call of GetByPhone.
|
||||
func (mr *MockUsersPersistenceInterfaceMockRecorder) GetByPhone(tenantID, phone interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByPhone", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetByPhone), tenantID, phone)
|
||||
}
|
||||
|
||||
// GetAllCreds mocks base method.
|
||||
func (m *MockUsersPersistenceInterface) GetAllCreds(tenantID types.TenantID, id types.Uid, method string, validatedOnly bool) ([]types.Credential, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -548,9 +563,9 @@ func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAllCreds(tenantID, id, m
|
||||
}
|
||||
|
||||
// GetAuthRecord mocks base method.
|
||||
func (m *MockUsersPersistenceInterface) GetAuthRecord(tenantID types.TenantID, user types.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) {
|
||||
func (m *MockUsersPersistenceInterface) GetAuthRecord(tenantID types.TenantID, user types.Uid) (string, auth.Level, []byte, time.Time, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAuthRecord", tenantID, user, scheme)
|
||||
ret := m.ctrl.Call(m, "GetAuthRecord", tenantID, user)
|
||||
ret0, _ := ret[0].(string)
|
||||
ret1, _ := ret[1].(auth.Level)
|
||||
ret2, _ := ret[2].([]byte)
|
||||
@@ -560,15 +575,15 @@ func (m *MockUsersPersistenceInterface) GetAuthRecord(tenantID types.TenantID, u
|
||||
}
|
||||
|
||||
// GetAuthRecord indicates an expected call of GetAuthRecord.
|
||||
func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthRecord(tenantID, user, scheme interface{}) *gomock.Call {
|
||||
func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthRecord(tenantID, user interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthRecord), tenantID, user, scheme)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthRecord), tenantID, user)
|
||||
}
|
||||
|
||||
// GetAuthUniqueRecord mocks base method.
|
||||
func (m *MockUsersPersistenceInterface) GetAuthUniqueRecord(tenantID types.TenantID, scheme, unique string) (types.Uid, auth.Level, []byte, time.Time, error) {
|
||||
// GetAuthByLogin mocks base method.
|
||||
func (m *MockUsersPersistenceInterface) GetAuthByLogin(tenantID types.TenantID, login string) (types.Uid, auth.Level, []byte, time.Time, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetAuthUniqueRecord", tenantID, scheme, unique)
|
||||
ret := m.ctrl.Call(m, "GetAuthByLogin", tenantID, login)
|
||||
ret0, _ := ret[0].(types.Uid)
|
||||
ret1, _ := ret[1].(auth.Level)
|
||||
ret2, _ := ret[2].([]byte)
|
||||
@@ -577,10 +592,10 @@ func (m *MockUsersPersistenceInterface) GetAuthUniqueRecord(tenantID types.Tenan
|
||||
return ret0, ret1, ret2, ret3, ret4
|
||||
}
|
||||
|
||||
// GetAuthUniqueRecord indicates an expected call of GetAuthUniqueRecord.
|
||||
func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthUniqueRecord(tenantID, scheme, unique interface{}) *gomock.Call {
|
||||
// GetAuthByLogin indicates an expected call of GetAuthByLogin.
|
||||
func (mr *MockUsersPersistenceInterfaceMockRecorder) GetAuthByLogin(tenantID, login interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthUniqueRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthUniqueRecord), tenantID, scheme, unique)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAuthByLogin", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).GetAuthByLogin), tenantID, login)
|
||||
}
|
||||
|
||||
// GetByCred mocks base method.
|
||||
@@ -723,17 +738,17 @@ func (mr *MockUsersPersistenceInterfaceMockRecorder) Update(tenantID, uid, updat
|
||||
}
|
||||
|
||||
// UpdateAuthRecord mocks base method.
|
||||
func (m *MockUsersPersistenceInterface) UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error {
|
||||
func (m *MockUsersPersistenceInterface) UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, username string, secret []byte, expires time.Time) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateAuthRecord", tenantID, uid, authLvl, scheme, unique, secret, expires)
|
||||
ret := m.ctrl.Call(m, "UpdateAuthRecord", tenantID, uid, authLvl, username, secret, expires)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// UpdateAuthRecord indicates an expected call of UpdateAuthRecord.
|
||||
func (mr *MockUsersPersistenceInterfaceMockRecorder) UpdateAuthRecord(tenantID, uid, authLvl, scheme, unique, secret, expires interface{}) *gomock.Call {
|
||||
func (mr *MockUsersPersistenceInterfaceMockRecorder) UpdateAuthRecord(tenantID, uid, authLvl, username, secret, expires interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).UpdateAuthRecord), tenantID, uid, authLvl, scheme, unique, secret, expires)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAuthRecord", reflect.TypeOf((*MockUsersPersistenceInterface)(nil).UpdateAuthRecord), tenantID, uid, authLvl, username, secret, expires)
|
||||
}
|
||||
|
||||
// UpdateLastSeen mocks base method.
|
||||
|
||||
+172
-28
@@ -137,6 +137,24 @@ type tenantsMapper struct{}
|
||||
// Tenants is the tenant registry access object.
|
||||
var Tenants TenantsPersistenceInterface
|
||||
|
||||
// RegistrationConfigPersistenceInterface exposes global registration policy.
|
||||
type RegistrationConfigPersistenceInterface interface {
|
||||
SMSEnabled() (bool, error)
|
||||
}
|
||||
|
||||
type registrationConfigMapper struct{}
|
||||
|
||||
// RegistrationConfig reads registration policy from the control database.
|
||||
var RegistrationConfig RegistrationConfigPersistenceInterface
|
||||
|
||||
func (registrationConfigMapper) SMSEnabled() (bool, error) {
|
||||
configAdp, ok := adp.(adapter.RegistrationConfigAdapter)
|
||||
if !ok {
|
||||
return true, types.ErrUnsupported
|
||||
}
|
||||
return configAdp.RegistrationSMSEnabled()
|
||||
}
|
||||
|
||||
// GetByCode resolves a tenant by its public enterprise code.
|
||||
func (tenantsMapper) GetByCode(code string) (*types.Tenant, error) {
|
||||
tenantAdp, ok := adp.(adapter.TenantAdapter)
|
||||
@@ -368,13 +386,14 @@ func (s storeObj) DbStats() func() any {
|
||||
// UsersPersistenceInterface is an interface which defines methods for persistent storage of user records.
|
||||
type UsersPersistenceInterface interface {
|
||||
Create(tenantID types.TenantID, user *types.User, private any) (*types.User, error)
|
||||
GetAuthRecord(tenantID types.TenantID, user types.Uid, scheme string) (string, auth.Level, []byte, time.Time, error)
|
||||
GetAuthUniqueRecord(tenantID types.TenantID, scheme, unique string) (types.Uid, auth.Level, []byte, time.Time, error)
|
||||
AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error
|
||||
UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte, expires time.Time) error
|
||||
DelAuthRecords(tenantID types.TenantID, uid types.Uid, scheme string) error
|
||||
GetAuthRecord(tenantID types.TenantID, user types.Uid) (string, auth.Level, []byte, time.Time, error)
|
||||
GetAuthByLogin(tenantID types.TenantID, login string) (types.Uid, auth.Level, []byte, time.Time, error)
|
||||
AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, secret []byte, expires time.Time) error
|
||||
UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, username string, secret []byte, expires time.Time) error
|
||||
DeleteAuthRecord(tenantID types.TenantID, uid types.Uid) error
|
||||
Get(tenantID types.TenantID, uid types.Uid) (*types.User, error)
|
||||
GetAll(tenantID types.TenantID, uid ...types.Uid) ([]types.User, error)
|
||||
GetByPhone(tenantID types.TenantID, phone string) (types.Uid, error)
|
||||
GetByCred(tenantID types.TenantID, method, value string) (types.Uid, error)
|
||||
Delete(tenantID types.TenantID, id types.Uid, hard bool) error
|
||||
UpdateLastSeen(tenantID types.TenantID, uid types.Uid, userAgent string, when time.Time) error
|
||||
@@ -455,61 +474,50 @@ func (usersMapper) Create(tenantID types.TenantID, user *types.User, private any
|
||||
|
||||
// GetAuthRecord takes a user ID and a authentication scheme name, fetches unique scheme-dependent identifier and
|
||||
// authentication secret.
|
||||
func (usersMapper) GetAuthRecord(tenantID types.TenantID, user types.Uid, scheme string) (string, auth.Level, []byte, time.Time, error) {
|
||||
func (usersMapper) GetAuthRecord(tenantID types.TenantID, user types.Uid) (string, auth.Level, []byte, time.Time, error) {
|
||||
tenantAdp, err := tenantAuthAdapter(tenantID)
|
||||
if err != nil {
|
||||
return "", auth.LevelNone, nil, time.Time{}, err
|
||||
}
|
||||
unique, authLvl, secret, expires, err := tenantAdp.TenantAuthGetRecord(tenantID, user, scheme)
|
||||
if err == nil {
|
||||
parts := strings.Split(unique, ":")
|
||||
if len(parts) > 1 {
|
||||
unique = parts[1]
|
||||
} else {
|
||||
err = types.ErrInternal
|
||||
}
|
||||
}
|
||||
|
||||
return unique, authLvl, secret, expires, err
|
||||
return tenantAdp.TenantAuthGetRecord(tenantID, user)
|
||||
}
|
||||
|
||||
// GetAuthUniqueRecord takes a unique identifier and a authentication scheme name, fetches user ID and
|
||||
// authentication secret.
|
||||
func (usersMapper) GetAuthUniqueRecord(tenantID types.TenantID, scheme, unique string) (types.Uid, auth.Level, []byte, time.Time, error) {
|
||||
// GetAuthByLogin fetches a Basic authentication record by username or phone.
|
||||
func (usersMapper) GetAuthByLogin(tenantID types.TenantID, login string) (types.Uid, auth.Level, []byte, time.Time, error) {
|
||||
tenantAdp, err := tenantAuthAdapter(tenantID)
|
||||
if err != nil {
|
||||
return types.ZeroUid, auth.LevelNone, nil, time.Time{}, err
|
||||
}
|
||||
return tenantAdp.TenantAuthGetUniqueRecord(tenantID, scheme+":"+unique)
|
||||
return tenantAdp.TenantAuthGetByLogin(tenantID, login)
|
||||
}
|
||||
|
||||
// AddAuthRecord creates a new authentication record for the given user.
|
||||
func (usersMapper) AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string, secret []byte,
|
||||
func (usersMapper) AddAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, secret []byte,
|
||||
expires time.Time) error {
|
||||
tenantAdp, err := tenantAuthAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tenantAdp.TenantAuthAddRecord(tenantID, uid, scheme, scheme+":"+unique, authLvl, secret, expires)
|
||||
return tenantAdp.TenantAuthAddRecord(tenantID, uid, authLvl, secret, expires)
|
||||
}
|
||||
|
||||
// UpdateAuthRecord updates authentication record with a new secret and expiration time.
|
||||
func (usersMapper) UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, scheme, unique string,
|
||||
func (usersMapper) UpdateAuthRecord(tenantID types.TenantID, uid types.Uid, authLvl auth.Level, username string,
|
||||
secret []byte, expires time.Time) error {
|
||||
tenantAdp, err := tenantAuthAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tenantAdp.TenantAuthUpdRecord(tenantID, uid, scheme, scheme+":"+unique, authLvl, secret, expires)
|
||||
return tenantAdp.TenantAuthUpdateRecord(tenantID, uid, username, authLvl, secret, expires)
|
||||
}
|
||||
|
||||
// DelAuthRecords deletes user's auth records of the given scheme.
|
||||
func (usersMapper) DelAuthRecords(tenantID types.TenantID, uid types.Uid, scheme string) error {
|
||||
// DeleteAuthRecord deletes the user's Basic authentication record.
|
||||
func (usersMapper) DeleteAuthRecord(tenantID types.TenantID, uid types.Uid) error {
|
||||
tenantAdp, err := tenantAuthAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tenantAdp.TenantAuthDelScheme(tenantID, uid, scheme)
|
||||
return tenantAdp.TenantAuthDeleteRecord(tenantID, uid)
|
||||
}
|
||||
|
||||
// Get returns a user object for the given user ID or nil if the user is not found.
|
||||
@@ -552,6 +560,16 @@ func (usersMapper) GetByCred(tenantID types.TenantID, method, value string) (typ
|
||||
return tenantAdp.TenantUserGetByCred(tenantID, method, value)
|
||||
}
|
||||
|
||||
// GetByPhone resolves an account by the phone identity stored on im_users.
|
||||
// It intentionally does not require a completed tel credential.
|
||||
func (usersMapper) GetByPhone(tenantID types.TenantID, phone string) (types.Uid, error) {
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return types.ZeroUid, err
|
||||
}
|
||||
return tenantAdp.TenantUserGetByPhone(tenantID, phone)
|
||||
}
|
||||
|
||||
// Delete deletes user records.
|
||||
func (usersMapper) Delete(tenantID types.TenantID, id types.Uid, hard bool) error {
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
@@ -3062,6 +3080,25 @@ type verificationMapper struct{}
|
||||
// Verifications is a singleton for product verification challenges.
|
||||
var Verifications VerificationPersistenceInterface
|
||||
|
||||
// RegistrationInvitePersistenceInterface manages tenant-scoped registration invitations.
|
||||
type RegistrationInvitePersistenceInterface interface {
|
||||
Create(tenantID types.TenantID, code *types.RegistrationInviteCode) error
|
||||
CreateBatch(tenantID types.TenantID, batch *types.RegistrationInviteBatch, codes []types.RegistrationInviteCode) error
|
||||
GetByCode(tenantID types.TenantID, code string) (*types.RegistrationInviteCode, error)
|
||||
List(tenantID types.TenantID, opts types.RegistrationInviteListOptions) (*types.RegistrationInvitePage, error)
|
||||
GetBatch(tenantID types.TenantID, batchID int64) (*types.RegistrationInviteBatch, error)
|
||||
GetBatchByIdempotency(tenantID types.TenantID, key string) (*types.RegistrationInviteBatch, error)
|
||||
Revoke(tenantID types.TenantID, codeID int64, now time.Time) error
|
||||
RevokeBatch(tenantID types.TenantID, batchID int64, now time.Time) (int, error)
|
||||
RecordUse(tenantID types.TenantID, code string, uid types.Uid, requestID string, now time.Time) error
|
||||
SyncUsage(tenantID types.TenantID, codeID int64) error
|
||||
}
|
||||
|
||||
type registrationInviteMapper struct{}
|
||||
|
||||
// RegistrationInvites is the tenant-scoped registration invitation store.
|
||||
var RegistrationInvites RegistrationInvitePersistenceInterface
|
||||
|
||||
// UserSecurityPersistenceInterface defines user security state used to
|
||||
// invalidate previously issued login tokens.
|
||||
type UserSecurityPersistenceInterface interface {
|
||||
@@ -3267,6 +3304,111 @@ func (verificationMapper) Consume(tenantID types.TenantID, challengeID, tokenHas
|
||||
return tenantAdp.TenantVerificationConsume(tenantID, challengeID, tokenHash, consumedAt)
|
||||
}
|
||||
|
||||
func tenantRegistrationInviteAdapter(tenantID types.TenantID) (adapter.TenantRegistrationInviteAdapter, error) {
|
||||
if !tenantID.IsValid() {
|
||||
return nil, types.ErrMalformed
|
||||
}
|
||||
if router, ok := adp.(adapter.TenantRoutedAdapter); ok {
|
||||
business, _, err := router.TenantAdapterFor(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if inviteAdp, ok := business.(adapter.TenantRegistrationInviteAdapter); ok {
|
||||
return inviteAdp, nil
|
||||
}
|
||||
}
|
||||
if inviteAdp, ok := adp.(adapter.TenantRegistrationInviteAdapter); ok {
|
||||
return inviteAdp, nil
|
||||
}
|
||||
return nil, types.ErrUnsupported
|
||||
}
|
||||
|
||||
func (registrationInviteMapper) Create(tenantID types.TenantID, code *types.RegistrationInviteCode) error {
|
||||
if code == nil {
|
||||
return types.ErrMalformed
|
||||
}
|
||||
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return inviteAdp.TenantRegistrationInviteCreate(tenantID, code)
|
||||
}
|
||||
|
||||
func (registrationInviteMapper) CreateBatch(tenantID types.TenantID, batch *types.RegistrationInviteBatch, codes []types.RegistrationInviteCode) error {
|
||||
if batch == nil || len(codes) == 0 {
|
||||
return types.ErrMalformed
|
||||
}
|
||||
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return inviteAdp.TenantRegistrationInviteCreateBatch(tenantID, batch, codes)
|
||||
}
|
||||
|
||||
func (registrationInviteMapper) GetByCode(tenantID types.TenantID, code string) (*types.RegistrationInviteCode, error) {
|
||||
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inviteAdp.TenantRegistrationInviteGetByCode(tenantID, code)
|
||||
}
|
||||
|
||||
func (registrationInviteMapper) List(tenantID types.TenantID, opts types.RegistrationInviteListOptions) (*types.RegistrationInvitePage, error) {
|
||||
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inviteAdp.TenantRegistrationInviteList(tenantID, opts)
|
||||
}
|
||||
|
||||
func (registrationInviteMapper) GetBatch(tenantID types.TenantID, batchID int64) (*types.RegistrationInviteBatch, error) {
|
||||
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inviteAdp.TenantRegistrationInviteBatchGet(tenantID, batchID)
|
||||
}
|
||||
|
||||
func (registrationInviteMapper) GetBatchByIdempotency(tenantID types.TenantID, key string) (*types.RegistrationInviteBatch, error) {
|
||||
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inviteAdp.TenantRegistrationInviteBatchGetByIdempotency(tenantID, key)
|
||||
}
|
||||
|
||||
func (registrationInviteMapper) Revoke(tenantID types.TenantID, codeID int64, now time.Time) error {
|
||||
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return inviteAdp.TenantRegistrationInviteRevoke(tenantID, codeID, now)
|
||||
}
|
||||
|
||||
func (registrationInviteMapper) RevokeBatch(tenantID types.TenantID, batchID int64, now time.Time) (int, error) {
|
||||
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return inviteAdp.TenantRegistrationInviteBatchRevoke(tenantID, batchID, now)
|
||||
}
|
||||
|
||||
func (registrationInviteMapper) RecordUse(tenantID types.TenantID, code string, uid types.Uid, requestID string, now time.Time) error {
|
||||
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return inviteAdp.TenantRegistrationInviteRecordUse(tenantID, code, uid, requestID, now)
|
||||
}
|
||||
|
||||
func (registrationInviteMapper) SyncUsage(tenantID types.TenantID, codeID int64) error {
|
||||
inviteAdp, err := tenantRegistrationInviteAdapter(tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return inviteAdp.TenantRegistrationInviteSyncUsage(tenantID, codeID)
|
||||
}
|
||||
|
||||
// Get reads a persistent cache entry.
|
||||
func (pcacheMapper) Get(tenantID types.TenantID, key string) (string, error) {
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
@@ -3310,6 +3452,7 @@ func SetTestUidGenerator(g types.UidGenerator) {
|
||||
func init() {
|
||||
Store = storeObj{}
|
||||
Tenants = tenantsMapper{}
|
||||
RegistrationConfig = registrationConfigMapper{}
|
||||
Users = usersMapper{}
|
||||
Topics = topicsMapper{}
|
||||
Subs = subsMapper{}
|
||||
@@ -3330,6 +3473,7 @@ func init() {
|
||||
Files = fileMapper{}
|
||||
PCache = pcacheMapper{}
|
||||
Verifications = verificationMapper{}
|
||||
RegistrationInvites = registrationInviteMapper{}
|
||||
Security = userSecurityMapper{}
|
||||
Relations = relationsMapper{}
|
||||
Favorites = favoritesMapper{}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInviteInvalid = errors.New("registration invite invalid")
|
||||
ErrInviteExpired = errors.New("registration invite expired")
|
||||
ErrInviteRevoked = errors.New("registration invite revoked")
|
||||
ErrInviteIdempotencyConflict = errors.New("registration invite idempotency conflict")
|
||||
)
|
||||
|
||||
type RegistrationInviteStatus string
|
||||
|
||||
const (
|
||||
RegistrationInviteActive RegistrationInviteStatus = "active"
|
||||
RegistrationInviteExpired RegistrationInviteStatus = "expired"
|
||||
RegistrationInviteRevoked RegistrationInviteStatus = "revoked"
|
||||
)
|
||||
|
||||
type RegistrationInviteBatch struct {
|
||||
ID int64 `db:"id" json:"batch_id"`
|
||||
TenantID TenantID `db:"tenant_id" json:"tenant_id"`
|
||||
Quantity int `db:"quantity" json:"quantity"`
|
||||
IdempotencyKey string `db:"idempotency_key" json:"-"`
|
||||
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
|
||||
CreatedBy int64 `db:"created_by" json:"created_by"`
|
||||
Remark string `db:"remark" json:"remark,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ActiveCount int `db:"active_count" json:"active_count"`
|
||||
UsageCount int `db:"usage_count" json:"usage_count"`
|
||||
ExpiredCount int `db:"expired_count" json:"expired_count"`
|
||||
RevokedCount int `db:"revoked_count" json:"revoked_count"`
|
||||
}
|
||||
|
||||
type RegistrationInviteCode struct {
|
||||
ID int64 `db:"id" json:"id"`
|
||||
TenantID TenantID `db:"tenant_id" json:"tenant_id"`
|
||||
BatchID *int64 `db:"batch_id" json:"batch_id,omitempty"`
|
||||
Code string `db:"code" json:"code"`
|
||||
Status RegistrationInviteStatus `db:"status" json:"status"`
|
||||
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
|
||||
UsageCount int `db:"usage_count" json:"usage_count"`
|
||||
LastUsedAt *time.Time `db:"last_used_at" json:"last_used_at,omitempty"`
|
||||
CreatedBy int64 `db:"created_by" json:"created_by"`
|
||||
Remark string `db:"remark" json:"remark,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
type RegistrationInviteListOptions struct {
|
||||
Code string
|
||||
Status RegistrationInviteStatus
|
||||
BatchID int64
|
||||
CreatedBy int64
|
||||
CreatedAtStart *time.Time
|
||||
CreatedAtEnd *time.Time
|
||||
ExpiresAtStart *time.Time
|
||||
ExpiresAtEnd *time.Time
|
||||
Offset int
|
||||
Limit int
|
||||
}
|
||||
|
||||
type RegistrationInvitePage struct {
|
||||
Items []RegistrationInviteCode
|
||||
Total int
|
||||
}
|
||||
@@ -521,6 +521,8 @@ type User struct {
|
||||
UserAgent string
|
||||
// Canonical mobile number used for registration and account recovery.
|
||||
Phone string
|
||||
// Primary login account. Ordinary accounts are lowercase ASCII names; phone accounts use E.164.
|
||||
Username string
|
||||
|
||||
Public any
|
||||
Trusted any
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@
|
||||
"logical_names": [],
|
||||
"basic": {
|
||||
"add_to_tags": false,
|
||||
"min_login_length": 4,
|
||||
"min_login_length": 1,
|
||||
"min_password_length": 6
|
||||
},
|
||||
"token": {
|
||||
|
||||
+154
-6
@@ -34,13 +34,27 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
||||
|
||||
// Find authenticator for the requested scheme.
|
||||
authhdl := store.Store.GetLogicalAuthHandler(msg.Acc.Scheme)
|
||||
if authhdl == nil {
|
||||
if authhdl == nil || msg.Acc.Scheme != "basic" {
|
||||
// New accounts must have an authentication scheme
|
||||
s.queueOut(ErrMalformed(msg.Id, "", msg.Timestamp))
|
||||
logs.Warn.Println("create user: unknown auth handler, sid=", s.sid)
|
||||
return
|
||||
}
|
||||
|
||||
account, phone, phoneRegistration, err := resolveRegistrationAccount(msg.Acc.Username, msg.Acc.Phone)
|
||||
if err != nil || !registrationSecretMatches(msg.Acc.Secret, account) {
|
||||
logs.Warn.Println("create user: invalid registration account fields, sid=", s.sid)
|
||||
s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp,
|
||||
map[string]any{"what": "account", "reason": "REGISTRATION_ACCOUNT_INVALID"}))
|
||||
return
|
||||
}
|
||||
inviteCode := strings.TrimSpace(msg.Acc.InviteCode)
|
||||
if msg.Acc.InviteCode != inviteCode || (inviteCode != "" && !isValidIMInviteCode(inviteCode)) {
|
||||
s.queueOut(decodeStoreError(types.ErrPolicy, msg.Id, msg.Timestamp,
|
||||
map[string]any{"what": "invite_code", "reason": "INVITE_CODE_INVALID"}))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if login is unique and compliance with the policy (not too long or too short).
|
||||
authCtx := auth.AuthContext{TenantID: s.tenantID, RemoteAddr: s.remoteAddr}
|
||||
if ok, err := authhdl.IsUnique(authCtx, msg.Acc.Secret); !ok {
|
||||
@@ -52,6 +66,8 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
||||
|
||||
var user types.User
|
||||
user.TenantID = s.tenantID
|
||||
user.Username = account
|
||||
user.Phone = phone
|
||||
var private any
|
||||
|
||||
// If account state is being assigned, make sure the sender is a root user.
|
||||
@@ -85,9 +101,15 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
||||
// consequently cannot check presence of required credentials. Must do that later.
|
||||
creds := normalizeCredentials(msg.Acc.Cred, true)
|
||||
var registrationPhone string
|
||||
var registrationTelCredential bool
|
||||
for i := range creds {
|
||||
cr := &creds[i]
|
||||
vld := store.Store.GetValidator(cr.Method)
|
||||
if vld == nil || !vld.IsInitialized() {
|
||||
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
|
||||
map[string]any{"what": cr.Method}))
|
||||
return
|
||||
}
|
||||
normalized, err := vld.PreCheck(cr.Value, cr.Params)
|
||||
if err != nil {
|
||||
logs.Warn.Println("create user: failed credential pre-check", cr, err, "sid=", s.sid)
|
||||
@@ -105,15 +127,40 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
||||
return
|
||||
}
|
||||
registrationPhone = phone
|
||||
registrationTelCredential = true
|
||||
}
|
||||
}
|
||||
if registrationPhone == "" && store.Verifications != nil {
|
||||
logs.Warn.Println("create user: verified phone is required, sid=", s.sid)
|
||||
if !phoneRegistration && registrationTelCredential {
|
||||
logs.Warn.Println("create user: username registration cannot attach a phone credential, sid=", s.sid)
|
||||
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
|
||||
map[string]any{"creds": []string{"tel"}}))
|
||||
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_NOT_SELECTED"}))
|
||||
return
|
||||
}
|
||||
user.Phone = registrationPhone
|
||||
if phoneRegistration && registrationTelCredential && registrationPhone != phone {
|
||||
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
|
||||
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_MISMATCH"}))
|
||||
return
|
||||
}
|
||||
smsEnabled := registrationSMSEnabled(time.Now().UTC())
|
||||
if phoneRegistration && smsEnabled && !registrationTelCredential {
|
||||
logs.Warn.Println("create user: verified phone is required while registration SMS is enabled, sid=", s.sid)
|
||||
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
|
||||
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_VERIFICATION_REQUIRED"}))
|
||||
return
|
||||
}
|
||||
inviteRequired := !phoneRegistration || (!smsEnabled && !registrationTelCredential)
|
||||
if inviteRequired && inviteCode == "" {
|
||||
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
|
||||
map[string]any{"what": "invite_code", "reason": "INVITE_CODE_REQUIRED"}))
|
||||
return
|
||||
}
|
||||
if inviteCode != "" {
|
||||
if err := validateRegistrationInvite(s.tenantID, inviteCode, time.Now().UTC()); err != nil {
|
||||
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
|
||||
map[string]any{"what": "invite_code", "reason": registrationInviteReason(err)}))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Assign default access values in case the acc creator has not provided them
|
||||
user.Access.Auth = getDefaultAccess(types.TopicCatP2P, true, false) |
|
||||
@@ -155,7 +202,7 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
||||
}
|
||||
|
||||
// Add authentication record.
|
||||
rec, err := authhdl.AddRecord(authCtx,
|
||||
rec, err = authhdl.AddRecord(authCtx,
|
||||
&auth.Rec{TenantID: s.tenantID, Uid: user.Uid()}, msg.Acc.Secret)
|
||||
if err != nil {
|
||||
logs.Warn.Println("create user: add auth record failed", err, "sid=", s.sid)
|
||||
@@ -201,6 +248,34 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
||||
}
|
||||
return
|
||||
}
|
||||
if registrationTelCredential && !slices.Contains(validated, "tel") {
|
||||
logs.Warn.Println("create user: phone credential was not validated, sid=", s.sid)
|
||||
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
|
||||
map[string]any{"creds": []string{"tel"}, "reason": "PHONE_VERIFICATION_REQUIRED"}))
|
||||
if err = store.Users.Delete(s.tenantID, user.Uid(), true); err != nil {
|
||||
logs.Warn.Println("create user: failed to delete incomplete user record", err, "sid=", s.sid)
|
||||
}
|
||||
return
|
||||
}
|
||||
if inviteCode != "" {
|
||||
requestID := strings.TrimSpace(msg.Id)
|
||||
if requestID == "" {
|
||||
requestID = "acc:" + s.sid
|
||||
}
|
||||
if len(requestID) > 64 {
|
||||
requestID = requestID[:64]
|
||||
}
|
||||
if err = store.RegistrationInvites.RecordUse(s.tenantID, inviteCode, user.Uid(), requestID, time.Now().UTC()); err != nil {
|
||||
logs.Warn.Println("create user: record invite use failed", err, "sid=", s.sid)
|
||||
s.queueOut(decodeStoreError(types.ErrCredentials, msg.Id, msg.Timestamp,
|
||||
map[string]any{"what": "invite_code", "reason": registrationInviteReason(err)}))
|
||||
if deleteErr := store.Users.Delete(s.tenantID, user.Uid(), true); deleteErr != nil {
|
||||
logs.Warn.Println("create user: failed to delete incomplete user record", deleteErr, "sid=", s.sid)
|
||||
}
|
||||
return
|
||||
}
|
||||
dispatchIMBusinessEvents(s.tenantID)
|
||||
}
|
||||
|
||||
if msg.Extra != nil && len(msg.Extra.Attachments) > 0 {
|
||||
if err := store.Files.LinkAttachments(s.tenantID, user.Uid().UserId(), types.ZeroUid, msg.Extra.Attachments); err != nil {
|
||||
@@ -240,6 +315,79 @@ func replyCreateUser(s *Session, msg *ClientComMessage, rec *auth.Rec) {
|
||||
pluginAccount(&user, plgActCreate)
|
||||
}
|
||||
|
||||
func resolveRegistrationAccount(username, phone string) (account, canonicalPhone string, phoneRegistration bool, err error) {
|
||||
if phone != "" {
|
||||
if len(phone) != 11 || !asciiRegistrationDigits(phone) {
|
||||
return "", "", false, types.ErrPolicy
|
||||
}
|
||||
canonicalPhone, err = normalizeIMPhone(phone, "CN")
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
return canonicalPhone, canonicalPhone, true, nil
|
||||
}
|
||||
if username == "" || len(username) > 10 {
|
||||
return "", "", false, types.ErrPolicy
|
||||
}
|
||||
for i := range username {
|
||||
if (username[i] < 'a' || username[i] > 'z') && (username[i] < '0' || username[i] > '9') {
|
||||
return "", "", false, types.ErrPolicy
|
||||
}
|
||||
}
|
||||
return username, "", false, nil
|
||||
}
|
||||
|
||||
func asciiRegistrationDigits(value string) bool {
|
||||
for i := range value {
|
||||
if value[i] < '0' || value[i] > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return value != ""
|
||||
}
|
||||
|
||||
func registrationSecretMatches(secret []byte, account string) bool {
|
||||
separator := strings.IndexByte(string(secret), ':')
|
||||
return separator > 0 && string(secret[:separator]) == account
|
||||
}
|
||||
|
||||
func validateRegistrationInvite(tenantID types.TenantID, code string, now time.Time) error {
|
||||
if store.RegistrationInvites == nil {
|
||||
return types.ErrInternal
|
||||
}
|
||||
invite, err := store.RegistrationInvites.GetByCode(tenantID, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if invite == nil {
|
||||
return types.ErrInviteInvalid
|
||||
}
|
||||
switch invite.Status {
|
||||
case types.RegistrationInviteRevoked:
|
||||
return types.ErrInviteRevoked
|
||||
case types.RegistrationInviteExpired:
|
||||
return types.ErrInviteExpired
|
||||
case types.RegistrationInviteActive:
|
||||
if !now.Before(invite.ExpiresAt) {
|
||||
return types.ErrInviteExpired
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return types.ErrInviteInvalid
|
||||
}
|
||||
}
|
||||
|
||||
func registrationInviteReason(err error) string {
|
||||
switch err {
|
||||
case types.ErrInviteExpired:
|
||||
return "INVITE_CODE_EXPIRED"
|
||||
case types.ErrInviteRevoked:
|
||||
return "INVITE_CODE_REVOKED"
|
||||
default:
|
||||
return "INVITE_CODE_INVALID"
|
||||
}
|
||||
}
|
||||
|
||||
// Process update to an account:
|
||||
// * Authentication update, i.e. login/password change
|
||||
// * Credentials update
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tinode/chat/server/store"
|
||||
"github.com/tinode/chat/server/store/types"
|
||||
)
|
||||
|
||||
func TestResolveRegistrationAccount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
phone string
|
||||
wantAccount string
|
||||
wantPhone string
|
||||
wantPhoneFlow bool
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "username", username: "alice1", wantAccount: "alice1"},
|
||||
{name: "numeric username", username: "12345", wantAccount: "12345"},
|
||||
{name: "phone wins", username: "INVALID", phone: "13800138000", wantAccount: "+8613800138000", wantPhone: "+8613800138000", wantPhoneFlow: true},
|
||||
{name: "uppercase rejected", username: "Alice", wantErr: true},
|
||||
{name: "long username rejected", username: "abcdefghijk", wantErr: true},
|
||||
{name: "malformed phone does not fall back", username: "alice", phone: "1380013800", wantErr: true},
|
||||
{name: "phone whitespace rejected", username: "alice", phone: " 13800138000", wantErr: true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
account, phone, phoneFlow, err := resolveRegistrationAccount(tc.username, tc.phone)
|
||||
if (err != nil) != tc.wantErr || account != tc.wantAccount || phone != tc.wantPhone || phoneFlow != tc.wantPhoneFlow {
|
||||
t.Fatalf("got account=%q phone=%q phoneFlow=%v err=%v", account, phone, phoneFlow, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationSecretMatchesSelectedAccount(t *testing.T) {
|
||||
if !registrationSecretMatches([]byte("alice:password:with-colon"), "alice") {
|
||||
t.Fatal("matching account was rejected")
|
||||
}
|
||||
if registrationSecretMatches([]byte("bob:password"), "alice") || registrationSecretMatches([]byte("password"), "alice") {
|
||||
t.Fatal("mismatched or malformed secret was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRegistrationInviteStates(t *testing.T) {
|
||||
oldInvites := store.RegistrationInvites
|
||||
defer func() { store.RegistrationInvites = oldInvites }()
|
||||
now := time.Now().UTC()
|
||||
tests := []struct {
|
||||
status types.RegistrationInviteStatus
|
||||
expires time.Time
|
||||
want error
|
||||
}{
|
||||
{types.RegistrationInviteActive, now.Add(time.Minute), nil},
|
||||
{types.RegistrationInviteActive, now, types.ErrInviteExpired},
|
||||
{types.RegistrationInviteRevoked, now.Add(time.Minute), types.ErrInviteRevoked},
|
||||
{types.RegistrationInviteExpired, now.Add(time.Minute), types.ErrInviteExpired},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
store.RegistrationInvites = &testRegistrationInviteStore{invite: &types.RegistrationInviteCode{Status: tc.status, ExpiresAt: tc.expires}}
|
||||
if got := validateRegistrationInvite(7, "AbCdE", now); got != tc.want {
|
||||
t.Fatalf("status=%q: got %v want %v", tc.status, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,8 +173,17 @@ func TestServeIMPasswordResetConfirm(t *testing.T) {
|
||||
ExpiresAt: time.Now().UTC().Add(time.Minute),
|
||||
}}
|
||||
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
||||
users.EXPECT().GetAuthRecord(types.TenantID(7), types.Uid(42), "basic").
|
||||
users.EXPECT().GetAuthRecord(types.TenantID(7), types.Uid(42)).
|
||||
Return("alice", auth.LevelAuth, []byte("old"), time.Time{}, nil)
|
||||
users.EXPECT().Get(types.TenantID(7), types.Uid(42)).
|
||||
Return(&types.User{Phone: "+8613800138000"}, nil)
|
||||
users.EXPECT().UpsertCred(types.TenantID(7), gomock.AssignableToTypeOf(&types.Credential{})).
|
||||
DoAndReturn(func(_ types.TenantID, cred *types.Credential) (bool, error) {
|
||||
if cred.Method != "tel" || cred.Value != "+8613800138000" || !cred.Done {
|
||||
t.Fatalf("unexpected reset credential: %#v", cred)
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
store.Users = users
|
||||
basicAuth := mock_auth.NewMockAuthHandler(ctrl)
|
||||
basicAuth.EXPECT().IsInitialized().Return(true)
|
||||
|
||||
@@ -214,6 +214,31 @@ func (v *validator) ResetSecret(tenantID t.TenantID, phone, scheme, lang string,
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendCode synchronously delivers a product verification code. Unlike the
|
||||
// legacy credential flow, it fails when no real SMS provider is configured.
|
||||
func (v *validator) SendCode(_ t.TenantID, phone, lang, scene, code string) error {
|
||||
if v.Twilio == nil {
|
||||
return t.ErrUnsupported
|
||||
}
|
||||
var template *textt.Template
|
||||
if v.langMatcher != nil {
|
||||
_, idx := i18n.MatchStrings(v.langMatcher, lang)
|
||||
template = v.universalTempl[idx]
|
||||
} else if len(v.universalTempl) > 0 {
|
||||
template = v.universalTempl[0]
|
||||
}
|
||||
if template == nil {
|
||||
return t.ErrInternal
|
||||
}
|
||||
content, err := validate.ExecuteTemplate(template, nil, map[string]any{
|
||||
"Code": code, "HostUrl": v.HostUrl, "Scene": scene,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return twilioSend(v.Sender, phone, content[""])
|
||||
}
|
||||
|
||||
// Check checks validity of user's response.
|
||||
func (v *validator) Check(tenantID t.TenantID, user t.Uid, resp string) (string, error) {
|
||||
cred, err := store.Users.GetActiveCred(tenantID, user, validatorName)
|
||||
|
||||
@@ -58,6 +58,12 @@ type Validator interface {
|
||||
TempAuthScheme() (string, error)
|
||||
}
|
||||
|
||||
// CodeSender is an optional synchronous delivery capability used by product
|
||||
// verification challenges which exist before a user account is created.
|
||||
type CodeSender interface {
|
||||
SendCode(tenantID t.TenantID, recipient, lang, scene, code string) error
|
||||
}
|
||||
|
||||
func ValidateHostURL(origUrl string) (string, error) {
|
||||
hostUrl, err := url.Parse(origUrl)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user