Harden cookie authentication

This commit is contained in:
lucast committed 2026-09-29 15:57:07 +02:00
1 parent a0a62c1c06
commit 31e6c0c5d2
5 files changed
+116 -17

No files matched your search

+7 -2
View File
@@ -1,10 +1,15 @@
import { readBody, setCookie, setResponseStatus } from "h3"
import argon2 from "argon2"
import { User } from "../../models/user"
import { REFRESH_COOKIE } from "../../utils/auth"
import {
assertCookieRequestOrigin,
defineAuthenticatedEventHandler,
REFRESH_COOKIE,
} from "../../utils/auth"
import { getAuthValidationMessage } from "../../utils/auth-validation"
export default defineEventHandler(async (event) => {
export default defineAuthenticatedEventHandler(async (event) => {
assertCookieRequestOrigin(event)
const body = await readBody(event)
const validationMessage = getAuthValidationMessage(body, [
{ name: "name" },
+7 -4
View File
@@ -2,13 +2,15 @@ import { getCookie, readBody, setCookie, setResponseStatus } from "h3"
import { User } from "../../models/user"
import { getSessionModel } from "../../models/session"
import {
assertCookieRequestOrigin,
defineAuthenticatedEventHandler,
getSessionTtl,
REFRESH_COOKIE,
SESSION_COOKIE,
} from "../../utils/auth"
import { getAuthValidationMessage } from "../../utils/auth-validation"
export default defineEventHandler(async (event) => {
export default defineAuthenticatedEventHandler(async (event) => {
const body = await readBody(event).catch(() => undefined)
const validationMessage =
body === undefined ? null : getAuthValidationMessage(body, [{ name: "refreshToken" }])
@@ -17,9 +19,10 @@ export default defineEventHandler(async (event) => {
return { message: validationMessage }
}
const refreshToken =
(typeof body?.refreshToken === "string" && body.refreshToken) ||
getCookie(event, REFRESH_COOKIE)
const bodyRefreshToken = typeof body?.refreshToken === "string" && body.refreshToken
const cookieRefreshToken = getCookie(event, REFRESH_COOKIE)
if (!bodyRefreshToken && cookieRefreshToken) assertCookieRequestOrigin(event)
const refreshToken = bodyRefreshToken || cookieRefreshToken
if (!refreshToken) {
setResponseStatus(event, 401)
+7 -2
View File
@@ -1,10 +1,15 @@
import { readBody, setCookie, setResponseStatus } from "h3"
import argon2 from "argon2"
import { User } from "../../models/user"
import { REFRESH_COOKIE } from "../../utils/auth"
import {
assertCookieRequestOrigin,
defineAuthenticatedEventHandler,
REFRESH_COOKIE,
} from "../../utils/auth"
import { getAuthValidationMessage } from "../../utils/auth-validation"
export default defineEventHandler(async (event) => {
export default defineAuthenticatedEventHandler(async (event) => {
assertCookieRequestOrigin(event)
const body = await readBody(event)
const validationMessage = getAuthValidationMessage(body, [
{ name: "email", email: true },
+8 -2
View File
@@ -1,7 +1,13 @@
import { deleteCookie } from "h3"
import { REFRESH_COOKIE, SESSION_COOKIE } from "../../utils/auth"
import {
assertCookieRequestOrigin,
defineAuthenticatedEventHandler,
REFRESH_COOKIE,
SESSION_COOKIE,
} from "../../utils/auth"
export default defineEventHandler((event) => {
export default defineAuthenticatedEventHandler((event) => {
assertCookieRequestOrigin(event)
deleteCookie(event, REFRESH_COOKIE, { path: "/", sameSite: "lax", secure: !import.meta.dev })
deleteCookie(event, SESSION_COOKIE, { path: "/", sameSite: "lax", secure: !import.meta.dev })
return { success: true }
+87 -7
View File
@@ -1,5 +1,13 @@
import type { H3Event } from "h3"
import { createError, getCookie } from "h3"
import {
createError,
defineEventHandler,
getCookie,
getRequestHeader,
getRequestURL,
setResponseStatus,
type EventHandler,
} from "h3"
import parseDuration from "parse-duration"
import mongoose from "mongoose"
import { getSessionModel } from "../models/session"
@@ -7,6 +15,64 @@ import { getBackendRuntimeConfig } from "./backend-config"
export const SESSION_COOKIE = "musicSession"
export const REFRESH_COOKIE = "refreshToken"
const INVALID_REQUEST_ORIGIN = "Invalid request origin"
/**
* Keep the legacy API's direct authentication response body while leaving all
* unrelated H3 errors to Nitro's normal error handler.
*/
export function defineAuthenticatedEventHandler(handler: EventHandler) {
return defineEventHandler(async (event) => {
try {
return await handler(event)
} catch (error) {
const statusCode = (error as { statusCode?: number } | undefined)?.statusCode
const statusMessage = (error as { statusMessage?: string } | undefined)?.statusMessage
const message = (error as { data?: { message?: string } } | undefined)?.data?.message
if (statusCode === 401 && statusMessage === "Unauthorized" && message === "Unauthorized") {
setResponseStatus(event, 401)
return { message: "Unauthorized" }
}
if (statusCode === 403 && statusMessage === "Forbidden" && message === INVALID_REQUEST_ORIGIN) {
setResponseStatus(event, 403)
return { message: INVALID_REQUEST_ORIGIN }
}
throw error
}
})
}
export function assertCookieRequestOrigin(event: H3Event) {
const originHeader = getRequestHeader(event, "origin")
const refererHeader = getRequestHeader(event, "referer")
let requestOrigin: string | undefined
let suppliedOrigin: string | undefined
try {
// H3 uses Host for the request host. A trusted ingress may provide
// x-forwarded-proto; it must overwrite that header before this app.
requestOrigin = getRequestURL(event, { xForwardedProto: true }).origin
if (originHeader !== undefined) {
const parsedOrigin = new URL(originHeader)
if (parsedOrigin.origin === originHeader) suppliedOrigin = parsedOrigin.origin
} else if (refererHeader !== undefined) {
suppliedOrigin = new URL(refererHeader).origin
}
} catch {
// Invalid or absent browser origin proof is rejected below.
}
if (!suppliedOrigin || !requestOrigin || suppliedOrigin !== requestOrigin) {
throw createError({
statusCode: 403,
statusMessage: "Forbidden",
data: { message: INVALID_REQUEST_ORIGIN },
})
}
}
export function getSessionTtl(event?: H3Event) {
const { tokenExpire } = getBackendRuntimeConfig(event)
@@ -14,15 +80,17 @@ export function getSessionTtl(event?: H3Event) {
}
function getSessionTokens(event: H3Event) {
const tokens: string[] = []
const tokens: { token: string; source: "cookie" | "bearer" }[] = []
const cookieToken = getCookie(event, SESSION_COOKIE)
const authorization = event.node.req.headers.authorization
const bearerToken = authorization?.startsWith("Bearer ")
? authorization.slice("Bearer ".length)
: undefined
if (cookieToken) tokens.push(cookieToken)
if (bearerToken && bearerToken !== cookieToken) tokens.push(bearerToken)
if (cookieToken) tokens.push({ token: cookieToken, source: "cookie" })
if (bearerToken && bearerToken !== cookieToken) {
tokens.push({ token: bearerToken, source: "bearer" })
}
return tokens
}
@@ -30,8 +98,8 @@ function getSessionTokens(event: H3Event) {
export async function findSession(event: H3Event) {
const Session = getSessionModel(getSessionTtl(event)) as mongoose.Model<any>
for (const token of getSessionTokens(event)) {
const session = await Session.findOne({ token })
for (const credential of getSessionTokens(event)) {
const session = await Session.findOne({ token: credential.token })
.populate("userId", "+verified")
.exec()
@@ -41,7 +109,13 @@ export async function findSession(event: H3Event) {
if (!session || !user) continue
return { session, user }
const bearerToken = event.node.req.headers.authorization?.startsWith("Bearer ")
? event.node.req.headers.authorization.slice("Bearer ".length)
: undefined
// If the cookie and bearer header carry the same valid token, the request
// is independently authenticated by the explicit bearer credential.
const source = bearerToken === credential.token ? "bearer" : credential.source
return { session, user, source }
}
return null
@@ -63,5 +137,11 @@ export async function requireAuthenticatedUser(event: H3Event, adminOnly = false
})
}
const method = event.node.req.method?.toUpperCase() || "GET"
const isUnsafeMethod = !["GET", "HEAD", "OPTIONS"].includes(method)
if (isUnsafeMethod && auth.source === "cookie") {
assertCookieRequestOrigin(event)
}
return auth.user
}