summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--README.md1
-rw-r--r--plan.md3
-rw-r--r--src/api/middlewares/authMiddleware.js25
-rw-r--r--src/api/middlewares/index.js2
-rw-r--r--src/api/routes.js26
-rw-r--r--src/api/routes/auth/accountRoutes.js36
-rw-r--r--src/api/routes/auth/deviceRoutes.js36
-rw-r--r--src/api/routes/auth/index.js2
-rw-r--r--src/api/routes/auth/registerRoute.js20
-rw-r--r--src/db/db.js7
-rw-r--r--src/db/dbAccess/user.js83
-rw-r--r--src/db/schemas/user.js22
-rw-r--r--src/dto/auth/AuthDto.js (renamed from src/dto/auth/LoginDto.js)20
-rw-r--r--src/dto/auth/DeviceDto.js36
-rw-r--r--src/dto/auth/WhoAmIDto.js26
-rw-r--r--src/dto/auth/index.js2
-rw-r--r--src/util/error.js3
-rw-r--r--testFrontend/SafeNSound.Frontend/Pages/Auth.razor57
-rw-r--r--testFrontend/SafeNSound.Sdk/SafeNSoundAuthentication.cs35
19 files changed, 373 insertions, 69 deletions
diff --git a/README.md b/README.md

