Some checks are pending
Security Checks / security (push) Waiting to run
将SMTP信封收件人与授权快照持久保存,按收件人投递并在执行前复核凭据状态;保留原邮件日期,新增队列元数据迁移并补齐相关回归用例源码。
206 lines
6.9 KiB
Go
206 lines
6.9 KiB
Go
package auth
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.pcux.cn/xuans/Smail/config"
|
|
"git.pcux.cn/xuans/Smail/db"
|
|
"git.pcux.cn/xuans/Smail/internal/passwordtest"
|
|
"git.pcux.cn/xuans/Smail/models"
|
|
"git.pcux.cn/xuans/Smail/services/credentials"
|
|
"git.pcux.cn/xuans/Smail/services/notification"
|
|
"xorm.io/xorm"
|
|
)
|
|
|
|
func TestQueuedNotificationAuthorizationTracksCredentialVersion(t *testing.T) {
|
|
oldDB := db.Instance
|
|
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "notification.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = engine.Close(); db.Instance = oldDB })
|
|
db.Instance = engine
|
|
if err := engine.Sync2(&models.User{}, &models.NotificationGrant{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
user := models.User{Account: "sender", Password: "test-hash", Role: "user", SessionVersion: 1, SecurityVersion: 1}
|
|
if _, err := engine.Insert(&user); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
code := "sn_" + strings.Repeat("A", 43)
|
|
item := models.NotificationGrant{
|
|
UserID: user.ID, Enabled: 1, CodeVersion: 2, CodeHash: queuedDigest(code),
|
|
SessionVersion: user.SessionVersion, SecurityVersion: user.SecurityVersion,
|
|
}
|
|
if _, err := engine.Insert(&item); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
grant, err := notification.Authenticate(engine, user, code)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session := &ProtocolSession{user: user, notification: grant}
|
|
snapshot, err := session.QueueAuthorization()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if snapshot.Kind != "notification" || snapshot.ID != user.ID || snapshot.Version != item.CodeVersion ||
|
|
snapshot.Credential == code || snapshot.Credential == item.CodeHash {
|
|
t.Fatal("notification snapshot is not bound to the original credential")
|
|
}
|
|
if valid, err := snapshot.Check(engine, user); err != nil || !valid {
|
|
t.Fatalf("notification queue authorization = %v, %v", valid, err)
|
|
}
|
|
for _, change := range []string{"disable", "version", "hash", "session", "security"} {
|
|
t.Run(change, func(t *testing.T) {
|
|
updated := item
|
|
switch change {
|
|
case "disable":
|
|
updated.Enabled = 0
|
|
case "version":
|
|
updated.CodeVersion++
|
|
case "hash":
|
|
updated.CodeHash = queuedDigest("replacement")
|
|
case "session":
|
|
updated.SessionVersion++
|
|
case "security":
|
|
updated.SecurityVersion++
|
|
}
|
|
if _, err := engine.ID(user.ID).Cols("enabled", "code_version", "code_hash", "session_version", "security_version").Update(&updated); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if valid, err := snapshot.Check(engine, user); err != nil || valid {
|
|
t.Fatalf("stale notification queue authorization = %v, %v", valid, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProtocolSessionRechecksUserAndCredential(t *testing.T) {
|
|
for _, kind := range []string{"protocol", "client"} {
|
|
for _, change := range []string{"revoke", "disabled", "password", "session_version", "security_version", "role", "deleted", "database_failure"} {
|
|
t.Run(kind+"/"+change, func(t *testing.T) {
|
|
oldDB, oldConfig := db.Instance, config.Instance
|
|
engine, err := xorm.NewEngine("sqlite", filepath.Join(t.TempDir(), "session.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = engine.Close(); db.Instance, config.Instance = oldDB, oldConfig })
|
|
db.Instance = engine
|
|
config.Instance = &config.Config{Domain: "example.com"}
|
|
if err := engine.Sync2(&models.User{}, &models.UserEmail{}, &models.ClientCredential{}, &models.ProtocolCredential{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
user := models.User{Account: "session-user", Password: "test-hash", Role: "user", SessionVersion: 1, SecurityVersion: 1}
|
|
if _, err := engine.Insert(&user); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
address, now := "session-user@example.com", time.Now().UTC()
|
|
if _, err := engine.Insert(&models.UserEmail{UserID: user.ID, Address: address, AddressCanonical: &address, VerifiedAt: &now}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service, err := credentials.NewService(engine)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var code string
|
|
var clientID int
|
|
if kind == "protocol" {
|
|
if err := service.SetProtocolEnabled(user.ID, "smtp", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
code, err = service.GenerateProtocolCode(user.ID, "smtp")
|
|
} else {
|
|
var item *credentials.ClientCredentialItem
|
|
code, item, err = service.CreateClientCredential(user.ID, "会话验收")
|
|
if item != nil {
|
|
clientID = item.ID
|
|
}
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, session, err := AuthenticateProtocolSession("smtp", "192.0.2.91:1234", address, code)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
queued, err := session.QueueAuthorization()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if valid, err := queued.Check(engine, user); err != nil || !valid {
|
|
t.Fatalf("new queued authorization = %v, %v", valid, err)
|
|
}
|
|
if queued.Credential == code || queued.Identity == user.Password || queued.Kind == "" {
|
|
t.Fatal("queued authorization contains reusable credentials or has no kind")
|
|
}
|
|
// 命令复核不占用密码哈希容量,也不累加登录失败计数。
|
|
release := passwordtest.Hold(t)
|
|
if err := session.Check(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
release()
|
|
switch change {
|
|
case "revoke":
|
|
if kind == "protocol" {
|
|
err = service.RevokeProtocolCode(user.ID, "smtp")
|
|
} else {
|
|
err = service.RevokeClientCredential(user.ID, clientID)
|
|
}
|
|
case "disabled":
|
|
_, err = engine.ID(user.ID).Cols("disabled").Update(&models.User{Disabled: 1})
|
|
case "password":
|
|
_, err = engine.ID(user.ID).Cols("password").Update(&models.User{Password: "new-hash"})
|
|
case "session_version":
|
|
_, err = engine.ID(user.ID).Incr("session_version", 1).Update(&models.User{})
|
|
case "security_version":
|
|
_, err = engine.ID(user.ID).Incr("security_version", 1).Update(&models.User{})
|
|
case "role":
|
|
_, err = engine.ID(user.ID).Cols("role").Update(&models.User{Role: "admin"})
|
|
case "deleted":
|
|
_, err = engine.ID(user.ID).Delete(&models.User{})
|
|
case "database_failure":
|
|
err = engine.Close()
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
current := user
|
|
if change != "database_failure" {
|
|
current = models.User{}
|
|
if _, err := engine.ID(user.ID).Get(¤t); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
valid, checkErr := queued.Check(engine, current)
|
|
if valid || (change == "database_failure") != (checkErr != nil) {
|
|
t.Fatalf("stale queued authorization = %v, %v", valid, checkErr)
|
|
}
|
|
var wait sync.WaitGroup
|
|
for range 4 {
|
|
wait.Add(1)
|
|
go func() {
|
|
defer wait.Done()
|
|
if session.Check() == nil {
|
|
t.Error("旧会话仍可访问数据")
|
|
}
|
|
}()
|
|
}
|
|
wait.Wait()
|
|
if change == "disabled" {
|
|
if _, err := engine.ID(user.ID).Cols("disabled").Update(&models.User{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if session.Check() == nil {
|
|
t.Fatal("被拒绝的连接不应恢复授权")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|