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