feat: add im auth verification flows
This commit is contained in:
@@ -292,4 +292,14 @@ type TenantBusinessAdapter interface {
|
||||
TenantPCacheUpsert(t.TenantID, string, string, bool) error
|
||||
TenantPCacheDelete(t.TenantID, string) error
|
||||
TenantPCacheExpire(t.TenantID, string, time.Time) error
|
||||
|
||||
TenantVerificationCreate(t.TenantID, *t.VerificationCode) error
|
||||
TenantVerificationGet(t.TenantID, string) (*t.VerificationCode, error)
|
||||
TenantVerificationGetActive(t.TenantID, string, string, time.Time) (*t.VerificationCode, error)
|
||||
TenantVerificationFail(t.TenantID, string, t.VerificationState) error
|
||||
TenantVerificationMarkVerified(t.TenantID, string, string, time.Time) error
|
||||
TenantVerificationConsume(t.TenantID, string, string, time.Time) error
|
||||
|
||||
TenantUserSecurityGetAuthVersion(t.TenantID, t.Uid) (int, error)
|
||||
TenantUserSecurityBumpAuthVersion(t.TenantID, t.Uid, time.Time) (int, error)
|
||||
}
|
||||
|
||||
+102
-1
@@ -41,7 +41,7 @@ type adapter struct {
|
||||
}
|
||||
|
||||
const (
|
||||
adpVersion = 117
|
||||
adpVersion = 118
|
||||
adapterName = "mysql"
|
||||
|
||||
defaultDSN = "root:@tcp(localhost:3306)/tinode?parseTime=true"
|
||||
@@ -375,6 +375,34 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = tx.Exec(
|
||||
`CREATE TABLE im_verification_codes(
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
challenge_id VARCHAR(64) NOT NULL,
|
||||
scene VARCHAR(32) NOT NULL,
|
||||
phone_hash CHAR(64) NOT NULL,
|
||||
phone_mask VARCHAR(32) NOT NULL,
|
||||
target_user BIGINT,
|
||||
code_hash CHAR(64) NOT NULL,
|
||||
verification_token_hash CHAR(64),
|
||||
state SMALLINT NOT NULL,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
max_attempts INT NOT NULL,
|
||||
expires_at DATETIME(3) NOT NULL,
|
||||
resend_after DATETIME(3) NOT NULL,
|
||||
verified_at DATETIME(3),
|
||||
consumed_at DATETIME(3),
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE INDEX uk_im_verification_tenant_challenge(tenant_id, challenge_id),
|
||||
INDEX idx_im_verification_tenant_scene_phone_state(tenant_id, scene, phone_hash, state, expires_at),
|
||||
CONSTRAINT fk_im_verification_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = tx.Exec(
|
||||
`INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
|
||||
VALUES('default','Default Tenant',NULL,?,0,0)`, t.TenantStateActive); err != nil {
|
||||
@@ -401,6 +429,20 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = tx.Exec(
|
||||
`CREATE TABLE im_user_security(
|
||||
tenant_id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
auth_version INT NOT NULL DEFAULT 1,
|
||||
password_updated_at DATETIME(3),
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(tenant_id,user_id),
|
||||
CONSTRAINT fk_im_user_security_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Indexed user tags.
|
||||
if _, err = tx.Exec(
|
||||
`CREATE TABLE usertags(
|
||||
@@ -935,6 +977,60 @@ func (a *adapter) UpgradeDb() error {
|
||||
}
|
||||
}
|
||||
|
||||
if a.version == 117 {
|
||||
if _, err := a.db.Exec(
|
||||
`CREATE TABLE im_verification_codes(
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
challenge_id VARCHAR(64) NOT NULL,
|
||||
scene VARCHAR(32) NOT NULL,
|
||||
phone_hash CHAR(64) NOT NULL,
|
||||
phone_mask VARCHAR(32) NOT NULL,
|
||||
target_user BIGINT,
|
||||
code_hash CHAR(64) NOT NULL,
|
||||
verification_token_hash CHAR(64),
|
||||
state SMALLINT NOT NULL,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
max_attempts INT NOT NULL,
|
||||
expires_at DATETIME(3) NOT NULL,
|
||||
resend_after DATETIME(3) NOT NULL,
|
||||
verified_at DATETIME(3),
|
||||
consumed_at DATETIME(3),
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE INDEX uk_im_verification_tenant_challenge(tenant_id, challenge_id),
|
||||
INDEX idx_im_verification_tenant_scene_phone_state(tenant_id, scene, phone_hash, state, expires_at),
|
||||
CONSTRAINT fk_im_verification_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := a.db.Exec(
|
||||
`CREATE TABLE im_user_security(
|
||||
tenant_id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
auth_version INT NOT NULL DEFAULT 1,
|
||||
password_updated_at DATETIME(3),
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(tenant_id,user_id),
|
||||
CONSTRAINT fk_im_user_security_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.Exec(
|
||||
`INSERT INTO im_user_security(tenant_id,user_id,auth_version,created_at,updated_at)
|
||||
SELECT tenant_id,id,1,updatedat,updatedat FROM users
|
||||
ON DUPLICATE KEY UPDATE auth_version=auth_version`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := bumpVersion(a, 118); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if a.version != adpVersion {
|
||||
return errors.New("Failed to perform database upgrade to version " + strconv.Itoa(adpVersion) +
|
||||
". DB is still at " + strconv.Itoa(a.version))
|
||||
@@ -1053,6 +1149,11 @@ func (a *adapter) UserCreate(user *t.User) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = tx.Exec("INSERT INTO im_user_security(tenant_id,user_id,auth_version,created_at,updated_at) VALUES(?,?,?,?,?)",
|
||||
user.TenantID, decoded_uid, 1, user.CreatedAt, user.UpdatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Save user's tags to a separate table to make user findable.
|
||||
if err = addTenantTags(tx, user.TenantID, "usertags", "userid", decoded_uid, user.Tags, false); err != nil {
|
||||
return err
|
||||
|
||||
@@ -45,6 +45,32 @@ CREATE TABLE im_tenant_config(
|
||||
CONSTRAINT fk_im_tenant_config_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE im_verification_codes(
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
challenge_id VARCHAR(64) NOT NULL,
|
||||
scene VARCHAR(32) NOT NULL,
|
||||
phone_hash CHAR(64) NOT NULL,
|
||||
phone_mask VARCHAR(32) NOT NULL,
|
||||
target_user BIGINT,
|
||||
code_hash CHAR(64) NOT NULL,
|
||||
verification_token_hash CHAR(64),
|
||||
state SMALLINT NOT NULL,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
max_attempts INT NOT NULL,
|
||||
expires_at DATETIME(3) NOT NULL,
|
||||
resend_after DATETIME(3) NOT NULL,
|
||||
verified_at DATETIME(3),
|
||||
consumed_at DATETIME(3),
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE INDEX uk_im_verification_tenant_challenge(tenant_id, challenge_id),
|
||||
INDEX idx_im_verification_tenant_scene_phone_state(tenant_id, scene, phone_hash, state, expires_at),
|
||||
CONSTRAINT fk_im_verification_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO im_tenant(code,name,tenant_desc,state,created_by,updated_by)
|
||||
VALUES('default','Default Tenant',NULL,1,0,0);
|
||||
|
||||
@@ -57,7 +83,7 @@ CREATE TABLE kvmeta(
|
||||
INDEX kvmeta_createdat_key(createdat, `key`)
|
||||
);
|
||||
|
||||
INSERT INTO kvmeta(`key`, `value`) VALUES("version", "117");
|
||||
INSERT INTO kvmeta(`key`, `value`) VALUES("version", "118");
|
||||
|
||||
CREATE TABLE users(
|
||||
id BIGINT NOT NULL,
|
||||
@@ -76,6 +102,18 @@ CREATE TABLE users(
|
||||
INDEX users_lastseen_updatedat(lastseen, updatedat)
|
||||
);
|
||||
|
||||
CREATE TABLE im_user_security(
|
||||
tenant_id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
auth_version INT NOT NULL DEFAULT 1,
|
||||
password_updated_at DATETIME(3),
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
|
||||
PRIMARY KEY(tenant_id,user_id),
|
||||
CONSTRAINT fk_im_user_security_tenant FOREIGN KEY(tenant_id) REFERENCES im_tenant(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
# Indexed user tags.
|
||||
CREATE TABLE usertags(
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
|
||||
@@ -1832,3 +1832,235 @@ func (a *adapter) TenantPCacheExpire(id t.TenantID, prefix string, before time.T
|
||||
}
|
||||
return a.PCacheExpire(scoped, before)
|
||||
}
|
||||
|
||||
func (a *adapter) TenantVerificationCreate(id t.TenantID, vc *t.VerificationCode) error {
|
||||
if err := validTenant(id); err != nil || vc == nil || vc.TenantID != id ||
|
||||
vc.ChallengeID == "" || vc.Scene == "" || vc.PhoneHash == "" || vc.CodeHash == "" {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
|
||||
ctx, cancel := a.getContextForTx()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
tx, err := a.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
now := t.TimeNow()
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`UPDATE im_verification_codes
|
||||
SET state=?,updated_at=?
|
||||
WHERE tenant_id=? AND scene=? AND phone_hash=? AND state=?`,
|
||||
t.VerificationStateExpired, now, id, vc.Scene, vc.PhoneHash, t.VerificationStatePending)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vc.CreatedAt = now
|
||||
vc.UpdatedAt = now
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`INSERT INTO im_verification_codes
|
||||
(tenant_id,challenge_id,scene,phone_hash,phone_mask,target_user,code_hash,state,attempts,max_attempts,
|
||||
expires_at,resend_after,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
id, vc.ChallengeID, vc.Scene, vc.PhoneHash, vc.PhoneMask, nullableUID(vc.TargetUser), vc.CodeHash, vc.State, vc.Attempts,
|
||||
vc.MaxAttempts, vc.ExpiresAt, vc.ResendAfter, vc.CreatedAt, vc.UpdatedAt)
|
||||
if isDupe(err) {
|
||||
return t.ErrDuplicate
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (a *adapter) TenantVerificationGet(id t.TenantID, challengeID string) (*t.VerificationCode, error) {
|
||||
if err := validTenant(id); err != nil || challengeID == "" {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
var vc t.VerificationCode
|
||||
err := a.db.GetContext(ctx, &vc,
|
||||
`SELECT id,tenant_id,challenge_id,scene,phone_hash,phone_mask,IFNULL(target_user,0) AS target_user,code_hash,verification_token_hash,
|
||||
state,attempts,max_attempts,expires_at,resend_after,verified_at,consumed_at,created_at,updated_at
|
||||
FROM im_verification_codes
|
||||
WHERE tenant_id=? AND challenge_id=? LIMIT 1`, id, challengeID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &vc, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantVerificationGetActive(id t.TenantID, scene, phoneHash string, now time.Time) (*t.VerificationCode, error) {
|
||||
if err := validTenant(id); err != nil || scene == "" || phoneHash == "" {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
var vc t.VerificationCode
|
||||
err := a.db.GetContext(ctx, &vc,
|
||||
`SELECT id,tenant_id,challenge_id,scene,phone_hash,phone_mask,IFNULL(target_user,0) AS target_user,code_hash,verification_token_hash,
|
||||
state,attempts,max_attempts,expires_at,resend_after,verified_at,consumed_at,created_at,updated_at
|
||||
FROM im_verification_codes
|
||||
WHERE tenant_id=? AND scene=? AND phone_hash=? AND state=? AND expires_at>?
|
||||
ORDER BY created_at DESC LIMIT 1`, id, scene, phoneHash, t.VerificationStatePending, now)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &vc, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantVerificationFail(id t.TenantID, challengeID string, state t.VerificationState) error {
|
||||
if err := validTenant(id); err != nil || challengeID == "" {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
now := t.TimeNow()
|
||||
_, err := a.db.ExecContext(ctx,
|
||||
`UPDATE im_verification_codes
|
||||
SET attempts=attempts+1,state=?,updated_at=?
|
||||
WHERE tenant_id=? AND challenge_id=?`,
|
||||
state, now, id, challengeID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *adapter) TenantVerificationMarkVerified(id t.TenantID, challengeID, tokenHash string, verifiedAt time.Time) error {
|
||||
if err := validTenant(id); err != nil || challengeID == "" || tokenHash == "" {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx,
|
||||
`UPDATE im_verification_codes
|
||||
SET state=?,verification_token_hash=?,verified_at=?,updated_at=?
|
||||
WHERE tenant_id=? AND challenge_id=? AND state=?`,
|
||||
t.VerificationStateVerified, tokenHash, verifiedAt, verifiedAt, id, challengeID, t.VerificationStatePending)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *adapter) TenantVerificationConsume(id t.TenantID, challengeID, tokenHash string, consumedAt time.Time) error {
|
||||
if err := validTenant(id); err != nil || challengeID == "" || tokenHash == "" {
|
||||
return t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
res, err := a.db.ExecContext(ctx,
|
||||
`UPDATE im_verification_codes
|
||||
SET state=?,consumed_at=?,updated_at=?
|
||||
WHERE tenant_id=? AND challenge_id=? AND verification_token_hash=? AND state=? AND expires_at>?`,
|
||||
t.VerificationStateConsumed, consumedAt, consumedAt, id, challengeID, tokenHash,
|
||||
t.VerificationStateVerified, consumedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows, _ := res.RowsAffected(); rows == 0 {
|
||||
return t.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantUserSecurityGetAuthVersion(id t.TenantID, uid t.Uid) (int, error) {
|
||||
if err := validTenant(id); err != nil || uid.IsZero() {
|
||||
return 0, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
decoded := decodeUID(uid)
|
||||
now := t.TimeNow()
|
||||
_, err := a.db.ExecContext(ctx,
|
||||
`INSERT INTO im_user_security(tenant_id,user_id,auth_version,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE auth_version=auth_version`,
|
||||
id, decoded, 1, now, now)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var version int
|
||||
err = a.db.GetContext(ctx, &version,
|
||||
`SELECT auth_version FROM im_user_security WHERE tenant_id=? AND user_id=?`,
|
||||
id, decoded)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, t.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantUserSecurityBumpAuthVersion(id t.TenantID, uid t.Uid, updatedAt time.Time) (int, error) {
|
||||
if err := validTenant(id); err != nil || uid.IsZero() {
|
||||
return 0, t.ErrMalformed
|
||||
}
|
||||
if updatedAt.IsZero() {
|
||||
updatedAt = t.TimeNow()
|
||||
}
|
||||
ctx, cancel := a.getContextForTx()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
tx, err := a.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
decoded := decodeUID(uid)
|
||||
if _, err = tx.ExecContext(ctx,
|
||||
`INSERT INTO im_user_security(tenant_id,user_id,auth_version,password_updated_at,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
auth_version=auth_version+1,
|
||||
password_updated_at=VALUES(password_updated_at),
|
||||
updated_at=VALUES(updated_at)`,
|
||||
id, decoded, 2, updatedAt, updatedAt, updatedAt); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var version int
|
||||
if err = tx.GetContext(ctx, &version,
|
||||
`SELECT auth_version FROM im_user_security WHERE tenant_id=? AND user_id=?`,
|
||||
id, decoded); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func nullableUID(uid t.Uid) any {
|
||||
if uid.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return store.DecodeUid(uid)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user