login and authentication

login via totp
authentication via access and refresh tokens
This commit is contained in:
Julian Krauser 2024-08-22 11:40:31 +02:00
parent 6696975bee
commit e1ec65350d
28 changed files with 3750 additions and 0 deletions

View file

@ -0,0 +1,8 @@
export interface CreateRefreshCommand {
userId: number;
}
export interface DeleteRefreshCommand {
id: number;
userId: number;
}

View file

@ -0,0 +1,39 @@
import { dataSource } from "../data-source";
import { refresh } from "../entity/refresh";
import InternalException from "../exceptions/internalException";
import { JWTHelper } from "../helpers/jwtHelper";
import UserService from "../service/userService";
import { JWTRefresh } from "../type/jwtTypes";
import { CreateRefreshCommand } from "./refreshCommand";
import ms from "ms";
export default abstract class RefreshCommandHandler {
/**
* @description create and save refreshToken to user
* @param CreateRefreshCommand
* @returns {Promise<string>}
*/
static async create(createRefresh: CreateRefreshCommand): Promise<string> {
let createRefreshToken: JWTRefresh = {
userId: createRefresh.userId,
};
const refreshToken = await JWTHelper.create(createRefreshToken);
return await dataSource
.createQueryBuilder()
.insert()
.into(refresh)
.values({
token: refreshToken,
user: await UserService.getById(createRefresh.userId),
expiry: ms(process.env.REFRESH_EXPIRATION),
})
.execute()
.then((result) => {
return refreshToken;
})
.catch((err) => {
throw new InternalException("Failed saving refresh token");
});
}
}

View file

@ -0,0 +1,5 @@
export interface CreateUserCommand {
mail: string;
username: string;
secret: string;
}

View file

@ -0,0 +1,30 @@
import { dataSource } from "../data-source";
import { user } from "../entity/user";
import InternalException from "../exceptions/internalException";
import { CreateUserCommand } from "./userCommand";
export default abstract class UserCommandHandler {
/**
* @description create user
* @param CreateUserCommand
* @returns {Promise<string>}
*/
static async create(createUser: CreateUserCommand): Promise<string> {
return await dataSource
.createQueryBuilder()
.insert()
.into(user)
.values({
username: createUser.username,
mail: createUser.mail,
secret: createUser.secret,
})
.execute()
.then((result) => {
return result.identifiers[0].id;
})
.catch((err) => {
throw new InternalException("Failed saving user");
});
}
}

View file

@ -0,0 +1,111 @@
import { Request, Response } from "express";
import { JWTHelper } from "../helpers/jwtHelper";
import { JWTToken } from "../type/jwtTypes";
import InternalException from "../exceptions/internalException";
import RefreshCommandHandler from "../command/refreshCommandHandler";
import { CreateRefreshCommand } from "../command/refreshCommand";
import UserService from "../service/userService";
import speakeasy from "speakeasy";
import UnauthorizedRequestException from "../exceptions/unauthorizedRequestException";
import QRCode from "qrcode";
import { CreateUserCommand } from "../command/userCommand";
import UserCommandHandler from "../command/userCommandHandler";
/**
* @description Check authentication status by token
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function login(req: Request, res: Response): Promise<any> {
let username = req.body.username;
let totp = req.body.totp;
let { id, secret } = await UserService.getByUsername(username);
let valid = speakeasy.totp.verify({
secret: secret,
encoding: "base32",
token: totp,
window: 2,
});
if (!valid) {
throw new UnauthorizedRequestException("Token not valid or expired");
}
let jwtData: JWTToken = {
userId: id,
username: username,
rights: [],
};
let accessToken: string;
let refreshToken: string;
JWTHelper.create(jwtData)
.then((result) => {
accessToken = result;
})
.catch((err) => {
console.log(err);
throw new InternalException("Failed accessToken creation");
});
let refreshCommand: CreateRefreshCommand = {
userId: id,
};
refreshToken = await RefreshCommandHandler.create(refreshCommand);
res.json({
accessToken,
refreshToken,
});
}
/**
* @description logout user by token (invalidate refresh token)
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function logout(req: Request, res: Response): Promise<any> {}
/**
* @description refresh expired token
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function refresh(req: Request, res: Response): Promise<any> {
let token = req.body.token;
let refresh = req.body.refresh;
}
/**
* @description register new user
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function register(req: Request, res: Response): Promise<any> {
// TODO: change to invitation only
let username = req.body.username;
let mail = req.body.mail;
var secret = speakeasy.generateSecret({ length: 20, name: "Mitgliederverwaltung" });
let createUser: CreateUserCommand = {
username: username,
mail: mail,
secret: secret.base32,
};
await UserCommandHandler.create(createUser);
QRCode.toDataURL(secret.otpauth_url)
.then((result) => {
res.send(result);
})
.catch((err) => {
throw new InternalException("QRCode not created");
});
}

25
src/data-source.ts Normal file
View file

@ -0,0 +1,25 @@
import "dotenv/config";
import "reflect-metadata";
import { DataSource } from "typeorm";
import { user } from "./entity/user";
import { refresh } from "./entity/refresh";
import { Initial1724317398939 } from "./migrations/1724317398939-initial";
const dataSource = new DataSource({
type: "mysql",
host: process.env.NODE_ENV || process.env.DBMODE ? "localhost" : process.env.DB_HOST,
port: 3306,
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
synchronize: false,
logging: process.env.NODE_ENV ? true : ["schema", "error", "warn", "log", "migration"],
bigNumberStrings: false,
entities: [user, refresh],
migrations: [Initial1724317398939],
migrationsRun: true,
migrationsTransactionMode: "each",
subscribers: [],
});
export { dataSource };

17
src/entity/refresh.ts Normal file
View file

@ -0,0 +1,17 @@
import { Column, Entity, ManyToOne, PrimaryColumn } from "typeorm";
import { user } from "./user";
@Entity()
export class refresh {
@PrimaryColumn({ generated: "increment", type: "int" })
id: number;
@Column({ type: "varchar", length: 255 })
token: string;
@Column({ type: "datetime" })
expiry: Date;
@ManyToOne(() => user)
user: user;
}

17
src/entity/user.ts Normal file
View file

@ -0,0 +1,17 @@
import { Column, Entity, OneToMany, PrimaryColumn } from "typeorm";
import { refresh } from "./refresh";
@Entity()
export class user {
@PrimaryColumn({ generated: "increment", type: "int" })
id: number;
@Column({ type: "varchar", length: 255 })
mail: string;
@Column({ type: "varchar", length: 255 })
username: string;
@Column({ type: "varchar", length: 255 })
secret: string;
}

View file

@ -0,0 +1,9 @@
import { ExceptionBase } from "./exceptionsBaseType";
export default class BadRequestException extends Error implements ExceptionBase {
statusCode: number = 400;
constructor(msg: string) {
super(msg);
}
}

View file

@ -0,0 +1,10 @@
import { ExceptionBase } from "./exceptionsBaseType";
export default class CustomRequestException extends Error implements ExceptionBase {
statusCode: number;
constructor(status: number, msg: string) {
super(msg);
this.statusCode = status;
}
}

View file

@ -0,0 +1,3 @@
export type ExceptionBase = {
statusCode: number;
} & Error;

View file

@ -0,0 +1,9 @@
import { ExceptionBase } from "./exceptionsBaseType";
export default class InternalException extends Error implements ExceptionBase {
statusCode: number = 500;
constructor(msg: string) {
super(msg);
}
}

View file

@ -0,0 +1,9 @@
import { ExceptionBase } from "./exceptionsBaseType";
export default class UnauthorizedRequestException extends Error implements ExceptionBase {
statusCode: number = 401;
constructor(msg: string) {
super(msg);
}
}

40
src/helpers/jwtHelper.ts Normal file
View file

@ -0,0 +1,40 @@
import jwt from "jsonwebtoken";
import { JWTData } from "../type/jwtTypes";
export abstract class JWTHelper {
static validate(token: string): Promise<string | jwt.JwtPayload> {
return new Promise<string | jwt.JwtPayload>((resolve, reject) => {
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
if (err) reject(err.message);
else resolve(decoded);
});
});
}
static create(data: JWTData): Promise<string> {
return new Promise<string>((resolve, reject) => {
jwt.sign(
data,
process.env.JWT_SECRET,
{
expiresIn: process.env.JWT_EXPIRATION,
},
(err, token) => {
if (err) reject(err.message);
else resolve(token);
}
);
});
}
static decode(token: string): Promise<string | jwt.JwtPayload> {
return new Promise<string | jwt.JwtPayload>((resolve, reject) => {
try {
let decoded = jwt.decode(token);
resolve(decoded);
} catch (err) {
reject(err.message);
}
});
}
}

View file

@ -0,0 +1,11 @@
export abstract class StringHelper {
static random(len: number, charSet?: string): string {
charSet = charSet || "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
var randomString = "";
for (var i = 0; i < len; i++) {
var randomPoz = Math.floor(Math.random() * charSet.length);
randomString += charSet.substring(randomPoz, randomPoz + 1);
}
return randomString;
}
}

23
src/index.ts Normal file
View file

@ -0,0 +1,23 @@
import "dotenv/config";
import express from "express";
declare global {
namespace Express {
export interface Request {
userId: string;
username: string;
rights: Array<string>;
}
}
}
import { dataSource } from "./data-source";
dataSource.initialize();
const app = express();
import router from "./routes/index";
router(app);
app.listen(process.env.SERVER_PORT, () => {
console.log(`listening on *:${process.env.SERVER_PORT}`);
});

View file

@ -0,0 +1,37 @@
import { Request, Response } from "express";
import jwt from "jsonwebtoken";
import BadRequestException from "../exceptions/badRequestException";
import UnauthorizedRequestException from "../exceptions/unauthorizedRequestException";
import InternalException from "../exceptions/internalException";
import { JWTHelper } from "../helpers/jwtHelper";
export default async function authenticate(req: Request, res: Response, next: Function) {
const bearer = req.headers.authorization;
if (!bearer) {
throw new BadRequestException("Provide Authorization Header");
}
let decoded: string | jwt.JwtPayload;
await JWTHelper.validate(bearer)
.then((result) => {
decoded = result;
})
.catch((err) => {
if (err == "jwt expired") {
throw new UnauthorizedRequestException("Token expired");
} else {
throw new BadRequestException("Failed Authorization Header decoding");
}
});
if (typeof decoded == "string" || !decoded) {
throw new InternalException("process failed");
}
req.userId = decoded.userId;
req.username = decoded.username;
req.rights = decoded.rights;
next();
}

View file

@ -0,0 +1,9 @@
import { Request, Response } from "express";
import { ExceptionBase } from "../exceptions/exceptionsBaseType";
export default function errorHandler(err: ExceptionBase, req: Request, res: Response, next: Function) {
let status = err.statusCode ?? 500;
let msg = err.message;
res.status(status).send(msg);
}

View file

@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Initial1724317398939 implements MigrationInterface {
name = 'Initial1724317398939'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE \`user\` (\`id\` int NOT NULL AUTO_INCREMENT, \`mail\` varchar(255) NOT NULL, \`username\` varchar(255) NOT NULL, \`secret\` varchar(255) NOT NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
await queryRunner.query(`CREATE TABLE \`refresh\` (\`id\` int NOT NULL AUTO_INCREMENT, \`token\` varchar(255) NOT NULL, \`expiry\` datetime NOT NULL, \`userId\` int NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
await queryRunner.query(`ALTER TABLE \`refresh\` ADD CONSTRAINT \`FK_b39e4ed3bfa789758e476870ec2\` FOREIGN KEY (\`userId\`) REFERENCES \`user\`(\`id\`) ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE \`refresh\` DROP FOREIGN KEY \`FK_b39e4ed3bfa789758e476870ec2\``);
await queryRunner.query(`DROP TABLE \`refresh\``);
await queryRunner.query(`DROP TABLE \`user\``);
}
}

22
src/routes/auth.ts Normal file
View file

@ -0,0 +1,22 @@
import express from "express";
import { login, logout, refresh, register } from "../controller/authController";
var router = express.Router({ mergeParams: true });
router.post("/login", async (req, res) => {
await login(req, res);
});
router.post("/logout", async (req, res) => {
await logout(req, res);
});
router.post("/refresh", async (req, res) => {
await refresh(req, res);
});
router.post("/register", async (req, res) => {
await register(req, res);
});
export default router;

27
src/routes/index.ts Normal file
View file

@ -0,0 +1,27 @@
import express from "express";
import cors from "cors";
import type { Express } from "express";
import errorHandler from "../middleware/errorHandler";
import authenticate from "../middleware/authenticate";
import auth from "./auth";
export default (app: Express) => {
app.set("query parser", "extended");
app.use(express.json());
app.use(
express.urlencoded({
extended: true,
})
);
app.use(cors());
app.options("*", cors());
app.use("/auth", auth);
app.use(authenticate);
app.use("/secured", (req, res) => {
res.send("hallo");
});
app.use(errorHandler);
};

View file

@ -0,0 +1,44 @@
import { dataSource } from "../data-source";
import { user } from "../entity/user";
import InternalException from "../exceptions/internalException";
export default abstract class UserService {
/**
* @description get user by id
* @param id number
* @returns {Promise<user>}
*/
static async getById(id: number): Promise<user> {
return await dataSource
.getRepository(user)
.createQueryBuilder("user")
.where("user.id = :id", { id: id })
.getOneOrFail()
.then((res) => {
return res;
})
.catch((err) => {
throw new InternalException("user not found by id");
});
}
/**
* @description get user by username
* @param username string
* @returns {Promise<user>}
*/
static async getByUsername(username: string): Promise<user> {
return await dataSource
.getRepository(user)
.createQueryBuilder("user")
.select()
.where("user.username = :username", { username: username })
.getOneOrFail()
.then((res) => {
return res;
})
.catch((err) => {
throw new InternalException("user not found by username");
});
}
}

13
src/type/jwtTypes.ts Normal file
View file

@ -0,0 +1,13 @@
export type JWTData = {
[key: string]: string | number | Array<string>;
};
export type JWTToken = {
userId: number;
username: string;
rights: Array<string>;
} & JWTData;
export type JWTRefresh = {
userId: number;
} & JWTData;