chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
102
internal/app/auth/login_email_test.go
Normal file
102
internal/app/auth/login_email_test.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// TestSignInWithEmailCompletesLogin 验证带 email_verification 的登录:注册账号→登出→
|
||||
// 重新 sendCode→用任意邮箱验证码经 SignInWithEmail 完成登录。
|
||||
func TestSignInWithEmailCompletesLogin(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
svc := NewService(users, authz, memory.NewCodeStore(), nil, nil, "12345")
|
||||
var key [8]byte
|
||||
key[0] = 0x42
|
||||
|
||||
hash, err := svc.SendCode(ctx, "+15550009001")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode signup: %v", err)
|
||||
}
|
||||
u, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550009001", hash, "Email", "Login")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp: %v", err)
|
||||
}
|
||||
if err := svc.LogOut(ctx, key); err != nil {
|
||||
t.Fatalf("LogOut: %v", err)
|
||||
}
|
||||
|
||||
hash, err = svc.SendCode(ctx, "+15550009001")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode signin: %v", err)
|
||||
}
|
||||
got, _, needSignUp, err := svc.SignInWithEmail(ctx, domain.Authorization{AuthKeyID: key}, "+15550009001", hash, "anything-goes")
|
||||
if err != nil {
|
||||
t.Fatalf("SignInWithEmail: %v", err)
|
||||
}
|
||||
if needSignUp || got.ID != u.ID {
|
||||
t.Fatalf("SignInWithEmail user=%+v needSignUp=%v, want existing user %d", got, needSignUp, u.ID)
|
||||
}
|
||||
bound, found, err := svc.UserID(ctx, key)
|
||||
if err != nil || !found || bound != u.ID {
|
||||
t.Fatalf("UserID after email signin = %d found=%v err=%v, want %d", bound, found, err, u.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignInWithEmailRejectsEmptyCode 空邮箱验证码必须被拒(即使开发环境码任意,也不能空)。
|
||||
func TestSignInWithEmailRejectsEmptyCode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345")
|
||||
hash, err := svc.SendCode(ctx, "+15550009002")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
if _, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{}, "+15550009002", hash, " "); !errors.Is(err, ErrCodeInvalid) {
|
||||
t.Fatalf("SignInWithEmail empty code err = %v, want ErrCodeInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSignInWithEmailStillHonorsTwoFactor 登录邮箱与 2FA 正交:即使走邮箱验证码,
|
||||
// 开启了两步验证的账号仍停在 SESSION_PASSWORD_NEEDED,不能绕过密码。
|
||||
func TestSignInWithEmailStillHonorsTwoFactor(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
passwords := memory.NewPasswordStore()
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", WithPasswords(passwords))
|
||||
var key [8]byte
|
||||
key[0] = 0x43
|
||||
|
||||
hash, err := svc.SendCode(ctx, "+15550009003")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode signup: %v", err)
|
||||
}
|
||||
u, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550009003", hash, "Two", "Factor")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp: %v", err)
|
||||
}
|
||||
if err := svc.LogOut(ctx, key); err != nil {
|
||||
t.Fatalf("LogOut: %v", err)
|
||||
}
|
||||
if err := passwords.Save(ctx, u.ID, domain.PasswordSettings{HasPassword: true}); err != nil {
|
||||
t.Fatalf("save password settings: %v", err)
|
||||
}
|
||||
|
||||
hash, err = svc.SendCode(ctx, "+15550009003")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode signin: %v", err)
|
||||
}
|
||||
got, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{AuthKeyID: key}, "+15550009003", hash, "any-email-code")
|
||||
if !errors.Is(err, domain.ErrSessionPasswordNeeded) {
|
||||
t.Fatalf("SignInWithEmail err = %v, want ErrSessionPasswordNeeded", err)
|
||||
}
|
||||
if got.ID != u.ID {
|
||||
t.Fatalf("SignInWithEmail user = %+v, want pending 2FA user %d", got, u.ID)
|
||||
}
|
||||
if bound, found, err := svc.UserID(ctx, key); err != nil || found || bound != 0 {
|
||||
t.Fatalf("UserID after email signin with 2FA = %d found=%v err=%v, want not-found", bound, found, err)
|
||||
}
|
||||
}
|
||||
51
internal/app/auth/premium_grant_test.go
Normal file
51
internal/app/auth/premium_grant_test.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// TestSignUpPremiumGrant 验证新注册账号默认赠送 3 个月会员(WithPremiumGrant),
|
||||
// 以及 0 = 关闭赠送分支。
|
||||
func TestSignUpPremiumGrant(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", WithPremiumGrant(3))
|
||||
|
||||
hash, err := svc.SendCode(ctx, "+15550004401")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
u, _, err := svc.SignUp(ctx, domain.Authorization{}, "+15550004401", hash, "Prem", "User")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp: %v", err)
|
||||
}
|
||||
wantMin := time.Now().AddDate(0, 3, 0).Add(-time.Minute).Unix()
|
||||
wantMax := time.Now().AddDate(0, 3, 0).Add(time.Minute).Unix()
|
||||
if int64(u.PremiumUntil) < wantMin || int64(u.PremiumUntil) > wantMax {
|
||||
t.Fatalf("PremiumUntil = %d, want ~now+3mo [%d,%d]", u.PremiumUntil, wantMin, wantMax)
|
||||
}
|
||||
if !u.PremiumActiveAt(time.Now().Unix()) {
|
||||
t.Fatal("new user should be premium active")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignUpPremiumGrantDisabled(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", WithPremiumGrant(0))
|
||||
|
||||
hash, err := svc.SendCode(ctx, "+15550004402")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
u, _, err := svc.SignUp(ctx, domain.Authorization{}, "+15550004402", hash, "Free", "User")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp: %v", err)
|
||||
}
|
||||
if u.PremiumUntil != 0 {
|
||||
t.Fatalf("PremiumUntil = %d, want 0 (grant disabled)", u.PremiumUntil)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
|
|
@ -25,8 +26,39 @@ var (
|
|||
ErrCodeExpired = errors.New("phone code expired or not found")
|
||||
ErrCodeInvalid = errors.New("phone code invalid")
|
||||
ErrEncryptedMessageInvalid = errors.New("encrypted message invalid")
|
||||
// ErrPhoneNumberInvalid 表示手机号为空或非纯数字/长度越界。
|
||||
// 0090 把 users.phone 唯一约束改为忽略空串的部分索引(bot 行 phone=''),
|
||||
// 因此 phone 校验必须前移到 auth 入口,否则 sendCode/signUp 可无限铸造
|
||||
// phone='' 的幽灵人类账号(且因 ByPhone('') 短路永远无法再登录)。
|
||||
ErrPhoneNumberInvalid = errors.New("phone number invalid")
|
||||
// ErrSystemUserLoginForbidden 表示内置系统账号被尝试绑定为普通业务会话。
|
||||
ErrSystemUserLoginForbidden = errors.New("system user login forbidden")
|
||||
)
|
||||
|
||||
// validPhone 校验规范化后的手机号:5-32 位纯数字(上限对齐 users.phone 列宽)。
|
||||
// 核心目的是拒绝空/非数字 phone(防 0090 partial index 下无限铸造幽灵账号),
|
||||
// 长度上限从宽,不强求 E.164 精确位数(测试常用更长的唯一 phone)。
|
||||
func validPhone(phone string) bool {
|
||||
if len(phone) < 5 || len(phone) > 32 {
|
||||
return false
|
||||
}
|
||||
for _, r := range phone {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func systemUserLoginForbidden(u domain.User) bool {
|
||||
return domain.IsSystemUserID(u.ID)
|
||||
}
|
||||
|
||||
func systemLoginPhoneForbidden(phone string) bool {
|
||||
_, ok := domain.SystemUserByPhone(phone)
|
||||
return ok
|
||||
}
|
||||
|
||||
// Service 实现登录/注册业务。第一阶段为开发固定验证码(不真实下发短信)。
|
||||
type Service struct {
|
||||
users store.UserStore
|
||||
|
|
@ -37,8 +69,16 @@ type Service struct {
|
|||
passwords store.PasswordStore
|
||||
messages store.MessageStore
|
||||
dialogs store.DialogStore
|
||||
bots store.BotStore
|
||||
fixedCode string
|
||||
codeTTL time.Duration
|
||||
// premiumGrantMonths 是新注册账号默认赠送的会员月数;0 表示关闭赠送。
|
||||
premiumGrantMonths int
|
||||
}
|
||||
|
||||
type authorizationRevoker interface {
|
||||
RevokeByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error)
|
||||
RevokeByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error)
|
||||
}
|
||||
|
||||
// Option 调整登录服务的可选依赖。
|
||||
|
|
@ -59,6 +99,21 @@ func WithPasswords(passwords store.PasswordStore) Option {
|
|||
}
|
||||
}
|
||||
|
||||
// WithBotLogin 启用 auth.importBotAuthorization 的 bot token 登录。
|
||||
func WithBotLogin(bots store.BotStore) Option {
|
||||
return func(s *Service) {
|
||||
s.bots = bots
|
||||
}
|
||||
}
|
||||
|
||||
// WithPremiumGrant 让新注册账号默认获得 months 个月会员(0 = 关闭赠送)。
|
||||
// 存量账号的同等赠送由迁移 0094 一次性 backfill。
|
||||
func WithPremiumGrant(months int) Option {
|
||||
return func(s *Service) {
|
||||
s.premiumGrantMonths = months
|
||||
}
|
||||
}
|
||||
|
||||
// NewService 创建登录服务。fixedCode 为开发固定验证码。
|
||||
func NewService(users store.UserStore, auths store.AuthorizationStore, codes store.CodeStore, authKeys store.AuthKeyStore, tempKeys store.TempAuthKeyBindingStore, fixedCode string, opts ...Option) *Service {
|
||||
s := &Service{users: users, auths: auths, codes: codes, authKeys: authKeys, tempKeys: tempKeys, fixedCode: fixedCode, codeTTL: 5 * time.Minute}
|
||||
|
|
@ -84,6 +139,13 @@ func (s *Service) BindTempAuthKey(ctx context.Context, sessionID int64, binding
|
|||
}
|
||||
|
||||
// ResolveAuthKey 将已绑定的 temp auth_key 解析为对应 perm auth_key。
|
||||
//
|
||||
// 过期处理是有意的连续性权衡(见 TestResolveAuthKeyAllowsExpiredTempBindingForAuthorizedPermKey):
|
||||
// temp 绑定 expires_at 已过时,仅当 perm key 也未授权才拒绝;perm 仍授权则继续解析,
|
||||
// 避免已登录会话因 temp key 过期而被强制踢下线。严格 PFS 要求过期 temp key 一律失效
|
||||
// (不以 perm 授权豁免),但收紧前需先核实目标客户端(TDesktop/DrKLO)会在过期前主动
|
||||
// 轮换 temp key 并优雅处理拒绝,否则会造成在线会话掉线。RetentionWorker 的 DeleteExpired
|
||||
// 已把残留窗口限制在 expires_at + 宽限(约 24h)内。收紧为显式硬化任务,需客户端验证。
|
||||
func (s *Service) ResolveAuthKey(ctx context.Context, authKeyID [8]byte) ([8]byte, bool, error) {
|
||||
if s == nil || s.tempKeys == nil {
|
||||
return [8]byte{}, false, nil
|
||||
|
|
@ -92,13 +154,22 @@ func (s *Service) ResolveAuthKey(ctx context.Context, authKeyID [8]byte) ([8]byt
|
|||
if err != nil || !found {
|
||||
return [8]byte{}, found, err
|
||||
}
|
||||
if binding.ExpiresAt <= int(time.Now().Unix()) {
|
||||
permID := authKeyIDFromInt64(binding.PermAuthKeyID)
|
||||
if binding.ExpiresAt <= int(time.Now().Unix()) && !s.permAuthKeyAuthorized(ctx, permID) {
|
||||
return [8]byte{}, false, nil
|
||||
}
|
||||
return authKeyIDFromInt64(binding.PermAuthKeyID), true, nil
|
||||
return permID, true, nil
|
||||
}
|
||||
|
||||
// UserID 返回 auth_key 当前绑定的用户。未登录时 found=false。
|
||||
func (s *Service) permAuthKeyAuthorized(ctx context.Context, authKeyID [8]byte) bool {
|
||||
if s == nil || s.auths == nil {
|
||||
return false
|
||||
}
|
||||
_, found, err := s.auths.ByAuthKey(ctx, authKeyID)
|
||||
return err == nil && found
|
||||
}
|
||||
|
||||
// UserID 返回 auth_key 当前绑定的用户。未登录、或两步验证未完成时 found=false。
|
||||
func (s *Service) UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error) {
|
||||
if s == nil || s.auths == nil {
|
||||
return 0, false, nil
|
||||
|
|
@ -107,16 +178,56 @@ func (s *Service) UserID(ctx context.Context, authKeyID [8]byte) (int64, bool, e
|
|||
if err != nil || !found {
|
||||
return 0, found, err
|
||||
}
|
||||
if a.PasswordPending {
|
||||
// 两步验证未完成:业务鉴权视为未登录,仅允许 auth.checkPassword 继续。
|
||||
return 0, false, nil
|
||||
}
|
||||
if domain.IsSystemUserID(a.UserID) {
|
||||
_ = s.auths.Delete(ctx, authKeyID)
|
||||
return 0, false, nil
|
||||
}
|
||||
return a.UserID, true, nil
|
||||
}
|
||||
|
||||
// PendingPasswordUserID 返回处于"待两步验证"状态的 auth_key 对应的用户。
|
||||
// UserID 对 password_pending 的 auth_key 返回未登录,auth.checkPassword 借此仍能定位待验证用户。
|
||||
func (s *Service) PendingPasswordUserID(ctx context.Context, authKeyID [8]byte) (int64, bool, error) {
|
||||
if s == nil || s.auths == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
a, found, err := s.auths.ByAuthKey(ctx, authKeyID)
|
||||
if err != nil || !found || !a.PasswordPending {
|
||||
return 0, false, err
|
||||
}
|
||||
if domain.IsSystemUserID(a.UserID) {
|
||||
_ = s.auths.Delete(ctx, authKeyID)
|
||||
return 0, false, nil
|
||||
}
|
||||
return a.UserID, true, nil
|
||||
}
|
||||
|
||||
// CompletePasswordSignIn 在两步验证通过后清除 password_pending,使 auth_key 转为完全授权。
|
||||
func (s *Service) CompletePasswordSignIn(ctx context.Context, authKeyID [8]byte) error {
|
||||
if s == nil || s.auths == nil {
|
||||
return nil
|
||||
}
|
||||
return s.auths.MarkPasswordPassed(ctx, authKeyID)
|
||||
}
|
||||
|
||||
// SendCode 为 phone 生成 phone_code_hash,暂存(开发)固定验证码,返回 hash。
|
||||
func (s *Service) SendCode(ctx context.Context, phone string) (string, error) {
|
||||
phone = normalizePhone(phone)
|
||||
if !validPhone(phone) {
|
||||
return "", ErrPhoneNumberInvalid
|
||||
}
|
||||
if systemLoginPhoneForbidden(phone) {
|
||||
return "", ErrSystemUserLoginForbidden
|
||||
}
|
||||
hash, err := randomHex(8)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.codes.Set(ctx, hash, store.PhoneCode{Phone: normalizePhone(phone), Code: s.fixedCode}, s.codeTTL); err != nil {
|
||||
if err := s.codes.Set(ctx, hash, store.PhoneCode{Phone: phone, Code: s.fixedCode}, s.codeTTL); err != nil {
|
||||
return "", fmt.Errorf("store code: %w", err)
|
||||
}
|
||||
return hash, nil
|
||||
|
|
@ -159,6 +270,9 @@ func (s *Service) CancelCode(ctx context.Context, phone, phoneCodeHash string) e
|
|||
// needSignUp=true 表示验证码正确但用户不存在,调用方应引导注册(此时不删验证码,留给 SignUp)。
|
||||
func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, code string) (u domain.User, loginMessage domain.Message, needSignUp bool, err error) {
|
||||
phone = normalizePhone(phone)
|
||||
if systemLoginPhoneForbidden(phone) {
|
||||
return domain.User{}, domain.Message{}, false, ErrSystemUserLoginForbidden
|
||||
}
|
||||
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
|
|
@ -177,14 +291,61 @@ func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone,
|
|||
if !found {
|
||||
return domain.User{}, domain.Message{}, true, nil // 验证码对、但需注册
|
||||
}
|
||||
return s.finishSignIn(ctx, auth, existing, phoneCodeHash, rec.Code)
|
||||
}
|
||||
|
||||
// SignInWithEmail 处理带 email_verification 的 auth.signIn:账号设置了登录邮箱后,新设备
|
||||
// 的验证码改投递到邮箱,客户端凭邮箱码(而非短信码)登录。开发环境接受任意非空邮箱码
|
||||
// (与短信固定码同口径,"随意输入");仍校验 phone_code_hash 有效、手机号匹配,并与短信
|
||||
// 登录共用 2FA 门控——即便走邮箱验证,开启了两步验证的账号同样会停在 SESSION_PASSWORD_NEEDED。
|
||||
func (s *Service) SignInWithEmail(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, code string) (domain.User, domain.Message, bool, error) {
|
||||
phone = normalizePhone(phone)
|
||||
if systemLoginPhoneForbidden(phone) {
|
||||
return domain.User{}, domain.Message{}, false, ErrSystemUserLoginForbidden
|
||||
}
|
||||
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.User{}, domain.Message{}, false, ErrCodeExpired
|
||||
}
|
||||
if rec.Phone != phone {
|
||||
return domain.User{}, domain.Message{}, false, ErrCodeInvalid
|
||||
}
|
||||
if strings.TrimSpace(code) == "" {
|
||||
return domain.User{}, domain.Message{}, false, ErrCodeInvalid
|
||||
}
|
||||
existing, found, err := s.users.ByPhone(ctx, phone)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
if !found {
|
||||
return domain.User{}, domain.Message{}, true, nil
|
||||
}
|
||||
return s.finishSignIn(ctx, auth, existing, phoneCodeHash, rec.Code)
|
||||
}
|
||||
|
||||
// finishSignIn 是短信/邮箱两条登录路径在「验证码已通过、用户已存在」之后的共用收尾:
|
||||
// 处理 2FA password_pending 绑定、写登录消息、消费验证码。
|
||||
func (s *Service) finishSignIn(ctx context.Context, auth domain.Authorization, existing domain.User, phoneCodeHash, loginCode string) (domain.User, domain.Message, bool, error) {
|
||||
if systemUserLoginForbidden(existing) {
|
||||
_ = s.codes.Del(ctx, phoneCodeHash)
|
||||
return domain.User{}, domain.Message{}, false, ErrSystemUserLoginForbidden
|
||||
}
|
||||
// 开启两步验证的账号:把授权标记为 password_pending 再写入,业务鉴权据此拒绝该 auth_key,
|
||||
// 直到 auth.checkPassword 通过。绝不能先以完全授权写入再返回 SESSION_PASSWORD_NEEDED,
|
||||
// 否则客户端忽略该错误即可直接调用业务 RPC 绕过两步验证。
|
||||
passwordNeeded := s.passwordNeeded(ctx, existing.ID)
|
||||
auth.PasswordPending = passwordNeeded
|
||||
if err := s.bind(ctx, auth, existing.ID); err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
if s.passwordNeeded(ctx, existing.ID) {
|
||||
if passwordNeeded {
|
||||
_ = s.codes.Del(ctx, phoneCodeHash)
|
||||
return existing, domain.Message{}, false, domain.ErrSessionPasswordNeeded
|
||||
}
|
||||
loginMessage, err = s.recordLoginMessage(ctx, existing.ID, rec.Code)
|
||||
loginMessage, err := s.recordLoginMessage(ctx, existing.ID, loginCode)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, false, err
|
||||
}
|
||||
|
|
@ -196,6 +357,12 @@ func (s *Service) SignIn(ctx context.Context, auth domain.Authorization, phone,
|
|||
// signUp 的 TL 请求不带验证码,这里校验 phone_code_hash 仍有效且手机号匹配。
|
||||
func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone, phoneCodeHash, firstName, lastName string) (domain.User, domain.Message, error) {
|
||||
phone = normalizePhone(phone)
|
||||
if !validPhone(phone) {
|
||||
return domain.User{}, domain.Message{}, ErrPhoneNumberInvalid
|
||||
}
|
||||
if systemLoginPhoneForbidden(phone) {
|
||||
return domain.User{}, domain.Message{}, ErrSystemUserLoginForbidden
|
||||
}
|
||||
firstName = strings.TrimSpace(firstName)
|
||||
lastName = strings.TrimSpace(lastName)
|
||||
if firstName == "" || utf8.RuneCountInString(firstName) > 64 || utf8.RuneCountInString(lastName) > 64 {
|
||||
|
|
@ -216,12 +383,18 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
|
|||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, err
|
||||
}
|
||||
u, err := s.users.Create(ctx, domain.User{
|
||||
newUser := domain.User{
|
||||
AccessHash: accessHash,
|
||||
Phone: phone,
|
||||
FirstName: firstName,
|
||||
LastName: lastName,
|
||||
})
|
||||
}
|
||||
// 新账号默认赠送会员:到期时间 = 注册时刻 + N 个月(与迁移 0094 对存量
|
||||
// 账号的 backfill 同一语义)。premium 状态由下发路径按该时间即时派生。
|
||||
if s.premiumGrantMonths > 0 {
|
||||
newUser.PremiumUntil = int(time.Now().AddDate(0, s.premiumGrantMonths, 0).Unix())
|
||||
}
|
||||
u, err := s.users.Create(ctx, newUser)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.Message{}, err
|
||||
}
|
||||
|
|
@ -236,11 +409,115 @@ func (s *Service) SignUp(ctx context.Context, auth domain.Authorization, phone,
|
|||
return u, loginMessage, nil
|
||||
}
|
||||
|
||||
// SignInBot 处理 auth.importBotAuthorization:校验 bot token 并把当前 auth_key
|
||||
// 绑定到 bot 账号。token 校验必须先于 bind(bind 即授权生效);bot 无 2FA,
|
||||
// PasswordPending 恒 false;不写登录消息、不推 signIn 通知(手机登录语义)。
|
||||
// 任何校验失败统一返回 domain.ErrBotTokenInvalid,不区分原因避免泄漏存在性。
|
||||
func (s *Service) SignInBot(ctx context.Context, auth domain.Authorization, token string) (domain.User, error) {
|
||||
if s == nil || s.bots == nil || s.users == nil {
|
||||
return domain.User{}, domain.ErrBotTokenInvalid
|
||||
}
|
||||
botUserID, secret, ok := domain.ParseBotToken(strings.TrimSpace(token))
|
||||
if !ok {
|
||||
return domain.User{}, domain.ErrBotTokenInvalid
|
||||
}
|
||||
profile, found, err := s.bots.GetBot(ctx, botUserID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
// 空 secret(内置 BotFather)永不可登录;比较走常数时间。
|
||||
if !found || profile.TokenSecret == "" ||
|
||||
subtle.ConstantTimeCompare([]byte(profile.TokenSecret), []byte(secret)) != 1 {
|
||||
return domain.User{}, domain.ErrBotTokenInvalid
|
||||
}
|
||||
u, found, err := s.users.ByID(ctx, botUserID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if !found || !u.Bot {
|
||||
return domain.User{}, domain.ErrBotTokenInvalid
|
||||
}
|
||||
if systemUserLoginForbidden(u) {
|
||||
return domain.User{}, domain.ErrBotTokenInvalid
|
||||
}
|
||||
auth.PasswordPending = false
|
||||
if err := s.bind(ctx, auth, u.ID); err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
// check-bind-recheck:SignInBot 的「校验 secret → bind」非原子,并发 /revoke
|
||||
// 可能在两步之间换 secret 并删除已有 authorization。此处 bind 写入的新行不会被
|
||||
// 那次删除覆盖(删除发生在 bind 之前),会逃过 session 撤销。bind 后复核 secret:
|
||||
// 若已被换掉,撤销刚写入的授权并拒登,闭合竞态窗口。
|
||||
if again, found, err := s.bots.GetBot(ctx, botUserID); err != nil {
|
||||
_ = s.auths.Delete(ctx, auth.AuthKeyID)
|
||||
return domain.User{}, err
|
||||
} else if !found || again.TokenSecret == "" ||
|
||||
subtle.ConstantTimeCompare([]byte(again.TokenSecret), []byte(secret)) != 1 {
|
||||
_ = s.auths.Delete(ctx, auth.AuthKeyID)
|
||||
return domain.User{}, domain.ErrBotTokenInvalid
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// BindVerifiedLogin 把当前 auth_key 绑定到一个已由外部强因子(如 passkey)验证过身份的
|
||||
// 用户,直接完成授权。passkey 是独立强因子,不再叠加 2FA password(PasswordPending=false),
|
||||
// 与官方"passkey 登录跳过密码步骤"一致。校验已发生在调用方(passkey 断言验证),此处只负责绑定。
|
||||
func (s *Service) BindVerifiedLogin(ctx context.Context, auth domain.Authorization, userID int64) (domain.User, error) {
|
||||
if s == nil || s.users == nil || userID == 0 {
|
||||
return domain.User{}, domain.ErrPasskeyInvalid
|
||||
}
|
||||
u, found, err := s.users.ByID(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.User{}, domain.ErrPasskeyNotFound
|
||||
}
|
||||
if systemUserLoginForbidden(u) {
|
||||
return domain.User{}, ErrSystemUserLoginForbidden
|
||||
}
|
||||
auth.PasswordPending = false
|
||||
if err := s.bind(ctx, auth, userID); err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// AcceptLoginToken 把 QR 登录请求方的 auth_key 绑定到扫码确认的 user。
|
||||
func (s *Service) AcceptLoginToken(ctx context.Context, auth domain.Authorization, userID int64) (domain.Authorization, error) {
|
||||
if s == nil || s.auths == nil || userID == 0 || auth.AuthKeyID == ([8]byte{}) {
|
||||
return domain.Authorization{}, fmt.Errorf("accept login token: invalid authorization")
|
||||
}
|
||||
if domain.IsSystemUserID(userID) {
|
||||
return domain.Authorization{}, ErrSystemUserLoginForbidden
|
||||
}
|
||||
auth.PasswordPending = false
|
||||
if err := s.bind(ctx, auth, userID); err != nil {
|
||||
return domain.Authorization{}, err
|
||||
}
|
||||
bound, found, err := s.auths.ByAuthKey(ctx, auth.AuthKeyID)
|
||||
if err != nil {
|
||||
return domain.Authorization{}, err
|
||||
}
|
||||
if found {
|
||||
return bound, nil
|
||||
}
|
||||
auth.UserID = userID
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
// LogOut 解绑当前 auth_key 的授权。
|
||||
func (s *Service) LogOut(ctx context.Context, authKeyID [8]byte) error {
|
||||
return s.auths.Delete(ctx, authKeyID)
|
||||
}
|
||||
|
||||
func (s *Service) Authorization(ctx context.Context, authKeyID [8]byte) (domain.Authorization, bool, error) {
|
||||
if s == nil || s.auths == nil || authKeyID == ([8]byte{}) {
|
||||
return domain.Authorization{}, false, nil
|
||||
}
|
||||
return s.auths.ByAuthKey(ctx, authKeyID)
|
||||
}
|
||||
|
||||
func (s *Service) ListAuthorizations(ctx context.Context, userID int64) ([]domain.Authorization, error) {
|
||||
if s == nil || s.auths == nil || userID == 0 {
|
||||
return nil, nil
|
||||
|
|
@ -252,14 +529,78 @@ func (s *Service) ResetAuthorization(ctx context.Context, userID, hash int64) (d
|
|||
if s == nil || s.auths == nil || userID == 0 {
|
||||
return domain.Authorization{}, false, nil
|
||||
}
|
||||
return s.auths.DeleteByHash(ctx, userID, hash)
|
||||
if revoker, ok := s.auths.(authorizationRevoker); ok {
|
||||
return revoker.RevokeByHash(ctx, userID, hash)
|
||||
}
|
||||
target, found, err := s.authorizationByHash(ctx, userID, hash)
|
||||
if err != nil || !found {
|
||||
return target, found, err
|
||||
}
|
||||
if err := s.deleteAuthKey(ctx, target.AuthKeyID); err != nil {
|
||||
return target, true, err
|
||||
}
|
||||
deleted, found, err := s.auths.DeleteByHash(ctx, userID, hash)
|
||||
if err != nil || !found {
|
||||
return deleted, found, err
|
||||
}
|
||||
return deleted, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) ResetAuthorizations(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
|
||||
if s == nil || s.auths == nil || userID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return s.auths.DeleteByUserExcept(ctx, userID, keepAuthKeyID)
|
||||
if revoker, ok := s.auths.(authorizationRevoker); ok {
|
||||
return revoker.RevokeByUserExcept(ctx, userID, keepAuthKeyID)
|
||||
}
|
||||
targets, err := s.authorizationsByUserExcept(ctx, userID, keepAuthKeyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range targets {
|
||||
if err := s.deleteAuthKey(ctx, a.AuthKeyID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
deleted, err := s.auths.DeleteByUserExcept(ctx, userID, keepAuthKeyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (s *Service) deleteAuthKey(ctx context.Context, authKeyID [8]byte) error {
|
||||
if s == nil || s.authKeys == nil || authKeyID == ([8]byte{}) {
|
||||
return nil
|
||||
}
|
||||
return s.authKeys.Delete(ctx, authKeyID)
|
||||
}
|
||||
|
||||
func (s *Service) authorizationByHash(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) {
|
||||
items, err := s.auths.ListByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.Authorization{}, false, err
|
||||
}
|
||||
for _, a := range items {
|
||||
if a.Hash == hash {
|
||||
return a, true, nil
|
||||
}
|
||||
}
|
||||
return domain.Authorization{}, false, nil
|
||||
}
|
||||
|
||||
func (s *Service) authorizationsByUserExcept(ctx context.Context, userID int64, keepAuthKeyID [8]byte) ([]domain.Authorization, error) {
|
||||
items, err := s.auths.ListByUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]domain.Authorization, 0, len(items))
|
||||
for _, a := range items {
|
||||
if a.AuthKeyID != keepAuthKeyID {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) bind(ctx context.Context, auth domain.Authorization, userID int64) error {
|
||||
|
|
@ -301,11 +642,10 @@ func (s *Service) recordLoginMessage(ctx context.Context, userID int64, code str
|
|||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
if err := s.dialogs.Upsert(ctx, userID, domain.Dialog{
|
||||
if err := s.dialogs.UpsertInbox(ctx, userID, domain.Dialog{
|
||||
Peer: msg.Peer,
|
||||
TopMessage: msg.ID,
|
||||
TopMessageDate: msg.Date,
|
||||
UnreadCount: 1,
|
||||
}); err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
|
|
@ -406,17 +746,7 @@ func authKeyIDInt64(id [8]byte) int64 {
|
|||
}
|
||||
|
||||
func normalizePhone(phone string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(phone))
|
||||
for _, r := range phone {
|
||||
if r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return phone
|
||||
}
|
||||
return b.String()
|
||||
return domain.NormalizePhone(phone)
|
||||
}
|
||||
|
||||
func randomHex(n int) (string, error) {
|
||||
|
|
|
|||
|
|
@ -95,6 +95,58 @@ func TestResolveAuthKeyUsesValidTempBinding(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestResolveAuthKeyAllowsExpiredTempBindingForAuthorizedPermKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tempBindings := memory.NewTempAuthKeyBindingStore()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
permKey := testAuthKey(0x21)
|
||||
tempKey := testAuthKey(0x65)
|
||||
svc := NewService(memory.NewUserStore(), authz, memory.NewCodeStore(), nil, tempBindings, "12345")
|
||||
|
||||
if err := authz.Bind(ctx, domain.Authorization{AuthKeyID: permKey.ID, UserID: 1000000001}); err != nil {
|
||||
t.Fatalf("bind authorization: %v", err)
|
||||
}
|
||||
if err := tempBindings.Save(ctx, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempKey.ID,
|
||||
PermAuthKeyID: permKey.IntID(),
|
||||
ExpiresAt: int(time.Now().Add(-time.Minute).Unix()),
|
||||
}); err != nil {
|
||||
t.Fatalf("save temp binding: %v", err)
|
||||
}
|
||||
|
||||
got, ok, err := svc.ResolveAuthKey(ctx, tempKey.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAuthKey: %v", err)
|
||||
}
|
||||
if !ok || got != permKey.ID {
|
||||
t.Fatalf("resolved = %x ok=%v, want authorized perm %x", got, ok, permKey.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAuthKeyRejectsExpiredTempBindingWithoutAuthorizedPermKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tempBindings := memory.NewTempAuthKeyBindingStore()
|
||||
permKey := testAuthKey(0x31)
|
||||
tempKey := testAuthKey(0x75)
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, tempBindings, "12345")
|
||||
|
||||
if err := tempBindings.Save(ctx, domain.TempAuthKeyBinding{
|
||||
TempAuthKeyID: tempKey.ID,
|
||||
PermAuthKeyID: permKey.IntID(),
|
||||
ExpiresAt: int(time.Now().Add(-time.Minute).Unix()),
|
||||
}); err != nil {
|
||||
t.Fatalf("save temp binding: %v", err)
|
||||
}
|
||||
|
||||
got, ok, err := svc.ResolveAuthKey(ctx, tempKey.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAuthKey: %v", err)
|
||||
}
|
||||
if ok || got != ([8]byte{}) {
|
||||
t.Fatalf("resolved = %x ok=%v, want expired unresolved", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneCodeAcceptsTDesktopDigitsOnlySignIn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
|
|
@ -126,6 +178,73 @@ func TestPhoneCodeAcceptsTDesktopDigitsOnlySignIn(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSystemUserPhoneCannotLoginOrSignUp(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
codes := memory.NewCodeStore()
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), codes, nil, nil, "12345")
|
||||
phone := domain.OfficialSystemUser().Phone
|
||||
|
||||
if _, err := svc.SendCode(ctx, phone); !errors.Is(err, ErrSystemUserLoginForbidden) {
|
||||
t.Fatalf("SendCode official system phone err = %v, want ErrSystemUserLoginForbidden", err)
|
||||
}
|
||||
|
||||
if err := codes.Set(ctx, "system-signin", store.PhoneCode{Phone: phone, Code: "12345"}, time.Minute); err != nil {
|
||||
t.Fatalf("seed sign-in code: %v", err)
|
||||
}
|
||||
if _, _, _, err := svc.SignIn(ctx, domain.Authorization{}, phone, "system-signin", "12345"); !errors.Is(err, ErrSystemUserLoginForbidden) {
|
||||
t.Fatalf("SignIn official system phone err = %v, want ErrSystemUserLoginForbidden", err)
|
||||
}
|
||||
|
||||
if err := codes.Set(ctx, "system-email", store.PhoneCode{Phone: phone, Code: "12345"}, time.Minute); err != nil {
|
||||
t.Fatalf("seed email code: %v", err)
|
||||
}
|
||||
if _, _, _, err := svc.SignInWithEmail(ctx, domain.Authorization{}, phone, "system-email", "anything"); !errors.Is(err, ErrSystemUserLoginForbidden) {
|
||||
t.Fatalf("SignInWithEmail official system phone err = %v, want ErrSystemUserLoginForbidden", err)
|
||||
}
|
||||
|
||||
if err := codes.Set(ctx, "system-signup", store.PhoneCode{Phone: phone, Code: "12345"}, time.Minute); err != nil {
|
||||
t.Fatalf("seed sign-up code: %v", err)
|
||||
}
|
||||
if _, _, err := svc.SignUp(ctx, domain.Authorization{}, phone, "system-signup", "System", "User"); !errors.Is(err, ErrSystemUserLoginForbidden) {
|
||||
t.Fatalf("SignUp official system phone err = %v, want ErrSystemUserLoginForbidden", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemUserAuthorizationIsRejectedAndRevoked(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
svc := NewService(memory.NewUserStore(), authz, memory.NewCodeStore(), nil, nil, "12345")
|
||||
|
||||
authKeyID := [8]byte{0x71}
|
||||
if err := authz.Bind(ctx, domain.Authorization{AuthKeyID: authKeyID, UserID: domain.OfficialSystemUserID}); err != nil {
|
||||
t.Fatalf("bind system authorization: %v", err)
|
||||
}
|
||||
if got, found, err := svc.UserID(ctx, authKeyID); err != nil || found || got != 0 {
|
||||
t.Fatalf("UserID(system auth) = %d found=%v err=%v, want not found", got, found, err)
|
||||
}
|
||||
if _, found, err := authz.ByAuthKey(ctx, authKeyID); err != nil || found {
|
||||
t.Fatalf("system authorization after UserID found=%v err=%v, want deleted", found, err)
|
||||
}
|
||||
|
||||
pendingAuthKeyID := [8]byte{0x72}
|
||||
if err := authz.Bind(ctx, domain.Authorization{AuthKeyID: pendingAuthKeyID, UserID: domain.OfficialSystemUserID, PasswordPending: true}); err != nil {
|
||||
t.Fatalf("bind pending system authorization: %v", err)
|
||||
}
|
||||
if got, pending, err := svc.PendingPasswordUserID(ctx, pendingAuthKeyID); err != nil || pending || got != 0 {
|
||||
t.Fatalf("PendingPasswordUserID(system auth) = %d pending=%v err=%v, want not pending", got, pending, err)
|
||||
}
|
||||
if _, found, err := authz.ByAuthKey(ctx, pendingAuthKeyID); err != nil || found {
|
||||
t.Fatalf("pending system authorization after lookup found=%v err=%v, want deleted", found, err)
|
||||
}
|
||||
|
||||
if _, err := svc.BindVerifiedLogin(ctx, domain.Authorization{AuthKeyID: [8]byte{0x73}}, domain.OfficialSystemUserID); !errors.Is(err, ErrSystemUserLoginForbidden) {
|
||||
t.Fatalf("BindVerifiedLogin official system user err = %v, want ErrSystemUserLoginForbidden", err)
|
||||
}
|
||||
if _, err := svc.AcceptLoginToken(ctx, domain.Authorization{AuthKeyID: [8]byte{0x74}}, domain.OfficialSystemUserID); !errors.Is(err, ErrSystemUserLoginForbidden) {
|
||||
t.Fatalf("AcceptLoginToken official system user err = %v, want ErrSystemUserLoginForbidden", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleAuthKeysKeepSeparateUsers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
|
|
@ -205,6 +324,77 @@ func TestLogOutThenSignInSameAuthKeySwitchesUser(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestResetAuthorizationDeletesProtocolAuthKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
keys := memory.NewAuthKeyStore()
|
||||
svc := NewService(memory.NewUserStore(), authz, memory.NewCodeStore(), keys, nil, "12345")
|
||||
key := [8]byte{0x31}
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: key}); err != nil {
|
||||
t.Fatalf("save auth key: %v", err)
|
||||
}
|
||||
hash, err := svc.SendCode(ctx, "+15550007001")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
u, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: key}, "+15550007001", hash, "One", "")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp: %v", err)
|
||||
}
|
||||
items, err := authz.ListByUser(ctx, u.ID)
|
||||
if err != nil || len(items) != 1 {
|
||||
t.Fatalf("ListByUser = %d err=%v, want one authorization", len(items), err)
|
||||
}
|
||||
|
||||
deleted, found, err := svc.ResetAuthorization(ctx, u.ID, items[0].Hash)
|
||||
if err != nil || !found || deleted.AuthKeyID != key {
|
||||
t.Fatalf("ResetAuthorization deleted=%x found=%v err=%v, want key %x", deleted.AuthKeyID, found, err, key)
|
||||
}
|
||||
if _, found, err := keys.Get(ctx, key); err != nil || found {
|
||||
t.Fatalf("auth key after reset found=%v err=%v, want missing", found, err)
|
||||
}
|
||||
if _, found, err := svc.UserID(ctx, key); err != nil || found {
|
||||
t.Fatalf("user after reset found=%v err=%v, want missing", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetAuthorizationsDeletesOnlyRevokedProtocolAuthKeys(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
authz := memory.NewAuthorizationStore()
|
||||
keys := memory.NewAuthKeyStore()
|
||||
users := memory.NewUserStore()
|
||||
svc := NewService(users, authz, memory.NewCodeStore(), keys, nil, "12345")
|
||||
keep := [8]byte{0x41}
|
||||
revoked := [8]byte{0x42}
|
||||
for _, key := range [][8]byte{keep, revoked} {
|
||||
if err := keys.Save(ctx, store.AuthKeyData{ID: key}); err != nil {
|
||||
t.Fatalf("save auth key %x: %v", key, err)
|
||||
}
|
||||
}
|
||||
hash, err := svc.SendCode(ctx, "+15550007002")
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode: %v", err)
|
||||
}
|
||||
u, _, err := svc.SignUp(ctx, domain.Authorization{AuthKeyID: keep}, "+15550007002", hash, "Two", "")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp: %v", err)
|
||||
}
|
||||
if err := authz.Bind(ctx, domain.Authorization{AuthKeyID: revoked, UserID: u.ID}); err != nil {
|
||||
t.Fatalf("bind revoked authorization: %v", err)
|
||||
}
|
||||
|
||||
deleted, err := svc.ResetAuthorizations(ctx, u.ID, keep)
|
||||
if err != nil || len(deleted) != 1 || deleted[0].AuthKeyID != revoked {
|
||||
t.Fatalf("ResetAuthorizations deleted=%v err=%v, want revoked key", deleted, err)
|
||||
}
|
||||
if _, found, err := keys.Get(ctx, revoked); err != nil || found {
|
||||
t.Fatalf("revoked auth key found=%v err=%v, want missing", found, err)
|
||||
}
|
||||
if _, found, err := keys.Get(ctx, keep); err != nil || !found {
|
||||
t.Fatalf("kept auth key found=%v err=%v, want present", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignUpWritesOfficialLoginMessage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := memory.NewDialogStore()
|
||||
|
|
@ -241,6 +431,63 @@ func TestSignUpWritesOfficialLoginMessage(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSignInLoginMessagePreservesOfficialDialogReadWatermark(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dialogs := memory.NewDialogStore()
|
||||
messages := memory.NewMessageStore(dialogs)
|
||||
svc := NewService(memory.NewUserStore(), memory.NewAuthorizationStore(), memory.NewCodeStore(), nil, nil, "12345", WithLoginMessages(messages, dialogs))
|
||||
phone := "+15550004312"
|
||||
|
||||
hash, err := svc.SendCode(ctx, phone)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode signup: %v", err)
|
||||
}
|
||||
u, first, err := svc.SignUp(ctx, domain.Authorization{}, phone, hash, "Test", "User")
|
||||
if err != nil {
|
||||
t.Fatalf("SignUp: %v", err)
|
||||
}
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: domain.OfficialSystemUserID}
|
||||
if read, err := dialogs.MarkRead(ctx, u.ID, peer, domain.MaxMessageBoxID); err != nil {
|
||||
t.Fatalf("MarkRead first login message: %v", err)
|
||||
} else if read.MaxID != first.ID || read.StillUnreadCount != 0 {
|
||||
t.Fatalf("read first login message = %+v, want max_id %d unread 0", read, first.ID)
|
||||
}
|
||||
|
||||
hash, err = svc.SendCode(ctx, phone)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode signin second: %v", err)
|
||||
}
|
||||
_, second, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345")
|
||||
if err != nil || needSignUp {
|
||||
t.Fatalf("SignIn second needSignUp=%v err=%v", needSignUp, err)
|
||||
}
|
||||
assertOfficialDialog := func(wantTop, wantRead, wantUnread int) {
|
||||
t.Helper()
|
||||
list, err := dialogs.ListByUser(ctx, u.ID, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ListByUser: %v", err)
|
||||
}
|
||||
if len(list.Dialogs) != 1 {
|
||||
t.Fatalf("dialogs = %+v, want official dialog", list.Dialogs)
|
||||
}
|
||||
got := list.Dialogs[0]
|
||||
if got.TopMessage != wantTop || got.ReadInboxMaxID != wantRead || got.UnreadCount != wantUnread {
|
||||
t.Fatalf("dialog = %+v, want top=%d read=%d unread=%d", got, wantTop, wantRead, wantUnread)
|
||||
}
|
||||
}
|
||||
assertOfficialDialog(second.ID, first.ID, 1)
|
||||
|
||||
hash, err = svc.SendCode(ctx, phone)
|
||||
if err != nil {
|
||||
t.Fatalf("SendCode signin third: %v", err)
|
||||
}
|
||||
_, third, needSignUp, err := svc.SignIn(ctx, domain.Authorization{}, phone, hash, "12345")
|
||||
if err != nil || needSignUp {
|
||||
t.Fatalf("SignIn third needSignUp=%v err=%v", needSignUp, err)
|
||||
}
|
||||
assertOfficialDialog(third.ID, first.ID, 2)
|
||||
}
|
||||
|
||||
func TestSignInExistingTwoFactorAccountNeedsPassword(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
passwords := memory.NewPasswordStore()
|
||||
|
|
@ -274,9 +521,23 @@ func TestSignInExistingTwoFactorAccountNeedsPassword(t *testing.T) {
|
|||
if needSignUp || got.ID != u.ID {
|
||||
t.Fatalf("SignIn user=%+v needSignUp=%v, want existing 2FA user", got, needSignUp)
|
||||
}
|
||||
// 两步验证未完成:业务鉴权(UserID)必须视为未登录,避免绕过 2FA。
|
||||
bound, found, err := svc.UserID(ctx, key)
|
||||
if err != nil || found || bound != 0 {
|
||||
t.Fatalf("UserID after password-needed = %d found=%v err=%v, want not-found", bound, found, err)
|
||||
}
|
||||
// 但仍可定位待验证用户,供 auth.checkPassword 继续。
|
||||
pendingUID, pending, err := svc.PendingPasswordUserID(ctx, key)
|
||||
if err != nil || !pending || pendingUID != u.ID {
|
||||
t.Fatalf("PendingPasswordUserID = %d pending=%v err=%v, want %d", pendingUID, pending, err, u.ID)
|
||||
}
|
||||
// 两步验证通过后转为完全授权。
|
||||
if err := svc.CompletePasswordSignIn(ctx, key); err != nil {
|
||||
t.Fatalf("CompletePasswordSignIn: %v", err)
|
||||
}
|
||||
bound, found, err = svc.UserID(ctx, key)
|
||||
if err != nil || !found || bound != u.ID {
|
||||
t.Fatalf("UserID after password-needed = %d found=%v err=%v, want %d", bound, found, err, u.ID)
|
||||
t.Fatalf("UserID after 2FA passed = %d found=%v err=%v, want %d", bound, found, err, u.ID)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue