Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ LOG_LEVEL=INFO
# python -c "import secrets; print(secrets.token_urlsafe(32))"
SECRET_KEY=changeme

# Optional. A way back into the dashboard if you forget your password and have
# no recovery code (every account is shown one when it is created). Put it in
# the deployment's environment, then give it on the Forgot password page with
# your email. At least 24 characters; leave it unset to turn it off.
# python -c "import secrets; print(secrets.token_urlsafe(32))"
# RECOVERY_KEY=

# Apply migrations when the container starts, before the app does.
#
# Off by default, and left off under Compose: the one-shot "migrate" service
Expand Down
5 changes: 5 additions & 0 deletions apps/api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ dependencies = [
"alembic>=1.13",
]

[project.scripts]
# `docker compose exec api seskit reset-password`. Named for the product rather
# than the package, because it is what an operator types.
seskit = "seskit_api.cli:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Expand Down
165 changes: 165 additions & 0 deletions apps/api/src/seskit_api/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
"""The ``seskit`` command (§24): what an operator does with a shell and no browser.

seskit reset-password
docker compose exec api seskit reset-password
docker compose exec api python -m seskit_api.cli reset-password

**Why this is the first command.** A forgotten password is the one problem the
dashboard cannot solve for itself. There is no emailed reset link, on purpose: a
fresh instance may not be able to send email until somebody can log in and
connect AWS, which is exactly when a password gets forgotten.

This is for an operator with a shell. Someone without one - a deployment made by
pushing to Git has none - recovers from the Forgot password page with a recovery
code or the ``RECOVERY_KEY``; see ``seskit_core.security.recovery``.

**Why it asks rather than accepts.** The password is read from a hidden prompt
and is deliberately not an argument or an environment variable: either would put
it in shell history or a process listing. The email may be an argument, because
it is not a secret.

**Why it ends the account's sessions.** Someone resetting a password has either
lost it or lost control of it, and in the second case every session already open
is the thing being reset away. Ending them costs a legitimate user one login.

It does not reactivate a deactivated account, and says so.
"""

from __future__ import annotations

import argparse
import asyncio
import getpass
import sys
from collections.abc import Sequence
from dataclasses import dataclass

from redis.asyncio import Redis
from seskit_core.db import dispose_engine, get_session_factory
from seskit_core.redis import close_redis, get_redis
from seskit_core.security.sessions import delete_user_sessions
from seskit_core.services import MIN_PASSWORD_LENGTH, PasswordTooShort, reset_password
from sqlalchemy.ext.asyncio import AsyncSession

EXIT_OK = 0
EXIT_NO_ACCOUNT = 1
EXIT_USAGE = 2


@dataclass(frozen=True, slots=True)
class Reset:
"""What a reset did, for the command to report."""

is_active: bool
sessions_ended: int


async def reset_account_password(
session: AsyncSession, redis: Redis, *, email: str, password: str
) -> Reset | None:
"""Set the password, then end the account's sessions. None when there is no account.

In that order: the new password is committed before anything is revoked, so a
Redis failure leaves the password changed and the old sessions standing -
which is recoverable by running it again - rather than sessions revoked and
the old password still in force.

Raises :class:`PasswordTooShort`.
"""
user = await reset_password(session, email=email, password=password)
if user is None:
return None
await session.commit()

ended = await delete_user_sessions(redis, user.id)
return Reset(is_active=user.is_active, sessions_ended=ended)


def _fail(message: str, code: int) -> int:
print(message, file=sys.stderr)
return code


#: Shown when the address matches nothing. A sentence, not a query the program
#: runs; it assumes the Compose stack because that is how SESKit is installed.
LIST_ACCOUNTS_HINT = (
"The addresses on this instance: "
'docker compose exec db psql -U seskit seskit -c "select email from users;"'
)


def _prompt(email: str | None) -> tuple[str, str] | int:
"""The address and the new password, or an exit code when they cannot be had.

Synchronous and before the event loop starts: ``input`` and ``getpass``
block, and nothing else is waiting on this process.
"""
address = (email or input("Account email: ")).strip()
if not address:
return _fail("An email address is required.", EXIT_USAGE)

password = getpass.getpass("New password: ")
if getpass.getpass("Again: ") != password:
return _fail("The two passwords differ. Nothing was changed.", EXIT_USAGE)
return address, password


async def _reset_password_command(address: str, password: str) -> int:
try:
async with get_session_factory()() as session:
result = await reset_account_password(
session, get_redis(), email=address, password=password
)
except PasswordTooShort:
return _fail(
f"Use at least {MIN_PASSWORD_LENGTH} characters. Nothing was changed.", EXIT_USAGE
)
finally:
await dispose_engine()
await close_redis()

