feat(auth): 接入 DCAuth v2 登录并完善成员管理
- 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名 - 迁移 014:新增 login_sms_codes / user_devices / device_challenges 与 users.phone - 新增 /api/dcauth/authorize 与 challenge 状态轮询;旧密码登录仅放行未绑定手机号的账号 - 成员管理支持手机号绑定、设备重置、账号改名、昵称与重置密码 - 移除最后一个商户关系时软删除账号,释放用户名、手机号并撤销设备绑定
This commit is contained in:
@@ -0,0 +1,442 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user