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

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

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

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

363 lines
18 KiB
Go

package controller
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// setupAdminUserTest promotes the enrolled operator to root and creates a
// managed common user for the administrative endpoints to act on.
func setupAdminUserTest(t *testing.T) (*model.User, service.AuthIdentity, *model.User) {
t.Helper()
operator, identity := setupSecurityEnrollmentTest(t)
require.NoError(t, model.DB.Model(operator).Update("role", common.RoleRootUser).Error)
require.NoError(t, model.PublishUserAuthCache(operator.Id))
password, err := common.Password2Hash("managed-password")
require.NoError(t, err)
target := &model.User{Username: "managed-user", Password: password, Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "managed-aff", GitHubId: "managed-github"}
require.NoError(t, model.DB.Create(target).Error)
return operator, identity, target
}
// adminUserRequest drives an admin user-management handler with the operator
// context that AdminAuth would have populated for a dashboard session.
func adminUserRequest(method, path, body, proof string, identity service.AuthIdentity, role int, params gin.Params, handler gin.HandlerFunc) *httptest.ResponseRecorder {
response := httptest.NewRecorder()
c, _ := gin.CreateTestContext(response)
c.Request = httptest.NewRequest(method, path, strings.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
if proof != "" {
c.Request.Header.Set("X-Security-Proof", proof)
}
c.Params = params
c.Set("id", identity.UserID)
c.Set("username", "enrollment-user")
c.Set("role", role)
c.Set("session_id", identity.SessionID)
c.Set("auth_version", identity.UserAuthVersion)
c.Set("session_version", identity.SessionVersion)
handler(c)
return response
}
func TestAdminUserRiskOperationsRequireProofBeforeMutation(t *testing.T) {
for _, test := range []struct {
name, method, path string
body func(target *model.User) string
params func(target *model.User) gin.Params
handler gin.HandlerFunc
unchanged func(t *testing.T, target *model.User)
}{
{
name: "hard delete", method: http.MethodDelete, path: "/api/user/:id", handler: DeleteUser,
params: func(target *model.User) gin.Params { return gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}} },
unchanged: func(t *testing.T, target *model.User) {
_, err := model.GetUserById(target.Id, false)
assert.NoError(t, err)
},
},
{
name: "manage disable", method: http.MethodPost, path: "/api/user/manage", handler: ManageUser,
body: func(target *model.User) string { return fmt.Sprintf(`{"id":%d,"action":"disable"}`, target.Id) },
unchanged: func(t *testing.T, target *model.User) {
stored, err := model.GetUserById(target.Id, false)
require.NoError(t, err)
assert.Equal(t, common.UserStatusEnabled, stored.Status)
},
},
{
name: "manage promote", method: http.MethodPost, path: "/api/user/manage", handler: ManageUser,
body: func(target *model.User) string { return fmt.Sprintf(`{"id":%d,"action":"promote"}`, target.Id) },
unchanged: func(t *testing.T, target *model.User) {
stored, err := model.GetUserById(target.Id, false)
require.NoError(t, err)
assert.Equal(t, common.RoleCommonUser, stored.Role)
},
},
{
name: "manage delete", method: http.MethodPost, path: "/api/user/manage", handler: ManageUser,
body: func(target *model.User) string { return fmt.Sprintf(`{"id":%d,"action":"delete"}`, target.Id) },
unchanged: func(t *testing.T, target *model.User) {
_, err := model.GetUserById(target.Id, false)
assert.NoError(t, err)
},
},
{
name: "password reset", method: http.MethodPut, path: "/api/user/", handler: UpdateUser,
body: func(target *model.User) string {
return fmt.Sprintf(`{"id":%d,"username":"managed-user","display_name":"Managed","password":"replacement-pass"}`, target.Id)
},
unchanged: func(t *testing.T, target *model.User) {
stored, err := model.GetUserById(target.Id, true)
require.NoError(t, err)
assert.True(t, common.ValidatePasswordAndHash("managed-password", stored.Password))
},
},
{
name: "admin permission matrix", method: http.MethodPut, path: "/api/user/", handler: UpdateUser,
body: func(target *model.User) string {
return fmt.Sprintf(`{"id":%d,"username":"managed-user","display_name":"Managed","admin_permissions":{}}`, target.Id)
},
unchanged: func(t *testing.T, target *model.User) {
stored, err := model.GetUserById(target.Id, false)
require.NoError(t, err)
assert.Equal(t, "", stored.DisplayName)
},
},
{
name: "create administrator", method: http.MethodPost, path: "/api/user/", handler: CreateUser,
body: func(*model.User) string { return `{"username":"new-admin","password":"admin-password-1","role":10}` },
unchanged: func(t *testing.T, _ *model.User) {
var count int64
require.NoError(t, model.DB.Model(&model.User{}).Where("username = ?", "new-admin").Count(&count).Error)
assert.Zero(t, count)
},
},
{
name: "passkey reset", method: http.MethodDelete, path: "/api/user/:id/reset_passkey", handler: AdminResetPasskey,
params: func(target *model.User) gin.Params { return gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}} },
unchanged: func(t *testing.T, target *model.User) {
_, err := model.GetPasskeyByUserID(target.Id)
assert.NoError(t, err)
},
},
{
name: "two-factor disable", method: http.MethodDelete, path: "/api/user/:id/2fa", handler: AdminDisable2FA,
params: func(target *model.User) gin.Params { return gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}} },
unchanged: func(t *testing.T, target *model.User) {
twoFA, err := model.GetTwoFAByUserId(target.Id)
require.NoError(t, err)
assert.True(t, twoFA.IsEnabled)
},
},
{
name: "built-in binding clear", method: http.MethodDelete, path: "/api/user/:id/bindings/:binding_type", handler: AdminClearUserBinding,
params: func(target *model.User) gin.Params {
return gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}, {Key: "binding_type", Value: "github"}}
},
unchanged: func(t *testing.T, target *model.User) {
stored, err := model.GetUserById(target.Id, false)
require.NoError(t, err)
assert.Equal(t, "managed-github", stored.GitHubId)
},
},
{
name: "custom oauth unbind", method: http.MethodDelete, path: "/api/user/:id/oauth/bindings/:provider_id", handler: UnbindCustomOAuthByAdmin,
params: func(target *model.User) gin.Params {
return gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}, {Key: "provider_id", Value: "7"}}
},
unchanged: func(t *testing.T, target *model.User) {
bindings, err := model.GetUserOAuthBindingsByUserId(target.Id)
require.NoError(t, err)
assert.Len(t, bindings, 1)
},
},
} {
t.Run(test.name, func(t *testing.T) {
_, identity, target := setupAdminUserTest(t)
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: target.Id, CredentialID: "managed-passkey", PublicKey: "public-key"}).Error)
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: target.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
require.NoError(t, model.DB.Create(&model.UserOAuthBinding{UserId: target.Id, ProviderId: 7, ProviderUserId: "provider-user"}).Error)
var body string
if test.body != nil {
body = test.body(target)
}
var params gin.Params
if test.params != nil {
params = test.params(target)
}
response := adminUserRequest(test.method, test.path, body, "", identity, common.RoleRootUser, params, 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)
test.unchanged(t, target)
var flows int64
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Count(&flows).Error)
assert.Zero(t, flows)
})
}
}
// An administrator (not root) exercises the routine paths without touching the
// Casbin policy store, which the security fixture does not provision.
func TestAdminUserRoutineEditsDoNotRequireProof(t *testing.T) {
_, identity, target := setupAdminUserTest(t)
response := adminUserRequest(http.MethodPut, "/api/user/", fmt.Sprintf(`{"id":%d,"username":"managed-user","display_name":"Renamed","group":"default"}`, target.Id), "", identity, common.RoleAdminUser, nil, UpdateUser)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
stored, err := model.GetUserById(target.Id, true)
require.NoError(t, err)
assert.Equal(t, "Renamed", stored.DisplayName)
assert.True(t, common.ValidatePasswordAndHash("managed-password", stored.Password))
response = adminUserRequest(http.MethodPost, "/api/user/", `{"username":"new-member","password":"member-password-1","role":1}`, "", identity, common.RoleAdminUser, nil, CreateUser)
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
var count int64
require.NoError(t, model.DB.Model(&model.User{}).Where("username = ?", "new-member").Count(&count).Error)
assert.EqualValues(t, 1, count)
}
func TestAdminUserProofIsBoundToTargetAndActionAndConsumedOnce(t *testing.T) {
_, identity, target := setupAdminUserTest(t)
other := &model.User{Username: "other-user", Password: target.Password, Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", AuthVersion: 1, AffCode: "other-aff"}
require.NoError(t, model.DB.Create(other).Error)
disableTarget := service.VerificationOperation{Scope: service.VerificationScopeAdminUserManage, Context: []byte(fmt.Sprintf(`{"user_id":%d,"action":"disable"}`, target.Id))}
proof := issueSecurityEnrollmentProof(t, identity, disableTarget, service.VerificationMethodPassword)
for _, test := range []struct {
name, body, code string
params gin.Params
handler gin.HandlerFunc
}{
{"different action", fmt.Sprintf(`{"id":%d,"action":"enable"}`, target.Id), "SECURITY_PROOF_CONTEXT_MISMATCH", nil, ManageUser},
{"different user", fmt.Sprintf(`{"id":%d,"action":"disable"}`, other.Id), "SECURITY_PROOF_CONTEXT_MISMATCH", nil, ManageUser},
{"different scope", "", "SECURITY_PROOF_SCOPE_MISMATCH", gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}}, DeleteUser},
} {
t.Run(test.name, func(t *testing.T) {
response := adminUserRequest(http.MethodPost, "/api/user/manage", test.body, proof, identity, common.RoleRootUser, test.params, test.handler)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Equal(t, test.code, result.Code)
})
}
for _, id := range []int{target.Id, other.Id} {
stored, err := model.GetUserById(id, false)
require.NoError(t, err)
assert.Equal(t, common.UserStatusEnabled, stored.Status)
}
response := adminUserRequest(http.MethodPost, "/api/user/manage", fmt.Sprintf(`{"id":%d,"action":"disable"}`, target.Id), proof, identity, common.RoleRootUser, nil, ManageUser)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
stored, err := model.GetUserById(target.Id, false)
require.NoError(t, err)
assert.Equal(t, common.UserStatusDisabled, stored.Status)
var audit model.AuditLog
require.NoError(t, model.LOG_DB.Where("action = ?", "user.manage").Last(&audit).Error)
require.NotNil(t, audit.Other.Op)
verificationMethod, err := common.Marshal(audit.Other.Op.Params["verification_method"])
require.NoError(t, err)
assert.JSONEq(t, `"password"`, string(verificationMethod))
targetUserID, err := common.Marshal(audit.Other.Op.Params["target_user_id"])
require.NoError(t, err)
assert.JSONEq(t, fmt.Sprint(target.Id), string(targetUserID))
auditJSON, err := common.Marshal(audit)
require.NoError(t, err)
assert.NotContains(t, string(auditJSON), proof)
response = adminUserRequest(http.MethodPost, "/api/user/manage", fmt.Sprintf(`{"id":%d,"action":"disable"}`, target.Id), proof, identity, common.RoleRootUser, nil, ManageUser)
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Equal(t, "SECURITY_PROOF_CONSUMED", result.Code)
}
func TestAdminUserBindingProofDistinguishesBuiltInAndCustomBindings(t *testing.T) {
_, identity, target := setupAdminUserTest(t)
require.NoError(t, model.DB.Create(&model.UserOAuthBinding{UserId: target.Id, ProviderId: 7, ProviderUserId: "provider-user"}).Error)
custom := service.VerificationOperation{Scope: service.VerificationScopeAdminUserBindingClear, Context: []byte(fmt.Sprintf(`{"user_id":%d,"provider_id":7}`, target.Id))}
proof := issueSecurityEnrollmentProof(t, identity, custom, service.VerificationMethodPassword)
params := gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}, {Key: "binding_type", Value: "github"}}
response := adminUserRequest(http.MethodDelete, "/api/user/:id/bindings/:binding_type", "", proof, identity, common.RoleRootUser, params, AdminClearUserBinding)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, "SECURITY_PROOF_CONTEXT_MISMATCH", result.Code)
stored, err := model.GetUserById(target.Id, false)
require.NoError(t, err)
assert.Equal(t, "managed-github", stored.GitHubId)
params = gin.Params{{Key: "id", Value: fmt.Sprint(target.Id)}, {Key: "provider_id", Value: "7"}}
response = adminUserRequest(http.MethodDelete, "/api/user/:id/oauth/bindings/:provider_id", "", proof, identity, common.RoleRootUser, params, UnbindCustomOAuthByAdmin)
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success, response.Body.String())
bindings, err := model.GetUserOAuthBindingsByUserId(target.Id)
require.NoError(t, err)
assert.Empty(t, bindings)
var audit model.AuditLog
require.NoError(t, model.LOG_DB.Where("action = ?", "user.binding_clear").Last(&audit).Error)
require.NotNil(t, audit.Other.Op)
providerID, err := common.Marshal(audit.Other.Op.Params["provider_id"])
require.NoError(t, err)
assert.JSONEq(t, "7", string(providerID))
}
func TestAdminUserVerificationPolicy(t *testing.T) {
t.Run("requires an administrator", func(t *testing.T) {
_, identity := setupSecurityEnrollmentTest(t)
_, err := service.GetVerificationRequirements(identity, service.VerificationScopeAdminUserDelete)
assert.ErrorIs(t, err, service.ErrVerificationForbidden)
})
t.Run("falls back to the password without a second factor", func(t *testing.T) {
_, identity, _ := setupAdminUserTest(t)
requirements, err := service.GetVerificationRequirements(identity, service.VerificationScopeAdminUserDelete)
require.NoError(t, err)
assert.Equal(t, []service.VerificationMethodOption{{Method: service.VerificationMethodPassword, Available: true}}, requirements.Methods)
})
t.Run("prefers an enrolled second factor", func(t *testing.T) {
operator, identity, _ := setupAdminUserTest(t)
require.NoError(t, model.DB.Create(&model.TwoFA{UserId: operator.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}).Error)
requirements, err := service.GetVerificationRequirements(identity, service.VerificationScopeAdminUserManage)
require.NoError(t, err)
assert.Equal(t, []service.VerificationMethodOption{{Method: service.VerificationMethodTwoFA, Available: true}}, requirements.Methods)
})
t.Run("a legacy token cannot present a proof", func(t *testing.T) {
operator, identity, target := setupAdminUserTest(t)
require.NoError(t, model.DB.Model(operator).Update("access_token", "legacy-admin-token").Error)
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAdminUserDelete, Context: []byte(fmt.Sprintf(`{"user_id":%d}`, target.Id))}, service.VerificationMethodPassword)
response := accessTokenRequest(newAccessTokenTestRouter(), http.MethodDelete, fmt.Sprintf("/api/user/%d", target.Id), "legacy-admin-token", proof, "")
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Equal(t, "SECURITY_PROOF_INVALID", decodeSecurityEnrollmentResponse(t, response).Code)
_, err := model.GetUserById(target.Id, false)
assert.NoError(t, err)
})
t.Run("a user:write token completes the deletion on its own proof", func(t *testing.T) {
operator, _, target := setupAdminUserTest(t)
require.NoError(t, model.DB.AutoMigrate(&model.ExternalIdentityClaim{}, &model.Token{}))
raw, _ := createScopedAccessToken(t, operator.Id, 0, "user:write")
router := newAccessTokenTestRouter()
response := accessTokenRequest(router, http.MethodPost, "/api/verify", raw, "", fmt.Sprintf(`{"scope":"admin.user.delete","method":"password","password":"enrollment-password","context":{"user_id":%d}}`, target.Id))
body := decodeSecurityEnrollmentResponse(t, response)
require.True(t, body.Success, body.Message)
var proof service.SecurityProof
require.NoError(t, common.Unmarshal(body.Data, &proof))
response = accessTokenRequest(router, http.MethodDelete, fmt.Sprintf("/api/user/%d", target.Id), raw, proof.ProofToken, "")
body = decodeSecurityEnrollmentResponse(t, response)
require.True(t, body.Success, body.Message)
_, err := model.GetUserById(target.Id, true)
assert.Error(t, err)
var audit model.AuditLog
require.NoError(t, model.LOG_DB.Where("action = ?", "user.delete").Last(&audit).Error)
assert.Equal(t, "access_token", audit.AuthMethod)
auditJSON, err := common.Marshal(audit)
require.NoError(t, err)
for _, secret := range []string{raw, proof.ProofToken, "enrollment-password"} {
assert.NotContains(t, string(auditJSON), secret)
}
})
t.Run("a user:read token cannot obtain a proof", func(t *testing.T) {
operator, _, target := setupAdminUserTest(t)
raw, _ := createScopedAccessToken(t, operator.Id, 0, "user:read")
response := accessTokenRequest(newAccessTokenTestRouter(), http.MethodPost, "/api/verify", raw, "", fmt.Sprintf(`{"scope":"admin.user.delete","method":"password","password":"enrollment-password","context":{"user_id":%d}}`, target.Id))
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Equal(t, "SECURITY_ACTION_FORBIDDEN", decodeSecurityEnrollmentResponse(t, response).Code)
var flows int64
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Count(&flows).Error)
assert.Zero(t, flows)
})
}