Files
new-api/controller/admin_user_verification_test.go
CaIon 2506e1b980 fix(auth): reject non-standard roles when creating a user
CreateUser accepted any integer role below the operator's own, so an
admin could create a user with role 5 or -1, and a root user's request
for role 99 went on to step-up verification before failing. Only the
guest, common, admin and root roles are accepted now; any other value
fails with an invalid-parameter error before verification and nothing
is written.
2026-09-30 20:52:10 +08:00

390 lines
20 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 TestCreateUserRejectsNonStandardRole(t *testing.T) {
_, identity, _ := setupAdminUserTest(t)
for i, test := range []struct {
name string
role int
operatorRole int
}{
{"between common and admin", 5, common.RoleAdminUser},
{"negative", -1, common.RoleAdminUser},
{"between admin and root", 99, common.RoleRootUser},
} {
t.Run(test.name, func(t *testing.T) {
username := fmt.Sprintf("odd-role-%d", i)
body := fmt.Sprintf(`{"username":%q,"password":"member-password-1","role":%d}`, username, test.role)
response := adminUserRequest(http.MethodPost, "/api/user/", body, "", identity, test.operatorRole, nil, CreateUser)
var result securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
assert.Equal(t, http.StatusOK, response.Code)
assert.False(t, result.Success)
assert.Empty(t, result.Code)
var count int64
require.NoError(t, model.DB.Model(&model.User{}).Where("username = ?", username).Count(&count).Error)
assert.Zero(t, 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)
})
}