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
|