Merge branch 'claude/relaxed-faraday-h4zd09' of https://github.com/pablovolenski/vienalatina
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
admin 2026-09-25 18:24:08 +00:00
commit e55ea94619
18 changed files with 949 additions and 24 deletions

View File

@ -55,6 +55,15 @@ def create_app(overrides: dict | None = None) -> Flask:
# publishing from Decap as far as the pipeline is concerned. # publishing from Decap as far as the pipeline is concerned.
CONTENT_REPO=os.environ.get("CONTENT_REPO", "pablo/vienalatina"), CONTENT_REPO=os.environ.get("CONTENT_REPO", "pablo/vienalatina"),
CONTENT_BRANCH=os.environ.get("CONTENT_BRANCH", "main"), CONTENT_BRANCH=os.environ.get("CONTENT_BRANCH", "main"),
# Outgoing mail. Without MAIL_HOST the app still runs, but nobody
# can be invited or recover a password, so the admin screens say so
# rather than failing at the moment somebody presses send.
MAIL_HOST=os.environ.get("MAIL_HOST", ""),
MAIL_PORT=os.environ.get("MAIL_PORT", "587"),
MAIL_SECURITY=os.environ.get("MAIL_SECURITY", "starttls"),
MAIL_USER=os.environ.get("MAIL_USER", ""),
MAIL_PASSWORD=os.environ.get("MAIL_PASSWORD", ""),
MAIL_FROM=os.environ.get("MAIL_FROM", "Viena Latina <hola@vienalatina.com>"),
URL_PREFIX=URL_PREFIX, URL_PREFIX=URL_PREFIX,
COOLDOWN_SECONDS=int(os.environ.get("BOARD_COOLDOWN_SECONDS", "20")), COOLDOWN_SECONDS=int(os.environ.get("BOARD_COOLDOWN_SECONDS", "20")),
SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_HTTPONLY=True,

View File

@ -14,11 +14,15 @@ import secrets
from flask import (Blueprint, current_app, flash, g, redirect, render_template, from flask import (Blueprint, current_app, flash, g, redirect, render_template,
request, session, url_for) request, session, url_for)
from . import gitea, tokens from . import gitea, invites, mail, tokens
from .db import get_db from .db import get_db
bp = Blueprint("auth", __name__) bp = Blueprint("auth", __name__)
# Gitea enforces its own minimum as well; this one is stricter so the member is
# told before the round trip rather than after it, in their own language.
PASSWORD_MIN = 10
def redirect_uri() -> str: def redirect_uri() -> str:
return current_app.config["BASE_URL"].rstrip("/") + url_for("auth.callback") return current_app.config["BASE_URL"].rstrip("/") + url_for("auth.callback")
@ -129,6 +133,82 @@ def callback():
return redirect(target) return redirect(target)
def invite_url(token: str) -> str:
return current_app.config["BASE_URL"].rstrip("/") + url_for(
"auth.set_password", token=token
)
@bp.route("/invitacion/<token>", methods=["GET", "POST"])
def set_password(token: str):
"""Where a member chooses their own password, from an invite or a reset.
One page for both, because they differ only in the wording and how long the
link lived. The token is the only credential: somebody arriving here is not
signed in and cannot be.
"""
member = invites.lookup(token)
if member is None:
# Deliberately one message for every reason it might fail — expired,
# already used, never existed. Distinguishing them tells whoever holds
# a stale link something about the account it points at.
return render_template("set_password.html", member=None, token=token,
minimum=PASSWORD_MIN), 400
if request.method == "GET":
return render_template("set_password.html", member=member, token=token,
minimum=PASSWORD_MIN)
password = request.form.get("password", "")
confirm = request.form.get("confirm", "")
if password != confirm:
flash("Las dos contraseñas no coinciden.", "error")
elif len(password) < PASSWORD_MIN:
flash(f"La contraseña necesita al menos {PASSWORD_MIN} caracteres.", "error")
else:
try:
gitea.admin_set_password(member["gitea_login"], password)
except gitea.GiteaError as exc:
flash(str(exc), "error")
else:
# Only now: a token that set a password is spent, but one whose
# password Gitea rejected has to keep working or the member is
# locked out by a typo.
invites.consume(member["invite_id"])
flash("Contraseña guardada. Ya puedes entrar.", "ok")
return redirect(url_for("auth.login"))
return render_template("set_password.html", member=member, token=token,
minimum=PASSWORD_MIN), 400
@bp.route("/recuperar", methods=["GET", "POST"])
def recover():
"""Replaces Gitea's recovery page, which is dead without a mailer."""
if request.method == "GET":
return render_template("recover.html")
email = request.form.get("email", "").strip()
member = get_db().execute(
"""SELECT * FROM members
WHERE email = ? COLLATE NOCASE AND active = 1
AND role IN ('owner', 'admin', 'user')""",
(email,),
).fetchone()
if member is not None and not invites.rate_limited(member["id"]):
try:
mail.send_reset(member["email"], member["display_name"],
invite_url(invites.issue(member["id"], "reset")))
except (mail.MailFailed, mail.MailNotConfigured) as exc:
current_app.logger.warning("Reset mail failed: %s", exc)
# The same answer either way, whatever happened above. Saying "no account
# with that address" would turn this form into a way to find out who is a
# member, one address at a time.
return render_template("recover.html", sent=True)
@bp.route("/logout", methods=["POST"]) @bp.route("/logout", methods=["POST"])
def logout(): def logout():
# Drop the Gitea token too. Signing out should stop the server being able to # Drop the Gitea token too. Signing out should stop the server being able to

