mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-11 02:46:58 +08:00
Treat TOTP and Passkey as alternative enrolled factors across login and sensitive account operations. Gate every primary login transport before issuing a session, require WebAuthn user verification, and consume login challenges atomically with session creation. Reuse the shared verification UI for login, 2FA management, and account deletion. Require scoped, single-use deletion proof; recheck the session inside the deletion transaction and revoke all sessions afterward. Validation: controller/service/model/middleware tests; real SQLite 3.50.4, MySQL 8.4.11, and PostgreSQL 16.15 security regressions; frontend tests, TypeScript, targeted lint, formatting, and production build. Deploy the frontend and all backend nodes together. No schema changes.
848 lines
39 KiB
Go
848 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.UpdateUserAccessToken(user.Id, "account-delete-access-token"))
|
|
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" {
|
|
assert.Error(t, model.DeleteUserForSession(identity))
|
|
}
|
|
_, 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]interface{}{"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]interface{} `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]interface{}{"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)
|
|
}
|
|
})
|
|
}
|
|
}
|