feat: major code refactor, add login and register endpoints with swagger
All checks were successful
Update changelog / changelog (push) Successful in 27s

This commit is contained in:
2025-12-29 18:26:50 +01:00
parent 3f225a1ecb
commit 41f3b0f0f2
22 changed files with 1425 additions and 128 deletions

View File

@@ -1,42 +1,79 @@
import * as express from 'express';
import router from './routes';
import * as dotenv from "dotenv";
import * as dotenv from 'dotenv';
dotenv.config({ quiet: true });
const app = express();
import express from 'express';
import { version } from '../package.json';
import miscRouter from './routes/miscRoutes';
import userRouter from './routes/userRoutes';
import { AppDataSource } from './data-source'
import inferUser from './middleware/inferUser';
const swaggerJsdocOpts = {
failOnErrors: true,
definition: {
openapi: '3.0.0',
info: {
title: 'kittyurl',
version: '0.0.1'
}
},
apis: ['./src/routes/*.ts']
};
const swaggerUi = require('swagger-ui-express');
const swaggerJsdoc = require('swagger-jsdoc');
const swaggerSpec = swaggerJsdoc(swaggerJsdocOpts);
AppDataSource.initialize().then(async () => {
app.use(express.json());
app.use(router);
if (process.env.DEBUG === "true") {
app.use('/swagger', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
}
await AppDataSource.runMigrations();
// Handle 404s
// https://stackoverflow.com/a/9802006
app.use(function(req, res) {
res.status(404);
const app: express.Express = express();
if (req.accepts('json')) {
res.json({ status: 'error', error: 'Not found' });
return;
app.use(express.json());
app.use(inferUser);
app.use(miscRouter, userRouter);
if (process.env['DEBUG'] === 'true') {
const swaggerJsdocOpts = {
failOnErrors: true,
definition: {
openapi: '3.0.4',
info: {
title: 'kittyurl API',
description: 'A Typescript API for the kittyurl url shortener project.',
version: version,
contact: {
name: 'Git repository for entire project',
url: 'https://gitea.7o7.cx/kittyteam/kittyurl'
},
license: {
name: 'AGPLv3',
url: 'https://www.gnu.org/licenses/agpl-3.0.en.html'
}
},
components: {
securitySchemes: {
BearerJWT: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'JWT Authorization header using the Bearer scheme.<br/>Enter your JWT from /api/v1/user/signIn to authorize.'
}
}
},
security: [
{ bearerAuth: [] }
],
},
apis: ['./src/routes/*.ts', './src/schemas/*.ts']
};
const swaggerUi = require('swagger-ui-express');
const swaggerJsdoc = require('swagger-jsdoc');
const swaggerSpec = swaggerJsdoc(swaggerJsdocOpts);
app.use('/kttydocs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
app.get('/kttydocs.json', (req: express.Request, res: express.Response) => {
res.setHeader('Content-Type', 'application/json');
res.send(swaggerSpec);
});
}
res.type('txt').send('Not found');
});
app.listen(6567, () => console.log('(HTTP Server) Listening on port 6567.'));
// Handle 404s
// https://stackoverflow.com/a/9802006
app.use(function(req: express.Request, res: express.Response) {
res.status(404);
if (req.accepts('json')) {
res.json({ status: 'error', error: 'Not found' });
return;
}
res.type('txt').send('Not found');
});
app.listen(6567, () => console.log('(HTTP Server) Listening on port 6567.'));
}).catch(error => console.log(error))

View File

@@ -0,0 +1,186 @@
import { Request, Response } from 'express';
import { User } from '../entities/User';
import * as ms from '../schemas/miscSchema';
import * as as from '../schemas/authSchema';
import { UserService } from '../services/userService';
import { generateSha512 } from '../tools/hasher';
import * as jwt from '../tools/jwt';
/**
* Handles requests for user logon
*
* @param {Request} req The Express request
* @param {Response} res The Express resource
*/
export async function loginUserHandler(
req: Request<{}, {}, as.LoginRequestDTO['body']>,
res: Response
) {
const userService: UserService = new UserService();
const requestBody = req.body;
// Compare against what exists in the DB
let existingUser: User | null = await userService.findByCredentials({
name: requestBody.name
});
if (existingUser === null) {
// User not found? Return an error.
const error: ms.ErrorDTO = {
status: 'error',
error: 'User does not exist'
};
return res.status(404)
.json(error);
}
// For the reasoning behind this, check the long
// comment inside of createUserHandler().
// TL;DR: This is a fail-safe in case of
// a server shutdown mid user creation.
if (existingUser.dirtyPasswordHashBit) {
existingUser.passwordHash = generateSha512(`${existingUser.id}:${existingUser.passwordHash}`);
existingUser.dirtyPasswordHashBit = false;
userService.save(existingUser);
}
// Compute salted SHA512
const passwordHashed: string = generateSha512(`${existingUser.id}:${requestBody.password}`);
if (passwordHashed !== existingUser.passwordHash) {
// User not found? Return an error.
const error: ms.ErrorDTO = {
status: 'error',
error: 'Username <-> password pair not found'
};
return res.status(404)
.json(error);
}
// User found? Generate a JWT.
const token = jwt.signJwt(
{ sub: existingUser.id, role: existingUser.role },
'accessTokenPrivateKey',
{ expiresIn: requestBody.ttl ?? as.DEFAULT_TOKEN_LIFETIME }
);
// Respond with UserInfoDTO
const userResponse: as.UserInfoDTO = {
status: 'ok',
name: requestBody.name,
role: existingUser.role,
token: token,
ttl: requestBody.ttl ?? as.DEFAULT_TOKEN_LIFETIME
}
return res.status(200)
.send(userResponse);
}
/**
* Handles requests for user registration
*
* @param {Request} req The Express request
* @param {Response} res The Express resource
*/
export async function createUserHandler(
req: Request<{}, {}, as.LoginRequestDTO['body']>,
res: Response
) {
// Ideally, there would be a registration DTO which would incorporate
// some sort of CAPTCHA or CSRF token.
const userService = new UserService();
const requestBody = req.body;
// Compare against what exists in the DB
let usersFound: number = await userService.countAll(); // CAN THIS BE REPLACED WITH userService.lastId()??????
let role: number = 0; // Let the default role be a standard user.
// No users found? Make the new (and only) user an Administrator.
if (usersFound == 0)
role = 1;
// Users found? The new user should NOT be an Administrator.
// Since role by default is 0 (non-admin), nothing needs to change.
// Check if user already exists. If yes, return an error.
let existingUserId: number = await userService.findIdByCredentials({
name: requestBody.name
});
if (existingUserId >= 0) {
const error: ms.ErrorDTO = {
status: 'error',
error: 'User already exists'
};
return res.status(403)
.json(error);
}
// Otherwise we're free to add him/her.
let user: User = {
id: undefined,
name: requestBody.name,
passwordHash: requestBody.password,
dirtyPasswordHashBit: true,
role: role,
createdAt: Date.now(),
links: []
};
await userService.add(user);
// Note how we're setting the retrieved password as the password hash,
// without hashing it first. This is because we don't know ahead of time
// what id will the user have (which we could use to salt the password).
// We also could hash the password with a user's name,
// but then we would either need to deem username unchangeable,
// or prompt the user for password once he/she wants to change his/her name.
// Out of the three not ideal solutions, the first has been incorporated,
// thus we add the user, then retrieve his/her id, to then salt user's
// password with it later. This requires setting a "dirty bit" and storing
// the password as received, so in a way in "plain text". Then recalculate
// the password and remove the dirty bit here, or, in a miniscule case if
// it so happens the server crashes or gets restarted while registering a user,
// the password will be recalculated on login attempt if dirty bit is set.
// TODO: explore alternative options with queryRunner's transactions?
// Check if user added successfully.
// If so, recalculate the salted hash.
const insertedUser: User | null = await userService.findByCredentials({
name: requestBody.name
});
// Return an error if - for whatever reason - the user has not been added.
if (insertedUser === null) {
const error: ms.ErrorDTO = {
status: 'error',
error: 'Could not add a new user',
code: 'server_error'
}
return res.status(500)
.json(error);
}
// Rewrite the user's password to use the salted hash
const passwordHashed: string = generateSha512(`${insertedUser.id}:${requestBody.password}`);
insertedUser.passwordHash = passwordHashed;
insertedUser.dirtyPasswordHashBit = false;
await userService.save(insertedUser);
// Generate a JWT.
const token = jwt.signJwt(
{ sub: insertedUser.id, role: role },
'accessTokenPrivateKey',
{ expiresIn: requestBody.ttl ?? as.DEFAULT_TOKEN_LIFETIME }
);
// Respond with UserInfoDTO
const userResponse: as.UserInfoDTO = {
status: 'ok',
name: requestBody.name,
role: role,
token: token,
ttl: requestBody.ttl ?? as.DEFAULT_TOKEN_LIFETIME
}
return res.status(201)
.send(userResponse);
}

View File

@@ -1,12 +1,12 @@
import "reflect-metadata"
import { DataSource } from "typeorm"
import "reflect-metadata";
import { DataSource } from "typeorm";
import * as dotenv from "dotenv";
dotenv.config();
dotenv.config({ quiet: true });
export const AppDataSource = new DataSource({
type: "postgres",
host: process.env.PG_HOST ?? "localhost",
port: parseInt(process.env.PG_PORT, 10) || 5432,
port: parseInt(process.env.PG_PORT!, 10) || 5432,
username: process.env.PG_USER ?? "kitty",
password: process.env.PG_PASS ?? "CHANGEME", // Please change your password inside of the .env file!
database: process.env.PG_DB ?? "kittyurl",

View File

@@ -1,48 +1,49 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinTable } from "typeorm"
import { User } from "./User"
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinTable } from 'typeorm'
import { User } from './User'
@Entity("links")
export class Link {
// Unique link id.
@PrimaryGeneratedColumn()
id: number
id: number;
// Experimental: subdomain which should be a part of the short url.
// For instance in the URL "abc.example.com/def", abc is the subdomain.
// "def.example.com/def" won't resolve to the URL that "abc.example.com/def" does.
@Column({ nullable: true })
subdomain: string | null
// https://stackoverflow.com/a/67535817
@Column({ type: 'varchar', nullable: true })
subdomain: string | null;
// Shortened Uri.
@Column()
shortUri: string
shortUri: string;
// URL to which the user should be redirected
@Column()
fullUrl: string
fullUrl: string;
// Unix timestamp of link creation date.
@Column('bigint')
createDate: number
createDate: number;
// Unix timestamp of when the link should expire.
// If null, the link will never expire unless deleted.
@Column('bigint', { nullable: true })
expiryDate: number | null
expiryDate: number | null;
// Aggregated amount of visits.
@Column('bigint')
visits: number
visits: number;
// Link privacy:
// - true, if link is private
// - false, if link can be shown in a list of recent links publicly.
@Column()
privacy: boolean
privacy: boolean;
// User to which the shortened URL belongs.
@ManyToOne(() => User, (user) => user.links, { nullable: true })
@JoinTable()
author: User | null
author: User | null;
}

View File

@@ -1,30 +1,35 @@
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from "typeorm"
import { Link } from "./Link"
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm'
import { Link } from './Link'
@Entity("users")
export class User {
// Unique user id.
@PrimaryGeneratedColumn()
id: number
id: number | undefined; // Is this a good idea?
// User name, must be unique.
@Column({ unique: true })
name: string
name: string;
// Salted password hash.
@Column()
passwordHash: string
passwordHash: string;
// Used to tell, whether password hash should
// be recalculated (salted) on next login.
@Column()
dirtyPasswordHashBit: boolean;
// User role:
// - 0 - means unprivileged user,
// - 1 - means administrative user.
@Column()
role: number
role: number;
// Account creation date as a Unix timestamp.
@Column('bigint')
createdAt: number
createdAt: number;
// List of shortened URLs which belong to the user.
@OneToMany(() => Link, (link) => link.author)

View File

@@ -1,25 +0,0 @@
import { AppDataSource } from "./data-source"
import { User } from "./entities/User"
import { smokeTest } from "./smoke-test";
AppDataSource.initialize().then(async () => {
// console.log("Inserting a new user into the database...");
// const user = new User();
// // user.firstName = "Timber";
// // user.lastName = "Saw";
// // user.age = 25;
// await AppDataSource.manager.save(user);
// console.log("Saved a new user with id: " + user.id);
// console.log("Loading users from the database...");
// const users = await AppDataSource.manager.find(User);
// console.log("Loaded users: ", users);
// console.log("Here you can setup and run express / fastify / any other framework.");
await AppDataSource.runMigrations();
await smokeTest(AppDataSource);
}).catch(error => console.log(error))

View File

@@ -0,0 +1,50 @@
// Heavily based on:
// https://github.com/TomDoesTech/REST-API-Tutorial-Updated/blob/7b5f040e1acd94d267df585516b33ee7e3b75f70/src/middleware/deserializeUser.ts
import { get } from 'lodash';
import { Request, Response, NextFunction } from 'express';
import { verifyJwt } from '../tools/jwt';
const inferUser = async (
req: Request,
res: Response,
next: NextFunction
) => {
const accessToken = get(req, 'headers.authorization', '').replace(
/^Bearer\s/,
''
);
if (!accessToken) return next();
const { decoded } = verifyJwt(accessToken, 'accessTokenPublicKey');
// console.log('decoded user:', decoded);
if (decoded) {
res.locals.user = decoded;
return next();
}
/*
// refresh token handling is not (yet) implemented
const refreshToken = get(req, 'headers.x-refresh');
if (expired && refreshToken) {
const newAccessToken = await reIssueAccessToken({ refreshToken });
if (newAccessToken) {
res.setHeader('x-access-token', newAccessToken);
}
const result = verifyJwt(newAccessToken as string, 'accessTokenPublicKey');
res.locals.user = result.decoded;
return next();
}
*/
return next();
};
export default inferUser;

View File

@@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class InitialMigration1767018509215 implements MigrationInterface {
name = 'InitialMigration1767018509215'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "links" (
"id" SERIAL NOT NULL,
"subdomain" character varying,
"shortUri" character varying NOT NULL,
"fullUrl" character varying NOT NULL,
"createDate" bigint NOT NULL,
"expiryDate" bigint,
"visits" bigint NOT NULL,
"privacy" boolean NOT NULL,
"authorId" integer,
CONSTRAINT "PK_ecf17f4a741d3c5ba0b4c5ab4b6" PRIMARY KEY ("id")
)
`);
await queryRunner.query(`
CREATE TABLE "users" (
"id" SERIAL NOT NULL,
"name" character varying NOT NULL,
"passwordHash" character varying NOT NULL,
"dirtyPasswordHashBit" boolean NOT NULL,
"role" integer NOT NULL,
"createdAt" bigint NOT NULL,
CONSTRAINT "UQ_51b8b26ac168fbe7d6f5653e6cf" UNIQUE ("name"),
CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id")
)
`);
await queryRunner.query(`
ALTER TABLE "links"
ADD CONSTRAINT "FK_c5287c1e74cbb62159104715543" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "links" DROP CONSTRAINT "FK_c5287c1e74cbb62159104715543"
`);
await queryRunner.query(`
DROP TABLE "users"
`);
await queryRunner.query(`
DROP TABLE "links"
`);
}
}

View File

@@ -1,20 +0,0 @@
import { Router } from 'express';
const router = Router();
/**
* @swagger
*
* /:
* get:
* description: Hello world!
* tags: [Default]
* responses:
* 200:
* description: Returns "Hello world!"
*/
router.get('/', (req, res) => {
res.send("Hello world!");
});
export default router;

30
src/routes/miscRoutes.ts Normal file
View File

@@ -0,0 +1,30 @@
import { Router, Request, Response } from 'express';
const miscRouter = Router();
/**
* @openapi
*
* /:
* get:
* description: Hello world!
* tags: [Default]
* responses:
* 200:
* description: Returns "Hello world!"
*/
miscRouter.get('/', (req: Request, res: Response) => res.send("Hello world!"));
/**
* @openapi
* /healthcheck:
* get:
* tags: [Healthcheck]
* description: Provides a response when backend is running
* responses:
* 200:
* description: Backend is online
*/
miscRouter.get('/healthcheck', (req: Request, res: Response) => res.sendStatus(200));
export default miscRouter;

72
src/routes/userRoutes.ts Normal file
View File

@@ -0,0 +1,72 @@
import { Router, Request, Response } from 'express';
import validateSchema from '../tools/validateSchema';
import * as ac from '../controllers/authController';
import * as as from '../schemas/authSchema';
const userRouter = Router();
/**
* @openapi
*
* /api/v1/user/signUp:
* post:
* description: Add user
* tags: [User]
* summary: Register a user
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/LoginRequestDTO'
* produces:
* - application/json
* responses:
* 200:
* description: User created successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/UserInfoDTO'
* 400:
* description: Bad request
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorDTO'
*/
userRouter.post('/api/v1/user/signUp', validateSchema(as.loginRequestSchema), ac.createUserHandler);
/**
* @openapi
*
* /api/v1/user/signIn:
* post:
* description: Log in
* tags: [User]
* summary: Log in to an account
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/LoginRequestDTO'
* produces:
* - application/json
* responses:
* 200:
* description: User logged in successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/UserInfoDTO'
* 400:
* description: Wrong password/non-existent user
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorDTO'
*/
userRouter.post('/api/v1/user/signIn', validateSchema(as.loginRequestSchema), ac.loginUserHandler);
export default userRouter;

82
src/schemas/authSchema.ts Normal file
View File

@@ -0,0 +1,82 @@
import z from 'zod';
// Maybe load this from .env? 24 hours in seconds.
export const DEFAULT_TOKEN_LIFETIME = 86_400;
/**
* @openapi
* components:
* schemas:
* LoginRequestDTO:
* type: object
* required:
* - name
* - password
* properties:
* name:
* type: string
* default: mail@example.com or username
* password:
* type: string
* default: sha512(Hunter2)
* ttl:
* type: number
* default: 86400 # 24 hours * 60 minutes * 60 seconds
*/
const loginRequestSchemaBody = z.object({
name: z.string({
error: (e) => e.input === undefined ? 'Name is required' : 'Name must be a string'
})
.min( 3, 'Name is too short (try something longer than 3 characters)')
.max(64, 'Name is too long (try something shorter than 64 characters)'),
password: z.hash('sha512', {
error: (e) => e.input === undefined ? 'Password is required' : 'Password must be a SHA512 hash'
}),
ttl: z.number('TTL must be a number between 120 and 2 592 000 seconds')
.min( 120, 'TTL is too short (try something longer than 120 seconds)') // 120s
.max(2_592_000, 'TTL is too long (try something shorter than 30 days)') // 30d
.optional()
});
// export type LoginRequestBodyDTO = z.TypeOf<typeof loginRequestSchemaBody>;
export const loginRequestSchema = z.object({
body: loginRequestSchemaBody
});
export type LoginRequestDTO = z.TypeOf<typeof loginRequestSchema>;
/**
* @swagger
* components:
* schemas:
* UserInfoDTO:
* type: object
* required:
* - status
* - name
* - role
* - token
* properties:
* status:
* type: string
* default: ok on success otherwise ErrorDTO with error
* name:
* type: string
* default: username
* role:
* type: number
* default: 0 # 0 - standard user, 1 - administrator
* token:
* type: string
* default: JWT
* ttl:
* type: number
* default: 86400 # 24 hours * 60 minutes * 60 seconds
*/
export type UserInfoDTO = {
status: 'ok';
name: string;
role: number;
token: string;
ttl: number | null;
};

26
src/schemas/miscSchema.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* @openapi
* components:
* schemas:
* ErrorDTO:
* type: object
* required:
* - status
* - error
* properties:
* status:
* type: string
* default: error
* error:
* type: string
* default: error message
* code:
* type: string
* default: error code (may not be returned for every request)
*/
export type ErrorDTO = {
status: 'error';
error: string;
code?: string | undefined;
};

113
src/services/userService.ts Normal file
View File

@@ -0,0 +1,113 @@
import { User } from '../entities/User';
import { AppDataSource } from '../data-source';
export type UserCredentials = {
name?: string | undefined;
password?: string | undefined;
}
export class UserService {
dataSource = AppDataSource;
userRepo = this.dataSource.getRepository(User);
//
// Phased out in favor of in-controller logic.
//
// /**
// * Register a new user. Checks if a user with provided name exists.
// * Returns `true` if user has been registered successfully.
// *
// * @param {string} name Username
// * @param {string} password User's password
// * @param {(null|number)} role User's role (0 | null = standard user, 1 = admin)
// * @return {Promise<boolean>} True if an account has been registered successfully.
// */
// async register(name: string, password: string, role: number | null): Promise<boolean> {
//
// // Check if user by this name already exists.
// // If so, return here to not create a new account.
// if (await this.userRepo.existsBy({ name: name }))
// return false;
//
// // TODO: insert "dirty" entity...
// // await this.userRepo.insert();
//
// // TODO: salt the password...
//
// return true;
// }
/**
* Finds entity id by any credentials passed.
* Returns user id (typically number > 0) on success,
* -1 on no match, and -2 on multiple matches (if that were to ever occur).
*
* @param {UserCredentials} creds The creds
* @return {Promise<number>} User id
*/
async findIdByCredentials(creds: UserCredentials): Promise<number> {
let users: User[] = await this.userRepo.find({
where: { ...creds }
});
if (users.length === 0) return -1; // no match
else if (users.length === 1) return users[0].id!; // exact match, return id
else return -2; // sus, too many matches
}
/**
* Finds the user entity by any credentials passed.
* Returns user on success, otherwise null.
*
* @param {UserCredentials} creds The creds
* @return {(Promise<User|null>)} User entity
*/
async findByCredentials(creds: UserCredentials): Promise<User | null> {
let users: User[] = await this.userRepo.find({
where: { ...creds }
});
if (users.length !== 1) return null; // no match, or too many matches, sus
else return users[0]; // exact match, return user
}
/**
* Counts all the user entities in DB.
* Used to tell whether the next created account should be an admin
* (No users in DB? That means most likely it's a fresh instance.
* Then the next user should be an admin.)
*
* @return {Promise<number>} Number of user entities in DB (0...).
*/
async countAll(): Promise<number> {
return await this.userRepo.count();
}
/**
* Simply insert a new user entity.
*
* @param {User} user The user to insert
*/
async add(user: User): Promise<any> {
await this.userRepo.insert(user);
}
/**
* Simply save user entity to DB.
* New user passed? New entity will get created.
* Existing entity passed? It'll get updated.
*
* @param {User} user The user to insert or update
*/
async save(user: User) {
// assert(user.id !== undefined, new Error("Passed user MUST contain an id!"));
await this.userRepo.save(user);
}
}
// export default new UserService();

View File

@@ -1,13 +0,0 @@
import { Connection } from "typeorm";
import { User } from "./entities/User";
export async function smokeTest(connection: Connection) {
const user = new User();
user.name = "admin";
user.role = "admin";
user.createdAt = Date.now();
user.passwordHash = "pretend this is a hash";
await connection.manager.save(user);
}

13
src/tools/hasher.ts Normal file
View File

@@ -0,0 +1,13 @@
// Credits:
// https://mojoauth.com/hashing/sha-512-in-typescript/
import { createHash } from 'crypto';
/**
* Generates a SHA-512 hash for the given input string.
* @param input - The input string to hash.
* @returns The SHA-512 hash as a hexadecimal string.
*/
export function generateSha512(input: string): string {
const hash = createHash('sha512');
hash.update(input);
return hash.digest('hex');
}

117
src/tools/jwt.ts Normal file
View File

@@ -0,0 +1,117 @@
// Heavily based on:
// https://github.com/TomDoesTech/REST-API-Tutorial-Updated/blob/7b5f040e1acd94d267df585516b33ee7e3b75f70/src/utils/jwt.utils.ts
import * as dotenv from 'dotenv';
dotenv.config({ quiet: true });
import jwt from 'jsonwebtoken';
import { DEFAULT_TOKEN_LIFETIME } from '../schemas/authSchema';
type JwtStatus = {
valid: boolean;
expired: boolean;
decoded: string | jwt.JwtPayload | null;
};
/**
* Get the environmental string from .env.
* Supports rewriting names to UPPER_SNAKE_CASE if isGlobal is set.
*
* @param {string} key The key
* @param {boolean} [isGlobal=true] Indicates if global
* @return {(string|undefined)} The environment string.
*/
export function getEnvString(
key: string,
isGlobal: boolean = true
): string | undefined {
let keyName: string = '';
if (isGlobal) {
// Global values are DECLARED_LIKE_THIS=...
for (let i: number = 0; i < key.length; i++) {
if (key[i].toLowerCase() === key[i]) {
// If is lowercase, skip.
keyName += key[i];
} else {
// If is uppercase, convert to snake case.
keyName += `_${key[i].toLowerCase()}`;
}
}
keyName.toUpperCase();
} else {
// Non-global keys are parsed as passed
keyName = key;
}
return process.env[keyName];
}
/**
* Sign a JWT containing sub (number), role (number, 0/1), iat/exp (unix timestamp) claims.
*
* @param {Object} object The object
* @param {('accessTokenPrivateKey'|'refreshTokenPrivateKey')} keyName The key name
* @param {} options?:jwt.SignOptions|undefined The sign options undefined
* @return {string} JWT string
*/
export function signJwt(
object: Object,
keyName: 'accessTokenPrivateKey' | 'refreshTokenPrivateKey',
options?: jwt.SignOptions | undefined
): string {
// refresh tokens aren't (yet) supported
// const signingKey = Buffer.from(
// process.env[keyName]!,
// 'base64'
// ).toString('utf8');
const secret: string = getEnvString(keyName, true)!;
// Use the default expiration time of 24 hours.
if (options === undefined)
options = { expiresIn: DEFAULT_TOKEN_LIFETIME };
return jwt.sign(object, secret, {
...options, // (options && options)?
// algorithm: 'RS256', // requires a valid private key, not a secret
});
}
/**
* Verify a JWT against one of the keys.
* Returns JwtStatus, which contains fields for checking validity, expiry and decoded subject claim (id).
*
* @param {string} token The token
* @param {('accessTokenPublicKey'|'refreshTokenPublicKey')} keyName The key name
* @return {JwtStatus} JWT status.
*/
export function verifyJwt(
token: string,
keyName: 'accessTokenPublicKey' | 'refreshTokenPublicKey'
): JwtStatus {
// refresh tokens aren't (yet) supported
// const publicKey = Buffer.from(
// process.env[keyName]!,
// 'base64'
// ).toString('utf8');
const secret: string = getEnvString(keyName, true)!;
try {
const decoded: string | jwt.JwtPayload = jwt.verify(token, secret);
return {
valid: true,
expired: false,
decoded,
};
} catch (e: any) {
console.error('JWT verify error:', e);
return {
valid: false,
expired: e.message === 'jwt expired',
decoded: null,
};
}
}

View File

@@ -0,0 +1,40 @@
// Heavily based on:
// https://github.com/TomDoesTech/REST-API-Tutorial-Updated/blob/7b5f040e1acd94d267df585516b33ee7e3b75f70/src/middleware/validateResource.ts
import { Request, Response, NextFunction } from 'express';
import { ErrorDTO } from '../schemas/miscSchema';
import z from 'zod';
const validate =
(schema: z.ZodObject) =>
(req: Request, res: Response, next: NextFunction) => {
try {
schema.parse({
body: req.body,
query: req.query,
params: req.params,
});
next();
} catch (e: any) {
if (e instanceof z.ZodError) {
let errorResponse: ErrorDTO = {
status: 'error',
error: e.issues[0]?.message ?? 'Unknown error',
code: e.issues[0]?.code
};
return res.status(400)
.json(errorResponse);
} else {
console.log('Generic error triggered:', e);
let errorResponse: ErrorDTO = {
status: 'error',
error: 'Unknown error',
code: 'generic-error'
};
return res.status(400)
.json(errorResponse);
}
}
};
export default validate;