Files
new-api/controller/security_account_test.go
CaIon 120dbeafb1 test(security): match the email code only as a whole number
The email binding audit test failed now and then because it searched the
whole audit JSON for the six-digit code. Audit event and request IDs start
with a long run of time digits, so a random code sometimes appeared inside
one of them. Treat only a code that no other digit touches as a leak.
2026-10-20 22:43:11 +08:00

909 lines
42 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/http/httptest"
"net/textproto"
"regexp"
"strings"
"sync"
"testing"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
"github.com/QuantumNous/new-api/relaykit/dto"
"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)
_, _, err := service.ValidateLoginSession(identity)
assert.Error(t, err)
count, err := model.CountActiveUserSessions(user.Id, time.Now().Unix())
require.NoError(t, err)
assert.Zero(t, count)
}
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 = "", "sender@example.com", ""
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 = "previous@example.com"
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, "New@Example.com", method)
assert.Equal(t, "new@example.com", 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":"other@example.com","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)
// Audit event and request IDs are long runs of time digits that can
// contain the six-digit code by chance; only the whole code is a leak.
assert.NotRegexp(t, `(^|\D)`+newCode+`(\D|$)`, string(encoded))
})
}
}
func TestSecurityAccountEmailResendAndAttemptLimit(t *testing.T) {
_, identity := setupSecurityEnrollmentTest(t)
mailbox := newSecurityMailbox(t)
flow := startSecurityEmailBinding(t, identity, "new@example.com", 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)
}
// Email binding codes and account security notices are written in the account
// owner's saved language, else in the language the request states, and quote
// provider names HTML-escaped.
func TestSecurityAccountMailsFollowTheOwnersLanguage(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
mailbox := newSecurityMailbox(t)
mails := func(receiver string) string {
mailbox.mutex.Lock()
defer mailbox.mutex.Unlock()
return strings.Join(mailbox.mail[receiver], "\n")
}
saveLanguage := func(language string) {
setting, err := common.Marshal(dto.UserSetting{Language: language})
require.NoError(t, err)
require.NoError(t, model.DB.Model(user).Update("setting", string(setting)).Error)
}
saveLanguage("zhCN")
require.NoError(t, model.DB.Model(user).Update("email", "previous@example.com").Error)
flow := startSecurityEmailBinding(t, identity, "new@example.com", service.VerificationMethodPassword)
request, err := common.Marshal(map[string]string{"flow_token": flow.FlowToken, "new_code": mailbox.code(t, flow.Email), "old_code": mailbox.code(t, "previous@example.com")})
require.NoError(t, err)
response := securityEnrollmentRequest("POST", "/api/oauth/email/bind", string(request), "", identity, EmailBind)
require.Contains(t, response.Body.String(), `"success":true`)
assert.Contains(t, mails("new@example.com"), "请确认将此邮箱地址绑定到您的账户。")
assert.Contains(t, mails("new@example.com"), "您的账户安全设置有变动:邮箱地址已确认。")
assert.Contains(t, mails("previous@example.com"), "您的账户正在申请更换邮箱地址,请确认替换当前邮箱。")
assert.Contains(t, mails("previous@example.com"), "您的账户安全设置有变动:邮箱地址已更换。")
for _, tc := range []struct {
name, saved, acceptLanguage, receiver, want string
}{
{"request language when none is saved", "", "zh-TW", "tw@example.com", "您的帳戶安全設定有變動:已綁定登入帳號 &lt;b&gt;Corp&lt;/b&gt;。"},
{"saved language over the request", "en", "zh-CN", "en@example.com", "Your account security settings have changed: Login account linked: &lt;b&gt;Corp&lt;/b&gt;."},
{"default language when neither states one", "", "", "default@example.com", "Your account security settings have changed: Login account linked: &lt;b&gt;Corp&lt;/b&gt;."},
} {
t.Run(tc.name, func(t *testing.T) {
saveLanguage(tc.saved)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/api/oauth/github", nil)
if tc.acceptLanguage != "" {
c.Request.Header.Set("Accept-Language", tc.acceptLanguage)
}
lang := service.AccountMailLanguage(c, user.Id)
require.NoError(t, service.NotifyAccountSecurityChange(lang, tc.receiver, i18n.MsgSecurityEventLoginLinked, map[string]any{"Provider": "<b>Corp</b>"}))
assert.Contains(t, mails(tc.receiver), tc.want)
})
}
}
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, "shared@example.com", 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 = ?", "shared@example.com").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, "new@example.com", 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", "changed@example.com").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": "verified@example.com"}).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)
}
})
}
}