mirror of
https://github.com/wassname/talk.git
synced 2026-07-12 01:57:58 +08:00
feat: added token blacklisting
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user