Skip to content

Commit

Permalink
🔀 Merge pull request #109 from tjarbo/103-admin-management
Browse files Browse the repository at this point in the history
🔀 Added administrator management based on registration tokens
  • Loading branch information
tjarbo authored Apr 28, 2021
2 parents ed8652e + b20b775 commit 953668e
Show file tree
Hide file tree
Showing 23 changed files with 906 additions and 762 deletions.
2 changes: 1 addition & 1 deletion packages/backend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ connect(config.mongo.host, { useNewUrlParser: true, useUnifiedTopology: true, us
// No admin has been found -> Create a registration token and print it into the logs
new RegistrationToken({ userIsDeletable: false }).save().then(token => {
loggerFile.info('No administrator found!');
loggerFile.info(`Visit ${config.rp.origin}/#/registration and use the following token: ${token.key}`);
loggerFile.info(`Visit ${config.rp.origin}/#/registration?token=${token.key} to register your user`);
loggerFile.info(`This token is valid for 15 minutes.`);
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* tslint:disable:ban-types */
import { Schema, model, Model, Document } from 'mongoose';
import { IAuthenticatorDocument } from '../authentication/authenticator.schema';
import { IAuthenticatorDocument, authenticatorSchema } from './authenticator.schema';
import { string } from '@hapi/joi';

export interface IAdministratorDocument extends Document {
[_id: string]: any;
Expand All @@ -16,7 +17,9 @@ const administratorSchema = new Schema({
createdAt: { type: Date, default: Date.now },
deletable: { type: Boolean, default: true },
currentChallenge: { type: String, default: null },
device: { type: Schema.Types.ObjectId, ref: 'Authenticator' },
device: { type: authenticatorSchema, default: null },
});

export const Administrator: Model<IAdministratorDocument> = model<IAdministratorDocument>('Administrator', administratorSchema);

export const administratorUsernameValidationSchema = string().alphanum().required().min(8).max(64).description('Username of administrator');
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,12 @@ export interface IAuthenticatorDocument extends Document {
credentialID: Buffer;
credentialPublicKey: Buffer;
counter: number;
// ['usb' | 'ble' | 'nfc' | 'internal']
transports?: AuthenticatorTransport[];
}

const authenticatorSchema = new Schema({
export const authenticatorSchema = new Schema({
credentialID: { type: Buffer, required: true },
credentialPublicKey: { type: Buffer, required: true },
counter: { type: Number, required: true },
// ['usb' | 'ble' | 'nfc' | 'internal']
transports: Array,
});

export const Authenticator: Model<IAuthenticatorDocument> = model<IAuthenticatorDocument>('Authenticator', authenticatorSchema);
119 changes: 59 additions & 60 deletions packages/backend/src/controllers/administrator/index.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,46 @@
import { Request, Response, NextFunction } from 'express';
import { object, string } from '@hapi/joi';
import { loggerFile } from '../../configuration/logger';
import { Administrator } from './administrator.schema';
import { Administrator, administratorUsernameValidationSchema } from './administrator.schema';
import { ApiError, ApiSuccess } from '../../utils/api';
import { RegistrationToken } from '../authentication/registrationToken.schema';
import { config } from '../../configuration/environment';
import { object } from '@hapi/joi';

const addAdministratorRequestSchema = object({
username: string().required().regex(/^[\w\s]+#\d{4}$/),
userid: string().required().regex(/^\d{18}$/),
});
/****************************************
* User input validation *
* **************************************/
const adminAdministratorDeleteRequestSchema = object({
username: administratorUsernameValidationSchema,
}).unknown();

const deleteAdministratorRequestSchema = string().required().regex(/^\d{18}$/);

/****************************************
* Endpoint Handlers *
* **************************************/

/**
* Handles POST /api/settings/administrator requests
* and creates a new Administrator if possible
* POST /settings/administrator
* Creates and returns a new registration token
*
* @param req Request
* @param res Response
* @param next NextFunction
*/
export async function addAdministratorRequest(req: Request, res: Response, next: NextFunction) {
export async function adminAdministratorPostRequest(req: Request, res: Response, next: NextFunction) {

try {
const administratorRequest = addAdministratorRequestSchema.validate(req.body);
if (administratorRequest.error) throw new ApiError(400, administratorRequest.error.message);

// check if administrator exists at the database
const administrator = await Administrator.findOne({ $or: [
{ userName: administratorRequest.value.username },
{ userId: administratorRequest.value.userid }
] });

// throw error, if admin name or id is already being used
if (administrator) {
if (administratorRequest.value.username === administrator.userName)
throw new ApiError(400, `Administrator ${administrator.userName} already exists`);
if (administratorRequest.value.userid === administrator.userId)
throw new ApiError(400, `Administrator with ID ${administrator.userId} already exists`);
}

// create new administrator
const adminObj = {
userId: administratorRequest.value.userid,
userName: administratorRequest.value.username,
};
// 1. Create a new registration token
const registrationToken = await new RegistrationToken({ userIsDeletable: true }).save();
if (registrationToken === null && registrationToken === undefined) throw new ApiError(500, 'Unable to create registration token');

await new Administrator(adminObj).save();
const responseBody = {
token: registrationToken.key,
origin: config.rp.origin,
lifetime: config.registrationTokenLifetime,
};

const response = new ApiSuccess(201);
const response = new ApiSuccess(201, responseBody);
next(response);

} catch (err) {
Expand All @@ -56,26 +50,27 @@ export async function addAdministratorRequest(req: Request, res: Response, next:
}

/**
* Handles GET /api/settings/administrator requests
* and responds with a list of all administrators.
* GET /api/settings/administrator
*
* Responds a list of all administrators.
* @param req Request
* @param res Response
* @param next NextFunction
*/
export async function getAdministratorListRequest(req: Request, res: Response, next: NextFunction) {
export async function adminAdministratorGetRequest(req: Request, res: Response, next: NextFunction) {

try {
// Get administrators from database
// 1. Get administrators from database
const administrators = await Administrator.find({});
if (!administrators) throw new ApiError(503, 'Internal error while retrieving administrators');
if (!administrators) throw new ApiError(500, 'Internal error while retrieving administrators');

// Extract relevant details
// 2. Extract relevant details
const administratorList = administrators.map(model => {
return {
userName: model.get('userName'),
userId: model.get('userId'),
username: model.get('username'),
createdAt: new Date(model.get('createdAt')).getTime(),
deletable: model.get('deletable'),
hasDevice: !!model.get('device'),
};
});

Expand All @@ -89,37 +84,41 @@ export async function getAdministratorListRequest(req: Request, res: Response, n
}

/**
* Handles DELETE /api/settings/administrator requests
* and deletes an administrator specified by user id from the database
* DELETE /settings/administrator/{username}
*
* Deletes an administrator specified by user id from the database
* @param req Request
* @param res Response
* @param next NextFunction
*/
export async function deleteAdministratorRequest(req: Request, res: Response, next: NextFunction) {
export async function adminAdministratorDeleteRequest(req: Request, res: Response, next: NextFunction) {

try {
const administratorRequest = deleteAdministratorRequestSchema.validate(req.params.id);
if (administratorRequest.error) throw new ApiError(400, administratorRequest.error.message);

// Delete administrator from database
const administrator = await Administrator.findOne({
userId: administratorRequest.value
});
// 1. Validate user input
const administratorDeleteRequest = adminAdministratorDeleteRequestSchema.validate(req.params);
if (administratorDeleteRequest.error) throw new ApiError(400, administratorDeleteRequest.error.message);

// Throw error, if admin user id is not in database
if (!administrator) {
throw new ApiError(404, `Administrator with id ${administratorRequest.value} not found in database`);
// 2. Get requested admin from database
const administrator = await Administrator.findOne({ username: administratorDeleteRequest.value.username });
if (administrator === null || administrator === undefined) {
throw new ApiError(404, `Administrator ${administratorDeleteRequest.value.username} not found`);
}

// Throw error, if admin is not deletable
if (!administrator.deletable) {
throw new ApiError(403, `Administrator with id ${administratorRequest.value} is not deletable`);
}
// 3. Validate that admin is deletable and throw error if not
if (!administrator.deletable) throw new ApiError(403, `Administrator ${administratorDeleteRequest.value.username} is not deletable`);

administrator.deleteOne();
try {
// 4. Delete admin
await administrator.deleteOne();

const response = new ApiSuccess(204);
next(response);
} catch (error) {
loggerFile.error(error.message);
throw new ApiError(500, 'Failed to delete administrator or authenticator');
}

const response = new ApiSuccess(204);
next(response);

} catch (err) {
loggerFile.error(err.message);
Expand Down
42 changes: 15 additions & 27 deletions packages/backend/src/controllers/authentication/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,43 +3,37 @@
*/

import { Request, Response, NextFunction } from 'express';
import { object, string, CustomHelpers } from '@hapi/joi';
import { Administrator, IAdministratorDocument } from '../administrator/administrator.schema';
import { object } from '@hapi/joi';
import { Administrator, administratorUsernameValidationSchema, IAdministratorDocument } from '../administrator/administrator.schema';
import { loggerFile } from '../../configuration/logger';
import jwt from 'jsonwebtoken';
import expressjwt from 'express-jwt';
import { config } from '../../configuration/environment';
import { ApiError, ApiSuccess } from '../../utils/api';
import { generateAssertionOptions, generateAttestationOptions, GenerateAttestationOptionsOpts, verifyAssertionResponse, verifyAttestationResponse } from '@simplewebauthn/server';
import { validate as validateUUID } from 'uuid';
import { RegistrationToken } from './registrationToken.schema';
import { Authenticator, IAuthenticatorDocument } from './authenticator.schema';
import { RegistrationToken, registrationTokenValidationSchema } from './registrationToken.schema';
import { IAuthenticatorDocument } from '../administrator/authenticator.schema';

/****************************************
* User input validation *
* **************************************/
const isUUID = (value: any, helper: CustomHelpers) : any => {
if (!validateUUID(value)) return helper.error('any.invalid');
return value;
};

const authAssertionGetRequestSchema = object({
username: string().alphanum().required().min(8).max(64).description('Username of admin to register'),
username: administratorUsernameValidationSchema,
});

const authAssertionPostRequestSchema = object({
username: string().alphanum().required().min(8).max(64).description('Username of admin to register'),
username: administratorUsernameValidationSchema,
assertionResponse: object().unknown().required().description('Webauthn challenge')
});

const authAttestationGetRequestSchema = object({
username: string().alphanum().required().min(8).max(64).description('Username of admin to register'),
token: string().required().custom(isUUID).description('Registration token'),
username: administratorUsernameValidationSchema,
token: registrationTokenValidationSchema,
});

const authAttestationPostRequestSchema = object({
username: string().alphanum().required().min(8).max(64).description('Username of admin to register'),
token: string().required().custom(isUUID).description('Registration token'),
username: administratorUsernameValidationSchema,
token: registrationTokenValidationSchema,
attestationResponse: object().unknown().required().description('Webauthn challenge')
});

Expand Down Expand Up @@ -129,7 +123,7 @@ export async function authAttestationGetRequest(req: Request, res: Response, nex

// 2. Validate registration token
const registrationTokenDoc = await RegistrationToken.findOne({ 'key': attestationGetRequest.value.token });
if (registrationTokenDoc === null) throw new ApiError(404, 'Registration token not found');
if (registrationTokenDoc === null) throw new ApiError(404, 'Registration token not found or expired');

// 3. Get user document; create if it does not exist
const userDoc = await Administrator.findOneAndUpdate({ username: attestationGetRequest.value.username }, {}, { upsert: true, new: true });
Expand Down Expand Up @@ -198,7 +192,7 @@ export async function authAttestationGetRequest(req: Request, res: Response, nex

// 2. Validate registration token
const registrationTokenDoc = await RegistrationToken.findOne({ 'key': attestationPostRequest.value.token });
if (registrationTokenDoc === null) throw new ApiError(404, 'Registration token not found');
if (registrationTokenDoc === null) throw new ApiError(404, 'Registration token not found or expired');

// 3. Get user document
const userDoc = await Administrator.findOne({ username: attestationPostRequest.value.username });
Expand All @@ -220,13 +214,7 @@ export async function authAttestationGetRequest(req: Request, res: Response, nex

// 5. Save authenticator
const { credentialPublicKey, credentialID, counter } = attestationInfo;
const authenticator = await new Authenticator({
credentialID,
credentialPublicKey,
counter
}).save();

userDoc.device = authenticator;
userDoc.device = { credentialID, credentialPublicKey, counter } as IAuthenticatorDocument;

// 6. Save whether user is deletable or not
userDoc.deletable = registrationTokenDoc.userIsDeletable;
Expand Down Expand Up @@ -269,7 +257,7 @@ export async function authAttestationGetRequest(req: Request, res: Response, nex
if (assertionGetRequest.error) throw new ApiError(400, assertionGetRequest.error.message);

// 2. Get user document
const userDoc = await Administrator.findOne({ username: assertionGetRequest.value.username }).populate('device');
const userDoc = await Administrator.findOne({ username: assertionGetRequest.value.username });
if (userDoc === null) throw new ApiError(404, 'User not found');
if (userDoc.device === null || userDoc.device === undefined) throw new ApiError(403, 'User not registered');

Expand Down Expand Up @@ -313,7 +301,7 @@ export async function authAttestationGetRequest(req: Request, res: Response, nex
if (assertionPostRequest.error) throw new ApiError(400, assertionPostRequest.error.message);

// 2. Get user document
const userDoc: IAdministratorDocument = await Administrator.findOne({ username: assertionPostRequest.value.username }).populate('device');
const userDoc: IAdministratorDocument = await Administrator.findOne({ username: assertionPostRequest.value.username });
if (userDoc === null) throw new ApiError(404, 'User not found');
if (userDoc.device === null || userDoc.device === undefined) throw new ApiError(403, 'User not registered');
if (userDoc.currentChallenge === undefined || userDoc.currentChallenge === null) throw new ApiError(400, 'User has no pending challenge');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
/* tslint:disable:ban-types */
import { config } from '../../configuration/environment';
import { Schema, model, Model, Document } from 'mongoose';
import { v4 as uuidv4 } from 'uuid';
import { v4 as uuidv4, validate as validateUUID } from 'uuid';
import { string, CustomHelpers } from '@hapi/joi';

interface IRegistrationTokenDocument extends Document {
[_id: string]: any;
Expand All @@ -21,3 +22,10 @@ const registrationTokenSchema = new Schema({
});

export const RegistrationToken: Model<IRegistrationTokenDocument> = model<IRegistrationTokenDocument>('RegistrationToken', registrationTokenSchema);

const isUUID = (value: any, helper: CustomHelpers) : any => {
if (!validateUUID(value)) return helper.error('any.invalid');
return value;
};

export const registrationTokenValidationSchema = string().required().custom(isUUID).description('Registration token');
8 changes: 4 additions & 4 deletions packages/backend/src/routes/settings.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Router } from 'express';
import { setRefreshRateRequest, getRefreshRateRequest } from '../controllers/moodle/refreshRate';
import { getCourseListRequest, setCourseRequest } from '../controllers/courseList/courseList';
import { setDiscordChannelRequest, getDiscordChannelRequest } from '../controllers/discordChannel/discordChannel';
// import { addAdministratorRequest, getAdministratorListRequest, deleteAdministratorRequest } from '../controllers/administrator';
import { adminAdministratorPostRequest, adminAdministratorGetRequest,adminAdministratorDeleteRequest } from '../controllers/administrator';
import { getStatusRequest } from '../controllers/status/status';
export const settingsRoutes = Router();

Expand All @@ -13,7 +13,7 @@ settingsRoutes.get('/courses', getCourseListRequest);
settingsRoutes.put('/courses/:id', setCourseRequest);
settingsRoutes.get('/discordChannel', getDiscordChannelRequest);
settingsRoutes.put('/discordChannel', setDiscordChannelRequest);
// settingsRoutes.post('/administrator', addAdministratorRequest);
// settingsRoutes.get('/administrator', getAdministratorListRequest);
// settingsRoutes.delete('/administrator/:id', deleteAdministratorRequest);
settingsRoutes.post('/administrators', adminAdministratorPostRequest);
settingsRoutes.get('/administrators', adminAdministratorGetRequest);
settingsRoutes.delete('/administrators/:username', adminAdministratorDeleteRequest);
settingsRoutes.get('/status', getStatusRequest);
Loading

0 comments on commit 953668e

Please sign in to comment.