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
2 changes: 2 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ Without `just`: run the commands in `justfile` by hand; they are one line each.

Every setting is an environment variable prefixed with `NEVUS_` (see `backend/src/nevus/config.py`). A checkout needs none: data goes to `./data`, SQLite is used, and the server answers to private addresses, `localhost` and `.local` names. Set `NEVUS_ALLOWED_HOSTS` to answer to a public hostname behind a reverse proxy, and `NEVUS_DATABASE_URL` to use an external PostgreSQL.

A fresh instance has no accounts: the first person to open it claims it and becomes the administrator (`POST /api/auth/claim`). Alternatively set `NEVUS_ADMIN_USER` and `NEVUS_ADMIN_PASSWORD` before the first start; they are read at start-up only, and setting them again resets that account's password, which is the documented way back in after a forgotten one. Pending database migrations are applied at start-up (`NEVUS_AUTO_MIGRATE=false` disables that).

## Tests

- Backend: `pytest` against a temporary data directory. Continuous integration runs the same suite twice, on SQLite and on PostgreSQL, plus `alembic upgrade head` and `alembic check` on both engines.
Expand Down
1 change: 1 addition & 0 deletions backend/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from sqlalchemy import engine_from_config, pool

from nevus.config import get_settings
from nevus.db import models # noqa: F401 - registers every table on the metadata
from nevus.db.base import Base

config = context.config
Expand Down
143 changes: 143 additions & 0 deletions backend/alembic/versions/20260923_57fafd32a1da_accounts_and_persons.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""accounts and persons

Revision ID: 57fafd32a1da
Revises: none
Create Date: 2026-09-23 16:41:36.300192
"""

from __future__ import annotations

import sqlalchemy as sa
from alembic import op

revision: str = "57fafd32a1da"
down_revision: str | None = None
branch_labels = None
depends_on = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"settings",
sa.Column("key", sa.String(length=64), nullable=False),
sa.Column("value", sa.JSON(), nullable=True),
sa.Column("encrypted", sa.Boolean(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("key", name=op.f("pk_settings")),
)
op.create_table(
"users",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("username", sa.String(length=64), nullable=False),
sa.Column("email", sa.String(length=254), nullable=True),
sa.Column("password_hash", sa.String(length=255), nullable=False),
sa.Column("role", sa.String(length=16), nullable=False),
sa.Column("language", sa.String(length=8), nullable=False),
sa.Column("theme", sa.String(length=8), nullable=False),
sa.Column("show_uncertainty", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("disabled_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id", name=op.f("pk_users")),
sa.UniqueConstraint("username", name=op.f("uq_users_username")),
)
op.create_table(
"audit_log",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
sa.Column("actor_user_id", sa.Uuid(), nullable=True),
sa.Column("action", sa.String(length=64), nullable=False),
sa.Column("target_type", sa.String(length=32), nullable=True),
sa.Column("target_id", sa.String(length=36), nullable=True),
sa.Column("ip", sa.String(length=45), nullable=True),
sa.Column("details", sa.JSON(), nullable=True),
sa.ForeignKeyConstraint(
["actor_user_id"], ["users.id"], name=op.f("fk_audit_log_actor_user_id_users"), ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_audit_log")),
)
with op.batch_alter_table("audit_log", schema=None) as batch_op:
batch_op.create_index("ix_audit_log_at", ["at"], unique=False)

op.create_table(
"auth_sessions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("sudo_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("user_agent", sa.String(length=255), nullable=True),
sa.Column("ip", sa.String(length=45), nullable=True),
sa.ForeignKeyConstraint(
["user_id"], ["users.id"], name=op.f("fk_auth_sessions_user_id_users"), ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_auth_sessions")),
sa.UniqueConstraint("token_hash", name=op.f("uq_auth_sessions_token_hash")),
)
with op.batch_alter_table("auth_sessions", schema=None) as batch_op:
batch_op.create_index("ix_auth_sessions_user_id", ["user_id"], unique=False)

op.create_table(
"persons",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("display_name", sa.String(length=120), nullable=False),
sa.Column("birth_year", sa.Integer(), nullable=True),
sa.Column("skin_tone", sa.String(length=16), nullable=True),
sa.Column("owner_user_id", sa.Uuid(), nullable=False),
sa.Column("experimental_analysis", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["owner_user_id"], ["users.id"], name=op.f("fk_persons_owner_user_id_users"), ondelete="RESTRICT"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_persons")),
)
with op.batch_alter_table("persons", schema=None) as batch_op:
batch_op.create_index("ix_persons_owner_user_id", ["owner_user_id"], unique=False)

op.create_table(
"person_access",
sa.Column("person_id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("role", sa.String(length=16), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["person_id"], ["persons.id"], name=op.f("fk_person_access_person_id_persons"), ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["user_id"], ["users.id"], name=op.f("fk_person_access_user_id_users"), ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("person_id", "user_id", name=op.f("pk_person_access")),
)
with op.batch_alter_table("person_access", schema=None) as batch_op:
batch_op.create_index("ix_person_access_user_id", ["user_id"], unique=False)

# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("person_access", schema=None) as batch_op:
batch_op.drop_index("ix_person_access_user_id")

op.drop_table("person_access")
with op.batch_alter_table("persons", schema=None) as batch_op:
batch_op.drop_index("ix_persons_owner_user_id")

op.drop_table("persons")
with op.batch_alter_table("auth_sessions", schema=None) as batch_op:
batch_op.drop_index("ix_auth_sessions_user_id")

op.drop_table("auth_sessions")
with op.batch_alter_table("audit_log", schema=None) as batch_op:
batch_op.drop_index("ix_audit_log_at")

op.drop_table("audit_log")
op.drop_table("users")
op.drop_table("settings")
# ### end Alembic commands ###
116 changes: 116 additions & 0 deletions backend/src/nevus/api/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""Claiming the instance, signing in and out, the current session, sudo mode and the own account."""