index a3069c3..b41cf3c 100644 --- a/README.md +++ b/README.md
@@ -13,4 +13,5 @@ Environment variables: | `PORT` | `3000` | The port the server will run on. | | `LOG_REQUESTS` | `-` | Requests to log to the console by status, `-` to invert. | | `DATABASE_SECRET_PATH` | `` | The path to the mongodb connection string. | +| `LOG_QUERIES` | `` | Whether to enable mongoose debug logs | | `JWT_SECRET_PATH` | `` | The path to the JWT secret certificate. | \ No newline at end of file diff --git a/plan.md b/plan.md
index 5f9b7c4..39ccb9b 100644 --- a/plan.md +++ b/plan.md
@@ -14,11 +14,12 @@ # Feature plan - [ ] User management (user/monitor) - - [ ] Registration + - [x] Registration - [ ] Validation based on type - [ ] Login - [ ] Password reset - [ ] User profile management + - [ ] Device management - [ ] Organisation (who's coming? announcement of events, ...) - [ ] Budgeting with tracking - [ ] Review spending diff --git a/src/api/middlewares/authMiddleware.js b/src/api/middlewares/authMiddleware.js new file mode 100644
index 0000000..4cdbb51 --- /dev/null +++ b/src/api/middlewares/authMiddleware.js
@@ -0,0 +1,25 @@ +import { validateJwtToken } from '#util/jwtUtils.js'; +import { DbUser } from '#db/schemas/index.js'; + +/** + * @param options {AuthValidationOptions} + * @returns {(function(*, *, *): void)|*} + */ +export function validateAuth(options) { + return async function (req, res, next) { + var auth = validateJwtToken(req.headers.authorization); + if (!auth) { + res.status(401).send('Unauthorized'); + return; + } + + req.user = await DbUser.findById(auth.id).exec(); + + req.auth = auth; + req = next(); + }; +} + +class AuthValidationOptions { + roles; +} diff --git a/src/api/middlewares/index.js b/src/api/middlewares/index.js
index 1894f1a..f712465 100644 --- a/src/api/middlewares/index.js +++ b/src/api/middlewares/index.js
@@ -1,2 +1,4 @@ export * from './corsMiddleware.js'; export * from './loggingMiddleware.js'; +export * from './errorMiddleware.js'; +export * from './authMiddleware.js'; diff --git a/src/api/routes.js b/src/api/routes.js
index 73d954e..0da8be9 100644 --- a/src/api/routes.js +++ b/src/api/routes.js
@@ -2,6 +2,7 @@ import * as routes from './routes/index.js'; export function registerRoutes(app) { // app.get("/status", routes.statusRoute); + let routeCount = 0; Object.values(routes).forEach(route => { console.log('Registering route:', route); if (!route.route) @@ -10,9 +11,26 @@ export function registerRoutes(app) { JSON.stringify(route) ); - if (route.onGet) app.get(route.route, route.onGet); - if (route.onPost) app.post(route.route, route.onPost); - if (route.onPut) app.put(route.route, route.onPut); - if (route.onDelete) app.put(route.route, route.onDelete); + if (route.onGet) { + app.get(route.route, route.onGet); + routeCount++; + } + if (route.onPost) { + app.post(route.route, route.onPost); + routeCount++; + } + if (route.onPut) { + app.put(route.route, route.onPut); + routeCount++; + } + if (route.onDelete) { + app.put(route.route, route.onDelete); + routeCount++; + } + if (route.onPatch) { + app.patch(route.route, route.onPatch); + routeCount++; + } }); + console.log(`Registered ${routeCount} routes.`); } diff --git a/src/api/routes/auth/accountRoutes.js b/src/api/routes/auth/accountRoutes.js new file mode 100644
index 0000000..6655ecb --- /dev/null +++ b/src/api/routes/auth/accountRoutes.js
@@ -0,0 +1,36 @@ +import { deleteUser, loginUser, registerUser } from '#db/index.js'; +import { AuthDto, RegisterDto } from '#dto/index.js'; + +export const registerRoute = { + route: '/auth/register', + async onPost(req, res) { + const data = await RegisterDto.create(req.body); + const registerResult = await registerUser(data); + res.send(registerResult); + } +}; + +export const loginRoute = { + route: '/auth/login', + /** + * + * @param req {Request} + * @param res + * @returns {Promise<WhoAmIDto>} + */ + async onPost(req, res) { + const data = await AuthDto.create(req.body); + console.log(req.headers['user-agent']); + const loginResult = await loginUser(data, req.headers['user-agent']); + res.send(loginResult); + } +}; + +export const deleteRoute = { + route: '/auth/delete', + async onDelete(req, res) { + const data = await AuthDto.create(req.body); + await deleteUser(data); + res.status(204).send(); + } +}; diff --git a/src/api/routes/auth/deviceRoutes.js b/src/api/routes/auth/deviceRoutes.js new file mode 100644
index 0000000..6655ecb --- /dev/null +++ b/src/api/routes/auth/deviceRoutes.js
@@ -0,0 +1,36 @@ +import { deleteUser, loginUser, registerUser } from '#db/index.js'; +import { AuthDto, RegisterDto } from '#dto/index.js'; + +export const registerRoute = { + route: '/auth/register', + async onPost(req, res) { + const data = await RegisterDto.create(req.body); + const registerResult = await registerUser(data); + res.send(registerResult); + } +}; + +export const loginRoute = { + route: '/auth/login', + /** + * + * @param req {Request} + * @param res + * @returns {Promise<WhoAmIDto>} + */ + async onPost(req, res) { + const data = await AuthDto.create(req.body); + console.log(req.headers['user-agent']); + const loginResult = await loginUser(data, req.headers['user-agent']); + res.send(loginResult); + } +}; + +export const deleteRoute = { + route: '/auth/delete', + async onDelete(req, res) { + const data = await AuthDto.create(req.body); + await deleteUser(data); + res.status(204).send(); + } +}; diff --git a/src/api/routes/auth/index.js b/src/api/routes/auth/index.js
index 7113a17..29a07ad 100644 --- a/src/api/routes/auth/index.js +++ b/src/api/routes/auth/index.js
@@ -1 +1 @@ -export * from './registerRoute.js'; +export * from './accountRoutes.js'; diff --git a/src/api/routes/auth/registerRoute.js b/src/api/routes/auth/registerRoute.js deleted file mode 100644
index 87762d3..0000000 --- a/src/api/routes/auth/registerRoute.js +++ /dev/null
@@ -1,20 +0,0 @@ -import { registerUser } from '#db/index.js'; -import { LoginDto, RegisterDto } from '#dto/index.js'; - -export const registerRoute = { - route: '/auth/register', - async onPost(req, res) { - const data = await RegisterDto.create(req.body); - await registerUser(data); - res.send(data); - } -}; - -export const loginRoute = { - route: '/auth/login', - async onPost(req, res) { - const data = await LoginDto.create(req.body); - await registerUser(data); - res.send(data); - } -}; diff --git a/src/db/db.js b/src/db/db.js
index b9a425c..2035731 100644 --- a/src/db/db.js +++ b/src/db/db.js
@@ -1,11 +1,14 @@ -import { connect } from 'mongoose'; +import mongoose, { connect } from 'mongoose'; import { readSecret } from '#util/secretUtils.js'; export async function initDb() { const connectionString = await readSecret( - "MongoDB connection string", + 'MongoDB connection string', process.env['DATABASE_SECRET_PATH'] ); + + if (process.env['LOG_QUERIES']) mongoose.set('debug', true); + try { const res = await connect(connectionString); if (res.connection.readyState === 1) { diff --git a/src/db/dbAccess/user.js b/src/db/dbAccess/user.js
index 6301cb5..a461f3e 100644 --- a/src/db/dbAccess/user.js +++ b/src/db/dbAccess/user.js
@@ -1,16 +1,53 @@ -import { hash, compare } from 'bcrypt'; -import { DbUser } from '#db/schemas/index.js'; -import { RegisterDto } from '#dto/auth/index.js'; +import { hash, compare, genSalt } from 'bcrypt'; +import { DbUser, deviceSchema } from '#db/schemas/index.js'; +import { AuthDto, RegisterDto } from '#dto/index.js'; +import { SafeNSoundError } from '#util/error.js'; +import { WhoAmIDto } from '#dto/auth/WhoAmIDto.js'; + +async function whoAmI(token) {} + +async function getUserByAuth(data) { + if (!(data instanceof AuthDto)) + throw new Error('Invalid data type. Expected AuthDto.'); + + let user; + + if (data.email) { + user = await DbUser.findOne({ email: data.email }); + } else if (data.username) { + user = await DbUser.findOne({ username: data.username }); + } + + console.log('user', user); + if (!user) { + // Sneaky: prevent user enumeration + throw new SafeNSoundError({ + errCode: 'INVALID_AUTH', + message: 'Invalid username or password.' + }); + } + + const isPasswordValid = await compare(data.password, user.passwordHash); + if (!isPasswordValid) { + throw new SafeNSoundError({ + errCode: 'INVALID_AUTH', + message: 'Invalid username or password.' + }); + } + + return user; +} /** * @param data {RegisterDto} - * @returns {Promise<(Error | HydratedDocument<InferSchemaType<module:mongoose.Schema>, ObtainSchemaGeneric<module:mongoose.Schema, "TVirtuals"> & ObtainSchemaGeneric<module:mongoose.Schema, "TInstanceMethods">, ObtainSchemaGeneric<module:mongoose.Schema, "TQueryHelpers">, ObtainSchemaGeneric<module:mongoose.Schema, "TVirtuals">>)[]>} + * @returns {Promise<DbUser>} */ export async function registerUser(data) { if (!(data instanceof RegisterDto)) throw new Error('Invalid data type. Expected RegisterDto.'); - const passwordHash = await hash(data.password, 10); + const salt = await genSalt(12); + const passwordHash = await hash(data.password, salt); if (!passwordHash) { throw new Error('Failed to hash password.'); } @@ -23,21 +60,29 @@ export async function registerUser(data) { }); } -export async function deleteUser(id, password) { - const user = await DbUser.findById(id); - DbUser.exists({ _id: id }).then(exists => { - if (!exists) { - throw new Error('User does not exist.'); - } +export async function deleteUser(data) { + var user = await getUserByAuth(data); + + await DbUser.findByIdAndDelete(data._id); +} + +/** + * @param data {AuthDto} + * @param deviceName {string} + * @returns {Promise<WhoAmIDto>} + */ +export async function loginUser(data, deviceName) { + const user = await getUserByAuth(data); + const device = await user.devices.create({ + name: deviceName }); - if (!user) { - throw new Error('User not found.'); - } - const isPasswordValid = await compare(password, user.passwordHash); - if (!isPasswordValid) { - throw new Error('Invalid password.'); - } + user.devices.push(device); + await user.save(); - await DbUser.findByIdAndDelete(id); + return WhoAmIDto.create({ + userId: user._id, + username: user.username, + deviceId: device._id + }); } diff --git a/src/db/schemas/user.js b/src/db/schemas/user.js
index 9d08680..f490966 100644 --- a/src/db/schemas/user.js +++ b/src/db/schemas/user.js
@@ -1,6 +1,24 @@ import { model, Schema } from 'mongoose'; import { hash, compare } from 'bcrypt'; +export const deviceSchema = new Schema({ + name: { + type: String, + required: true, + trim: true + }, + createdAt: { + type: Date, + default: Date.now, + immutable: true + }, + lastSeen: { + type: Date, + default: Date.now, + required: true + } +}); + /** * User schema for MongoDB. * @type {module:mongoose.Schema} @@ -31,6 +49,10 @@ export const userSchema = new Schema({ type: Date, default: Date.now, immutable: true + }, + devices: { + type: [deviceSchema], + default: [] } }); diff --git a/src/dto/auth/LoginDto.js b/src/dto/auth/AuthDto.js
index b675b4d..14e09ae 100644 --- a/src/dto/auth/LoginDto.js +++ b/src/dto/auth/AuthDto.js
@@ -1,7 +1,10 @@ import { SafeNSoundError } from '#util/error.js'; import Joi from 'joi'; -export class LoginDto { +/** + * Generic authentication DTO. + */ +export class AuthDto { static schema = new Joi.object({ username: Joi.string().required(), email: Joi.string().email().required(), @@ -9,16 +12,19 @@ export class LoginDto { }).or('username', 'email'); username; + email; password; - async Create({ username, email, password }) { - this.username = username ?? email; - this.password = password; + static async create(data) { + const obj = new AuthDto(); + for (const key of Object.keys(data)) { + if (key in obj) { + obj[key] = data[key]; + } + } try { - return await LoginDto.schema.validateAsync(this, { - abortEarly: true - }); + return await AuthDto.schema.validateAsync(obj); } catch (e) { console.log(e); throw new SafeNSoundError({ diff --git a/src/dto/auth/DeviceDto.js b/src/dto/auth/DeviceDto.js new file mode 100644
index 0000000..40f1959 --- /dev/null +++ b/src/dto/auth/DeviceDto.js
@@ -0,0 +1,36 @@ +import { SafeNSoundError } from '#util/error.js'; +import Joi from 'joi'; + +export class RegisterDto { + static schema = new Joi.object({ + username: Joi.string().required(), + email: Joi.string().email().required(), + password: Joi.string().required(), + type: Joi.string().valid('user', 'monitor', 'admin').required() + }); + + username; + email; + password; + type = 'user'; + + static async create(data) { + const obj = new RegisterDto(); + for (const key of Object.keys(data)) { + if (key in obj) { + obj[key] = data[key]; + } + } + + try { + return await RegisterDto.schema.validateAsync(obj); + } catch (e) { + console.log(e); + throw new SafeNSoundError({ + errCode: 'JOI_VALIDATION_ERROR', + message: e.message, + validation_details: e.details + }); + } + } +} diff --git a/src/dto/auth/WhoAmIDto.js b/src/dto/auth/WhoAmIDto.js new file mode 100644
index 0000000..ae1795a --- /dev/null +++ b/src/dto/auth/WhoAmIDto.js
@@ -0,0 +1,26 @@ +import { SafeNSoundError } from '#util/error.js'; +import Joi from 'joi'; + +/** + * Generic authentication DTO. + */ +export class WhoAmIDto { + userId; + username; + deviceId; + + /** + * @param data {WhoAmIDto} + * @returns {Promise<WhoAmIDto>} + */ + static async create(data) { + const obj = new WhoAmIDto(); + for (const key of Object.keys(data)) { + if (key in obj) { + obj[key] = data[key]; + } + } + + return obj; + } +} diff --git a/src/dto/auth/index.js b/src/dto/auth/index.js
index 6d57d5e..aa1d435 100644 --- a/src/dto/auth/index.js +++ b/src/dto/auth/index.js
@@ -1,2 +1,2 @@ -export * from './LoginDto.js'; +export * from './AuthDto.js'; export * from './RegisterDto.js'; diff --git a/src/util/error.js b/src/util/error.js
index 5fc454a..9816b65 100644 --- a/src/util/error.js +++ b/src/util/error.js
@@ -1,4 +1,7 @@ export class SafeNSoundError extends Error { + /** + * @param options {SafeNSoundError} + */ constructor(options) { super(); if (typeof options === 'string') { diff --git a/testFrontend/SafeNSound.Frontend/Pages/Auth.razor b/testFrontend/SafeNSound.Frontend/Pages/Auth.razor
index 533fc01..6c28bf1 100644 --- a/testFrontend/SafeNSound.Frontend/Pages/Auth.razor +++ b/testFrontend/SafeNSound.Frontend/Pages/Auth.razor
@@ -11,8 +11,10 @@ <FancyTextBox @bind-Value="@Password"/><br/> <span>Type (R): </span> <FancyTextBox @bind-Value="@UserType"/><span> (one of user|monitor|admin)</span><br/> -<LinkButton OnClick="Login">Login</LinkButton> -<LinkButton OnClick="Register">Register</LinkButton> +<LinkButton OnClick="@Randomise">Randomise</LinkButton> +<LinkButton OnClick="@Register">Register</LinkButton> +<LinkButton OnClick="@Login">Login</LinkButton> +<LinkButton OnClick="@Delete">Delete</LinkButton> <br/><br/> @if (Exception != null) { @@ -34,14 +36,22 @@ } @code { - private string Username { get; set; } = ""; - private string Email { get; set; } = ""; - private string Password { get; set; } = ""; + private string Username { get; set; } = String.Empty; + private string Email { get; set; } = String.Empty; + private string Password { get; set; } = String.Empty; private string UserType { get; set; } = ""; private Exception? Exception { get; set; } private object? Result { get; set; } + private async Task Randomise() { + Username = Guid.NewGuid().ToString(); + Email = Guid.NewGuid().ToString() + "@example.com"; + Password = Guid.NewGuid().ToString(); + UserType = Random.Shared.GetItems(["user", "monitor", "admin"], 1)[0]; + StateHasChanged(); + } + private async Task Register() { Result = null; Exception = null; @@ -56,13 +66,42 @@ catch (Exception ex) { Exception = ex; } - finally { - StateHasChanged(); + + StateHasChanged(); + } + + private async Task Login() { + Result = null; + Exception = null; + try { + Result = await Authentication.Login(new() { + Username = Username, + Password = Password, + Email = Email + }); + } + catch (Exception ex) { + Exception = ex; } + + StateHasChanged(); } - private Task Login() { - throw new NotImplementedException(); + private async Task Delete() { + Result = null; + Exception = null; + try { + Result = await Authentication.Delete(new() { + Username = Username, + Password = Password, + Email = Email + }); + } + catch (Exception ex) { + Exception = ex; + } + + StateHasChanged(); } } \ No newline at end of file diff --git a/testFrontend/SafeNSound.Sdk/SafeNSoundAuthentication.cs b/testFrontend/SafeNSound.Sdk/SafeNSoundAuthentication.cs
index 429e93c..cbff880 100644 --- a/testFrontend/SafeNSound.Sdk/SafeNSoundAuthentication.cs +++ b/testFrontend/SafeNSound.Sdk/SafeNSoundAuthentication.cs
@@ -3,11 +3,6 @@ namespace SafeNSound.Sdk; public class SafeNSoundAuthentication(SafeNSoundConfiguration config) { - // public async Task<SafeNSoundAuthResult> Login(string username, string password) - // { - - // } - public async Task<SafeNSoundAuthResult> Register(RegisterDto registerDto) { var hc = new WrappedHttpClient() { BaseAddress = new Uri(config.BaseUri) @@ -16,6 +11,25 @@ public class SafeNSoundAuthentication(SafeNSoundConfiguration config) { var res = await hc.PostAsJsonAsync("/auth/register", registerDto); return null!; } + + public async Task<SafeNSoundAuthResult> Login(AuthDto authDto) { + var hc = new WrappedHttpClient() { + BaseAddress = new Uri(config.BaseUri) + }; + + var res = await hc.PostAsJsonAsync("/auth/login", authDto); + return null!; + } + + public async Task<SafeNSoundAuthResult> Delete(AuthDto authDto) { + var hc = new WrappedHttpClient() { + BaseAddress = new Uri(config.BaseUri) + }; + + var res = await hc.DeleteAsJsonAsync("/auth/delete", authDto); + res.EnsureSuccessStatusCode(); + return null!; + } } public class RegisterDto { @@ -32,4 +46,15 @@ public class RegisterDto { public string UserType { get; set; } = string.Empty; } +public class AuthDto { + [JsonPropertyName("username")] + public string Username { get; set; } = string.Empty; + + [JsonPropertyName("password")] + public string Password { get; set; } = string.Empty; + + [JsonPropertyName("email")] + public string Email { get; set; } = string.Empty; +} + public class SafeNSoundAuthResult { } \ No newline at end of file