feat: add im auth verification flows

This commit is contained in:
richjohnrambo
2026-08-12 00:20:27 +08:00
parent 1ffcb85bee
commit d239e37f56
16 changed files with 2030 additions and 22 deletions
+2
View File
@@ -247,6 +247,8 @@ type Rec struct {
State types.ObjState State types.ObjState
// Credential 'method:value' associated with this record. // Credential 'method:value' associated with this record.
Credential string `json:"cred,omitempty"` Credential string `json:"cred,omitempty"`
// AuthVersion invalidates previously issued login tokens when incremented.
AuthVersion int `json:"auth_version,omitempty"`
// Authenticator may request the server to create a new account. // Authenticator may request the server to create a new account.
// These are the account parameters which can be used for creating the account. // These are the account parameters which can be used for creating the account.
+28 -9
View File
@@ -23,10 +23,10 @@ type authenticator struct {
serialNumber int serialNumber int
} }
const tokenVersion = uint8(2) const tokenVersion = uint8(3)
// tokenLayout defines positioning of various bytes in token v2. // tokenLayout defines positioning of various bytes in token v3.
// [1:version][8:tenant][8:UID][4:expires][2:authLevel][2:serial][2:features][32:signature] = 59 bytes. // [1:version][8:tenant][8:UID][4:expires][2:authLevel][2:serial][2:features][4:authVersion][32:signature] = 63 bytes.
type tokenLayout struct { type tokenLayout struct {
// Token format version. // Token format version.
Version uint8 Version uint8
@@ -42,6 +42,8 @@ type tokenLayout struct {
SerialNumber uint16 SerialNumber uint16
// Bitmap with feature bits. // Bitmap with feature bits.
Features uint16 Features uint16
// User-scoped auth version.
AuthVersion uint32
} }
// Init initializes the authenticator: parses the config and sets salt, serial number and lifetime. // Init initializes the authenticator: parses the config and sets salt, serial number and lifetime.
@@ -143,13 +145,29 @@ func (ta *authenticator) Authenticate(ctx auth.AuthContext, token []byte) (*auth
return nil, nil, types.ErrExpired return nil, nil, types.ErrExpired
} }
features := auth.Feature(tl.Features)
if features&auth.FeatureNoLogin == 0 {
if store.Security == nil {
return nil, nil, types.ErrInternal
}
currentVersion, err := store.Security.GetAuthVersion(types.TenantID(tl.TenantID), types.Uid(tl.Uid))
if err != nil {
return nil, nil, err
}
if int(tl.AuthVersion) != currentVersion {
return nil, nil, types.ErrFailed
}
}
return &auth.Rec{ return &auth.Rec{
TenantID: types.TenantID(tl.TenantID), TenantID: types.TenantID(tl.TenantID),
Uid: types.Uid(tl.Uid), Uid: types.Uid(tl.Uid),
AuthLevel: auth.Level(tl.AuthLevel), AuthLevel: auth.Level(tl.AuthLevel),
Lifetime: auth.Duration(time.Until(expires)), Lifetime: auth.Duration(time.Until(expires)),
Features: auth.Feature(tl.Features), Features: features,
State: types.StateUndefined}, nil, nil State: types.StateUndefined,
AuthVersion: int(tl.AuthVersion),
}, nil, nil
} }
// GenSecret generates a new token. // GenSecret generates a new token.
@@ -173,6 +191,7 @@ func (ta *authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {
AuthLevel: uint16(rec.AuthLevel), AuthLevel: uint16(rec.AuthLevel),
SerialNumber: uint16(ta.serialNumber), SerialNumber: uint16(ta.serialNumber),
Features: uint16(rec.Features), Features: uint16(rec.Features),
AuthVersion: uint32(rec.AuthVersion),
} }
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
binary.Write(buf, binary.LittleEndian, &tl) binary.Write(buf, binary.LittleEndian, &tl)
+70 -11
View File
@@ -6,9 +6,23 @@ import (
"time" "time"
"github.com/tinode/chat/server/auth" "github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types" "github.com/tinode/chat/server/store/types"
) )
type testSecurityStore struct {
version int
err error
}
func (s testSecurityStore) GetAuthVersion(types.TenantID, types.Uid) (int, error) {
return s.version, s.err
}
func (s testSecurityStore) BumpAuthVersion(types.TenantID, types.Uid, time.Time) (int, error) {
return s.version + 1, s.err
}
func newTestAuthenticator(t *testing.T) *authenticator { func newTestAuthenticator(t *testing.T) *authenticator {
t.Helper() t.Helper()
conf, err := json.Marshal(map[string]any{ conf, err := json.Marshal(map[string]any{
@@ -26,20 +40,25 @@ func newTestAuthenticator(t *testing.T) *authenticator {
return ta return ta
} }
func TestTokenV2TenantRoundTrip(t *testing.T) { func TestTokenV3TenantRoundTrip(t *testing.T) {
oldSecurity := store.Security
store.Security = testSecurityStore{version: 11}
defer func() { store.Security = oldSecurity }()
ta := newTestAuthenticator(t) ta := newTestAuthenticator(t)
rec := &auth.Rec{ rec := &auth.Rec{
TenantID: 7, TenantID: 7,
Uid: 42, Uid: 42,
AuthLevel: auth.LevelAuth, AuthLevel: auth.LevelAuth,
Lifetime: auth.Duration(time.Hour), Lifetime: auth.Duration(time.Hour),
Features: auth.FeatureValidated, Features: auth.FeatureValidated,
AuthVersion: 11,
} }
token, _, err := ta.GenSecret(rec) token, _, err := ta.GenSecret(rec)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got, want := len(token), 59; got != want { if got, want := len(token), 63; got != want {
t.Fatalf("token length: got %d, want %d", got, want) t.Fatalf("token length: got %d, want %d", got, want)
} }
@@ -50,7 +69,8 @@ func TestTokenV2TenantRoundTrip(t *testing.T) {
if challenge != nil { if challenge != nil {
t.Fatal("unexpected challenge") t.Fatal("unexpected challenge")
} }
if got.TenantID != rec.TenantID || got.Uid != rec.Uid || got.AuthLevel != rec.AuthLevel || got.Features != rec.Features { if got.TenantID != rec.TenantID || got.Uid != rec.Uid || got.AuthLevel != rec.AuthLevel ||
got.Features != rec.Features || got.AuthVersion != rec.AuthVersion {
t.Fatalf("round trip mismatch: got %+v, want %+v", got, rec) t.Fatalf("round trip mismatch: got %+v, want %+v", got, rec)
} }
@@ -61,10 +81,14 @@ func TestTokenV2TenantRoundTrip(t *testing.T) {
} }
} }
func TestTokenV2RejectsWrongOrTamperedTenant(t *testing.T) { func TestTokenV3RejectsWrongOrTamperedTenant(t *testing.T) {
oldSecurity := store.Security
store.Security = testSecurityStore{version: 1}
defer func() { store.Security = oldSecurity }()
ta := newTestAuthenticator(t) ta := newTestAuthenticator(t)
token, _, err := ta.GenSecret(&auth.Rec{ token, _, err := ta.GenSecret(&auth.Rec{
TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth, Lifetime: auth.Duration(time.Hour), TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth, Lifetime: auth.Duration(time.Hour), AuthVersion: 1,
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -81,7 +105,11 @@ func TestTokenV2RejectsWrongOrTamperedTenant(t *testing.T) {
} }
} }
func TestTokenV2RejectsLegacyAndMissingIdentity(t *testing.T) { func TestTokenV3RejectsLegacyMissingIdentityAndStaleVersion(t *testing.T) {
oldSecurity := store.Security
store.Security = testSecurityStore{version: 2}
defer func() { store.Security = oldSecurity }()
ta := newTestAuthenticator(t) ta := newTestAuthenticator(t)
if _, _, err := ta.Authenticate(auth.AuthContext{}, make([]byte, 50)); err != types.ErrMalformed { if _, _, err := ta.Authenticate(auth.AuthContext{}, make([]byte, 50)); err != types.ErrMalformed {
t.Fatalf("legacy token: got %v, want %v", err, types.ErrMalformed) t.Fatalf("legacy token: got %v, want %v", err, types.ErrMalformed)
@@ -92,4 +120,35 @@ func TestTokenV2RejectsLegacyAndMissingIdentity(t *testing.T) {
if _, _, err := ta.GenSecret(&auth.Rec{TenantID: 7, AuthLevel: auth.LevelAuth}); err != types.ErrMalformed { if _, _, err := ta.GenSecret(&auth.Rec{TenantID: 7, AuthLevel: auth.LevelAuth}); err != types.ErrMalformed {
t.Fatalf("missing uid: got %v, want %v", err, types.ErrMalformed) t.Fatalf("missing uid: got %v, want %v", err, types.ErrMalformed)
} }
token, _, err := ta.GenSecret(&auth.Rec{
TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth, Lifetime: auth.Duration(time.Hour), AuthVersion: 1,
})
if err != nil {
t.Fatal(err)
}
if _, _, err = ta.Authenticate(auth.AuthContext{TenantID: 7}, token); err != types.ErrFailed {
t.Fatalf("stale auth version: got %v, want %v", err, types.ErrFailed)
}
}
func TestTokenV3NoLoginDoesNotRequireAuthVersion(t *testing.T) {
oldSecurity := store.Security
store.Security = testSecurityStore{version: 99}
defer func() { store.Security = oldSecurity }()
ta := newTestAuthenticator(t)
token, _, err := ta.GenSecret(&auth.Rec{
TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth, Lifetime: auth.Duration(time.Hour), Features: auth.FeatureNoLogin,
})
if err != nil {
t.Fatal(err)
}
got, _, err := ta.Authenticate(auth.AuthContext{TenantID: 7}, token)
if err != nil {
t.Fatal(err)
}
if got.AuthVersion != 0 || got.Features&auth.FeatureNoLogin == 0 {
t.Fatalf("unexpected no-login token auth: %+v", got)
}
} }
+10
View File
@@ -292,4 +292,14 @@ type TenantBusinessAdapter interface {
TenantPCacheUpsert(t.TenantID, string, string, bool) error TenantPCacheUpsert(t.TenantID, string, string, bool) error
TenantPCacheDelete(t.TenantID, string) error TenantPCacheDelete(t.TenantID, string) error
TenantPCacheExpire(t.TenantID, string, time.Time) error TenantPCacheExpire(t.TenantID, string, time.Time) error
TenantVerificationCreate(t.TenantID, *t.VerificationCode) error
TenantVerificationGet(t.TenantID, string) (*t.VerificationCode, error)
TenantVerificationGetActive(t.TenantID, string, string, time.Time) (*t.VerificationCode, error)
TenantVerificationFail(t.TenantID, string, t.VerificationState) error
TenantVerificationMarkVerified(t.TenantID, string, string, time.Time) error
TenantVerificationConsume(t.TenantID, string, string, time.Time) error
TenantUserSecurityGetAuthVersion(t.TenantID, t.Uid) (int, error)
TenantUserSecurityBumpAuthVersion(t.TenantID, t.Uid, time.Time) (int, error)
} }
+102 -1
View File
@@ -41,7 +41,7 @@ type adapter struct {
} }
const ( const (
adpVersion = 117 adpVersion = 118
adapterName = "mysql" adapterName = "mysql"
defaultDSN = "root:@tcp(localhost:3306)/tinode?parseTime=true" defaultDSN = "root:@tcp(localhost:3306)/tinode?parseTime=true"
@@ -375,6 +375,34 @@ func (a *adapter) CreateDb(reset bool) error {
return err return err
} }
if _, err = tx.Exec(
`CREATE TABLE im_verification_codes(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
challenge_id VARCHAR(64) NOT NULL,
scene VARCHAR(32) NOT NULL,
phone_hash CHAR(64) NOT NULL,
phone_mask VARCHAR(32) NOT NULL,
target_user BIGINT,
code_hash CHAR(64) NOT NULL,
verification_token_hash CHAR(64),
state SMALLINT NOT NULL,
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL,
expires_at DATETIME(3) NOT NULL,
resend_after DATETIME(3) NOT NULL,
verified_at DATETIME(3),
consumed_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_verification_tenant_challenge(tenant_id, challenge_id),
INDEX idx_im_verification_tenant_scene_phone_state(tenant_id, scene, phone_hash, state, expires_at),
CONSTRAINT fk_im_verification_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
)`); err != nil {
return err
}
if _, err = tx.Exec( if _, err = tx.Exec(
`INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by) `INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
VALUES('default','Default Tenant',NULL,?,0,0)`, t.TenantStateActive); err != nil { VALUES('default','Default Tenant',NULL,?,0,0)`, t.TenantStateActive); err != nil {
@@ -401,6 +429,20 @@ func (a *adapter) CreateDb(reset bool) error {
return err return err
} }
if _, err = tx.Exec(
`CREATE TABLE im_user_security(
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
auth_version INT NOT NULL DEFAULT 1,
password_updated_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(tenant_id,user_id),
CONSTRAINT fk_im_user_security_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
)`); err != nil {
return err
}
// Indexed user tags. // Indexed user tags.
if _, err = tx.Exec( if _, err = tx.Exec(
`CREATE TABLE usertags( `CREATE TABLE usertags(
@@ -935,6 +977,60 @@ func (a *adapter) UpgradeDb() error {
} }
} }
if a.version == 117 {
if _, err := a.db.Exec(
`CREATE TABLE im_verification_codes(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
challenge_id VARCHAR(64) NOT NULL,
scene VARCHAR(32) NOT NULL,
phone_hash CHAR(64) NOT NULL,
phone_mask VARCHAR(32) NOT NULL,
target_user BIGINT,
code_hash CHAR(64) NOT NULL,
verification_token_hash CHAR(64),
state SMALLINT NOT NULL,
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL,
expires_at DATETIME(3) NOT NULL,
resend_after DATETIME(3) NOT NULL,
verified_at DATETIME(3),
consumed_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_verification_tenant_challenge(tenant_id, challenge_id),
INDEX idx_im_verification_tenant_scene_phone_state(tenant_id, scene, phone_hash, state, expires_at),
CONSTRAINT fk_im_verification_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
)`); err != nil {
return err
}
if _, err := a.db.Exec(
`CREATE TABLE im_user_security(
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
auth_version INT NOT NULL DEFAULT 1,
password_updated_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(tenant_id,user_id),
CONSTRAINT fk_im_user_security_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
)`); err != nil {
return err
}
if _, err := a.db.Exec(
`INSERT INTO im_user_security(tenant_id,user_id,auth_version,created_at,updated_at)
SELECT tenant_id,id,1,updatedat,updatedat FROM users
ON DUPLICATE KEY UPDATE auth_version=auth_version`); err != nil {
return err
}
if err := bumpVersion(a, 118); err != nil {
return err
}
}
if a.version != adpVersion { if a.version != adpVersion {
return errors.New("Failed to perform database upgrade to version " + strconv.Itoa(adpVersion) + return errors.New("Failed to perform database upgrade to version " + strconv.Itoa(adpVersion) +
". DB is still at " + strconv.Itoa(a.version)) ". DB is still at " + strconv.Itoa(a.version))
@@ -1053,6 +1149,11 @@ func (a *adapter) UserCreate(user *t.User) error {
return err return err
} }
if _, err = tx.Exec("INSERT INTO im_user_security(tenant_id,user_id,auth_version,created_at,updated_at) VALUES(?,?,?,?,?)",
user.TenantID, decoded_uid, 1, user.CreatedAt, user.UpdatedAt); err != nil {
return err
}
// Save user's tags to a separate table to make user findable. // Save user's tags to a separate table to make user findable.
if err = addTenantTags(tx, user.TenantID, "usertags", "userid", decoded_uid, user.Tags, false); err != nil { if err = addTenantTags(tx, user.TenantID, "usertags", "userid", decoded_uid, user.Tags, false); err != nil {
return err return err
+39 -1
View File
@@ -45,6 +45,32 @@ CREATE TABLE im_tenant_config(
CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
); );
CREATE TABLE im_verification_codes(
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_id BIGINT NOT NULL,
challenge_id VARCHAR(64) NOT NULL,
scene VARCHAR(32) NOT NULL,
phone_hash CHAR(64) NOT NULL,
phone_mask VARCHAR(32) NOT NULL,
target_user BIGINT,
code_hash CHAR(64) NOT NULL,
verification_token_hash CHAR(64),
state SMALLINT NOT NULL,
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL,
expires_at DATETIME(3) NOT NULL,
resend_after DATETIME(3) NOT NULL,
verified_at DATETIME(3),
consumed_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(id),
UNIQUE INDEX uk_im_verification_tenant_challenge(tenant_id, challenge_id),
INDEX idx_im_verification_tenant_scene_phone_state(tenant_id, scene, phone_hash, state, expires_at),
CONSTRAINT fk_im_verification_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
);
INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by) INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
VALUES('default','Default Tenant',NULL,1,0,0); VALUES('default','Default Tenant',NULL,1,0,0);
@@ -57,7 +83,7 @@ CREATE TABLE kvmeta(
INDEX kvmeta_createdat_key(createdat, `key`) INDEX kvmeta_createdat_key(createdat, `key`)
); );
INSERT INTO kvmeta(`key`, `value`) VALUES("version", "117"); INSERT INTO kvmeta(`key`, `value`) VALUES("version", "118");
CREATE TABLE users( CREATE TABLE users(
id BIGINT NOT NULL, id BIGINT NOT NULL,
@@ -76,6 +102,18 @@ CREATE TABLE users(
INDEX users_lastseen_updatedat(lastseen, updatedat) INDEX users_lastseen_updatedat(lastseen, updatedat)
); );
CREATE TABLE im_user_security(
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
auth_version INT NOT NULL DEFAULT 1,
password_updated_at DATETIME(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY(tenant_id,user_id),
CONSTRAINT fk_im_user_security_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
);
# Indexed user tags. # Indexed user tags.
CREATE TABLE usertags( CREATE TABLE usertags(
id INT NOT NULL AUTO_INCREMENT, id INT NOT NULL AUTO_INCREMENT,
+232
View File
@@ -1832,3 +1832,235 @@ func (a *adapter) TenantPCacheExpire(id t.TenantID, prefix string, before time.T
} }
return a.PCacheExpire(scoped, before) return a.PCacheExpire(scoped, before)
} }
func (a *adapter) TenantVerificationCreate(id t.TenantID, vc *t.VerificationCode) error {
if err := validTenant(id); err != nil || vc == nil || vc.TenantID != id ||
vc.ChallengeID == "" || vc.Scene == "" || vc.PhoneHash == "" || vc.CodeHash == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
now := t.TimeNow()
_, err = tx.ExecContext(ctx,
`UPDATE im_verification_codes
SET state=?,updated_at=?
WHERE tenant_id=? AND scene=? AND phone_hash=? AND state=?`,
t.VerificationStateExpired, now, id, vc.Scene, vc.PhoneHash, t.VerificationStatePending)
if err != nil {
return err
}
vc.CreatedAt = now
vc.UpdatedAt = now
_, err = tx.ExecContext(ctx,
`INSERT INTO im_verification_codes
(tenant_id,challenge_id,scene,phone_hash,phone_mask,target_user,code_hash,state,attempts,max_attempts,
expires_at,resend_after,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
id, vc.ChallengeID, vc.Scene, vc.PhoneHash, vc.PhoneMask, nullableUID(vc.TargetUser), vc.CodeHash, vc.State, vc.Attempts,
vc.MaxAttempts, vc.ExpiresAt, vc.ResendAfter, vc.CreatedAt, vc.UpdatedAt)
if isDupe(err) {
return t.ErrDuplicate
}
if err != nil {
return err
}
return tx.Commit()
}
func (a *adapter) TenantVerificationGet(id t.TenantID, challengeID string) (*t.VerificationCode, error) {
if err := validTenant(id); err != nil || challengeID == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var vc t.VerificationCode
err := a.db.GetContext(ctx, &vc,
`SELECT id,tenant_id,challenge_id,scene,phone_hash,phone_mask,IFNULL(target_user,0) AS target_user,code_hash,verification_token_hash,
state,attempts,max_attempts,expires_at,resend_after,verified_at,consumed_at,created_at,updated_at
FROM im_verification_codes
WHERE tenant_id=? AND challenge_id=? LIMIT 1`, id, challengeID)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
return &vc, nil
}
func (a *adapter) TenantVerificationGetActive(id t.TenantID, scene, phoneHash string, now time.Time) (*t.VerificationCode, error) {
if err := validTenant(id); err != nil || scene == "" || phoneHash == "" {
return nil, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
var vc t.VerificationCode
err := a.db.GetContext(ctx, &vc,
`SELECT id,tenant_id,challenge_id,scene,phone_hash,phone_mask,IFNULL(target_user,0) AS target_user,code_hash,verification_token_hash,
state,attempts,max_attempts,expires_at,resend_after,verified_at,consumed_at,created_at,updated_at
FROM im_verification_codes
WHERE tenant_id=? AND scene=? AND phone_hash=? AND state=? AND expires_at>?
ORDER BY created_at DESC LIMIT 1`, id, scene, phoneHash, t.VerificationStatePending, now)
if err == sql.ErrNoRows {
return nil, t.ErrNotFound
}
if err != nil {
return nil, err
}
return &vc, nil
}
func (a *adapter) TenantVerificationFail(id t.TenantID, challengeID string, state t.VerificationState) error {
if err := validTenant(id); err != nil || challengeID == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
now := t.TimeNow()
_, err := a.db.ExecContext(ctx,
`UPDATE im_verification_codes
SET attempts=attempts+1,state=?,updated_at=?
WHERE tenant_id=? AND challenge_id=?`,
state, now, id, challengeID)
return err
}
func (a *adapter) TenantVerificationMarkVerified(id t.TenantID, challengeID, tokenHash string, verifiedAt time.Time) error {
if err := validTenant(id); err != nil || challengeID == "" || tokenHash == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
_, err := a.db.ExecContext(ctx,
`UPDATE im_verification_codes
SET state=?,verification_token_hash=?,verified_at=?,updated_at=?
WHERE tenant_id=? AND challenge_id=? AND state=?`,
t.VerificationStateVerified, tokenHash, verifiedAt, verifiedAt, id, challengeID, t.VerificationStatePending)
return err
}
func (a *adapter) TenantVerificationConsume(id t.TenantID, challengeID, tokenHash string, consumedAt time.Time) error {
if err := validTenant(id); err != nil || challengeID == "" || tokenHash == "" {
return t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
res, err := a.db.ExecContext(ctx,
`UPDATE im_verification_codes
SET state=?,consumed_at=?,updated_at=?
WHERE tenant_id=? AND challenge_id=? AND verification_token_hash=? AND state=? AND expires_at>?`,
t.VerificationStateConsumed, consumedAt, consumedAt, id, challengeID, tokenHash,
t.VerificationStateVerified, consumedAt)
if err != nil {
return err
}
if rows, _ := res.RowsAffected(); rows == 0 {
return t.ErrNotFound
}
return nil
}
func (a *adapter) TenantUserSecurityGetAuthVersion(id t.TenantID, uid t.Uid) (int, error) {
if err := validTenant(id); err != nil || uid.IsZero() {
return 0, t.ErrMalformed
}
ctx, cancel := a.getContext()
if cancel != nil {
defer cancel()
}
decoded := decodeUID(uid)
now := t.TimeNow()
_, err := a.db.ExecContext(ctx,
`INSERT INTO im_user_security(tenant_id,user_id,auth_version,created_at,updated_at)
VALUES(?,?,?,?,?)
ON DUPLICATE KEY UPDATE auth_version=auth_version`,
id, decoded, 1, now, now)
if err != nil {
return 0, err
}
var version int
err = a.db.GetContext(ctx, &version,
`SELECT auth_version FROM im_user_security WHERE tenant_id=? AND user_id=?`,
id, decoded)
if err == sql.ErrNoRows {
return 0, t.ErrNotFound
}
if err != nil {
return 0, err
}
return version, nil
}
func (a *adapter) TenantUserSecurityBumpAuthVersion(id t.TenantID, uid t.Uid, updatedAt time.Time) (int, error) {
if err := validTenant(id); err != nil || uid.IsZero() {
return 0, t.ErrMalformed
}
if updatedAt.IsZero() {
updatedAt = t.TimeNow()
}
ctx, cancel := a.getContextForTx()
if cancel != nil {
defer cancel()
}
tx, err := a.db.BeginTxx(ctx, nil)
if err != nil {
return 0, err
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
decoded := decodeUID(uid)
if _, err = tx.ExecContext(ctx,
`INSERT INTO im_user_security(tenant_id,user_id,auth_version,password_updated_at,created_at,updated_at)
VALUES(?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE
auth_version=auth_version+1,
password_updated_at=VALUES(password_updated_at),
updated_at=VALUES(updated_at)`,
id, decoded, 2, updatedAt, updatedAt, updatedAt); err != nil {
return 0, err
}
var version int
if err = tx.GetContext(ctx, &version,
`SELECT auth_version FROM im_user_security WHERE tenant_id=? AND user_id=?`,
id, decoded); err != nil {
return 0, err
}
if err = tx.Commit(); err != nil {
return 0, err
}
return version, nil
}
func nullableUID(uid t.Uid) any {
if uid.IsZero() {
return nil
}
return store.DecodeUid(uid)
}
+671
View File
@@ -0,0 +1,671 @@
package main
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"time"
"github.com/nyaruka/phonenumbers"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/logs"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
const (
imReasonAPIKeyRequired = "API_KEY_REQUIRED"
imReasonMethodNotAllowed = "METHOD_NOT_ALLOWED"
imReasonMalformedRequest = "MALFORMED_REQUEST"
imReasonTenantUnavailable = "TENANT_UNAVAILABLE"
imReasonVerificationRate = "VERIFICATION_RATE_LIMITED"
imReasonChallengeExpired = "CHALLENGE_EXPIRED"
imReasonCodeInvalid = "CODE_INVALID"
imReasonTooManyAttempts = "TOO_MANY_ATTEMPTS"
imReasonPasswordPolicy = "PASSWORD_POLICY_VIOLATION"
imReasonInternal = "INTERNAL_ERROR"
)
const (
imVerificationCodeLength = 6
imVerificationExpiresInSeconds = 300
imVerificationResendAfterSeconds = 60
imVerificationMaxAttempts = 5
)
type imHTTPResponse struct {
Code int `json:"code"`
Data any `json:"data,omitempty"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
Details any `json:"details,omitempty"`
RequestID string `json:"request_id,omitempty"`
ServerTime string `json:"server_time"`
}
type imTenantResolveRequest struct {
TenantCode string `json:"tenant_code"`
Platform string `json:"platform,omitempty"`
AppVersion string `json:"app_version,omitempty"`
}
type imVerificationCreateRequest struct {
TenantCode string `json:"tenant_code,omitempty"`
Scene string `json:"scene"`
Phone string `json:"phone"`
Country string `json:"country,omitempty"`
}
type imVerificationVerifyRequest struct {
Code string `json:"code"`
}
type imPasswordResetConfirmRequest struct {
ResetToken string `json:"reset_token"`
NewPassword string `json:"new_password"`
}
func serveIMTenantResolve(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
return
}
var body imTenantResolveRequest
if req.Body != nil {
dec := json.NewDecoder(req.Body)
dec.DisallowUnknownFields()
err := dec.Decode(&body)
if err != nil {
if errors.Is(err, io.EOF) {
err = nil
}
}
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
var extra any
if err := dec.Decode(&extra); err != nil && !errors.Is(err, io.EOF) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
} else if err == nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
}
tenantCode := strings.TrimSpace(req.Header.Get("X-Tenant-Code"))
if tenantCode == "" {
tenantCode = strings.TrimSpace(body.TenantCode)
}
if tenantCode == "" || len(tenantCode) > 64 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{
"field": "tenant_code",
})
return
}
if store.Tenants == nil {
logs.Err.Println("im tenant resolve: tenant store is not initialized")
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
tenant, err := store.Tenants.GetByCode(tenantCode)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
return
}
logs.Warn.Println("im tenant resolve: failed to resolve tenant", tenantCode, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if !tenant.IsActive() {
writeIMError(wrt, req, http.StatusForbidden, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{
"tenant": map[string]any{
"code": tenant.Code,
"name": tenant.Name,
"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",
},
"auth_methods": []string{"password", "sms"},
"password_policy": map[string]any{
"min_length": 8,
"max_length": 64,
},
"app_update": map[string]any{"type": "none"},
})
}
func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
return
}
var body imVerificationCreateRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
scene := strings.TrimSpace(body.Scene)
if !isAllowedVerificationScene(scene) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "scene"})
return
}
phone, err := normalizeIMPhone(body.Phone, body.Country)
if err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "phone"})
return
}
tenant, ok := resolveIMHTTPTenant(wrt, req, body.TenantCode)
if !ok {
return
}
now := time.Now().UTC().Round(time.Millisecond)
phoneHash := hashIMSecret(tenant.ID, scene, phone, "phone")
active, err := store.Verifications.GetActive(tenant.ID, scene, phoneHash, now)
if err != nil && !errors.Is(err, types.ErrNotFound) {
logs.Warn.Println("im verification create: active lookup failed", tenant.Code, scene, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if active != nil && now.Before(active.ResendAfter) {
retryAfter := int(time.Until(active.ResendAfter).Seconds())
if retryAfter < 1 {
retryAfter = 1
}
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
"challenge_id": active.ChallengeID,
"retry_after_seconds": retryAfter,
})
return
}
challengeID, err := randomIMToken(18)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
code, err := randomIMNumericCode(imVerificationCodeLength)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
vc := &types.VerificationCode{
TenantID: tenant.ID,
ChallengeID: challengeID,
Scene: scene,
PhoneHash: phoneHash,
PhoneMask: maskIMPhone(phone),
CodeHash: hashIMSecret(tenant.ID, scene, challengeID, phoneHash, code),
State: types.VerificationStatePending,
MaxAttempts: imVerificationMaxAttempts,
ExpiresAt: now.Add(imVerificationExpiresInSeconds * time.Second),
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)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
} else {
vc.TargetUser = uid
}
}
if err := store.Verifications.Create(tenant.ID, vc); err != nil {
logs.Warn.Println("im verification create: store failed", tenant.Code, scene, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
logs.Info.Println("im verification code generated:", tenant.Code, scene, vc.PhoneMask)
writeIMSuccess(wrt, req, map[string]any{
"challenge_id": vc.ChallengeID,
"phone_mask": vc.PhoneMask,
"expires_in_seconds": imVerificationExpiresInSeconds,
"resend_after_seconds": imVerificationResendAfterSeconds,
})
}
func serveIMVerificationCodeVerify(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
return
}
challengeID := verificationChallengeIDFromPath(req.URL.Path)
if challengeID == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "challenge_id"})
return
}
var body imVerificationVerifyRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
code := strings.TrimSpace(body.Code)
if len(code) != imVerificationCodeLength {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "code"})
return
}
tenant, ok := resolveIMHTTPTenant(wrt, req, "")
if !ok {
return
}
vc, err := store.Verifications.Get(tenant.ID, challengeID)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
logs.Warn.Println("im verification verify: lookup failed", tenant.Code, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
now := time.Now().UTC().Round(time.Millisecond)
if vc.State == types.VerificationStateLocked || vc.Attempts >= vc.MaxAttempts {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
return
}
if vc.State != types.VerificationStatePending || !now.Before(vc.ExpiresAt) {
_ = store.Verifications.Fail(tenant.ID, challengeID, types.VerificationStateExpired)
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
if vc.CodeHash != hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, code) {
nextState := types.VerificationStatePending
if vc.Attempts+1 >= vc.MaxAttempts {
nextState = types.VerificationStateLocked
}
_ = store.Verifications.Fail(tenant.ID, challengeID, nextState)
if nextState == types.VerificationStateLocked {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
return
}
writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil)
return
}
token, err := randomIMToken(32)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if err := store.Verifications.MarkVerified(tenant.ID, challengeID, hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, token), now); err != nil {
logs.Warn.Println("im verification verify: mark verified failed", tenant.Code, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
writeIMSuccess(wrt, req, map[string]any{
"verification_token": token,
"expires_in_seconds": imVerificationExpiresInSeconds,
})
}
func serveIMPasswordResetVerify(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
return
}
challengeID := passwordResetChallengeIDFromPath(req.URL.Path)
if challengeID == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "challenge_id"})
return
}
var body imVerificationVerifyRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
tenant, ok := resolveIMHTTPTenant(wrt, req, "")
if !ok {
return
}
vc, token, ok := verifyIMCodeChallenge(wrt, req, tenant.ID, challengeID, strings.TrimSpace(body.Code), "reset_password")
if !ok {
return
}
if vc.TargetUser.IsZero() {
writeIMSuccess(wrt, req, map[string]any{
"reset_token": vc.ChallengeID + "." + token,
"expires_in_seconds": imVerificationExpiresInSeconds,
})
return
}
writeIMSuccess(wrt, req, map[string]any{
"reset_token": vc.ChallengeID + "." + token,
"expires_in_seconds": imVerificationExpiresInSeconds,
})
}
func serveIMPasswordResetConfirm(wrt http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid {
writeIMError(wrt, req, http.StatusForbidden, 30003, imReasonAPIKeyRequired, "valid API key required", nil)
return
}
var body imPasswordResetConfirmRequest
if !decodeIMJSONBody(wrt, req, &body) {
return
}
tenant, ok := resolveIMHTTPTenant(wrt, req, "")
if !ok {
return
}
challengeID, token, ok := strings.Cut(strings.TrimSpace(body.ResetToken), ".")
if !ok || challengeID == "" || token == "" || strings.TrimSpace(body.NewPassword) == "" {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return
}
vc, err := store.Verifications.Get(tenant.ID, challengeID)
if err != nil {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
if vc.Scene != "reset_password" || vc.State != types.VerificationStateVerified || vc.VerificationTokenHash == nil {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
tokenHash := hashIMSecret(tenant.ID, vc.Scene, vc.ChallengeID, vc.PhoneHash, token)
if *vc.VerificationTokenHash != tokenHash || !time.Now().UTC().Before(vc.ExpiresAt) {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
if vc.TargetUser.IsZero() {
_ = store.Verifications.Consume(tenant.ID, vc.ChallengeID, tokenHash, time.Now().UTC().Round(time.Millisecond))
writeIMSuccess(wrt, req, map[string]any{"status": "ok"})
return
}
login, authLevel, _, _, err := store.Users.GetAuthRecord(tenant.ID, vc.TargetUser, "basic")
if err != nil || login == "" {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
basicAuth := store.Store.GetLogicalAuthHandler("basic")
if basicAuth == nil || !basicAuth.IsInitialized() {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if _, err = basicAuth.UpdateRecord(auth.AuthContext{TenantID: tenant.ID}, &auth.Rec{
TenantID: tenant.ID,
Uid: vc.TargetUser,
AuthLevel: authLevel,
}, []byte(":"+body.NewPassword)); err != nil {
if err == types.ErrPolicy {
writeIMError(wrt, req, http.StatusBadRequest, 30040, imReasonPasswordPolicy, "password policy violation", nil)
return
}
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
now := time.Now().UTC().Round(time.Millisecond)
if store.Security == nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if _, err = store.Security.BumpAuthVersion(tenant.ID, vc.TargetUser, now); err != nil {
logs.Warn.Println("im password reset confirm: bump auth version failed", err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
if err = store.Verifications.Consume(tenant.ID, vc.ChallengeID, tokenHash, now); err != nil {
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return
}
if globals.sessionStore != nil {
globals.sessionStore.EvictUser(vc.TargetUser, "")
}
writeIMSuccess(wrt, req, map[string]any{"status": "ok"})
}
func verifyIMCodeChallenge(wrt http.ResponseWriter, req *http.Request, tenantID types.TenantID, challengeID, code, scene string) (*types.VerificationCode, string, bool) {
if len(code) != imVerificationCodeLength {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "code"})
return nil, "", false
}
vc, err := store.Verifications.Get(tenantID, challengeID)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30030, imReasonChallengeExpired, "challenge expired", nil)
return nil, "", false
}
logs.Warn.Println("im verification verify: lookup failed", err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, "", false
}
now := time.Now().UTC().Round(time.Millisecond)
if vc.Scene != scene || vc.State == types.VerificationStateLocked || vc.Attempts >= vc.MaxAttempts {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
return nil, "", false
}
if vc.State != types.VerificationStatePending || !now.Before(vc.ExpiresAt) {
_ = store.Verifications.Fail(tenantID, challengeID, types.VerificationStateExpired)
writeIMError(wrt, req, http.StatusGone, 30030, imReasonChallengeExpired, "challenge expired", nil)
return nil, "", false
}
if vc.CodeHash != hashIMSecret(tenantID, vc.Scene, vc.ChallengeID, vc.PhoneHash, code) {
nextState := types.VerificationStatePending
if vc.Attempts+1 >= vc.MaxAttempts {
nextState = types.VerificationStateLocked
}
_ = store.Verifications.Fail(tenantID, challengeID, nextState)
if nextState == types.VerificationStateLocked {
writeIMError(wrt, req, http.StatusTooManyRequests, 30032, imReasonTooManyAttempts, "too many attempts", nil)
return nil, "", false
}
writeIMError(wrt, req, http.StatusUnauthorized, 30031, imReasonCodeInvalid, "code invalid", nil)
return nil, "", false
}
token, err := randomIMToken(32)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, "", false
}
if err := store.Verifications.MarkVerified(tenantID, challengeID, hashIMSecret(tenantID, vc.Scene, vc.ChallengeID, vc.PhoneHash, token), now); err != nil {
logs.Warn.Println("im verification verify: mark verified failed", err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, "", false
}
return vc, token, true
}
func decodeIMJSONBody(wrt http.ResponseWriter, req *http.Request, dst any) bool {
if req.Body == nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return false
}
dec := json.NewDecoder(req.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return false
}
var extra any
if err := dec.Decode(&extra); err != nil && !errors.Is(err, io.EOF) {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return false
} else if err == nil {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil)
return false
}
return true
}
func resolveIMHTTPTenant(wrt http.ResponseWriter, req *http.Request, fallbackCode string) (*types.Tenant, bool) {
tenantCode := strings.TrimSpace(req.Header.Get("X-Tenant-Code"))
if tenantCode == "" {
tenantCode = strings.TrimSpace(fallbackCode)
}
if tenantCode == "" || len(tenantCode) > 64 {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "tenant_code"})
return nil, false
}
if store.Tenants == nil {
logs.Err.Println("im http: tenant store is not initialized")
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, false
}
tenant, err := store.Tenants.GetByCode(tenantCode)
if err != nil {
if errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusNotFound, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
return nil, false
}
logs.Warn.Println("im http: failed to resolve tenant", tenantCode, err)
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return nil, false
}
if !tenant.IsActive() {
writeIMError(wrt, req, http.StatusForbidden, 30010, imReasonTenantUnavailable, "tenant unavailable", nil)
return nil, false
}
return tenant, true
}
func writeIMSuccess(wrt http.ResponseWriter, req *http.Request, data any) {
writeIMJSON(wrt, http.StatusOK, imHTTPResponse{
Code: 0,
Data: data,
RequestID: req.Header.Get("X-Request-ID"),
ServerTime: time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano),
})
}
func writeIMError(wrt http.ResponseWriter, req *http.Request, status, code int, reason, message string, details any) {
writeIMJSON(wrt, status, imHTTPResponse{
Code: code,
Reason: reason,
Message: message,
Details: details,
RequestID: req.Header.Get("X-Request-ID"),
ServerTime: time.Now().UTC().Round(time.Millisecond).Format(time.RFC3339Nano),
})
}
func writeIMJSON(wrt http.ResponseWriter, status int, resp imHTTPResponse) {
wrt.Header().Set("Content-Type", "application/json; charset=utf-8")
wrt.WriteHeader(status)
if err := json.NewEncoder(wrt).Encode(resp); err != nil {
logs.Warn.Println("im http: failed to write response", err)
}
}
func isAllowedVerificationScene(scene string) bool {
switch scene {
case "register", "login", "reset_password", "sensitive_action":
return true
default:
return false
}
}
func normalizeIMPhone(phone, country string) (string, error) {
if strings.TrimSpace(country) == "" {
country = "CN"
}
number, err := phonenumbers.Parse(strings.TrimSpace(phone), country)
if err != nil || !phonenumbers.IsValidNumber(number) {
return "", types.ErrMalformed
}
return phonenumbers.Format(number, phonenumbers.E164), nil
}
func maskIMPhone(phone string) string {
if len(phone) <= 7 {
return phone
}
return phone[:4] + "****" + phone[len(phone)-3:]
}
func randomIMNumericCode(length int) (string, error) {
max := big.NewInt(0).Exp(big.NewInt(10), big.NewInt(int64(length)), nil)
n, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
code := fmt.Sprintf("%0*d", length, n.Int64())
return code, nil
}
func randomIMToken(size int) (string, error) {
buf := make([]byte, size)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
func hashIMSecret(tenantID types.TenantID, parts ...string) string {
h := sha256.New()
h.Write(globals.apiKeySalt)
h.Write([]byte(fmt.Sprintf(":%d:", tenantID)))
for _, part := range parts {
h.Write([]byte{0})
h.Write([]byte(part))
}
return fmt.Sprintf("%x", h.Sum(nil))
}
func verificationChallengeIDFromPath(path string) string {
const marker = "/v1/im/auth/verification-codes/"
idx := strings.Index(path, marker)
if idx < 0 {
return ""
}
rest := strings.TrimPrefix(path[idx:], marker)
challengeID, suffix, ok := strings.Cut(rest, "/")
if !ok || suffix != "verify" {
return ""
}
return strings.TrimSpace(challengeID)
}
func passwordResetChallengeIDFromPath(path string) string {
const marker = "/v1/im/auth/password-reset/challenges/"
idx := strings.Index(path, marker)
if idx < 0 {
return ""
}
rest := strings.TrimPrefix(path[idx:], marker)
challengeID, suffix, ok := strings.Cut(rest, "/")
if !ok || suffix != "verify" {
return ""
}
return strings.TrimSpace(challengeID)
}
+430
View File
@@ -0,0 +1,430 @@
package main
import (
"bytes"
"crypto/hmac"
"crypto/md5"
"encoding/base64"
"encoding/binary"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type testVerificationStore struct {
active *types.VerificationCode
get *types.VerificationCode
err error
created *types.VerificationCode
failed bool
verified bool
consumed bool
tokenHash string
}
func (s *testVerificationStore) Create(_ types.TenantID, vc *types.VerificationCode) error {
s.created = vc
return s.err
}
func (s *testVerificationStore) Get(_ types.TenantID, _ string) (*types.VerificationCode, error) {
if s.err != nil {
return nil, s.err
}
if s.get == nil {
return nil, types.ErrNotFound
}
return s.get, nil
}
func (s *testVerificationStore) GetActive(_ types.TenantID, _, _ string, _ time.Time) (*types.VerificationCode, error) {
if s.err != nil {
return nil, s.err
}
if s.active == nil {
return nil, types.ErrNotFound
}
return s.active, nil
}
func (s *testVerificationStore) Fail(_ types.TenantID, _ string, _ types.VerificationState) error {
s.failed = true
return nil
}
func (s *testVerificationStore) MarkVerified(_ types.TenantID, _, tokenHash string, _ time.Time) error {
s.verified = true
s.tokenHash = tokenHash
return nil
}
func (s *testVerificationStore) Consume(_ types.TenantID, _, tokenHash string, _ time.Time) error {
s.consumed = true
s.tokenHash = tokenHash
return nil
}
func makeTestAPIKey(t *testing.T) string {
t.Helper()
globals.apiKeySalt = []byte("0123456789abcdef")
data := make([]byte, apikeyLength)
data[0] = 1
binary.LittleEndian.PutUint32(data[apikeyVersion:], uint32(0))
binary.LittleEndian.PutUint16(data[apikeyVersion+apikeyAppID:], uint16(1))
data[apikeyVersion+apikeyAppID+apikeySequence] = uint8(0)
hasher := hmac.New(md5.New, globals.apiKeySalt)
hasher.Write(data[:apikeyVersion+apikeyAppID+apikeySequence+apikeyWho])
copy(data[apikeyVersion+apikeyAppID+apikeySequence+apikeyWho:], hasher.Sum(nil))
return base64.URLEncoding.EncodeToString(data)
}
func decodeIMHTTPResponse(t *testing.T, rec *httptest.ResponseRecorder) imHTTPResponse {
t.Helper()
var resp imHTTPResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v; body=%s", err, rec.Body.String())
}
return resp
}
func TestServeIMTenantResolveSuccessFromHeader(t *testing.T) {
oldTenants := store.Tenants
store.Tenants = testTenantStore{tenant: &types.Tenant{
ID: 7,
Code: "acme",
Name: "Acme Corp",
TenantDesc: ptrString("demo tenant"),
State: types.TenantStateActive,
}}
defer func() { store.Tenants = oldTenants }()
globals.servingAt = "https://im.example.com/"
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", nil)
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", " acme ")
req.Header.Set("X-Request-ID", "req-1")
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())
}
resp := decodeIMHTTPResponse(t, rec)
if resp.Code != 0 || resp.RequestID != "req-1" {
t.Fatalf("unexpected response envelope: %#v", resp)
}
data := resp.Data.(map[string]any)
tenant := data["tenant"].(map[string]any)
if tenant["code"] != "acme" || tenant["name"] != "Acme Corp" {
t.Fatalf("unexpected tenant payload: %#v", tenant)
}
if _, ok := tenant["id"]; ok {
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" {
t.Fatalf("unexpected websocket endpoint: %#v", endpoints)
}
}
func TestServeIMTenantResolveSuccessFromBody(t *testing.T) {
oldTenants := store.Tenants
store.Tenants = testTenantStore{tenant: &types.Tenant{
ID: 7,
Code: "acme",
Name: "Acme Corp",
State: types.TenantStateActive,
}}
defer func() { store.Tenants = oldTenants }()
globals.servingAt = "http://127.0.0.1:6060/"
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve",
bytes.NewBufferString(`{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
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())
}
resp := decodeIMHTTPResponse(t, rec)
if resp.Code != 0 || resp.Reason != "" {
t.Fatalf("unexpected response envelope: %#v", resp)
}
}
func TestServeIMTenantResolveRequiresPost(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/im/tenants/resolve", nil)
rec := httptest.NewRecorder()
serveIMTenantResolve(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status: got %d, want %d", rec.Code, http.StatusMethodNotAllowed)
}
resp := decodeIMHTTPResponse(t, rec)
if resp.Reason != imReasonMethodNotAllowed {
t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonMethodNotAllowed)
}
}
func TestServeIMTenantResolveRequiresAPIKey(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve",
bytes.NewBufferString(`{"tenant_code":"acme"}`))
rec := httptest.NewRecorder()
serveIMTenantResolve(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status: got %d, want %d", rec.Code, http.StatusForbidden)
}
resp := decodeIMHTTPResponse(t, rec)
if resp.Reason != imReasonAPIKeyRequired {
t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonAPIKeyRequired)
}
}
func TestServeIMTenantResolveRejectsMalformedTenantCode(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve",
bytes.NewBufferString(`{"tenant_code":"`+strings.Repeat("a", 65)+`"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
rec := httptest.NewRecorder()
serveIMTenantResolve(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status: got %d, want %d", rec.Code, http.StatusBadRequest)
}
resp := decodeIMHTTPResponse(t, rec)
if resp.Reason != imReasonMalformedRequest {
t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonMalformedRequest)
}
}
func TestServeIMTenantResolveHidesUnavailableTenantState(t *testing.T) {
oldTenants := store.Tenants
store.Tenants = testTenantStore{tenant: &types.Tenant{
ID: 7,
Code: "acme",
Name: "Acme Corp",
State: types.TenantStateSuspended,
}}
defer func() { store.Tenants = oldTenants }()
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve",
bytes.NewBufferString(`{"tenant_code":"acme"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
rec := httptest.NewRecorder()
serveIMTenantResolve(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status: got %d, want %d", rec.Code, http.StatusForbidden)
}
resp := decodeIMHTTPResponse(t, rec)
if resp.Reason != imReasonTenantUnavailable {
t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonTenantUnavailable)
}
}
func TestServeIMTenantResolveHidesMissingTenant(t *testing.T) {
oldTenants := store.Tenants
store.Tenants = testTenantStore{err: types.ErrNotFound}
defer func() { store.Tenants = oldTenants }()
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve",
bytes.NewBufferString(`{"tenant_code":"acme"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
rec := httptest.NewRecorder()
serveIMTenantResolve(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status: got %d, want %d", rec.Code, http.StatusNotFound)
}
resp := decodeIMHTTPResponse(t, rec)
if resp.Reason != imReasonTenantUnavailable {
t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonTenantUnavailable)
}
}
func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
store.Tenants = testTenantStore{tenant: &types.Tenant{
ID: 7,
Code: "acme",
Name: "Acme Corp",
State: types.TenantStateActive,
}}
verifications := &testVerificationStore{}
store.Verifications = verifications
defer func() {
store.Tenants = oldTenants
store.Verifications = oldVerifications
}()
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 {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if verifications.created == nil {
t.Fatal("expected challenge to be stored")
}
if verifications.created.TenantID != 7 || verifications.created.Scene != "register" {
t.Fatalf("unexpected stored challenge: %#v", verifications.created)
}
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())
}
resp := decodeIMHTTPResponse(t, rec)
data := resp.Data.(map[string]any)
if data["challenge_id"] == "" || data["expires_in_seconds"].(float64) == 0 {
t.Fatalf("unexpected response data: %#v", data)
}
}
func TestServeIMVerificationCodesRateLimited(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
store.Tenants = testTenantStore{tenant: &types.Tenant{
ID: 7,
Code: "acme",
Name: "Acme Corp",
State: types.TenantStateActive,
}}
store.Verifications = &testVerificationStore{active: &types.VerificationCode{
TenantID: 7,
ChallengeID: "challenge-1",
State: types.VerificationStatePending,
ResendAfter: time.Now().UTC().Add(time.Minute),
}}
defer func() {
store.Tenants = oldTenants
store.Verifications = oldVerifications
}()
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.StatusTooManyRequests {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusTooManyRequests, rec.Body.String())
}
resp := decodeIMHTTPResponse(t, rec)
if resp.Reason != imReasonVerificationRate {
t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonVerificationRate)
}
}
func TestServeIMVerificationCodeVerifySuccess(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
store.Tenants = testTenantStore{tenant: &types.Tenant{
ID: 7,
Code: "acme",
Name: "Acme Corp",
State: types.TenantStateActive,
}}
vc := &types.VerificationCode{
TenantID: 7,
ChallengeID: "challenge-1",
Scene: "register",
PhoneHash: "phone-hash",
CodeHash: hashIMSecret(7, "register", "challenge-1", "phone-hash", "123456"),
State: types.VerificationStatePending,
MaxAttempts: 5,
ExpiresAt: time.Now().UTC().Add(time.Minute),
}
verifications := &testVerificationStore{get: vc}
store.Verifications = verifications
defer func() {
store.Tenants = oldTenants
store.Verifications = oldVerifications
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify",
bytes.NewBufferString(`{"code":"123456"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
serveIMVerificationCodeVerify(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if !verifications.verified || verifications.tokenHash == "" {
t.Fatalf("expected challenge to be marked verified: %#v", verifications)
}
resp := decodeIMHTTPResponse(t, rec)
data := resp.Data.(map[string]any)
if data["verification_token"] == "" {
t.Fatalf("missing verification token: %#v", data)
}
}
func TestServeIMVerificationCodeVerifyInvalidCode(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
store.Tenants = testTenantStore{tenant: &types.Tenant{
ID: 7,
Code: "acme",
Name: "Acme Corp",
State: types.TenantStateActive,
}}
store.Verifications = &testVerificationStore{get: &types.VerificationCode{
TenantID: 7,
ChallengeID: "challenge-1",
Scene: "register",
PhoneHash: "phone-hash",
CodeHash: hashIMSecret(7, "register", "challenge-1", "phone-hash", "123456"),
State: types.VerificationStatePending,
MaxAttempts: 5,
ExpiresAt: time.Now().UTC().Add(time.Minute),
}}
defer func() {
store.Tenants = oldTenants
store.Verifications = oldVerifications
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify",
bytes.NewBufferString(`{"code":"654321"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
serveIMVerificationCodeVerify(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String())
}
resp := decodeIMHTTPResponse(t, rec)
if resp.Reason != imReasonCodeInvalid {
t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonCodeInvalid)
}
}
func ptrString(s string) *string {
return &s
}
+6
View File
@@ -776,6 +776,12 @@ func main() {
// Handle websocket clients. // Handle websocket clients.
mux.HandleFunc(config.ApiPath+"v0/channels", serveWebSocket) mux.HandleFunc(config.ApiPath+"v0/channels", serveWebSocket)
// Product HTTP API.
mux.HandleFunc(config.ApiPath+"v1/im/tenants/resolve", serveIMTenantResolve)
mux.HandleFunc(config.ApiPath+"v1/im/auth/verification-codes", serveIMVerificationCodes)
mux.HandleFunc(config.ApiPath+"v1/im/auth/verification-codes/", serveIMVerificationCodeVerify)
mux.HandleFunc(config.ApiPath+"v1/im/auth/password-reset/challenges/", serveIMPasswordResetVerify)
mux.HandleFunc(config.ApiPath+"v1/im/auth/password-reset/confirm", serveIMPasswordResetConfirm)
// Handle long polling clients. Enable compression. // Handle long polling clients. Enable compression.
mux.Handle(config.ApiPath+"v0/channels/lp", gh.CompressHandler(http.HandlerFunc(serveLongPoll))) mux.Handle(config.ApiPath+"v0/channels/lp", gh.CompressHandler(http.HandlerFunc(serveLongPoll)))
if config.Media != nil { if config.Media != nil {
+12
View File
@@ -1175,6 +1175,18 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
// Check if the token is suitable for session authentication. // Check if the token is suitable for session authentication.
if features&auth.FeatureNoLogin == 0 { if features&auth.FeatureNoLogin == 0 {
if store.Security == nil {
logs.Warn.Println("s.onLogin: user security store is unavailable", s.sid)
reply = decodeStoreError(types.ErrInternal, msgID, timestamp, nil)
return reply
}
authVersion, err := store.Security.GetAuthVersion(s.tenantID, rec.Uid)
if err != nil {
logs.Warn.Println("s.onLogin: failed to read auth version", rec.Uid, err, s.sid)
reply = decodeStoreError(err, msgID, timestamp, nil)
return reply
}
rec.AuthVersion = authVersion
// Authenticate the session. // Authenticate the session.
s.uid = rec.Uid s.uid = rec.Uid
s.authLvl = rec.AuthLevel s.authLvl = rec.AuthLevel
+6
View File
@@ -226,9 +226,12 @@ func TestDispatchLogin(t *testing.T) {
aa := mock_auth.NewMockAuthHandler(ctrl) aa := mock_auth.NewMockAuthHandler(ctrl)
uid := types.Uid(1) uid := types.Uid(1)
oldSecurity := store.Security
store.Store = ss store.Store = ss
store.Security = &testSecurityStore{version: 1}
defer func() { defer func() {
store.Store = nil store.Store = nil
store.Security = oldSecurity
ctrl.Finish() ctrl.Finish()
}() }()
@@ -1194,11 +1197,14 @@ func TestDispatchAccNew(t *testing.T) {
aa := mock_auth.NewMockAuthHandler(ctrl) aa := mock_auth.NewMockAuthHandler(ctrl)
uid := types.Uid(1) uid := types.Uid(1)
oldSecurity := store.Security
store.Store = ss store.Store = ss
store.Users = uu store.Users = uu
store.Security = &testSecurityStore{version: 1}
defer func() { defer func() {
store.Store = nil store.Store = nil
store.Users = nil store.Users = nil
store.Security = oldSecurity
ctrl.Finish() ctrl.Finish()
}() }()
+96
View File
@@ -1449,6 +1449,100 @@ type pcacheMapper struct{}
var PCache PersistentCacheInterface var PCache PersistentCacheInterface
// VerificationPersistenceInterface defines storage for product verification challenges.
type VerificationPersistenceInterface interface {
Create(tenantID types.TenantID, vc *types.VerificationCode) error
Get(tenantID types.TenantID, challengeID string) (*types.VerificationCode, error)
GetActive(tenantID types.TenantID, scene, phoneHash string, now time.Time) (*types.VerificationCode, error)
Fail(tenantID types.TenantID, challengeID string, state types.VerificationState) error
MarkVerified(tenantID types.TenantID, challengeID, tokenHash string, verifiedAt time.Time) error
Consume(tenantID types.TenantID, challengeID, tokenHash string, consumedAt time.Time) error
}
type verificationMapper struct{}
// Verifications is a singleton for product verification challenges.
var Verifications VerificationPersistenceInterface
// UserSecurityPersistenceInterface defines user security state used to
// invalidate previously issued login tokens.
type UserSecurityPersistenceInterface interface {
GetAuthVersion(tenantID types.TenantID, uid types.Uid) (int, error)
BumpAuthVersion(tenantID types.TenantID, uid types.Uid, updatedAt time.Time) (int, error)
}
type userSecurityMapper struct{}
// Security is a singleton for user security metadata.
var Security UserSecurityPersistenceInterface
func (userSecurityMapper) GetAuthVersion(tenantID types.TenantID, uid types.Uid) (int, error) {
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return 0, err
}
return tenantAdp.TenantUserSecurityGetAuthVersion(tenantID, uid)
}
func (userSecurityMapper) BumpAuthVersion(tenantID types.TenantID, uid types.Uid, updatedAt time.Time) (int, error) {
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return 0, err
}
return tenantAdp.TenantUserSecurityBumpAuthVersion(tenantID, uid, updatedAt)
}
func (verificationMapper) Create(tenantID types.TenantID, vc *types.VerificationCode) error {
if vc == nil || vc.TenantID != tenantID {
return types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantVerificationCreate(tenantID, vc)
}
func (verificationMapper) Get(tenantID types.TenantID, challengeID string) (*types.VerificationCode, error) {
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantVerificationGet(tenantID, challengeID)
}
func (verificationMapper) GetActive(tenantID types.TenantID, scene, phoneHash string, now time.Time) (*types.VerificationCode, error) {
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantVerificationGetActive(tenantID, scene, phoneHash, now)
}
func (verificationMapper) Fail(tenantID types.TenantID, challengeID string, state types.VerificationState) error {
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantVerificationFail(tenantID, challengeID, state)
}
func (verificationMapper) MarkVerified(tenantID types.TenantID, challengeID, tokenHash string, verifiedAt time.Time) error {
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantVerificationMarkVerified(tenantID, challengeID, tokenHash, verifiedAt)
}
func (verificationMapper) Consume(tenantID types.TenantID, challengeID, tokenHash string, consumedAt time.Time) error {
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantVerificationConsume(tenantID, challengeID, tokenHash, consumedAt)
}
// Get reads a persistent cache entry. // Get reads a persistent cache entry.
func (pcacheMapper) Get(tenantID types.TenantID, key string) (string, error) { func (pcacheMapper) Get(tenantID types.TenantID, key string) (string, error) {
tenantAdp, err := tenantBusinessAdapter(tenantID) tenantAdp, err := tenantBusinessAdapter(tenantID)
@@ -1499,4 +1593,6 @@ func init() {
Devices = deviceMapper{} Devices = deviceMapper{}
Files = fileMapper{} Files = fileMapper{}
PCache = pcacheMapper{} PCache = pcacheMapper{}
Verifications = verificationMapper{}
Security = userSecurityMapper{}
} }
+36
View File
@@ -0,0 +1,36 @@
package types
import "time"
// VerificationState is the lifecycle state of an SMS verification challenge.
type VerificationState int16
const (
VerificationStatePending VerificationState = iota
VerificationStateVerified
VerificationStateConsumed
VerificationStateExpired
VerificationStateLocked
)
// VerificationCode is an out-of-band verification challenge.
type VerificationCode struct {
ID int64 `db:"id" json:"-"`
TenantID TenantID `db:"tenant_id" json:"-"`
ChallengeID string `db:"challenge_id" json:"challenge_id"`
Scene string `db:"scene" json:"scene"`
PhoneHash string `db:"phone_hash" json:"-"`
PhoneMask string `db:"phone_mask" json:"phone_mask,omitempty"`
TargetUser Uid `db:"target_user" json:"-"`
CodeHash string `db:"code_hash" json:"-"`
VerificationTokenHash *string `db:"verification_token_hash" json:"-"`
State VerificationState `db:"state" json:"-"`
Attempts int `db:"attempts" json:"-"`
MaxAttempts int `db:"max_attempts" json:"-"`
ExpiresAt time.Time `db:"expires_at" json:"expires_at"`
ResendAfter time.Time `db:"resend_after" json:"resend_after"`
VerifiedAt *time.Time `db:"verified_at" json:"-"`
ConsumedAt *time.Time `db:"consumed_at" json:"-"`
CreatedAt time.Time `db:"created_at" json:"-"`
UpdatedAt time.Time `db:"updated_at" json:"-"`
}
+60
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"container/heap" "container/heap"
"math/rand" "math/rand"
"strings"
"time" "time"
"slices" "slices"
@@ -383,6 +384,19 @@ func addCreds(tenantID types.TenantID, uid types.Uid, creds []MsgCredClient, ext
continue continue
} }
if cr.Response != "" {
if value, ok, err := consumeProductVerificationCred(tenantID, uid, cr, vld.PreCheck); ok || err != nil {
if err != nil {
return nil, nil, err
}
validated = append(validated, cr.Method)
if globals.validators[cr.Method].addToTags {
extraTags = append(extraTags, cr.Method+":"+value)
}
continue
}
}
isNew, err := vld.Request(tenantID, uid, cr.Value, lang, cr.Response, tmpToken) isNew, err := vld.Request(tenantID, uid, cr.Value, lang, cr.Response, tmpToken)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -413,6 +427,52 @@ func addCreds(tenantID types.TenantID, uid types.Uid, creds []MsgCredClient, ext
return validated, extraTags, nil return validated, extraTags, nil
} }
func consumeProductVerificationCred(tenantID types.TenantID, uid types.Uid, cr *MsgCredClient,
preCheck func(string, map[string]any) (string, error)) (string, bool, error) {
challengeID, _ := cr.Params["challenge_id"].(string)
challengeID = strings.TrimSpace(challengeID)
if challengeID == "" {
return "", false, nil
}
if store.Verifications == nil {
return "", true, types.ErrInternal
}
normalized, err := preCheck(cr.Value, cr.Params)
if err != nil {
return "", true, err
}
value := strings.TrimPrefix(normalized, cr.Method+":")
vc, err := store.Verifications.Get(tenantID, challengeID)
if err != nil {
return "", true, err
}
if vc.Scene != "register" || vc.State != types.VerificationStateVerified || vc.PhoneHash == "" {
return "", true, types.ErrInvalidResponse
}
tokenHash := hashIMSecret(tenantID, vc.Scene, vc.ChallengeID, vc.PhoneHash, cr.Response)
if vc.VerificationTokenHash == nil || *vc.VerificationTokenHash != tokenHash {
return "", true, types.ErrInvalidResponse
}
now := time.Now().UTC().Round(time.Millisecond)
if !now.Before(vc.ExpiresAt) {
return "", true, types.ErrExpired
}
if err := store.Verifications.Consume(tenantID, vc.ChallengeID, tokenHash, now); err != nil {
return "", true, err
}
_, err = store.Users.UpsertCred(tenantID, &types.Credential{
ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now},
User: uid.String(),
Method: cr.Method,
Value: value,
Done: true,
})
if err != nil {
return "", true, err
}
return value, true, nil
}
// validatedCreds returns the list of validated credentials including those validated in this call. // validatedCreds returns the list of validated credentials including those validated in this call.
// Returns all validated methods including those validated earlier and now. // Returns all validated methods including those validated earlier and now.
// Returns either a full set of tags or nil for tags if tags are unchanged. // Returns either a full set of tags or nil for tags if tags are unchanged.
+230
View File
@@ -0,0 +1,230 @@
package main
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/auth/mock_auth"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/mock_store"
"github.com/tinode/chat/server/store/types"
)
func TestConsumeProductVerificationCred(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldUsers := store.Users
oldVerifications := store.Verifications
defer func() {
store.Users = oldUsers
store.Verifications = oldVerifications
}()
makeTestAPIKey(t)
token := "verified-token"
tokenHash := hashIMSecret(7, "register", "challenge-1", "phone-hash", token)
store.Verifications = &testVerificationStore{get: &types.VerificationCode{
TenantID: 7,
ChallengeID: "challenge-1",
Scene: "register",
PhoneHash: "phone-hash",
VerificationTokenHash: &tokenHash,
State: types.VerificationStateVerified,
ExpiresAt: time.Now().UTC().Add(time.Minute),
}}
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
users.EXPECT().UpsertCred(types.TenantID(7), gomock.AssignableToTypeOf(&types.Credential{})).
DoAndReturn(func(_ types.TenantID, cred *types.Credential) (bool, error) {
if cred.User != types.Uid(42).String() || cred.Method != "tel" || cred.Value != "+8613800138000" || !cred.Done {
t.Fatalf("unexpected credential: %#v", cred)
}
return true, nil
})
store.Users = users
value, ok, err := consumeProductVerificationCred(7, 42, &MsgCredClient{
Method: "tel",
Value: "+8613800138000",
Response: token,
Params: map[string]any{"challenge_id": "challenge-1", "countryCode": "CN"},
}, func(value string, _ map[string]any) (string, error) {
return "tel:" + value, nil
})
if err != nil || !ok || value != "+8613800138000" {
t.Fatalf("consumeProductVerificationCred = value %q ok %v err %v", value, ok, err)
}
if !store.Verifications.(*testVerificationStore).consumed {
t.Fatal("expected verification token to be consumed")
}
}
func TestConsumeProductVerificationCredRejectsBadToken(t *testing.T) {
oldUsers := store.Users
oldVerifications := store.Verifications
defer func() {
store.Users = oldUsers
store.Verifications = oldVerifications
}()
makeTestAPIKey(t)
tokenHash := hashIMSecret(7, "register", "challenge-1", "phone-hash", "good-token")
store.Verifications = &testVerificationStore{get: &types.VerificationCode{
TenantID: 7,
ChallengeID: "challenge-1",
Scene: "register",
PhoneHash: "phone-hash",
VerificationTokenHash: &tokenHash,
State: types.VerificationStateVerified,
ExpiresAt: time.Now().UTC().Add(time.Minute),
}}
_, ok, err := consumeProductVerificationCred(7, 42, &MsgCredClient{
Method: "tel",
Value: "+8613800138000",
Response: "bad-token",
Params: map[string]any{"challenge_id": "challenge-1"},
}, func(value string, _ map[string]any) (string, error) {
return "tel:" + value, nil
})
if !ok || err != types.ErrInvalidResponse {
t.Fatalf("consumeProductVerificationCred bad token ok %v err %v", ok, err)
}
if store.Verifications.(*testVerificationStore).consumed {
t.Fatal("bad token must not be consumed")
}
}
func TestServeIMPasswordResetVerify(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
defer func() {
store.Tenants = oldTenants
store.Verifications = oldVerifications
}()
makeTestAPIKey(t)
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", Name: "Acme", State: types.TenantStateActive}}
store.Verifications = &testVerificationStore{get: &types.VerificationCode{
TenantID: 7,
ChallengeID: "challenge-1",
Scene: "reset_password",
PhoneHash: "phone-hash",
CodeHash: hashIMSecret(7, "reset_password", "challenge-1", "phone-hash", "123456"),
State: types.VerificationStatePending,
MaxAttempts: 5,
ExpiresAt: time.Now().UTC().Add(time.Minute),
}}
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/password-reset/challenges/challenge-1/verify",
bytes.NewBufferString(`{"code":"123456"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
serveIMPasswordResetVerify(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
resp := decodeIMHTTPResponse(t, rec)
data := resp.Data.(map[string]any)
token, _ := data["reset_token"].(string)
if !strings.HasPrefix(token, "challenge-1.") {
t.Fatalf("unexpected reset token: %#v", data)
}
}
func TestServeIMPasswordResetConfirm(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldTenants := store.Tenants
oldVerifications := store.Verifications
oldUsers := store.Users
oldStore := store.Store
oldSecurity := store.Security
defer func() {
store.Tenants = oldTenants
store.Verifications = oldVerifications
store.Users = oldUsers
store.Store = oldStore
store.Security = oldSecurity
}()
makeTestAPIKey(t)
token := "reset-secret"
tokenHash := hashIMSecret(7, "reset_password", "challenge-1", "phone-hash", token)
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", Name: "Acme", State: types.TenantStateActive}}
store.Verifications = &testVerificationStore{get: &types.VerificationCode{
TenantID: 7,
ChallengeID: "challenge-1",
Scene: "reset_password",
PhoneHash: "phone-hash",
TargetUser: 42,
VerificationTokenHash: &tokenHash,
State: types.VerificationStateVerified,
ExpiresAt: time.Now().UTC().Add(time.Minute),
}}
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
users.EXPECT().GetAuthRecord(types.TenantID(7), types.Uid(42), "basic").
Return("alice", auth.LevelAuth, []byte("old"), time.Time{}, nil)
store.Users = users
basicAuth := mock_auth.NewMockAuthHandler(ctrl)
basicAuth.EXPECT().IsInitialized().Return(true)
basicAuth.EXPECT().UpdateRecord(auth.AuthContext{TenantID: 7}, gomock.Any(), []byte(":new-password")).
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil)
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
mockStore.EXPECT().GetLogicalAuthHandler("basic").Return(basicAuth)
store.Store = mockStore
security := &testSecurityStore{}
store.Security = security
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/password-reset/confirm",
bytes.NewBufferString(`{"reset_token":"challenge-1.`+token+`","new_password":"new-password"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
serveIMPasswordResetConfirm(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if !store.Verifications.(*testVerificationStore).consumed {
t.Fatal("expected reset token to be consumed")
}
if !security.bumped || security.bumpedUser != types.Uid(42) {
t.Fatalf("expected auth version bump, got %#v", security)
}
}
type testSecurityStore struct {
version int
err error
bumped bool
bumpedUser types.Uid
}
func (s *testSecurityStore) GetAuthVersion(types.TenantID, types.Uid) (int, error) {
if s.version == 0 {
return 1, s.err
}
return s.version, s.err
}
func (s *testSecurityStore) BumpAuthVersion(_ types.TenantID, uid types.Uid, _ time.Time) (int, error) {
s.bumped = true
s.bumpedUser = uid
if s.version == 0 {
s.version = 1
}
s.version++
return s.version, s.err
}