feat: add im auth verification flows
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+102
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}()
|
||||
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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:"-"`
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user