if result is None:
return _fail(
f"There is no account for {address}. Nothing was changed.\n{LIST_ACCOUNTS_HINT}",
EXIT_NO_ACCOUNT,
)

print("Password changed.")
if result.sessions_ended:
print(f"Signed out {result.sessions_ended} open session(s).")
if not result.is_active:
print(
"This account is deactivated, so it still cannot sign in. "
"Resetting a password does not reactivate it."
)
return EXIT_OK


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="seskit", description="Operate a SESKit instance.")
commands = parser.add_subparsers(dest="command", required=True)

reset = commands.add_parser(
"reset-password",
help="set a new dashboard password for an account",
description=(
"Set a new dashboard password. The password is asked for, never "
"passed as an argument, so it stays out of shell history."
),
)
reset.add_argument("--email", help="the account's email address (asked for if omitted)")
return parser


def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.command == "reset-password":
answers = _prompt(args.email)
if isinstance(answers, int):
return answers
return asyncio.run(_reset_password_command(*answers))
return EXIT_USAGE # pragma: no cover - argparse rejects an unknown command first


if __name__ == "__main__":
raise SystemExit(main())
2 changes: 2 additions & 0 deletions apps/api/src/seskit_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
)
from seskit_api.queue import create_queue
from seskit_api.routes import (
account,
api_keys,
auth,
aws,
Expand Down Expand Up @@ -259,6 +260,7 @@ async def _unhandled(request: Request, exc: Exception) -> JSONResponse | Respons
app.include_router(domains.router)
app.include_router(emails.router)
app.include_router(webhooks.router)
app.include_router(account.router)
app.include_router(suppressions.router)
# Public and unauthenticated, unlike everything above it.
app.include_router(unsubscribe.router)
Expand Down
88 changes: 88 additions & 0 deletions apps/api/src/seskit_api/routes/account.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""The Settings page (``/settings``): your account, and the way back into it (§7).

Today that is one thing - the recovery code. The nav has linked here since the
shell was built, and until now it led nowhere; a page that holds the one setting
that has to exist before it is needed is a reasonable first occupant.

**Why regenerating asks for the password.** A signed-in session is not proof of
ownership: it is also exactly what a stolen cookie looks like, and a new
recovery code would let whoever holds the cookie take the account for good,
including after the real owner changes their password. Asking for the password
again makes the code something only the owner can replace.
"""

from __future__ import annotations

from typing import Annotated

from fastapi import APIRouter, Depends, Form, Request, Response, status
from fastapi.responses import HTMLResponse
from seskit_core.db import get_session
from seskit_core.logging import get_logger
from seskit_core.services import list_projects, regenerate_recovery_code
from sqlalchemy.ext.asyncio import AsyncSession

from seskit_api.dependencies import CurrentUser, require_user, verify_csrf
from seskit_api.routes.auth import show_recovery_code
from seskit_api.templating import render

logger = get_logger(__name__)

router = APIRouter(tags=["settings"], include_in_schema=False)


async def _page(
request: Request,
db: AsyncSession,
current: CurrentUser,
*,
error: str | None = None,
status_code: int = status.HTTP_200_OK,
) -> HTMLResponse:
return render(
request,
"pages/settings.html",
current=current,
nav_active="settings",
projects=await list_projects(db, current.user.id),
has_recovery_code=current.user.recovery_code_hash is not None,
error=error,
status_code=status_code,
)


@router.get("/settings", response_class=HTMLResponse, summary="Settings")
async def settings_page(
request: Request,
db: Annotated[AsyncSession, Depends(get_session)],
current: Annotated[CurrentUser, Depends(require_user)],
) -> HTMLResponse:
return await _page(request, db, current)


@router.post(
"/settings/recovery-code",
response_class=HTMLResponse,
dependencies=[Depends(verify_csrf)],
summary="Make a new recovery code",
)
async def new_recovery_code(
request: Request,
db: Annotated[AsyncSession, Depends(get_session)],
current: Annotated[CurrentUser, Depends(require_user)],
password: Annotated[str, Form()],
) -> Response:
code = await regenerate_recovery_code(db, current.user, password=password)
if code is None:
logger.info("recovery_code_refused", user_id=current.user.id)
return await _page(
request,
db,
current,
error="That is not your current password.",
status_code=status.HTTP_400_BAD_REQUEST,
)

await db.commit()
logger.info("recovery_code_replaced", user_id=current.user.id)
return show_recovery_code(request, code, reason="regenerated")
Loading
Loading