Browse Source

feat: Implement login functionality

Owen Diffey 1 month ago
parent
commit
ec21e5ae2e

+ 38 - 0
backend/package-lock.json

@@ -37,11 +37,13 @@
 			"devDependencies": {
 				"@faker-js/faker": "^8.4.1",
 				"@microsoft/tsdoc": "^0.14.2",
+				"@types/bcrypt": "^5.0.2",
 				"@types/chai": "^4.3.5",
 				"@types/chai-as-promised": "^7.1.8",
 				"@types/config": "^3.3.0",
 				"@types/express": "^4.17.17",
 				"@types/mocha": "^10.0.1",
+				"@types/sha256": "^0.2.2",
 				"@types/sinon": "^10.0.14",
 				"@types/sinon-chai": "^3.2.9",
 				"@types/ws": "^8.5.4",
@@ -568,6 +570,15 @@
 			"resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz",
 			"integrity": "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="
 		},
+		"node_modules/@types/bcrypt": {
+			"version": "5.0.2",
+			"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz",
+			"integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==",
+			"dev": true,
+			"dependencies": {
+				"@types/node": "*"
+			}
+		},
 		"node_modules/@types/body-parser": {
 			"version": "1.19.2",
 			"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz",
@@ -712,6 +723,15 @@
 				"@types/node": "*"
 			}
 		},
+		"node_modules/@types/sha256": {
+			"version": "0.2.2",
+			"resolved": "https://registry.npmjs.org/@types/sha256/-/sha256-0.2.2.tgz",
+			"integrity": "sha512-uKMaDzyzfcDYGEwTgLh+hmgDMxXWyIVodY8T+qt7A+NYvikW0lmGLMGbQ7BipCB8dzXHa55C9g+Ii/3Lgt1KmA==",
+			"dev": true,
+			"dependencies": {
+				"@types/node": "*"
+			}
+		},
 		"node_modules/@types/sinon": {
 			"version": "10.0.14",
 			"resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.14.tgz",
@@ -6616,6 +6636,15 @@
 			"resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz",
 			"integrity": "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA=="
 		},
+		"@types/bcrypt": {
+			"version": "5.0.2",
+			"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz",
+			"integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==",
+			"dev": true,
+			"requires": {
+				"@types/node": "*"
+			}
+		},
 		"@types/body-parser": {
 			"version": "1.19.2",
 			"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz",
@@ -6760,6 +6789,15 @@
 				"@types/node": "*"
 			}
 		},
