From 14c1bc16e01d8b7bb51696c4fed11e322369d474 Mon Sep 17 00:00:00 2001 From: Senko-san Date: Wed, 10 Jun 2026 14:06:52 +0300 Subject: [PATCH] feat(auth): public self-service registration (ALLOW_REGISTRATION) Add POST /auth/register: creates a non-superuser then auto-logs in, returning the same TokenResponse as login. Gated by the new allow_registration setting (env ALLOW_REGISTRATION, default true); when disabled it raises PermissionDeniedError (403). Accounts remain admin-only for superusers. Tests cover create+login, duplicate (409), short password (422), and the disabled (403) path. Co-Authored-By: Claude Opus 4.8 --- .env.example | 3 +++ app/api/schemas/auth.py | 5 +++++ app/api/v1/auth.py | 27 +++++++++++++++++++++-- app/core/config.py | 3 +++ tests/test_auth_api.py | 47 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 0c9d15a..9aab16d 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,9 @@ REDIS_URL=redis://localhost:6379/0 JWT_SECRET=change-me-in-prod ACCESS_TOKEN_TTL_SECONDS=900 REFRESH_TOKEN_TTL_SECONDS=2592000 +# Public self-service sign-up (POST /auth/register). Set to false to make +# accounts admin-only. Registered users are never superusers. +ALLOW_REGISTRATION=true # subsonic — key that encrypts per-user Subsonic app-passwords at rest. # GENERATE a strong secret for prod (`openssl rand -hex 32`); rotating it diff --git a/app/api/schemas/auth.py b/app/api/schemas/auth.py index efe41aa..ade1db1 100644 --- a/app/api/schemas/auth.py +++ b/app/api/schemas/auth.py @@ -10,6 +10,11 @@ class LoginRequest(BaseModel): password: str = Field(min_length=1) +class RegisterRequest(BaseModel): + username: str = Field(min_length=1, max_length=64) + password: str = Field(min_length=8) + + class RefreshRequest(BaseModel): refresh_token: str diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index 7af7beb..450387d 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -2,9 +2,16 @@ from fastapi import APIRouter, status -from app.api.deps import AuthServiceDep, CurrentUser -from app.api.schemas.auth import LoginRequest, RefreshRequest, TokenResponse +from app.api.deps import AuthServiceDep, CurrentUser, UserServiceDep +from app.api.schemas.auth import ( + LoginRequest, + RefreshRequest, + RegisterRequest, + TokenResponse, +) from app.api.schemas.user import UserResponse +from app.core.config import get_settings +from app.domain.errors import PermissionDeniedError from app.domain.tokens import TokenPair router = APIRouter(prefix="/auth", tags=["auth"]) @@ -23,6 +30,22 @@ async def login(body: LoginRequest, auth: AuthServiceDep) -> TokenResponse: return _to_token_response(pair) +@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED) +async def register( + body: RegisterRequest, users: UserServiceDep, auth: AuthServiceDep +) -> TokenResponse: + """Public self-service sign-up (gated by ``ALLOW_REGISTRATION``). + + Registered accounts are always regular users — superusers are created + admin-only. On success the new account is logged straight in. + """ + if not get_settings().allow_registration: + raise PermissionDeniedError("Registration is disabled on this instance.") + await users.create_user(username=body.username, password=body.password, is_superuser=False) + pair = await auth.login(body.username, body.password) + return _to_token_response(pair) + + @router.post("/refresh", response_model=TokenResponse) async def refresh(body: RefreshRequest, auth: AuthServiceDep) -> TokenResponse: pair = await auth.refresh(body.refresh_token) diff --git a/app/core/config.py b/app/core/config.py index c14e19a..3a1d675 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -44,6 +44,9 @@ class Settings(BaseSettings): jwt_algorithm: str = "HS256" access_token_ttl_seconds: int = 60 * 15 # 15 min refresh_token_ttl_seconds: int = 60 * 60 * 24 * 30 # 30 days (offline-first) + # Public self-service sign-up. When disabled, accounts are created + # admin-only (POST /admin/users). Registered users are never superusers. + allow_registration: bool = True # -- subsonic --------------------------------------------------------- # Symmetric key (any string) used to encrypt each user's recoverable diff --git a/tests/test_auth_api.py b/tests/test_auth_api.py index 41410c3..26f7c30 100644 --- a/tests/test_auth_api.py +++ b/tests/test_auth_api.py @@ -152,6 +152,53 @@ async def test_admin_create_duplicate_conflicts(api: AsyncClient) -> None: assert dup.status_code == 409 +async def test_register_creates_user_and_logs_in(api: AsyncClient) -> None: + resp = await api.post( + "/api/v1/auth/register", + json={"username": "frank", "password": "frankpass1"}, + ) + assert resp.status_code == 201, resp.text + access = resp.json()["access_token"] + assert resp.json()["refresh_token"] + + # The returned access token is immediately usable; account is a regular user. + me = await api.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {access}"}) + assert me.status_code == 200 + assert me.json()["username"] == "frank" + assert me.json()["is_superuser"] is False + + +async def test_register_duplicate_conflicts(api: AsyncClient) -> None: + payload = {"username": "grace", "password": "gracepass1"} + first = await api.post("/api/v1/auth/register", json=payload) + assert first.status_code == 201 + dup = await api.post("/api/v1/auth/register", json=payload) + assert dup.status_code == 409 + + +async def test_register_short_password_rejected(api: AsyncClient) -> None: + resp = await api.post( + "/api/v1/auth/register", + json={"username": "heidi", "password": "short"}, + ) + assert resp.status_code == 422 + + +async def test_register_disabled_forbidden(api: AsyncClient) -> None: + from app.core.config import get_settings + + settings = get_settings() + settings.allow_registration = False + try: + resp = await api.post( + "/api/v1/auth/register", + json={"username": "ivan", "password": "ivanpass12"}, + ) + assert resp.status_code == 403 + finally: + settings.allow_registration = True + + async def test_deactivated_user_cannot_login(api: AsyncClient) -> None: admin_access, _ = await _login(api, "admin", "adminpass1") headers = {"Authorization": f"Bearer {admin_access}"}