Commit a8729b5c by CaIon

feat(security): require verification for access token management

parent 3e84ec0a
package controller package controller
import ( import (
"net/http"
"strconv"
"github.com/QuantumNous/new-api/common" "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/model"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"strconv"
) )
func GetAccessTokenStatus(c *gin.Context) { func GetAccessTokenStatus(c *gin.Context) {
status, err := model.GetUserAccessTokenStatus(c.GetInt("id")) status, err := model.GetUserAccessTokenStatus(c.GetInt("id"))
if err != nil { if err != nil {
common.ApiError(c, err) writeSecurityOperationError(c, err)
return return
} }
common.ApiSuccess(c, status) 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) { func RevokeAccessToken(c *gin.Context) {
if middleware.RequireSecurityProof(c, service.VerificationOperation{Scope: service.VerificationScopeAccessTokenRevoke}) == nil {
return
}
ref, err := model.RevokeUserAccessToken(c.GetInt("id")) ref, err := model.RevokeUserAccessToken(c.GetInt("id"))
if err != nil { if err != nil {
common.ApiError(c, err) writeSecurityOperationError(c, err)
return return
} }
if ref != "" { if ref != "" {
......
...@@ -91,21 +91,16 @@ func TestAccessTokenLifecycleAndLateRequests(t *testing.T) { ...@@ -91,21 +91,16 @@ func TestAccessTokenLifecycleAndLateRequests(t *testing.T) {
assert.NotNil(t, status.CreatedAt) assert.NotNil(t, status.CreatedAt)
assert.Nil(t, status.LastUsedAt, "in-flight old requests must not mark the new generation as used") 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) 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") response := auditRequest(router, method, "/api/user/token", "new-token")
var result struct { assert.Equal(t, http.StatusForbidden, response.Code)
Success bool assert.Contains(t, response.Body.String(), `"code":"SECURITY_PROOF_INVALID"`)
Data string stored, err := model.GetUserById(user.Id, true)
} require.NoError(t, err)
require.NoError(t, common.Unmarshal(response.Body.Bytes(), &result)) assert.Equal(t, "new-token", stored.GetAccessToken(), "a PAT cannot manage itself without a dashboard verification")
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"))
} }
response := auditRequest(router, "DELETE", "/api/user/token", "new-token") _, err = model.RevokeUserAccessToken(user.Id)
assert.Contains(t, response.Body.String(), `"success":true`) require.NoError(t, err)
assert.Equal(t, 401, auditRequest(router, "GET", "/api/user/token/status", "new-token").Code) assert.Equal(t, 401, auditRequest(router, "GET", "/api/user/token/status", "new-token").Code)
ref, err := model.RevokeUserAccessToken(user.Id) ref, err := model.RevokeUserAccessToken(user.Id)
require.NoError(t, err) require.NoError(t, err)
......
...@@ -427,36 +427,6 @@ func GetUser(c *gin.Context) { ...@@ -427,36 +427,6 @@ func GetUser(c *gin.Context) {
return 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 { type TransferAffQuotaRequest struct {
Quota int `json:"quota" binding:"required"` Quota int `json:"quota" binding:"required"`
} }
......
...@@ -97,7 +97,7 @@ func SetApiRouter(router *gin.Engine) { ...@@ -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", middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("access-token"), middleware.DisableCache(), controller.GenerateAccessToken)
selfRoute.GET("/token/status", middleware.DisableCache(), controller.GetAccessTokenStatus) selfRoute.GET("/token/status", middleware.DisableCache(), controller.GetAccessTokenStatus)
selfRoute.POST("/token", middleware.CriticalRateLimit(), middleware.UserCriticalRateLimit("access-token"), middleware.DisableCache(), controller.GenerateAccessToken) 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.GET("/passkey", controller.PasskeyStatus)
selfRoute.POST("/passkey/register/begin", middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.PasskeyRegisterBegin) 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) selfRoute.POST("/passkey/register/finish", middleware.UserCriticalRateLimit("security-verification"), middleware.DisableCache(), controller.PasskeyRegisterFinish)
......
...@@ -15,15 +15,17 @@ import ( ...@@ -15,15 +15,17 @@ import (
) )
const ( const (
VerificationMethodTwoFA = "2fa" VerificationMethodTwoFA = "2fa"
VerificationMethodPasskey = "passkey" VerificationMethodPasskey = "passkey"
VerificationMethodPassword = "password" VerificationMethodPassword = "password"
VerificationMethodOAuth = "oauth" VerificationMethodOAuth = "oauth"
VerificationMethodSession = "session" VerificationMethodSession = "session"
VerificationScopeChannelKeyRead = "channel.key.read" VerificationScopeChannelKeyRead = "channel.key.read"
VerificationScopePasskeyRegister = "passkey.register" VerificationScopePasskeyRegister = "passkey.register"
VerificationScopePasskeyDelete = "passkey.delete" VerificationScopePasskeyDelete = "passkey.delete"
VerificationScopeTwoFASetup = "2fa.setup" VerificationScopeTwoFASetup = "2fa.setup"
VerificationScopeAccessTokenGenerate = "access_token.generate"
VerificationScopeAccessTokenRevoke = "access_token.revoke"
) )
var ( var (
...@@ -68,7 +70,8 @@ func BindVerificationOperation(operation VerificationOperation) (VerificationBin ...@@ -68,7 +70,8 @@ func BindVerificationOperation(operation VerificationOperation) (VerificationBin
return VerificationBinding{}, ErrVerificationContextInvalid return VerificationBinding{}, ErrVerificationContextInvalid
} }
normalized = context normalized = context
case VerificationScopePasskeyRegister, VerificationScopePasskeyDelete, VerificationScopeTwoFASetup: case VerificationScopePasskeyRegister, VerificationScopePasskeyDelete, VerificationScopeTwoFASetup,
VerificationScopeAccessTokenGenerate, VerificationScopeAccessTokenRevoke:
if len(fields) != 0 { if len(fields) != 0 {
return VerificationBinding{}, ErrVerificationContextInvalid return VerificationBinding{}, ErrVerificationContextInvalid
} }
...@@ -142,7 +145,8 @@ func securityVerificationPolicy(scope string, state verificationAccountState) ([ ...@@ -142,7 +145,8 @@ func securityVerificationPolicy(scope string, state verificationAccountState) ([
} else if state.HasPasskey { } else if state.HasPasskey {
methods = []string{VerificationMethodPasskey} methods = []string{VerificationMethodPasskey}
} }
case VerificationScopePasskeyRegister, VerificationScopeTwoFASetup: case VerificationScopePasskeyRegister, VerificationScopeTwoFASetup,
VerificationScopeAccessTokenGenerate, VerificationScopeAccessTokenRevoke:
if scope == VerificationScopeTwoFASetup && state.HasTwoFA { if scope == VerificationScopeTwoFASetup && state.HasTwoFA {
return nil, model.ErrTwoFAAlreadyEnabled return nil, model.ErrTwoFAAlreadyEnabled
} }
...@@ -153,7 +157,7 @@ func securityVerificationPolicy(scope string, state verificationAccountState) ([ ...@@ -153,7 +157,7 @@ func securityVerificationPolicy(scope string, state verificationAccountState) ([
methods = []string{VerificationMethodPasskey} methods = []string{VerificationMethodPasskey}
case state.HasPassword: case state.HasPassword:
methods = []string{VerificationMethodPassword} methods = []string{VerificationMethodPassword}
case state.WeChatEnrollment: case state.WeChatEnrollment && (scope == VerificationScopePasskeyRegister || scope == VerificationScopeTwoFASetup):
methods = []string{VerificationMethodSession} methods = []string{VerificationMethodSession}
default: default:
methods = []string{VerificationMethodOAuth} methods = []string{VerificationMethodOAuth}
......
...@@ -27,6 +27,8 @@ export type SecurityProofScope = ...@@ -27,6 +27,8 @@ export type SecurityProofScope =
| 'passkey.register' | 'passkey.register'
| 'passkey.delete' | 'passkey.delete'
| '2fa.setup' | '2fa.setup'
| 'access_token.generate'
| 'access_token.revoke'
export type VerificationOperation = export type VerificationOperation =
| { scope: 'channel.key.read'; context: { channel_id: number } } | { scope: 'channel.key.read'; context: { channel_id: number } }
......
...@@ -16,9 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,9 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com 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 { api } from '@/lib/api'
import { authRequestOptions, authResult } from '@/lib/secure-verification' import {
AuthOperationError,
authRequestOptions,
authResult,
} from '@/lib/secure-verification'
export interface AccessTokenStatus { export interface AccessTokenStatus {
exists: boolean exists: boolean
...@@ -28,29 +32,43 @@ export interface AccessTokenStatus { ...@@ -28,29 +32,43 @@ export interface AccessTokenStatus {
last_used_ip: string last_used_ip: string
} }
export async function getAccessTokenStatus(): Promise<AccessTokenStatus> { export function getAccessTokenStatus(): Promise<AccessTokenStatus> {
const response = await api.get<ApiResponse<AccessTokenStatus>>( return authResult(
'/api/user/token/status' 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> { export async function createAccessToken(
const response = await api.post<ApiResponse<string>>('/api/user/token') proofToken: string,
if (!response.data.success || !response.data.data) { signal: AbortSignal
throw new Error(response.data.message || 'Failed to generate token') ): Promise<string> {
} const token = await authResult<string>(
return response.data.data 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> { export async function revokeAccessToken(
const response = await api.delete<ApiResponse>('/api/user/token') proofToken: string,
if (!response.data.success) { signal: AbortSignal
throw new Error(response.data.message || 'Failed to revoke token') ): 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 { export interface TwoFASetupData {
......
...@@ -30,6 +30,7 @@ import { ...@@ -30,6 +30,7 @@ import {
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
} from '@/components/ui/sheet' } from '@/components/ui/sheet'
import { SecureVerificationDialog } from '@/features/auth/secure-verification'
import { AuditLogViewer } from '@/features/usage-logs/audit/components/audit-log-viewer' import { AuditLogViewer } from '@/features/usage-logs/audit/components/audit-log-viewer'
import dayjs from '@/lib/dayjs' import dayjs from '@/lib/dayjs'
...@@ -43,21 +44,19 @@ export function AccessTokenCard() { ...@@ -43,21 +44,19 @@ export function AccessTokenCard() {
null null
) )
const [historyOpen, setHistoryOpen] = useState(false) const [historyOpen, setHistoryOpen] = useState(false)
const pending = access.generate.isPending || access.revoke.isPending const pending = access.pending
const status = access.status.data const status = access.status.data
const ready = !access.status.isError && !access.status.isPending && !!status const ready = !access.status.isError && !access.status.isPending && !!status
let lastUsed = t('Unknown') let lastUsed = t('Unknown')
if (status?.last_used_at) { if (status?.last_used_at) {
lastUsed = dayjs.unix(status.last_used_at).format('YYYY-MM-DD HH:mm:ss') lastUsed = dayjs.unix(status.last_used_at).format('YYYY-MM-DD HH:mm:ss')
} else if (status?.created_at) lastUsed = t('Not used yet') } else if (status?.created_at) lastUsed = t('Not used yet')
const confirm = async () => { const confirm = () => {
try { if (pending || !confirmation) return
if (confirmation === 'revoke') await access.revoke.mutateAsync() const operation = confirmation
else await access.generate.mutateAsync() setConfirmation(null)
setConfirmation(null) if (operation === 'revoke') void access.revoke()
} catch { else void access.generate()
/* The mutation displays the error and preserves the confirmation. */
}
} }
return ( return (
<> <>
...@@ -152,7 +151,7 @@ export function AccessTokenCard() { ...@@ -152,7 +151,7 @@ export function AccessTokenCard() {
<Button <Button
size='sm' size='sm'
disabled={pending} disabled={pending}
onClick={() => access.generate.mutate()} onClick={() => void access.generate()}
> >
{t('Generate')} {t('Generate')}
</Button> </Button>
...@@ -164,6 +163,7 @@ export function AccessTokenCard() { ...@@ -164,6 +163,7 @@ export function AccessTokenCard() {
{access.token && ( {access.token && (
<AccessTokenDialog token={access.token} onClose={access.clearToken} /> <AccessTokenDialog token={access.token} onClose={access.clearToken} />
)} )}
<SecureVerificationDialog {...access.verificationDialogProps} />
<ConfirmDialog <ConfirmDialog
open={confirmation !== null} open={confirmation !== null}
onOpenChange={(open) => { onOpenChange={(open) => {
......
...@@ -16,11 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,11 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useSecureVerification } from '@/features/auth/secure-verification'
import { AuthOperationError } from '@/lib/secure-verification'
import { useAuthStore } from '@/stores/auth-store' import { useAuthStore } from '@/stores/auth-store'
import { import {
...@@ -33,36 +35,93 @@ export function useAccessToken() { ...@@ -33,36 +35,93 @@ export function useAccessToken() {
const { t } = useTranslation() const { t } = useTranslation()
const client = useQueryClient() const client = useQueryClient()
const userId = useAuthStore((state) => state.auth.user?.id) 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 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({ const status = useQuery({
queryKey: statusKey, queryKey: statusKey,
queryFn: getAccessTokenStatus, queryFn: getAccessTokenStatus,
retry: false, retry: false,
}) })
const refresh = () => client.invalidateQueries({ queryKey: statusKey }) useEffect(
const generate = useMutation({ () => () => {
// Keep plaintext out of the query/mutation cache and persistent storage. const current = currentOperation.current
mutationFn: async () => { currentOperation.current = null
setToken(await createAccessToken()) current?.abort()
cancelVerification()
setPending(false)
setGeneratedToken(null)
}, },
onSuccess: refresh, [cancelVerification, userId, sessionId]
onError: () => { )
toast.error(t('Failed to generate token'))
void refresh() const performOperation = useCallback(
}, async (operation: 'generate' | 'revoke') => {
}) if (currentOperation.current) return
const revoke = useMutation({ const controller = new AbortController()
mutationFn: revokeAccessToken, currentOperation.current = controller
onSuccess: () => { setPending(true)
setToken('') try {
toast.success(t('Access token revoked')) const proof = await requestVerification({
return refresh() 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: () => { [client, requestVerification, t, userId, sessionId]
toast.error(t('Failed to revoke token')) )
void refresh()
return {
status,
token:
generatedToken?.userId === userId &&
generatedToken?.sessionId === sessionId
? (generatedToken?.value ?? '')
: '',
pending,
clearToken: () => {
setGeneratedToken(null)
}, },
}) generate: () => performOperation('generate'),
return { status, token, clearToken: () => setToken(''), generate, revoke } revoke: () => performOperation('revoke'),
verificationDialogProps: verification.dialogProps,
}
} }
...@@ -64,20 +64,19 @@ export const authRequestOptions = { ...@@ -64,20 +64,19 @@ export const authRequestOptions = {
export async function authResult<T>( export async function authResult<T>(
request: Promise<{ request: Promise<{
data: { success: boolean; message?: string; code?: string; data?: T } data: { success: boolean; message?: string; code?: string; data?: T }
}> }>,
fallback = 'Verification failed. Please try again.'
): Promise<T> { ): Promise<T> {
try { try {
const { data: response } = await request const { data: response } = await request
if (!response.success || response.data === undefined) { if (!response.success || response.data === undefined) {
throw new AuthOperationError( throw new AuthOperationError(
getServerErrorMessageKey(response) || getServerErrorMessageKey(response) || response.message || fallback,
response.message ||
'Verification failed. Please try again.',
response.code response.code
) )
} }
return response.data return response.data
} catch (error) { } 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