feat(security): Session-Cookies statt X-User-Id und API-Härtung
Ersetzt die spoofbare X-User-Id-Auth durch signierte HttpOnly-Sessions nach WebAuthn, erzwingt WRITE-only Sync, speichert den Master-Key nur im RAM und ergänzt CORS, Rate-Limits, Helmet sowie Passkey-Reauth für sensible Aktionen. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Generated
+72
@@ -10,13 +10,17 @@
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.10.2",
|
||||
"@simplewebauthn/server": "^9.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"helmet": "^8.2.0",
|
||||
"prisma": "^5.10.2",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.24",
|
||||
@@ -657,6 +661,16 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie-parser": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
|
||||
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
@@ -932,6 +946,25 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
@@ -1175,6 +1208,24 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.5.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz",
|
||||
"integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
@@ -1307,6 +1358,18 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz",
|
||||
"integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/EvanHahn"
|
||||
}
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
@@ -1390,6 +1453,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
|
||||
@@ -12,13 +12,17 @@
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.10.2",
|
||||
"@simplewebauthn/server": "^9.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"helmet": "^8.2.0",
|
||||
"prisma": "^5.10.2",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.24",
|
||||
|
||||
+37
-4
@@ -1,5 +1,8 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import cookieParser from 'cookie-parser'
|
||||
import helmet from 'helmet'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import dotenv from 'dotenv'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -21,8 +24,39 @@ dotenv.config({ path: resolve(__dirname, '../.env') })
|
||||
const app = express()
|
||||
const PORT = process.env.PORT || 5000
|
||||
|
||||
app.use(cors())
|
||||
app.use(express.json({ limit: '50mb' }))
|
||||
const allowedOrigin = process.env.ORIGIN || 'http://localhost:5173'
|
||||
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: false,
|
||||
crossOriginEmbedderPolicy: false
|
||||
})
|
||||
)
|
||||
app.use(
|
||||
cors({
|
||||
origin: allowedOrigin,
|
||||
credentials: true
|
||||
})
|
||||
)
|
||||
app.use(cookieParser())
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 60,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
})
|
||||
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 1 * 60 * 1000,
|
||||
max: 300,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
})
|
||||
|
||||
app.use('/api/auth', authLimiter)
|
||||
app.use('/api', apiLimiter)
|
||||
|
||||
// Mount routes
|
||||
app.use('/api/auth', authRouter)
|
||||
@@ -44,11 +78,10 @@ app.get('/api/health', async (req, res) => {
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'Kapteins Daagbok Backend'
|
||||
})
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
database: 'disconnected',
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'Kapteins Daagbok Backend'
|
||||
})
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import { hasValidReauth, readSessionFromRequest } from '../session.js'
|
||||
|
||||
export interface AuthedRequest extends Request {
|
||||
userId: string
|
||||
session: NonNullable<ReturnType<typeof readSessionFromRequest>>
|
||||
}
|
||||
|
||||
export function requireUser(req: Request, res: Response, next: NextFunction): void {
|
||||
const session = readSessionFromRequest(req)
|
||||
if (!session) {
|
||||
res.status(401).json({ error: 'Unauthorized: valid session required' })
|
||||
return
|
||||
}
|
||||
;(req as AuthedRequest).userId = session.userId
|
||||
;(req as AuthedRequest).session = session
|
||||
next()
|
||||
}
|
||||
|
||||
export function requireReauth(req: Request, res: Response, next: NextFunction): void {
|
||||
const session = readSessionFromRequest(req)
|
||||
if (!session) {
|
||||
res.status(401).json({ error: 'Unauthorized: valid session required' })
|
||||
return
|
||||
}
|
||||
if (!hasValidReauth(session)) {
|
||||
res.status(403).json({ error: 'Recent passkey confirmation required' })
|
||||
return
|
||||
}
|
||||
;(req as AuthedRequest).userId = session.userId
|
||||
;(req as AuthedRequest).session = session
|
||||
next()
|
||||
}
|
||||
+119
-37
@@ -6,6 +6,14 @@ import {
|
||||
verifyAuthenticationResponse
|
||||
} from '@simplewebauthn/server'
|
||||
import { prisma } from '../db.js'
|
||||
import { requireReauth, requireUser } from '../middleware/auth.js'
|
||||
import {
|
||||
clearSessionCookie,
|
||||
extendReauth,
|
||||
readSessionFromRequest,
|
||||
setSessionCookie,
|
||||
setSessionTokenCookie
|
||||
} from '../session.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -13,12 +21,9 @@ const rpName = 'Kapteins Daagbok'
|
||||
const rpID = process.env.RP_ID || 'localhost'
|
||||
const origin = process.env.ORIGIN || 'http://localhost:5173'
|
||||
|
||||
// In-memory challenge stores
|
||||
const registrationChallenges = new Map<string, string>()
|
||||
const authenticationChallenges = new Map<string, { challenge: string; userId: string }>()
|
||||
const activeChallenges = new Set<string>()
|
||||
|
||||
// 1. Generate Registration Options
|
||||
router.post('/register-options', async (req, res) => {
|
||||
try {
|
||||
const { username } = req.body
|
||||
@@ -34,13 +39,6 @@ router.post('/register-options', async (req, res) => {
|
||||
return res.status(400).json({ error: 'User already exists' })
|
||||
}
|
||||
|
||||
// NOTE: @simplewebauthn/server v9 places `userID` verbatim into the
|
||||
// emitted `user.id` JSON field. The browser client (v13) however decodes
|
||||
// `user.id` as a base64url string. Passing a raw username therefore either
|
||||
// corrupts the user handle or, for usernames containing characters outside
|
||||
// the base64url alphabet (".", " ", "@", umlauts, ...), makes the browser
|
||||
// throw "Invalid character" before the passkey prompt even appears.
|
||||
// Encoding the username as base64url keeps the value spec-compliant.
|
||||
const userID = Buffer.from(username, 'utf8').toString('base64url')
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
@@ -54,10 +52,9 @@ router.post('/register-options', async (req, res) => {
|
||||
residentKey: 'required',
|
||||
userVerification: 'preferred'
|
||||
},
|
||||
supportedAlgorithmIDs: [-7, -257] // ES256 and RS256
|
||||
supportedAlgorithmIDs: [-7, -257]
|
||||
})
|
||||
|
||||
// Store challenge
|
||||
registrationChallenges.set(username, options.challenge)
|
||||
|
||||
return res.json(options)
|
||||
@@ -67,7 +64,6 @@ router.post('/register-options', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 2. Verify Registration Response
|
||||
router.post('/register-verify', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
@@ -103,7 +99,6 @@ router.post('/register-verify', async (req, res) => {
|
||||
|
||||
const { credentialID, credentialPublicKey, counter } = verification.registrationInfo
|
||||
|
||||
// Save user and credential
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
@@ -125,6 +120,7 @@ router.post('/register-verify', async (req, res) => {
|
||||
})
|
||||
|
||||
registrationChallenges.delete(username)
|
||||
setSessionCookie(res, user.id, true)
|
||||
|
||||
return res.json({ verified: true, userId: user.id })
|
||||
} catch (error: any) {
|
||||
@@ -133,12 +129,10 @@ router.post('/register-verify', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 3. Generate Authentication Options
|
||||
router.post('/login-options', async (req, res) => {
|
||||
try {
|
||||
const { username } = req.body
|
||||
|
||||
// If username is supplied, we do a targeted login, otherwise usernameless
|
||||
let allowCredentials: any[] = []
|
||||
if (username) {
|
||||
const user = await prisma.user.findUnique({
|
||||
@@ -146,7 +140,7 @@ router.post('/login-options', async (req, res) => {
|
||||
include: { credentials: true }
|
||||
})
|
||||
if (user) {
|
||||
allowCredentials = user.credentials.map(cred => ({
|
||||
allowCredentials = user.credentials.map((cred) => ({
|
||||
id: Buffer.from(cred.credentialId, 'base64url'),
|
||||
type: 'public-key',
|
||||
transports: cred.transports as any[]
|
||||
@@ -160,7 +154,6 @@ router.post('/login-options', async (req, res) => {
|
||||
userVerification: 'preferred'
|
||||
})
|
||||
|
||||
// Store challenge
|
||||
activeChallenges.add(options.challenge)
|
||||
|
||||
return res.json(options)
|
||||
@@ -170,7 +163,6 @@ router.post('/login-options', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 4. Verify Authentication Response
|
||||
router.post('/login-verify', async (req, res) => {
|
||||
try {
|
||||
const { credentialResponse, challenge } = req.body
|
||||
@@ -178,13 +170,11 @@ router.post('/login-verify', async (req, res) => {
|
||||
return res.status(400).json({ error: 'credentialResponse and challenge are required' })
|
||||
}
|
||||
|
||||
// Verify challenge
|
||||
if (!activeChallenges.has(challenge)) {
|
||||
return res.status(400).json({ error: 'Challenge not found or expired' })
|
||||
}
|
||||
activeChallenges.delete(challenge)
|
||||
|
||||
// Find the credential in DB
|
||||
const dbCred = await prisma.credential.findUnique({
|
||||
where: { credentialId: credentialResponse.id },
|
||||
include: { user: true }
|
||||
@@ -212,12 +202,13 @@ router.post('/login-verify', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Authentication failed' })
|
||||
}
|
||||
|
||||
// Update counter
|
||||
await prisma.credential.update({
|
||||
where: { id: dbCred.id },
|
||||
data: { counter: BigInt(verification.authenticationInfo.newCounter) }
|
||||
})
|
||||
|
||||
setSessionCookie(res, user.id, true)
|
||||
|
||||
return res.json({
|
||||
verified: true,
|
||||
userId: user.id,
|
||||
@@ -235,16 +226,112 @@ router.post('/login-verify', async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 5. Delete own account
|
||||
router.delete('/delete-account', async (req: any, res) => {
|
||||
router.get('/session', (req, res) => {
|
||||
const session = readSessionFromRequest(req)
|
||||
if (!session) {
|
||||
return res.status(401).json({ authenticated: false })
|
||||
}
|
||||
return res.json({ authenticated: true, userId: session.userId })
|
||||
})
|
||||
|
||||
router.post('/logout', (req, res) => {
|
||||
clearSessionCookie(res)
|
||||
return res.json({ success: true })
|
||||
})
|
||||
|
||||
router.post('/reauth-options', requireUser, async (req: any, res) => {
|
||||
try {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.userId },
|
||||
include: { credentials: true }
|
||||
})
|
||||
|
||||
if (!user || user.credentials.length === 0) {
|
||||
return res.status(400).json({ error: 'No passkey credentials found' })
|
||||
}
|
||||
|
||||
const allowCredentials = user.credentials.map((cred) => ({
|
||||
id: Buffer.from(cred.credentialId, 'base64url'),
|
||||
type: 'public-key' as const,
|
||||
transports: cred.transports as any[]
|
||||
}))
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
allowCredentials,
|
||||
userVerification: 'required'
|
||||
})
|
||||
|
||||
activeChallenges.add(options.challenge)
|
||||
|
||||
return res.json(options)
|
||||
} catch (error: any) {
|
||||
console.error('Error generating reauth options:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/reauth-verify', requireUser, async (req: any, res) => {
|
||||
try {
|
||||
const { credentialResponse, challenge } = req.body
|
||||
if (!credentialResponse || !challenge) {
|
||||
return res.status(400).json({ error: 'credentialResponse and challenge are required' })
|
||||
}
|
||||
|
||||
if (!activeChallenges.has(challenge)) {
|
||||
return res.status(400).json({ error: 'Challenge not found or expired' })
|
||||
}
|
||||
activeChallenges.delete(challenge)
|
||||
|
||||
const dbCred = await prisma.credential.findUnique({
|
||||
where: { credentialId: credentialResponse.id },
|
||||
include: { user: true }
|
||||
})
|
||||
|
||||
if (!dbCred || dbCred.userId !== req.userId) {
|
||||
return res.status(403).json({ error: 'Credential does not belong to this account' })
|
||||
}
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response: credentialResponse,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
authenticator: {
|
||||
credentialID: Buffer.from(dbCred.credentialId, 'base64url'),
|
||||
credentialPublicKey: dbCred.publicKey,
|
||||
counter: Number(dbCred.counter)
|
||||
}
|
||||
})
|
||||
|
||||
if (!verification.verified || !verification.authenticationInfo) {
|
||||
return res.status(400).json({ error: 'Reauthentication failed' })
|
||||
}
|
||||
|
||||
await prisma.credential.update({
|
||||
where: { id: dbCred.id },
|
||||
data: { counter: BigInt(verification.authenticationInfo.newCounter) }
|
||||
})
|
||||
|
||||
const currentToken = req.cookies?.daagbok_session
|
||||
const extended = typeof currentToken === 'string' ? extendReauth(currentToken) : null
|
||||
if (extended) {
|
||||
setSessionTokenCookie(res, extended)
|
||||
} else {
|
||||
setSessionCookie(res, req.userId, true)
|
||||
}
|
||||
|
||||
return res.json({ verified: true })
|
||||
} catch (error: any) {
|
||||
console.error('Error verifying reauth:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/delete-account', requireReauth, async (req: any, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId }
|
||||
where: { id: req.userId }
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
@@ -252,9 +339,10 @@ router.delete('/delete-account', async (req: any, res) => {
|
||||
}
|
||||
|
||||
await prisma.user.delete({
|
||||
where: { id: userId }
|
||||
where: { id: req.userId }
|
||||
})
|
||||
|
||||
clearSessionCookie(res)
|
||||
return res.json({ success: true })
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting account:', error)
|
||||
@@ -262,14 +350,8 @@ router.delete('/delete-account', async (req: any, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 6. Enroll PRF encrypted master key
|
||||
router.post('/enroll-prf', async (req: any, res) => {
|
||||
router.post('/enroll-prf', requireReauth, async (req: any, res) => {
|
||||
try {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
}
|
||||
|
||||
const { encryptedMasterKeyPrf, encryptedMasterKeyPrfIv, encryptedMasterKeyPrfTag } = req.body
|
||||
if (!encryptedMasterKeyPrf || !encryptedMasterKeyPrfIv || !encryptedMasterKeyPrfTag) {
|
||||
return res.status(400).json({ error: 'Missing required PRF key fields' })
|
||||
@@ -284,7 +366,7 @@ router.post('/enroll-prf', async (req: any, res) => {
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
where: { id: req.userId },
|
||||
data: {
|
||||
encryptedMasterKeyPrf,
|
||||
encryptedMasterKeyPrfIv,
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { requireUser } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Middleware to extract user ID from headers (for authenticated routes)
|
||||
const requireUser = (req: any, res: any, next: any) => {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
}
|
||||
req.userId = userId
|
||||
next()
|
||||
}
|
||||
|
||||
// 1. Get invitation details (public route, does not require authentication)
|
||||
router.get('/invite-details', async (req: any, res) => {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Router } from 'express'
|
||||
import { isNtfyConfigured, sendFeedbackViaNtfy } from '../services/ntfyNotify.js'
|
||||
import { requireUser } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -20,15 +21,6 @@ function parseOptionalEmail(value: unknown): string | undefined {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const requireUser = (req: any, res: any, next: any) => {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
}
|
||||
req.userId = userId
|
||||
next()
|
||||
}
|
||||
|
||||
router.get('/status', requireUser, (_req, res) => {
|
||||
res.json({ enabled: isNtfyConfigured() })
|
||||
})
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { requireUser } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Middleware to extract user ID from headers
|
||||
const requireUser = (req: any, res: any, next: any) => {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
}
|
||||
req.userId = userId
|
||||
next()
|
||||
}
|
||||
|
||||
router.use(requireUser)
|
||||
|
||||
// 1. Get all logbooks for the authenticated user (owned and shared)
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { requireUser } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const requireUser = (req: any, res: any, next: any) => {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
}
|
||||
req.userId = userId
|
||||
next()
|
||||
}
|
||||
|
||||
function isValidHttpsEndpoint(endpoint: unknown): endpoint is string {
|
||||
if (typeof endpoint !== 'string' || endpoint.length > 2048) return false
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
verifyAuthenticationResponse
|
||||
} from '@simplewebauthn/server'
|
||||
import { prisma } from '../db.js'
|
||||
import { requireUser } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -31,15 +32,6 @@ function pruneExpiredChallenges() {
|
||||
}
|
||||
}
|
||||
|
||||
const requireUser = (req: any, res: any, next: any) => {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
}
|
||||
req.userId = userId
|
||||
next()
|
||||
}
|
||||
|
||||
router.use(requireUser)
|
||||
|
||||
async function getLogbookWithAccess(logbookId: string, userId: string) {
|
||||
|
||||
+11
-15
@@ -1,19 +1,10 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { notifyOwnerOfCollaboratorChanges } from '../services/pushNotify.js'
|
||||
import { requireUser } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
// Middleware to extract user ID from headers
|
||||
const requireUser = (req: any, res: any, next: any) => {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
}
|
||||
req.userId = userId
|
||||
next()
|
||||
}
|
||||
|
||||
router.use(requireUser)
|
||||
|
||||
// 1. Push local changes to the server
|
||||
@@ -99,7 +90,7 @@ router.post('/push', async (req: any, res) => {
|
||||
}
|
||||
|
||||
const isOwner = logbook.userId === req.userId
|
||||
const isCollaborator = await prisma.collaboration.findUnique({
|
||||
const collaboration = await prisma.collaboration.findUnique({
|
||||
where: {
|
||||
logbookId_userId: {
|
||||
logbookId,
|
||||
@@ -108,11 +99,16 @@ router.post('/push', async (req: any, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
if (!isOwner && !isCollaborator) {
|
||||
if (!isOwner && !collaboration) {
|
||||
results.push({ payloadId, status: 'error', error: 'Forbidden: Access denied' })
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isOwner && (!collaboration || collaboration.role !== 'WRITE')) {
|
||||
results.push({ payloadId, status: 'error', error: 'Forbidden: WRITE access required' })
|
||||
continue
|
||||
}
|
||||
|
||||
if (type === 'logbook' && action === 'delete') {
|
||||
if (!isOwner) {
|
||||
results.push({ payloadId, status: 'error', error: 'Forbidden: Only owner can delete logbook' })
|
||||
@@ -244,7 +240,7 @@ router.post('/push', async (req: any, res) => {
|
||||
logbook.userId,
|
||||
logbookId,
|
||||
isOwner,
|
||||
isCollaborator,
|
||||
collaboration,
|
||||
action,
|
||||
type
|
||||
)
|
||||
@@ -284,7 +280,7 @@ router.get('/pull', async (req: any, res) => {
|
||||
}
|
||||
|
||||
const isOwner = logbook.userId === req.userId
|
||||
const isCollaborator = await prisma.collaboration.findUnique({
|
||||
const collaboration = await prisma.collaboration.findUnique({
|
||||
where: {
|
||||
logbookId_userId: {
|
||||
logbookId,
|
||||
@@ -293,7 +289,7 @@ router.get('/pull', async (req: any, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
if (!isOwner && !isCollaborator) {
|
||||
if (!isOwner && !collaboration) {
|
||||
return res.status(403).json({ error: 'Forbidden: Access denied' })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import { Router } from 'express'
|
||||
import { requireUser } from '../middleware/auth.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const requireUser = (req: any, res: any, next: any) => {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
}
|
||||
req.userId = userId
|
||||
next()
|
||||
}
|
||||
|
||||
function resolveOwmApiKey(userProvidedKey: unknown): string | null {
|
||||
if (typeof userProvidedKey === 'string' && userProvidedKey.trim()) {
|
||||
return userProvidedKey.trim()
|
||||
@@ -21,7 +13,7 @@ function resolveOwmApiKey(userProvidedKey: unknown): string | null {
|
||||
return fromEnv || null
|
||||
}
|
||||
|
||||
router.get('/current', requireUser, async (req: any, res) => {
|
||||
router.get('/current', requireUser, async (req, res) => {
|
||||
try {
|
||||
const { lat, lon, q } = req.query
|
||||
const apiKey = resolveOwmApiKey(req.headers['x-owm-api-key'])
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import crypto from 'crypto'
|
||||
import type { CookieOptions, Request, Response } from 'express'
|
||||
|
||||
export const SESSION_COOKIE = 'daagbok_session'
|
||||
const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
export const REAUTH_MAX_AGE_MS = 10 * 60 * 1000
|
||||
|
||||
export interface SessionPayload {
|
||||
userId: string
|
||||
exp: number
|
||||
reauthExp?: number
|
||||
}
|
||||
|
||||
function sessionSecret(): string {
|
||||
const secret = process.env.SESSION_SECRET?.trim()
|
||||
if (secret && secret.length >= 32) return secret
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('SESSION_SECRET must be set in production (min. 32 characters)')
|
||||
}
|
||||
return 'dev-only-insecure-session-secret-change-me!!'
|
||||
}
|
||||
|
||||
function sign(data: string): string {
|
||||
return crypto.createHmac('sha256', sessionSecret()).update(data).digest('base64url')
|
||||
}
|
||||
|
||||
export function createSessionToken(userId: string, withReauth = true): string {
|
||||
const payload: SessionPayload = {
|
||||
userId,
|
||||
exp: Date.now() + SESSION_MAX_AGE_MS,
|
||||
...(withReauth ? { reauthExp: Date.now() + REAUTH_MAX_AGE_MS } : {})
|
||||
}
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||
const signature = sign(body)
|
||||
return `${body}.${signature}`
|
||||
}
|
||||
|
||||
export function extendReauth(token: string): string | null {
|
||||
const payload = verifySessionToken(token)
|
||||
if (!payload) return null
|
||||
payload.reauthExp = Date.now() + REAUTH_MAX_AGE_MS
|
||||
const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||
return `${body}.${sign(body)}`
|
||||
}
|
||||
|
||||
export function verifySessionToken(token: string | undefined): SessionPayload | null {
|
||||
if (!token || typeof token !== 'string') return null
|
||||
const dot = token.lastIndexOf('.')
|
||||
if (dot <= 0) return null
|
||||
const body = token.slice(0, dot)
|
||||
const sig = token.slice(dot + 1)
|
||||
if (sig !== sign(body)) return null
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as SessionPayload
|
||||
if (!payload.userId || typeof payload.exp !== 'number') return null
|
||||
if (payload.exp <= Date.now()) return null
|
||||
return payload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function readSessionFromRequest(req: Request): SessionPayload | null {
|
||||
const raw = req.cookies?.[SESSION_COOKIE]
|
||||
if (typeof raw !== 'string') return null
|
||||
return verifySessionToken(raw)
|
||||
}
|
||||
|
||||
export function sessionCookieOptions(): CookieOptions {
|
||||
const origin = process.env.ORIGIN || 'http://localhost:5173'
|
||||
const secure = origin.startsWith('https://')
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: SESSION_MAX_AGE_MS
|
||||
}
|
||||
}
|
||||
|
||||
export function setSessionCookie(res: Response, userId: string, withReauth = true): void {
|
||||
res.cookie(SESSION_COOKIE, createSessionToken(userId, withReauth), sessionCookieOptions())
|
||||
}
|
||||
|
||||
export function setSessionTokenCookie(res: Response, token: string): void {
|
||||
res.cookie(SESSION_COOKIE, token, sessionCookieOptions())
|
||||
}
|
||||
|
||||
export function clearSessionCookie(res: Response): void {
|
||||
res.clearCookie(SESSION_COOKIE, {
|
||||
httpOnly: true,
|
||||
secure: sessionCookieOptions().secure,
|
||||
sameSite: 'lax',
|
||||
path: '/'
|
||||
})
|
||||
}
|
||||
|
||||
export function hasValidReauth(payload: SessionPayload): boolean {
|
||||
return typeof payload.reauthExp === 'number' && payload.reauthExp > Date.now()
|
||||
}
|
||||
Reference in New Issue
Block a user