feat: added token blacklisting

This commit is contained in:
Wyatt Johnson
2018-08-21 14:22:04 -06:00
parent 8e3c62c9db
commit 5aad008261
4 changed files with 136 additions and 11 deletions
+41 -1
View File
@@ -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);
}
};
@@ -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,
+37 -5
View File
@@ -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.
+10 -5
View File
@@ -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(),