diff --git a/server/auth/auth.go b/server/auth/auth.go index 597d1ef..fd22df0 100644 --- a/server/auth/auth.go +++ b/server/auth/auth.go @@ -247,6 +247,8 @@ type Rec struct { State types.ObjState // Credential 'method:value' associated with this record. 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. // These are the account parameters which can be used for creating the account. diff --git a/server/auth/token/auth_token.go b/server/auth/token/auth_token.go index 75d9a35..8c28530 100644 --- a/server/auth/token/auth_token.go +++ b/server/auth/token/auth_token.go @@ -23,10 +23,10 @@ type authenticator struct { serialNumber int } -const tokenVersion = uint8(2) +const tokenVersion = uint8(3) -// tokenLayout defines positioning of various bytes in token v2. -// [1:version][8:tenant][8:UID][4:expires][2:authLevel][2:serial][2:features][32:signature] = 59 bytes. +// tokenLayout defines positioning of various bytes in token v3. +// [1:version][8:tenant][8:UID][4:expires][2:authLevel][2:serial][2:features][4:authVersion][32:signature] = 63 bytes. type tokenLayout struct { // Token format version. Version uint8 @@ -42,6 +42,8 @@ type tokenLayout struct { SerialNumber uint16 // Bitmap with feature bits. Features uint16 + // User-scoped auth version. + AuthVersion uint32 } // 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 } + 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{ - TenantID: types.TenantID(tl.TenantID), - Uid: types.Uid(tl.Uid), - AuthLevel: auth.Level(tl.AuthLevel), - Lifetime: auth.Duration(time.Until(expires)), - Features: auth.Feature(tl.Features), - State: types.StateUndefined}, nil, nil + TenantID: types.TenantID(tl.TenantID), + Uid: types.Uid(tl.Uid), + AuthLevel: auth.Level(tl.AuthLevel), + Lifetime: auth.Duration(time.Until(expires)), + Features: features, + State: types.StateUndefined, + AuthVersion: int(tl.AuthVersion), + }, nil, nil } // GenSecret generates a new token. @@ -173,6 +191,7 @@ func (ta *authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) { AuthLevel: uint16(rec.AuthLevel), SerialNumber: uint16(ta.serialNumber), Features: uint16(rec.Features), + AuthVersion: uint32(rec.AuthVersion), } buf := new(bytes.Buffer) binary.Write(buf, binary.LittleEndian, &tl) diff --git a/server/auth/token/auth_token_test.go b/server/auth/token/auth_token_test.go index d38bebb..7aa5443 100644 --- a/server/auth/token/auth_token_test.go +++ b/server/auth/token/auth_token_test.go @@ -6,9 +6,23 @@ import ( "time" "github.com/tinode/chat/server/auth" + "github.com/tinode/chat/server/store" "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 { t.Helper() conf, err := json.Marshal(map[string]any{ @@ -26,20 +40,25 @@ func newTestAuthenticator(t *testing.T) *authenticator { 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) rec := &auth.Rec{ - TenantID: 7, - Uid: 42, - AuthLevel: auth.LevelAuth, - Lifetime: auth.Duration(time.Hour), - Features: auth.FeatureValidated, + TenantID: 7, + Uid: 42, + AuthLevel: auth.LevelAuth, + Lifetime: auth.Duration(time.Hour), + Features: auth.FeatureValidated, + AuthVersion: 11, } token, _, err := ta.GenSecret(rec) if err != nil { 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) } @@ -50,7 +69,8 @@ func TestTokenV2TenantRoundTrip(t *testing.T) { if challenge != nil { 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) } @@ -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) 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 { 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) if _, _, err := ta.Authenticate(auth.AuthContext{}, make([]byte, 50)); 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 { 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) + } } diff --git a/server/db/adapter.go b/server/db/adapter.go index 3f41f2b..1e78285 100644 --- a/server/db/adapter.go +++ b/server/db/adapter.go @@ -292,4 +292,14 @@ type TenantBusinessAdapter interface { TenantPCacheUpsert(t.TenantID, string, string, bool) error TenantPCacheDelete(t.TenantID, string) 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) } diff --git a/server/db/mysql/adapter.go b/server/db/mysql/adapter.go index 97db350..1138c28 100644 --- a/server/db/mysql/adapter.go +++ b/server/db/mysql/adapter.go @@ -41,7 +41,7 @@ type adapter struct { } const ( - adpVersion = 117 + adpVersion = 118 adapterName = "mysql" defaultDSN = "root:@tcp(localhost:3306)/tinode?parseTime=true" @@ -375,6 +375,34 @@ func (a *adapter) CreateDb(reset bool) error { 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( `INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by) VALUES('default','Default Tenant',NULL,?,0,0)`, t.TenantStateActive); err != nil { @@ -401,6 +429,20 @@ func (a *adapter) CreateDb(reset bool) error { 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. if _, err = tx.Exec( `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 { return errors.New("Failed to perform database upgrade to version " + strconv.Itoa(adpVersion) + ". DB is still at " + strconv.Itoa(a.version)) @@ -1053,6 +1149,11 @@ func (a *adapter) UserCreate(user *t.User) error { 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. if err = addTenantTags(tx, user.TenantID, "usertags", "userid", decoded_uid, user.Tags, false); err != nil { return err diff --git a/server/db/mysql/schema.sql b/server/db/mysql/schema.sql index c11df06..3914df1 100644 --- a/server/db/mysql/schema.sql +++ b/server/db/mysql/schema.sql @@ -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 ); +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) VALUES('default','Default Tenant',NULL,1,0,0); @@ -57,7 +83,7 @@ CREATE TABLE kvmeta( 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( id BIGINT NOT NULL, @@ -76,6 +102,18 @@ CREATE TABLE users( 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. CREATE TABLE usertags( id INT NOT NULL AUTO_INCREMENT, diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index 0acaa74..d94858e 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -1832,3 +1832,235 @@ func (a *adapter) TenantPCacheExpire(id t.TenantID, prefix string, before time.T } 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) +} diff --git a/server/im_http.go b/server/im_http.go new file mode 100644 index 0000000..4d241ef --- /dev/null +++ b/server/im_http.go @@ -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) +} diff --git a/server/im_http_test.go b/server/im_http_test.go new file mode 100644 index 0000000..d9fe1b9 --- /dev/null +++ b/server/im_http_test.go @@ -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 +} diff --git a/server/main.go b/server/main.go index c8d24f5..3e316f9 100644 --- a/server/main.go +++ b/server/main.go @@ -776,6 +776,12 @@ func main() { // Handle websocket clients. 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. mux.Handle(config.ApiPath+"v0/channels/lp", gh.CompressHandler(http.HandlerFunc(serveLongPoll))) if config.Media != nil { diff --git a/server/session.go b/server/session.go index bcb91c1..6ffd3cd 100644 --- a/server/session.go +++ b/server/session.go @@ -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. 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. s.uid = rec.Uid s.authLvl = rec.AuthLevel diff --git a/server/session_test.go b/server/session_test.go index d2bdb82..df491be 100644 --- a/server/session_test.go +++ b/server/session_test.go @@ -226,9 +226,12 @@ func TestDispatchLogin(t *testing.T) { aa := mock_auth.NewMockAuthHandler(ctrl) uid := types.Uid(1) + oldSecurity := store.Security store.Store = ss + store.Security = &testSecurityStore{version: 1} defer func() { store.Store = nil + store.Security = oldSecurity ctrl.Finish() }() @@ -1194,11 +1197,14 @@ func TestDispatchAccNew(t *testing.T) { aa := mock_auth.NewMockAuthHandler(ctrl) uid := types.Uid(1) + oldSecurity := store.Security store.Store = ss store.Users = uu + store.Security = &testSecurityStore{version: 1} defer func() { store.Store = nil store.Users = nil + store.Security = oldSecurity ctrl.Finish() }() diff --git a/server/store/store.go b/server/store/store.go index ad26e63..c4cc6c2 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -1449,6 +1449,100 @@ type pcacheMapper struct{} 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. func (pcacheMapper) Get(tenantID types.TenantID, key string) (string, error) { tenantAdp, err := tenantBusinessAdapter(tenantID) @@ -1499,4 +1593,6 @@ func init() { Devices = deviceMapper{} Files = fileMapper{} PCache = pcacheMapper{} + Verifications = verificationMapper{} + Security = userSecurityMapper{} } diff --git a/server/store/types/verification.go b/server/store/types/verification.go new file mode 100644 index 0000000..88750f4 --- /dev/null +++ b/server/store/types/verification.go @@ -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:"-"` +} diff --git a/server/user.go b/server/user.go index 25aabe7..57165f1 100644 --- a/server/user.go +++ b/server/user.go @@ -3,6 +3,7 @@ package main import ( "container/heap" "math/rand" + "strings" "time" "slices" @@ -383,6 +384,19 @@ func addCreds(tenantID types.TenantID, uid types.Uid, creds []MsgCredClient, ext 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) if err != nil { return nil, nil, err @@ -413,6 +427,52 @@ func addCreds(tenantID types.TenantID, uid types.Uid, creds []MsgCredClient, ext 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. // 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. diff --git a/server/user_verification_test.go b/server/user_verification_test.go new file mode 100644 index 0000000..3df8ab5 --- /dev/null +++ b/server/user_verification_test.go @@ -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 +}