From d354da5b51f0e56249c940745d23a23b21790e22 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 13 Feb 2026 21:42:45 -0500 Subject: [PATCH] Add email-based password reset flow Users who forget their password can now request a reset link via email. Tokens are SHA-256 hashed before storage, expire after 1 hour, and previous unused tokens are invalidated on new requests. The forgot-password endpoint always returns success to prevent email enumeration. Co-Authored-By: Claude Opus 4.6 --- .../0011_add_password_reset_tokens.py | 25 ++ frontend/src/router/index.ts | 12 + frontend/src/views/ForgotPasswordView.vue | 167 +++++++++++++ frontend/src/views/LoginView.vue | 14 ++ frontend/src/views/ResetPasswordView.vue | 225 ++++++++++++++++++ src/fabledassistant/config.py | 1 + src/fabledassistant/models/__init__.py | 1 + src/fabledassistant/models/password_reset.py | 24 ++ src/fabledassistant/routes/auth.py | 77 +++++- src/fabledassistant/services/auth.py | 70 ++++++ src/fabledassistant/services/notifications.py | 46 ++++ 11 files changed, 661 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/0011_add_password_reset_tokens.py create mode 100644 frontend/src/views/ForgotPasswordView.vue create mode 100644 frontend/src/views/ResetPasswordView.vue create mode 100644 src/fabledassistant/models/password_reset.py diff --git a/alembic/versions/0011_add_password_reset_tokens.py b/alembic/versions/0011_add_password_reset_tokens.py new file mode 100644 index 0000000..02fee2b --- /dev/null +++ b/alembic/versions/0011_add_password_reset_tokens.py @@ -0,0 +1,25 @@ +"""Add password_reset_tokens table.""" + +from alembic import op + +revision = "0011" +down_revision = "0010" + + +def upgrade() -> None: + op.execute(""" + CREATE TABLE password_reset_tokens ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + used BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) + op.execute("CREATE INDEX ix_password_reset_tokens_token_hash ON password_reset_tokens (token_hash)") + op.execute("CREATE INDEX ix_password_reset_tokens_user_id ON password_reset_tokens (user_id)") + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS password_reset_tokens") diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 1b895ed..e76f7b8 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -22,6 +22,18 @@ const router = createRouter({ component: () => import("@/views/RegisterView.vue"), meta: { public: true }, }, + { + path: "/forgot-password", + name: "forgot-password", + component: () => import("@/views/ForgotPasswordView.vue"), + meta: { public: true }, + }, + { + path: "/reset-password", + name: "reset-password", + component: () => import("@/views/ResetPasswordView.vue"), + meta: { public: true }, + }, { path: "/notes", name: "notes", diff --git a/frontend/src/views/ForgotPasswordView.vue b/frontend/src/views/ForgotPasswordView.vue new file mode 100644 index 0000000..2a69590 --- /dev/null +++ b/frontend/src/views/ForgotPasswordView.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 732307d..620b96e 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -69,6 +69,9 @@ async function handleSubmit() { class="input" /> +

{{ error }}