feat: add sample endpoint to test JWT

This commit is contained in:
2026-01-03 04:37:20 +01:00
parent ec5cedce5a
commit c19a098b1c
8 changed files with 173 additions and 15 deletions

View File

@@ -188,3 +188,43 @@ export async function createUserHandler(
return res.status(201) return res.status(201)
.send(userResponse); .send(userResponse);
} }
/**
* `GET /api/v1/user/account`
*
* Handles requests for user info retrieval
*
* @param {Request} req The Express request
* @param {Response} res The Express resource
*/
export async function getUserHandler(
req: Request,
res: Response
) {
const userService = new UserService();
const user: jwt.JwtDecoded = res.locals.user.decoded;
// Get detailed data from DB
let existingUser: User | null = await userService.findById(user.sub);
if (existingUser === null) {
const error: ms.ErrorDTO = {
status: 'error',
error: 'User does not exist',
code: 'deleted_user'
};
return res.status(404)
.json(error);
}
// Respond with ShortUserInfoDTO
const userResponse: as.ShortUserInfoDTO = {
status: 'ok',
id: existingUser.id!,
name: existingUser.name,
role: existingUser.role
}
return res.status(201)
.send(userResponse);
}

View File

@@ -2,7 +2,7 @@
// https://github.com/TomDoesTech/REST-API-Tutorial-Updated/blob/7b5f040e1acd94d267df585516b33ee7e3b75f70/src/middleware/deserializeUser.ts // https://github.com/TomDoesTech/REST-API-Tutorial-Updated/blob/7b5f040e1acd94d267df585516b33ee7e3b75f70/src/middleware/deserializeUser.ts
import { get } from 'lodash'; import { get } from 'lodash';
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import { verifyJwt } from '../tools/jwt'; import * as jwt from '../tools/jwt';
const inferUser = async ( const inferUser = async (
req: Request, req: Request,
@@ -17,12 +17,9 @@ const inferUser = async (
if (!accessToken) return next(); if (!accessToken) return next();
const { decoded } = verifyJwt(accessToken, 'accessTokenPublicKey'); const token = jwt.verifyJwt(accessToken, 'accessTokenPrivateKey');
if (token) {
// console.log('decoded user:', decoded); res.locals.user = token;
if (decoded) {
res.locals.user = decoded;
return next(); return next();
} }

View File

@@ -0,0 +1,41 @@
import { Request, Response, NextFunction } from "express";
import { ErrorDTO } from "../schemas/miscSchema";
import * as jwt from "../tools/jwt";
const requireUser = (req: Request, res: Response, next: NextFunction) => {
const user: jwt.JwtStatus = res.locals.user;
let error: ErrorDTO | null = null;
// No user? Something errored partway. Display an error.
if (!user)
error = {
status: 'error',
error: 'Unauthorized, please sign in',
code: 'unauthorized_generic'
};
// Check if token is expired first.
// This is because a token can be valid
// (if signature matches) while being expired.
else if (user.expired)
error = {
status: 'error',
error: 'Token expired, please sign in again',
code: 'expired_token'
};
// Previous checks failed?
// As a last resort, check if the token is valid.
else if (!user.valid)
error = {
status: 'error',
error: 'Invalid token, please sign in',
code: 'invalid_token'
};
if (error !== null)
return res.status(401)
.send(error);
return next();
};
export default requireUser;

View File

@@ -2,6 +2,7 @@ import { Router } from 'express';
import validateSchema from '../tools/validateSchema'; import validateSchema from '../tools/validateSchema';
import * as ac from '../controllers/authController'; import * as ac from '../controllers/authController';
import * as as from '../schemas/authSchema'; import * as as from '../schemas/authSchema';
import requireUser from '../middleware/requireUser';
const userRouter = Router(); const userRouter = Router();
@@ -69,4 +70,33 @@ userRouter.post('/api/v1/user/signUp', validateSchema(as.loginRequestSchema), ac
*/ */
userRouter.post('/api/v1/user/signIn', validateSchema(as.loginRequestSchema), ac.loginUserHandler); userRouter.post('/api/v1/user/signIn', validateSchema(as.loginRequestSchema), ac.loginUserHandler);
/**
* @openapi
*
* /api/v1/user/account:
* get:
* description: Get authenticated user info
* tags: [User]
* summary: "[AUTHED] Get user info"
* produces:
* - application/json
* responses:
* 200:
* description: User logged in successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ShortUserInfoDTO'
* 400:
* description: Wrong password/non-existent user
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorDTO'
*/
userRouter.get('/api/v1/user/account',
requireUser,
ac.getUserHandler
);
export default userRouter; export default userRouter;

View File

@@ -80,3 +80,33 @@ export type UserInfoDTO = {
ttl: number | null; ttl: number | null;
}; };
/**
* @swagger
* components:
* schemas:
* ShortUserInfoDTO:
* type: object
* required:
* - status
* - id
* - name
* - role
* properties:
* status:
* type: string
* default: ok on success, otherwise ErrorDTO with error
* id:
* type: number
* name:
* type: string
* default: username
* role:
* type: number
* default: 0 # 0 - standard user, 1 - administrator
*/
export type ShortUserInfoDTO = {
status: 'ok';
id: number;
name: string;
role: number;
};

View File

@@ -51,7 +51,7 @@ export type SentenceLinkRequestDTO = z.TypeOf<typeof sentenceLinkRequestSchema>;
* default: ok on success, otherwise ErrorDTO with error * default: ok on success, otherwise ErrorDTO with error
* uri: * uri:
* type: string * type: string
* default: username * default: generated uri
* subdomain: * subdomain:
* type: string * type: string
* default: subdomain or null * default: subdomain or null

View File

@@ -75,6 +75,16 @@ export class UserService {
} }
/**
* Finds a User by it's identifier.
*
* @param {number} id The identifier
* @return {(Promise<User|null>)} User (or null if not found)
*/
async findById(id: number): Promise<User | null> {
return await this.userRepo.findOneBy({id});
}
/** /**
* Counts all the user entities in DB. * Counts all the user entities in DB.
* Used to tell whether the next created account should be an admin * Used to tell whether the next created account should be an admin

View File

@@ -4,10 +4,17 @@ import jwt from 'jsonwebtoken';
import { DEFAULT_TOKEN_LIFETIME } from '../schemas/authSchema'; import { DEFAULT_TOKEN_LIFETIME } from '../schemas/authSchema';
import * as env from './env'; import * as env from './env';
type JwtStatus = { export type JwtDecoded = {
sub: number;
role: number;
iat: number;
exp: number;
};
export type JwtStatus = {
valid: boolean; valid: boolean;
expired: boolean; expired: boolean;
decoded: string | jwt.JwtPayload | null; decoded: JwtDecoded | null; // null if decoding failed
}; };
/** /**
@@ -52,7 +59,7 @@ export function signJwt(
*/ */
export function verifyJwt( export function verifyJwt(
token: string, token: string,
keyName: 'accessTokenPublicKey' | 'refreshTokenPublicKey' keyName: 'accessTokenPrivateKey' | 'refreshTokenPrivateKey'
): JwtStatus { ): JwtStatus {
// refresh tokens aren't (yet) supported // refresh tokens aren't (yet) supported
@@ -64,18 +71,21 @@ export function verifyJwt(
const secret: string = env.getString(keyName, true)!; const secret: string = env.getString(keyName, true)!;
try { try {
const decoded: string | jwt.JwtPayload = jwt.verify(token, secret); const decoded: jwt.JwtPayload | string = jwt.verify(token, secret);
// TODO: Can this be done better, smarter?
return { return {
valid: true, valid: true,
expired: false, expired: false,
decoded, decoded: decoded as unknown as JwtDecoded
}; };
} catch (e: any) { } catch (e: any) {
console.error('JWT verify error:', e); console.error('JWT verify error:', e);
return { return {
valid: false, valid: e.message !== 'jwt malformed',
expired: e.message === 'jwt expired', expired: e.message === 'jwt expired',
decoded: null, decoded: null
}; };
} }
} }