Harden cookie authentication
This commit is contained in:
1 parent
a0a62c1c06
commit
31e6c0c5d2
5 files changed
+116
-17
No files matched your search
@@ -1,10 +1,15 @@
|
|||||||
import { readBody, setCookie, setResponseStatus } from "h3"
|
import { readBody, setCookie, setResponseStatus } from "h3"
|
||||||
import argon2 from "argon2"
|
import argon2 from "argon2"
|
||||||
import { User } from "../../models/user"
|
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"
|
import { getAuthValidationMessage } from "../../utils/auth-validation"
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineAuthenticatedEventHandler(async (event) => {
|
||||||
|
assertCookieRequestOrigin(event)
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const validationMessage = getAuthValidationMessage(body, [
|
const validationMessage = getAuthValidationMessage(body, [
|
||||||
{ name: "name" },
|
{ name: "name" },
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ import { getCookie, readBody, setCookie, setResponseStatus } from "h3"
|
|||||||
import { User } from "../../models/user"
|
import { User } from "../../models/user"
|
||||||
import { getSessionModel } from "../../models/session"
|
import { getSessionModel } from "../../models/session"
|
||||||
import {
|
import {
|
||||||
|
assertCookieRequestOrigin,
|
||||||
|
defineAuthenticatedEventHandler,
|
||||||
getSessionTtl,
|
getSessionTtl,
|
||||||
REFRESH_COOKIE,
|
REFRESH_COOKIE,
|
||||||
SESSION_COOKIE,
|
SESSION_COOKIE,
|
||||||
} from "../../utils/auth"
|
} from "../../utils/auth"
|
||||||
import { getAuthValidationMessage } from "../../utils/auth-validation"
|
import { getAuthValidationMessage } from "../../utils/auth-validation"
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineAuthenticatedEventHandler(async (event) => {
|
||||||
const body = await readBody(event).catch(() => undefined)
|
const body = await readBody(event).catch(() => undefined)
|
||||||
const validationMessage =
|
const validationMessage =
|
||||||
body === undefined ? null : getAuthValidationMessage(body, [{ name: "refreshToken" }])
|
body === undefined ? null : getAuthValidationMessage(body, [{ name: "refreshToken" }])
|
||||||
@@ -17,9 +19,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
return { message: validationMessage }
|
return { message: validationMessage }
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshToken =
|
const bodyRefreshToken = typeof body?.refreshToken === "string" && body.refreshToken
|
||||||
(typeof body?.refreshToken === "string" && body.refreshToken) ||
|
const cookieRefreshToken = getCookie(event, REFRESH_COOKIE)
|
||||||
getCookie(event, REFRESH_COOKIE)
|
if (!bodyRefreshToken && cookieRefreshToken) assertCookieRequestOrigin(event)
|
||||||
|
const refreshToken = bodyRefreshToken || cookieRefreshToken
|
||||||
|
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
setResponseStatus(event, 401)
|
setResponseStatus(event, 401)
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { readBody, setCookie, setResponseStatus } from "h3"
|
import { readBody, setCookie, setResponseStatus } from "h3"
|
||||||
import argon2 from "argon2"
|
import argon2 from "argon2"
|
||||||
import { User } from "../../models/user"
|
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"
|
import { getAuthValidationMessage } from "../../utils/auth-validation"
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineAuthenticatedEventHandler(async (event) => {
|
||||||
|
assertCookieRequestOrigin(event)
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const validationMessage = getAuthValidationMessage(body, [
|
const validationMessage = getAuthValidationMessage(body, [
|
||||||
{ name: "email", email: true },
|
{ name: "email", email: true },
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { deleteCookie } from "h3"
|
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, REFRESH_COOKIE, { path: "/", sameSite: "lax", secure: !import.meta.dev })
|
||||||
deleteCookie(event, SESSION_COOKIE, { path: "/", sameSite: "lax", secure: !import.meta.dev })
|
deleteCookie(event, SESSION_COOKIE, { path: "/", sameSite: "lax", secure: !import.meta.dev })
|
||||||
return { success: true }
|
return { success: true }
|
||||||
|
|||||||
+87
-7
@@ -1,5 +1,13 @@
|
|||||||
import type { H3Event } from "h3"
|
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 parseDuration from "parse-duration"
|
||||||
import mongoose from "mongoose"
|
import mongoose from "mongoose"
|
||||||
import { getSessionModel } from "../models/session"
|
import { getSessionModel } from "../models/session"
|
||||||
@@ -7,6 +15,64 @@ import { getBackendRuntimeConfig } from "./backend-config"
|
|||||||
|
|
||||||
export const SESSION_COOKIE = "musicSession"
|
export const SESSION_COOKIE = "musicSession"
|
||||||
export const REFRESH_COOKIE = "refreshToken"
|
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) {
|
export function getSessionTtl(event?: H3Event) {
|
||||||
const { tokenExpire } = getBackendRuntimeConfig(event)
|
const { tokenExpire } = getBackendRuntimeConfig(event)
|
||||||
@@ -14,15 +80,17 @@ export function getSessionTtl(event?: H3Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSessionTokens(event: H3Event) {
|
function getSessionTokens(event: H3Event) {
|
||||||
const tokens: string[] = []
|
const tokens: { token: string; source: "cookie" | "bearer" }[] = []
|
||||||
const cookieToken = getCookie(event, SESSION_COOKIE)
|
const cookieToken = getCookie(event, SESSION_COOKIE)
|
||||||
const authorization = event.node.req.headers.authorization
|
const authorization = event.node.req.headers.authorization
|
||||||
const bearerToken = authorization?.startsWith("Bearer ")
|
const bearerToken = authorization?.startsWith("Bearer ")
|
||||||
? authorization.slice("Bearer ".length)
|
? authorization.slice("Bearer ".length)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
if (cookieToken) tokens.push(cookieToken)
|
if (cookieToken) tokens.push({ token: cookieToken, source: "cookie" })
|
||||||
if (bearerToken && bearerToken !== cookieToken) tokens.push(bearerToken)
|
if (bearerToken && bearerToken !== cookieToken) {
|
||||||
|
tokens.push({ token: bearerToken, source: "bearer" })
|
||||||
|
}
|
||||||
|
|
||||||
return tokens
|
return tokens
|
||||||
}
|
}
|
||||||
@@ -30,8 +98,8 @@ function getSessionTokens(event: H3Event) {
|
|||||||
export async function findSession(event: H3Event) {
|
export async function findSession(event: H3Event) {
|
||||||
const Session = getSessionModel(getSessionTtl(event)) as mongoose.Model<any>
|
const Session = getSessionModel(getSessionTtl(event)) as mongoose.Model<any>
|
||||||
|
|
||||||
for (const token of getSessionTokens(event)) {
|
for (const credential of getSessionTokens(event)) {
|
||||||
const session = await Session.findOne({ token })
|
const session = await Session.findOne({ token: credential.token })
|
||||||
.populate("userId", "+verified")
|
.populate("userId", "+verified")
|
||||||
.exec()
|
.exec()
|
||||||
|
|
||||||
@@ -41,7 +109,13 @@ export async function findSession(event: H3Event) {
|
|||||||
|
|
||||||
if (!session || !user) continue
|
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
|
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
|
return auth.user
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user