feat: major code refactor, add login and register endpoints with swagger
All checks were successful
Update changelog / changelog (push) Successful in 27s
All checks were successful
Update changelog / changelog (push) Successful in 27s
This commit is contained in:
103
src/app.ts
103
src/app.ts
@@ -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))
|
||||
|
||||
186
src/controllers/authController.ts
Normal file
186
src/controllers/authController.ts
Normal 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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
25
src/index.ts
25
src/index.ts
@@ -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))
|
||||
50
src/middleware/inferUser.ts
Normal file
50
src/middleware/inferUser.ts
Normal 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;
|
||||
51
src/migrations/1767018509215-initialMigration.ts
Normal file
51
src/migrations/1767018509215-initialMigration.ts
Normal 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"
|
||||
`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
30
src/routes/miscRoutes.ts
Normal 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
72
src/routes/userRoutes.ts
Normal 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
82
src/schemas/authSchema.ts
Normal 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
26
src/schemas/miscSchema.ts
Normal 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
113
src/services/userService.ts
Normal 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();
|
||||
@@ -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
13
src/tools/hasher.ts
Normal 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
117
src/tools/jwt.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
40
src/tools/validateSchema.ts
Normal file
40
src/tools/validateSchema.ts
Normal 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;
|
||||
Reference in New Issue
Block a user