feat: add im auth verification flows
This commit is contained in:
@@ -247,6 +247,8 @@ type Rec struct {
|
|||||||
State types.ObjState
|
State types.ObjState
|
||||||
// Credential 'method:value' associated with this record.
|
// Credential 'method:value' associated with this record.
|
||||||
Credential string `json:"cred,omitempty"`
|
Credential string `json:"cred,omitempty"`
|
||||||
|
// AuthVersion invalidates previously issued login tokens when incremented.
|
||||||
|
AuthVersion int `json:"auth_version,omitempty"`
|
||||||
|
|
||||||
// Authenticator may request the server to create a new account.
|
// Authenticator may request the server to create a new account.
|
||||||
// These are the account parameters which can be used for creating the account.
|
// These are the account parameters which can be used for creating the account.
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ type authenticator struct {
|
|||||||
serialNumber int
|
serialNumber int
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenVersion = uint8(2)
|
const tokenVersion = uint8(3)
|
||||||
|
|
||||||
// tokenLayout defines positioning of various bytes in token v2.
|
// tokenLayout defines positioning of various bytes in token v3.
|
||||||
// [1:version][8:tenant][8:UID][4:expires][2:authLevel][2:serial][2:features][32:signature] = 59 bytes.
|
// [1:version][8:tenant][8:UID][4:expires][2:authLevel][2:serial][2:features][4:authVersion][32:signature] = 63 bytes.
|
||||||
type tokenLayout struct {
|
type tokenLayout struct {
|
||||||
// Token format version.
|
// Token format version.
|
||||||
Version uint8
|
Version uint8
|
||||||
@@ -42,6 +42,8 @@ type tokenLayout struct {
|
|||||||
SerialNumber uint16
|
SerialNumber uint16
|
||||||
// Bitmap with feature bits.
|
// Bitmap with feature bits.
|
||||||
Features uint16
|
Features uint16
|
||||||
|
// User-scoped auth version.
|
||||||
|
AuthVersion uint32
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init initializes the authenticator: parses the config and sets salt, serial number and lifetime.
|
// Init initializes the authenticator: parses the config and sets salt, serial number and lifetime.
|
||||||
@@ -143,13 +145,29 @@ func (ta *authenticator) Authenticate(ctx auth.AuthContext, token []byte) (*auth
|
|||||||
return nil, nil, types.ErrExpired
|
return nil, nil, types.ErrExpired
|
||||||
}
|
}
|
||||||
|
|
||||||
|
features := auth.Feature(tl.Features)
|
||||||
|
if features&auth.FeatureNoLogin == 0 {
|
||||||
|
if store.Security == nil {
|
||||||
|
return nil, nil, types.ErrInternal
|
||||||
|
}
|
||||||
|
currentVersion, err := store.Security.GetAuthVersion(types.TenantID(tl.TenantID), types.Uid(tl.Uid))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if int(tl.AuthVersion) != currentVersion {
|
||||||
|
return nil, nil, types.ErrFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &auth.Rec{
|
return &auth.Rec{
|
||||||
TenantID: types.TenantID(tl.TenantID),
|
TenantID: types.TenantID(tl.TenantID),
|
||||||
Uid: types.Uid(tl.Uid),
|
Uid: types.Uid(tl.Uid),
|
||||||
AuthLevel: auth.Level(tl.AuthLevel),
|
AuthLevel: auth.Level(tl.AuthLevel),
|
||||||
Lifetime: auth.Duration(time.Until(expires)),
|
Lifetime: auth.Duration(time.Until(expires)),
|
||||||
Features: auth.Feature(tl.Features),
|
Features: features,
|
||||||
State: types.StateUndefined}, nil, nil
|
State: types.StateUndefined,
|
||||||
|
AuthVersion: int(tl.AuthVersion),
|
||||||
|
}, nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenSecret generates a new token.
|
// GenSecret generates a new token.
|
||||||
@@ -173,6 +191,7 @@ func (ta *authenticator) GenSecret(rec *auth.Rec) ([]byte, time.Time, error) {
|
|||||||
AuthLevel: uint16(rec.AuthLevel),
|
AuthLevel: uint16(rec.AuthLevel),
|
||||||
SerialNumber: uint16(ta.serialNumber),
|
SerialNumber: uint16(ta.serialNumber),
|
||||||
Features: uint16(rec.Features),
|
Features: uint16(rec.Features),
|
||||||
|
AuthVersion: uint32(rec.AuthVersion),
|
||||||
}
|
}
|
||||||
buf := new(bytes.Buffer)
|
buf := new(bytes.Buffer)
|
||||||
binary.Write(buf, binary.LittleEndian, &tl)
|
binary.Write(buf, binary.LittleEndian, &tl)
|
||||||
|
|||||||
@@ -6,9 +6,23 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tinode/chat/server/auth"
|
"github.com/tinode/chat/server/auth"
|
||||||
|
"github.com/tinode/chat/server/store"
|
||||||
"github.com/tinode/chat/server/store/types"
|
"github.com/tinode/chat/server/store/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type testSecurityStore struct {
|
||||||
|
version int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s testSecurityStore) GetAuthVersion(types.TenantID, types.Uid) (int, error) {
|
||||||
|
return s.version, s.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s testSecurityStore) BumpAuthVersion(types.TenantID, types.Uid, time.Time) (int, error) {
|
||||||
|
return s.version + 1, s.err
|
||||||
|
}
|
||||||
|
|
||||||
func newTestAuthenticator(t *testing.T) *authenticator {
|
func newTestAuthenticator(t *testing.T) *authenticator {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
conf, err := json.Marshal(map[string]any{
|
conf, err := json.Marshal(map[string]any{
|
||||||
@@ -26,20 +40,25 @@ func newTestAuthenticator(t *testing.T) *authenticator {
|
|||||||
return ta
|
return ta
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTokenV2TenantRoundTrip(t *testing.T) {
|
func TestTokenV3TenantRoundTrip(t *testing.T) {
|
||||||
|
oldSecurity := store.Security
|
||||||
|
store.Security = testSecurityStore{version: 11}
|
||||||
|
defer func() { store.Security = oldSecurity }()
|
||||||
|
|
||||||
ta := newTestAuthenticator(t)
|
ta := newTestAuthenticator(t)
|
||||||
rec := &auth.Rec{
|
rec := &auth.Rec{
|
||||||
TenantID: 7,
|
TenantID: 7,
|
||||||
Uid: 42,
|
Uid: 42,
|
||||||
AuthLevel: auth.LevelAuth,
|
AuthLevel: auth.LevelAuth,
|
||||||
Lifetime: auth.Duration(time.Hour),
|
Lifetime: auth.Duration(time.Hour),
|
||||||
Features: auth.FeatureValidated,
|
Features: auth.FeatureValidated,
|
||||||
|
AuthVersion: 11,
|
||||||
}
|
}
|
||||||
token, _, err := ta.GenSecret(rec)
|
token, _, err := ta.GenSecret(rec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got, want := len(token), 59; got != want {
|
if got, want := len(token), 63; got != want {
|
||||||
t.Fatalf("token length: got %d, want %d", got, want)
|
t.Fatalf("token length: got %d, want %d", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +69,8 @@ func TestTokenV2TenantRoundTrip(t *testing.T) {
|
|||||||
if challenge != nil {
|
if challenge != nil {
|
||||||
t.Fatal("unexpected challenge")
|
t.Fatal("unexpected challenge")
|
||||||
}
|
}
|
||||||
if got.TenantID != rec.TenantID || got.Uid != rec.Uid || got.AuthLevel != rec.AuthLevel || got.Features != rec.Features {
|
if got.TenantID != rec.TenantID || got.Uid != rec.Uid || got.AuthLevel != rec.AuthLevel ||
|
||||||
|
got.Features != rec.Features || got.AuthVersion != rec.AuthVersion {
|
||||||
t.Fatalf("round trip mismatch: got %+v, want %+v", got, rec)
|
t.Fatalf("round trip mismatch: got %+v, want %+v", got, rec)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,10 +81,14 @@ func TestTokenV2TenantRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTokenV2RejectsWrongOrTamperedTenant(t *testing.T) {
|
func TestTokenV3RejectsWrongOrTamperedTenant(t *testing.T) {
|
||||||
|
oldSecurity := store.Security
|
||||||
|
store.Security = testSecurityStore{version: 1}
|
||||||
|
defer func() { store.Security = oldSecurity }()
|
||||||
|
|
||||||
ta := newTestAuthenticator(t)
|
ta := newTestAuthenticator(t)
|
||||||
token, _, err := ta.GenSecret(&auth.Rec{
|
token, _, err := ta.GenSecret(&auth.Rec{
|
||||||
TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth, Lifetime: auth.Duration(time.Hour),
|
TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth, Lifetime: auth.Duration(time.Hour), AuthVersion: 1,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -81,7 +105,11 @@ func TestTokenV2RejectsWrongOrTamperedTenant(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTokenV2RejectsLegacyAndMissingIdentity(t *testing.T) {
|
func TestTokenV3RejectsLegacyMissingIdentityAndStaleVersion(t *testing.T) {
|
||||||
|
oldSecurity := store.Security
|
||||||
|
store.Security = testSecurityStore{version: 2}
|
||||||
|
defer func() { store.Security = oldSecurity }()
|
||||||
|
|
||||||
ta := newTestAuthenticator(t)
|
ta := newTestAuthenticator(t)
|
||||||
if _, _, err := ta.Authenticate(auth.AuthContext{}, make([]byte, 50)); err != types.ErrMalformed {
|
if _, _, err := ta.Authenticate(auth.AuthContext{}, make([]byte, 50)); err != types.ErrMalformed {
|
||||||
t.Fatalf("legacy token: got %v, want %v", err, types.ErrMalformed)
|
t.Fatalf("legacy token: got %v, want %v", err, types.ErrMalformed)
|
||||||
@@ -92,4 +120,35 @@ func TestTokenV2RejectsLegacyAndMissingIdentity(t *testing.T) {
|
|||||||
if _, _, err := ta.GenSecret(&auth.Rec{TenantID: 7, AuthLevel: auth.LevelAuth}); err != types.ErrMalformed {
|
if _, _, err := ta.GenSecret(&auth.Rec{TenantID: 7, AuthLevel: auth.LevelAuth}); err != types.ErrMalformed {
|
||||||
t.Fatalf("missing uid: got %v, want %v", err, types.ErrMalformed)
|
t.Fatalf("missing uid: got %v, want %v", err, types.ErrMalformed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
token, _, err := ta.GenSecret(&auth.Rec{
|
||||||
|
TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth, Lifetime: auth.Duration(time.Hour), AuthVersion: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, _, err = ta.Authenticate(auth.AuthContext{TenantID: 7}, token); err != types.ErrFailed {
|
||||||
|
t.Fatalf("stale auth version: got %v, want %v", err, types.ErrFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenV3NoLoginDoesNotRequireAuthVersion(t *testing.T) {
|
||||||
|
oldSecurity := store.Security
|
||||||
|
store.Security = testSecurityStore{version: 99}
|
||||||
|
defer func() { store.Security = oldSecurity }()
|
||||||
|
|
||||||
|
ta := newTestAuthenticator(t)
|
||||||
|
token, _, err := ta.GenSecret(&auth.Rec{
|
||||||
|
TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth, Lifetime: auth.Duration(time.Hour), Features: auth.FeatureNoLogin,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, _, err := ta.Authenticate(auth.AuthContext{TenantID: 7}, token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.AuthVersion != 0 || got.Features&auth.FeatureNoLogin == 0 {
|
||||||
|
t.Fatalf("unexpected no-login token auth: %+v", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -292,4 +292,14 @@ type TenantBusinessAdapter interface {
|
|||||||
TenantPCacheUpsert(t.TenantID, string, string, bool) error
|
TenantPCacheUpsert(t.TenantID, string, string, bool) error
|
||||||
TenantPCacheDelete(t.TenantID, string) error
|
TenantPCacheDelete(t.TenantID, string) error
|
||||||
TenantPCacheExpire(t.TenantID, string, time.Time) error
|
TenantPCacheExpire(t.TenantID, string, time.Time) error
|
||||||
|
|
||||||
|
TenantVerificationCreate(t.TenantID, *t.VerificationCode) error
|
||||||
|
TenantVerificationGet(t.TenantID, string) (*t.VerificationCode, error)
|
||||||
|
TenantVerificationGetActive(t.TenantID, string, string, time.Time) (*t.VerificationCode, error)
|
||||||
|
TenantVerificationFail(t.TenantID, string, t.VerificationState) error
|
||||||
|
TenantVerificationMarkVerified(t.TenantID, string, string, time.Time) error
|
||||||
|
TenantVerificationConsume(t.TenantID, string, string, time.Time) error
|
||||||
|
|
||||||
|
TenantUserSecurityGetAuthVersion(t.TenantID, t.Uid) (int, error)
|
||||||
|
TenantUserSecurityBumpAuthVersion(t.TenantID, t.Uid, time.Time) (int, error)
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-1
@@ -41,7 +41,7 @@ type adapter struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
adpVersion = 117
|
adpVersion = 118
|
||||||
adapterName = "mysql"
|
adapterName = "mysql"
|
||||||
|
|
||||||
defaultDSN = "root:@tcp(localhost:3306)/tinode?parseTime=true"
|
defaultDSN = "root:@tcp(localhost:3306)/tinode?parseTime=true"
|
||||||
@@ -375,6 +375,34 @@ func (a *adapter) CreateDb(reset bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, err = tx.Exec(
|
||||||
|
`CREATE TABLE im_verification_codes(
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT NOT NULL,
|
||||||
|
challenge_id VARCHAR(64) NOT NULL,
|
||||||
|
scene VARCHAR(32) NOT NULL,
|
||||||
|
phone_hash CHAR(64) NOT NULL,
|
||||||
|
phone_mask VARCHAR(32) NOT NULL,
|
||||||
|
target_user BIGINT,
|
||||||
|
code_hash CHAR(64) NOT NULL,
|
||||||
|
verification_token_hash CHAR(64),
|
||||||
|
state SMALLINT NOT NULL,
|
||||||
|
attempts INT NOT NULL DEFAULT 0,
|
||||||
|
max_attempts INT NOT NULL,
|
||||||
|
expires_at DATETIME(3) NOT NULL,
|
||||||
|
resend_after DATETIME(3) NOT NULL,
|
||||||
|
verified_at DATETIME(3),
|
||||||
|
consumed_at DATETIME(3),
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY(id),
|
||||||
|
UNIQUE INDEX uk_im_verification_tenant_challenge(tenant_id, challenge_id),
|
||||||
|
INDEX idx_im_verification_tenant_scene_phone_state(tenant_id, scene, phone_hash, state, expires_at),
|
||||||
|
CONSTRAINT fk_im_verification_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
|
||||||
|
)`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if _, err = tx.Exec(
|
if _, err = tx.Exec(
|
||||||
`INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
|
`INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
|
||||||
VALUES('default','Default Tenant',NULL,?,0,0)`, t.TenantStateActive); err != nil {
|
VALUES('default','Default Tenant',NULL,?,0,0)`, t.TenantStateActive); err != nil {
|
||||||
@@ -401,6 +429,20 @@ func (a *adapter) CreateDb(reset bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, err = tx.Exec(
|
||||||
|
`CREATE TABLE im_user_security(
|
||||||
|
tenant_id BIGINT NOT NULL,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
auth_version INT NOT NULL DEFAULT 1,
|
||||||
|
password_updated_at DATETIME(3),
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY(tenant_id,user_id),
|
||||||
|
CONSTRAINT fk_im_user_security_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
|
||||||
|
)`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Indexed user tags.
|
// Indexed user tags.
|
||||||
if _, err = tx.Exec(
|
if _, err = tx.Exec(
|
||||||
`CREATE TABLE usertags(
|
`CREATE TABLE usertags(
|
||||||
@@ -935,6 +977,60 @@ func (a *adapter) UpgradeDb() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.version == 117 {
|
||||||
|
if _, err := a.db.Exec(
|
||||||
|
`CREATE TABLE im_verification_codes(
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT NOT NULL,
|
||||||
|
challenge_id VARCHAR(64) NOT NULL,
|
||||||
|
scene VARCHAR(32) NOT NULL,
|
||||||
|
phone_hash CHAR(64) NOT NULL,
|
||||||
|
phone_mask VARCHAR(32) NOT NULL,
|
||||||
|
target_user BIGINT,
|
||||||
|
code_hash CHAR(64) NOT NULL,
|
||||||
|
verification_token_hash CHAR(64),
|
||||||
|
state SMALLINT NOT NULL,
|
||||||
|
attempts INT NOT NULL DEFAULT 0,
|
||||||
|
max_attempts INT NOT NULL,
|
||||||
|
expires_at DATETIME(3) NOT NULL,
|
||||||
|
resend_after DATETIME(3) NOT NULL,
|
||||||
|
verified_at DATETIME(3),
|
||||||
|
consumed_at DATETIME(3),
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY(id),
|
||||||
|
UNIQUE INDEX uk_im_verification_tenant_challenge(tenant_id, challenge_id),
|
||||||
|
INDEX idx_im_verification_tenant_scene_phone_state(tenant_id, scene, phone_hash, state, expires_at),
|
||||||
|
CONSTRAINT fk_im_verification_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
|
||||||
|
)`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := a.db.Exec(
|
||||||
|
`CREATE TABLE im_user_security(
|
||||||
|
tenant_id BIGINT NOT NULL,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
auth_version INT NOT NULL DEFAULT 1,
|
||||||
|
password_updated_at DATETIME(3),
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY(tenant_id,user_id),
|
||||||
|
CONSTRAINT fk_im_user_security_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
|
||||||
|
)`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := a.db.Exec(
|
||||||
|
`INSERT INTO im_user_security(tenant_id,user_id,auth_version,created_at,updated_at)
|
||||||
|
SELECT tenant_id,id,1,updatedat,updatedat FROM users
|
||||||
|
ON DUPLICATE KEY UPDATE auth_version=auth_version`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bumpVersion(a, 118); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if a.version != adpVersion {
|
if a.version != adpVersion {
|
||||||
return errors.New("Failed to perform database upgrade to version " + strconv.Itoa(adpVersion) +
|
return errors.New("Failed to perform database upgrade to version " + strconv.Itoa(adpVersion) +
|
||||||
". DB is still at " + strconv.Itoa(a.version))
|
". DB is still at " + strconv.Itoa(a.version))
|
||||||
@@ -1053,6 +1149,11 @@ func (a *adapter) UserCreate(user *t.User) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if _, err = tx.Exec("INSERT INTO im_user_security(tenant_id,user_id,auth_version,created_at,updated_at) VALUES(?,?,?,?,?)",
|
||||||
|
user.TenantID, decoded_uid, 1, user.CreatedAt, user.UpdatedAt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Save user's tags to a separate table to make user findable.
|
// Save user's tags to a separate table to make user findable.
|
||||||
if err = addTenantTags(tx, user.TenantID, "usertags", "userid", decoded_uid, user.Tags, false); err != nil {
|
if err = addTenantTags(tx, user.TenantID, "usertags", "userid", decoded_uid, user.Tags, false); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -45,6 +45,32 @@ CREATE TABLE im_tenant_config(
|
|||||||
CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
|
CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE im_verification_codes(
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT NOT NULL,
|
||||||
|
challenge_id VARCHAR(64) NOT NULL,
|
||||||
|
scene VARCHAR(32) NOT NULL,
|
||||||
|
phone_hash CHAR(64) NOT NULL,
|
||||||
|
phone_mask VARCHAR(32) NOT NULL,
|
||||||
|
target_user BIGINT,
|
||||||
|
code_hash CHAR(64) NOT NULL,
|
||||||
|
verification_token_hash CHAR(64),
|
||||||
|
state SMALLINT NOT NULL,
|
||||||
|
attempts INT NOT NULL DEFAULT 0,
|
||||||
|
max_attempts INT NOT NULL,
|
||||||
|
expires_at DATETIME(3) NOT NULL,
|
||||||
|
resend_after DATETIME(3) NOT NULL,
|
||||||
|
verified_at DATETIME(3),
|
||||||
|
consumed_at DATETIME(3),
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
PRIMARY KEY(id),
|
||||||
|
UNIQUE INDEX uk_im_verification_tenant_challenge(tenant_id, challenge_id),
|
||||||
|
INDEX idx_im_verification_tenant_scene_phone_state(tenant_id, scene, phone_hash, state, expires_at),
|
||||||
|
CONSTRAINT fk_im_verification_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
|
INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
|
||||||
VALUES('default','Default Tenant',NULL,1,0,0);
|
VALUES('default','Default Tenant',NULL,1,0,0);
|
||||||
|
|
||||||
@@ -57,7 +83,7 @@ CREATE TABLE kvmeta(
|
|||||||
INDEX kvmeta_createdat_key(createdat, `key`)
|
INDEX kvmeta_createdat_key(createdat, `key`)
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO kvmeta(`key`, `value`) VALUES("version", "117");
|
INSERT INTO kvmeta(`key`, `value`) VALUES("version", "118");
|
||||||
|
|
||||||
CREATE TABLE users(
|
CREATE TABLE users(
|
||||||
id BIGINT NOT NULL,
|
id BIGINT NOT NULL,
|
||||||
@@ -76,6 +102,18 @@ CREATE TABLE users(
|
|||||||
INDEX users_lastseen_updatedat(lastseen, updatedat)
|
INDEX users_lastseen_updatedat(lastseen, updatedat)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE im_user_security(
|
||||||
|
tenant_id BIGINT NOT NULL,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
auth_version INT NOT NULL DEFAULT 1,
|
||||||
|
password_updated_at DATETIME(3),
|
||||||
|
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
PRIMARY KEY(tenant_id,user_id),
|
||||||
|
CONSTRAINT fk_im_user_security_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
# Indexed user tags.
|
# Indexed user tags.
|
||||||
CREATE TABLE usertags(
|
CREATE TABLE usertags(
|
||||||
id INT NOT NULL AUTO_INCREMENT,
|
id INT NOT NULL AUTO_INCREMENT,
|
||||||
|
|||||||
@@ -1832,3 +1832,235 @@ func (a *adapter) TenantPCacheExpire(id t.TenantID, prefix string, before time.T
|
|||||||
}
|
}
|
||||||
return a.PCacheExpire(scoped, before)
|
return a.PCacheExpire(scoped, before)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *adapter) TenantVerificationCreate(id t.TenantID, vc *t.VerificationCode) error {
|
||||||
|
if err := validTenant(id); err != nil || vc == nil || vc.TenantID != id ||
|
||||||
|
vc.ChallengeID == "" || vc.Scene == "" || vc.PhoneHash == "" || vc.CodeHash == "" {
|
||||||
|
return t.ErrMalformed
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := a.getContextForTx()
|
||||||
|
if cancel != nil {
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
tx, err := a.db.BeginTxx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
now := t.TimeNow()
|
||||||
|
_, err = tx.ExecContext(ctx,
|
||||||
|
`UPDATE im_verification_codes
|
||||||
|
SET state=?,updated_at=?
|
||||||
|
WHERE tenant_id=? AND scene=? AND phone_hash=? AND state=?`,
|
||||||
|
t.VerificationStateExpired, now, id, vc.Scene, vc.PhoneHash, t.VerificationStatePending)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
vc.CreatedAt = now
|
||||||
|
vc.UpdatedAt = now
|
||||||
|
_, err = tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO im_verification_codes
|
||||||
|
(tenant_id,challenge_id,scene,phone_hash,phone_mask,target_user,code_hash,state,attempts,max_attempts,
|
||||||
|
expires_at,resend_after,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
id, vc.ChallengeID, vc.Scene, vc.PhoneHash, vc.PhoneMask, nullableUID(vc.TargetUser), vc.CodeHash, vc.State, vc.Attempts,
|
||||||
|
vc.MaxAttempts, vc.ExpiresAt, vc.ResendAfter, vc.CreatedAt, vc.UpdatedAt)
|
||||||
|
if isDupe(err) {
|
||||||
|
return t.ErrDuplicate
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adapter) TenantVerificationGet(id t.TenantID, challengeID string) (*t.VerificationCode, error) {
|
||||||
|
if err := validTenant(id); err != nil || challengeID == "" {
|
||||||
|
return nil, t.ErrMalformed
|
||||||
|
}
|
||||||
|
ctx, cancel := a.getContext()
|
||||||
|
if cancel != nil {
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
var vc t.VerificationCode
|
||||||
|
err := a.db.GetContext(ctx, &vc,
|
||||||
|
`SELECT id,tenant_id,challenge_id,scene,phone_hash,phone_mask,IFNULL(target_user,0) AS target_user,code_hash,verification_token_hash,
|
||||||
|
state,attempts,max_attempts,expires_at,resend_after,verified_at,consumed_at,created_at,updated_at
|
||||||
|
FROM im_verification_codes
|
||||||
|
WHERE tenant_id=? AND challenge_id=? LIMIT 1`, id, challengeID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, t.ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &vc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adapter) TenantVerificationGetActive(id t.TenantID, scene, phoneHash string, now time.Time) (*t.VerificationCode, error) {
|
||||||
|
if err := validTenant(id); err != nil || scene == "" || phoneHash == "" {
|
||||||
|
return nil, t.ErrMalformed
|
||||||
|
}
|
||||||
|
ctx, cancel := a.getContext()
|
||||||
|
if cancel != nil {
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
var vc t.VerificationCode
|
||||||
|
err := a.db.GetContext(ctx, &vc,
|
||||||
|
`SELECT id,tenant_id,challenge_id,scene,phone_hash,phone_mask,IFNULL(target_user,0) AS target_user,code_hash,verification_token_hash,
|
||||||
|
state,attempts,max_attempts,expires_at,resend_after,verified_at,consumed_at,created_at,updated_at
|
||||||
|
FROM im_verification_codes
|
||||||
|
WHERE tenant_id=? AND scene=? AND phone_hash=? AND state=? AND expires_at>?
|
||||||
|
ORDER BY created_at DESC LIMIT 1`, id, scene, phoneHash, t.VerificationStatePending, now)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, t.ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &vc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adapter) TenantVerificationFail(id t.TenantID, challengeID string, state t.VerificationState) error {
|
||||||
|
if err := validTenant(id); err != nil || challengeID == "" {
|
||||||
|
return t.ErrMalformed
|
||||||
|
}
|
||||||
|
ctx, cancel := a.getContext()
|
||||||
|
if cancel != nil {
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
now := t.TimeNow()
|
||||||
|
_, err := a.db.ExecContext(ctx,
|
||||||
|
`UPDATE im_verification_codes
|
||||||
|
SET attempts=attempts+1,state=?,updated_at=?
|
||||||
|
WHERE tenant_id=? AND challenge_id=?`,
|
||||||
|
state, now, id, challengeID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adapter) TenantVerificationMarkVerified(id t.TenantID, challengeID, tokenHash string, verifiedAt time.Time) error {
|
||||||
|
if err := validTenant(id); err != nil || challengeID == "" || tokenHash == "" {
|
||||||
|
return t.ErrMalformed
|
||||||
|
}
|
||||||
|
ctx, cancel := a.getContext()
|
||||||
|
if cancel != nil {
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
_, err := a.db.ExecContext(ctx,
|
||||||
|
`UPDATE im_verification_codes
|
||||||
|
SET state=?,verification_token_hash=?,verified_at=?,updated_at=?
|
||||||
|
WHERE tenant_id=? AND challenge_id=? AND state=?`,
|
||||||
|
t.VerificationStateVerified, tokenHash, verifiedAt, verifiedAt, id, challengeID, t.VerificationStatePending)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adapter) TenantVerificationConsume(id t.TenantID, challengeID, tokenHash string, consumedAt time.Time) error {
|
||||||
|
if err := validTenant(id); err != nil || challengeID == "" || tokenHash == "" {
|
||||||
|
return t.ErrMalformed
|
||||||
|
}
|
||||||
|
ctx, cancel := a.getContext()
|
||||||
|
if cancel != nil {
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
res, err := a.db.ExecContext(ctx,
|
||||||
|
`UPDATE im_verification_codes
|
||||||
|
SET state=?,consumed_at=?,updated_at=?
|
||||||
|
WHERE tenant_id=? AND challenge_id=? AND verification_token_hash=? AND state=? AND expires_at>?`,
|
||||||
|
t.VerificationStateConsumed, consumedAt, consumedAt, id, challengeID, tokenHash,
|
||||||
|
t.VerificationStateVerified, consumedAt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||||
|
return t.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adapter) TenantUserSecurityGetAuthVersion(id t.TenantID, uid t.Uid) (int, error) {
|
||||||
|
if err := validTenant(id); err != nil || uid.IsZero() {
|
||||||
|
return 0, t.ErrMalformed
|
||||||
|
}
|
||||||
|
ctx, cancel := a.getContext()
|
||||||
|
if cancel != nil {
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
decoded := decodeUID(uid)
|
||||||
|
now := t.TimeNow()
|
||||||
|
_, err := a.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO im_user_security(tenant_id,user_id,auth_version,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?)
|
||||||
|
ON DUPLICATE KEY UPDATE auth_version=auth_version`,
|
||||||
|
id, decoded, 1, now, now)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var version int
|
||||||
|
err = a.db.GetContext(ctx, &version,
|
||||||
|
`SELECT auth_version FROM im_user_security WHERE tenant_id=? AND user_id=?`,
|
||||||
|
id, decoded)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return 0, t.ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adapter) TenantUserSecurityBumpAuthVersion(id t.TenantID, uid t.Uid, updatedAt time.Time) (int, error) {
|
||||||
|
if err := validTenant(id); err != nil || uid.IsZero() {
|
||||||
|
return 0, t.ErrMalformed
|
||||||
|
}
|
||||||
|
if updatedAt.IsZero() {
|
||||||
|
updatedAt = t.TimeNow()
|
||||||
|
}
|
||||||
|
ctx, cancel := a.getContextForTx()
|
||||||
|
if cancel != nil {
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
tx, err := a.db.BeginTxx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
decoded := decodeUID(uid)
|
||||||
|
if _, err = tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO im_user_security(tenant_id,user_id,auth_version,password_updated_at,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
auth_version=auth_version+1,
|
||||||
|
password_updated_at=VALUES(password_updated_at),
|
||||||
|
updated_at=VALUES(updated_at)`,
|
||||||
|
id, decoded, 2, updatedAt, updatedAt, updatedAt); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var version int
|
||||||
|
if err = tx.GetContext(ctx, &version,
|
||||||
|
`SELECT auth_version FROM im_user_security WHERE tenant_id=? AND user_id=?`,
|
||||||
|
id, decoded); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableUID(uid t.Uid) any {
|
||||||
|
if uid.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return store.DecodeUid(uid)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
// Handle websocket clients.
|
||||||
mux.HandleFunc(config.ApiPath+"v0/channels", serveWebSocket)
|
mux.HandleFunc(config.ApiPath+"v0/channels", serveWebSocket)
|
||||||
|
// Product HTTP API.
|
||||||
|
mux.HandleFunc(config.ApiPath+"v1/im/tenants/resolve", serveIMTenantResolve)
|
||||||
|
mux.HandleFunc(config.ApiPath+"v1/im/auth/verification-codes", serveIMVerificationCodes)
|
||||||
|
mux.HandleFunc(config.ApiPath+"v1/im/auth/verification-codes/", serveIMVerificationCodeVerify)
|
||||||
|
mux.HandleFunc(config.ApiPath+"v1/im/auth/password-reset/challenges/", serveIMPasswordResetVerify)
|
||||||
|
mux.HandleFunc(config.ApiPath+"v1/im/auth/password-reset/confirm", serveIMPasswordResetConfirm)
|
||||||
// Handle long polling clients. Enable compression.
|
// Handle long polling clients. Enable compression.
|
||||||
mux.Handle(config.ApiPath+"v0/channels/lp", gh.CompressHandler(http.HandlerFunc(serveLongPoll)))
|
mux.Handle(config.ApiPath+"v0/channels/lp", gh.CompressHandler(http.HandlerFunc(serveLongPoll)))
|
||||||
if config.Media != nil {
|
if config.Media != nil {
|
||||||
|
|||||||
@@ -1175,6 +1175,18 @@ func (s *Session) onLogin(msgID string, timestamp time.Time, rec *auth.Rec, miss
|
|||||||
|
|
||||||
// Check if the token is suitable for session authentication.
|
// Check if the token is suitable for session authentication.
|
||||||
if features&auth.FeatureNoLogin == 0 {
|
if features&auth.FeatureNoLogin == 0 {
|
||||||
|
if store.Security == nil {
|
||||||
|
logs.Warn.Println("s.onLogin: user security store is unavailable", s.sid)
|
||||||
|
reply = decodeStoreError(types.ErrInternal, msgID, timestamp, nil)
|
||||||
|
return reply
|
||||||
|
}
|
||||||
|
authVersion, err := store.Security.GetAuthVersion(s.tenantID, rec.Uid)
|
||||||
|
if err != nil {
|
||||||
|
logs.Warn.Println("s.onLogin: failed to read auth version", rec.Uid, err, s.sid)
|
||||||
|
reply = decodeStoreError(err, msgID, timestamp, nil)
|
||||||
|
return reply
|
||||||
|
}
|
||||||
|
rec.AuthVersion = authVersion
|
||||||
// Authenticate the session.
|
// Authenticate the session.
|
||||||
s.uid = rec.Uid
|
s.uid = rec.Uid
|
||||||
s.authLvl = rec.AuthLevel
|
s.authLvl = rec.AuthLevel
|
||||||
|
|||||||
@@ -226,9 +226,12 @@ func TestDispatchLogin(t *testing.T) {
|
|||||||
aa := mock_auth.NewMockAuthHandler(ctrl)
|
aa := mock_auth.NewMockAuthHandler(ctrl)
|
||||||
|
|
||||||
uid := types.Uid(1)
|
uid := types.Uid(1)
|
||||||
|
oldSecurity := store.Security
|
||||||
store.Store = ss
|
store.Store = ss
|
||||||
|
store.Security = &testSecurityStore{version: 1}
|
||||||
defer func() {
|
defer func() {
|
||||||
store.Store = nil
|
store.Store = nil
|
||||||
|
store.Security = oldSecurity
|
||||||
ctrl.Finish()
|
ctrl.Finish()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -1194,11 +1197,14 @@ func TestDispatchAccNew(t *testing.T) {
|
|||||||
aa := mock_auth.NewMockAuthHandler(ctrl)
|
aa := mock_auth.NewMockAuthHandler(ctrl)
|
||||||
|
|
||||||
uid := types.Uid(1)
|
uid := types.Uid(1)
|
||||||
|
oldSecurity := store.Security
|
||||||
store.Store = ss
|
store.Store = ss
|
||||||
store.Users = uu
|
store.Users = uu
|
||||||
|
store.Security = &testSecurityStore{version: 1}
|
||||||
defer func() {
|
defer func() {
|
||||||
store.Store = nil
|
store.Store = nil
|
||||||
store.Users = nil
|
store.Users = nil
|
||||||
|
store.Security = oldSecurity
|
||||||
ctrl.Finish()
|
ctrl.Finish()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -1449,6 +1449,100 @@ type pcacheMapper struct{}
|
|||||||
|
|
||||||
var PCache PersistentCacheInterface
|
var PCache PersistentCacheInterface
|
||||||
|
|
||||||
|
// VerificationPersistenceInterface defines storage for product verification challenges.
|
||||||
|
type VerificationPersistenceInterface interface {
|
||||||
|
Create(tenantID types.TenantID, vc *types.VerificationCode) error
|
||||||
|
Get(tenantID types.TenantID, challengeID string) (*types.VerificationCode, error)
|
||||||
|
GetActive(tenantID types.TenantID, scene, phoneHash string, now time.Time) (*types.VerificationCode, error)
|
||||||
|
Fail(tenantID types.TenantID, challengeID string, state types.VerificationState) error
|
||||||
|
MarkVerified(tenantID types.TenantID, challengeID, tokenHash string, verifiedAt time.Time) error
|
||||||
|
Consume(tenantID types.TenantID, challengeID, tokenHash string, consumedAt time.Time) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type verificationMapper struct{}
|
||||||
|
|
||||||
|
// Verifications is a singleton for product verification challenges.
|
||||||
|
var Verifications VerificationPersistenceInterface
|
||||||
|
|
||||||
|
// UserSecurityPersistenceInterface defines user security state used to
|
||||||
|
// invalidate previously issued login tokens.
|
||||||
|
type UserSecurityPersistenceInterface interface {
|
||||||
|
GetAuthVersion(tenantID types.TenantID, uid types.Uid) (int, error)
|
||||||
|
BumpAuthVersion(tenantID types.TenantID, uid types.Uid, updatedAt time.Time) (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type userSecurityMapper struct{}
|
||||||
|
|
||||||
|
// Security is a singleton for user security metadata.
|
||||||
|
var Security UserSecurityPersistenceInterface
|
||||||
|
|
||||||
|
func (userSecurityMapper) GetAuthVersion(tenantID types.TenantID, uid types.Uid) (int, error) {
|
||||||
|
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return tenantAdp.TenantUserSecurityGetAuthVersion(tenantID, uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (userSecurityMapper) BumpAuthVersion(tenantID types.TenantID, uid types.Uid, updatedAt time.Time) (int, error) {
|
||||||
|
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return tenantAdp.TenantUserSecurityBumpAuthVersion(tenantID, uid, updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (verificationMapper) Create(tenantID types.TenantID, vc *types.VerificationCode) error {
|
||||||
|
if vc == nil || vc.TenantID != tenantID {
|
||||||
|
return types.ErrMalformed
|
||||||
|
}
|
||||||
|
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tenantAdp.TenantVerificationCreate(tenantID, vc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (verificationMapper) Get(tenantID types.TenantID, challengeID string) (*types.VerificationCode, error) {
|
||||||
|
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return tenantAdp.TenantVerificationGet(tenantID, challengeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (verificationMapper) GetActive(tenantID types.TenantID, scene, phoneHash string, now time.Time) (*types.VerificationCode, error) {
|
||||||
|
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return tenantAdp.TenantVerificationGetActive(tenantID, scene, phoneHash, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (verificationMapper) Fail(tenantID types.TenantID, challengeID string, state types.VerificationState) error {
|
||||||
|
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tenantAdp.TenantVerificationFail(tenantID, challengeID, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (verificationMapper) MarkVerified(tenantID types.TenantID, challengeID, tokenHash string, verifiedAt time.Time) error {
|
||||||
|
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tenantAdp.TenantVerificationMarkVerified(tenantID, challengeID, tokenHash, verifiedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (verificationMapper) Consume(tenantID types.TenantID, challengeID, tokenHash string, consumedAt time.Time) error {
|
||||||
|
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tenantAdp.TenantVerificationConsume(tenantID, challengeID, tokenHash, consumedAt)
|
||||||
|
}
|
||||||
|
|
||||||
// Get reads a persistent cache entry.
|
// Get reads a persistent cache entry.
|
||||||
func (pcacheMapper) Get(tenantID types.TenantID, key string) (string, error) {
|
func (pcacheMapper) Get(tenantID types.TenantID, key string) (string, error) {
|
||||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||||
@@ -1499,4 +1593,6 @@ func init() {
|
|||||||
Devices = deviceMapper{}
|
Devices = deviceMapper{}
|
||||||
Files = fileMapper{}
|
Files = fileMapper{}
|
||||||
PCache = pcacheMapper{}
|
PCache = pcacheMapper{}
|
||||||
|
Verifications = verificationMapper{}
|
||||||
|
Security = userSecurityMapper{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"container/heap"
|
"container/heap"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"slices"
|
"slices"
|
||||||
@@ -383,6 +384,19 @@ func addCreds(tenantID types.TenantID, uid types.Uid, creds []MsgCredClient, ext
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cr.Response != "" {
|
||||||
|
if value, ok, err := consumeProductVerificationCred(tenantID, uid, cr, vld.PreCheck); ok || err != nil {
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
validated = append(validated, cr.Method)
|
||||||
|
if globals.validators[cr.Method].addToTags {
|
||||||
|
extraTags = append(extraTags, cr.Method+":"+value)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
isNew, err := vld.Request(tenantID, uid, cr.Value, lang, cr.Response, tmpToken)
|
isNew, err := vld.Request(tenantID, uid, cr.Value, lang, cr.Response, tmpToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -413,6 +427,52 @@ func addCreds(tenantID types.TenantID, uid types.Uid, creds []MsgCredClient, ext
|
|||||||
return validated, extraTags, nil
|
return validated, extraTags, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func consumeProductVerificationCred(tenantID types.TenantID, uid types.Uid, cr *MsgCredClient,
|
||||||
|
preCheck func(string, map[string]any) (string, error)) (string, bool, error) {
|
||||||
|
challengeID, _ := cr.Params["challenge_id"].(string)
|
||||||
|
challengeID = strings.TrimSpace(challengeID)
|
||||||
|
if challengeID == "" {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
if store.Verifications == nil {
|
||||||
|
return "", true, types.ErrInternal
|
||||||
|
}
|
||||||
|
normalized, err := preCheck(cr.Value, cr.Params)
|
||||||
|
if err != nil {
|
||||||
|
return "", true, err
|
||||||
|
}
|
||||||
|
value := strings.TrimPrefix(normalized, cr.Method+":")
|
||||||
|
vc, err := store.Verifications.Get(tenantID, challengeID)
|
||||||
|
if err != nil {
|
||||||
|
return "", true, err
|
||||||
|
}
|
||||||
|
if vc.Scene != "register" || vc.State != types.VerificationStateVerified || vc.PhoneHash == "" {
|
||||||
|
return "", true, types.ErrInvalidResponse
|
||||||
|
}
|
||||||
|
tokenHash := hashIMSecret(tenantID, vc.Scene, vc.ChallengeID, vc.PhoneHash, cr.Response)
|
||||||
|
if vc.VerificationTokenHash == nil || *vc.VerificationTokenHash != tokenHash {
|
||||||
|
return "", true, types.ErrInvalidResponse
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Round(time.Millisecond)
|
||||||
|
if !now.Before(vc.ExpiresAt) {
|
||||||
|
return "", true, types.ErrExpired
|
||||||
|
}
|
||||||
|
if err := store.Verifications.Consume(tenantID, vc.ChallengeID, tokenHash, now); err != nil {
|
||||||
|
return "", true, err
|
||||||
|
}
|
||||||
|
_, err = store.Users.UpsertCred(tenantID, &types.Credential{
|
||||||
|
ObjHeader: types.ObjHeader{CreatedAt: now, UpdatedAt: now},
|
||||||
|
User: uid.String(),
|
||||||
|
Method: cr.Method,
|
||||||
|
Value: value,
|
||||||
|
Done: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", true, err
|
||||||
|
}
|
||||||
|
return value, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// validatedCreds returns the list of validated credentials including those validated in this call.
|
// validatedCreds returns the list of validated credentials including those validated in this call.
|
||||||
// Returns all validated methods including those validated earlier and now.
|
// Returns all validated methods including those validated earlier and now.
|
||||||
// Returns either a full set of tags or nil for tags if tags are unchanged.
|
// Returns either a full set of tags or nil for tags if tags are unchanged.
|
||||||
|
|||||||
@@ -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