auth: complete account authorization flows

(cherry picked from commit 04f4527df32ad5c35720cccc41d27fe51549612f)
This commit is contained in:
A 2026-06-08 21:42:32 +08:00
parent af41d18478
commit 6dc42942c8
21 changed files with 1780 additions and 50 deletions

View file

@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
@ -24,35 +25,97 @@ func NewPasswordStore(db sqlcgen.DBTX) *PasswordStore {
}
func (s *PasswordStore) GetByUser(ctx context.Context, userID int64) (domain.PasswordSettings, bool, error) {
row, err := s.q.GetPasswordByUser(ctx, userID)
if err != nil {
row := s.db.QueryRow(ctx, `
SELECT
has_recovery, has_secure_values, has_password, hint,
email_unconfirmed_pattern, login_email_pattern, secure_random,
current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p,
srp_id, srp_verifier, srp_b_secret, srp_b,
recovery_email, recovery_code, recovery_code_expires_at
FROM account_passwords
WHERE user_id = $1`, userID)
var settings domain.PasswordSettings
var salt1, salt2, p []byte
var recoveryExpires sql.NullTime
if err := row.Scan(
&settings.HasRecovery, &settings.HasSecureValues, &settings.HasPassword, &settings.Hint,
&settings.EmailUnconfirmedPattern, &settings.LoginEmailPattern, &settings.SecureRandom,
&salt1, &salt2, &settings.NewAlgo.G, &p,
&settings.SRPID, &settings.SRPVerifier, &settings.SRPBSecret, &settings.SRPB,
&settings.RecoveryEmail, &settings.RecoveryCode, &recoveryExpires,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.PasswordSettings{}, false, nil
}
return domain.PasswordSettings{}, false, fmt.Errorf("get account password: %w", err)
}
return domain.PasswordSettings{
HasRecovery: row.HasRecovery,
HasSecureValues: row.HasSecureValues,
HasPassword: row.HasPassword,
Hint: row.Hint,
EmailUnconfirmedPattern: row.EmailUnconfirmedPattern,
LoginEmailPattern: row.LoginEmailPattern,
SecureRandom: append([]byte(nil), row.SecureRandom...),
}, true, nil
if len(salt1) > 0 || len(salt2) > 0 || len(p) > 0 || settings.NewAlgo.G != 0 {
settings.CurrentAlgo = &domain.PasswordKDFAlgo{
Salt1: append([]byte(nil), salt1...),
Salt2: append([]byte(nil), salt2...),
G: settings.NewAlgo.G,
P: append([]byte(nil), p...),
}
}
settings.NewAlgo.Salt1 = append([]byte(nil), salt1...)
settings.NewAlgo.Salt2 = append([]byte(nil), salt2...)
settings.NewAlgo.P = append([]byte(nil), p...)
if recoveryExpires.Valid {
settings.RecoveryCodeExpiresAt = recoveryExpires.Time.Unix()
}
settings.SecureRandom = append([]byte(nil), settings.SecureRandom...)
settings.SRPVerifier = append([]byte(nil), settings.SRPVerifier...)
settings.SRPBSecret = append([]byte(nil), settings.SRPBSecret...)
settings.SRPB = append([]byte(nil), settings.SRPB...)
return settings, true, nil
}
func (s *PasswordStore) Save(ctx context.Context, userID int64, settings domain.PasswordSettings) error {
if err := s.q.UpsertPassword(ctx, sqlcgen.UpsertPasswordParams{
UserID: userID,
HasRecovery: settings.HasRecovery,
HasSecureValues: settings.HasSecureValues,
HasPassword: settings.HasPassword,
Hint: settings.Hint,
EmailUnconfirmedPattern: settings.EmailUnconfirmedPattern,
LoginEmailPattern: settings.LoginEmailPattern,
SecureRandom: settings.SecureRandom,
}); err != nil {
algo := settings.NewAlgo
if settings.CurrentAlgo != nil {
algo = *settings.CurrentAlgo
}
var recoveryExpires any
if settings.RecoveryCodeExpiresAt > 0 {
recoveryExpires = time.Unix(settings.RecoveryCodeExpiresAt, 0)
}
_, err := s.db.Exec(ctx, `
INSERT INTO account_passwords (
user_id, has_recovery, has_secure_values, has_password, hint,
email_unconfirmed_pattern, login_email_pattern, secure_random,
current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p,
srp_id, srp_verifier, srp_b_secret, srp_b,
recovery_email, recovery_code, recovery_code_expires_at
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)
ON CONFLICT (user_id) DO UPDATE SET
has_recovery = EXCLUDED.has_recovery,
has_secure_values = EXCLUDED.has_secure_values,
has_password = EXCLUDED.has_password,
hint = EXCLUDED.hint,
email_unconfirmed_pattern = EXCLUDED.email_unconfirmed_pattern,
login_email_pattern = EXCLUDED.login_email_pattern,
secure_random = EXCLUDED.secure_random,
current_algo_salt1 = EXCLUDED.current_algo_salt1,
current_algo_salt2 = EXCLUDED.current_algo_salt2,
current_algo_g = EXCLUDED.current_algo_g,
current_algo_p = EXCLUDED.current_algo_p,
srp_id = EXCLUDED.srp_id,
srp_verifier = EXCLUDED.srp_verifier,
srp_b_secret = EXCLUDED.srp_b_secret,
srp_b = EXCLUDED.srp_b,
recovery_email = EXCLUDED.recovery_email,
recovery_code = EXCLUDED.recovery_code,
recovery_code_expires_at = EXCLUDED.recovery_code_expires_at,
updated_at = now()`,
userID,
settings.HasRecovery, settings.HasSecureValues, settings.HasPassword, settings.Hint,
settings.EmailUnconfirmedPattern, settings.LoginEmailPattern, settings.SecureRandom,
algo.Salt1, algo.Salt2, algo.G, algo.P,
settings.SRPID, settings.SRPVerifier, settings.SRPBSecret, settings.SRPB,
settings.RecoveryEmail, settings.RecoveryCode, recoveryExpires,
)
if err != nil {
return fmt.Errorf("upsert account password: %w", err)
}
return nil

View file

@ -2,6 +2,8 @@ package postgres
import (
"context"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
@ -13,26 +15,36 @@ import (
// AuthorizationStore 用 PostgreSQL 实现 store.AuthorizationStore。
type AuthorizationStore struct {
q *sqlcgen.Queries
db sqlcgen.DBTX
q *sqlcgen.Queries
}
// NewAuthorizationStore 基于 pgx 连接池(或事务)创建 AuthorizationStore。
func NewAuthorizationStore(db sqlcgen.DBTX) *AuthorizationStore {
return &AuthorizationStore{q: sqlcgen.New(db)}
return &AuthorizationStore{db: db, q: sqlcgen.New(db)}
}
func (s *AuthorizationStore) Bind(ctx context.Context, a domain.Authorization) error {
if err := s.q.UpsertAuthorization(ctx, sqlcgen.UpsertAuthorizationParams{
AuthKeyID: authKeyIDToInt64(a.AuthKeyID),
UserID: a.UserID,
Layer: int32(a.Layer),
DeviceModel: a.DeviceModel,
Platform: a.Platform,
SystemVersion: a.SystemVersion,
ApiID: int32(a.APIID),
AppVersion: a.AppVersion,
Ip: a.IP,
}); err != nil {
if a.Hash == 0 {
a.Hash = authorizationHash(a.AuthKeyID)
}
_, err := s.db.Exec(ctx, `
INSERT INTO authorizations (auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (auth_key_id) DO UPDATE SET
user_id = EXCLUDED.user_id,
hash = EXCLUDED.hash,
layer = EXCLUDED.layer,
device_model = EXCLUDED.device_model,
platform = EXCLUDED.platform,
system_version = EXCLUDED.system_version,
api_id = EXCLUDED.api_id,
app_version = EXCLUDED.app_version,
ip = EXCLUDED.ip,
active_at = now()`,
authKeyIDToInt64(a.AuthKeyID), a.UserID, a.Hash, int32(a.Layer), a.DeviceModel, a.Platform, a.SystemVersion, int32(a.APIID), a.AppVersion, a.IP,
)
if err != nil {
return fmt.Errorf("upsert authorization: %w", err)
}
return nil
@ -78,10 +90,59 @@ func (s *AuthorizationStore) Delete(ctx context.Context, id [8]byte) error {
return nil
}
func (s *AuthorizationStore) DeleteByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
row := s.db.QueryRow(ctx, `
DELETE FROM authorizations
WHERE user_id = $1 AND hash = $2
RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, created_at, active_at`, userID, hash)
var a domain.Authorization
var authKeyID int64
if err := row.Scan(
&authKeyID, &a.UserID, &a.Hash, &a.Layer, &a.DeviceModel, &a.Platform, &a.SystemVersion,
&a.APIID, &a.AppVersion, &a.IP, &a.CreatedAt, &a.ActiveAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.Authorization{}, false, nil
}
return domain.Authorization{}, false, fmt.Errorf("delete authorization by hash: %w", err)
}
a.AuthKeyID = authKeyIDFromInt64(authKeyID)
return a, true, nil
}
func (s *AuthorizationStore) DeleteByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
rows, err := s.db.Query(ctx, `
DELETE FROM authorizations
WHERE user_id = $1 AND auth_key_id <> $2
RETURNING auth_key_id, user_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, created_at, active_at`, userID, authKeyIDToInt64(keepAuthKeyID))
if err != nil {
return nil, fmt.Errorf("delete authorizations by user: %w", err)
}
defer rows.Close()
out := make([]domain.Authorization, 0)
for rows.Next() {
var a domain.Authorization
var authKeyID int64
if err := rows.Scan(
&authKeyID, &a.UserID, &a.Hash, &a.Layer, &a.DeviceModel, &a.Platform, &a.SystemVersion,
&a.APIID, &a.AppVersion, &a.IP, &a.CreatedAt, &a.ActiveAt,
); err != nil {
return nil, fmt.Errorf("scan deleted authorization: %w", err)
}
a.AuthKeyID = authKeyIDFromInt64(authKeyID)
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate deleted authorizations: %w", err)
}
return out, nil
}
func authorizationFromRow(row sqlcgen.Authorization) domain.Authorization {
return domain.Authorization{
AuthKeyID: authKeyIDFromInt64(row.AuthKeyID),
UserID: row.UserID,
Hash: row.Hash,
Layer: int(row.Layer),
DeviceModel: row.DeviceModel,
Platform: row.Platform,
@ -89,5 +150,16 @@ func authorizationFromRow(row sqlcgen.Authorization) domain.Authorization {
APIID: int(row.ApiID),
AppVersion: row.AppVersion,
IP: row.Ip,
CreatedAt: row.CreatedAt.Time,
ActiveAt: row.ActiveAt.Time,
}
}
func authorizationHash(authKeyID [8]byte) int64 {
sum := sha256.Sum256(authKeyID[:])
hash := int64(binary.LittleEndian.Uint64(sum[:8]))
if hash == 0 {
return 1
}
return hash
}