diff --git a/server/api/auth/register.post.ts b/server/api/auth/register.post.ts index 9eaf46f..f047bfa 100644 --- a/server/api/auth/register.post.ts +++ b/server/api/auth/register.post.ts @@ -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" }, diff --git a/server/api/auth/session.post.ts b/server/api/auth/session.post.ts index e580f3d..a17b376 100644 --- a/server/api/auth/session.post.ts +++ b/server/api/auth/session.post.ts @@ -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) diff --git a/server/api/auth/signin.post.ts b/server/api/auth/signin.post.ts index f41b1cd..4d70398 100644 --- a/server/api/auth/signin.post.ts +++ b/server/api/auth/signin.post.ts @@ -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 }, diff --git a/server/api/auth/signout.post.ts b/server/api/auth/signout.post.ts index 2f00685..aa8643d 100644 --- a/server/api/auth/signout.post.ts +++ b/server/api/auth/signout.post.ts @@ -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 } diff --git a/server/utils/auth.ts b/server/utils/auth.ts index 48ffd99..821989f 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -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 - 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 }