From 11f6419c3c0fb53310743b747c1934b0492f6e8c Mon Sep 17 00:00:00 2001 From: Sean Hatfield Date: Thu, 25 Apr 2024 16:52:30 -0700 Subject: [PATCH] [FEAT] Implement new login screen UI & multi-user password reset (#1074) * WIP new login screen UI * update prisma schema/create new models for pw recovery * WIP password recovery backend * WIP reset password flow * WIP pw reset flow * password reset logic complete & functional UI * WIP login screen redesign for single and multi user * create placeholder modal to display recovery codes * implement UI for recovery code modals/download recovery codes * multiuser desktop password reset UI/functionality complete * support single user mode for pw reset * mobile styles for all password reset/login flows complete * lint * remove single user password recovery * create PasswordRecovery util file to make more readable * do not drop-replace users table in migration * review pr --------- Co-authored-by: timothycarambat --- .../Modals/DisplayRecoveryCodeModal/index.jsx | 86 +++++ .../Modals/Password/MultiUserAuth.jsx | 362 +++++++++++++++--- .../Modals/Password/SingleUserAuth.jsx | 127 ++++-- .../src/components/Modals/Password/index.jsx | 34 +- .../illustrations/login-illustration.svg | 174 +++++++++ .../src/media/illustrations/login-logo.svg | 37 ++ frontend/src/models/system.js | 37 ++ frontend/tailwind.config.js | 1 + server/endpoints/system.js | 73 ++++ server/models/passwordRecovery.js | 115 ++++++ .../20240425004220_init/migration.sql | 30 ++ server/prisma/schema.prisma | 24 ++ server/utils/PasswordRecovery/index.js | 98 +++++ server/utils/middleware/multiUserProtected.js | 16 + 14 files changed, 1109 insertions(+), 105 deletions(-) create mode 100644 frontend/src/components/Modals/DisplayRecoveryCodeModal/index.jsx create mode 100644 frontend/src/media/illustrations/login-illustration.svg create mode 100644 frontend/src/media/illustrations/login-logo.svg create mode 100644 server/models/passwordRecovery.js create mode 100644 server/prisma/migrations/20240425004220_init/migration.sql create mode 100644 server/utils/PasswordRecovery/index.js diff --git a/frontend/src/components/Modals/DisplayRecoveryCodeModal/index.jsx b/frontend/src/components/Modals/DisplayRecoveryCodeModal/index.jsx new file mode 100644 index 00000000..a75a3538 --- /dev/null +++ b/frontend/src/components/Modals/DisplayRecoveryCodeModal/index.jsx @@ -0,0 +1,86 @@ +import showToast from "@/utils/toast"; +import { DownloadSimple, Key } from "@phosphor-icons/react"; +import { saveAs } from "file-saver"; +import { useState } from "react"; + +export default function RecoveryCodeModal({ + recoveryCodes, + onDownloadComplete, + onClose, +}) { + const [downloadClicked, setDownloadClicked] = useState(false); + + const downloadRecoveryCodes = () => { + const blob = new Blob([recoveryCodes.join("\n")], { type: "text/plain" }); + saveAs(blob, "recovery_codes.txt"); + setDownloadClicked(true); + }; + + const handleClose = () => { + if (downloadClicked) { + onDownloadComplete(); + onClose(); + } + }; + + const handleCopyToClipboard = () => { + navigator.clipboard.writeText(recoveryCodes.join(",\n")).then(() => { + showToast("Recovery codes copied to clipboard", "success", { + clear: true, + }); + }); + }; + + return ( +
+
+
+ + +
+
+

+ In order to reset your password in the future, you will need these + recovery codes. Download or copy your recovery codes to save them.{" "} +
+ These recovery codes are only shown once! +

+
+
    + {recoveryCodes.map((code, index) => ( +
  • + {code} +
  • + ))} +
+
+
+
+
+ +
+
+ ); +} diff --git a/frontend/src/components/Modals/Password/MultiUserAuth.jsx b/frontend/src/components/Modals/Password/MultiUserAuth.jsx index de086fc0..a44e040c 100644 --- a/frontend/src/components/Modals/Password/MultiUserAuth.jsx +++ b/frontend/src/components/Modals/Password/MultiUserAuth.jsx @@ -1,26 +1,203 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import System from "../../../models/system"; import { AUTH_TOKEN, AUTH_USER } from "../../../utils/constants"; import useLogo from "../../../hooks/useLogo"; import paths from "../../../utils/paths"; +import showToast from "@/utils/toast"; +import ModalWrapper from "@/components/ModalWrapper"; +import { useModal } from "@/hooks/useModal"; +import RecoveryCodeModal from "@/components/Modals/DisplayRecoveryCodeModal"; + +const RecoveryForm = ({ onSubmit, setShowRecoveryForm }) => { + const [username, setUsername] = useState(""); + const [recoveryCodeInputs, setRecoveryCodeInputs] = useState( + Array(2).fill("") + ); + + const handleRecoveryCodeChange = (index, value) => { + const updatedCodes = [...recoveryCodeInputs]; + updatedCodes[index] = value; + setRecoveryCodeInputs(updatedCodes); + }; + + const handleSubmit = (e) => { + e.preventDefault(); + const recoveryCodes = recoveryCodeInputs.filter( + (code) => code.trim() !== "" + ); + onSubmit(username, recoveryCodes); + }; + + return ( +
+
+
+

+ Password Reset +

+

+ Provide the necessary information below to reset your password. +

+
+
+
+
+
+ + setUsername(e.target.value)} + className="bg-zinc-900 text-white placeholder-white/20 text-sm rounded-md p-2.5 w-full h-[48px] md:w-[300px] md:h-[34px]" + required + /> +
+
+ + {recoveryCodeInputs.map((code, index) => ( +
+ + handleRecoveryCodeChange(index, e.target.value) + } + className="bg-zinc-900 text-white placeholder-white/20 text-sm rounded-md p-2.5 w-full h-[48px] md:w-[300px] md:h-[34px]" + required + /> +
+ ))} +
+
+
+
+ + +
+
+ ); +}; + +const ResetPasswordForm = ({ onSubmit }) => { + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + + const handleSubmit = (e) => { + e.preventDefault(); + onSubmit(newPassword, confirmPassword); + }; + + return ( +
+
+
+

+ Reset Password +

+

+ Enter your new password. +

+
+
+
+
+
+ setNewPassword(e.target.value)} + className="bg-zinc-900 text-white placeholder-white/20 text-sm rounded-md p-2.5 w-full h-[48px] md:w-[300px] md:h-[34px]" + required + /> +
+
+ setConfirmPassword(e.target.value)} + className="bg-zinc-900 text-white placeholder-white/20 text-sm rounded-md p-2.5 w-full h-[48px] md:w-[300px] md:h-[34px]" + required + /> +
+
+
+
+ +
+
+ ); +}; export default function MultiUserAuth() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const { logo: _initLogo } = useLogo(); + const [recoveryCodes, setRecoveryCodes] = useState([]); + const [downloadComplete, setDownloadComplete] = useState(false); + const [user, setUser] = useState(null); + const [token, setToken] = useState(null); + const [showRecoveryForm, setShowRecoveryForm] = useState(false); + const [showResetPasswordForm, setShowResetPasswordForm] = useState(false); + + const { + isOpen: isRecoveryCodeModalOpen, + openModal: openRecoveryCodeModal, + closeModal: closeRecoveryCodeModal, + } = useModal(); + const handleLogin = async (e) => { setError(null); setLoading(true); e.preventDefault(); const data = {}; - const form = new FormData(e.target); for (var [key, value] of form.entries()) data[key] = value; - const { valid, user, token, message } = await System.requestToken(data); + const { valid, user, token, message, recoveryCodes } = + await System.requestToken(data); if (valid && !!token && !!user) { - window.localStorage.setItem(AUTH_USER, JSON.stringify(user)); - window.localStorage.setItem(AUTH_TOKEN, token); - window.location = paths.home(); + setUser(user); + setToken(token); + + if (recoveryCodes) { + setRecoveryCodes(recoveryCodes); + openRecoveryCodeModal(); + } else { + window.localStorage.setItem(AUTH_USER, JSON.stringify(user)); + window.localStorage.setItem(AUTH_TOKEN, token); + window.location = paths.home(); + } } else { setError(message); setLoading(false); @@ -28,57 +205,134 @@ export default function MultiUserAuth() { setLoading(false); }; + const handleDownloadComplete = () => setDownloadComplete(true); + const handleResetPassword = () => setShowRecoveryForm(true); + const handleRecoverySubmit = async (username, recoveryCodes) => { + const { success, resetToken, error } = await System.recoverAccount( + username, + recoveryCodes + ); + + if (success && resetToken) { + window.localStorage.setItem("resetToken", resetToken); + setShowRecoveryForm(false); + setShowResetPasswordForm(true); + } else { + showToast(error, "error", { clear: true }); + } + }; + + const handleResetSubmit = async (newPassword, confirmPassword) => { + const resetToken = window.localStorage.getItem("resetToken"); + + if (resetToken) { + const { success, error } = await System.resetPassword( + resetToken, + newPassword, + confirmPassword + ); + + if (success) { + window.localStorage.removeItem("resetToken"); + setShowResetPasswordForm(false); + showToast("Password reset successful", "success", { clear: true }); + } else { + showToast(error, "error", { clear: true }); + } + } else { + showToast("Invalid reset token", "error", { clear: true }); + } + }; + + useEffect(() => { + if (downloadComplete && user && token) { + window.localStorage.setItem(AUTH_USER, JSON.stringify(user)); + window.localStorage.setItem(AUTH_TOKEN, token); + window.location = paths.home(); + } + }, [downloadComplete, user, token]); + + if (showRecoveryForm) { + return ( + + ); + } + + if (showResetPasswordForm) + return ; return ( -
-
-
-
-

- Sign In -

-
-
-
-
-
- -
- -
- -
- - {error && ( -

- Error: {error} + <> + +

+
+
+
+

+ Welcome to +

+

+ AnythingLLM +

+
+

+ Sign in to your AnythingLLM account.

- )} +
+
+
+
+
+ +
+
+ +
+ {error &&

Error: {error}

} +
+
+
+ +
-
- -
-
- + + + + + + ); } diff --git a/frontend/src/components/Modals/Password/SingleUserAuth.jsx b/frontend/src/components/Modals/Password/SingleUserAuth.jsx index 8135f8f9..c1f328ba 100644 --- a/frontend/src/components/Modals/Password/SingleUserAuth.jsx +++ b/frontend/src/components/Modals/Password/SingleUserAuth.jsx @@ -1,25 +1,44 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import System from "../../../models/system"; import { AUTH_TOKEN } from "../../../utils/constants"; import useLogo from "../../../hooks/useLogo"; import paths from "../../../utils/paths"; +import ModalWrapper from "@/components/ModalWrapper"; +import { useModal } from "@/hooks/useModal"; +import RecoveryCodeModal from "@/components/Modals/DisplayRecoveryCodeModal"; export default function SingleUserAuth() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const { logo: _initLogo } = useLogo(); + const [recoveryCodes, setRecoveryCodes] = useState([]); + const [downloadComplete, setDownloadComplete] = useState(false); + const [token, setToken] = useState(null); + + const { + isOpen: isRecoveryCodeModalOpen, + openModal: openRecoveryCodeModal, + closeModal: closeRecoveryCodeModal, + } = useModal(); + const handleLogin = async (e) => { setError(null); setLoading(true); e.preventDefault(); const data = {}; - const form = new FormData(e.target); for (var [key, value] of form.entries()) data[key] = value; - const { valid, token, message } = await System.requestToken(data); + const { valid, token, message, recoveryCodes } = + await System.requestToken(data); if (valid && !!token) { - window.localStorage.setItem(AUTH_TOKEN, token); - window.location = paths.home(); + setToken(token); + if (recoveryCodes) { + setRecoveryCodes(recoveryCodes); + openRecoveryCodeModal(); + } else { + window.localStorage.setItem(AUTH_TOKEN, token); + window.location = paths.home(); + } } else { setError(message); setLoading(false); @@ -27,45 +46,71 @@ export default function SingleUserAuth() { setLoading(false); }; + const handleDownloadComplete = () => { + setDownloadComplete(true); + }; + + useEffect(() => { + if (downloadComplete && token) { + window.localStorage.setItem(AUTH_TOKEN, token); + window.location = paths.home(); + } + }, [downloadComplete, token]); + return ( -
-
-
-
-

- Sign In -

-
-
-
-
-
- -
- {error && ( -

- Error: {error} + <> + +

+
+
+
+

+ Welcome to +

+

+ AnythingLLM +

+
+

+ Sign in to your AnythingLLM instance.

- )} +
+
+
+
+
+ +
+ + {error &&

Error: {error}

} +
+
+
+
-
- -
-
- + + + + + + ); } diff --git a/frontend/src/components/Modals/Password/index.jsx b/frontend/src/components/Modals/Password/index.jsx index d31b80fd..9305d032 100644 --- a/frontend/src/components/Modals/Password/index.jsx +++ b/frontend/src/components/Modals/Password/index.jsx @@ -8,26 +8,40 @@ import { AUTH_TIMESTAMP, } from "../../../utils/constants"; import useLogo from "../../../hooks/useLogo"; +import illustration from "@/media/illustrations/login-illustration.svg"; +import loginLogo from "@/media/illustrations/login-logo.svg"; export default function PasswordModal({ mode = "single" }) { const { logo: _initLogo } = useLogo(); return ( -
+
- -
- logo +
+ login illustration +
+
+ logo {mode === "single" ? : }
diff --git a/frontend/src/media/illustrations/login-illustration.svg b/frontend/src/media/illustrations/login-illustration.svg new file mode 100644 index 00000000..8b8b92ce --- /dev/null +++ b/frontend/src/media/illustrations/login-illustration.svg @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/media/illustrations/login-logo.svg b/frontend/src/media/illustrations/login-logo.svg new file mode 100644 index 00000000..729f847c --- /dev/null +++ b/frontend/src/media/illustrations/login-logo.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/models/system.js b/frontend/src/models/system.js index 89deda75..af532a04 100644 --- a/frontend/src/models/system.js +++ b/frontend/src/models/system.js @@ -77,6 +77,43 @@ const System = { return { valid: false, message: e.message }; }); }, + recoverAccount: async function (username, recoveryCodes) { + return await fetch(`${API_BASE}/system/recover-account`, { + method: "POST", + headers: baseHeaders(), + body: JSON.stringify({ username, recoveryCodes }), + }) + .then(async (res) => { + const data = await res.json(); + if (!res.ok) { + throw new Error(data.message || "Error recovering account."); + } + return data; + }) + .catch((e) => { + console.error(e); + return { success: false, error: e.message }; + }); + }, + resetPassword: async function (token, newPassword, confirmPassword) { + return await fetch(`${API_BASE}/system/reset-password`, { + method: "POST", + headers: baseHeaders(), + body: JSON.stringify({ token, newPassword, confirmPassword }), + }) + .then(async (res) => { + const data = await res.json(); + if (!res.ok) { + throw new Error(data.message || "Error resetting password."); + } + return data; + }) + .catch((e) => { + console.error(e); + return { success: false, error: e.message }; + }); + }, + checkDocumentProcessorOnline: async () => { return await fetch(`${API_BASE}/system/document-processing-status`, { headers: baseHeaders(), diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 8add5a69..b0ac87c9 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -37,6 +37,7 @@ export default { "main-gradient": "linear-gradient(180deg, #3D4147 0%, #2C2F35 100%)", "modal-gradient": "linear-gradient(180deg, #3D4147 0%, #2C2F35 100%)", "sidebar-gradient": "linear-gradient(90deg, #5B616A 0%, #3F434B 100%)", + "login-gradient": "linear-gradient(180deg, #3D4147 0%, #2C2F35 100%)", "menu-item-gradient": "linear-gradient(90deg, #3D4147 0%, #2C2F35 100%)", "menu-item-selected-gradient": diff --git a/server/endpoints/system.js b/server/endpoints/system.js index 761f892f..3ea7fb24 100644 --- a/server/endpoints/system.js +++ b/server/endpoints/system.js @@ -36,6 +36,7 @@ const { WorkspaceChats } = require("../models/workspaceChats"); const { flexUserRoleValid, ROLES, + isMultiUserSetup, } = require("../utils/middleware/multiUserProtected"); const { fetchPfp, determinePfpFilepath } = require("../utils/files/pfp"); const { @@ -44,6 +45,11 @@ const { } = require("../utils/helpers/chat/convertTo"); const { EventLogs } = require("../models/eventLogs"); const { CollectorApi } = require("../utils/collectorApi"); +const { + recoverAccount, + resetPassword, + generateRecoveryCodes, +} = require("../utils/PasswordRecovery"); function systemEndpoints(app) { if (!app) return; @@ -174,6 +180,24 @@ function systemEndpoints(app) { existingUser?.id ); + // Check if the user has seen the recovery codes + if (!existingUser.seen_recovery_codes) { + const plainTextCodes = await generateRecoveryCodes(existingUser.id); + + // Return recovery codes to frontend + response.status(200).json({ + valid: true, + user: existingUser, + token: makeJWT( + { id: existingUser.id, username: existingUser.username }, + "30d" + ), + message: null, + recoveryCodes: plainTextCodes, + }); + return; + } + response.status(200).json({ valid: true, user: existingUser, @@ -221,6 +245,55 @@ function systemEndpoints(app) { } }); + app.post( + "/system/recover-account", + [isMultiUserSetup], + async (request, response) => { + try { + const { username, recoveryCodes } = reqBody(request); + const { success, resetToken, error } = await recoverAccount( + username, + recoveryCodes + ); + + if (success) { + response.status(200).json({ success, resetToken }); + } else { + response.status(400).json({ success, message: error }); + } + } catch (error) { + console.error("Error recovering account:", error); + response + .status(500) + .json({ success: false, message: "Internal server error" }); + } + } + ); + + app.post( + "/system/reset-password", + [isMultiUserSetup], + async (request, response) => { + try { + const { token, newPassword, confirmPassword } = reqBody(request); + const { success, message, error } = await resetPassword( + token, + newPassword, + confirmPassword + ); + + if (success) { + response.status(200).json({ success, message }); + } else { + response.status(400).json({ success, error }); + } + } catch (error) { + console.error("Error resetting password:", error); + response.status(500).json({ success: false, message: error.message }); + } + } + ); + app.get( "/system/system-vectors", [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])], diff --git a/server/models/passwordRecovery.js b/server/models/passwordRecovery.js new file mode 100644 index 00000000..1d09d08b --- /dev/null +++ b/server/models/passwordRecovery.js @@ -0,0 +1,115 @@ +const { v4 } = require("uuid"); +const prisma = require("../utils/prisma"); +const bcrypt = require("bcrypt"); + +const RecoveryCode = { + tablename: "recovery_codes", + writable: [], + create: async function (userId, code) { + try { + const codeHash = await bcrypt.hash(code, 10); + const recoveryCode = await prisma.recovery_codes.create({ + data: { user_id: userId, code_hash: codeHash }, + }); + return { recoveryCode, error: null }; + } catch (error) { + console.error("FAILED TO CREATE RECOVERY CODE.", error.message); + return { recoveryCode: null, error: error.message }; + } + }, + createMany: async function (data) { + try { + const recoveryCodes = await prisma.$transaction( + data.map((recoveryCode) => + prisma.recovery_codes.create({ data: recoveryCode }) + ) + ); + return { recoveryCodes, error: null }; + } catch (error) { + console.error("FAILED TO CREATE RECOVERY CODES.", error.message); + return { recoveryCodes: null, error: error.message }; + } + }, + findFirst: async function (clause = {}) { + try { + const recoveryCode = await prisma.recovery_codes.findFirst({ + where: clause, + }); + return recoveryCode; + } catch (error) { + console.error("FAILED TO FIND RECOVERY CODE.", error.message); + return null; + } + }, + findMany: async function (clause = {}) { + try { + const recoveryCodes = await prisma.recovery_codes.findMany({ + where: clause, + }); + return recoveryCodes; + } catch (error) { + console.error("FAILED TO FIND RECOVERY CODES.", error.message); + return null; + } + }, + deleteMany: async function (clause = {}) { + try { + await prisma.recovery_codes.deleteMany({ where: clause }); + return true; + } catch (error) { + console.error("FAILED TO DELETE RECOVERY CODES.", error.message); + return false; + } + }, + hashesForUser: async function (userId = null) { + if (!userId) return []; + return (await this.findMany({ user_id: userId })).map( + (recovery) => recovery.code_hash + ); + }, +}; + +const PasswordResetToken = { + tablename: "password_reset_tokens", + resetExpiryMs: 600_000, // 10 minutes in ms; + writable: [], + calcExpiry: function () { + return new Date(Date.now() + this.resetExpiryMs); + }, + create: async function (userId) { + try { + const passwordResetToken = await prisma.password_reset_tokens.create({ + data: { user_id: userId, token: v4(), expiresAt: this.calcExpiry() }, + }); + return { passwordResetToken, error: null }; + } catch (error) { + console.error("FAILED TO CREATE PASSWORD RESET TOKEN.", error.message); + return { passwordResetToken: null, error: error.message }; + } + }, + findUnique: async function (clause = {}) { + try { + const passwordResetToken = await prisma.password_reset_tokens.findUnique({ + where: clause, + }); + return passwordResetToken; + } catch (error) { + console.error("FAILED TO FIND PASSWORD RESET TOKEN.", error.message); + return null; + } + }, + deleteMany: async function (clause = {}) { + try { + await prisma.password_reset_tokens.deleteMany({ where: clause }); + return true; + } catch (error) { + console.error("FAILED TO DELETE PASSWORD RESET TOKEN.", error.message); + return false; + } + }, +}; + +module.exports = { + RecoveryCode, + PasswordResetToken, +}; diff --git a/server/prisma/migrations/20240425004220_init/migration.sql b/server/prisma/migrations/20240425004220_init/migration.sql new file mode 100644 index 00000000..14ec7643 --- /dev/null +++ b/server/prisma/migrations/20240425004220_init/migration.sql @@ -0,0 +1,30 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "seen_recovery_codes" BOOLEAN DEFAULT false; + +-- CreateTable +CREATE TABLE "recovery_codes" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "user_id" INTEGER NOT NULL, + "code_hash" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "recovery_codes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "password_reset_tokens" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "user_id" INTEGER NOT NULL, + "token" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "password_reset_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "recovery_codes_user_id_idx" ON "recovery_codes"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "password_reset_tokens_token_key" ON "password_reset_tokens"("token"); + +-- CreateIndex +CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens"("user_id"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index a07eb005..6c8689b9 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -62,6 +62,7 @@ model users { pfpFilename String? role String @default("default") suspended Int @default(0) + seen_recovery_codes Boolean? @default(false) createdAt DateTime @default(now()) lastUpdatedAt DateTime @default(now()) workspace_chats workspace_chats[] @@ -69,9 +70,32 @@ model users { embed_configs embed_configs[] embed_chats embed_chats[] threads workspace_threads[] + recovery_codes recovery_codes[] + password_reset_tokens password_reset_tokens[] workspace_agent_invocations workspace_agent_invocations[] } +model recovery_codes { + id Int @id @default(autoincrement()) + user_id Int + code_hash String + createdAt DateTime @default(now()) + user users @relation(fields: [user_id], references: [id], onDelete: Cascade) + + @@index([user_id]) +} + +model password_reset_tokens { + id Int @id @default(autoincrement()) + user_id Int + token String @unique + expiresAt DateTime + createdAt DateTime @default(now()) + user users @relation(fields: [user_id], references: [id], onDelete: Cascade) + + @@index([user_id]) +} + model document_vectors { id Int @id @default(autoincrement()) docId String diff --git a/server/utils/PasswordRecovery/index.js b/server/utils/PasswordRecovery/index.js new file mode 100644 index 00000000..fbcbe579 --- /dev/null +++ b/server/utils/PasswordRecovery/index.js @@ -0,0 +1,98 @@ +const bcrypt = require("bcrypt"); +const { v4, validate } = require("uuid"); +const { User } = require("../../models/user"); +const { + RecoveryCode, + PasswordResetToken, +} = require("../../models/passwordRecovery"); + +async function generateRecoveryCodes(userId) { + const newRecoveryCodes = []; + const plainTextCodes = []; + for (let i = 0; i < 4; i++) { + const code = v4(); + const hashedCode = bcrypt.hashSync(code, 10); + newRecoveryCodes.push({ + user_id: userId, + code_hash: hashedCode, + }); + plainTextCodes.push(code); + } + + const { error } = await RecoveryCode.createMany(newRecoveryCodes); + if (!!error) throw new Error(error); + + const { success } = await User.update(userId, { + seen_recovery_codes: true, + }); + if (!success) throw new Error("Failed to generate user recovery codes!"); + + return plainTextCodes; +} + +async function recoverAccount(username = "", recoveryCodes = []) { + const user = await User.get({ username: String(username) }); + if (!user) return { success: false, error: "Invalid recovery codes." }; + + // If hashes do not exist for a user + // because this is a user who has not logged out and back in since upgrade. + const allUserHashes = await RecoveryCode.hashesForUser(user.id); + if (allUserHashes.length < 4) + return { success: false, error: "Invalid recovery codes" }; + + // If they tried to send more than two unique codes, we only take the first two + const uniqueRecoveryCodes = [...new Set(recoveryCodes)] + .map((code) => code.trim()) + .filter((code) => validate(code)) // we know that any provided code must be a uuid v4. + .slice(0, 2); + if (uniqueRecoveryCodes.length !== 2) + return { success: false, error: "Invalid recovery codes." }; + + const validCodes = uniqueRecoveryCodes.every((code) => { + let valid = false; + allUserHashes.forEach((hash) => { + if (bcrypt.compareSync(code, hash)) valid = true; + }); + return valid; + }); + if (!validCodes) return { success: false, error: "Invalid recovery codes" }; + + const { passwordResetToken, error } = await PasswordResetToken.create( + user.id + ); + if (!!error) return { success: false, error }; + return { success: true, resetToken: passwordResetToken.token }; +} + +async function resetPassword(token, _newPassword = "", confirmPassword = "") { + const newPassword = String(_newPassword).trim(); // No spaces in passwords + if (!newPassword) throw new Error("Invalid password."); + if (newPassword !== String(confirmPassword)) + throw new Error("Passwords do not match"); + + const resetToken = await PasswordResetToken.findUnique({ + token: String(token), + }); + if (!resetToken || resetToken.expiresAt < new Date()) { + return { success: false, message: "Invalid reset token" }; + } + + // JOI password rules will be enforced inside .update. + const { error } = await User.update(resetToken.user_id, { + password: newPassword, + seen_recovery_codes: false, + }); + + if (error) return { success: false, message: error }; + await PasswordResetToken.deleteMany({ user_id: resetToken.user_id }); + await RecoveryCode.deleteMany({ user_id: resetToken.user_id }); + + // New codes are provided on first new login. + return { success: true, message: "Password reset successful" }; +} + +module.exports = { + recoverAccount, + resetPassword, + generateRecoveryCodes, +}; diff --git a/server/utils/middleware/multiUserProtected.js b/server/utils/middleware/multiUserProtected.js index f8a28c96..4f128ace 100644 --- a/server/utils/middleware/multiUserProtected.js +++ b/server/utils/middleware/multiUserProtected.js @@ -64,8 +64,24 @@ function flexUserRoleValid(allowedRoles = DEFAULT_ROLES) { }; } +// Middleware check on a public route if the instance is in a valid +// multi-user set up. +async function isMultiUserSetup(_request, response, next) { + const multiUserMode = await SystemSettings.isMultiUserMode(); + if (!multiUserMode) { + response.status(403).json({ + error: "Invalid request", + }); + return; + } + + next(); + return; +} + module.exports = { ROLES, strictMultiUserRoleValid, flexUserRoleValid, + isMultiUserSetup, };