View File

@ -157,6 +157,47 @@ def admin_create_user(login: str, email: str, full_name: str, password: str) ->
raise GiteaError(f"Gitea rechazó la creación del usuario ({response.status_code}).") raise GiteaError(f"Gitea rechazó la creación del usuario ({response.status_code}).")
def admin_set_password(login: str, password: str) -> None:
"""Set a member's password on their behalf, after they chose it here.
This is what lets the whole invitation and reset flow stay inside
/comunidad/ instead of handing people to Gitea, whose own recovery page is
dead without a mailer anyway.
`login_name` and `source_id` are sent although nothing about them changes:
Gitea's EditUserOption has historically treated them as required, and
omitting them has been reported to move a local account onto a different
authentication source. For a local user they are the username and 0, so
sending them is a no-op that avoids the question.
"""
token = current_app.config.get("ADMIN_TOKEN")
if not token:
raise GiteaError(
"Falta GITEA_ADMIN_TOKEN: el servidor no puede cambiar contraseñas."
)
response = requests.patch(
_api(f"/admin/users/{quote(login, safe='')}"),
headers={"Authorization": f"token {token}"},
json={
"password": password,
"must_change_password": False,
"login_name": login,
"source_id": 0,
},
timeout=TIMEOUT,
)
if response.status_code in (200, 201):
return
if response.status_code == 422:
# Gitea enforces its own minimum length and complexity, and its message
# is in the admin's language rather than the member's, so it is not
# passed through.
raise GiteaError("Gitea rechazó esa contraseña. Prueba con una más larga.")
if response.status_code in (401, 403):
raise GiteaError("El token de administración de Gitea no es válido.")
raise GiteaError(f"Gitea rechazó el cambio de contraseña ({response.status_code}).")
# --- content (the member's own token) ------------------------------------ # --- content (the member's own token) ------------------------------------
def _contents_url(path: str) -> str: def _contents_url(path: str) -> str:

105
apps/board/invites.py Normal file
View File