from __future__ import annotations

from fastapi import APIRouter, HTTPException, Request, Response, status

from nevus import __version__
from nevus.api.schemas import Credentials, InstanceStatus, PasswordChange, SessionOut, SudoIn, UserOut, UserUpdateMe
from nevus.auth import service
from nevus.auth.dependencies import AppSettings, CurrentSession, DbSession, client_ip, request_is_secure
from nevus.auth.passwords import hash_password
from nevus.auth.ratelimit import LoginRateLimiter
from nevus.auth.service import SESSION_COOKIE
from nevus.db.models import ROLE_ADMIN

router = APIRouter(prefix="/api/auth", tags=["auth"])


def _limiter(request: Request) -> LoginRateLimiter:
limiter: LoginRateLimiter = request.app.state.login_limiter
return limiter


def _set_cookie(response: Response, token: str, request: Request, settings: AppSettings) -> None:
response.set_cookie(
SESSION_COOKIE,
token,
max_age=settings.session_max_days * 86400,
httponly=True,
samesite="lax",
secure=request_is_secure(request, settings),
path="/",
)


@router.get("/instance", response_model=InstanceStatus)
def instance_status(db: DbSession) -> InstanceStatus:
return InstanceStatus(claimed=service.count_users(db) > 0, version=__version__)


@router.post("/claim", response_model=SessionOut, status_code=status.HTTP_201_CREATED)
def claim(body: Credentials, request: Request, response: Response, db: DbSession, settings: AppSettings) -> SessionOut:
"""The first person in claims the instance and becomes its administrator. Refused once anybody exists."""
if service.count_users(db) > 0:
raise HTTPException(status.HTTP_409_CONFLICT, "This instance has already been claimed.")
user = service.create_user(db, body.username, body.password, ROLE_ADMIN)
ip = client_ip(request, settings)
service.audit(db, "instance.claim", user, "user", user.id, ip)
token = service.create_session(db, user, settings, request.headers.get("user-agent"), ip)
_set_cookie(response, token, request, settings)
return SessionOut(user=UserOut.model_validate(user), sudo_until=None)


@router.post("/login", response_model=SessionOut)
def login(body: Credentials, request: Request, response: Response, db: DbSession, settings: AppSettings) -> SessionOut:
ip = client_ip(request, settings)
key = f"{ip}|{service.normalise_username(body.username)}"
limiter = _limiter(request)
if not limiter.allow(key):
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Too many attempts. Try again later.")
user = service.authenticate(db, body.username, body.password)
if user is None:
limiter.record_failure(key)
service.audit(db, "login.failed", None, "user", service.normalise_username(body.username), ip)
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wrong username or password.")
limiter.reset(key)
token = service.create_session(db, user, settings, request.headers.get("user-agent"), ip)
service.audit(db, "login", user, "user", user.id, ip)
_set_cookie(response, token, request, settings)
return SessionOut(user=UserOut.model_validate(user), sudo_until=None)


@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
def logout(response: Response, session: CurrentSession, db: DbSession) -> Response:
service.revoke_session(db, session)
response.delete_cookie(SESSION_COOKIE, path="/")
return Response(status_code=status.HTTP_204_NO_CONTENT, headers=response.headers)


@router.get("/session", response_model=SessionOut)
def session_info(session: CurrentSession) -> SessionOut:
return SessionOut(user=UserOut.model_validate(session.user), sudo_until=session.sudo_until)


@router.post("/sudo", response_model=SessionOut)
def sudo(body: SudoIn, request: Request, session: CurrentSession, db: DbSession, settings: AppSettings) -> SessionOut:
"""Re-authenticate for a few minutes before destructive actions."""
if service.authenticate(db, session.user.username, body.password) is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wrong password.")
service.grant_sudo(session, settings)
service.audit(db, "sudo", session.user, "user", session.user.id, client_ip(request, settings))
return SessionOut(user=UserOut.model_validate(session.user), sudo_until=session.sudo_until)


@router.patch("/me", response_model=UserOut)
def update_me(body: UserUpdateMe, session: CurrentSession, db: DbSession) -> UserOut:
user = session.user
for field, value in body.model_dump(exclude_unset=True).items():
setattr(user, field, value)
db.flush()
return UserOut.model_validate(user)


@router.post("/me/password", status_code=status.HTTP_204_NO_CONTENT)
def change_password(
body: PasswordChange, request: Request, session: CurrentSession, db: DbSession, settings: AppSettings
) -> Response:
user = session.user
if service.authenticate(db, user.username, body.current_password) is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wrong current password.")
user.password_hash = hash_password(body.new_password)
for other in list(user.sessions):
if other.id != session.id:
db.delete(other)
service.audit(db, "password.change", user, "user", user.id, client_ip(request, settings))
return Response(status_code=status.HTTP_204_NO_CONTENT)
Loading
Loading