+		"@types/sha256": {
+			"version": "0.2.2",
+			"resolved": "https://registry.npmjs.org/@types/sha256/-/sha256-0.2.2.tgz",
+			"integrity": "sha512-uKMaDzyzfcDYGEwTgLh+hmgDMxXWyIVodY8T+qt7A+NYvikW0lmGLMGbQ7BipCB8dzXHa55C9g+Ii/3Lgt1KmA==",
+			"dev": true,
+			"requires": {
+				"@types/node": "*"
+			}
+		},
 		"@types/sinon": {
 			"version": "10.0.14",
 			"resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-10.0.14.tgz",

+ 2 - 0
backend/package.json

@@ -45,11 +45,13 @@
 	"devDependencies": {
 		"@faker-js/faker": "^8.4.1",
 		"@microsoft/tsdoc": "^0.14.2",
+		"@types/bcrypt": "^5.0.2",
 		"@types/chai": "^4.3.5",
 		"@types/chai-as-promised": "^7.1.8",
 		"@types/config": "^3.3.0",
 		"@types/express": "^4.17.17",
 		"@types/mocha": "^10.0.1",
+		"@types/sha256": "^0.2.2",
 		"@types/sinon": "^10.0.14",
 		"@types/sinon-chai": "^3.2.9",
 		"@types/ws": "^8.5.4",

+ 51 - 0
backend/src/modules/DataModule/models/User/jobs/Login.ts

@@ -0,0 +1,51 @@
+import Joi from "joi";
+import User from "@models/User";
+import bcrypt from "bcrypt";
+import sha256 from "sha256";
+import isLoggedOut from "@/modules/DataModule/permissions/isLoggedOut";
+import DataModuleJob from "@/modules/DataModule/DataModuleJob";
+
+export default class Login extends DataModuleJob {
+	protected static _model = User;
+
+	protected static _hasPermission = isLoggedOut;
+
+	protected static _payloadSchema = Joi.object({
+		query: Joi.object({
+			identifier: Joi.string().required(),
+			password: Joi.string().required()
+		}).required()
+	});
+
+	protected async _execute() {
+		const { query } = this._payload;
+
+		const where: Record<string, string> = {};
+
+		if (query.identifier.includes("@")) {
+			where.emailAddress = query.identifier;
+		} else {
+			where.username = query.identifier;
+		}
+
+		const user = await User.unscoped().findOne({
+			where
+		});
+
+		if (!user) throw new Error("User not found with provided credentials");
+
+		const isValid = await bcrypt.compare(
+			sha256(query.password),
+			user.password
+		);
+
+		if (!isValid)
+			throw new Error("User not found with provided credentials");
+
+		const session = await user.createSessionModel();
+
+		return {
+			sessionId: session._id
+		};
+	}
+}

+ 3 - 0
backend/src/modules/DataModule/permissions/isLoggedOut.ts

@@ -0,0 +1,3 @@
+import User from "../models/User";
+
+export default (user?: User) => !user;

+ 44 - 26
frontend/src/components/modals/Login.vue

@@ -6,16 +6,18 @@ import { storeToRefs } from "pinia";
 import { useConfigStore } from "@/stores/config";
 import { useUserAuthStore } from "@/stores/userAuth";
 import { useModalsStore } from "@/stores/modals";
+import { useWebsocketStore } from "@/stores/websocket";
+import { useForm } from "@/composables/useForm";
 
 const Modal = defineAsyncComponent(() => import("@/components/Modal.vue"));
 
+const props = defineProps({
+	modalUuid: { type: String, required: true }
+});
+
 const route = useRoute();
 
-const email = ref("");
-const password = ref({
-	value: "",
-	visible: false
-});
+const passwordVisible = ref(false);
 const passwordElement = ref();
 
 const configStore = useConfigStore();
@@ -23,21 +25,37 @@ const { githubAuthentication, registrationDisabled } = storeToRefs(configStore);
 const { login } = useUserAuthStore();
 
 const { openModal, closeCurrentModal } = useModalsStore();
+const { runJob } = useWebsocketStore();
+
+const { inputs, save: submitModal } = useForm(
+	{
+		identifier: "",
+		password: ""
+	},
+	({ status, messages, values }, resolve, reject) => {
+		if (status === "success") {
+			runJob("data.users.login", {
+				query: values
+			})
+				.then(({ sessionId }) => login(sessionId))
+				.then(resolve)
+				.catch(reject);
+		} else {
+			if (status === "unchanged") new Toast(messages.unchanged);
+			else if (status === "error")
+				Object.values(messages).forEach(message => {
+					new Toast({ content: message, timeout: 8000 });
+				});
+			resolve();
+		}
+	},
+	{
+		modalUuid: props.modalUuid,
+		preventCloseUnsaved: false
+	}
+);
 
-const submitModal = () => {
-	if (!email.value || !password.value.value) return;
-
-	login({
-		email: email.value,
-		password: password.value.value
-	})
-		.then((res: any) => {
-			if (res.status === "success") window.location.reload();
-		})
-		.catch(err => new Toast(err.message));
-};
-
-const checkForAutofill = (type, event) => {
+const checkForAutofill = event => {
 	if (
 		event.target.value !== "" &&
 		event.inputType === undefined &&
@@ -51,10 +69,10 @@ const checkForAutofill = (type, event) => {
 const togglePasswordVisibility = () => {
 	if (passwordElement.value.type === "password") {
 		passwordElement.value.type = "text";
-		password.value.visible = true;
+		passwordVisible.value = true;
 	} else {
 		passwordElement.value.type = "password";
-		password.value.visible = false;
+		passwordVisible.value = false;
 	}
 };
 
@@ -82,12 +100,12 @@ const githubRedirect = () => {
 					<p class="control">
 						<label class="label">Username/Email</label>
 						<input
-							v-model="email"
+							v-model="inputs.identifier.value"
 							class="input"
 							type="email"
 							autocomplete="username"
 							placeholder="Username/Email..."
-							@input="checkForAutofill('email', $event)"
+							@input="checkForAutofill"
 							@keyup.enter="submitModal()"
 						/>
 					</p>
@@ -99,19 +117,19 @@ const githubRedirect = () => {
 
 					<div id="password-visibility-container">
 						<input
-							v-model="password.value"
+							v-model="inputs.password.value"
 							class="input"
 							type="password"
 							autocomplete="current-password"
 							ref="passwordElement"
 							placeholder="Password..."
-							@input="checkForAutofill('password', $event)"
+							@input="checkForAutofill"
 							@keyup.enter="submitModal()"
 						/>
 						<a @click="togglePasswordVisibility()">
 							<i class="material-icons">
 								{{
-									!password.visible
+									!passwordVisible
 										? "visibility"
 										: "visibility_off"
 								}}

+ 2 - 2
frontend/src/stores/config.ts

@@ -64,12 +64,12 @@ export const useConfigStore = defineStore("config", {
 	},
 	getters: {
 		urls() {
-			const { protocol, host } = document.location;
+			const { protocol, host, hostname, port } = document.location;
 			const secure = protocol !== "http:";
 			const client = `${protocol}//${host}`;
 			const api = `${client}/backend`;
 			const ws = `${secure ? "wss" : "ws"}://${host}/backend/ws`;
-			return { client, api, ws, host, secure };
+			return { client, api, ws, host, hostname, port, secure };
 		}
 	}
 });

+ 9 - 28
frontend/src/stores/userAuth.ts

@@ -86,40 +86,21 @@ export const useUserAuthStore = defineStore("userAuth", () => {
 
 		if (!data?.SID) throw new Error("You must login");
 
-		const date = new Date();
-		date.setTime(new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000);
-
-		const secure = configStore.urls.secure ? "secure=true; " : "";
-
-		let domain = "";
-		if (configStore.urls.host !== "localhost")
-			domain = ` domain=${configStore.urls.host};`;
-
-		document.cookie = `${configStore.cookie}=${
-			data.SID
-		}; expires=${date.toUTCString()}; ${domain}${secure}path=/`;
+		await login(data.SID);
 	};
 
-	const login = async (user: { email: string; password: string }) => {
-		const { email, password } = user;
-
-		const data = await websocketStore.runJob("users.login", {
-			email,
-			password
-		});
-
+	const login = async (sessionId: string) => {
 		const date = new Date();
 		date.setTime(new Date().getTime() + 2 * 365 * 24 * 60 * 60 * 1000);
 
-		const secure = configStore.urls.secure ? "secure=true; " : "";
+		let cookie = `${configStore.cookie}=${sessionId};`;
+		cookie += `expires=${date.toUTCString()};`;
+		if (configStore.urls.hostname !== "localhost")
+			cookie += `domain=${configStore.urls.hostname};`;
+		if (configStore.urls.secure) cookie += `secure=true;`;
+		cookie += "path=/;";
 
-		let domain = "";
-		if (configStore.urls.host !== "localhost")
-			domain = ` domain=${configStore.urls.host};`;
-
-		document.cookie = `${configStore.cookie}=${
-			data.SID
-		}; expires=${date.toUTCString()}; ${domain}${secure}path=/`;
+		document.cookie = cookie;
 
 		const loginBroadcastChannel = new BroadcastChannel(
 			`${configStore.cookie}.user_login`