2 커밋 d57675cc49 ... ec21e5ae2e

작성자 SHA1 메시지 날짜
  Owen Diffey ec21e5ae2e feat: Implement login functionality 2 주 전
  Owen Diffey 03d542ff5a feat: Implement logout functionality 2 주 전

+ 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",

+ 10 - 0
backend/src/modules/DataModule/models/Session/events/SessionDeletedEvent.ts

@@ -0,0 +1,10 @@
+import Session from "@models/Session";
+import User from "@models/User";
+import ModelDeletedEvent from "@/modules/DataModule/ModelDeletedEvent";
+
+export default abstract class SessionDeletedEvent extends ModelDeletedEvent {
+	protected static _model = Session;
+
+	protected static _hasModelPermission = (model?: Session, user?: User) =>
+		model && user && model.userId === user._id;
+}

+ 9 - 1
backend/src/modules/DataModule/models/User.ts

@@ -332,7 +332,15 @@ export const setup = async () => {
 
 	// User.afterSave(async record => {});
 
-	// User.afterDestroy(async record => {});
+	User.addHook("beforeDestroy", async (record: User, options) => {
+		await Session.destroy({
+			where: {
+				userId: user._id
+			},
+			individualHooks: true,
+			transaction: options.transaction
+		});
+	});
 };
 
 export default User;

+ 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
+		};
+	}
+}

+ 13 - 0
backend/src/modules/DataModule/models/User/jobs/Logout.ts

@@ -0,0 +1,13 @@
+import User from "@models/User";
+import DataModuleJob from "@/modules/DataModule/DataModuleJob";
+import isLoggedIn from "@/modules/DataModule/permissions/isLoggedIn";
+
+export default class Logout extends DataModuleJob {
+	protected static _model = User;
+
+	protected static _hasPermission = isLoggedIn;
+
+	protected async _execute() {
+		await this._context.getSession()!.destroy();
+	}
+}

+ 21 - 0
backend/src/modules/DataModule/models/User/jobs/LogoutAll.ts

@@ -0,0 +1,21 @@
+import User from "@models/User";
+import Session from "@models/Session";
+import DataModuleJob from "@/modules/DataModule/DataModuleJob";
+import isLoggedIn from "@/modules/DataModule/permissions/isLoggedIn";
+
+export default class LogoutAll extends DataModuleJob {
+	protected static _model = User;
+
+	protected static _hasPermission = isLoggedIn;
+
+	protected async _execute() {
+		const user = await this._context.getUser();
+
+		await Session.destroy({
+			where: {
+				userId: user._id
+			},
+			individualHooks: true
+		});
+	}
+}

+ 1 - 1
backend/src/modules/DataModule/permissions/isLoggedIn.ts

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

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

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

+ 21 - 1
backend/src/modules/WebSocketModule.ts

@@ -72,6 +72,26 @@ export class WebSocketModule extends BaseModule {
 			this.dispatch(socketId, "jobCallback", callbackRef, data);
 		});
 
+		await EventsModule.pSubscribe(
+			"data.sessions.deleted:*",
+			async event => {
+				// assertEventDerived(event);
+				const { oldDoc } = event.getData();
+
+				for (const clients of this._wsServer!.clients.entries() as IterableIterator<
+					[WebSocket, WebSocket]
+				>) {
+					const socket = clients.find(
+						socket => socket.getSessionId() === oldDoc._id
+					);
+
+					if (!socket) continue;
+
+					socket.close(1000, "logout");
+				}
+			}
+		);
+
 		await super._started();
 	}
 
@@ -300,7 +320,7 @@ export class WebSocketModule extends BaseModule {
 	}
 
 	/**
-	 * getSocket - Get websocket client by id
+	 * getSocketById - Get websocket client by id
 	 */
 	public async getSocketById(socketId: string) {
 		if (!this._wsServer) return null;

+ 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"
 								}}

+ 6 - 4
frontend/src/pages/Settings/Tabs/Security.vue

@@ -4,6 +4,7 @@ import Toast from "toasters";
 import { storeToRefs } from "pinia";
 import { useConfigStore } from "@/stores/config";
 import { useSettingsStore } from "@/stores/settings";
+import { useWebsocketStore } from "@/stores/websocket";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useUserAuthStore } from "@/stores/userAuth";
 import _validation from "@/validation";
@@ -19,6 +20,7 @@ const configStore = useConfigStore();
 const { githubAuthentication, sitename } = storeToRefs(configStore);
 const settingsStore = useSettingsStore();
 const userAuthStore = useUserAuthStore();
+const { runJob } = useWebsocketStore();
 
 const { socket } = useWebsocketsStore();
 
@@ -90,10 +92,10 @@ const unlinkGitHub = () => {
 		new Toast(res.message);
 	});
 };
-const removeSessions = () => {
-	socket.dispatch(`users.removeSessions`, currentUser.value?._id, res => {
-		new Toast(res.message);
-	});
+const removeSessions = async () => {
+	await runJob("data.users.logoutAll");
+
+	new Toast("Successfully logged out of all sessions");
 };
 
 watch(validation, newValidation => {

+ 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 };
 		}
 	}
 });

+ 10 - 33
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 domain = "";
-		if (configStore.urls.host !== "localhost")
-			domain = ` domain=${configStore.urls.host};`;
+		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=/;";
 
-		document.cookie = `${configStore.cookie}=${
-			data.SID
-		}; expires=${date.toUTCString()}; ${domain}${secure}path=/`;
+		document.cookie = cookie;
 
 		const loginBroadcastChannel = new BroadcastChannel(
 			`${configStore.cookie}.user_login`
@@ -129,11 +110,7 @@ export const useUserAuthStore = defineStore("userAuth", () => {
 	};
 
 	const logout = async () => {
-		await websocketStore.runJob("users.logout", {});
-
-		document.cookie = `${configStore.cookie}=;expires=Thu, 01 Jan 1970 00:00:01 GMT;`;
-
-		window.location.reload();
+		await websocketStore.runJob("data.users.logout"); // TODO: Deal with socket closing before callback received
 	};
 
 	const mapUserId = (data: {

+ 9 - 1
frontend/src/stores/websocket.ts

@@ -244,9 +244,17 @@ export const useWebsocketStore = defineStore("websocket", () => {
 		);
 	};
 
-	const onClose = () => {
+	const onClose = (event: CloseEvent) => {
 		ready.value = false;
 
+		if (event.reason === "logout") {
+			document.cookie = `${configStore.cookie}=;expires=Thu, 01 Jan 1970 00:00:01 GMT;`;
+
+			window.location.reload();
+
+			return;
+		}
+
 		// try to reconnect every 1000ms, if the user isn't banned
 		// eslint-disable-next-line no-use-before-define
 		if (!userAuthStore.banned) setTimeout(init, 1000);