@ -0,0 +1,105 @@
"""One-time links for setting a password.
Used for two things that are the same mechanism with different clocks: the
invitation a new member gets, and the reset an existing one asks for.
A token is a bearer credential — whoever holds it can set the password on that
account — so the rules are deliberately strict: single use, short-lived, and
only ever stored as a hash.
"""
from __future__ import annotations
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from .db import get_db
INVITE_LIFETIME = timedelta(days=7)
RESET_LIFETIME = timedelta(hours=1)
# How many resets one member may ask for before the rest are quietly dropped.
# Without this, the "forgot password" form is a way to mail-bomb somebody using
# your server's good name.
RESET_WINDOW = timedelta(minutes=15)
RESET_LIMIT = 3
def _hash(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _now() -> datetime:
return datetime.now(timezone.utc)
def issue(member_id: int, purpose: str) -> str:
"""Create a token, store its hash, return the token itself — once.
Any earlier unused token for the same member and purpose is marked used.
A member who asks for a second reset should not leave a first one lying in
an inbox, still working.
"""
lifetime = INVITE_LIFETIME if purpose == "invite" else RESET_LIFETIME
token = secrets.token_urlsafe(32)
db = get_db()
db.execute(
"""UPDATE invites SET used_at = datetime('now')
WHERE member_id = ? AND purpose = ? AND used_at IS NULL""",
(member_id, purpose),
)
db.execute(
"""INSERT INTO invites (member_id, token_hash, purpose, expires_at)
VALUES (?, ?, ?, ?)""",
(member_id, _hash(token), purpose, (_now() + lifetime).isoformat()),
)
return token
def rate_limited(member_id: int) -> bool:
"""Compared entirely inside SQLite, on purpose.
`created_at` is written by SQLite's own `datetime('now')`, which formats as
`2026-09-25 15:00:00` — a space, no offset. Python's `.isoformat()` produces
`2026-09-25T15:00:00+00:00`. Compared as strings, a space sorts before `T`,
so every stored row looks older than any Python-generated threshold and the
limit silently never fires. Letting SQLite compare its own format to its own
clock keeps the two conventions from ever meeting.
"""
row = get_db().execute(
"""SELECT COUNT(*) AS n FROM invites
WHERE member_id = ? AND purpose = 'reset'
AND created_at > datetime('now', ?)""",
(member_id, f"-{int(RESET_WINDOW.total_seconds() // 60)} minutes"),
).fetchone()
return row["n"] >= RESET_LIMIT
def lookup(token: str):
"""The member this token belongs to, or None if it is no good.
One return value for every kind of failure — unknown, used, expired — so a
caller cannot accidentally tell the holder which it was.
"""
if not token:
return None
row = get_db().execute(
"""SELECT i.id AS invite_id, i.purpose, i.expires_at, m.*
FROM invites i JOIN members m ON m.id = i.member_id
WHERE i.token_hash = ? AND i.used_at IS NULL""",
(_hash(token),),
).fetchone()
if row is None:
return None
if datetime.fromisoformat(row["expires_at"]) <= _now():
return None
if not row["active"] or row["role"] == "tombstone":
return None
return row
def consume(invite_id: int) -> None:
get_db().execute(
"UPDATE invites SET used_at = datetime('now') WHERE id = ?", (invite_id,)
)

114
apps/board/mail.py Normal file
View File

@ -0,0 +1,114 @@
"""Sending mail, through the association's own mailbox.
`smtplib` from the standard library rather than a mail framework: this sends
two kinds of message, both short and both plain text. A dependency would buy
templating and queueing that nothing here asks for.
Plain text only, no HTML alternative. An invitation is a sentence and a link —
HTML would add a second body to keep in step with the first, another place for
an escaping mistake, and a slightly worse chance of landing in the inbox rather
than the spam folder.
"""
from __future__ import annotations
import smtplib
import ssl
from email.message import EmailMessage
from flask import current_app
class MailNotConfigured(RuntimeError):
"""No SMTP host is set, so this server cannot send anything."""
class MailFailed(RuntimeError):
"""The mail server refused or could not be reached."""
def configured() -> bool:
return bool(current_app.config.get("MAIL_HOST"))
def send(to: str, subject: str, body: str) -> None:
if not configured():
raise MailNotConfigured(
"No hay servidor de correo configurado (MAIL_HOST)."
)
config = current_app.config
message = EmailMessage()
message["From"] = config["MAIL_FROM"]
message["To"] = to
message["Subject"] = subject
message.set_content(body)
host, port = config["MAIL_HOST"], int(config["MAIL_PORT"])
security = (config.get("MAIL_SECURITY") or "starttls").lower()
try:
# 465 speaks TLS from the first byte; 587 starts in the clear and
# upgrades. Getting this pair wrong is the usual reason a mailbox that
# works in a mail client fails here, so it is configuration rather than
# a guess from the port number.
if security == "ssl":
server = smtplib.SMTP_SSL(host, port, timeout=20,
context=ssl.create_default_context())
else:
server = smtplib.SMTP(host, port, timeout=20)
with server:
if security == "starttls":
server.starttls(context=ssl.create_default_context())
if config.get("MAIL_USER"):
server.login(config["MAIL_USER"], config["MAIL_PASSWORD"])
server.send_message(message)
except (smtplib.SMTPException, OSError, ssl.SSLError) as exc:
# The caller decides what to do about it — for an invitation that means
# showing the admin the link so the member is not stranded.
current_app.logger.warning("Mail to %s failed: %s", to, exc)
raise MailFailed(str(exc)) from exc
def send_invite(to: str, name: str, url: str) -> None:
send(
to,
"Tu acceso a Viena Latina",
f"""Hola {name},
Te damos de alta en el área de la comunidad de Viena Latina.
Elige tu contraseña aquí:
{url}
El enlace sirve una sola vez y caduca en 7 días. Si caduca, pide a un
administrador que te envíe uno nuevo.
Si no esperabas este correo, puedes ignorarlo.
— Viena Latina
""",
)
def send_reset(to: str, name: str, url: str) -> None:
send(
to,
"Restablecer tu contraseña — Viena Latina",
f"""Hola {name},
Alguien pidió restablecer la contraseña de tu cuenta.
Si fuiste tú, elige una nueva aquí:
{url}
El enlace sirve una sola vez y caduca en 1 hora.
Si no fuiste tú, ignora este correo: tu contraseña no ha cambiado.
— Viena Latina
""",
)

View File

@ -18,10 +18,10 @@ import json
import re import re
import sqlite3 import sqlite3
from flask import (Blueprint, Response, abort, flash, g, redirect, from flask import (Blueprint, Response, abort, current_app, flash, g, redirect,
render_template, request, url_for) render_template, request, url_for)
from . import gitea from . import auth, gitea, invites, mail
from .db import TOMBSTONE_LOGIN, get_db from .db import TOMBSTONE_LOGIN, get_db
from .security import admin_required, login_required, owner_required from .security import admin_required, login_required, owner_required
@ -121,7 +121,16 @@ def index():
@admin_required @admin_required
def new(): def new():
if request.method == "GET": if request.method == "GET":
return render_template("member_new.html", can_make_admin=g.member["role"] == "owner") return render_template(
"member_new.html",
can_make_admin=g.member["role"] == "owner",
# Without a site-admin token the server cannot create Gitea accounts,
# which is the documented safer configuration rather than a fault.
# The form says so before it is filled in; offering a ticked checkbox
# and reporting the problem on submit wastes the work of filling it.
can_create_accounts=bool(current_app.config.get("ADMIN_TOKEN")),
gitea_url=current_app.config["GITEA_URL"].rstrip("/"),
)
login = request.form.get("login", "").strip() login = request.form.get("login", "").strip()
display_name = request.form.get("display_name", "").strip() display_name = request.form.get("display_name", "").strip()
@ -143,17 +152,20 @@ def new():
flash("Ese usuario ya es miembro.", "error") flash("Ese usuario ya es miembro.", "error")
return redirect(url_for("members.new")) return redirect(url_for("members.new"))
password = None
if create_account: if create_account:
password = gitea.generate_password() # A random password nobody ever sees, not even the admin creating the
# account. It exists only so the Gitea account is not passwordless
# until the invitation is used — and because nobody knows it, the
# invitation is the only way in, which is the point.
try: try:
gitea.admin_create_user(login, email, display_name or login, password) gitea.admin_create_user(login, email, display_name or login,
gitea.generate_password())
except gitea.GiteaError as exc: except gitea.GiteaError as exc:
flash(str(exc), "error") flash(str(exc), "error")
return redirect(url_for("members.new")) return redirect(url_for("members.new"))
try: try:
db.execute( cursor = db.execute(
"""INSERT INTO members (gitea_login, display_name, email, role, created_by) """INSERT INTO members (gitea_login, display_name, email, role, created_by)
VALUES (?, ?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?)""",
(login, display_name or login, email, role, g.member["id"]), (login, display_name or login, email, role, g.member["id"]),
@ -162,8 +174,20 @@ def new():
flash("No se pudo dar de alta a ese miembro.", "error") flash("No se pudo dar de alta a ese miembro.", "error")
return redirect(url_for("members.new")) return redirect(url_for("members.new"))
# Shown once and never stored: Gitea has the hash, we have nothing. invite_link = None
return render_template("member_created.html", login=login, password=password, if create_account:
link = auth.invite_url(invites.issue(cursor.lastrowid, "invite"))
try:
mail.send_invite(email, display_name or login, link)
except (mail.MailFailed, mail.MailNotConfigured):
# The account exists and the member cannot reach it. Showing the
# admin the link is the difference between a delayed invitation and
# a person who simply never gets in.
invite_link = link
return render_template("member_created.html", login=login,
email=email, created=create_account,
invite_link=invite_link,
role_label=ROLE_LABELS[role]) role_label=ROLE_LABELS[role])

View File

@ -89,3 +89,26 @@ CREATE TABLE IF NOT EXISTS content_cache (
generated INTEGER NOT NULL DEFAULT 0 CHECK (generated IN (0, 1)), generated INTEGER NOT NULL DEFAULT 0 CHECK (generated IN (0, 1)),
updated_at TEXT NOT NULL DEFAULT (datetime('now')) updated_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
-- One-time links: invitations to set a first password, and password resets.
--
-- A token here is enough to take over an account, so only its SHA-256 lives in
-- this table. A database backup that leaks is then a list of useless hashes
-- rather than a set of live keys.
--
-- SHA-256 rather than a password hash on purpose: these are 32 random bytes
-- from secrets.token_urlsafe, not something a person chose. There is no
-- dictionary to run against them, so the slow hashing that protects weak
-- passwords buys nothing and costs a round trip on every click.
CREATE TABLE IF NOT EXISTS invites (
id INTEGER PRIMARY KEY,
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
purpose TEXT NOT NULL CHECK (purpose IN ('invite', 'reset')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL,
used_at TEXT
);
CREATE INDEX IF NOT EXISTS invites_open
ON invites(member_id, purpose) WHERE used_at IS NULL;

View File

@ -9,6 +9,9 @@
cuenta que se usa para publicar en el sitio. cuenta que se usa para publicar en el sitio.
</p> </p>
<a class="btn" href="{{ url_for('auth.start', next=next) }}">Entrar con Gitea</a> <a class="btn" href="{{ url_for('auth.start', next=next) }}">Entrar con Gitea</a>
<p class="muted small">
<a href="{{ url_for('auth.recover') }}">¿Olvidaste tu contraseña?</a>
</p>
<p class="muted small"> <p class="muted small">
¿No tienes cuenta? Pídesela a un administrador: las cuentas se crean a mano, ¿No tienes cuenta? Pídesela a un administrador: las cuentas se crean a mano,
no hay registro abierto. no hay registro abierto.

View File

@ -5,19 +5,33 @@
<article class="card"> <article class="card">
<h1>{{ login }} ya es {{ role_label|lower }}</h1> <h1>{{ login }} ya es {{ role_label|lower }}</h1>
{% if password %} {% if not created %}
<p>Esta contraseña se muestra <strong>una sola vez</strong>. Cópiala ahora y
entrégasela en persona o por un canal privado.</p>
<p class="password mono">{{ password }}</p>
<p class="muted small">
No se guarda en ningún sitio: el servidor sólo conserva el hash, igual que
con cualquier contraseña. Si se pierde, hay que restablecerla desde Gitea.
La persona tendrá que cambiarla la primera vez que entre.
</p>
{% else %}
<p>No se creó ninguna cuenta nueva: ya existía. Puede entrar con la que tenía.</p> <p>No se creó ninguna cuenta nueva: ya existía. Puede entrar con la que tenía.</p>
{% elif invite_link %}
{# The account exists but the invitation never left the building. Showing the
link is the difference between a delayed invitation and a person who
simply never gets in. #}
<p class="flash flash--error">
No se pudo enviar el correo. Pásale este enlace por un canal privado —
sirve una sola vez y caduca en 7 días.
</p>
<p class="password mono">{{ invite_link }}</p>
<p class="muted small">
Revisa la configuración de correo del servidor para que la próxima
invitación salga sola.
</p>
{% else %}
<p>
Le enviamos un correo a <span class="mono">{{ email }}</span> con un enlace
para elegir su contraseña.
</p>
<p class="muted small">
El enlace caduca en 7 días y sirve una sola vez. Nadie conoce su contraseña
— ni tú ni el servidor — hasta que la elija. Si no llega, que mire en spam,
o pídele que use «¿olvidaste tu contraseña?».
</p>
{% endif %} {% endif %}
<div class="actions"> <div class="actions">

View File

@ -17,10 +17,25 @@
<label for="email">Correo</label> <label for="email">Correo</label>
<input id="email" name="email" type="email" placeholder="maria@ejemplo.com"> <input id="email" name="email" type="email" placeholder="maria@ejemplo.com">
{% if can_create_accounts %}
<label class="check"> <label class="check">
<input type="checkbox" name="create_account" checked> <input type="checkbox" name="create_account" checked>
Crear también su cuenta (desmarca si ya tiene una) Crear también su cuenta (desmarca si ya tiene una)
</label> </label>
{% else %}
{# Disabled rather than hidden: "you cannot do this here" is more use than
an option that quietly is not there. The handler refuses it either way. #}
<label class="check">
<input type="checkbox" name="create_account" disabled>
Crear también su cuenta
</label>
<p class="muted small">
Este servidor no puede crear cuentas (no tiene un token de administración
de Gitea). Crea la cuenta primero en
<a href="{{ gitea_url }}/admin/users/new">Gitea</a> y luego da de alta
aquí ese mismo usuario.
</p>
{% endif %}
{% if can_make_admin %} {% if can_make_admin %}
<label for="role">Rol</label> <label for="role">Rol</label>

View File

@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}Recuperar contraseña{% endblock %}
{% block main %}
{% if sent %}
<article class="card card--center">
<h1>Revisa tu correo</h1>
{# Says the same thing whether or not that address belongs to a member.
Confirming it would turn this form into a way to find out who is one. #}
<p class="muted">
Si esa dirección pertenece a un miembro, le enviamos un enlace para elegir
una contraseña nueva. Caduca en una hora.
</p>
<p><a class="linkish" href="{{ url_for('auth.login') }}">Volver a entrar</a></p>
</article>
{% else %}
<form class="card" method="post" action="{{ url_for('auth.recover') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h1>¿Olvidaste tu contraseña?</h1>
<p class="muted">
Escribe el correo con el que te dieron de alta y te enviamos un enlace para
elegir una nueva.
</p>
<label for="email">Correo</label>
<input id="email" name="email" type="email" required autocomplete="email">
<div class="actions">
<button class="btn" type="submit">Enviar enlace</button>
<a class="linkish" href="{{ url_for('auth.login') }}">Cancelar</a>
</div>
</form>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}Elige tu contraseña{% endblock %}
{% block main %}
{% if member is none %}
{# One message for expired, already used and never existed alike. Which one it
was would tell whoever holds a stale link something about the account. #}
<article class="card card--center">
<h1>Este enlace ya no sirve</h1>
<p class="muted">
Los enlaces caducan y sólo se pueden usar una vez. Pide uno nuevo desde
<a href="{{ url_for('auth.recover') }}">¿olvidaste tu contraseña?</a>,
o a un administrador si aún no habías entrado nunca.
</p>
</article>
{% else %}
<form class="card" method="post" action="{{ url_for('auth.set_password', token=token) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<h1>Hola, {{ member.display_name }}</h1>
<p class="muted">
Elige una contraseña para entrar al área de la comunidad. Tu usuario es
<span class="mono">{{ member.gitea_login }}</span>.
</p>
<label for="password">Contraseña</label>
<input id="password" name="password" type="password" required
minlength="{{ minimum }}" autocomplete="new-password">
<p class="muted small">Al menos {{ minimum }} caracteres.</p>
<label for="confirm">Repite la contraseña</label>
<input id="confirm" name="confirm" type="password" required
minlength="{{ minimum }}" autocomplete="new-password">
<div class="actions">
<button class="btn" type="submit">Guardar contraseña</button>
</div>
</form>
{% endif %}
{% endblock %}

View File

@ -83,6 +83,11 @@ def post(client):
"""POST with a valid CSRF token, so tests exercise authorisation rather """POST with a valid CSRF token, so tests exercise authorisation rather
than repeatedly rediscovering that the CSRF hook works.""" than repeatedly rediscovering that the CSRF hook works."""
def _post(url, data=None, **kwargs): def _post(url, data=None, **kwargs):
# A real anonymous visitor gets a CSRF token when the form renders, so
# signed-out pages (invitations, password recovery) need one here too —
# otherwise every such test fails on the hook rather than on its subject.
with client.session_transaction() as session:
session.setdefault("csrf", "token-for-tests")
payload = dict(data or {}) payload = dict(data or {})
payload.setdefault("csrf_token", "token-for-tests") payload.setdefault("csrf_token", "token-for-tests")
return client.post(url, data=payload, **kwargs) return client.post(url, data=payload, **kwargs)

View File

@ -0,0 +1,264 @@
"""Invitations, password resets, and the rules that keep a link from being a
permanent key to somebody's account.
A token here is a bearer credential: whoever holds it sets the password. So
most of these tests are about the ways a token must *stop* working, and about
what the pages give away to somebody who is only guessing.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from apps.board import gitea, invites, mail
@pytest.fixture
def outbox(monkeypatch):
"""Mail captured rather than sent. No SMTP anywhere in the suite."""
sent = []
monkeypatch.setattr(mail, "send", lambda to, subject, body: sent.append(
{"to": to, "subject": subject, "body": body}))
return sent
@pytest.fixture
def passwords(monkeypatch):
"""Gitea's password API stubbed; the calls are what matters."""
changed = []
monkeypatch.setattr(gitea, "admin_set_password",
lambda login, password: changed.append((login, password)))
return changed
def link_in(message: str) -> str:
for word in message.split():
if "/comunidad/invitacion/" in word:
return word
raise AssertionError("no invite link in the message")
def token_in(message: str) -> str:
return link_in(message).rsplit("/", 1)[1]
# --- the tokens themselves ------------------------------------------------
def test_the_database_never_holds_the_token_itself(app, db, make_member):
"""A leaked backup should be a list of useless hashes, not live keys."""
with app.test_request_context():
member_id = make_member("maria")
token = invites.issue(member_id, "invite")
stored = db.execute("SELECT token_hash FROM invites").fetchone()["token_hash"]
assert token not in stored
assert len(stored) == 64 # sha256 hex, not the 43-char token
def test_a_token_works_once(app, db, make_member):
with app.test_request_context():
member_id = make_member("maria")
token = invites.issue(member_id, "invite")
found = invites.lookup(token)
assert found["id"] == member_id
invites.consume(found["invite_id"])
assert invites.lookup(token) is None
def test_an_expired_token_is_refused(app, db, make_member):
with app.test_request_context():
member_id = make_member("maria")
token = invites.issue(member_id, "reset")
db.execute(
"UPDATE invites SET expires_at = ? WHERE member_id = ?",
((datetime.now(timezone.utc) - timedelta(minutes=1)).isoformat(), member_id),
)
assert invites.lookup(token) is None
def test_issuing_a_new_token_kills_the_old_one(app, db, make_member):
"""Asking for a second reset should not leave the first one live in an
inbox somebody else can read."""
with app.test_request_context():
member_id = make_member("maria")
first = invites.issue(member_id, "reset")
second = invites.issue(member_id, "reset")
assert invites.lookup(first) is None
assert invites.lookup(second) is not None
def test_a_suspended_member_cannot_use_their_link(app, db, make_member):
with app.test_request_context():
member_id = make_member("expulsada")
token = invites.issue(member_id, "invite")
db.execute("UPDATE members SET active = 0 WHERE id = ?", (member_id,))
assert invites.lookup(token) is None
def test_a_made_up_token_is_refused(app):
with app.test_request_context():
assert invites.lookup("not-a-real-token") is None
assert invites.lookup("") is None
# --- setting the password -------------------------------------------------
def test_a_member_sets_their_own_password(app, client, db, post, make_member, passwords):
with app.test_request_context():
member_id = make_member("maria")
token = invites.issue(member_id, "invite")
response = post(f"/comunidad/invitacion/{token}",
{"password": "una-contrasena-larga", "confirm": "una-contrasena-larga"})
assert response.status_code == 302
assert passwords == [("maria", "una-contrasena-larga")]
assert db.execute("SELECT used_at FROM invites").fetchone()["used_at"] is not None
def test_a_short_password_is_refused_and_the_link_survives(
app, client, db, post, make_member, passwords):
"""Rejecting the password must not spend the token, or a typo locks the
member out of an account they have never reached."""
with app.test_request_context():
member_id = make_member("maria")
token = invites.issue(member_id, "invite")
response = post(f"/comunidad/invitacion/{token}",
{"password": "corta", "confirm": "corta"})
assert response.status_code == 400
assert passwords == []
assert db.execute("SELECT used_at FROM invites").fetchone()["used_at"] is None
assert client.get(f"/comunidad/invitacion/{token}").status_code == 200
def test_mismatched_passwords_are_refused(app, post, make_member, passwords):
with app.test_request_context():
token = invites.issue(make_member("maria"), "invite")
response = post(f"/comunidad/invitacion/{token}",
{"password": "una-contrasena-larga", "confirm": "otra-cosa-larga"})
assert response.status_code == 400
assert passwords == []
def test_a_rejection_from_gitea_leaves_the_link_usable(
app, db, post, make_member, monkeypatch):
def refuse(login, password):
raise gitea.GiteaError("Gitea rechazó esa contraseña.")
monkeypatch.setattr(gitea, "admin_set_password", refuse)
with app.test_request_context():
token = invites.issue(make_member("maria"), "invite")
response = post(f"/comunidad/invitacion/{token}",
{"password": "una-contrasena-larga", "confirm": "una-contrasena-larga"})
assert response.status_code == 400
assert db.execute("SELECT used_at FROM invites").fetchone()["used_at"] is None
def test_a_dead_link_says_nothing_about_the_account(client):
body = client.get("/comunidad/invitacion/inventado").get_data(as_text=True)
assert "ya no sirve" in body
# Not "expired", not "already used", not "unknown" — those distinctions tell
# the holder of a stale link something about the account behind it.
assert "caducado" not in body
# --- recovery -------------------------------------------------------------
def test_recovery_emails_a_member(app, client, post, db, make_member, outbox):
make_member("maria")
response = post("/comunidad/recuperar", {"email": "maria@example.com"})
assert response.status_code == 200
assert len(outbox) == 1
assert outbox[0]["to"] == "maria@example.com"
with app.test_request_context():
assert invites.lookup(token_in(outbox[0]["body"])) is not None
def test_recovery_answers_the_same_for_an_unknown_address(client, post, outbox):
"""Otherwise the form is a way to find out who is a member, one address at
a time."""
known = post("/comunidad/recuperar", {"email": "maria@example.com"})
unknown = post("/comunidad/recuperar", {"email": "nadie@example.com"})
assert known.status_code == unknown.status_code == 200
assert known.get_data() == unknown.get_data()
assert outbox == []
def test_recovery_stops_after_a_few_tries(client, post, make_member, outbox):
"""A reset form with no limit is a way to mail-bomb somebody using your
server's reputation."""
make_member("maria")
for _ in range(6):
post("/comunidad/recuperar", {"email": "maria@example.com"})
assert len(outbox) == invites.RESET_LIMIT
def test_a_suspended_member_gets_no_reset(client, post, make_member, outbox):
make_member("expulsada", active=0)
post("/comunidad/recuperar", {"email": "expulsada@example.com"})
assert outbox == []
# --- inviting from the members screen -------------------------------------
def test_creating_a_member_emails_them_instead_of_showing_a_password(
app, client, post, owner_id, sign_in, outbox, monkeypatch):
monkeypatch.setattr(gitea, "admin_create_user",
lambda login, email, name, password: None)
sign_in(owner_id)
response = post("/comunidad/miembros/nuevo", {
"login": "maria", "display_name": "María", "email": "m@example.com",
"role": "user", "create_account": "on",
})
page = response.get_data(as_text=True)
assert len(outbox) == 1
assert outbox[0]["to"] == "m@example.com"
assert "m@example.com" in page
# The admin never sees a password, so there is none to pass on or mislay.
assert "/comunidad/invitacion/" not in page
def test_when_mail_fails_the_admin_is_given_the_link(
app, client, post, owner_id, sign_in, monkeypatch):
"""Otherwise the account exists and the member simply never gets in."""
monkeypatch.setattr(gitea, "admin_create_user",
lambda login, email, name, password: None)
def explode(to, subject, body):
raise mail.MailFailed("connection refused")
monkeypatch.setattr(mail, "send", explode)
sign_in(owner_id)
response = post("/comunidad/miembros/nuevo", {
"login": "maria", "display_name": "María", "email": "m@example.com",
"role": "user", "create_account": "on",
})
page = response.get_data(as_text=True)
assert "No se pudo enviar el correo" in page
assert "/comunidad/invitacion/" in page
def test_linking_an_existing_account_sends_nothing(
app, client, post, owner_id, sign_in, outbox):
"""They already have a password; an unexpected invitation would be noise."""
sign_in(owner_id)
post("/comunidad/miembros/nuevo", {
"login": "maria", "display_name": "María", "email": "m@example.com",
"role": "user", "create_account": "",
})
assert outbox == []

View File

@ -101,18 +101,26 @@ def test_an_admin_cannot_demote_another_admin(client, post, make_member, sign_in
assert response.status_code == 403 assert response.status_code == 403
def test_creating_a_user_shows_the_password_once(client, monkeypatch, post, owner_id, sign_in): def test_the_password_is_never_shown_to_the_admin(
client, monkeypatch, post, owner_id, sign_in):
"""It used to be, printed once for the admin to pass on. Now the member is
emailed a link and chooses their own, so the generated password exists only
to keep the Gitea account from being reachable before they do — and nobody,
the admin included, ever learns it."""
created = {} created = {}
monkeypatch.setattr(gitea, "admin_create_user", monkeypatch.setattr(gitea, "admin_create_user",
lambda login, email, name, password: created.update( lambda login, email, name, password: created.update(
login=login, password=password)) login=login, password=password))
monkeypatch.setattr("apps.board.mail.send", lambda to, subject, body: None)
sign_in(owner_id) sign_in(owner_id)
response = post("/comunidad/miembros/nuevo", { response = post("/comunidad/miembros/nuevo", {
"login": "maria", "display_name": "María", "email": "m@example.com", "login": "maria", "display_name": "María", "email": "m@example.com",
"role": "user", "create_account": "on", "role": "user", "create_account": "on",
}) })
assert created["login"] == "maria" assert created["login"] == "maria"
assert created["password"].encode() in response.data assert created["password"].encode() not in response.data
def test_a_rejected_gitea_call_creates_no_member(client, monkeypatch, db, post, owner_id, sign_in): def test_a_rejected_gitea_call_creates_no_member(client, monkeypatch, db, post, owner_id, sign_in):
@ -182,3 +190,40 @@ def test_the_export_is_only_your_own_writing(client, db, make_member, sign_in):
body = client.get("/comunidad/mis-datos").get_data(as_text=True) body = client.get("/comunidad/mis-datos").get_data(as_text=True)
assert "Mío" in body assert "Mío" in body
assert "Suyo" not in body assert "Suyo" not in body
def test_the_form_says_so_when_accounts_cannot_be_created(app, client, owner_id, sign_in):
"""Without a site-admin token the server cannot create Gitea accounts. That
is the documented safer setup, not a fault — but it has to be said before
someone fills the form, not after they submit it."""
app.config["ADMIN_TOKEN"] = ""
sign_in(owner_id)
body = client.get("/comunidad/miembros/nuevo").get_data(as_text=True)
assert "no puede crear cuentas" in body
assert 'name="create_account" disabled' in body
assert 'name="create_account" checked' not in body
def test_with_a_token_the_form_is_unchanged(app, client, owner_id, sign_in):
app.config["ADMIN_TOKEN"] = "admintoken"
sign_in(owner_id)
body = client.get("/comunidad/miembros/nuevo").get_data(as_text=True)
assert 'name="create_account" checked' in body
assert "no puede crear cuentas" not in body
def test_ticking_it_anyway_still_creates_nothing(app, client, db, post, owner_id, sign_in):
"""A disabled input is a courtesy, not a permission — the refusal lives in
the handler, where a hand-crafted POST also meets it."""
app.config["ADMIN_TOKEN"] = ""
sign_in(owner_id)
response = post("/comunidad/miembros/nuevo", {
"login": "maria", "display_name": "María", "email": "m@example.com",
"role": "user", "create_account": "on",
}, follow_redirects=True)
assert "GITEA_ADMIN_TOKEN" in response.get_data(as_text=True)
assert db.execute("SELECT 1 FROM members WHERE gitea_login = 'maria'").fetchone() is None

View File

@ -346,6 +346,11 @@ board's environment — the compose file, `docker inspect`, a shell in the
container — can use it. If you would rather not have that on the box, leave container — can use it. If you would rather not have that on the box, leave
`GITEA_ADMIN_TOKEN` empty and create accounts in Gitea by hand. `GITEA_ADMIN_TOKEN` empty and create accounts in Gitea by hand.
Leaving it empty is a supported configuration, not a half-finished one: the
*Dar de alta* form checks for the token when it renders, says plainly that this
server cannot create accounts, and links to Gitea's own create-user page. You
then add that username here, with the checkbox already off.
### 11.3 Build and run ### 11.3 Build and run
```sh ```sh
@ -574,3 +579,85 @@ So the logout page says plainly what is and is not closed, and offers the link
that finishes the job. On a shared computer, use it — or close the browser, that finishes the job. On a shared computer, use it — or close the browser,
which also works. The members-area cookie is already a browser-session cookie, which also works. The members-area cookie is already a browser-session cookie,
so it does not survive that either way. so it does not survive that either way.
## 13. Email: invitations and passwords
Until this is configured, an admin can add members but **nobody else can get
in**. The password was shown once to the admin, and Gitea's *Forgot password*
answers "Account recovery is disabled because no email is set up". That was the
state the members area shipped in; this section is what fixes it.
### 13.1 What happens now
An admin enters a username and an email. The server creates the Gitea account
with a random password **nobody ever sees, the admin included**, and emails the
member a link. The link opens a Viena Latina page where they choose their own
password, and only then can the account be used.
The same mechanism powers *¿Olvidaste tu contraseña?* on the sign-in page, which
replaces Gitea's dead recovery page. Nobody leaves the site for either.
### 13.2 SMTP settings
These are the details of the `hola@vienalatina.com` mailbox. If that mailbox is
part of the old Hetzner shared hosting, they are in the Konsole panel under the
email account.
```sh
sudo nano /srv/board/.env
```
```
MAIL_HOST=
MAIL_PORT=587
MAIL_SECURITY=starttls
MAIL_USER=hola@vienalatina.com
MAIL_PASSWORD=
MAIL_FROM=Viena Latina <hola@vienalatina.com>
```
**Port and security go together.** 465 means `MAIL_SECURITY=ssl`; 587 means
`starttls`. Mismatching the pair is the usual reason a mailbox that works
perfectly in a mail client fails here, and the error it produces is a timeout
rather than anything that names the cause.
```sh
cd /srv/board && sudo docker compose up -d --force-recreate
```
Test it by adding a member with an address you can read. If the mail cannot be
sent, the screen says so and shows you the invitation link to pass on by hand —
the account is created either way, so a mail problem delays somebody rather
than stranding them.
### 13.3 What the links are, and why they expire
A link is enough to set the password on that account, so it is treated as a
credential:
- **single use** — following it and choosing a password spends it
- **invitations last 7 days, resets 1 hour**
- **only a hash is stored**, so a leaked database backup is a list of useless
hashes rather than a set of live keys
- asking for a new link **invalidates the previous one**, so an older email
sitting in an inbox stops working
- recovery answers identically for an address that belongs to a member and one
that does not, and stops after three attempts in fifteen minutes
If a member says a link does not work, the fix is always to send another. There
is deliberately no way to find out *why* one failed from the page itself: that
distinction would tell whoever holds a stale link something about the account
behind it.
### 13.4 The sign-in page loses two tabs
`GITEA__openid__ENABLE_OPENID_SIGNIN=false` and
`GITEA__service__SHOW_REGISTRATION_BUTTON=false` in
`/srv/gitea/docker-compose.yml`. OpenID is sign-in with an external identity
URL, which nobody here will use, and the register button contradicts
`DISABLE_REGISTRATION` — it invited people to try something the server then
refused.
```sh
cd /srv/gitea && sudo docker compose up -d
```

View File

@ -18,3 +18,17 @@ BOARD_OWNER=pablo
# the members area. Without it, everything works except account creation, and # the members area. Without it, everything works except account creation, and
# admins add people who already have a Gitea login. # admins add people who already have a Gitea login.
GITEA_ADMIN_TOKEN= GITEA_ADMIN_TOKEN=
# Outgoing mail, for invitations and password resets. Without it an admin can
# still add members, but nobody can be invited and nobody can recover a
# password — which is the whole reason members could not get in before.
#
# These are the SMTP details of the hola@vienalatina.com mailbox. Port 465 means
# MAIL_SECURITY=ssl; port 587 means starttls. Using the wrong one of the pair is
# the usual reason a mailbox that works in a mail client fails here.
MAIL_HOST=
MAIL_PORT=587
MAIL_SECURITY=starttls
MAIL_USER=hola@vienalatina.com
MAIL_PASSWORD=
MAIL_FROM=Viena Latina <hola@vienalatina.com>

View File

@ -30,6 +30,15 @@ services:
# visitors get, which is exactly those two pages. # visitors get, which is exactly those two pages.
# Install the theme first: bash scripts/gitea-theme.sh # Install the theme first: bash scripts/gitea-theme.sh
- GITEA__ui__DEFAULT_THEME=vienalatina - GITEA__ui__DEFAULT_THEME=vienalatina
# Two tabs on the sign-in page that should not be offered here.
# OpenID is sign-in with an external identity URL, which nobody in
# this association will ever use; the register button contradicts
# DISABLE_REGISTRATION above, which is worse than useless — it invites
# people to try something the server then refuses.
- GITEA__openid__ENABLE_OPENID_SIGNIN=false
- GITEA__openid__ENABLE_OPENID_SIGNUP=false
- GITEA__service__SHOW_REGISTRATION_BUTTON=false
volumes: volumes:
- ./data:/data - ./data:/data
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro