Hi ${escapeHtml(this.user.firstName)},
` + + `Welcome to AdonisJS Tutorial. Activate your account by clicking the link below:
` + + `` + + `This link expires in 24 hours.
` + ) + } +} diff --git a/app/mails/escape_html.ts b/app/mails/escape_html.ts new file mode 100644 index 0000000..0f6e6ef --- /dev/null +++ b/app/mails/escape_html.ts @@ -0,0 +1,19 @@ +/** + * Escapes the five HTML-significant characters in untrusted user input + * before it is interpolated into a mail's `.html()` body. + * + * registerSchema only bounds firstName/lastName by length (README's Auth + * Model column widths), not by character set, so a value like + * `Hi ${escapeHtml(this.user.firstName)},
` + + `We received a request to reset your password. Click the link below to choose a new one:
` + + `` + + `This link expires in 1 hour. If you did not request this, you can ignore this email.
` + ) + } +} diff --git a/app/middleware/auth_middleware.ts b/app/middleware/auth_middleware.ts index 8c86950..814ba7e 100644 --- a/app/middleware/auth_middleware.ts +++ b/app/middleware/auth_middleware.ts @@ -1,10 +1,32 @@ import type { HttpContext } from '@adonisjs/core/http' import type { NextFn } from '@adonisjs/core/types/http' import type { Authenticators } from '@adonisjs/auth/types' +import BlacklistedToken from '#models/blacklisted_token' +import { fail } from '#helpers/api_response' /** - * Auth middleware is used authenticate HTTP requests and deny - * access to unauthenticated users. + * Auth middleware authenticates HTTP requests and denies access to + * unauthenticated users. It also rejects requests carrying a JWT that has + * been explicitly revoked (logout writes the token's `jti` into + * `blacklisted_tokens`), and requests for a user an ADMIN has since + * disabled or locked. + * + * The `jti` and `enabled`/`accountLocked` checks live here rather than + * inside JwtGuard itself. JwtGuard (app/auth/jwt_guard.ts) is deliberately + * scoped to proving a token is validly signed, not expired, and resolves to + * a real user, nothing more. Both of these are application-level policy, + * not a property of the token's cryptographic validity, so they are + * layered on top of `authenticateUsing()` here instead of being baked into + * the guard. Every route that applies this middleware gets the check; + * nothing bypasses it by calling the guard directly, since the guard is + * never used outside this middleware and RoleMiddleware (which always runs + * after this one). + * + * Without the enabled/accountLocked recheck, a JWT issued before an ADMIN + * disables or locks that account (via PUT /api/v1/users/:id) would keep + * authenticating successfully against every protected route until the + * token's own expiry, since AuthService only checks those flags at login + * and refresh time, not on every request. */ export default class AuthMiddleware { async handle( @@ -14,7 +36,26 @@ export default class AuthMiddleware { guards?: (keyof Authenticators)[] } = {} ) { - await ctx.auth.authenticateUsing(options.guards) + const user = await ctx.auth.authenticateUsing(options.guards) + + if (!user.enabled || user.accountLocked) { + return ctx.response.unauthorized(fail('Account is no longer active', ctx.request.url())) + } + + const payload = ctx.auth.use('jwt').payload + const jti = payload?.jti + + // NOTE: `jti` is only present when the request actually authenticated + // via the jwt guard. If a future guard is added to `options.guards` + // and wins the authentication race, there is no jti to check against + // the blacklist and this step is a no-op rather than a hard failure. + if (jti) { + const blacklisted = await BlacklistedToken.findBy('jti', jti) + if (blacklisted) { + return ctx.response.unauthorized(fail('Token has been revoked', ctx.request.url())) + } + } + return next() } } diff --git a/app/middleware/initialize_bouncer_middleware.ts b/app/middleware/initialize_bouncer_middleware.ts new file mode 100644 index 0000000..6ab6d4c --- /dev/null +++ b/app/middleware/initialize_bouncer_middleware.ts @@ -0,0 +1,36 @@ +import * as abilities from '#abilities/main' +import { policies } from '#generated/policies' + +import { Bouncer } from '@adonisjs/bouncer' +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +/** + * Init bouncer middleware is used to create a bouncer instance + * during an HTTP request + */ +export default class InitializeBouncerMiddleware { + async handle(ctx: HttpContext, next: NextFn) { + /** + * Create bouncer instance for the ongoing HTTP request. + * We will pull the user from the HTTP context. + */ + ctx.bouncer = new Bouncer( + () => ctx.auth.user || null, + abilities, + policies + ).setContainerResolver(ctx.containerResolver) + + return next() + } +} + +declare module '@adonisjs/core/http' { + export interface HttpContext { + bouncer: Bouncer< + Exclude