Ctrl K
ComunidadGuías…

controller/security_account_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)
			}
		})
	}
}