Files
affiliate_dash/backend/internal/service/login_auth_test.go
T
yml2213 bab522bcb3 feat(auth): 接入 DCAuth v2 登录并完善成员管理
- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名
- 迁移 014:新增 login_sms_codes / user_devices / device_challenges 与 users.phone
- 新增 /api/dcauth/authorize 与 challenge 状态轮询;旧密码登录仅放行未绑定手机号的账号
- 成员管理支持手机号绑定、设备重置、账号改名、昵称与重置密码
- 移除最后一个商户关系时软删除账号,释放用户名、手机号并撤销设备绑定
2026-10-21 22:37:49 +08:00

443 lines
14 KiB
Go

package service
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"os"
"strings"
"testing"
"time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/jwt"
"affiliate_dash/internal/testdb"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestBuildDCAuthCanonicalExact(t *testing.T) {
got := BuildDCAuthCanonical(
"affiliate_dash",
"challenge-id-1",
"challenge-value",
"D1-ABCDEFGH",
1700000000,
)
want := "dcauth.v2\naffiliate_dash\nchallenge-id-1\nchallenge-value\nD1-ABCDEFGH\n1700000000"
if got != want {
t.Fatalf("canonical mismatch:\nwant %q\n got %q", want, got)
}
}
func TestIsSupportedDCAuthClientVersion(t *testing.T) {
cases := []struct {
version string
want bool
}{
{"2.0.0", true},
{"2", true},
{"10.1.2", true},
{"1.9.9", false},
{"0.1.0", false},
{"v2.0.0", false},
{"", false},
}
for _, tc := range cases {
if got := IsSupportedDCAuthClientVersion(tc.version); got != tc.want {
t.Errorf("IsSupportedDCAuthClientVersion(%q) = %v, want %v", tc.version, got, tc.want)
}
}
}
func TestNormalizeAPIBase(t *testing.T) {
cases := []struct {
name string
input string
want string
wantErr bool
}{
{name: "http origin", input: "http://localhost:15173", want: "http://localhost:15173"},
{name: "https with trailing slash", input: "https://admin.example.com/", want: "https://admin.example.com"},
{name: "case normalization", input: "HTTPS://Admin.Example.com", want: "https://admin.example.com"},
{name: "path rejected", input: "https://admin.example.com/api", wantErr: true},
{name: "query rejected", input: "https://admin.example.com?x=1", wantErr: true},
{name: "scheme rejected", input: "ftp://admin.example.com", wantErr: true},
{name: "empty rejected", input: " ", wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := NormalizeAPIBase(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Fatalf("got %q, want %q", got, tc.want)
}
})
}
}
func TestDCAuthChallengeSingleConsumption(t *testing.T) {
db := loginAuthTestDB(t)
user := model.User{
Username: "dcauth-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""),
PasswordHash: "unused",
Role: model.RoleAdmin,
Status: 1,
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("create user: %v", err)
}
svc := NewDCAuthService(db)
prepared, err := svc.CreateChallenge(user.ID, "http://localhost:15173")
if err != nil {
t.Fatalf("create challenge: %v", err)
}
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
deviceID := "D1-ABCDEFGH"
timestamp := time.Now().Unix()
canonical := BuildDCAuthCanonical(DCAuthApp, prepared.ChallengeID, prepared.Challenge, deviceID, timestamp)
signature := ed25519.Sign(privateKey, []byte(canonical))
if err := svc.Authorize(DCAuthorizeRequest{
App: DCAuthApp,
ChallengeID: prepared.ChallengeID,
Challenge: prepared.Challenge,
DeviceID: deviceID,
PublicKey: base64.RawURLEncoding.EncodeToString(publicKey),
Signature: base64.RawURLEncoding.EncodeToString(signature),
Timestamp: timestamp,
ClientVersion: "2.0.0",
}); err != nil {
t.Fatalf("authorize: %v", err)
}
status, err := svc.ChallengeStatus(prepared.ChallengeID, prepared.PollToken)
if err != nil {
t.Fatalf("challenge status: %v", err)
}
if status.Status != model.DeviceChallengeStatusAuthorized || status.DeviceTicket == "" {
t.Fatalf("expected authorized ticket, got %+v", status)
}
consumed, err := svc.ConsumeTicket(prepared.ChallengeID, prepared.PollToken, status.DeviceTicket)
if err != nil {
t.Fatalf("consume ticket: %v", err)
}
if consumed.Status != model.DeviceChallengeStatusConsumed || consumed.DeviceID != deviceID {
t.Fatalf("unexpected consumed challenge: %+v", consumed)
}
if _, err := svc.ConsumeTicket(prepared.ChallengeID, prepared.PollToken, status.DeviceTicket); err == nil {
t.Fatal("second consumption must fail")
} else if authErr, ok := AsAuthError(err); !ok || authErr.Status != 401 {
t.Fatalf("expected 401 on second consumption, got %v", err)
}
}
func TestDCAuthBindingMismatchRejected(t *testing.T) {
db := loginAuthTestDB(t)
user := model.User{
Username: "bind-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""),
PasswordHash: "unused",
Role: model.RoleAdmin,
Status: 1,
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("create user: %v", err)
}
boundKey, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate bound key: %v", err)
}
otherKey, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate other key: %v", err)
}
now := time.Now()
if err := db.Create(&model.UserDevice{
UserID: user.ID,
DeviceID: "D1-BOUNDDEV",
PublicKey: base64.RawURLEncoding.EncodeToString(boundKey),
ClientVersion: "2.0.0",
Status: model.UserDeviceStatusActive,
BoundAt: now,
CreatedAt: now,
UpdatedAt: now,
}).Error; err != nil {
t.Fatalf("create bound device: %v", err)
}
svc := NewDCAuthService(db)
err = svc.EnsureDeviceBinding(user.ID, &model.DeviceChallenge{
DeviceID: "D1-OTHERDEV",
PublicKey: base64.RawURLEncoding.EncodeToString(otherKey),
ClientVersion: "2.0.0",
})
if err == nil {
t.Fatal("device mismatch must be rejected")
}
authErr, ok := AsAuthError(err)
if !ok || authErr.Status != 401 {
t.Fatalf("expected 401 AuthError, got %v", err)
}
if !strings.Contains(authErr.Message, "当前设备与账号绑定设备不一致") {
t.Fatalf("unexpected message: %s", authErr.Message)
}
// 同一设备与公钥应通过。
if err := svc.EnsureDeviceBinding(user.ID, &model.DeviceChallenge{
DeviceID: "D1-BOUNDDEV",
PublicKey: base64.RawURLEncoding.EncodeToString(boundKey),
ClientVersion: "2.0.0",
}); err != nil {
t.Fatalf("matching device should pass: %v", err)
}
}
func TestSecureLoginPrepareCompleteReusesJWT(t *testing.T) {
db := loginAuthTestDB(t)
password := "pass123456"
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
user := model.User{
Username: "secure-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""),
PasswordHash: string(hash),
Phone: "13900139000",
Role: model.RoleAdmin,
Status: 1,
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("create user: %v", err)
}
now := time.Now()
if err := db.Create(&model.LoginSMSCode{
UserID: user.ID,
Phone: user.Phone,
Purpose: model.LoginSMSPurposeLogin,
CodeHash: HashSMSCode("123456"),
ExpiresAt: now.Add(5 * time.Minute),
CreatedAt: now,
}).Error; err != nil {
t.Fatalf("create sms code: %v", err)
}
jm := jwt.NewManager("test-jwt-secret")
smsSvc := NewLoginSMSService(db, SMSConfig{Provider: "mock"})
deviceSvc := NewDCAuthService(db)
authSvc := NewAuthService(db, jm, nil, WithLoginSecurity(smsSvc, deviceSvc))
prepared, err := authSvc.PrepareLogin(user.Username, password, "123456", "http://localhost:15173")
if err != nil {
t.Fatalf("prepare login: %v", err)
}
if prepared.App != DCAuthApp || prepared.PhoneMasked != "139****9000" {
t.Fatalf("unexpected prepared result: %+v", prepared)
}
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
deviceID := "D1-SECUREDEV"
timestamp := time.Now().Unix()
canonical := BuildDCAuthCanonical(DCAuthApp, prepared.ChallengeID, prepared.Challenge, deviceID, timestamp)
if err := deviceSvc.Authorize(DCAuthorizeRequest{
App: DCAuthApp,
ChallengeID: prepared.ChallengeID,
Challenge: prepared.Challenge,
DeviceID: deviceID,
PublicKey: base64.RawURLEncoding.EncodeToString(publicKey),
Signature: base64.RawURLEncoding.EncodeToString(ed25519.Sign(privateKey, []byte(canonical))),
Timestamp: timestamp,
ClientVersion: "2.0.0",
}); err != nil {
t.Fatalf("authorize: %v", err)
}
status, err := deviceSvc.ChallengeStatus(prepared.ChallengeID, prepared.PollToken)
if err != nil || status.DeviceTicket == "" {
t.Fatalf("challenge status: %+v %v", status, err)
}
result, err := authSvc.CompleteLogin(prepared.ChallengeID, prepared.PollToken, status.DeviceTicket)
if err != nil {
t.Fatalf("complete login: %v", err)
}
if result.Token == "" || result.User == nil || result.User.ID != user.ID {
t.Fatalf("unexpected login result: %+v", result)
}
claims, err := jm.Parse(result.Token)
if err != nil || claims.UserID != user.ID {
t.Fatalf("issued JWT should be reusable: %+v %v", claims, err)
}
var device model.UserDevice
if err := db.Where("user_id = ? AND status = ?", user.ID, model.UserDeviceStatusActive).First(&device).Error; err != nil {
t.Fatalf("device should be auto-bound: %v", err)
}
if device.DeviceID != deviceID {
t.Fatalf("unexpected bound device: %+v", device)
}
if _, err := authSvc.CompleteLogin(prepared.ChallengeID, prepared.PollToken, status.DeviceTicket); err == nil {
t.Fatal("ticket must be single-use")
}
if _, err := authSvc.Login(user.Username, password); err == nil {
t.Fatal("password-only login must be rejected once phone is bound")
} else if authErr, ok := AsAuthError(err); !ok || authErr.Status != 403 {
t.Fatalf("expected 403 for bound account legacy login, got %v", err)
}
}
func TestBindUserPhoneAndResetDevice(t *testing.T) {
db := loginAuthTestDB(t)
first := model.User{Username: "bindphone-a-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""), PasswordHash: "x", Role: model.RoleAdmin, Status: 1}
second := model.User{Username: "bindphone-b-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""), PasswordHash: "x", Role: model.RoleAdmin, Status: 1}
if err := db.Create(&first).Error; err != nil {
t.Fatalf("create first user: %v", err)
}
if err := db.Create(&second).Error; err != nil {
t.Fatalf("create second user: %v", err)
}
actor := first.ID
svc := NewUserService(db, nil)
updated, err := svc.BindUserPhone(first.ID, "13700137000", actor)
if err != nil {
t.Fatalf("bind phone: %v", err)
}
if updated.Phone != "13700137000" || updated.PhoneBoundBy == nil || *updated.PhoneBoundBy != actor {
t.Fatalf("unexpected bound user: %+v", updated)
}
if _, err := svc.BindUserPhone(second.ID, "13700137000", actor); err == nil {
t.Fatal("duplicate phone must be rejected")
} else if authErr, ok := AsAuthError(err); !ok || authErr.Status != 409 {
t.Fatalf("expected 409 duplicate phone, got %v", err)
}
now := time.Now()
device := model.UserDevice{
UserID: first.ID,
DeviceID: "D1-RESETDEV",
PublicKey: "test-public-key",
ClientVersion: "2.0.0",
Status: model.UserDeviceStatusActive,
BoundAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Create(&device).Error; err != nil {
t.Fatalf("create device: %v", err)
}
reset, err := svc.ResetUserDevice(first.ID, actor)
if err != nil || !reset {
t.Fatalf("reset device: reset=%v err=%v", reset, err)
}
var stored model.UserDevice
if err := db.First(&stored, device.ID).Error; err != nil {
t.Fatalf("reload device: %v", err)
}
if stored.Status != model.UserDeviceStatusRevoked || stored.RevokedAt == nil {
t.Fatalf("device should be revoked: %+v", stored)
}
if reset, err := svc.ResetUserDevice(first.ID, actor); err != nil || reset {
t.Fatalf("second reset should be a no-op: reset=%v err=%v", reset, err)
}
}
func TestLoginSMSCodeLocksAfterFiveFailures(t *testing.T) {
db := loginAuthTestDB(t)
user := model.User{
Username: "sms-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""),
PasswordHash: "unused",
Phone: "13800138000",
Role: model.RoleAdmin,
Status: 1,
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("create user: %v", err)
}
now := time.Now()
row := model.LoginSMSCode{
UserID: user.ID,
Phone: user.Phone,
Purpose: model.LoginSMSPurposeLogin,
CodeHash: HashSMSCode("123456"),
ExpiresAt: now.Add(5 * time.Minute),
CreatedAt: now,
}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("create sms code: %v", err)
}
svc := NewLoginSMSService(db, SMSConfig{})
for attempt := 1; attempt <= 4; attempt++ {
err := svc.VerifyLoginCode(user.ID, "000000")
authErr, ok := AsAuthError(err)
if !ok || authErr.Code != "sms_code_invalid" {
t.Fatalf("attempt %d: expected sms_code_invalid, got %v", attempt, err)
}
}
err := svc.VerifyLoginCode(user.ID, "000000")
authErr, ok := AsAuthError(err)
if !ok || authErr.Code != "sms_code_locked" {
t.Fatalf("fifth attempt should lock the code, got %v", err)
}
var stored model.LoginSMSCode
if err := db.First(&stored, row.ID).Error; err != nil {
t.Fatalf("reload sms code: %v", err)
}
if stored.FailedAttemptCount != 5 || stored.ConsumedAt == nil {
t.Fatalf("expected locked row with 5 failures, got %+v", stored)
}
if err := svc.VerifyLoginCode(user.ID, "123456"); err == nil {
t.Fatal("correct code must not work after lock")
}
}
// loginAuthTestDB 在 PostgreSQL 可用时创建隔离 schema;不可用时跳过集成断言。
func loginAuthTestDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
dsn = os.Getenv("DATABASE_URL")
}
if dsn == "" {
dsn = "postgres://affiliate:affiliate_dev_password@127.0.0.1:15432/affiliate_dash?sslmode=disable"
}
probe, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Skipf("跳过数据库集成测试:PostgreSQL 不可用: %v", err)
}
sqlDB, err := probe.DB()
if err != nil {
t.Skipf("跳过数据库集成测试:获取连接失败: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := sqlDB.PingContext(ctx); err != nil {
_ = sqlDB.Close()
t.Skipf("跳过数据库集成测试:PostgreSQL ping 失败: %v", err)
}
_ = sqlDB.Close()
return testdb.New(t)
}