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)
......
......@@ -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 {
......
......@@ -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