password reset by email: Settings → Email, Forgot password?, and a test-email button
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 11s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 15s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Successful in 1m16s
CI & Build / Build & push image (push) Successful in 1m15s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m49s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m26s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Build, or is the channel already serving this? (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Skipped
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 11s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 15s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / integration (push) Successful in 1m16s
CI & Build / Build & push image (push) Successful in 1m15s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m49s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m26s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m26s
Desktop (Tauri) / Update manifest (push) Successful in 4s
The operator asked for self-service reset over SMTP. It reuses #5173's password_resets table, /reset-password page, one-hour single-use token and sign-out-everywhere. - Settings (rule 25, not env): a new Email group (SMTP server, port, encryption as a choice, username, password, from), General → Public address, and Security → Reset emails per account. The registry gains `choices`, `secret` (the value is never sent back, `is_set` says one is saved, an empty save keeps it) and `url` (http(s), trailing slash stripped). - mailer.py: stdlib smtplib on a worker thread, 20 s timeout, starttls | tls | none. mail_settings() is None until a server, a sender and the public address are set. Links are built from the public address because the Host header can be forged. - POST /api/auth/forgot-password: the same answer at the same speed for any address. The link is made and mailed off the request (send_later). It is throttled like a sign-in per visitor address, and capped per typed email by reset_emails_per_account; past the cap it answers the same and sends nothing. - POST /api/settings/test-email: mails the admin with the saved settings and shows the server's error if it fails. - Public config `password_reset_by_email`. Sign-in shows "Forgot password?" only then, linking to a new /forgot-password page. - docs/public-hosting.md: an "Email and forgotten passwords" section. Tests: the secret stays server-side; emailed link → reset; the same answer for unknown addresses; the cap; test email success and failure; validation units. #5266. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+27
-5
@@ -108,15 +108,37 @@ docker run --rm -v inkwell-data:/d -v "$PWD":/out alpine tar czf /out/media.tgz
|
||||
- **Session cookies are `HttpOnly` and `SameSite=Lax`**, which is also what stands in
|
||||
for CSRF protection: a `Lax` cookie is not sent on a cross-site POST.
|
||||
|
||||
## Email and forgotten passwords
|
||||
|
||||
A forgotten password can be reset two ways. Either way the link works once, within
|
||||
an hour, and using it signs the account out everywhere and unlinks its apps.
|
||||
|
||||
- **By an admin, always.** Settings → People → Reset password makes a link, and the
|
||||
admin hands it over.
|
||||
- **By the person, once email is set up.** Fill in Settings → Email (SMTP server,
|
||||
port, encryption, sign-in and a from address) and Settings → General → Public
|
||||
address, the URL people reach this server at. Save, then press **Send test email**,
|
||||
which mails you with exactly the settings a reset will use. From then on the sign-in
|
||||
screen offers **Forgot password?**
|
||||
|
||||
The public address is required because the emailed link has to point somewhere the
|
||||
server can trust. Built from the request instead, a forged `Host` header would mail
|
||||
someone a reset link to a site the forger controls.
|
||||
|
||||
The SMTP password is kept in the database and never sent back to the browser; the
|
||||
field shows only that one is saved. The forgot-password page answers the same way,
|
||||
at the same speed, whether or not an address has an account, and
|
||||
`reset_emails_per_account` (Settings → Security) caps how many reset emails one
|
||||
address can be sent.
|
||||
|
||||
## What it does not do
|
||||
|
||||
Know these before you decide who gets an account.
|
||||
|
||||
- **No email at all.** `email_verified` exists on the user row and nothing sets it.
|
||||
A forgotten password is reset by an admin: Settings → People → Reset password makes
|
||||
a link that works once, within an hour, and the admin hands it over. Using it signs
|
||||
the account out everywhere and unlinks its apps. An admin who forgets their own
|
||||
password and has no other admin still needs a hand on the database.
|
||||
- **No email verification.** `email_verified` exists on the user row and nothing sets
|
||||
it. Email is used only for password resets (above).
|
||||
- **An admin who forgets their own password**, with email off and no other admin,
|
||||
still needs a hand on the database.
|
||||
- **No second factor.** A password is the whole of it.
|
||||
- **No per-user storage quota.** Any account can upload attachments until the volume
|
||||
is full. `max_attachment_mb` caps a single file, not a total.
|
||||
|
||||
@@ -73,7 +73,16 @@ const router = createRouter({
|
||||
meta: { title: "Create account", guestOnly: true },
|
||||
},
|
||||
{
|
||||
// Where an admin-made password reset link lands (#5173). Not guest-only: the
|
||||
// Ask for a reset link by email (#5266). Only linked from sign-in when the
|
||||
// server can send mail; reached otherwise, the server says it can't.
|
||||
path: "/forgot-password",
|
||||
name: "forgot-password",
|
||||
component: () => import("../views/ForgotPasswordView.vue"),
|
||||
meta: { title: "Forgot password", guestOnly: true },
|
||||
},
|
||||
{
|
||||
// Where a password reset link lands, whether an admin made it (#5173) or it was
|
||||
// emailed (#5266). Not guest-only: the
|
||||
// link signs in whoever uses it as the account it was made for, whoever was
|
||||
// signed in on this browser before.
|
||||
path: "/reset-password",
|
||||
|
||||
@@ -43,6 +43,9 @@ export interface PublicConfig {
|
||||
enable_url_unfurl: boolean;
|
||||
// How many days a note survives in Trash before the server purges it. 0 = forever.
|
||||
trash_retention_days: number;
|
||||
// Whether this server can email a reset link, so sign-in offers "Forgot password?"
|
||||
// (#5266). Absent on an older server and on the offline desktop: no.
|
||||
password_reset_by_email?: boolean;
|
||||
// Every client this server holds, keyed by platform id. Absent on a server that
|
||||
// holds none, and absent on the desktop's own offline config — the Tauri build
|
||||
// answers `config_get` locally and has no clients to hand out.
|
||||
@@ -66,6 +69,7 @@ export const useConfigStore = defineStore("config", () => {
|
||||
// Empty until proven otherwise: a server with no clients, and an older server
|
||||
// that never had the field, both correctly offer no downloads.
|
||||
const clients = ref<Record<string, ClientRelease>>({});
|
||||
const passwordResetByEmail = ref(false);
|
||||
const loaded = ref(false);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
@@ -78,6 +82,7 @@ export const useConfigStore = defineStore("config", () => {
|
||||
enableUrlUnfurl.value = cfg.enable_url_unfurl ?? true;
|
||||
trashRetentionDays.value = cfg.trash_retention_days ?? 30;
|
||||
clients.value = cfg.clients ?? {};
|
||||
passwordResetByEmail.value = cfg.password_reset_by_email ?? false;
|
||||
} catch {
|
||||
// Keep defaults if the config endpoint is unreachable.
|
||||
} finally {
|
||||
@@ -97,6 +102,7 @@ export const useConfigStore = defineStore("config", () => {
|
||||
enableUrlUnfurl,
|
||||
trashRetentionDays,
|
||||
clients,
|
||||
passwordResetByEmail,
|
||||
loaded,
|
||||
load,
|
||||
reload,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { api } from "../api/client";
|
||||
import { errorMessage } from "../api/errors";
|
||||
import { useConfigStore } from "../stores/config";
|
||||
import BaseInput from "../components/BaseInput.vue";
|
||||
import BaseButton from "../components/BaseButton.vue";
|
||||
|
||||
// Ask for a password reset link by email (#5266). The server answers the same way
|
||||
// whether or not the address has an account, so this page does too.
|
||||
|
||||
const config = useConfigStore();
|
||||
const route = useRoute();
|
||||
|
||||
const email = ref(typeof route.query.email === "string" ? route.query.email : "");
|
||||
const sent = ref("");
|
||||
const error = ref("");
|
||||
const loading = ref(false);
|
||||
|
||||
async function submit() {
|
||||
error.value = "";
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await api.post<{ message: string }>("/api/auth/forgot-password", { email: email.value });
|
||||
sent.value = res.message;
|
||||
} catch (e) {
|
||||
error.value = errorMessage(e, "Couldn't send a reset link.");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="flex min-h-full items-center justify-center px-4 py-12">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="mb-8 text-center">
|
||||
<img
|
||||
src="/icon.svg"
|
||||
:alt="config.siteName"
|
||||
class="mx-auto mb-3 h-12 w-12 rounded-xl"
|
||||
width="48"
|
||||
height="48"
|
||||
/>
|
||||
<h1 class="text-2xl font-bold tracking-tight">Forgot your password?</h1>
|
||||
<p class="mt-1 text-sm text-neutral-500 dark:text-neutral-400">We'll email you a link to choose a new one.</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="sent"
|
||||
role="status"
|
||||
class="rounded-lg bg-green-50 px-3 py-2 text-sm text-green-800 dark:bg-green-950/50 dark:text-green-300"
|
||||
>
|
||||
{{ sent }}
|
||||
</p>
|
||||
<form v-else class="flex flex-col gap-4" novalidate @submit.prevent="submit">
|
||||
<BaseInput
|
||||
id="email"
|
||||
v-model="email"
|
||||
label="Email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
<p
|
||||
v-if="error"
|
||||
role="alert"
|
||||
class="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950/50 dark:text-red-300"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
<BaseButton type="submit" :loading="loading">Send reset link</BaseButton>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-center text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<RouterLink to="/login" class="font-semibold text-brand-700 hover:underline dark:text-brand"
|
||||
>Back to sign in</RouterLink
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -82,6 +82,12 @@ async function submit() {
|
||||
{{ error }}
|
||||
</p>
|
||||
<BaseButton type="submit" :loading="loading">Sign in</BaseButton>
|
||||
<RouterLink
|
||||
v-if="config.passwordResetByEmail"
|
||||
:to="{ name: 'forgot-password', query: email ? { email } : undefined }"
|
||||
class="text-center text-sm text-neutral-500 hover:underline dark:text-neutral-400"
|
||||
>Forgot password?</RouterLink
|
||||
>
|
||||
</form>
|
||||
|
||||
<p v-if="config.allowRegistration" class="mt-6 text-center text-sm text-neutral-500 dark:text-neutral-400">
|
||||
|
||||
@@ -6,6 +6,7 @@ import BaseButton from "../components/BaseButton.vue";
|
||||
import InviteList from "../components/InviteList.vue";
|
||||
import AccountList from "../components/AccountList.vue";
|
||||
import { errorMessage } from "../api/errors";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
|
||||
interface SettingItem {
|
||||
key: string;
|
||||
@@ -19,9 +20,16 @@ interface SettingItem {
|
||||
// input can refuse an out-of-range value before the round trip.
|
||||
minimum: number | null;
|
||||
maximum: number | null;
|
||||
// Strings: the allowed values, drawn as a choice.
|
||||
choices: string[] | null;
|
||||
// A credential (the SMTP password). Its value never comes back; `is_set` says
|
||||
// whether one is saved, and leaving the field empty keeps it.
|
||||
secret: boolean;
|
||||
is_set: boolean | null;
|
||||
}
|
||||
|
||||
const config = useConfigStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
const items = ref<SettingItem[]>([]);
|
||||
const original = ref<Record<string, string | boolean | number>>({});
|
||||
@@ -83,6 +91,21 @@ async function save() {
|
||||
}
|
||||
}
|
||||
|
||||
// Sends with the SAVED settings, which is what a reset email will use, so it waits
|
||||
// for unsaved changes to be saved first.
|
||||
const testing = ref(false);
|
||||
async function sendTestEmail() {
|
||||
testing.value = true;
|
||||
try {
|
||||
const res = await api.post<{ to: string }>("/api/settings/test-email");
|
||||
ui.showToast(`Test email sent to ${res.to}.`);
|
||||
} catch (e) {
|
||||
ui.showToast(errorMessage(e, "Couldn't send a test email."));
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
@@ -152,6 +175,25 @@ onMounted(load);
|
||||
:value="Number(it.value)"
|
||||
@input="it.value = Number(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<select
|
||||
v-else-if="it.choices"
|
||||
:id="it.key"
|
||||
class="w-56 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
:value="String(it.value)"
|
||||
@change="it.value = ($event.target as HTMLSelectElement).value"
|
||||
>
|
||||
<option v-for="c in it.choices" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<input
|
||||
v-else-if="it.secret"
|
||||
:id="it.key"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:placeholder="it.is_set ? 'Saved — type to replace' : ''"
|
||||
class="w-56 rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100"
|
||||
:value="String(it.value)"
|
||||
@input="it.value = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
:id="it.key"
|
||||
@@ -163,6 +205,18 @@ onMounted(load);
|
||||
</div>
|
||||
<p class="max-w-md text-xs text-neutral-400">{{ it.description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="group.name === 'Email'" class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||
:disabled="testing || dirty"
|
||||
@click="sendTestEmail"
|
||||
>
|
||||
Send test email
|
||||
</button>
|
||||
<span v-if="dirty" class="text-xs text-neutral-400">Save first</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
+40
-2
@@ -11,17 +11,19 @@ from sqlalchemy import delete, func, select
|
||||
from .common import iso
|
||||
from .db import session_scope
|
||||
from .invites import INVALID as INVALID_INVITE, record_redeemer, redeem
|
||||
from .password_resets import INVALID as INVALID_RESET, claim as claim_reset
|
||||
from .mailer import send_later
|
||||
from .password_resets import INVALID as INVALID_RESET, claim as claim_reset, mail_reset_link
|
||||
from .models.device_token import DeviceToken
|
||||
from .models.user import User
|
||||
from .proxy import client_address
|
||||
from .ratelimit import (
|
||||
register_by_address,
|
||||
reset_mail_by_account,
|
||||
sign_in_by_account,
|
||||
sign_in_by_address,
|
||||
)
|
||||
from .security import dummy_verify, generate_token, hash_password, hash_token, verify_password
|
||||
from .settings import get_setting, set_settings
|
||||
from .settings import get_setting, mail_configured, set_settings
|
||||
|
||||
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
||||
|
||||
@@ -330,6 +332,42 @@ async def me():
|
||||
return jsonify(_serialize_user(user))
|
||||
|
||||
|
||||
# The one answer to every forgot-password request that gets as far as sending.
|
||||
FORGOT_SENT = "If an account uses that address, a reset link is on its way. It works for an hour."
|
||||
|
||||
|
||||
@bp.post("/forgot-password")
|
||||
async def forgot_password():
|
||||
"""Email a reset link to whoever owns this address (#5266).
|
||||
|
||||
Says the same thing, at the same speed, whether or not the address has an
|
||||
account: the link is made and sent off the request. Throttled per visitor address
|
||||
like a sign-in, and per typed email by `reset_emails_per_account` — past that cap
|
||||
the answer is unchanged and nothing is sent, so the cap is no oracle either.
|
||||
"""
|
||||
data = await request.get_json(silent=True) or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
if not email or "@" not in email:
|
||||
return jsonify({"error": "a valid email is required"}), 400
|
||||
|
||||
wait = _sign_in_block("")
|
||||
if wait is not None:
|
||||
return _throttled(wait)
|
||||
_sign_in_failed("")
|
||||
|
||||
async with session_scope() as db:
|
||||
if not await mail_configured(db):
|
||||
return jsonify({"error": "this server can't send email; ask your admin for a reset link"}), 400
|
||||
|
||||
if reset_mail_by_account.retry_after(email) is None:
|
||||
reset_mail_by_account.record(email)
|
||||
send_later(mail_reset_link(email))
|
||||
logger.info("password reset requested for=%s from=%s", email, client_address())
|
||||
else:
|
||||
logger.warning("password reset email capped for=%s from=%s", email, client_address())
|
||||
return jsonify({"ok": True, "message": FORGOT_SENT})
|
||||
|
||||
|
||||
@bp.post("/reset-password")
|
||||
async def reset_password():
|
||||
"""Set a new password with a reset link an admin made (#5173), and sign the
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Sending email over the SMTP server set in Settings → Email (#5266).
|
||||
|
||||
Used for password reset links, and for the admin's test message. Email is an optional
|
||||
feature (rule 164): with no server set, `mail_settings` answers None and everything
|
||||
that would send says it is not available. A send that fails is logged and never
|
||||
takes anything else down with it.
|
||||
|
||||
stdlib `smtplib`, run on a worker thread, so the event loop never waits on a mail
|
||||
server and nothing new is installed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
|
||||
from .settings import get_setting, mail_configured
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Long enough for a slow relay, short enough that a dead one doesn't hold a thread.
|
||||
TIMEOUT_S = 20
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MailSettings:
|
||||
host: str
|
||||
port: int
|
||||
security: str # starttls | tls | none
|
||||
username: str
|
||||
password: str
|
||||
sender: str
|
||||
public_url: str
|
||||
|
||||
|
||||
async def mail_settings(db) -> MailSettings | None:
|
||||
"""The saved SMTP settings, or None when email is off."""
|
||||
if not await mail_configured(db):
|
||||
return None
|
||||
username = await get_setting(db, "smtp_username")
|
||||
return MailSettings(
|
||||
host=await get_setting(db, "smtp_host"),
|
||||
port=await get_setting(db, "smtp_port"),
|
||||
security=await get_setting(db, "smtp_security"),
|
||||
username=username,
|
||||
password=await get_setting(db, "smtp_password"),
|
||||
sender=await get_setting(db, "smtp_from") or username,
|
||||
public_url=await get_setting(db, "public_url"),
|
||||
)
|
||||
|
||||
|
||||
def build_message(cfg: MailSettings, to: str, subject: str, text: str) -> EmailMessage:
|
||||
msg = EmailMessage()
|
||||
msg["From"] = cfg.sender
|
||||
msg["To"] = to
|
||||
msg["Subject"] = subject
|
||||
msg.set_content(text)
|
||||
return msg
|
||||
|
||||
|
||||
def _deliver(cfg: MailSettings, msg: EmailMessage) -> None:
|
||||
"""Hand one message to the SMTP server. Blocking; raises on any failure."""
|
||||
context = ssl.create_default_context()
|
||||
if cfg.security == "tls":
|
||||
server: smtplib.SMTP = smtplib.SMTP_SSL(cfg.host, cfg.port, timeout=TIMEOUT_S, context=context)
|
||||
else:
|
||||
server = smtplib.SMTP(cfg.host, cfg.port, timeout=TIMEOUT_S)
|
||||
with server:
|
||||
if cfg.security == "starttls":
|
||||
server.starttls(context=context)
|
||||
if cfg.username:
|
||||
server.login(cfg.username, cfg.password)
|
||||
server.send_message(msg)
|
||||
|
||||
|
||||
async def send(cfg: MailSettings, to: str, subject: str, text: str) -> None:
|
||||
"""Send one plain-text email. Raises when the server refuses or can't be reached."""
|
||||
await asyncio.to_thread(_deliver, cfg, build_message(cfg, to, subject, text))
|
||||
|
||||
|
||||
# Sends started by `send_later`, held so they aren't collected mid-flight.
|
||||
_running: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def send_later(job) -> None:
|
||||
"""Run a coroutine that sends mail without making the request wait for it.
|
||||
|
||||
For the forgot-password route, where waiting would also be an oracle: an address
|
||||
with an account would answer as slowly as a mail server, and one without, at once.
|
||||
`job` handles and logs its own failures."""
|
||||
task = asyncio.create_task(job)
|
||||
_running.add(task)
|
||||
task.add_done_callback(_running.discard)
|
||||
|
||||
|
||||
async def drain() -> None:
|
||||
"""Wait for every pending send. For tests — nothing in the app calls this."""
|
||||
while _running:
|
||||
await asyncio.gather(*list(_running), return_exceptions=True)
|
||||
@@ -8,19 +8,29 @@ Using the link sets a new password and signs the account out everywhere. Its web
|
||||
sessions end because the account's `session_epoch` moves on (see `auth`), and its
|
||||
device tokens are deleted, so each linked app has to sign in again.
|
||||
|
||||
A person can also ask for one themselves when the server can send email (#5266):
|
||||
`mail_reset_link` makes the same link and mails it to the account's address.
|
||||
|
||||
This module is the reset itself; `auth.reset_password` redeems it and the admin route
|
||||
is in `accounts_api.py`, the same split as invites and for the same reason.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete, update
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .db import session_scope
|
||||
from .mailer import mail_settings, send
|
||||
from .models.password_reset import PasswordReset
|
||||
from .models.user import User
|
||||
from .security import generate_token, hash_token
|
||||
from .settings import get_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Long enough to read a message and act on it; short enough that a link left in a
|
||||
# chat history is dead by the time anyone else scrolls past it.
|
||||
@@ -30,8 +40,9 @@ LIFETIME = timedelta(hours=1)
|
||||
INVALID = "invalid or expired reset link"
|
||||
|
||||
|
||||
async def issue(db: AsyncSession, user_id: uuid.UUID, by: uuid.UUID) -> tuple[str, datetime]:
|
||||
"""Make a reset link for the account, returning the token and its expiry.
|
||||
async def issue(db: AsyncSession, user_id: uuid.UUID, by: uuid.UUID | None) -> tuple[str, datetime]:
|
||||
"""Make a reset link for the account, returning the token and its expiry. `by` is
|
||||
the admin who made it, or None when the person asked by email.
|
||||
|
||||
Its earlier unused links are deleted first, so only the newest one works: an admin
|
||||
who makes a second link because the first went astray has closed the first.
|
||||
@@ -64,3 +75,35 @@ async def claim(db: AsyncSession, token: str) -> uuid.UUID | None:
|
||||
.values(used_at=now)
|
||||
.returning(PasswordReset.user_id)
|
||||
)
|
||||
|
||||
|
||||
async def mail_reset_link(email: str) -> None:
|
||||
"""Email a reset link to the account with this address, if there is one.
|
||||
|
||||
Run off the request (`mailer.send_later`), so the person asking gets the same
|
||||
answer at the same speed whether or not the address has an account. Logs and
|
||||
swallows every failure: there is no one left to tell.
|
||||
"""
|
||||
try:
|
||||
async with session_scope() as db:
|
||||
cfg = await mail_settings(db)
|
||||
user = await db.scalar(select(User).where(User.email == email))
|
||||
if cfg is None or user is None:
|
||||
return
|
||||
token, _ = await issue(db, user.id, None)
|
||||
await db.commit()
|
||||
site = await get_setting(db, "site_name")
|
||||
to = user.email
|
||||
link = f"{cfg.public_url}/reset-password?token={token}"
|
||||
await send(
|
||||
cfg,
|
||||
to,
|
||||
f"Reset your {site} password",
|
||||
f"Someone asked to reset the password for {to} on {site}.\n\n"
|
||||
f"If it was you, open this link within the hour to choose a new one:\n\n{link}\n\n"
|
||||
"Setting it signs you out everywhere else. If it wasn't you, ignore this "
|
||||
"email and your password stays as it is.\n",
|
||||
)
|
||||
logger.info("password reset email sent to=%s", to)
|
||||
except Exception:
|
||||
logger.exception("password reset email failed for=%s", email)
|
||||
|
||||
@@ -126,9 +126,14 @@ sign_in_by_address = SlidingWindow(
|
||||
register_by_address = SlidingWindow(
|
||||
lambda: live("register_limit_per_address"), _minutes("register_window_minutes")
|
||||
)
|
||||
# Reset emails, counted per address typed in whether or not it has an account, so
|
||||
# the cap can't be used to learn which ones do (#5266).
|
||||
reset_mail_by_account = SlidingWindow(
|
||||
lambda: live("reset_emails_per_account"), _minutes("signin_window_minutes")
|
||||
)
|
||||
|
||||
|
||||
def reset_all() -> None:
|
||||
"""Drop every counter. For tests — nothing in the app calls this."""
|
||||
for window in (sign_in_by_account, sign_in_by_address, register_by_address):
|
||||
for window in (sign_in_by_account, sign_in_by_address, register_by_address, reset_mail_by_account):
|
||||
window.clear()
|
||||
|
||||
+83
-2
@@ -26,6 +26,13 @@ class SettingDef:
|
||||
# lock every account out permanently.
|
||||
minimum: int | None = None
|
||||
maximum: int | None = None
|
||||
# Strings only: the values allowed, shown as a choice rather than a text field.
|
||||
choices: tuple[str, ...] | None = None
|
||||
# A credential. The admin API never sends its value back, only whether one is
|
||||
# set, and saving it empty leaves it as it was (#5266).
|
||||
secret: bool = False
|
||||
# An http(s) address, stored without a trailing slash.
|
||||
url: bool = False
|
||||
|
||||
|
||||
# The source of truth for every user-facing setting. Add a row here and it appears
|
||||
@@ -34,6 +41,15 @@ REGISTRY: list[SettingDef] = [
|
||||
SettingDef(
|
||||
"site_name", "string", "Inkwell", "Site name", "Shown in the header and the browser tab.", "General"
|
||||
),
|
||||
SettingDef(
|
||||
"public_url",
|
||||
"string",
|
||||
"",
|
||||
"Public address",
|
||||
"Where people reach this server, like https://notes.example.com. Emailed links use it.",
|
||||
"General",
|
||||
url=True,
|
||||
),
|
||||
SettingDef(
|
||||
"allow_registration",
|
||||
"bool",
|
||||
@@ -77,6 +93,35 @@ REGISTRY: list[SettingDef] = [
|
||||
"The server contacts the linked site; private/internal addresses are always blocked.",
|
||||
"Links",
|
||||
),
|
||||
# --- Email (#5266) ---------------------------------------------------------------
|
||||
#
|
||||
# Only for password reset links so far. Off until a server, a sender and the
|
||||
# public address are all set (`mailer.mail_settings`).
|
||||
SettingDef("smtp_host", "string", "", "SMTP server", "Leave empty to turn email off.", "Email"),
|
||||
SettingDef(
|
||||
"smtp_port",
|
||||
"int",
|
||||
587,
|
||||
"SMTP port",
|
||||
"587 for STARTTLS, 465 for TLS.",
|
||||
"Email",
|
||||
minimum=1,
|
||||
maximum=65535,
|
||||
),
|
||||
SettingDef(
|
||||
"smtp_security",
|
||||
"string",
|
||||
"starttls",
|
||||
"Encryption",
|
||||
"How the connection to the SMTP server is secured.",
|
||||
"Email",
|
||||
choices=("starttls", "tls", "none"),
|
||||
),
|
||||
SettingDef("smtp_username", "string", "", "SMTP username", "Leave empty if no sign-in is needed.", "Email"),
|
||||
SettingDef(
|
||||
"smtp_password", "string", "", "SMTP password", "Kept on the server and never shown again.", "Email", secret=True
|
||||
),
|
||||
SettingDef("smtp_from", "string", "", "From address", "Who emails come from. Defaults to the username.", "Email"),
|
||||
# --- Security -----------------------------------------------------------------
|
||||
#
|
||||
# Read on paths too hot for a database round trip (the credential throttle checks
|
||||
@@ -151,6 +196,16 @@ REGISTRY: list[SettingDef] = [
|
||||
minimum=1,
|
||||
maximum=10080,
|
||||
),
|
||||
SettingDef(
|
||||
"reset_emails_per_account",
|
||||
"int",
|
||||
3,
|
||||
"Reset emails per account",
|
||||
"How many password reset emails one address can be sent within the sign-in window.",
|
||||
"Security",
|
||||
minimum=1,
|
||||
maximum=100,
|
||||
),
|
||||
]
|
||||
|
||||
_BY_KEY: dict[str, SettingDef] = {d.key: d for d in REGISTRY}
|
||||
@@ -218,18 +273,34 @@ async def get_public_config(db) -> dict:
|
||||
# left in Trash. A native client also reads it BEFORE linking, which is why
|
||||
# it belongs on the unauthenticated config rather than behind login.
|
||||
"trash_retention_days": await get_setting(db, "trash_retention_days"),
|
||||
# Whether the sign-in screen offers "Forgot password?" (#5266).
|
||||
"password_reset_by_email": await mail_configured(db),
|
||||
}
|
||||
|
||||
|
||||
async def mail_configured(db) -> bool:
|
||||
"""Whether the server can send email: an SMTP server, someone to send as, and a
|
||||
public address for the links to point at. The last is not optional: built from
|
||||
the request's Host instead, a forged Host would mail someone a reset link to a
|
||||
site the forger controls."""
|
||||
host = await get_setting(db, "smtp_host")
|
||||
sender = await get_setting(db, "smtp_from") or await get_setting(db, "smtp_username")
|
||||
return bool(host and sender and await get_setting(db, "public_url"))
|
||||
|
||||
|
||||
async def get_admin_settings(db) -> list[dict]:
|
||||
"""Every registry setting with its current value + metadata, for the admin UI."""
|
||||
result: list[dict] = []
|
||||
for d in REGISTRY:
|
||||
value = await get_setting(db, d.key)
|
||||
result.append(
|
||||
{
|
||||
"key": d.key,
|
||||
"type": d.type,
|
||||
"value": await get_setting(db, d.key),
|
||||
"value": "" if d.secret else value,
|
||||
"is_set": bool(value) if d.secret else None,
|
||||
"secret": d.secret,
|
||||
"choices": list(d.choices) if d.choices else None,
|
||||
"default": d.default,
|
||||
"label": d.label,
|
||||
"description": d.description,
|
||||
@@ -258,6 +329,7 @@ _LIVE_KEYS = (
|
||||
"signin_window_minutes",
|
||||
"register_limit_per_address",
|
||||
"register_window_minutes",
|
||||
"reset_emails_per_account",
|
||||
)
|
||||
|
||||
_live: dict[str, Any] = {k: _BY_KEY[k].default for k in _LIVE_KEYS}
|
||||
@@ -304,7 +376,16 @@ def validate_updates(updates: dict) -> tuple[dict, str | None]:
|
||||
elif defn.type == "bool":
|
||||
clean[key] = _coerce_bool(val)
|
||||
else:
|
||||
clean[key] = str(val)
|
||||
text = str(val if val is not None else "").strip()
|
||||
if defn.secret and not text:
|
||||
continue # empty means "leave it as it is"; the UI never has the value
|
||||
if defn.choices and text not in defn.choices:
|
||||
return {}, f"{defn.label} must be one of: {', '.join(defn.choices)}"
|
||||
if defn.url and text:
|
||||
if not text.startswith(("http://", "https://")):
|
||||
return {}, f"{defn.label} must start with http:// or https://"
|
||||
text = text.rstrip("/")
|
||||
clean[key] = text
|
||||
return clean, None
|
||||
|
||||
|
||||
|
||||
@@ -2,11 +2,17 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from quart import Blueprint, current_app, jsonify, request
|
||||
import logging
|
||||
|
||||
from quart import Blueprint, current_app, g, jsonify, request
|
||||
|
||||
from .auth import require_admin
|
||||
from .db import session_scope
|
||||
from .settings import get_admin_settings, refresh_live, set_settings, validate_updates
|
||||
from .mailer import mail_settings, send
|
||||
from .models.user import User
|
||||
from .settings import get_admin_settings, get_setting, refresh_live, set_settings, validate_updates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint("settings", __name__, url_prefix="/api/settings")
|
||||
|
||||
@@ -46,3 +52,30 @@ async def update_settings():
|
||||
current_app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=int(clean["session_ttl_days"]))
|
||||
|
||||
return jsonify({"settings": result})
|
||||
|
||||
|
||||
@bp.post("/test-email")
|
||||
@require_admin
|
||||
async def test_email():
|
||||
"""Send a test message to the signed-in admin with the SAVED email settings, so
|
||||
"Send test email" checks what reset links will actually use (#5266). An admin is
|
||||
shown the server's own error, which is what fixing the settings takes."""
|
||||
async with session_scope() as db:
|
||||
cfg = await mail_settings(db)
|
||||
if cfg is None:
|
||||
return jsonify(
|
||||
{"error": "Email is off: it needs an SMTP server, a from address and the public address."}
|
||||
), 400
|
||||
admin = await db.get(User, g.user_id)
|
||||
site = await get_setting(db, "site_name")
|
||||
try:
|
||||
await send(
|
||||
cfg,
|
||||
admin.email,
|
||||
f"{site} test email",
|
||||
f"This is a test from {site} at {cfg.public_url}. Password reset emails will arrive like this one.\n",
|
||||
)
|
||||
except Exception as e: # any SMTP, socket or TLS failure; the admin needs its text
|
||||
logger.warning("test email failed: %s", e)
|
||||
return jsonify({"error": f"Couldn't send: {e}"}), 502
|
||||
return jsonify({"ok": True, "to": admin.email})
|
||||
|
||||
+119
-2
@@ -17,19 +17,22 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import re
|
||||
import smtplib
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import func, select, text, update
|
||||
from sqlalchemy import delete, func, select, text, update
|
||||
|
||||
from inkwell import ratelimit
|
||||
from inkwell import mailer, ratelimit
|
||||
from inkwell.app import create_app
|
||||
from inkwell.config import Config
|
||||
from inkwell.db import dispose_engine, session_scope
|
||||
from inkwell.models.invite import Invite
|
||||
from inkwell.models.password_reset import PasswordReset
|
||||
from inkwell.models.settings import Setting
|
||||
from inkwell.models.share import Share
|
||||
from inkwell.models.label import NoteLabel
|
||||
from inkwell.models.note import Note
|
||||
@@ -476,6 +479,7 @@ async def test_the_security_group_reaches_the_admin_ui(app_client, db):
|
||||
"signin_window_minutes",
|
||||
"register_limit_per_address",
|
||||
"register_window_minutes",
|
||||
"reset_emails_per_account",
|
||||
}
|
||||
# The UI renders a number input from these, and it cannot offer a safe range it
|
||||
# was never told about.
|
||||
@@ -1337,3 +1341,116 @@ async def test_a_share_names_someone_else_on_this_instance(app_client, db):
|
||||
other = await _owners_note(app_client, "another")
|
||||
sid = (await (await _share(app_client, nid, people["stranger"])).get_json())["shares"][-1]["id"]
|
||||
assert (await app_client.delete(f"/api/notes/{other}/shares/{sid}")).status_code == 404
|
||||
|
||||
|
||||
# --- Password reset by email (#5266) ------------------------------------------
|
||||
#
|
||||
# The SMTP hand-off is replaced by a list; everything up to it is real.
|
||||
|
||||
_MAIL_SETTINGS = {
|
||||
"smtp_host": "smtp.example.test",
|
||||
"smtp_from": "inkwell@example.test",
|
||||
"smtp_password": "hunter2",
|
||||
"public_url": "https://notes.example.test/",
|
||||
}
|
||||
|
||||
|
||||
async def _mail_off() -> None:
|
||||
"""Settings outlive the per-test truncate, so each email test starts from none."""
|
||||
async with session_scope() as fresh:
|
||||
await fresh.execute(delete(Setting).where(Setting.key.in_([*_MAIL_SETTINGS, "smtp_security"])))
|
||||
await fresh.commit()
|
||||
|
||||
|
||||
def _outbox(monkeypatch) -> list:
|
||||
sent: list = []
|
||||
monkeypatch.setattr(mailer, "_deliver", lambda cfg, msg: sent.append(msg))
|
||||
return sent
|
||||
|
||||
|
||||
async def _forgot(email: str):
|
||||
return await create_app().test_client().post("/api/auth/forgot-password", json={"email": email})
|
||||
|
||||
|
||||
async def test_the_smtp_password_never_leaves_the_server(app_client, db):
|
||||
await _mail_off()
|
||||
await _admin_with_invite(app_client)
|
||||
saved = await app_client.patch("/api/settings", json=_MAIL_SETTINGS)
|
||||
assert saved.status_code == 200, await saved.get_data(as_text=True)
|
||||
rows = {r["key"]: r for r in (await saved.get_json())["settings"]}
|
||||
assert (rows["smtp_password"]["value"], rows["smtp_password"]["is_set"]) == ("", True)
|
||||
assert rows["public_url"]["value"] == "https://notes.example.test"
|
||||
|
||||
# Saving the form again sends the password field empty, which keeps it.
|
||||
assert (await app_client.patch("/api/settings", json={"smtp_password": ""})).status_code == 200
|
||||
async with session_scope() as fresh:
|
||||
assert await get_setting(fresh, "smtp_password") == "hunter2"
|
||||
|
||||
assert (await app_client.patch("/api/settings", json={"smtp_security": "ssl3"})).status_code == 400
|
||||
assert (await app_client.patch("/api/settings", json={"public_url": "notes.example.test"})).status_code == 400
|
||||
|
||||
|
||||
async def test_a_forgotten_password_is_reset_from_an_emailed_link(app_client, db, monkeypatch):
|
||||
await _mail_off()
|
||||
outbox = _outbox(monkeypatch)
|
||||
token = await _admin_with_invite(app_client)
|
||||
assert (await _register("guest@example.test", token)).status_code == 201
|
||||
|
||||
# Off until the server can send: no link on sign-in, and the route says so.
|
||||
assert (await (await app_client.get("/api/config")).get_json())["password_reset_by_email"] is False
|
||||
assert (await _forgot("guest@example.test")).status_code == 400
|
||||
|
||||
await app_client.patch("/api/settings", json=_MAIL_SETTINGS)
|
||||
assert (await (await app_client.get("/api/config")).get_json())["password_reset_by_email"] is True
|
||||
|
||||
known = await _forgot("Guest@Example.test")
|
||||
unknown = await _forgot("nobody@example.test")
|
||||
assert known.status_code == unknown.status_code == 200
|
||||
assert await known.get_json() == await unknown.get_json()
|
||||
await mailer.drain()
|
||||
|
||||
assert [m["To"] for m in outbox] == ["guest@example.test"]
|
||||
text = outbox[0].get_content()
|
||||
link = re.search(r"https://notes\.example\.test/reset-password\?token=([\w-]+)", text)
|
||||
assert link, text
|
||||
async with session_scope() as fresh:
|
||||
assert await fresh.scalar(select(PasswordReset.created_by)) is None
|
||||
|
||||
reset = await create_app().test_client().post(
|
||||
"/api/auth/reset-password", json={"token": link.group(1), "password": "chosen-by-email"}
|
||||
)
|
||||
assert reset.status_code == 200
|
||||
assert (await reset.get_json())["email"] == "guest@example.test"
|
||||
|
||||
|
||||
async def test_reset_emails_stop_at_the_cap_without_saying_so(app_client, db, monkeypatch):
|
||||
await _mail_off()
|
||||
outbox = _outbox(monkeypatch)
|
||||
token = await _admin_with_invite(app_client)
|
||||
assert (await _register("guest@example.test", token)).status_code == 201
|
||||
await app_client.patch("/api/settings", json=_MAIL_SETTINGS)
|
||||
|
||||
answers = [await _forgot("guest@example.test") for _ in range(4)]
|
||||
assert [a.status_code for a in answers] == [200, 200, 200, 200]
|
||||
await mailer.drain()
|
||||
assert len(outbox) == 3 # reset_emails_per_account defaults to 3
|
||||
|
||||
|
||||
async def test_the_test_email_goes_to_the_admin_and_reports_a_failure(app_client, db, monkeypatch):
|
||||
await _mail_off()
|
||||
outbox = _outbox(monkeypatch)
|
||||
await _admin_with_invite(app_client)
|
||||
assert (await app_client.post("/api/settings/test-email")).status_code == 400
|
||||
|
||||
await app_client.patch("/api/settings", json=_MAIL_SETTINGS)
|
||||
sent = await app_client.post("/api/settings/test-email")
|
||||
assert sent.status_code == 200
|
||||
assert [m["To"] for m in outbox] == ["owner@example.test"]
|
||||
|
||||
def refuse(cfg, msg):
|
||||
raise smtplib.SMTPAuthenticationError(535, b"bad credentials")
|
||||
|
||||
monkeypatch.setattr(mailer, "_deliver", refuse)
|
||||
failed = await app_client.post("/api/settings/test-email")
|
||||
assert failed.status_code == 502
|
||||
assert "bad credentials" in (await failed.get_json())["error"]
|
||||
|
||||
@@ -23,3 +23,25 @@ def test_validate_rejects_bad_int():
|
||||
clean, error = validate_updates({"session_ttl_days": "not-a-number"})
|
||||
assert error is not None
|
||||
assert clean == {}
|
||||
|
||||
|
||||
def test_an_empty_secret_keeps_what_is_saved():
|
||||
clean, error = validate_updates({"smtp_password": "", "smtp_host": "smtp.example.test"})
|
||||
assert error is None
|
||||
assert clean == {"smtp_host": "smtp.example.test"}
|
||||
|
||||
|
||||
def test_a_choice_must_be_one_of_its_values():
|
||||
assert validate_updates({"smtp_security": "tls"}) == ({"smtp_security": "tls"}, None)
|
||||
clean, error = validate_updates({"smtp_security": "ssl"})
|
||||
assert clean == {} and error
|
||||
|
||||
|
||||
def test_the_public_address_is_an_http_url_without_a_trailing_slash():
|
||||
assert validate_updates({"public_url": "https://notes.example.test/"}) == (
|
||||
{"public_url": "https://notes.example.test"},
|
||||
None,
|
||||
)
|
||||
assert validate_updates({"public_url": ""}) == ({"public_url": ""}, None)
|
||||
clean, error = validate_updates({"public_url": "javascript:alert(1)"})
|
||||
assert clean == {} and error
|
||||
|
||||
Reference in New Issue
Block a user