diff --git a/client/pages/_middleware.tsx b/client/pages/_middleware.tsx index 480feb92..b2d909f3 100644 --- a/client/pages/_middleware.tsx +++ b/client/pages/_middleware.tsx @@ -1,8 +1,8 @@ -import { NextRequest, NextResponse } from 'next/server' +import { NextFetchEvent, NextRequest, NextResponse } from 'next/server' const PUBLIC_FILE = /.(.*)$/ -export function middleware(req: NextRequest) { +export function middleware(req: NextRequest, event: NextFetchEvent) { const pathname = req.nextUrl.pathname const signedIn = req.cookies['drift-token'] const getURL = (pageName: string) => new URL(`/${pageName}`, req.url).href @@ -19,6 +19,20 @@ export function middleware(req: NextRequest) { const resp = NextResponse.redirect(getURL('')); resp.clearCookie('drift-token'); resp.clearCookie('drift-userid'); + const signoutPromise = new Promise((resolve) => { + fetch(`${process.env.API_URL}/auth/signout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${signedIn}`, + 'x-secret-key': process.env.SECRET_KEY || '', + }, + }) + .then(() => { + resolve(true) + }) + }) + event.waitUntil(signoutPromise) return resp } diff --git a/server/src/lib/middleware/jwt.ts b/server/src/lib/middleware/jwt.ts index ca7d1582..ce273073 100644 --- a/server/src/lib/middleware/jwt.ts +++ b/server/src/lib/middleware/jwt.ts @@ -1,3 +1,4 @@ +import { AuthToken } from "@lib/models/AuthToken" import { NextFunction, Request, Response } from "express" import * as jwt from "jsonwebtoken" import config from "../config" @@ -11,7 +12,7 @@ export interface UserJwtRequest extends Request { user?: User } -export default function authenticateToken( +export default async function authenticateToken( req: UserJwtRequest, res: Response, next: NextFunction @@ -21,6 +22,17 @@ export default function authenticateToken( if (token == null) return res.sendStatus(401) + const authToken = await AuthToken.findOne({ where: { token: token } }) + if (authToken == null) { + return res.sendStatus(401) + } + + if (authToken.deletedAt) { + return res.sendStatus(401).json({ + message: "Token is no longer valid", + }) + } + jwt.verify(token, config.jwt_secret, async (err: any, user: any) => { if (err) return res.sendStatus(403) const userObj = await UserModel.findByPk(user.id, { diff --git a/server/src/lib/models/JWTDenyList.ts b/server/src/lib/models/AuthToken.ts similarity index 64% rename from server/src/lib/models/JWTDenyList.ts rename to server/src/lib/models/AuthToken.ts index fce57cec..bd5616da 100644 --- a/server/src/lib/models/JWTDenyList.ts +++ b/server/src/lib/models/AuthToken.ts @@ -8,11 +8,14 @@ import { CreatedAt, UpdatedAt, DeletedAt, - Unique + Unique, + BelongsTo, + ForeignKey } from "sequelize-typescript" +import { User } from "./User" @Table -export class JWTDenyList extends Model { +export class AuthToken extends Model { @IsUUID(4) @PrimaryKey @Unique @@ -25,8 +28,15 @@ export class JWTDenyList extends Model { @Column token!: string + @BelongsTo(() => User, "userId") + user!: User + + @ForeignKey(() => User) @Column - reason!: string + userId!: number + + @Column + expiredReason?: string @CreatedAt @Column diff --git a/server/src/migrations/07_denylist-table.ts b/server/src/migrations/07_auth-tokens.ts similarity index 58% rename from server/src/migrations/07_denylist-table.ts rename to server/src/migrations/07_auth-tokens.ts index 2ba01062..6df2a3d3 100644 --- a/server/src/migrations/07_denylist-table.ts +++ b/server/src/migrations/07_auth-tokens.ts @@ -3,7 +3,7 @@ import { DataTypes } from "sequelize" import type { Migration } from "../database" export const up: Migration = async ({ context: queryInterface }) => - queryInterface.createTable("JWTDenyLists", { + queryInterface.createTable("AuthTokens", { id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, @@ -13,8 +13,9 @@ export const up: Migration = async ({ context: queryInterface }) => token: { type: DataTypes.STRING }, - reason: { - type: DataTypes.STRING + expiredReason: { + type: DataTypes.STRING, + allowNull: true }, createdAt: { type: DataTypes.DATE @@ -23,9 +24,20 @@ export const up: Migration = async ({ context: queryInterface }) => type: DataTypes.DATE }, deletedAt: { - type: DataTypes.DATE + type: DataTypes.DATE, + allowNull: true + }, + userId: { + type: DataTypes.UUID, + allowNull: false, + references: { + model: "users", + key: "id" + }, + onUpdate: "CASCADE", + onDelete: "CASCADE" } }) export const down: Migration = async ({ context: queryInterface }) => - queryInterface.dropTable("JWTDenyLists") + queryInterface.dropTable("AuthTokens") diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 2b485432..83502d2e 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -1,11 +1,12 @@ import { Router } from "express" import { genSalt, hash, compare } from "bcryptjs" import { User } from "@lib/models/User" -import { JWTDenyList } from "@lib/models/JWTDenyList" +import { AuthToken } from "@lib/models/AuthToken" import { sign, verify } from "jsonwebtoken" import config from "@lib/config" import jwt from "@lib/middleware/jwt" import { celebrate, Joi } from "celebrate" +import secretKey from "@lib/middleware/secret-key" const NO_EMPTY_SPACE_REGEX = /^\S*$/ @@ -71,7 +72,7 @@ auth.post( const created_user = await User.create(user) - const token = generateAccessToken(created_user.id) + const token = generateAccessToken(created_user) res.status(201).json({ token: token, userId: created_user.id }) } catch (e) { @@ -109,7 +110,7 @@ auth.post( const password_valid = await compare(req.body.password, user.password) if (password_valid) { - const token = generateAccessToken(user.id) + const token = generateAccessToken(user) res.status(200).json({ token: token, userId: user.id }) } else { throw errorToThrow @@ -132,8 +133,19 @@ auth.get("/requires-passcode", async (req, res, next) => { } }) -function generateAccessToken(id: string) { - return sign({ id: id }, config.jwt_secret, { expiresIn: "2d" }) + +/** + * Creates an access token, stores it in AuthToken table, and returns it + */ +function generateAccessToken(user: User) { + const token = sign({ id: user.id }, config.jwt_secret, { expiresIn: "2d" }) + const authToken = new AuthToken({ + userId: user.id, + token: token + }) + authToken.save() + + return token } auth.get("/verify-token", jwt, async (req, res, next) => { @@ -146,28 +158,38 @@ auth.get("/verify-token", jwt, async (req, res, next) => { } }) -auth.post("/signout", jwt, async (req, res, next) => { +auth.post("/signout", secretKey, async (req, res, next) => { try { const authHeader = req.headers["authorization"] const token = authHeader?.split(" ")[1] let reason = "" if (token == null) return res.sendStatus(401) - verify(token, config.jwt_secret, (err: any, user: any) => { - if (err) return res.sendStatus(403) - if (user) { - reason = "Manually revoked" - } else { + verify(token, config.jwt_secret, async (err: any, user: any) => { + if (err) { reason = "Token expired" + } else if (user) { + reason = "User signed out" + } else { + reason = "Unknown" } - }) - const denylist = await new JWTDenyList({ token, reason }) - await denylist.save() - req.headers["authorization"] = "" - res.status(201).json({ - message: "You are now logged out", - token, - reason + + // find and destroy the AuthToken + set the reason + const authToken = await AuthToken.findOne({ where: { token: token } }) + if (authToken == null) { + res.sendStatus(401) + } else { + authToken.expiredReason = reason + authToken.save() + authToken.destroy() + } + + req.headers["authorization"] = "" + res.status(201).json({ + message: "You are now logged out", + token, + reason + }) }) } catch (e) { next(e)