178 lines
4.9 KiB
TypeScript
178 lines
4.9 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
|
import { config as appConfig } from './lib/config'
|
|
|
|
const AUTH_COOKIE_NAME = 'auth_token'
|
|
const SECRET_KEY = appConfig.auth.secret
|
|
|
|
// Helper to generate a random nonce
|
|
function generateNonce() {
|
|
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)
|
|
}
|
|
|
|
// Helper to create a signed token (simplified JWT-like structure)
|
|
async function createToken(username: string) {
|
|
const header = { alg: 'HS256', typ: 'JWT' }
|
|
const payload = {
|
|
sub: username,
|
|
iat: Date.now(),
|
|
exp: Date.now() + 24 * 60 * 60 * 1000, // 24 hours
|
|
nonce: generateNonce() // Ensure token is different every time
|
|
}
|
|
|
|
const encodedHeader = btoa(JSON.stringify(header))
|
|
const encodedPayload = btoa(JSON.stringify(payload))
|
|
const data = `${encodedHeader}.${encodedPayload}`
|
|
|
|
const key = await crypto.subtle.importKey(
|
|
'raw',
|
|
new TextEncoder().encode(appConfig.auth.secret),
|
|
{ name: 'HMAC', hash: 'SHA-256' },
|
|
false,
|
|
['sign']
|
|
)
|
|
|
|
const signature = await crypto.subtle.sign(
|
|
'HMAC',
|
|
key,
|
|
new TextEncoder().encode(data)
|
|
)
|
|
|
|
// Convert signature to base64
|
|
const signatureArray = Array.from(new Uint8Array(signature))
|
|
const encodedSignature = btoa(String.fromCharCode.apply(null, signatureArray))
|
|
|
|
return `${data}.${encodedSignature}`
|
|
}
|
|
|
|
// Helper to verify token
|
|
async function verifyToken(token: string) {
|
|
try {
|
|
const parts = token.split('.')
|
|
if (parts.length !== 3) return false
|
|
|
|
const [encodedHeader, encodedPayload, encodedSignature] = parts
|
|
const data = `${encodedHeader}.${encodedPayload}`
|
|
|
|
const key = await crypto.subtle.importKey(
|
|
'raw',
|
|
new TextEncoder().encode(SECRET_KEY),
|
|
{ name: 'HMAC', hash: 'SHA-256' },
|
|
false,
|
|
['verify']
|
|
)
|
|
|
|
const signature = new Uint8Array(
|
|
atob(encodedSignature).split('').map(c => c.charCodeAt(0))
|
|
)
|
|
|
|
const isValid = await crypto.subtle.verify(
|
|
'HMAC',
|
|
key,
|
|
signature,
|
|
new TextEncoder().encode(data)
|
|
)
|
|
|
|
if (!isValid) return false
|
|
|
|
const payload = JSON.parse(atob(encodedPayload))
|
|
if (payload.exp < Date.now()) return false
|
|
|
|
return true
|
|
} catch (e) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export async function middleware(req: NextRequest) {
|
|
const url = new URL(req.url)
|
|
|
|
// Skip auth for certain paths (same logic as original Express app)
|
|
if (
|
|
url.pathname.startsWith('/api/') ||
|
|
url.pathname.startsWith('/screenshots/') ||
|
|
url.pathname.startsWith('/downloads/') ||
|
|
url.pathname.startsWith('/manifest.json') ||
|
|
url.pathname.startsWith('/sw.js') ||
|
|
url.pathname.includes('favicon.png') ||
|
|
url.pathname.includes('install') ||
|
|
url.pathname.includes('WinupdateCore') ||
|
|
req.method === 'POST' ||
|
|
url.pathname.startsWith('/_next/') || // Next.js static files
|
|
url.pathname.startsWith('/api-test') // API test page (for development)
|
|
) {
|
|
return NextResponse.next()
|
|
}
|
|
|
|
// 1. Try Cookie Authentication
|
|
const authCookie = req.cookies.get(AUTH_COOKIE_NAME)
|
|
if (authCookie) {
|
|
const isValid = await verifyToken(authCookie.value)
|
|
if (isValid) {
|
|
return NextResponse.next()
|
|
}
|
|
}
|
|
|
|
// 2. Fallback to Basic Auth
|
|
const authHeader = req.headers.get('authorization')
|
|
|
|
if (!authHeader) {
|
|
return new NextResponse('Authentication required', {
|
|
status: 401,
|
|
headers: {
|
|
'WWW-Authenticate': 'Basic realm="Restricted Access"'
|
|
}
|
|
})
|
|
}
|
|
|
|
try {
|
|
// Use atob for Edge compatibility
|
|
const authValue = authHeader.split(' ')[1]
|
|
const auth = atob(authValue)
|
|
const [username, password] = auth.split(':')
|
|
|
|
if (username === appConfig.auth.username && password === appConfig.auth.password) {
|
|
const response = NextResponse.next()
|
|
|
|
// Set auth cookie on successful Basic Auth
|
|
const token = await createToken(username)
|
|
response.cookies.set(AUTH_COOKIE_NAME, token, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'lax',
|
|
maxAge: 60 * 60 * 24, // 1 day
|
|
path: '/'
|
|
})
|
|
|
|
return response
|
|
} else {
|
|
return new NextResponse('Authentication failed', {
|
|
status: 401,
|
|
headers: {
|
|
'WWW-Authenticate': 'Basic realm="Restricted Access"'
|
|
}
|
|
})
|
|
}
|
|
} catch {
|
|
return new NextResponse('Invalid authentication', {
|
|
status: 401,
|
|
headers: {
|
|
'WWW-Authenticate': 'Basic realm="Restricted Access"'
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 配置中间件匹配的路径
|
|
export const config = {
|
|
matcher: [
|
|
/*
|
|
* Match all request paths except for the ones starting with:
|
|
* - api (API routes)
|
|
* - _next/static (static files)
|
|
* - _next/image (image optimization files)
|
|
* - favicon.ico (favicon file)
|
|
*/
|
|
'/((?!_next/static|_next/image|favicon.ico|manifest.json|sw.js).*)',
|
|
],
|
|
}
|