Files
new-api/controller/security_account_test.go
T
CaIon caca52f8de feat(auth): replace the dashboard access token with scoped access tokens
Each user can now hold up to 20 named access tokens in the
user_access_tokens table instead of a single token on the user row.
Tokens are shown once at creation (nap_ prefix) and stored as a SHA-256
digest. They expire after 30 days by default; a token without expiry is
allowed with a warning.

A token only reaches the routes its scopes allow. Scopes are picked from
the same permission matrix as admin permissions and are limited to what
the owner can do; every management route declares the scope it needs,
and routes without a declaration reject tokens. Token management itself
always requires a browser session.

Tokens can complete step-up verification, so verified actions such as
changing a password or 2FA settings work with a token; such changes sign
out all browser sessions. Disabling a user keeps their tokens, deleting
the account removes them.

The existing dashboard token keeps working for 30 days after the
upgrade. It can be revoked but no longer regenerated, and afterwards it
is rejected with ACCESS_TOKEN_LEGACY_RETIRED. The security page lists
tokens with their access records, and audit details show scope names.
2026-09-30 17:24:19 +08:00

849 lines
39 KiB
Go

package controller
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"io"
"net"
"net/http"
"net/textproto"
"regexp"
"strings"
"sync"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/system_setting"
"github.com/gin-gonic/gin"
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestSecurityAccountDeletionRequiresScopedProof(t *testing.T) {
for _, scenario := range []string{"missing", "wrong scope", "expired", "consumed", "other session", "other account", "password disabled", "factor added"} {
t.Run(scenario, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
originalIdentity := identity
operation := service.VerificationOperation{Scope: service.VerificationScopeAccountDelete}
proof := ""
if scenario != "missing" {
proof = issueSecurityEnrollmentProof(t, identity, operation, service.VerificationMethodPassword)
}
if scenario == "wrong scope" {
proof = issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopePasswordChange}, service.VerificationMethodPassword)
}
switch scenario {
case "expired":
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeSecurityProof).Update("expires_at", time.Now().Add(-time.Minute)).Error)
case "consumed":
_, err := service.ConsumeOperationProof(proof, identity, operation)
require.NoError(t, err)
case "other session", "other account":
userID := user.Id
if scenario == "other account" {
other := &model.User{Username: "other", AffCode: "other", Group: "default", Password: user.Password, Status: common.UserStatusEnabled, Role: common.RoleCommonUser, AuthVersion: 1}
require.NoError(t, model.DB.Create(other).Error)
userID = other.Id
}
bundle, err := service.CreateLoginSession(userID, "password", "127.0.0.1", scenario)
require.NoError(t, err)
identity, err = service.ParseAccessToken(bundle.AccessToken)
require.NoError(t, err)
case "password disabled":
previous := common.PasswordLoginEnabled
common.PasswordLoginEnabled = false
t.Cleanup(func() { common.PasswordLoginEnabled = previous })
case "factor added":
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
}
response := securityEnrollmentRequest("DELETE", "/api/user/self", "", proof, identity, DeleteSelf)
assert.Equal(t, http.StatusForbidden, response.Code, response.Body.String())
stored, err := model.GetUserById(user.Id, false)
require.NoError(t, err)
assert.Equal(t, identity.UserAuthVersion, stored.AuthVersion)
_, _, err = service.ValidateLoginSession(originalIdentity)
assert.NoError(t, err)
var audit model.AuditLog
require.NoError(t, model.LOG_DB.Where("action = ?", "user.account_delete").Last(&audit).Error)
assert.False(t, audit.Success)
auditJSON, err := common.Marshal(audit)
require.NoError(t, err)
if proof != "" {
assert.NotContains(t, string(auditJSON), proof)
}
})
}
}
func TestSecurityAccountDeletionAcceptsEitherFactorAndRevokesSessions(t *testing.T) {
for _, method := range []string{"password", "oauth", "2fa", "passkey"} {
t.Run(method, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
if method == "oauth" {
require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "github_id": "linked-user"}).Error)
oauth.Register("account-delete-oauth", &enrollmentOAuthProvider{externalID: "linked-user"})
t.Cleanup(func() { oauth.Unregister("account-delete-oauth") })
}
if method == "2fa" || method == "passkey" {
newSecurityLoginPasskey(t, user.Id)
factor := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
if method == "passkey" {
locked := time.Now().Add(time.Minute)
factor.LockedUntil = &locked
} else {
system_setting.GetPasskeySettings().Enabled = false
}
require.NoError(t, model.DB.Create(factor).Error)
}
requirements, err := service.GetVerificationRequirements(identity, service.VerificationScopeAccountDelete)
require.NoError(t, err)
_, err = service.RequireVerificationMethod(identity, service.VerificationScopeAccountDelete, method)
require.NoError(t, err)
if method == "2fa" || method == "passkey" {
assert.Len(t, requirements.Methods, 2)
_, err = service.RequireVerificationMethod(identity, service.VerificationScopeAccountDelete, "password")
assert.ErrorIs(t, err, service.ErrProofMethod)
}
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountDelete}, method)
if method == "password" || method == "2fa" {
input := service.VerificationInput{Scope: service.VerificationScopeAccountDelete, Method: method, Password: "enrollment-password"}
if method == "2fa" {
input.Code, err = totp.GenerateCode("JBSWY3DPEHPK3PXP", time.Now())
require.NoError(t, err)
}
verified, err := service.VerifySecurityInput(identity, input)
require.NoError(t, err)
proof = verified.ProofToken
}
otherSession, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "second-session")
require.NoError(t, err)
require.NoError(t, model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("access_token", "account-delete-access-token").Error)
response := securityEnrollmentRequest("DELETE", "/api/user/self", "", proof, identity, DeleteSelf)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
assert.Contains(t, response.Header().Get("Set-Cookie"), "Max-Age=0")
assert.Contains(t, response.Header().Get("Cache-Control"), "no-store")
var deleted model.User
require.NoError(t, model.DB.Unscoped().First(&deleted, user.Id).Error)
assert.True(t, deleted.DeletedAt.Valid)
assert.Equal(t, user.AuthVersion+1, deleted.AuthVersion)
_, _, err = service.ValidateLoginSession(identity)
assert.Error(t, err)
otherIdentity, err := service.ParseAccessToken(otherSession.AccessToken)
require.NoError(t, err)
_, _, err = service.ValidateLoginSession(otherIdentity)
assert.Error(t, err)
_, _, err = service.RefreshLoginSession(otherSession.RefreshToken, otherIdentity.SessionID, "127.0.0.1", "second-session")
assert.Error(t, err)
count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
require.NoError(t, err)
assert.Zero(t, count)
tokenUser, err := model.ValidateAccessToken("account-delete-access-token")
require.NoError(t, err)
assert.Nil(t, tokenUser)
var audit model.AuditLog
require.NoError(t, model.LOG_DB.Where("action = ?", "user.account_delete").Last(&audit).Error)
assert.True(t, audit.Success)
})
}
}
func TestSecurityAccountDeletionRechecksTransactionAndConsumesFailedProof(t *testing.T) {
for _, scenario := range []string{"revoked session", "auth version", "root", "write failure"} {
t.Run(scenario, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
operation := service.VerificationOperation{Scope: service.VerificationScopeAccountDelete}
proof := issueSecurityEnrollmentProof(t, identity, operation, "password")
if scenario != "write failure" {
_, err := service.ConsumeOperationProof(proof, identity, operation)
require.NoError(t, err)
}
switch scenario {
case "revoked session":
_, err := model.RevokeAllUserSessions(user.Id, "test")
require.NoError(t, err)
case "auth version":
require.NoError(t, model.DB.Model(user).Update("auth_version", user.AuthVersion+1).Error)
case "root":
require.NoError(t, model.DB.Model(user).Update("role", common.RoleRootUser).Error)
_, err := service.GetVerificationRequirements(identity, service.VerificationScopeAccountDelete)
assert.ErrorIs(t, err, service.ErrVerificationForbidden)
case "write failure":
require.NoError(t, model.DB.Callback().Delete().Before("gorm:delete").Register("account-delete-failure", func(tx *gorm.DB) {
if tx.Statement.Table == "users" {
_ = tx.AddError(errors.New("injected deletion failure"))
}
}))
response := securityEnrollmentRequest("DELETE", "/api/user/self", "", proof, identity, DeleteSelf)
assert.Contains(t, response.Body.String(), `"success":false`)
require.NoError(t, model.DB.Callback().Delete().Remove("account-delete-failure"))
response = securityEnrollmentRequest("DELETE", "/api/user/self", "", proof, identity, DeleteSelf)
assert.Contains(t, response.Body.String(), "SECURITY_PROOF_CONSUMED")
}
if scenario != "write failure" {
_, err := model.DeleteUserForSession(identity)
assert.Error(t, err)
}
_, err := model.GetUserById(user.Id, false)
require.NoError(t, err)
if scenario == "write failure" {
_, _, err = service.ValidateLoginSession(identity)
assert.NoError(t, err, "failed deletion must roll back the account version and preserve sessions")
}
})
}
}
func TestSecurityAccountDeletionConcurrentRequestsHaveOneWinner(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountDelete}, "password")
start := make(chan struct{})
responses := make(chan string, 2)
for range 2 {
go func() {
<-start
response := securityEnrollmentRequest("DELETE", "/api/user/self", "", proof, identity, DeleteSelf)
responses <- response.Body.String()
}()
}
close(start)
succeeded := 0
for range 2 {
var response securityEnrollmentResponse
require.NoError(t, common.UnmarshalJsonStr(<-responses, &response))
if response.Success {
succeeded++
}
}
assert.Equal(t, 1, succeeded)
var deleted model.User
require.NoError(t, model.DB.Unscoped().First(&deleted, user.Id).Error)
assert.True(t, deleted.DeletedAt.Valid)
assert.Equal(t, identity.UserAuthVersion+1, deleted.AuthVersion)
}
type securityMailbox struct {
mutex sync.Mutex
mail map[string][]string
}
// Use a real local SMTP boundary so delivery, failure and code handling are
// exercised without exporting test-only mail hooks from production packages.
func newSecurityMailbox(t *testing.T) *securityMailbox {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
mailbox := &securityMailbox{mail: map[string][]string{}}
previousServer, previousPort := common.SMTPServer, common.SMTPPort
previousAccount, previousFrom, previousToken := common.SMTPAccount, common.SMTPFrom, common.SMTPToken
previousSSL, previousTLS := common.SMTPSSLEnabled, common.SMTPStartTLSEnabled
common.SMTPServer, common.SMTPPort = "127.0.0.1", listener.Addr().(*net.TCPAddr).Port
common.SMTPAccount, common.SMTPFrom, common.SMTPToken = "", "[email protected]", ""
common.SMTPSSLEnabled, common.SMTPStartTLSEnabled = false, false
done := make(chan struct{})
go func() {
defer close(done)
for {
connection, err := listener.Accept()
if err != nil {
return
}
mailbox.receive(connection)
}
}()
t.Cleanup(func() {
_ = listener.Close()
<-done
common.SMTPServer, common.SMTPPort = previousServer, previousPort
common.SMTPAccount, common.SMTPFrom, common.SMTPToken = previousAccount, previousFrom, previousToken
common.SMTPSSLEnabled, common.SMTPStartTLSEnabled = previousSSL, previousTLS
})
return mailbox
}
func (mailbox *securityMailbox) receive(connection net.Conn) {
defer connection.Close()
_ = connection.SetDeadline(time.Now().Add(10 * time.Second))
client := textproto.NewConn(connection)
if client.PrintfLine("220 localhost ESMTP") != nil {
return
}
receiver := ""
for {
line, err := client.ReadLine()
if err != nil {
return
}
switch {
case strings.HasPrefix(line, "RCPT TO:"):
receiver = strings.TrimSuffix(strings.TrimPrefix(line, "RCPT TO:<"), ">")
case line == "DATA":
if client.PrintfLine("354 Send message") != nil {
return
}
message, err := io.ReadAll(client.DotReader())
if err != nil {
return
}
mailbox.mutex.Lock()
mailbox.mail[receiver] = append(mailbox.mail[receiver], string(message))
mailbox.mutex.Unlock()
case line == "QUIT":
_ = client.PrintfLine("221 Goodbye")
return
}
if client.PrintfLine("250 OK") != nil {
return
}
}
}
func (mailbox *securityMailbox) code(t *testing.T, receiver string) string {
t.Helper()
mailbox.mutex.Lock()
defer mailbox.mutex.Unlock()
for index := len(mailbox.mail[receiver]) - 1; index >= 0; index-- {
match := regexp.MustCompile(`<strong>([0-9]{6})</strong>`).FindStringSubmatch(mailbox.mail[receiver][index])
if len(match) == 2 {
return match[1]
}
}
t.Fatalf("no verification email delivered to %s", receiver)
return ""
}
func startSecurityEmailBinding(t *testing.T, identity service.AuthIdentity, email, method string) service.EmailBindingData {
t.Helper()
context, err := common.Marshal(service.AccountBindingContext{Provider: "email", Email: email})
require.NoError(t, err)
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: context}, method)
request, err := common.Marshal(map[string]string{"email": email})
require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/oauth/email/bind/start", string(request), proof, identity, EmailBindStart)
var body securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
require.True(t, body.Success, response.Body.String())
var flow service.EmailBindingData
require.NoError(t, common.Unmarshal(body.Data, &flow))
require.NotEmpty(t, flow.FlowToken)
return flow
}
func TestSecurityAccountRequiresProofBeforeMutation(t *testing.T) {
for _, test := range []struct {
name, path, body string
handler gin.HandlerFunc
}{
{"password", "/api/user/self", `{"password":"account-password!42","original_password":"enrollment-password"}`, UpdateSelf},
{"oauth binding", "/api/oauth/state", `{"provider":"security-account-test","intent":"bind"}`, GenerateOAuthCode},
} {
t.Run(test.name, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
oauth.Register("security-account-test", &authFlowTestOAuthProvider{})
t.Cleanup(func() { oauth.Unregister("security-account-test") })
response := securityEnrollmentRequest(http.MethodPost, test.path, test.body, "", identity, test.handler)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Equal(t, "SECURITY_PROOF_REQUIRED", result.Code)
stored, err := model.GetUserById(user.Id, true)
require.NoError(t, err)
assert.True(t, common.ValidatePasswordAndHash("enrollment-password", stored.Password))
var flows int64
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Count(&flows).Error)
assert.Zero(t, flows)
})
}
}
func TestSecurityAccountPasswordRequiresCurrentPassword(t *testing.T) {
for _, test := range []struct {
name, original string
success bool
}{
{"missing", "", false},
{"wrong", "wrong-password", false},
{"correct", "enrollment-password", true},
} {
t.Run(test.name, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopePasswordChange}, service.VerificationMethodPassword)
request, err := common.Marshal(map[string]string{"password": "password123", "original_password": test.original})
require.NoError(t, err)
response := securityEnrollmentRequest(http.MethodPut, "/api/user/self", string(request), proof, identity, UpdateSelf)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, test.success, result.Success, response.Body.String())
stored, err := model.GetUserById(user.Id, true)
require.NoError(t, err)
if test.success {
assert.True(t, common.ValidatePasswordAndHash("password123", stored.Password))
assert.True(t, strings.HasPrefix(stored.Password, "$argon2id$"))
} else {
assert.Equal(t, "CURRENT_PASSWORD_INVALID", result.Code)
assert.Equal(t, user.Password, stored.Password)
repeated := securityEnrollmentRequest(http.MethodPut, "/api/user/self", string(request), proof, identity, UpdateSelf)
require.NoError(t, common.Unmarshal(repeated.Body.Bytes(), &result))
assert.Equal(t, "SECURITY_PROOF_CONSUMED", result.Code)
}
})
}
}
func TestSecurityAccountProfileReadsPasswordStatusInOneQuery(t *testing.T) {
for _, hasPassword := range []bool{true, false} {
name := "password set"
if !hasPassword {
name = "password unset"
}
t.Run(name, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
updates := map[string]any{"access_token": "private-profile-token", "remark": "private-profile-remark"}
if !hasPassword {
updates["password"] = ""
}
require.NoError(t, model.DB.Model(&model.User{}).Where("id = ?", user.Id).Updates(updates).Error)
queries := 0
require.NoError(t, model.DB.Callback().Query().Before("gorm:query").Register("profile_query_count", func(tx *gorm.DB) {
queries++
}))
profile, err := model.GetSelfUserById(user.Id)
require.NoError(t, err)
assert.Equal(t, 1, queries)
assert.Equal(t, hasPassword, profile.HasPassword)
assert.Empty(t, profile.Password)
assert.Nil(t, profile.AccessToken)
assert.Empty(t, profile.Remark)
assert.Equal(t, user.AuthVersion, profile.AuthVersion)
assert.Equal(t, user.Group, profile.Group)
require.NoError(t, model.DB.Callback().Query().Remove("profile_query_count"))
response := securityEnrollmentRequest(http.MethodGet, "/api/user/self", "", "", identity, GetSelf)
var result struct {
Success bool `json:"success"`
Data map[string]any `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
assert.Equal(t, hasPassword, result.Data["has_password"])
assert.NotContains(t, result.Data, "password")
assert.NotContains(t, result.Data, "access_token")
assert.NotContains(t, result.Data, "remark")
bundle, err := service.CreateLoginSession(user.Id, "profile-test", "127.0.0.1", "profile-test")
require.NoError(t, err)
_, refreshed, err := service.RefreshLoginSession(bundle.RefreshToken, "", "127.0.0.1", "profile-test")
require.NoError(t, err)
assert.Equal(t, hasPassword, refreshed.HasPassword)
assert.Empty(t, refreshed.Password)
assert.Nil(t, refreshed.AccessToken)
})
}
}
func TestSecurityAccountProfileUpdateDoesNotRequireProof(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
response := securityEnrollmentRequest(http.MethodPut, "/api/user/self", `{"display_name":"Updated"}`, "", identity, UpdateSelf)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
stored, err := model.GetUserById(user.Id, true)
require.NoError(t, err)
assert.Equal(t, "Updated", stored.DisplayName)
assert.Equal(t, user.Password, stored.Password)
}
func TestSecurityAccountLongUnicodePasswordAndSessionRotation(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
other, err := service.CreateLoginSession(user.Id, "password", "127.0.0.1", "other-device")
require.NoError(t, err)
otherIdentity, err := service.ParseAccessToken(other.AccessToken)
require.NoError(t, err)
password := strings.Repeat("安全🔒 ", 24)
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopePasswordChange}, service.VerificationMethodPassword)
body, err := common.Marshal(map[string]string{"password": password, "original_password": "enrollment-password"})
require.NoError(t, err)
response := securityEnrollmentRequest(http.MethodPut, "/api/user/self", string(body), proof, identity, UpdateSelf)
var result struct {
Success bool `json:"success"`
Data struct {
AccessToken string `json:"access_token"`
HasPassword bool `json:"has_password"`
} `json:"data"`
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
assert.True(t, result.Data.HasPassword)
currentIdentity, err := service.ParseAccessToken(result.Data.AccessToken)
require.NoError(t, err)
_, _, err = service.ValidateLoginSession(currentIdentity)
require.NoError(t, err)
_, _, err = service.ValidateLoginSession(otherIdentity)
require.Error(t, err)
login := model.User{Username: user.Username, Password: password}
require.NoError(t, login.ValidateAndFill())
assert.False(t, common.ValidatePasswordAndHash(strings.TrimSpace(password), login.Password))
assert.False(t, common.ValidatePasswordAndHash("enrollment-password", login.Password))
}
func TestSecurityAccountPasswordPolicyAndHashCompatibility(t *testing.T) {
t.Setenv("ACCOUNT_PASSWORD_HASH_ALGORITHM", "argon2id")
for _, test := range []struct {
name, password string
valid bool
}{
{"common password accepted", "password123", true},
{"short", "seven77", false},
{"too long", strings.Repeat("界", 129), false},
{"long Unicode", strings.Repeat("界🔒 ", 40), true},
{"whitespace preserved", " phrase with whitespace ", true},
} {
t.Run(test.name, func(t *testing.T) {
hash, err := common.HashAccountPassword(test.password)
if !test.valid {
require.Error(t, err)
assert.Empty(t, hash)
return
}
require.NoError(t, err)
assert.True(t, common.ValidatePasswordAndHash(test.password, hash))
assert.False(t, common.ValidatePasswordAndHash(test.password+"x", hash))
})
}
legacy, err := common.Password2Hash("123456")
require.NoError(t, err)
assert.True(t, common.ValidatePasswordAndHash("123456", legacy), "login preserves historical passwords without applying new policy")
for _, invalid := range []string{"$argon2id$", "$argon2id$v=19$m=4294967295,t=2,p=1$bad$bad", "$argon2id$v=19$m=19456,t=2,p=1$bad$bad"} {
assert.False(t, common.ValidatePasswordAndHash("example-password", invalid))
}
}
func TestSecurityAccountEncryptedLongPasswordLogin(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
password := strings.Repeat("🔒", 128)
hash, err := common.HashAccountPassword(password)
require.NoError(t, err)
require.NoError(t, model.DB.Model(user).Update("password", hash).Error)
keyID, publicPEM := common.PasswordEncryptionPublicKey()
if keyID == "" {
privatePEM, err := common.GeneratePasswordEncryptionPrivateKey()
require.NoError(t, err)
require.NoError(t, common.LoadPasswordEncryptionPrivateKey(privatePEM))
keyID, publicPEM = common.PasswordEncryptionPublicKey()
}
block, _ := pem.Decode([]byte(publicPEM))
require.NotNil(t, block)
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
require.NoError(t, err)
publicKey, ok := parsed.(*rsa.PublicKey)
require.True(t, ok)
key, nonce := make([]byte, 32), make([]byte, 12)
_, err = rand.Read(key)
require.NoError(t, err)
_, err = rand.Read(nonce)
require.NoError(t, err)
wrappedKey, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, publicKey, key, []byte("password-v2"))
require.NoError(t, err)
aesBlock, err := aes.NewCipher(key)
require.NoError(t, err)
gcm, err := cipher.NewGCM(aesBlock)
require.NoError(t, err)
ciphertext := gcm.Seal(nil, nonce, []byte(password), []byte("password-v2:"+keyID))
parts := []string{"v2", base64.StdEncoding.EncodeToString(wrappedKey), base64.StdEncoding.EncodeToString(nonce), base64.StdEncoding.EncodeToString(ciphertext)}
encrypted := strings.Join(parts, ".")
common.PasswordLoginEncryptionEnabled = true
request, err := common.Marshal(LoginRequest{Username: user.Username, PasswordEncrypted: encrypted, EncryptionKeyID: keyID})
require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/user/login", string(request), "", identity, Login)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
assert.Contains(t, string(result.Data), `"has_password":true`)
proof, err := service.VerifySecurityInput(identity, service.VerificationInput{Method: "password", Scope: service.VerificationScopePasswordChange, PasswordEncrypted: encrypted, EncryptionKeyID: keyID})
require.NoError(t, err)
assert.NotEmpty(t, proof.ProofToken)
ciphertext[len(ciphertext)-1] ^= 1
parts[3] = base64.StdEncoding.EncodeToString(ciphertext)
for _, invalid := range []string{strings.Join(parts, "."), "v2.bad.bad.bad", "v2." + strings.Repeat("a", 4096)} {
_, err := common.DecryptPassword(invalid, keyID)
assert.ErrorIs(t, err, common.ErrPasswordEncryptionInvalid)
}
_, err = common.DecryptPassword(encrypted, "wrong-key-id")
assert.ErrorIs(t, err, common.ErrPasswordEncryptionInvalid)
}
func TestSecurityAccountEmailConfirmationAndAudit(t *testing.T) {
for _, scenario := range []string{"first email", "replace without mfa", "replace with mfa"} {
t.Run(scenario, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
mailbox := newSecurityMailbox(t)
method, previous := service.VerificationMethodPassword, ""
if scenario != "first email" {
previous = "[email protected]"
require.NoError(t, model.DB.Model(user).Update("email", previous).Error)
}
if scenario == "replace with mfa" {
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
method = service.VerificationMethodTwoFA
}
flow := startSecurityEmailBinding(t, identity, "[email protected]", method)
assert.Equal(t, "[email protected]", flow.Email)
assert.Equal(t, scenario == "replace without mfa", flow.OldEmailRequired)
newCode, oldCode := mailbox.code(t, flow.Email), ""
_, state, err := model.GetEmailBinding(identity, flow.FlowToken)
require.NoError(t, err)
assert.NotEqual(t, newCode, state.NewCodeHash)
assert.True(t, common.ValidatePasswordAndHash(newCode, state.NewCodeHash))
if flow.OldEmailRequired {
oldCode = mailbox.code(t, previous)
_, err := service.FinishEmailBinding(identity, flow.FlowToken, newCode, "")
assert.ErrorIs(t, err, model.ErrEmailBindingCodeInvalid)
stored, err := model.GetUserById(user.Id, false)
require.NoError(t, err)
assert.Equal(t, previous, stored.Email)
}
request, err := common.Marshal(map[string]string{"flow_token": flow.FlowToken, "new_code": newCode, "old_code": oldCode})
require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/oauth/email/bind", string(request), "", identity, EmailBind)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
stored, err := model.GetUserById(user.Id, false)
require.NoError(t, err)
assert.Equal(t, flow.Email, stored.Email)
_, err = service.FinishEmailBinding(identity, flow.FlowToken, newCode, oldCode)
assert.ErrorIs(t, err, model.ErrAuthFlowConsumed)
legacy := securityEnrollmentRequest("POST", "/api/oauth/email/bind", `{"email":"[email protected]","code":"123456"}`, "", identity, EmailBind)
assert.Contains(t, legacy.Body.String(), `"success":false`)
var audits []model.AuditLog
require.NoError(t, model.LOG_DB.Find(&audits).Error)
require.NotEmpty(t, audits)
assert.False(t, audits[len(audits)-1].Success, "a rejected legacy request must be recorded as failed")
encoded, err := common.Marshal(audits)
require.NoError(t, err)
assert.Contains(t, string(encoded), "user.binding_bind")
assert.NotContains(t, string(encoded), flow.FlowToken)
assert.NotContains(t, string(encoded), newCode)
})
}
}
func TestSecurityAccountEmailResendAndAttemptLimit(t *testing.T) {
_, identity := setupSecurityEnrollmentTest(t)
mailbox := newSecurityMailbox(t)
flow := startSecurityEmailBinding(t, identity, "[email protected]", service.VerificationMethodPassword)
_, err := service.ResendAccountEmailBinding(identity, flow.FlowToken)
assert.ErrorIs(t, err, model.ErrEmailBindingResendWait)
_, err = service.FinishEmailBinding(identity, flow.FlowToken, "invalid", "")
assert.ErrorIs(t, err, model.ErrEmailBindingCodeInvalid)
stored, state, err := model.GetEmailBinding(identity, flow.FlowToken)
require.NoError(t, err)
oldHash := state.NewCodeHash
state.ResendAt = time.Now().Add(-time.Second).Unix()
payload, err := common.Marshal(state)
require.NoError(t, err)
require.NoError(t, model.DB.Model(stored).Update("payload", string(payload)).Error)
replacement, err := service.ResendAccountEmailBinding(identity, flow.FlowToken)
require.NoError(t, err)
assert.Equal(t, flow.ExpiresAt, replacement.ExpiresAt)
_, state, err = model.GetEmailBinding(identity, flow.FlowToken)
require.NoError(t, err)
assert.Equal(t, 1, state.FailedAttempts)
assert.NotEqual(t, oldHash, state.NewCodeHash)
assert.True(t, common.ValidatePasswordAndHash(mailbox.code(t, flow.Email), state.NewCodeHash))
for attempt := 2; attempt <= model.EmailBindingMaxAttempts; attempt++ {
_, err = service.FinishEmailBinding(identity, flow.FlowToken, "invalid", "")
if attempt < model.EmailBindingMaxAttempts {
assert.ErrorIs(t, err, model.ErrEmailBindingCodeInvalid)
} else {
assert.ErrorIs(t, err, model.ErrEmailBindingLocked)
}
}
_, err = service.FinishEmailBinding(identity, flow.FlowToken, mailbox.code(t, flow.Email), "")
assert.ErrorIs(t, err, model.ErrEmailBindingLocked)
}
func TestSecurityAccountEmailConcurrentClaimsHaveOneOwner(t *testing.T) {
user, firstIdentity := setupSecurityEnrollmentTest(t)
mailbox := newSecurityMailbox(t)
other := &model.User{Username: "other-user", AffCode: "other-account", Group: "default", Password: user.Password, Status: common.UserStatusEnabled, Role: common.RoleCommonUser, AuthVersion: 1}
require.NoError(t, model.DB.Create(other).Error)
require.NoError(t, model.PublishUserAuthCache(other.Id))
bundle, err := service.CreateLoginSession(other.Id, "password", "127.0.0.1", "other-user")
require.NoError(t, err)
secondIdentity, err := service.ParseAccessToken(bundle.AccessToken)
require.NoError(t, err)
start, results := make(chan struct{}), make(chan error, 2)
for _, identity := range []service.AuthIdentity{firstIdentity, secondIdentity} {
flow := startSecurityEmailBinding(t, identity, "[email protected]", service.VerificationMethodPassword)
code := mailbox.code(t, flow.Email)
go func(identity service.AuthIdentity, token, code string) {
<-start
_, err := service.FinishEmailBinding(identity, token, code, "")
results <- err
}(identity, flow.FlowToken, code)
}
close(start)
first, second := <-results, <-results
assert.NotEqual(t, first == nil, second == nil, "only one account may claim the address: %v / %v", first, second)
var owners int64
require.NoError(t, model.DB.Model(&model.User{}).Where("email = ?", "[email protected]").Count(&owners).Error)
assert.EqualValues(t, 1, owners)
}
func TestSecurityAccountEmailRejectsChangedAuthorization(t *testing.T) {
for _, scenario := range []string{"other session", "expired", "email changed", "mfa enabled", "notification failure"} {
t.Run(scenario, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
mailbox := newSecurityMailbox(t)
flow := startSecurityEmailBinding(t, identity, "[email protected]", service.VerificationMethodPassword)
code := mailbox.code(t, flow.Email)
stored, _, err := model.GetEmailBinding(identity, flow.FlowToken)
require.NoError(t, err)
switch scenario {
case "other session":
identity.SessionID = "different-session"
case "expired":
require.NoError(t, model.DB.Model(stored).Update("expires_at", time.Now().Add(-time.Second)).Error)
case "email changed":
require.NoError(t, model.DB.Model(user).Update("email", "[email protected]").Error)
case "mfa enabled":
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
case "notification failure":
common.SMTPServer = ""
}
request, err := common.Marshal(map[string]string{"flow_token": flow.FlowToken, "new_code": code})
require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/oauth/email/bind", string(request), "", identity, EmailBind)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, scenario == "notification failure", result.Success, response.Body.String())
if scenario == "notification failure" {
assert.Contains(t, string(result.Data), `"notification_warning":true`)
}
current, err := model.GetUserById(user.Id, false)
require.NoError(t, err)
assert.Equal(t, scenario == "notification failure", current.Email == flow.Email)
})
}
}
func TestSecurityAccountOAuthBindingRejectsInvalidFlow(t *testing.T) {
for _, scenario := range []string{"success and replay", "other session", "wrong provider", "expired", "old flow", "mfa enabled"} {
t.Run(scenario, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
oauth.Register("account-oauth", &enrollmentOAuthProvider{externalID: "new-binding"})
oauth.Register("other-oauth", &enrollmentOAuthProvider{externalID: "wrong-binding"})
t.Cleanup(func() { oauth.Unregister("account-oauth"); oauth.Unregister("other-oauth") })
operation := service.VerificationOperation{Scope: service.VerificationScopeAccountBind, Context: []byte(`{"provider":"account-oauth"}`)}
proof := issueSecurityEnrollmentProof(t, identity, operation, service.VerificationMethodPassword)
response := securityEnrollmentRequest("POST", "/api/oauth/state", `{"provider":"account-oauth","intent":"bind"}`, proof, identity, GenerateOAuthCode)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
var started struct {
FlowToken string `json:"flow_token"`
}
require.NoError(t, common.Unmarshal(result.Data, &started))
flow, err := model.GetAuthFlow(started.FlowToken, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth})
require.NoError(t, err)
provider := "account-oauth"
switch scenario {
case "other session":
identity.SessionID = "different-session"
case "wrong provider":
provider = "other-oauth"
case "expired":
require.NoError(t, model.DB.Model(flow).Update("expires_at", time.Now().Add(-time.Second)).Error)
case "old flow":
require.NoError(t, model.DB.Model(flow).Update("payload", "{}").Error)
case "mfa enabled":
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
}
handler := func(c *gin.Context) { c.Params = gin.Params{{Key: "provider", Value: provider}}; HandleOAuth(c) }
path := "/api/oauth/" + provider + "?state=" + started.FlowToken + "&code=provider-code"
response = securityEnrollmentRequest("GET", path, "", "", identity, handler)
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, scenario == "success and replay", result.Success, response.Body.String())
current, err := model.GetUserById(user.Id, false)
require.NoError(t, err)
assert.Equal(t, scenario == "success and replay", current.GitHubId == "new-binding")
if result.Success {
replay := securityEnrollmentRequest("GET", path, "", "", identity, handler)
assert.Contains(t, replay.Body.String(), `"success":false`)
}
})
}
}
func TestSecurityAccountFirstPasswordRace(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
require.NoError(t, model.DB.Model(user).Update("password", "").Error)
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "existing-key", PublicKey: "public-key"}).Error)
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopePasswordSet}, service.VerificationMethodPasskey)
request := `{"password":"initial-account-password!42"}`
response := securityEnrollmentRequest("PUT", "/api/user/self", request, proof, identity, UpdateSelf)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
user, identity = setupSecurityEnrollmentTest(t)
require.NoError(t, model.DB.Model(user).Update("password", "").Error)
start, results := make(chan struct{}), make(chan error, 2)
for _, password := range []string{"first-password!42", "second-password!42"} {
go func(password string) {
<-start
results <- model.ChangeUserPassword(identity, &model.User{Id: user.Id, Password: password}, true)
}(password)
}
close(start)
first, second := <-results, <-results
assert.NotEqual(t, first == nil, second == nil, "only one concurrent first password may succeed: %v / %v", first, second)
stored, err := model.GetUserById(user.Id, true)
require.NoError(t, err)
assert.True(t, common.ValidatePasswordAndHash("first-password!42", stored.Password) || common.ValidatePasswordAndHash("second-password!42", stored.Password))
}
func TestSecurityAccountUnbindPreservesUsableLoginMethod(t *testing.T) {
for _, scenario := range []string{"password", "passkey", "email only", "twofa only", "disabled password", "disabled passkey"} {
t.Run(scenario, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
previousPasswordEnabled := common.PasswordLoginEnabled
t.Cleanup(func() { common.PasswordLoginEnabled = previousPasswordEnabled })
common.PasswordLoginEnabled = scenario != "disabled password"
require.NoError(t, model.DB.Create(&model.UserOAuthBinding{UserId: user.Id, ProviderId: 31, ProviderUserId: "linked-subject"}).Error)
if scenario != "password" && scenario != "disabled password" {
require.NoError(t, model.DB.Model(user).Updates(map[string]any{"password": "", "email": "[email protected]"}).Error)
}
if scenario == "passkey" || scenario == "disabled passkey" {
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "existing-key", PublicKey: "public-key"}).Error)
system_setting.GetPasskeySettings().Enabled = scenario == "passkey"
}
if scenario == "twofa only" {
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
}
err := service.UnbindAccountOAuth(identity, 31)
if scenario == "password" || scenario == "passkey" {
require.NoError(t, err)
} else {
assert.ErrorIs(t, err, model.ErrLastLoginMethod)
var binding model.UserOAuthBinding
require.NoError(t, model.DB.Where("user_id = ? AND provider_id = ?", user.Id, 31).First(&binding).Error)
}
})
}
}