From 5aad008261f7ab7f283fd17021586c69de2d64a3 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 21 Aug 2018 14:22:04 -0600 Subject: [PATCH] feat: added token blacklisting --- src/core/server/app/handlers/auth/local.ts | 42 +++++++++++++++- .../server/app/middleware/passport/index.ts | 48 +++++++++++++++++++ .../server/app/middleware/passport/jwt.ts | 42 ++++++++++++++-- src/core/server/app/router.ts | 15 ++++-- 4 files changed, 136 insertions(+), 11 deletions(-) diff --git a/src/core/server/app/handlers/auth/local.ts b/src/core/server/app/handlers/auth/local.ts index 48daf4c32..25688d25a 100644 --- a/src/core/server/app/handlers/auth/local.ts +++ b/src/core/server/app/handlers/auth/local.ts @@ -1,8 +1,12 @@ import { RequestHandler } from "express"; +import { Redis } from "ioredis"; import Joi from "joi"; import { Db } from "mongodb"; -import { handleSuccessfulLogin } from "talk-server/app/middleware/passport"; +import { + handleLogout, + handleSuccessfulLogin, +} from "talk-server/app/middleware/passport"; import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt"; import { validate } from "talk-server/app/request/body"; import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types"; @@ -74,3 +78,39 @@ export const signupHandler = (options: SignupOptions): RequestHandler => async ( return next(err); } }; + +export interface LogoutOptions { + redis: Redis; +} + +export const logoutHandler = (options: LogoutOptions): RequestHandler => async ( + req: Request, + res, + next +) => { + try { + // TODO: rate limit based on the IP address and user agent. + + // Tenant is guaranteed at this point. + const tenant = req.tenant!; + + // Check to ensure that the local integration has been enabled. + if (!tenant.auth.integrations.local.enabled) { + // TODO: replace with better error. + return next(new Error("integration is disabled")); + } + + // Get the user on the request. + const user = req.user; + if (!user) { + return next( + new Error("cannot logout when there is no user on the request") + ); + } + + // Delegate to the logout handler. + return handleLogout(options.redis, req, res); + } catch (err) { + return next(err); + } +}; diff --git a/src/core/server/app/middleware/passport/index.ts b/src/core/server/app/middleware/passport/index.ts index ae1a3406f..b5f06ba03 100644 --- a/src/core/server/app/middleware/passport/index.ts +++ b/src/core/server/app/middleware/passport/index.ts @@ -1,10 +1,15 @@ import { NextFunction, RequestHandler, Response } from "express"; +import { Redis } from "ioredis"; +import Joi from "joi"; +import jwt from "jsonwebtoken"; import { Db } from "mongodb"; import passport, { Authenticator } from "passport"; import { Config } from "talk-common/config"; import { + blacklistJWT, createJWTStrategy, + extractJWTFromRequest, JWTSigningConfig, SigningTokenOptions, signTokenString, @@ -12,6 +17,7 @@ import { import { createLocalStrategy } from "talk-server/app/middleware/passport/local"; import { createOIDCStrategy } from "talk-server/app/middleware/passport/oidc"; import { createSSOStrategy } from "talk-server/app/middleware/passport/sso"; +import { validate } from "talk-server/app/request/body"; import { User } from "talk-server/models/user"; import TenantCache from "talk-server/services/tenant/cache"; import { Request } from "talk-server/types/express"; @@ -25,6 +31,7 @@ export type VerifyCallback = ( export interface PassportOptions { config: Config; mongo: Db; + redis: Redis; signingConfig: JWTSigningConfig; tenantCache: TenantCache; } @@ -50,6 +57,47 @@ export function createPassport( return auth; } +interface LogoutToken { + jti: string; + exp: number; +} + +const LogoutTokenSchema = Joi.object().keys({ + jti: Joi.string(), + exp: Joi.number(), +}); + +export async function handleLogout(redis: Redis, req: Request, res: Response) { + // Extract the token from the request. + const token = extractJWTFromRequest(req); + if (!token) { + // TODO: (wyattjoh) return a better error. + throw new Error("logout requires a token on the request, none was found"); + } + + // Decode the token. + const decoded = jwt.decode(token, {}); + if (!decoded) { + // TODO: (wyattjoh) return a better error. + throw new Error( + "logout requires a token on the request, token was invalid" + ); + } + + // Grab the JTI from the decoded token. + const { jti, exp }: LogoutToken = validate(LogoutTokenSchema, decoded); + + // Compute the number of seconds that the token will be valid for. + const validFor = exp - Date.now() / 1000; + if (validFor > 0) { + // Invalidate the token, the expiry is in the future and it needs to be + // blacklisted. + await blacklistJWT(redis, jti, validFor); + } + + return res.sendStatus(204); +} + export async function handleSuccessfulLogin( user: User, signingConfig: JWTSigningConfig, diff --git a/src/core/server/app/middleware/passport/jwt.ts b/src/core/server/app/middleware/passport/jwt.ts index da4d0be11..257b67d1c 100644 --- a/src/core/server/app/middleware/passport/jwt.ts +++ b/src/core/server/app/middleware/passport/jwt.ts @@ -1,8 +1,9 @@ +import { Redis } from "ioredis"; import jwt, { SignOptions } from "jsonwebtoken"; -import uuid from "uuid"; - import { Db } from "mongodb"; import { Strategy } from "passport-strategy"; +import uuid from "uuid"; + import { Config } from "talk-common/config"; import { retrieveUser, User } from "talk-server/models/user"; import { Request } from "talk-server/types/express"; @@ -38,6 +39,31 @@ export function extractJWTFromRequest(req: Request) { return null; } +function generateJTIBlacklistKey(jti: string) { + // jtib: JTI Blacklist namespace. + return `jtib:${jti}`; +} + +export async function blacklistJWT( + redis: Redis, + jti: string, + validFor: number +) { + await redis.setex( + generateJTIBlacklistKey(jti), + Math.ceil(validFor), + Date.now() + ); +} + +export async function checkBlacklistJWT(redis: Redis, jti: string) { + const expiredAtString = await redis.get(generateJTIBlacklistKey(jti)); + if (expiredAtString) { + // TODO: (wyattjoh) return a better error. + throw new Error("JWT exists in blacklist"); + } +} + export enum AsymmetricSigningAlgorithm { RS256 = "RS256", RS384 = "RS384", @@ -139,6 +165,7 @@ export interface JWTToken { export interface JWTStrategyOptions { signingConfig: JWTSigningConfig; mongo: Db; + redis: Redis; } export class JWTStrategy extends Strategy { @@ -146,12 +173,14 @@ export class JWTStrategy extends Strategy { private signingConfig: JWTSigningConfig; private mongo: Db; + private redis: Redis; - constructor({ signingConfig, mongo }: JWTStrategyOptions) { + constructor({ signingConfig, mongo, redis }: JWTStrategyOptions) { super(); this.signingConfig = signingConfig; this.mongo = mongo; + this.redis = redis; } public authenticate(req: Request) { @@ -179,13 +208,16 @@ export class JWTStrategy extends Strategy { // Use the algorithm specified in the configuration. algorithms: [this.signingConfig.algorithm], }, - async (err: Error | undefined, { sub }: JWTToken) => { + async (err: Error | undefined, { jti, sub }: JWTToken) => { if (err) { return this.fail(err, 401); } try { - // Find the user. + // Check to see if the token has been blacklisted. + await checkBlacklistJWT(this.redis, jti); + + // Find the user referenced by the token. const user = await retrieveUser(this.mongo, tenant.id, sub); // Return them! The user may be null, but that's ok here. diff --git a/src/core/server/app/router.ts b/src/core/server/app/router.ts index 75b195ab3..c298a40aa 100644 --- a/src/core/server/app/router.ts +++ b/src/core/server/app/router.ts @@ -1,7 +1,10 @@ import express from "express"; import passport from "passport"; -import { signupHandler } from "talk-server/app/handlers/auth/local"; +import { + logoutHandler, + signupHandler, +} from "talk-server/app/handlers/auth/local"; import { streamHandler } from "talk-server/app/handlers/embed/stream"; import { apiErrorHandler } from "talk-server/app/middleware/error"; import { errorLogger } from "talk-server/app/middleware/logging"; @@ -64,15 +67,17 @@ function createNewAuthRouter(app: AppOptions, options: RouterOptions) { const router = express.Router(); // Mount the passport routes. + router.delete( + "/", + options.passport.authenticate("jwt", { session: false }), + logoutHandler({ redis: app.redis }) + ); + router.post( "/local", express.json(), wrapAuthn(options.passport, app.signingConfig, "local") ); - - // TODO (bc) - add delete user serverside - router.delete("/local", express.json()); - router.post( "/local/signup", express.json(),