Commit a8729b5c by CaIon

feat(security): require verification for access token management

parent 3e84ec0a
package controller
import (
"net/http"
"strconv"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"strconv"
)
func GetAccessTokenStatus(c *gin.Context) {
status, err := model.GetUserAccessTokenStatus(c.GetInt("id"))
if err != nil {
common.ApiError(c, err)
writeSecurityOperationError(c, err)
return
}
common.ApiSuccess(c, status)
}
func GenerateAccessToken(c *gin.Context) {
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenGenerate}) == nil {
return
}
id := c.GetInt("id")
key, err := common.GenerateRandomKey(29 + common.GetRandomInt(4))
if err != nil {
writeSecurityOperationError(c, err)
return
}
var existing int64
if err := model.DB.Model(&model.User{}).Where("access_token = ?", key).Count(&existing).Error; err != nil {
writeSecurityOperationError(c, err)
return
}
if existing != 0 {
common.ApiErrorI18n(c, i18n.MsgUuidDuplicate)
return
}
if err := model.UpdateUserAccessToken(id, key); err != nil {
writeSecurityOperationError(c, err)
return
}
recordUserSecurityAudit(c, id, "access_token.generate", map[string]interface{}{"token_ref": model.AccessTokenFingerprint(key)})
c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": key})
}
func RevokeAccessToken(c *gin.Context) {
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke}) == nil {
return
}
ref, err := model.RevokeUserAccessToken(c.GetInt("id"))
if err != nil {
common.ApiError(c, err)
writeSecurityOperationError(c, err)
return
}
if ref != "" {
......
......@@ -91,21 +91,16 @@ func TestAccessTokenLifecycleAndLateRequests(t *testing.T) {
assert.NotNil(t, status.CreatedAt)
assert.Nil(t, status.LastUsedAt, "in-flight old requests must not mark the new generation as used")
assert.Equal(t, 401, auditRequest(router, "GET", "/api/user/token/status", old).Code)
for _, method := range []string{"POST", "GET"} {
for _, method := range []string{"POST", "GET", "DELETE"} {
response := auditRequest(router, method, "/api/user/token", "new-token")
var result struct {
Success bool
Data string
}
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result))
require.True(t, result.Success)
require.GreaterOrEqual(t, len(result.Data), 28)
require.LessOrEqual(t, len(result.Data), 32)
assert.Equal(t, 401, auditRequest(router, "GET", "/api/user/token/status", "new-token").Code)
require.NoError(t, model.UpdateUserAccessToken(user.Id, "new-token"))
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_INVALID"`)
stored, err := model.GetUserById(user.Id, true)
require.NoError(t, err)
assert.Equal(t, "new-token", stored.GetAccessToken(), "a PAT cannot manage itself without a dashboard verification")
}
response := auditRequest(router, "DELETE", "/api/user/token", "new-token")
assert.Contains(t, response.Body.String(), `"success":true`)
_, err = model.RevokeUserAccessToken(user.Id)
require.NoError(t, err)
assert.Equal(t, 401, auditRequest(router, "GET", "/api/user/token/status", "new-token").Code)
ref, err := model.RevokeUserAccessToken(user.Id)
require.NoError(t, err)
......
......@@ -126,6 +126,211 @@ func issueSecurityEnrollmentProof(t *testing.T, identity service.AuthIdentity, o
return proof
}
func TestSecurityEnrollmentAccessTokenRequiresProofBeforeMutation(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
require.NoError(t, model.UpdateUserAccessToken(user.Id, "existing-system-token"))
for _, endpoint := range []struct {
method string
handler gin.HandlerFunc
}{
{"GET", GenerateAccessToken},
{"POST", GenerateAccessToken},
{"DELETE", RevokeAccessToken},
} {
t.Run(endpoint.method, func(t *testing.T) {
response := securityEnrollmentRequest(endpoint.method, "/api/user/token", "", "", identity, endpoint.handler)
var body securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
assert.Equal(t, http.StatusForbidden, response.Code)
assert.False(t, body.Success)
assert.Equal(t, "SECURITY_PROOF_REQUIRED", body.Code)
stored, err := model.ValidateAccessToken("existing-system-token")
require.NoError(t, err)
require.NotNil(t, stored)
assert.Equal(t, user.Id, stored.Id)
})
}
}
func TestSecurityEnrollmentAccessTokenMethodPolicy(t *testing.T) {
for _, test := range []struct {
name, method string
password, passkey, twoFA, locked bool
disabledPasskey, oauth, wechat bool
available bool
}{
{name: "password", method: "password", password: true, oauth: true, available: true},
{name: "existing passkey", method: "passkey", password: true, passkey: true, available: true},
{name: "existing twofa", method: "2fa", password: true, passkey: true, twoFA: true, available: true},
{name: "locked twofa blocks fallback", method: "2fa", password: true, twoFA: true, locked: true},
{name: "disabled passkey blocks fallback", method: "passkey", password: true, passkey: true, disabledPasskey: true},
{name: "disabled passkey does not block password", method: "password", password: true, disabledPasskey: true, available: true},
{name: "linked oauth", method: "oauth", oauth: true, available: true},
{name: "wechat session cannot manage tokens", method: "oauth", wechat: true},
} {
t.Run(test.name, func(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
if !test.password {
require.NoError(t, model.DB.Model(user).Update("password", "").Error)
}
if test.passkey {
require.NoError(t, model.DB.Create(&model.PasskeyCredential{UserID: user.Id, CredentialID: "existing-key", PublicKey: "public-key"}).Error)
}
if test.twoFA {
twoFA := &model.TwoFA{UserId: user.Id, Secret: "JBSWY3DPEHPK3PXP", IsEnabled: true}
if test.locked {
until := time.Now().Add(time.Minute)
twoFA.LockedUntil = &until
}
require.NoError(t, model.DB.Create(twoFA).Error)
}
if test.oauth {
require.NoError(t, model.DB.Model(user).Update("github_id", "linked-user").Error)
oauth.Register("access-token-oauth", &enrollmentOAuthProvider{externalID: "linked-user"})
t.Cleanup(func() { oauth.Unregister("access-token-oauth") })
}
if test.wechat {
require.NoError(t, model.DB.Model(user).Update("wechat_id", "wechat-user").Error)
}
system_setting.GetPasskeySettings().Enabled = !test.disabledPasskey
for _, scope := range []string{service.VerificationScopeAccessTokenGenerate, service.VerificationScopeAccessTokenRevoke} {
requirements, err := service.GetVerificationRequirements(identity, scope)
require.NoError(t, err)
require.Len(t, requirements.Methods, 1)
assert.Equal(t, test.method, requirements.Methods[0].Method)
assert.Equal(t, test.available, requirements.Methods[0].Available)
if test.wechat {
_, err := service.VerifySecurityInput(identity, service.VerificationInput{Scope: scope, Method: "session"})
assert.ErrorIs(t, err, service.ErrProofMethod)
}
}
})
}
}
func TestSecurityEnrollmentAccessTokenLifecycleConsumesProofs(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
require.NoError(t, model.UpdateUserAccessToken(user.Id, "previous-token"))
previousToken := "previous-token"
for _, method := range []string{"GET", "POST"} {
proof, err := service.VerifySecurityInput(identity, service.VerificationInput{
Scope: service.VerificationScopeAccessTokenGenerate, Method: "password", Password: "enrollment-password",
})
require.NoError(t, err)
wrongScope := securityEnrollmentRequest("DELETE", "/api/user/token", "", proof.ProofToken, identity, RevokeAccessToken)
assert.Contains(t, wrongScope.Body.String(), `"code":"SECURITY_PROOF_SCOPE_MISMATCH"`)
response := securityEnrollmentRequest(method, "/api/user/token", "", proof.ProofToken, identity, GenerateAccessToken)
var body securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
require.True(t, body.Success, body.Message)
var token string
require.NoError(t, common.Unmarshal(body.Data, &token))
assert.GreaterOrEqual(t, len(token), 28)
assert.LessOrEqual(t, len(token), 32)
assert.NotEqual(t, previousToken, token)
stored, err := model.ValidateAccessToken(token)
require.NoError(t, err)
require.NotNil(t, stored)
assert.Equal(t, user.Id, stored.Id)
assert.Equal(t, model.AccessTokenFingerprint(token), model.AccessTokenFingerprint(stored.GetAccessToken()))
oldUser, err := model.ValidateAccessToken(previousToken)
assert.Nil(t, oldUser)
require.NoError(t, err)
response = securityEnrollmentRequest(method, "/api/user/token", "", proof.ProofToken, identity, GenerateAccessToken)
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
previousToken = token
}
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke}, "password")
response := securityEnrollmentRequest("DELETE", "/api/user/token", "", proof, identity, RevokeAccessToken)
var body securityEnrollmentResponse
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &body))
require.True(t, body.Success, body.Message)
stored, err := model.GetUserById(user.Id, true)
require.NoError(t, err)
assert.Empty(t, stored.GetAccessToken())
revokedUser, err := model.ValidateAccessToken(previousToken)
require.NoError(t, err)
assert.Nil(t, revokedUser)
response = securityEnrollmentRequest("DELETE", "/api/user/token", "", proof, identity, RevokeAccessToken)
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
response = securityEnrollmentRequest("GET", "/api/user/token/status", "", "", identity, GetAccessTokenStatus)
assert.Contains(t, response.Body.String(), `"exists":false`)
var audits []model.AuditLog
require.NoError(t, model.LOG_DB.Find(&audits).Error)
require.Len(t, audits, 3)
encoded, err := common.Marshal(audits)
require.NoError(t, err)
assert.Contains(t, string(encoded), "access_token.generate")
assert.Contains(t, string(encoded), "access_token.revoke")
assert.NotContains(t, string(encoded), previousToken)
assert.NotContains(t, string(encoded), proof)
}
func TestSecurityEnrollmentAccessTokenRejectsInvalidProofs(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
require.NoError(t, model.UpdateUserAccessToken(user.Id, "unchanged-token"))
for _, endpoint := range []struct {
method, scope string
handler gin.HandlerFunc
}{
{"GET", service.VerificationScopeAccessTokenGenerate, GenerateAccessToken},
{"POST", service.VerificationScopeAccessTokenGenerate, GenerateAccessToken},
{"DELETE", service.VerificationScopeAccessTokenRevoke, RevokeAccessToken},
} {
for _, failure := range []string{"session", "user", "expired"} {
t.Run(endpoint.method+"/"+failure, func(t *testing.T) {
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: endpoint.scope}, "password")
requestIdentity := identity
code := "SECURITY_PROOF_INVALID"
switch failure {
case "session":
requestIdentity.SessionID = "other-session"
case "user":
requestIdentity.UserID++
case "expired":
require.NoError(t, model.DB.Model(&model.AuthFlow{}).Where("purpose = ?", model.AuthFlowPurposeSecurityProof).Update("expires_at", time.Now().Add(-time.Minute)).Error)
code = "SECURITY_PROOF_EXPIRED"
}
response := securityEnrollmentRequest(endpoint.method, "/api/user/token", "", proof, requestIdentity, endpoint.handler)
assert.Equal(t, http.StatusForbidden, response.Code)
assert.Contains(t, response.Body.String(), code)
stored, err := model.ValidateAccessToken("unchanged-token")
require.NoError(t, err)
require.NotNil(t, stored)
assert.Equal(t, user.Id, stored.Id)
})
}
}
}
func TestSecurityEnrollmentAccessTokenFailureDoesNotRestoreProof(t *testing.T) {
user, identity := setupSecurityEnrollmentTest(t)
require.NoError(t, model.UpdateUserAccessToken(user.Id, "unchanged-token"))
require.NoError(t, model.DB.Callback().Update().Before("gorm:update").Register("access_token_write_failure", func(tx *gorm.DB) {
if tx.Statement.Table == "users" {
tx.AddError(errors.New("private database failure"))
}
}))
for _, endpoint := range []struct {
method, scope string
handler gin.HandlerFunc
}{
{"POST", service.VerificationScopeAccessTokenGenerate, GenerateAccessToken},
{"DELETE", service.VerificationScopeAccessTokenRevoke, RevokeAccessToken},
} {
proof := issueSecurityEnrollmentProof(t, identity, service.VerificationOperation{Scope: endpoint.scope}, "password")
response := securityEnrollmentRequest(endpoint.method, "/api/user/token", "", proof, identity, endpoint.handler)
assert.Equal(t, http.StatusInternalServerError, response.Code)
assert.NotContains(t, response.Body.String(), "private database")
response = securityEnrollmentRequest(endpoint.method, "/api/user/token", "", proof, identity, endpoint.handler)
assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_CONSUMED"`)
}
stored, err := model.ValidateAccessToken("unchanged-token")
require.NoError(t, err)
require.NotNil(t, stored)
assert.Equal(t, user.Id, stored.Id)
}
func authorizeSecurityEnrollment(t *testing.T, identity service.AuthIdentity) *model.AuthFlowAuthorization {
t.Helper()
operation := service.VerificationOperation{Scope: service.VerificationScopeTwoFASetup}
......@@ -314,6 +519,9 @@ func TestSecurityEnrollmentOperationContext(t *testing.T) {
{"array context", "passkey.register", `[]`, service.ErrVerificationContextInvalid},
{"empty enrollment", "passkey.register", `{}`, nil},
{"implicit enrollment", "passkey.register", ``, nil},
{"generate access token", "access_token.generate", `{}`, nil},
{"revoke access token", "access_token.revoke", ``, nil},
{"access token target injection", "access_token.revoke", `{"user_id":42}`, service.ErrVerificationContextInvalid},
{"enrollment target injection", "passkey.register", `{"user_id":42}`, service.ErrVerificationContextInvalid},
{"unknown scope", "user.email.change", `{}`, service.ErrProofScope},
} {
......
......@@ -427,36 +427,6 @@ func GetUser(c *gin.Context) {
return
}
func GenerateAccessToken(c *gin.Context) {
id := c.GetInt("id")
// get rand int 28-32
randI := common.GetRandomInt(4)
key, err := common.GenerateRandomKey(29 + randI)
if err != nil {
common.ApiErrorI18n(c, i18n.MsgGenerateFailed)
common.SysLog("failed to generate key: " + err.Error())
return
}
if model.DB.Where("access_token = ?", key).First(&model.User{}).RowsAffected != 0 {
common.ApiErrorI18n(c, i18n.MsgUuidDuplicate)
return
}
if err := model.UpdateUserAccessToken(id, key); err != nil {
common.ApiError(c, err)
return
}
recordUserSecurityAudit(c, id, "access_token.generate", map[string]interface{}{"token_ref": model.AccessTokenFingerprint(key)})
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": key,
})
return
}
type TransferAffQuotaRequest struct {
Quota int `json:"quota" binding:"required"`
}
......
......@@ -97,7 +97,7 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.GET("/token", middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("access-token"), middleware.DisableCache(), controller.GenerateAccessToken)
selfRoute.GET("/token/status", middleware.DisableCache(), controller.GetAccessTokenStatus)
selfRoute.POST("/token", middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("access-token"), middleware.DisableCache(), controller.GenerateAccessToken)
selfRoute.DELETE("/token", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.RevokeAccessToken)
selfRoute.DELETE("/token", middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("access-token"), middleware.DisableCache(), controller.RevokeAccessToken)
selfRoute.GET("/passkey", controller.PasskeyStatus)
selfRoute.POST("/passkey/register/begin", middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.PasskeyRegisterBegin)
selfRoute.POST("/passkey/register/finish", middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.PasskeyRegisterFinish)
......
......@@ -15,15 +15,17 @@ import (
)
const (
VerificationMethodTwoFA = "2fa"
VerificationMethodPasskey = "passkey"
VerificationMethodPassword = "password"
VerificationMethodOAuth = "oauth"
VerificationMethodSession = "session"
VerificationScopeChannelKeyRead = "channel.key.read"
VerificationScopePasskeyRegister = "passkey.register"
VerificationScopePasskeyDelete = "passkey.delete"
VerificationScopeTwoFASetup = "2fa.setup"
VerificationMethodTwoFA = "2fa"
VerificationMethodPasskey = "passkey"
VerificationMethodPassword = "password"
VerificationMethodOAuth = "oauth"
VerificationMethodSession = "session"
VerificationScopeChannelKeyRead = "channel.key.read"
VerificationScopePasskeyRegister = "passkey.register"
VerificationScopePasskeyDelete = "passkey.delete"
VerificationScopeTwoFASetup = "2fa.setup"
VerificationScopeAccessTokenGenerate = "access_token.generate"
VerificationScopeAccessTokenRevoke = "access_token.revoke"
)
var (
......@@ -68,7 +70,8 @@ func BindVerificationOperation(operation VerificationOperation) (VerificationBin
return VerificationBinding{}, ErrVerificationContextInvalid
}
normalized = context
case VerificationScopePasskeyRegister, VerificationScopePasskeyDelete, VerificationScopeTwoFASetup:
case VerificationScopePasskeyRegister, VerificationScopePasskeyDelete, VerificationScopeTwoFASetup,
VerificationScopeAccessTokenGenerate, VerificationScopeAccessTokenRevoke:
if len(fields) != 0 {
return VerificationBinding{}, ErrVerificationContextInvalid
}
......@@ -142,7 +145,8 @@ func securityVerificationPolicy(scope string, state verificationAccountState) ([
} else if state.HasPasskey {
methods = []string{VerificationMethodPasskey}
}
case VerificationScopePasskeyRegister, VerificationScopeTwoFASetup:
case VerificationScopePasskeyRegister, VerificationScopeTwoFASetup,
VerificationScopeAccessTokenGenerate, VerificationScopeAccessTokenRevoke:
if scope == VerificationScopeTwoFASetup && state.HasTwoFA {
return nil, model.ErrTwoFAAlreadyEnabled
}
......@@ -153,7 +157,7 @@ func securityVerificationPolicy(scope string, state verificationAccountState) ([
methods = []string{VerificationMethodPasskey}
case state.HasPassword:
methods = []string{VerificationMethodPassword}
case state.WeChatEnrollment:
case state.WeChatEnrollment && (scope == VerificationScopePasskeyRegister || scope == VerificationScopeTwoFASetup):
methods = []string{VerificationMethodSession}
default:
methods = []string{VerificationMethodOAuth}
......
......@@ -27,6 +27,8 @@ export type SecurityProofScope =
| 'passkey.register'
| 'passkey.delete'
| '2fa.setup'
| 'access_token.generate'
| 'access_token.revoke'
export type VerificationOperation =
| { scope: 'channel.key.read'; context: { channel_id: number } }
......
......@@ -16,9 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { ApiResponse, TwoFAStatus } from '@/features/profile/types'
import type { TwoFAStatus } from '@/features/profile/types'
import { api } from '@/lib/api'
import { authRequestOptions, authResult } from '@/lib/secure-verification'
import {
AuthOperationError,
authRequestOptions,
authResult,
} from '@/lib/secure-verification'
export interface AccessTokenStatus {
exists: boolean
......@@ -28,29 +32,43 @@ export interface AccessTokenStatus {
last_used_ip: string
}
export async function getAccessTokenStatus(): Promise<AccessTokenStatus> {
const response = await api.get<ApiResponse<AccessTokenStatus>>(
'/api/user/token/status'
export function getAccessTokenStatus(): Promise<AccessTokenStatus> {
return authResult(
api.get('/api/user/token/status', authRequestOptions),
'Failed to load token status'
)
if (!response.data.success || !response.data.data) {
throw new Error(response.data.message || 'Failed to load token status')
}
return response.data.data
}
export async function createAccessToken(): Promise<string> {
const response = await api.post<ApiResponse<string>>('/api/user/token')
if (!response.data.success || !response.data.data) {
throw new Error(response.data.message || 'Failed to generate token')
}
return response.data.data
export async function createAccessToken(
proofToken: string,
signal: AbortSignal
): Promise<string> {
const token = await authResult<string>(
api.post('/api/user/token', undefined, {
...authRequestOptions,
headers: { 'X-Security-Proof': proofToken },
singleUseAuthorization: true,
signal,
}),
'Failed to generate token'
)
if (!token) throw new AuthOperationError('Failed to generate token')
return token
}
export async function revokeAccessToken(): Promise<void> {
const response = await api.delete<ApiResponse>('/api/user/token')
if (!response.data.success) {
throw new Error(response.data.message || 'Failed to revoke token')
}
export async function revokeAccessToken(
proofToken: string,
signal: AbortSignal
): Promise<void> {
await authResult<null>(
api.delete('/api/user/token', {
...authRequestOptions,
headers: { 'X-Security-Proof': proofToken },
singleUseAuthorization: true,
signal,
}),
'Failed to revoke token'
)
}
export interface TwoFASetupData {
......
......@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
act,
cleanup,
render,
screen,
......@@ -35,7 +36,34 @@ import type { AccessTokenStatus } from '../../api'
import { AccessTokenCard } from '../access-token-card'
let status: AccessTokenStatus
let proofCount: number
function passwordProof(scope: string) {
proofCount += 1
return {
data: {
success: true,
data: {
proof_token: `one-use-proof-${proofCount}`,
scope,
method: 'password',
expires_at: Math.floor(Date.now() / 1000) + 60,
},
},
}
}
async function verifyPassword(user: ReturnType<typeof userEvent.setup>) {
await user.type(
await screen.findByLabelText('Password', { selector: 'input' }),
'current-password'
)
await user.click(screen.getByRole('button', { name: 'Verify' }))
}
beforeEach(() => {
proofCount = 0
useAuthStore.getState().auth.setUser({ id: 1, username: 'admin', role: 100 })
vi.stubGlobal('localStorage', {
getItem: () => null,
setItem: () => undefined,
......@@ -48,12 +76,31 @@ beforeEach(() => {
last_used_at: null,
last_used_ip: '',
}
vi.spyOn(api, 'get').mockImplementation(async (url) => {
vi.spyOn(api, 'get').mockImplementation(async (url, config) => {
if (url === '/api/audit/self') {
return { data: { success: true, data: { items: [], total: 0 } } }
}
if (url === '/api/verify/methods') {
return {
data: {
success: true,
data: {
scope: config?.params?.scope,
methods: [{ method: 'password', available: true }],
oauth_providers: [],
password_encryption_enabled: false,
},
},
}
}
return { data: { success: true, data: status } }
})
vi.spyOn(api, 'post').mockImplementation(async (url, data) => {
if (url === '/api/verify') {
return passwordProof((data as { scope: string }).scope)
}
throw new Error(`Unexpected POST ${url}`)
})
})
afterEach(() => {
cleanup()
......@@ -76,9 +123,109 @@ function renderCard() {
}
describe('system access token management', () => {
it('does not generate a token when identity verification is cancelled', async () => {
const post = vi.mocked(api.post)
renderCard()
const user = userEvent.setup()
await user.dblClick(await screen.findByRole('button', { name: 'Generate' }))
await screen.findByLabelText('Password', { selector: 'input' })
expect(post).not.toHaveBeenCalled()
expect(
vi
.mocked(api.get)
.mock.calls.filter(([url]) => url === '/api/verify/methods')
).toHaveLength(1)
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(post).not.toHaveBeenCalled()
expect(screen.queryByLabelText('Token')).not.toBeInTheDocument()
})
it('ignores a successful verification response that arrives after cancellation', async () => {
let complete!: (value: ReturnType<typeof passwordProof>) => void
const pending = new Promise<ReturnType<typeof passwordProof>>((resolve) => {
complete = resolve
})
const post = vi.mocked(api.post).mockImplementation(async (url) => {
if (url === '/api/verify') return pending
throw new Error(`Unexpected POST ${url}`)
})
renderCard()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: 'Generate' }))
await verifyPassword(user)
await waitFor(() =>
expect(post).toHaveBeenCalledWith(
'/api/verify',
expect.anything(),
expect.anything()
)
)
await user.click(screen.getByRole('button', { name: 'Cancel' }))
await act(async () => {
complete(passwordProof('access_token.generate'))
await pending
})
expect(
post.mock.calls.filter(([url]) => url === '/api/user/token')
).toHaveLength(0)
expect(screen.queryByLabelText('Token')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Generate' })).toBeEnabled()
})
it('aborts and ignores a token response for an account that is no longer active', async () => {
const token = 'previous-account-private-token'
let complete!: (value: { data: { success: boolean; data: string } }) => void
const pending = new Promise<{ data: { success: boolean; data: string } }>(
(resolve) => {
complete = resolve
}
)
let signal: { readonly aborted: boolean } | undefined
vi.mocked(api.post).mockImplementation(async (url, data, config) => {
if (url === '/api/verify') {
return passwordProof((data as { scope: string }).scope)
}
if (url === '/api/user/token') {
signal = config?.signal
return pending
}
throw new Error(`Unexpected POST ${url}`)
})
const client = renderCard()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: 'Generate' }))
await verifyPassword(user)
await waitFor(() => expect(signal).toBeDefined())
await act(async () => {
useAuthStore
.getState()
.auth.setUser({ id: 2, username: 'other-user', role: 100 })
})
expect(signal?.aborted).toBe(true)
await act(async () => {
complete({ data: { success: true, data: token } })
await pending
})
expect(screen.queryByDisplayValue(token)).not.toBeInTheDocument()
expect(
await screen.findByRole('button', { name: 'Generate' })
).toBeEnabled()
expect(
JSON.stringify(
client
.getQueryCache()
.getAll()
.map((entry) => entry.state.data)
)
).not.toContain(token)
})
it('generates a missing token and clears the one-time plaintext on Escape', async () => {
const token = 'one-time-private-token'
vi.spyOn(api, 'post').mockImplementation(async () => {
vi.mocked(api.post).mockImplementation(async (url, data) => {
if (url === '/api/verify') {
return passwordProof((data as { scope: string }).scope)
}
status = {
...status,
exists: true,
......@@ -90,9 +237,17 @@ describe('system access token management', () => {
const client = renderCard()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: 'Generate' }))
await verifyPassword(user)
const dialog = await screen.findByRole('dialog', { name: 'Access Token' })
expect(within(dialog).getByLabelText('Token')).toHaveValue(token)
expect(api.post).toHaveBeenCalledWith('/api/user/token')
expect(api.post).toHaveBeenCalledWith(
'/api/user/token',
undefined,
expect.objectContaining({
headers: { 'X-Security-Proof': 'one-use-proof-1' },
singleUseAuthorization: true,
})
)
await user.keyboard('{Escape}')
await waitFor(() =>
expect(screen.queryByDisplayValue(token)).not.toBeInTheDocument()
......@@ -105,6 +260,9 @@ describe('system access token management', () => {
.map((entry) => entry.state.data)
)
).not.toContain(token)
expect(JSON.stringify(client.getMutationCache().getAll())).not.toContain(
'one-use-proof-1'
)
expect(
JSON.stringify(
client
......@@ -141,11 +299,14 @@ describe('system access token management', () => {
expect(await screen.findByText('Not generated')).toBeVisible()
})
it('rotation requires confirmation and a failed rotation keeps the existing token state', async () => {
it('rotation requires confirmation and verification, and failure keeps the existing token state', async () => {
status = { ...status, exists: true, token_ref: 'a'.repeat(64) }
const post = vi
.spyOn(api, 'post')
.mockResolvedValue({ data: { success: false } })
const post = vi.mocked(api.post).mockImplementation(async (url, data) => {
if (url === '/api/verify') {
return passwordProof((data as { scope: string }).scope)
}
return { data: { success: false } }
})
renderCard()
const user = userEvent.setup()
await user.click(await screen.findByRole('button', { name: 'Regenerate' }))
......@@ -154,6 +315,8 @@ describe('system access token management', () => {
await user.click(
within(confirmation).getByRole('button', { name: 'Regenerate token' })
)
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
await verifyPassword(user)
expect(await screen.findByText('Failed to generate token')).toBeVisible()
expect(
screen.queryByRole('dialog', { name: 'Access Token' })
......@@ -164,10 +327,10 @@ describe('system access token management', () => {
it('revocation failures can be retried and success restores the generate action', async () => {
status = { ...status, exists: true, token_ref: 'a'.repeat(64) }
vi.spyOn(api, 'delete')
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce({ data: { success: false } })
.mockImplementation(async () => {
status = { ...status, exists: false, token_ref: '' }
return { data: { success: true } }
return { data: { success: true, data: null } }
})
renderCard()
const user = userEvent.setup()
......@@ -177,9 +340,27 @@ describe('system access token management', () => {
{ name: 'Revoke' }
)
await user.click(confirm)
await verifyPassword(user)
expect(await screen.findByText('Failed to revoke token')).toBeVisible()
await waitFor(() => expect(confirm).toBeEnabled())
await user.click(confirm)
expect(api.delete).toHaveBeenLastCalledWith(
'/api/user/token',
expect.objectContaining({
headers: { 'X-Security-Proof': 'one-use-proof-1' },
})
)
await user.click(screen.getByRole('button', { name: 'Revoke' }))
await user.click(
within(await screen.findByRole('alertdialog')).getByRole('button', {
name: 'Revoke',
})
)
await verifyPassword(user)
expect(api.delete).toHaveBeenLastCalledWith(
'/api/user/token',
expect.objectContaining({
headers: { 'X-Security-Proof': 'one-use-proof-2' },
})
)
expect(
await screen.findByRole('button', { name: 'Generate' })
).toBeVisible()
......
......@@ -30,6 +30,7 @@ import {
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { SecureVerificationDialog } from '@/features/auth/secure-verification'
import { AuditLogViewer } from '@/features/usage-logs/audit/components/audit-log-viewer'
import dayjs from '@/lib/dayjs'
......@@ -43,21 +44,19 @@ export function AccessTokenCard() {
null
)
const [historyOpen, setHistoryOpen] = useState(false)
const pending = access.generate.isPending || access.revoke.isPending
const pending = access.pending
const status = access.status.data
const ready = !access.status.isError && !access.status.isPending && !!status
let lastUsed = t('Unknown')
if (status?.last_used_at) {
lastUsed = dayjs.unix(status.last_used_at).format('YYYY-MM-DD HH:mm:ss')
} else if (status?.created_at) lastUsed = t('Not used yet')
const confirm = async () => {
try {
if (confirmation === 'revoke') await access.revoke.mutateAsync()
else await access.generate.mutateAsync()
setConfirmation(null)
} catch {
/* The mutation displays the error and preserves the confirmation. */
}
const confirm = () => {
if (pending || !confirmation) return
const operation = confirmation
setConfirmation(null)
if (operation === 'revoke') void access.revoke()
else void access.generate()
}
return (
<>
......@@ -152,7 +151,7 @@ export function AccessTokenCard() {
<Button
size='sm'
disabled={pending}
onClick={() => access.generate.mutate()}
onClick={() => void access.generate()}
>
{t('Generate')}
</Button>
......@@ -164,6 +163,7 @@ export function AccessTokenCard() {
{access.token && (
<AccessTokenDialog token={access.token} onClose={access.clearToken} />
)}
<SecureVerificationDialog {...access.verificationDialogProps} />
<ConfirmDialog
open={confirmation !== null}
onOpenChange={(open) => {
......
......@@ -16,11 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useSecureVerification } from '@/features/auth/secure-verification'
import { AuthOperationError } from '@/lib/secure-verification'
import { useAuthStore } from '@/stores/auth-store'
import {
......@@ -33,36 +35,93 @@ export function useAccessToken() {
const { t } = useTranslation()
const client = useQueryClient()
const userId = useAuthStore((state) => state.auth.user?.id)
const sessionId = useAuthStore((state) => state.auth.session?.sid)
const statusKey = ['security', 'access-token', 'status', userId] as const
const [token, setToken] = useState('')
const [generatedToken, setGeneratedToken] = useState<{
value: string
userId: number | undefined
sessionId: string | undefined
} | null>(null)
const [pending, setPending] = useState(false)
const currentOperation = useRef<AbortController | null>(null)
const verification = useSecureVerification()
const requestVerification = verification.requestVerification
const cancelVerification = verification.cancel
const status = useQuery({
queryKey: statusKey,
queryFn: getAccessTokenStatus,
retry: false,
})
const refresh = () => client.invalidateQueries({ queryKey: statusKey })
const generate = useMutation({
// Keep plaintext out of the query/mutation cache and persistent storage.
mutationFn: async () => {
setToken(await createAccessToken())
useEffect(
() => () => {
const current = currentOperation.current
currentOperation.current = null
current?.abort()
cancelVerification()
setPending(false)
setGeneratedToken(null)
},
onSuccess: refresh,
onError: () => {
toast.error(t('Failed to generate token'))
void refresh()
},
})
const revoke = useMutation({
mutationFn: revokeAccessToken,
onSuccess: () => {
setToken('')
toast.success(t('Access token revoked'))
return refresh()
[cancelVerification, userId, sessionId]
)
const performOperation = useCallback(
async (operation: 'generate' | 'revoke') => {
if (currentOperation.current) return
const controller = new AbortController()
currentOperation.current = controller
setPending(true)
try {
const proof = await requestVerification({
scope: `access_token.${operation}`,
})
if (currentOperation.current !== controller || !proof) return
// Proofs and plaintext stay local to this action, outside React Query caches.
if (operation === 'generate') {
const generated = await createAccessToken(
proof.proof_token,
controller.signal
)
if (currentOperation.current !== controller) return
setGeneratedToken({ value: generated, userId, sessionId })
} else {
await revokeAccessToken(proof.proof_token, controller.signal)
if (currentOperation.current !== controller) return
setGeneratedToken(null)
toast.success(t('Access token revoked'))
}
void client.invalidateQueries({
queryKey: ['security', 'access-token', 'status', userId],
})
} catch (error) {
if (currentOperation.current !== controller) return
const failure = AuthOperationError.from(error)
if (failure.code !== 'AUTH_CANCELLED') toast.error(t(failure.message))
void client.invalidateQueries({
queryKey: ['security', 'access-token', 'status', userId],
})
} finally {
if (currentOperation.current === controller) {
currentOperation.current = null
setPending(false)
}
}
},
onError: () => {
toast.error(t('Failed to revoke token'))
void refresh()
[client, requestVerification, t, userId, sessionId]
)
return {
status,
token:
generatedToken?.userId === userId &&
generatedToken?.sessionId === sessionId
? (generatedToken?.value ?? '')
: '',
pending,
clearToken: () => {
setGeneratedToken(null)
},
})
return { status, token, clearToken: () => setToken(''), generate, revoke }
generate: () => performOperation('generate'),
revoke: () => performOperation('revoke'),
verificationDialogProps: verification.dialogProps,
}
}
......@@ -64,20 +64,19 @@ export const authRequestOptions = {
export async function authResult<T>(
request: Promise<{
data: { success: boolean; message?: string; code?: string; data?: T }
}>
}>,
fallback = 'Verification failed. Please try again.'
): Promise<T> {
try {
const { data: response } = await request
if (!response.success || response.data === undefined) {
throw new AuthOperationError(
getServerErrorMessageKey(response) ||
response.message ||
'Verification failed. Please try again.',
getServerErrorMessageKey(response) || response.message || fallback,
response.code
)
}
return response.data
} catch (error) {
throw AuthOperationError.from(error)
throw AuthOperationError.from(error, fallback)
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment