setup and invite

This commit is contained in:
Julian Krauser 2024-08-25 13:36:19 +02:00
parent 03e0f90279
commit 7df7cf2697
23 changed files with 515 additions and 43 deletions

View file

@ -8,3 +8,11 @@ SERVER_PORT = portnumber
JWT_SECRET = ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890 JWT_SECRET = ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890
JWT_EXPIRATION = [0-9]*(y|d|h|m|s) JWT_EXPIRATION = [0-9]*(y|d|h|m|s)
REFRESH_EXPIRATION = [0-9]*(y|d|h|m|s) REFRESH_EXPIRATION = [0-9]*(y|d|h|m|s)
MAIL_USERNAME = mail_username
MAIL_PASSWORD = mail_password
MAIL_HOST = mail_hoststring
MAIL_PORT = mail_portnumber
MAIL_SECURE (true|false) // true for port 465, fals for other ports
CLUB_NAME = clubname

View file

@ -1,3 +1,30 @@
# member-administration-server # member-administration-server
Mitgliederverwaltung Memberadministration
Authentications is realized via JWT-Tokens. The server is able to send Mails to the members.
Login is possible via Username and TOTP.
## Installation
### Requirements
1. MySql Database
2. Access to the internet for sending Mails
### Configuration
1. Copy the .env.example file to .env and fill in the required information
2. Create a new Database in MySql named as in the .env file
3. Install all packages via `npm install`
4. Start the application to create the database schema
## Testing
1. Install the database-system-package you like (e.g. mysql, mariadb, postgresql, sqlite3)
2. Configure type inside src/data-source.ts to run the database-system you like.
3. Set migrationsRun to false and synchronize to true. (Migrations are build to suit mysql)
4. Building the schema via CLI:
- Run `npm run update-database` to build the schema using the migrations without starting the application
- Run `npm run synchronize-database` to build the schema without using migrations without starting the application
5. Run `npm run dev` to run inside dev-environment

View file

@ -7,7 +7,9 @@
"start_ts": "ts-node src/index.ts", "start_ts": "ts-node src/index.ts",
"typeorm": "typeorm-ts-node-commonjs", "typeorm": "typeorm-ts-node-commonjs",
"migrate": "set DBMODE=migration && npx typeorm-ts-node-commonjs migration:generate ./src/migrations/%npm_config_name% -d ./src/data-source.ts", "migrate": "set DBMODE=migration && npx typeorm-ts-node-commonjs migration:generate ./src/migrations/%npm_config_name% -d ./src/data-source.ts",
"synchronize-database": "set DBMODE=update-database && npx typeorm-ts-node-commonjs schema:sync -d ./src/data-source.ts",
"update-database": "set DBMODE=update-database && npx typeorm-ts-node-commonjs migration:run -d ./src/data-source.ts", "update-database": "set DBMODE=update-database && npx typeorm-ts-node-commonjs migration:run -d ./src/data-source.ts",
"revert-database": "set DBMODE=update-database && npx typeorm-ts-node-commonjs migration:revert -d ./src/data-source.ts",
"build": "tsc", "build": "tsc",
"start": "node .", "start": "node .",
"dev": "npm run build && set NODE_ENV=development && npm run start" "dev": "npm run build && set NODE_ENV=development && npm run start"

View file

@ -0,0 +1,12 @@
export interface CreateInviteCommand {
mail: string;
username: string;
firstname: string;
lastname: string;
secret: string;
}
export interface DeleteInviteCommand {
token: string;
mail: string;
}

View file

@ -0,0 +1,56 @@
import { dataSource } from "../data-source";
import { invite } from "../entity/invite";
import InternalException from "../exceptions/internalException";
import { StringHelper } from "../helpers/stringHelper";
import { CreateInviteCommand, DeleteInviteCommand } from "./inviteCommand";
export default abstract class InviteCommandHandler {
/**
* @description create user
* @param CreateInviteCommand
* @returns {Promise<string>}
*/
static async create(createInvite: CreateInviteCommand): Promise<string> {
const token = StringHelper.random(32);
return await dataSource
.createQueryBuilder()
.insert()
.into(invite)
.values({
mail: createInvite.mail,
token: token,
username: createInvite.username,
firstname: createInvite.firstname,
lastname: createInvite.lastname,
secret: createInvite.secret,
})
.orUpdate(["firstName", "lastName", "token", "secret"], ["mail"])
.execute()
.then((result) => {
return token;
})
.catch((err) => {
throw new InternalException("Failed saving invite");
});
}
/**
* @description delete invite by mail and token
* @param DeleteRefreshCommand
* @returns {Promise<any>}
*/
static async deleteByTokenAndMail(deleteInvite: DeleteInviteCommand): Promise<any> {
return await dataSource
.createQueryBuilder()
.delete()
.from(invite)
.where("invite.token = :token", { token: deleteInvite.token })
.andWhere("invite.mail = :mail", { mail: deleteInvite.mail })
.execute()
.then((res) => {})
.catch((err) => {
throw new InternalException("failed invite removal");
});
}
}

View file

@ -42,7 +42,7 @@ export default abstract class RefreshCommandHandler {
/** /**
* @description delete refresh by user and token * @description delete refresh by user and token
* @param DeleteRefreshCommand * @param DeleteRefreshCommand
* @returns {Promise<refresh>} * @returns {Promise<any>}
*/ */
static async deleteByToken(deleteRefresh: DeleteRefreshCommand): Promise<any> { static async deleteByToken(deleteRefresh: DeleteRefreshCommand): Promise<any> {
return await dataSource return await dataSource

View file

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

View file

@ -7,9 +7,9 @@ export default abstract class UserCommandHandler {
/** /**
* @description create user * @description create user
* @param CreateUserCommand * @param CreateUserCommand
* @returns {Promise<string>} * @returns {Promise<number>}
*/ */
static async create(createUser: CreateUserCommand): Promise<string> { static async create(createUser: CreateUserCommand): Promise<number> {
return await dataSource return await dataSource
.createQueryBuilder() .createQueryBuilder()
.insert() .insert()
@ -17,6 +17,8 @@ export default abstract class UserCommandHandler {
.values({ .values({
username: createUser.username, username: createUser.username,
mail: createUser.mail, mail: createUser.mail,
firstname: createUser.firstname,
lastname: createUser.lastname,
secret: createUser.secret, secret: createUser.secret,
}) })
.execute() .execute()

View file

@ -132,31 +132,3 @@ export async function refresh(req: Request, res: Response): Promise<any> {
refreshToken, refreshToken,
}); });
} }
/**
* @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");
});
}

View file

@ -0,0 +1,159 @@
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 speakeasy from "speakeasy";
import UnauthorizedRequestException from "../exceptions/unauthorizedRequestException";
import QRCode from "qrcode";
import { CreateUserCommand } from "../command/userCommand";
import UserCommandHandler from "../command/userCommandHandler";
import { CreateInviteCommand, DeleteInviteCommand } from "../command/inviteCommand";
import InviteCommandHandler from "../command/inviteCommandHandler";
import MailHelper from "../helpers/mailHelper";
import InviteService from "../service/inviteService";
import UserService from "../service/userService";
import CustomRequestException from "../exceptions/customRequestException";
/**
* @description start first user
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function inviteUser(req: Request, res: Response): Promise<any> {
let origin = req.headers.origin;
let username = req.body.username;
let mail = req.body.mail;
let firstname = req.body.firstname;
let lastname = req.body.lastname;
let users = await UserService.getByMailOrUsername(mail, username);
if (users.length == 1) {
// username or mail is used
if (users[0].username == username && users[0].mail == mail) {
throw new CustomRequestException(409, "Username and Mail are already in use");
} else if (users[0].username == username) {
throw new CustomRequestException(409, "Username is already in use");
} else {
throw new CustomRequestException(409, "Mail is already in use");
}
} else if (users.length >= 2) {
throw new CustomRequestException(409, "Username and Mail are already in use");
}
var secret = speakeasy.generateSecret({ length: 20, name: `Mitgliederverwaltung ${process.env.CLUB_NAME}` });
let createInvite: CreateInviteCommand = {
username: username,
mail: mail,
firstname: firstname,
lastname: lastname,
secret: secret.base32,
};
let token = await InviteCommandHandler.create(createInvite);
// sendmail
let mailhelper = new MailHelper();
await mailhelper.sendMail(
mail,
`Email Bestätigung für Mitglieder Admin-Portal von ${process.env.CLUB_NAME}`,
`Öffne folgenden Link: ${origin}/setup/verify?mail=${mail}&token=${token}`
);
res.sendStatus(204);
}
/**
* @description Create first user
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function verifyInvite(req: Request, res: Response): Promise<any> {
let mail = req.body.mail;
let token = req.body.token;
let { secret } = await InviteService.getByMailAndToken(mail, token);
const url = `otpauth://totp/Mitgliederverwaltung ${process.env.CLUB_NAME}?secret=${secret}`;
QRCode.toDataURL(url)
.then((result) => {
res.send(result);
})
.catch((err) => {
throw new InternalException("QRCode not created");
});
}
/**
* @description Create first user
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function finishInvite(req: Request, res: Response): Promise<any> {
let mail = req.body.mail;
let token = req.body.token;
let totp = req.body.totp;
let { secret, username, firstname, lastname } = await InviteService.getByMailAndToken(mail, token);
let valid = speakeasy.totp.verify({
secret: secret,
encoding: "base32",
token: totp,
window: 2,
});
console.log(valid);
if (!valid) {
throw new UnauthorizedRequestException("Token not valid or expired");
}
let createUser: CreateUserCommand = {
username: username,
firstname: firstname,
lastname: lastname,
mail: mail,
secret: secret,
};
let id = await UserCommandHandler.create(createUser);
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);
let deleteInvite: DeleteInviteCommand = {
mail: mail,
token: token,
};
await InviteCommandHandler.deleteByTokenAndMail(deleteInvite);
res.json({
accessToken,
refreshToken,
});
}

View file

@ -0,0 +1,11 @@
import { Request, Response } from "express";
/**
* @description Service is currently not configured
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function isSetup(req: Request, res: Response): Promise<any> {
res.sendStatus(204);
}

View file

@ -1,10 +1,14 @@
import "dotenv/config"; import "dotenv/config";
import "reflect-metadata"; import "reflect-metadata";
import { DataSource } from "typeorm"; import { DataSource } from "typeorm";
import { user } from "./entity/user"; import { user } from "./entity/user";
import { refresh } from "./entity/refresh"; import { refresh } from "./entity/refresh";
import { invite } from "./entity/invite";
import { Initial1724317398939 } from "./migrations/1724317398939-initial"; import { Initial1724317398939 } from "./migrations/1724317398939-initial";
import { RefreshPrimaryChange1724573307851 } from "./migrations/1724573307851-refreshPrimaryChange"; import { RefreshPrimaryChange1724573307851 } from "./migrations/1724573307851-refreshPrimaryChange";
import { Invite1724579024939 } from "./migrations/1724579024939-invite";
const dataSource = new DataSource({ const dataSource = new DataSource({
type: "mysql", type: "mysql",
@ -16,8 +20,8 @@ const dataSource = new DataSource({
synchronize: false, synchronize: false,
logging: process.env.NODE_ENV ? true : ["schema", "error", "warn", "log", "migration"], logging: process.env.NODE_ENV ? true : ["schema", "error", "warn", "log", "migration"],
bigNumberStrings: false, bigNumberStrings: false,
entities: [user, refresh], entities: [user, refresh, invite],
migrations: [Initial1724317398939, RefreshPrimaryChange1724573307851], migrations: [Initial1724317398939, RefreshPrimaryChange1724573307851, Invite1724579024939],
migrationsRun: true, migrationsRun: true,
migrationsTransactionMode: "each", migrationsTransactionMode: "each",
subscribers: [], subscribers: [],

22
src/entity/invite.ts Normal file
View file

@ -0,0 +1,22 @@
import { Column, Entity, PrimaryColumn } from "typeorm";
@Entity()
export class invite {
@PrimaryColumn({ type: "varchar", length: 255 })
mail: string;
@Column({ type: "varchar", length: 255 })
token: string;
@Column({ type: "varchar", length: 255 })
username: string;
@Column({ type: "varchar", length: 255 })
firstname: string;
@Column({ type: "varchar", length: 255 })
lastname: string;
@Column({ type: "varchar", length: 255 })
secret: string;
}

View file

@ -1,4 +1,4 @@
import { Column, Entity, OneToMany, PrimaryColumn } from "typeorm"; import { Column, Entity, PrimaryColumn } from "typeorm";
import { refresh } from "./refresh"; import { refresh } from "./refresh";
@Entity() @Entity()
@ -12,6 +12,12 @@ export class user {
@Column({ type: "varchar", length: 255 }) @Column({ type: "varchar", length: 255 })
username: string; username: string;
@Column({ type: "varchar", length: 255 })
firstname: string;
@Column({ type: "varchar", length: 255 })
lastname: string;
@Column({ type: "varchar", length: 255 }) @Column({ type: "varchar", length: 255 })
secret: string; secret: string;
} }

38
src/helpers/mailHelper.ts Normal file
View file

@ -0,0 +1,38 @@
import { Transporter, createTransport, TransportOptions } from "nodemailer";
export default class MailHelper {
private readonly transporter: Transporter;
constructor() {
this.transporter = createTransport({
host: process.env.MAIL_HOST,
port: Number(process.env.MAIL_PORT),
secure: (process.env.MAIL_SECURE as "true" | "false") == "true",
auth: {
user: process.env.MAIL_USERNAME,
pass: process.env.MAIL_PASSWORD,
},
} as TransportOptions);
}
/**
* @description send mail
* @param {string} target
* @param {string} subject
* @param {string} content
* @returns {Prmose<*>}
*/
async sendMail(target: string, subject: string, content: string): Promise<any> {
return new Promise((resolve, reject) => {
this.transporter
.sendMail({
from: `"${process.env.CLUB_NAME}" <${process.env.MAIL_USERNAME}>`,
to: target,
subject,
text: content,
})
.then((info) => resolve(info.messageId))
.catch((e) => reject(e));
});
}
}

View file

@ -0,0 +1,28 @@
import { Request, Response } from "express";
import BadRequestException from "../exceptions/badRequestException";
export default class ParamaterPassCheckHelper {
static requiredIncluded(testfor: Array<string>, obj: object) {
let result = testfor.every((key) => Object.keys(obj).includes(key));
if (!result) throw new BadRequestException(`not all required parameters included: ${testfor.join(",")}`);
}
static forbiddenIncluded(testfor: Array<string>, obj: object) {
let result = testfor.some((key) => Object.keys(obj).includes(key));
if (!result) throw new BadRequestException(`PPC: forbidden parameters included: ${testfor.join(",")}`);
}
static requiredIncludedMiddleware(testfor: Array<string>): (req: Request, res: Response, next: Function) => void {
return (req: Request, res: Response, next: Function) => {
this.requiredIncluded(testfor, req.body);
next();
};
}
static forbiddenIncludedMiddleware(testfor: Array<string>): (req: Request, res: Response, next: Function) => void {
return (req: Request, res: Response, next: Function) => {
this.requiredIncluded(testfor, req.body);
next();
};
}
}

View file

@ -0,0 +1,12 @@
import { Request, Response } from "express";
import UserService from "../service/userService";
import CustomRequestException from "../exceptions/customRequestException";
export default async function allowSetup(req: Request, res: Response, next: Function) {
let count = await UserService.count();
if (count != 0) {
throw new CustomRequestException(405, "service is already set up");
}
next();
}

View file

@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Invite1724579024939 implements MigrationInterface {
name = 'Invite1724579024939'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE \`invite\` (\`mail\` varchar(255) NOT NULL, \`token\` varchar(255) NOT NULL, \`username\` varchar(255) NOT NULL, \`firstname\` varchar(255) NOT NULL, \`lastname\` varchar(255) NOT NULL, \`secret\` varchar(255) NOT NULL, PRIMARY KEY (\`mail\`)) ENGINE=InnoDB`);
await queryRunner.query(`ALTER TABLE \`user\` ADD \`firstname\` varchar(255) NOT NULL`);
await queryRunner.query(`ALTER TABLE \`user\` ADD \`lastname\` varchar(255) NOT NULL`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE \`user\` DROP COLUMN \`lastname\``);
await queryRunner.query(`ALTER TABLE \`user\` DROP COLUMN \`firstname\``);
await queryRunner.query(`DROP TABLE \`invite\``);
}
}

