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 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,9 @@ REDIS_URL=redis://localhost:6379/0
|
|||||||
JWT_SECRET=change-me-in-prod
|
JWT_SECRET=change-me-in-prod
|
||||||
ACCESS_TOKEN_TTL_SECONDS=900
|
ACCESS_TOKEN_TTL_SECONDS=900
|
||||||
REFRESH_TOKEN_TTL_SECONDS=2592000
|
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.
|
# subsonic — key that encrypts per-user Subsonic app-passwords at rest.
|
||||||
# GENERATE a strong secret for prod (`openssl rand -hex 32`); rotating it
|
# GENERATE a strong secret for prod (`openssl rand -hex 32`); rotating it
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ class LoginRequest(BaseModel):
|
|||||||
password: str = Field(min_length=1)
|
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):
|
class RefreshRequest(BaseModel):
|
||||||
refresh_token: str
|
refresh_token: str
|
||||||
|
|
||||||
|
|||||||
+25
-2
@@ -2,9 +2,16 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter, status
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
from app.api.deps import AuthServiceDep, CurrentUser
|
from app.api.deps import AuthServiceDep, CurrentUser, UserServiceDep
|
||||||
from app.api.schemas.auth import LoginRequest, RefreshRequest, TokenResponse
|
from app.api.schemas.auth import (
|
||||||
|
LoginRequest,
|
||||||
|
RefreshRequest,
|
||||||
|
RegisterRequest,
|
||||||
|
TokenResponse,
|
||||||
|
)
|
||||||
from app.api.schemas.user import UserResponse
|
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
|
from app.domain.tokens import TokenPair
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
@@ -23,6 +30,22 @@ async def login(body: LoginRequest, auth: AuthServiceDep) -> TokenResponse:
|
|||||||
return _to_token_response(pair)
|
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)
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
async def refresh(body: RefreshRequest, auth: AuthServiceDep) -> TokenResponse:
|
async def refresh(body: RefreshRequest, auth: AuthServiceDep) -> TokenResponse:
|
||||||
pair = await auth.refresh(body.refresh_token)
|
pair = await auth.refresh(body.refresh_token)
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ class Settings(BaseSettings):
|
|||||||
jwt_algorithm: str = "HS256"
|
jwt_algorithm: str = "HS256"
|
||||||
access_token_ttl_seconds: int = 60 * 15 # 15 min
|
access_token_ttl_seconds: int = 60 * 15 # 15 min
|
||||||
refresh_token_ttl_seconds: int = 60 * 60 * 24 * 30 # 30 days (offline-first)
|
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 ---------------------------------------------------------
|
# -- subsonic ---------------------------------------------------------
|
||||||
# Symmetric key (any string) used to encrypt each user's recoverable
|
# Symmetric key (any string) used to encrypt each user's recoverable
|
||||||
|
|||||||
@@ -152,6 +152,53 @@ async def test_admin_create_duplicate_conflicts(api: AsyncClient) -> None:
|
|||||||
assert dup.status_code == 409
|
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:
|
async def test_deactivated_user_cannot_login(api: AsyncClient) -> None:
|
||||||
admin_access, _ = await _login(api, "admin", "adminpass1")
|
admin_access, _ = await _login(api, "admin", "adminpass1")
|
||||||
headers = {"Authorization": f"Bearer {admin_access}"}
|
headers = {"Authorization": f"Bearer {admin_access}"}
|
||||||
|
|||||||
Reference in New Issue
Block a user