Merge latest beta with verified auth hardening
This commit is contained in:
@@ -64,10 +64,10 @@ QBIT_URL="http://localhost:8080"
|
||||
QBIT_USERNAME="..."
|
||||
QBIT_PASSWORD="..."
|
||||
SQLITE_PATH="data/magent.db"
|
||||
JWT_SECRET="change-me"
|
||||
JWT_SECRET="replace-with-a-long-random-secret"
|
||||
JWT_EXP_MINUTES="720"
|
||||
ADMIN_USERNAME="admin"
|
||||
ADMIN_PASSWORD="adminadmin"
|
||||
ADMIN_USERNAME="set-a-real-admin-username"
|
||||
ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
@@ -112,10 +112,10 @@ $env:QBIT_URL="http://localhost:8080"
|
||||
$env:QBIT_USERNAME="..."
|
||||
$env:QBIT_PASSWORD="..."
|
||||
$env:SQLITE_PATH="data/magent.db"
|
||||
$env:JWT_SECRET="change-me"
|
||||
$env:JWT_SECRET="replace-with-a-long-random-secret"
|
||||
$env:JWT_EXP_MINUTES="720"
|
||||
$env:ADMIN_USERNAME="admin"
|
||||
$env:ADMIN_PASSWORD="adminadmin"
|
||||
$env:ADMIN_USERNAME="set-a-real-admin-username"
|
||||
$env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
|
||||
```
|
||||
|
||||
### Frontend (Next.js)
|
||||
|
||||
@@ -44,6 +44,8 @@ def _create_token(
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
|
||||
|
||||
def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str:
|
||||
if not settings.jwt_secret:
|
||||
raise ValueError("JWT_SECRET is not configured")
|
||||
minutes = expires_minutes or settings.jwt_exp_minutes
|
||||
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
||||
return _create_token(subject, role, expires_at=expires, token_type="access")
|
||||
@@ -55,6 +57,8 @@ def create_stream_token(subject: str, role: str, expires_seconds: int = 120) ->
|
||||
|
||||
|
||||
def decode_token(token: str) -> Dict[str, Any]:
|
||||
if not settings.jwt_secret:
|
||||
raise ValueError("JWT_SECRET is not configured")
|
||||
return jwt.decode(token, settings.jwt_secret, algorithms=[_ALGORITHM])
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth'
|
||||
|
||||
type Profile = {
|
||||
username?: string
|
||||
@@ -24,15 +24,17 @@ export default function FeedbackPage() {
|
||||
const load = async () => {
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/auth/me`)
|
||||
const response = await authFetchOrThrow(`${baseUrl}/auth/me`)
|
||||
if (!response.ok) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
throw new Error('Could not load profile.')
|
||||
}
|
||||
const data = await response.json()
|
||||
setProfile({ username: data?.username })
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
@@ -49,7 +51,7 @@ export default function FeedbackPage() {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/feedback`, {
|
||||
const response = await authFetchOrThrow(`${baseUrl}/feedback`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -58,17 +60,16 @@ export default function FeedbackPage() {
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
const text = await response.text()
|
||||
throw new Error(text || `Request failed: ${response.status}`)
|
||||
}
|
||||
setMessage('')
|
||||
setStatus('Thanks! Your message has been sent.')
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
console.error(error)
|
||||
setStatus('That did not send. Please try again.')
|
||||
} finally {
|
||||
|
||||
@@ -64,3 +64,37 @@ export const getEventStreamToken = async () => {
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor() {
|
||||
super('Unauthorized')
|
||||
this.name = 'UnauthorizedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends Error {
|
||||
constructor() {
|
||||
super('Forbidden')
|
||||
this.name = 'ForbiddenError'
|
||||
}
|
||||
}
|
||||
|
||||
export const authFetchOrThrow = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const response = await authFetch(input, init)
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
throw new UnauthorizedError()
|
||||
}
|
||||
if (response.status === 403) {
|
||||
throw new ForbiddenError()
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
export const readResponseText = async (response: Response) => {
|
||||
try {
|
||||
return (await response.text()).trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user