View file

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

View file

@ -1,10 +1,12 @@
import express from "express"; import express from "express";
import type { Express } from "express";
import cors from "cors"; import cors from "cors";
import type { Express } from "express"; import allowSetup from "../middleware/allowSetup";
import errorHandler from "../middleware/errorHandler";
import authenticate from "../middleware/authenticate"; import authenticate from "../middleware/authenticate";
import errorHandler from "../middleware/errorHandler";
import setup from "./setup";
import auth from "./auth"; import auth from "./auth";
export default (app: Express) => { export default (app: Express) => {
@ -18,6 +20,7 @@ export default (app: Express) => {
app.use(cors()); app.use(cors());
app.options("*", cors()); app.options("*", cors());
app.use("/setup", allowSetup, setup);
app.use("/auth", auth); app.use("/auth", auth);
app.use(authenticate); app.use(authenticate);
app.use("/secured", (req, res) => { app.use("/secured", (req, res) => {

28
src/routes/setup.ts Normal file
View file

@ -0,0 +1,28 @@
import express from "express";
import { isSetup } from "../controller/setupController";
import { finishInvite, inviteUser, verifyInvite } from "../controller/inviteController";
import ParamaterPassCheckHelper from "../helpers/parameterPassCheckHelper";
var router = express.Router({ mergeParams: true });
router.get("/", async (req, res) => {
await isSetup(req, res);
});
router.post("/verify", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token"]), async (req, res) => {
await verifyInvite(req, res);
});
router.post(
"/",
ParamaterPassCheckHelper.requiredIncludedMiddleware(["username", "mail", "firstname", "lastname"]),
async (req, res) => {
await inviteUser(req, res);
}
);
router.put("/", ParamaterPassCheckHelper.requiredIncludedMiddleware(["mail", "token", "totp"]), async (req, res) => {
await finishInvite(req, res);
});
export default router;

View file

@ -0,0 +1,26 @@
import { dataSource } from "../data-source";
import { invite } from "../entity/invite";
import InternalException from "../exceptions/internalException";
export default abstract class InviteService {
/**
* @description get invite by id
* @param mail string
* @param token string
* @returns {Promise<invite>}
*/
static async getByMailAndToken(mail: string, token: string): Promise<invite> {
return await dataSource
.getRepository(invite)
.createQueryBuilder("invite")
.where("invite.mail = :mail", { mail: mail })
.andWhere("invite.token = :token", { token: token })
.getOneOrFail()
.then((res) => {
return res;
})
.catch((err) => {
throw new InternalException("invite not found by mail and token");
});
}
}

View file

@ -41,4 +41,44 @@ export default abstract class UserService {
throw new InternalException("user not found by username"); throw new InternalException("user not found by username");
}); });
} }
/**
* @description get users by mail or username
* @param username string
* @param mail string
* @returns {Promise<Array<user>>}
*/
static async getByMailOrUsername(mail?: string, username?: string): Promise<Array<user>> {
return await dataSource
.getRepository(user)
.createQueryBuilder("user")
.select()
.where("user.mail = :mail", { mail: mail })
.orWhere("user.username = :username", { username: username })
.getMany()
.then((res) => {
return res;
})
.catch((err) => {
throw new InternalException("user not found by mail or username");
});
}
/**
* @description get count of users
* @returns {Promise<number>}
*/
static async count(): Promise<number> {
return await dataSource
.getRepository(user)
.createQueryBuilder("user")
.select()
.getCount()
.then((res) => {
return res;
})
.catch((err) => {
throw new InternalException("could not count users");
});
}
} }