Merge branch 'claude/relaxed-faraday-h4zd09' of https://github.com/pablovolenski/vienalatina
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
commit
e55ea94619
@ -55,6 +55,15 @@ def create_app(overrides: dict | None = None) -> Flask:
|
||||
# publishing from Decap as far as the pipeline is concerned.
|
||||
CONTENT_REPO=os.environ.get("CONTENT_REPO", "pablo/vienalatina"),
|
||||
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,
|
||||
COOLDOWN_SECONDS=int(os.environ.get("BOARD_COOLDOWN_SECONDS", "20")),
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
|
||||
@ -14,11 +14,15 @@ import secrets
|
||||
from flask import (Blueprint, current_app, flash, g, redirect, render_template,
|
||||
request, session, url_for)
|
||||
|
||||
from . import gitea, tokens
|
||||
from . import gitea, invites, mail, tokens
|
||||
from .db import get_db
|
||||
|
||||
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:
|
||||
return current_app.config["BASE_URL"].rstrip("/") + url_for("auth.callback")
|
||||
@ -129,6 +133,82 @@ def callback():
|
||||
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"])
|
||||
def logout():
|
||||
# Drop the Gitea token too. Signing out should stop the server being able to
|
||||
|
||||
@ -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}).")
|
||||
|
||||
|
||||
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) ------------------------------------
|
||||
|
||||
def _contents_url(path: str) -> str:
|
||||
|
||||
105
apps/board/invites.py
Normal file
105
apps/board/invites.py
Normal 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
114
apps/board/mail.py
Normal 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
|
||||
""",
|
||||
)
|
||||
@ -18,10 +18,10 @@ import json
|
||||
import re
|
||||
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)
|
||||
|
||||
from . import gitea
|
||||
from . import auth, gitea, invites, mail
|
||||
from .db import TOMBSTONE_LOGIN, get_db
|
||||
from .security import admin_required, login_required, owner_required
|
||||
|
||||
@ -121,7 +121,16 @@ def index():
|
||||
@admin_required
|
||||
def new():
|
||||
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()
|
||||
display_name = request.form.get("display_name", "").strip()
|
||||
@ -143,17 +152,20 @@ def new():
|
||||
flash("Ese usuario ya es miembro.", "error")
|
||||
return redirect(url_for("members.new"))
|
||||
|
||||
password = None
|
||||
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:
|
||||
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:
|
||||
flash(str(exc), "error")
|
||||
return redirect(url_for("members.new"))
|
||||
|
||||
try:
|
||||
db.execute(
|
||||
cursor = db.execute(
|
||||
"""INSERT INTO members (gitea_login, display_name, email, role, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(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")
|
||||
return redirect(url_for("members.new"))
|
||||
|
||||
# Shown once and never stored: Gitea has the hash, we have nothing.
|
||||
return render_template("member_created.html", login=login, password=password,
|
||||
invite_link = None
|
||||
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])
|
||||
|
||||
|
||||
|
||||
@ -89,3 +89,26 @@ CREATE TABLE IF NOT EXISTS content_cache (
|
||||
generated INTEGER NOT NULL DEFAULT 0 CHECK (generated IN (0, 1)),
|
||||
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;
|
||||
|
||||
@ -9,6 +9,9 @@
|
||||
cuenta que se usa para publicar en el sitio.
|
||||
</p>
|
||||
<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">
|
||||
¿No tienes cuenta? Pídesela a un administrador: las cuentas se crean a mano,
|
||||
no hay registro abierto.
|
||||
|
||||
@ -5,19 +5,33 @@
|
||||
<article class="card">
|
||||
<h1>{{ login }} ya es {{ role_label|lower }}</h1>
|
||||
|
||||
{% if password %}
|
||||
<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 %}
|
||||
{% if not created %}
|
||||
<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 %}
|
||||
|
||||
<div class="actions">
|
||||
|
||||
@ -17,10 +17,25 @@
|
||||
<label for="email">Correo</label>
|
||||
<input id="email" name="email" type="email" placeholder="maria@ejemplo.com">
|
||||
|
||||
{% if can_create_accounts %}
|
||||
<label class="check">
|
||||
<input type="checkbox" name="create_account" checked>
|
||||
Crear también su cuenta (desmarca si ya tiene una)
|
||||
</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 %}
|
||||
<label for="role">Rol</label>
|
||||
|
||||
34
apps/board/templates/recover.html
Normal file
34
apps/board/templates/recover.html
Normal 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 %}
|
||||
39
apps/board/templates/set_password.html
Normal file
39
apps/board/templates/set_password.html
Normal 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 %}
|
||||
@ -83,6 +83,11 @@ def post(client):
|
||||
"""POST with a valid CSRF token, so tests exercise authorisation rather
|
||||
than repeatedly rediscovering that the CSRF hook works."""
|
||||
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.setdefault("csrf_token", "token-for-tests")
|
||||
return client.post(url, data=payload, **kwargs)
|
||||
|
||||
264
apps/board/tests/test_invites.py
Normal file
264
apps/board/tests/test_invites.py
Normal 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 == []
|
||||
@ -101,18 +101,26 @@ def test_an_admin_cannot_demote_another_admin(client, post, make_member, sign_in
|
||||
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 = {}
|
||||
monkeypatch.setattr(gitea, "admin_create_user",
|
||||
lambda login, email, name, password: created.update(
|
||||
login=login, password=password))
|
||||
monkeypatch.setattr("apps.board.mail.send", lambda to, subject, body: 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",
|
||||
})
|
||||
|
||||
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):
|
||||
@ -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)
|
||||
assert "Mío" 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
|
||||
|
||||
@ -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
|
||||
`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
|
||||
|
||||
```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,
|
||||
which also works. The members-area cookie is already a browser-session cookie,
|
||||
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
|
||||
```
|
||||
|
||||
@ -18,3 +18,17 @@ BOARD_OWNER=pablo
|
||||
# the members area. Without it, everything works except account creation, and
|
||||
# admins add people who already have a Gitea login.
|
||||
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>
|
||||
|
||||
@ -30,6 +30,15 @@ services:
|
||||
# visitors get, which is exactly those two pages.
|
||||
# Install the theme first: bash scripts/gitea-theme.sh
|
||||
- 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:
|
||||
- ./data:/data
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
|
||||
Loading…
Reference in New Issue
Block a user