From 55a7168fcc690fb39292f6c11bc57239ac982bed Mon Sep 17 00:00:00 2001 From: t0xa Date: Fri, 23 Oct 2026 21:08:43 +0300 Subject: [PATCH] Auth: Add basic JWT authorization --- .env.example | 6 + CLAUDE.md | 10 +- backend/app/api/deps.py | 45 ++- backend/app/api/v1/auth.py | 123 ++++++++ backend/app/api/v1/exercises.py | 31 +- backend/app/api/v1/router.py | 14 +- backend/app/api/v1/trainings.py | 70 +++-- backend/app/api/v1/users.py | 33 +- backend/app/cli.py | 42 ++- backend/app/config.py | 7 + backend/app/core/database/repository.py | 74 +++-- backend/app/core/database/tables.py | 13 + backend/app/core/database/token_repository.py | 96 ++++++ backend/app/core/database/user_repository.py | 9 +- backend/app/core/dto/training.py | 1 + backend/app/core/dto/user.py | 7 + backend/app/core/security.py | 50 ++- backend/app/main.py | 2 + backend/app/schemas/auth.py | 24 ++ backend/app/schemas/exercises.py | 1 + ...23_1800-57f1f0337033_add_refresh_tokens.py | 51 ++++ backend/requirements-dev.txt | 1 + backend/requirements.txt | 1 + backend/tests/conftest.py | 17 ++ backend/tests/test_api/__init__.py | 0 backend/tests/test_api/test_auth.py | 289 ++++++++++++++++++ compose.yaml | 1 + frontend/Dockerfile | 2 +- frontend/exercises.html | 3 +- frontend/index.html | 3 +- frontend/js/auth.js | 101 ++++++ frontend/js/common.js | 28 +- frontend/js/edit-dialog.js | 6 +- frontend/js/edit-form.js | 6 +- frontend/js/exercises.js | 4 +- frontend/js/login.js | 26 ++ frontend/js/page.js | 28 ++ frontend/js/parse.js | 1 + frontend/js/trainings-view.js | 3 +- frontend/js/trainings.js | 1 + frontend/js/users.js | 5 + frontend/login.html | 30 ++ frontend/parse.html | 3 +- frontend/users.html | 3 +- 44 files changed, 1197 insertions(+), 74 deletions(-) create mode 100644 backend/app/api/v1/auth.py create mode 100644 backend/app/core/database/token_repository.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/migrations/versions/2026_10_23_1800-57f1f0337033_add_refresh_tokens.py create mode 100644 backend/tests/test_api/__init__.py create mode 100644 backend/tests/test_api/test_auth.py create mode 100644 frontend/js/auth.js create mode 100644 frontend/js/login.js create mode 100644 frontend/js/page.js create mode 100644 frontend/login.html diff --git a/.env.example b/.env.example index 526d990..1c25f4b 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,9 @@ PYTHONPATH=/app # Development settings DEBUG=true + +# Auth +# Signs access tokens. Generate: python -c "import secrets; print(secrets.token_urlsafe(48))" +JWT_SECRET=change-me-to-a-long-random-string +# Set to true in production (HTTPS only), the web client's refresh cookie is then Secure +COOKIE_SECURE=false diff --git a/CLAUDE.md b/CLAUDE.md index 805c00c..dbf7c33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,13 +73,19 @@ docker compose exec fastapi-app mypy app/ - `Approach` - single set with weight and reps **API Structure** (backend, JSON only): -- `/api/v1/trainings/` (GET list, GET/PUT/DELETE `{id}/`; PUT replaces the whole training), `/api/v1/exercises/`, `/api/v1/exercises/progress/?name=`, `/api/v1/users/` (POST, GET list with `?role=`, GET/PATCH/DELETE `{id}/`; role is `trainer` or `trainee`, passwords are hashed with scrypt, no login yet), `/api/v1/parser/parse/`, `/api/v1/health/` +- `/api/v1/auth/` (login, refresh, logout, me), `/api/v1/trainings/` (GET list, GET/PUT/DELETE `{id}/`; PUT replaces the whole training), `/api/v1/exercises/`, `/api/v1/exercises/progress/?name=`, `/api/v1/users/` (POST, GET list with `?role=`, GET/PATCH/DELETE `{id}/`; role is `trainer` or `trainee`, passwords are hashed with scrypt, no login yet), `/api/v1/parser/parse/`, `/api/v1/health/` **Frontend** (`frontend/`): -- `index.html`, `exercises.html`, `parse.html`, `users.html` - pages; `js/common.js` (API + DOM helpers), `js/table.js` (DataTables 3 wrapper), one module per page, `js/modal.js` (Bootstrap modal shell), `js/edit-dialog.js` + `js/edit-form.js` (training edit modal), `js/user-dialog.js` (user create/edit modal) +- `index.html`, `exercises.html`, `parse.html`, `users.html`, `login.html` - pages; `js/auth.js` (tokens, refresh, logout) and `js/page.js` (every page imports it first: requires a login, fills the sidebar, exports `user`); `js/common.js` (API + DOM helpers), `js/table.js` (DataTables 3 wrapper), one module per page, `js/modal.js` (Bootstrap modal shell), `js/edit-dialog.js` + `js/edit-form.js` (training edit modal), `js/user-dialog.js` (user create/edit modal) - `vendor/` - Bootstrap (CSS + JS bundle), Chart.js and DataTables 3 (jQuery-free): the only third-party code - `nginx.conf` - serves static files and proxies `/api/` to `fastapi-app:8000` (same origin, no CORS) +### Authentication + +Access token: JWT (HS256, 15 min, `JWT_SECRET` in `.env`), sent as `Authorization: Bearer`. Refresh token: random opaque string, 30 days, only its hash is stored (`refresh_tokens`); it works once (rotation), reusing a used one revokes the whole chain. `POST /auth/login/` with `client: "web"` puts the refresh token only into an httpOnly `SameSite=Strict` cookie (path `/api/v1/auth`, `Secure` when `COOKIE_SECURE=true`, set it in production with HTTPS); `client: "native"` (iOS) returns it in the body for the Keychain. The web frontend keeps the access token in `sessionStorage`. + +There is no registration: only a superuser creates users (`/api/v1/users/`). The first login: `docker compose exec fastapi-app python -m app.cli set-password EMAIL [--superuser]`. Access: a superuser sees and changes everything; a trainee sees and changes their trainings; a trainer only sees the trainings where they are the trainer. Exercise statistics are about the user's own trainings (`?trainee_id=` narrows to one trainee). + ### Database Trainings belong to a trainee (`trainings.trainee_id`, required) and optionally have a trainer (`trainings.trainer_id`, NULL = solo); both are users (role `trainee` / `trainer`). A user with trainings can't be deleted. `python -m app.cli import` takes the trainer from the note header and matches it with a trainer user by "First Last". diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 7eb393c..caf4862 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -1,9 +1,17 @@ -from fastapi import Depends +import jwt +from fastapi import Depends, HTTPException +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.ext.asyncio import AsyncSession from app.core.database.connection import get_session from app.core.database.repository import TrainingRepository +from app.core.database.token_repository import RefreshTokenRepository from app.core.database.user_repository import UserRepository +from app.core.dto.user import UserDTO +from app.core.security import decode_access_token + +# auto_error=False: a missing header gets the same 401 as a bad token +bearer_scheme = HTTPBearer(auto_error=False) def get_training_repository( @@ -16,3 +24,38 @@ def get_user_repository( session: AsyncSession = Depends(get_session), ) -> UserRepository: return UserRepository(session) + + +def get_token_repository( + session: AsyncSession = Depends(get_session), +) -> RefreshTokenRepository: + return RefreshTokenRepository(session) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), + repository: UserRepository = Depends(get_user_repository), +) -> UserDTO: + """The user of the access token. The user is read from the database on every request, so + a deactivated user loses access at once, not when the token expires.""" + unauthorized = HTTPException( + status_code=401, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + if credentials is None: + raise unauthorized + try: + user_id = int(decode_access_token(credentials.credentials)["sub"]) + except (jwt.PyJWTError, ValueError): + raise unauthorized + user = await repository.get(user_id) + if user is None or not user.is_active: + raise unauthorized + return user + + +async def require_superuser(user: UserDTO = Depends(get_current_user)) -> UserDTO: + if not user.is_superuser: + raise HTTPException(status_code=403, detail="Superuser only") + return user diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..c91d7d5 --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -0,0 +1,123 @@ +import uuid +from datetime import datetime, timedelta, timezone +from typing import Optional + +from fastapi import APIRouter, Cookie, Depends, HTTPException, Response + +from app.api.deps import get_current_user, get_token_repository, get_user_repository +from app.config import settings +from app.core.database.token_repository import RefreshTokenRepository +from app.core.database.user_repository import UserRepository +from app.core.dto.user import UserDTO +from app.core.security import ( + DUMMY_PASSWORD_HASH, + create_access_token, + hash_token, + new_refresh_token, + verify_password, +) +from app.schemas.auth import LoginRequest, RefreshRequest, TokenResponse + +router = APIRouter() + +REFRESH_COOKIE = "refresh_token" +# The cookie is sent only to the auth endpoints +COOKIE_PATH = "/api/v1/auth" + + +def _invalid_credentials() -> HTTPException: + return HTTPException(status_code=401, detail="Invalid email or password") + + +def _set_refresh_cookie(response: Response, token: str) -> None: + response.set_cookie( + REFRESH_COOKIE, + token, + max_age=settings.REFRESH_TOKEN_TTL_DAYS * 24 * 3600, + path=COOKIE_PATH, + httponly=True, + secure=settings.COOKIE_SECURE, + samesite="strict", + ) + + +async def _issue_tokens( + user: UserDTO, + family_id: str, + web: bool, + response: Response, + tokens: RefreshTokenRepository, +) -> TokenResponse: + refresh_token = new_refresh_token() + await tokens.create( + user_id=user.id, + family_id=family_id, + token_hash=hash_token(refresh_token), + expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), + ) + if web: + _set_refresh_cookie(response, refresh_token) + return TokenResponse( + access_token=create_access_token(user.id, user.role.value), + expires_in=settings.ACCESS_TOKEN_TTL_MINUTES * 60, + refresh_token=None if web else refresh_token, + ) + + +@router.post("/login/", response_model=TokenResponse) +async def login( + payload: LoginRequest, + response: Response, + users: UserRepository = Depends(get_user_repository), + tokens: RefreshTokenRepository = Depends(get_token_repository), +): + found = await users.get_credentials(payload.email.strip().lower()) + # Check the password even for an unknown email: the response time must not tell them apart + user, password_hash = found if found else (None, DUMMY_PASSWORD_HASH) + if not verify_password(payload.password, password_hash) or user is None or not user.is_active: + raise _invalid_credentials() + + await users.update(user.id, {"last_login": datetime.now(timezone.utc)}) + return await _issue_tokens(user, uuid.uuid4().hex, payload.client == "web", response, tokens) + + +@router.post("/refresh/", response_model=TokenResponse) +async def refresh( + response: Response, + payload: Optional[RefreshRequest] = None, + cookie_token: Optional[str] = Cookie(default=None, alias=REFRESH_COOKIE), + users: UserRepository = Depends(get_user_repository), + tokens: RefreshTokenRepository = Depends(get_token_repository), +): + """Exchange a refresh token for a new pair. The old refresh token stops working.""" + body_token = payload.refresh_token if payload else None + token = body_token or cookie_token + web = body_token is None + claim = await tokens.claim(hash_token(token)) if token else None + user = await users.get(claim.user_id) if claim else None + if claim is None or user is None or not user.is_active: + if claim is not None: + await tokens.revoke_all_of_user(claim.user_id) + response.delete_cookie(REFRESH_COOKIE, path=COOKIE_PATH) + raise HTTPException(status_code=401, detail="Invalid refresh token") + return await _issue_tokens(user, claim.family_id, web, response, tokens) + + +@router.post("/logout/") +async def logout( + response: Response, + payload: Optional[RefreshRequest] = None, + cookie_token: Optional[str] = Cookie(default=None, alias=REFRESH_COOKIE), + tokens: RefreshTokenRepository = Depends(get_token_repository), +): + """Revoke the refresh token of this login. The access token expires by itself in minutes.""" + token = (payload.refresh_token if payload else None) or cookie_token + if token: + await tokens.revoke_family_of(hash_token(token)) + response.delete_cookie(REFRESH_COOKIE, path=COOKIE_PATH) + return {"status": "ok"} + + +@router.get("/me/", response_model=UserDTO) +async def me(user: UserDTO = Depends(get_current_user)): + return user diff --git a/backend/app/api/v1/exercises.py b/backend/app/api/v1/exercises.py index 7fdbee9..9ae1feb 100644 --- a/backend/app/api/v1/exercises.py +++ b/backend/app/api/v1/exercises.py @@ -2,9 +2,10 @@ from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException -from app.api.deps import get_training_repository +from app.api.deps import get_current_user, get_training_repository from app.core.database.repository import TrainingRepository from app.core.dto.training import ExerciseNameDTO +from app.core.dto.user import UserDTO, UserRole from app.core.metrics import Record, summarize from app.schemas.exercises import ( ExerciseProgressResponse, @@ -16,36 +17,57 @@ from app.schemas.exercises import ( router = APIRouter() +def stats_trainee_id(user: UserDTO, trainee_id: Optional[int]) -> Optional[int]: + """Whose trainings the statistics are about: a trainee's own unless another one is asked + for (which, like everything else, is limited to the trainings the user may see).""" + if trainee_id is None and user.role == UserRole.trainee: + return user.id + return trainee_id + + def record_response(record: Optional[Record]) -> Optional[RecordResponse]: return RecordResponse(value=record.value, date=record.date) if record else None @router.get("/", response_model=List[ExerciseNameDTO]) async def list_exercise_names( + trainee_id: Optional[int] = None, + user: UserDTO = Depends(get_current_user), repository: TrainingRepository = Depends(get_training_repository), ): """Distinct exercise names with the number of times each was done.""" - return await repository.list_exercise_names() + return await repository.list_exercise_names( + viewer=user, trainee_id=stats_trainee_id(user, trainee_id) + ) @router.get("/progress/", response_model=ExerciseProgressResponse) async def exercise_progress( name: str, + trainee_id: Optional[int] = None, + user: UserDTO = Depends(get_current_user), repository: TrainingRepository = Depends(get_training_repository), ): """The whole history of the exercise with progress metrics. The name is matched exactly, but case-insensitively: what was typed may differ in case. """ + trainee_id = stats_trainee_id(user, trainee_id) names_by_casefold = { item.name.casefold(): item.name - for item in await repository.list_exercise_names() + for item in await repository.list_exercise_names( + viewer=user, trainee_id=trainee_id + ) } exact_name = names_by_casefold.get(name.strip().casefold()) if exact_name is None: raise HTTPException(status_code=404, detail="Exercise not found") - progress = summarize(await repository.exercise_history(exact_name)) + progress = summarize( + await repository.exercise_history( + exact_name, viewer=user, trainee_id=trainee_id + ) + ) return ExerciseProgressResponse( name=exact_name, weighted=progress.weighted, @@ -59,6 +81,7 @@ async def exercise_progress( rows=[ SessionRowResponse( training_id=row.session.training_id, + trainee_id=row.session.trainee_id, date=row.session.date, trainer=row.session.trainer, splitted_weight=row.session.splitted_weight, diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 7b4afdd..31e8c69 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,10 +1,18 @@ -from fastapi import APIRouter -from app.api.v1 import exercises, parser, health, trainings, users +from fastapi import APIRouter, Depends + +from app.api.deps import get_current_user +from app.api.v1 import auth, exercises, parser, health, trainings, users api_router = APIRouter() api_router.include_router(health.router, prefix="/health", tags=["health"]) -api_router.include_router(parser.router, prefix="/parser", tags=["parser"]) +api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) +api_router.include_router( + parser.router, + prefix="/parser", + tags=["parser"], + dependencies=[Depends(get_current_user)], +) api_router.include_router(trainings.router, prefix="/trainings", tags=["trainings"]) api_router.include_router(exercises.router, prefix="/exercises", tags=["exercises"]) api_router.include_router(users.router, prefix="/users", tags=["users"]) diff --git a/backend/app/api/v1/trainings.py b/backend/app/api/v1/trainings.py index e1f8430..bdda0b0 100644 --- a/backend/app/api/v1/trainings.py +++ b/backend/app/api/v1/trainings.py @@ -2,30 +2,23 @@ from typing import List from fastapi import APIRouter, Depends, HTTPException -from app.api.deps import get_training_repository, get_user_repository +from app.api.deps import ( + get_current_user, + get_training_repository, + get_user_repository, + require_superuser, +) from app.core.database.repository import TrainingRepository from app.core.database.user_repository import UserRepository from app.core.dto.training import TrainingDTO -from app.core.dto.user import UserRole -from app.schemas.trainings import TrainingUpdate +from app.core.dto.user import UserDTO, UserRole from app.core.parsers.obsidian import parse_training_data +from app.schemas.trainings import TrainingUpdate router = APIRouter() -@router.get("/obsidian/") -async def obsidian_trainings_list(): - return {"data": parse_training_data()} - - -@router.get("/", response_model=List[TrainingDTO]) -async def get_trainings_list( - repository: TrainingRepository = Depends(get_training_repository), -): - return await repository.list() - - async def _check_participant( repository: UserRepository, user_id: int, role: UserRole ) -> None: @@ -36,12 +29,42 @@ async def _check_participant( ) +async def _get_editable_training( + training_id: int, user: UserDTO, repository: TrainingRepository +) -> TrainingDTO: + """A training the user may change: their own (as the trainee), or any for a superuser. + + The trainer can only see the training. A training the user can't see at all is a 404. + """ + training = await repository.get(training_id, viewer=user) + if training is None: + raise HTTPException(status_code=404, detail="Training not found") + if not (user.is_superuser or training.trainee_id == user.id): + raise HTTPException(status_code=403, detail="Only the trainee can change a training") + return training + + +@router.get("/obsidian/", dependencies=[Depends(require_superuser)]) +async def obsidian_trainings_list(): + return {"data": parse_training_data()} + + +@router.get("/", response_model=List[TrainingDTO]) +async def get_trainings_list( + user: UserDTO = Depends(get_current_user), + repository: TrainingRepository = Depends(get_training_repository), +): + """Trainings the user takes part in (all of them for a superuser).""" + return await repository.list(viewer=user) + + @router.get("/{training_id}/", response_model=TrainingDTO) async def get_training( training_id: int, + user: UserDTO = Depends(get_current_user), repository: TrainingRepository = Depends(get_training_repository), ): - training = await repository.get(training_id) + training = await repository.get(training_id, viewer=user) if training is None: raise HTTPException(status_code=404, detail="Training not found") return training @@ -51,11 +74,18 @@ async def get_training( async def update_training( training_id: int, payload: TrainingUpdate, + user: UserDTO = Depends(get_current_user), repository: TrainingRepository = Depends(get_training_repository), user_repository: UserRepository = Depends(get_user_repository), ): """Replace the training: its date, trainee, trainer, exercises and approaches.""" - await _check_participant(user_repository, payload.trainee_id, UserRole.trainee) + current = await _get_editable_training(training_id, user, repository) + if payload.trainee_id != current.trainee_id: + if not user.is_superuser: + raise HTTPException( + status_code=403, detail="Only a superuser can change the trainee" + ) + await _check_participant(user_repository, payload.trainee_id, UserRole.trainee) if payload.trainer_id is not None: await _check_participant(user_repository, payload.trainer_id, UserRole.trainer) training = TrainingDTO.model_validate(payload.model_dump()) @@ -67,9 +97,9 @@ async def update_training( @router.delete("/{training_id}/") async def delete_training( training_id: int, + user: UserDTO = Depends(get_current_user), repository: TrainingRepository = Depends(get_training_repository), ): - deleted = await repository.delete([training_id]) - if not deleted: - raise HTTPException(status_code=404, detail="Training not found") + await _get_editable_training(training_id, user, repository) + await repository.delete([training_id]) return {"status": "ok"} diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index 495ff10..3080b2d 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -2,13 +2,13 @@ from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, status -from app.api.deps import get_user_repository +from app.api.deps import get_current_user, get_user_repository, require_superuser from app.core.database.user_repository import ( EmailTakenError, UserInUseError, UserRepository, ) -from app.core.dto.user import UserDTO, UserRole +from app.core.dto.user import TrainerDTO, UserDTO, UserRole from app.core.security import hash_password from app.schemas.users import UserCreate, UserUpdate @@ -17,7 +17,7 @@ router = APIRouter() EMAIL_TAKEN = HTTPException(status_code=409, detail="Email already registered") -@router.post("/", response_model=UserDTO, status_code=status.HTTP_201_CREATED) +@router.post("/", response_model=UserDTO, status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_superuser)]) async def create_user( payload: UserCreate, repository: UserRepository = Depends(get_user_repository), @@ -34,7 +34,7 @@ async def create_user( raise EMAIL_TAKEN -@router.get("/", response_model=List[UserDTO]) +@router.get("/", response_model=List[UserDTO], dependencies=[Depends(require_superuser)]) async def list_users( role: Optional[UserRole] = None, repository: UserRepository = Depends(get_user_repository), @@ -42,7 +42,23 @@ async def list_users( return await repository.list(role) -@router.get("/{user_id}/", response_model=UserDTO) +@router.get("/trainers/", response_model=List[TrainerDTO]) +async def list_trainers( + _: UserDTO = Depends(get_current_user), + repository: UserRepository = Depends(get_user_repository), +): + """Trainers to pick from in a training form: any signed-in user may ask for them.""" + return [ + TrainerDTO( + id=user.id, + name=f"{user.first_name} {user.last_name}".strip() or user.email, + ) + for user in await repository.list(UserRole.trainer) + if user.is_active + ] + + +@router.get("/{user_id}/", response_model=UserDTO, dependencies=[Depends(require_superuser)]) async def get_user( user_id: int, repository: UserRepository = Depends(get_user_repository), @@ -53,7 +69,7 @@ async def get_user( return user -@router.patch("/{user_id}/", response_model=UserDTO) +@router.patch("/{user_id}/", response_model=UserDTO, dependencies=[Depends(require_superuser)]) async def update_user( user_id: int, payload: UserUpdate, @@ -74,11 +90,14 @@ async def update_user( return user -@router.delete("/{user_id}/") +@router.delete("/{user_id}/", dependencies=[Depends(require_superuser)]) async def delete_user( user_id: int, + current: UserDTO = Depends(require_superuser), repository: UserRepository = Depends(get_user_repository), ): + if user_id == current.id: + raise HTTPException(status_code=400, detail="You can't delete yourself") try: deleted = await repository.delete(user_id) except UserInUseError: diff --git a/backend/app/cli.py b/backend/app/cli.py index c0fd861..8d5c875 100644 --- a/backend/app/cli.py +++ b/backend/app/cli.py @@ -1,23 +1,31 @@ """Command line helpers. python -m app.cli import [--replace] [--trainee-email EMAIL] + python -m app.cli set-password EMAIL [--superuser] Imports all trainings from the Obsidian and Apple notes into the database as trainings of one trainee. The trainer from a note header is matched with a user of the trainer role by "First Last" ("соло" means no trainer). The schema must already exist (`alembic upgrade head`). + +`set-password` asks for a new password of the user (and with --superuser makes them a +superuser). It is the way to get a working login: users are created only by superusers, and +the users made by migrations have random passwords. """ import argparse import asyncio +import getpass import logging -from typing import List, Optional, Set +from typing import Any, Dict, List, Optional, Set from app.config import settings from app.core.database.connection import create_engine, create_session_factory from app.core.database.repository import TrainingRepository +from app.core.database.token_repository import RefreshTokenRepository from app.core.database.user_repository import UserRepository from app.core.dto.training import TrainingDTO from app.core.dto.user import UserRole +from app.core.security import hash_password from app.core.parsers.apple import AppleNotesParser from app.core.parsers.obsidian import ObsidianNotesParser @@ -90,6 +98,26 @@ async def import_notes(replace: bool, trainee_email: Optional[str]) -> None: print(f"Imported {len(trainings)} trainings") +async def set_password(email: str, password: str, superuser: bool) -> None: + engine = create_engine(settings.get_postgres_database_url()) + try: + async with create_session_factory(engine)() as session: + users = UserRepository(session) + found = await users.get_credentials(email.strip().lower()) + if found is None: + raise SystemExit(f"No user with email {email}") + user, _ = found + values: Dict[str, Any] = {"password_hash": hash_password(password)} + if superuser: + values["is_superuser"] = True + await users.update(user.id, values) + # Whoever was logged in with the old password is logged out + await RefreshTokenRepository(session).revoke_all_of_user(user.id) + finally: + await engine.dispose() + print(f"Password of {email} is changed") + + def main() -> None: logging.basicConfig(level=logging.WARNING) arg_parser = argparse.ArgumentParser(prog="app.cli") @@ -102,10 +130,22 @@ def main() -> None: "--trainee-email", help="whose trainings these are (not needed when there is a single trainee)", ) + password_parser = subcommands.add_parser("set-password", help="set a user's password") + password_parser.add_argument("email") + password_parser.add_argument( + "--superuser", action="store_true", help="also make the user a superuser" + ) args = arg_parser.parse_args() if args.command == "import": asyncio.run(import_notes(args.replace, args.trainee_email)) + elif args.command == "set-password": + password = getpass.getpass("New password: ") + if len(password) < 8: + raise SystemExit("The password must be at least 8 characters long") + if password != getpass.getpass("Repeat: "): + raise SystemExit("The passwords differ") + asyncio.run(set_password(args.email, password, args.superuser)) if __name__ == "__main__": diff --git a/backend/app/config.py b/backend/app/config.py index 487e61c..1f6980e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -11,6 +11,13 @@ class Settings(BaseSettings): version: str = "0.0.1" debug: bool = False + # Auth: JWT_SECRET signs access tokens, it must be long and random (see .env.example) + JWT_SECRET: str = "" + ACCESS_TOKEN_TTL_MINUTES: int = 15 + REFRESH_TOKEN_TTL_DAYS: int = 30 + # The refresh cookie of the web client is sent only over HTTPS when this is on + COOKIE_SECURE: bool = False + # Postgres database settings POSTGRES_DATABASE_URL: Optional[str] = None POSTGRES_DATABASE_HOST: str = "localhost" diff --git a/backend/app/core/database/repository.py b/backend/app/core/database/repository.py index cc73f79..fb2a789 100644 --- a/backend/app/core/database/repository.py +++ b/backend/app/core/database/repository.py @@ -1,7 +1,7 @@ from collections import defaultdict from typing import Dict, List, Optional, Sequence -from sqlalchemy import delete, func, insert, select, text, update +from sqlalchemy import ColumnElement, delete, func, insert, or_, select, text, true, update from sqlalchemy.ext.asyncio import AsyncSession from app.core.database.tables import approaches, exercises, trainings, users @@ -12,6 +12,7 @@ from app.core.dto.training import ( ExerciseSessionDTO, TrainingDTO, ) +from app.core.dto.user import UserDTO _trainer = users.alias("trainer") # "First Last", the email if the name is empty @@ -28,6 +29,22 @@ def _trainings_query(): ) +def _scope(viewer: Optional[UserDTO], trainee_id: Optional[int]) -> List[ColumnElement]: + """Conditions on `trainings`: what the viewer may see, narrowed to one trainee if asked. + + A superuser sees everything, anyone else the trainings where they are the trainee or the + trainer. No viewer means no restriction (imports, tests). + """ + conditions: List[ColumnElement] = [] + if viewer is not None and not viewer.is_superuser: + conditions.append( + or_(trainings.c.trainee_id == viewer.id, trainings.c.trainer_id == viewer.id) + ) + if trainee_id is not None: + conditions.append(trainings.c.trainee_id == trainee_id) + return conditions + + class TrainingRepository: """Storage for trainings together with their exercises and approaches (SQLAlchemy Core).""" @@ -60,10 +77,14 @@ class TrainingRepository: await self._session.commit() return training_ids - async def get(self, training_id: int) -> Optional[TrainingDTO]: + async def get( + self, training_id: int, *, viewer: Optional[UserDTO] = None + ) -> Optional[TrainingDTO]: row = ( await self._session.execute( - _trainings_query().where(trainings.c.id == training_id) + _trainings_query().where( + trainings.c.id == training_id, *_scope(viewer, None) + ) ) ).first() if row is None: @@ -120,19 +141,25 @@ class TrainingRepository: await self._session.commit() return True - async def list(self) -> List[TrainingDTO]: + async def list(self, *, viewer: Optional[UserDTO] = None) -> List[TrainingDTO]: training_rows = ( await self._session.execute( - _trainings_query().order_by( - trainings.c.date.desc(), trainings.c.id.desc() - ) + _trainings_query() + .where(*_scope(viewer, None)) + .order_by(trainings.c.date.desc(), trainings.c.id.desc()) ) ).all() - approaches_by_exercise = await self._approaches_by_exercise() - exercises_by_training: Dict[int, List[ExerciseDTO]] = defaultdict(list) - exercise_rows = await self._session.execute( - select(exercises).order_by(exercises.c.id) + exercise_rows = ( + await self._session.execute( + select(exercises) + .where(exercises.c.training_id.in_([row.id for row in training_rows])) + .order_by(exercises.c.id) + ) + ).all() + approaches_by_exercise = await self._approaches_by_exercise( + [row.id for row in exercise_rows] ) + exercises_by_training: Dict[int, List[ExerciseDTO]] = defaultdict(list) for row in exercise_rows: exercises_by_training[row.training_id].append( ExerciseDTO( @@ -155,29 +182,41 @@ class TrainingRepository: for row in training_rows ] - async def list_exercise_names(self) -> List[ExerciseNameDTO]: + async def list_exercise_names( + self, *, viewer: Optional[UserDTO] = None, trainee_id: Optional[int] = None + ) -> List[ExerciseNameDTO]: """Distinct exercise names with the number of times each was done.""" - query = select( - exercises.c.name, func.count(exercises.c.id).label("times") - ).group_by(exercises.c.name) + query = ( + select(exercises.c.name, func.count(exercises.c.id).label("times")) + .join(trainings, trainings.c.id == exercises.c.training_id) + .where(*_scope(viewer, trainee_id)) + .group_by(exercises.c.name) + ) result = await self._session.execute(query) names = [ExerciseNameDTO(name=row.name, count=row.times) for row in result] # Sorted here, not in SQL: the order must not depend on DB collation return sorted(names, key=lambda item: item.name.casefold()) - async def exercise_history(self, name: str) -> List[ExerciseSessionDTO]: + async def exercise_history( + self, + name: str, + *, + viewer: Optional[UserDTO] = None, + trainee_id: Optional[int] = None, + ) -> List[ExerciseSessionDTO]: """Every occurrence of the exercise, newest first.""" query = ( select( exercises.c.id, exercises.c.splitted_weight, trainings.c.id.label("training_id"), + trainings.c.trainee_id, trainings.c.date, _trainer_name, ) .join(trainings, trainings.c.id == exercises.c.training_id) .outerjoin(_trainer, _trainer.c.id == trainings.c.trainer_id) - .where(exercises.c.name == name) + .where(exercises.c.name == name, *_scope(viewer, trainee_id)) .order_by(trainings.c.date.desc(), exercises.c.id.desc()) ) rows = (await self._session.execute(query)).all() @@ -187,6 +226,7 @@ class TrainingRepository: return [ ExerciseSessionDTO( training_id=row.training_id, + trainee_id=row.trainee_id, date=row.date, trainer=row.trainer, splitted_weight=row.splitted_weight, diff --git a/backend/app/core/database/tables.py b/backend/app/core/database/tables.py index dbac926..b0664c6 100644 --- a/backend/app/core/database/tables.py +++ b/backend/app/core/database/tables.py @@ -86,3 +86,16 @@ users = Table( Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), Column("last_login", DateTime(timezone=True)), ) + +refresh_tokens = Table( + "refresh_tokens", + metadata, + Column("id", Integer, primary_key=True), + Column("user_id", ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True), + # Tokens issued by rotation from one login share a family: a reused token revokes it all + Column("family_id", String(32), nullable=False, index=True), + Column("token_hash", String(64), nullable=False, unique=True), + Column("expires_at", DateTime(timezone=True), nullable=False), + Column("revoked_at", DateTime(timezone=True)), + Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), +) diff --git a/backend/app/core/database/token_repository.py b/backend/app/core/database/token_repository.py new file mode 100644 index 0000000..a25afb7 --- /dev/null +++ b/backend/app/core/database/token_repository.py @@ -0,0 +1,96 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + +from sqlalchemy import func, insert, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database.tables import refresh_tokens + + +@dataclass +class RefreshClaim: + user_id: int + family_id: str + + +class RefreshTokenRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create( + self, *, user_id: int, family_id: str, token_hash: str, expires_at: datetime + ) -> None: + await self._session.execute( + insert(refresh_tokens).values( + user_id=user_id, + family_id=family_id, + token_hash=token_hash, + expires_at=expires_at, + ) + ) + await self._session.commit() + + async def claim(self, token_hash: str) -> Optional[RefreshClaim]: + """Use up a valid refresh token (it can be used only once). + + The check and the revocation are one UPDATE, so of two simultaneous requests with the + same token only one wins. A token that was already used up means it leaked (or was + replayed): the whole family is revoked and None is returned. + """ + row = ( + await self._session.execute( + update(refresh_tokens) + .where( + refresh_tokens.c.token_hash == token_hash, + refresh_tokens.c.revoked_at.is_(None), + refresh_tokens.c.expires_at > func.now(), + ) + .values(revoked_at=func.now()) + .returning(refresh_tokens.c.user_id, refresh_tokens.c.family_id) + ) + ).first() + if row is not None: + await self._session.commit() + return RefreshClaim(row.user_id, row.family_id) + + used = ( + await self._session.execute( + select(refresh_tokens.c.family_id).where( + refresh_tokens.c.token_hash == token_hash, + refresh_tokens.c.revoked_at.is_not(None), + ) + ) + ).first() + if used is not None: + await self._revoke_family(used.family_id) + await self._session.commit() + return None + + async def revoke_family_of(self, token_hash: str) -> None: + """Log out: revoke the token and everything issued from it.""" + family = ( + await self._session.execute( + select(refresh_tokens.c.family_id).where( + refresh_tokens.c.token_hash == token_hash + ) + ) + ).first() + if family is not None: + await self._revoke_family(family.family_id) + await self._session.commit() + + async def revoke_all_of_user(self, user_id: int) -> None: + await self._session.execute( + update(refresh_tokens) + .where(refresh_tokens.c.user_id == user_id, refresh_tokens.c.revoked_at.is_(None)) + .values(revoked_at=func.now()) + ) + await self._session.commit() + + async def _revoke_family(self, family_id: str) -> None: + await self._session.execute( + update(refresh_tokens) + .where(refresh_tokens.c.family_id == family_id, refresh_tokens.c.revoked_at.is_(None)) + .values(revoked_at=func.now()) + ) diff --git a/backend/app/core/database/user_repository.py b/backend/app/core/database/user_repository.py index 8d8ea45..1d1a8b5 100644 --- a/backend/app/core/database/user_repository.py +++ b/backend/app/core/database/user_repository.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from sqlalchemy import delete, insert, select, update from sqlalchemy.exc import IntegrityError @@ -54,6 +54,13 @@ class UserRepository: ).first() return UserDTO.model_validate(row) if row else None + async def get_credentials(self, email: str) -> Optional[Tuple[UserDTO, str]]: + """The user and the hash of the password, for logging in.""" + row = ( + await self._session.execute(select(users).where(users.c.email == email)) + ).first() + return (UserDTO.model_validate(row), row.password_hash) if row else None + async def list(self, role: Optional[UserRole] = None) -> List[UserDTO]: query = select(users).order_by(users.c.id) if role is not None: diff --git a/backend/app/core/dto/training.py b/backend/app/core/dto/training.py index 9580c95..56b50e3 100644 --- a/backend/app/core/dto/training.py +++ b/backend/app/core/dto/training.py @@ -42,6 +42,7 @@ class ExerciseSessionDTO(DTOBase): """One occurrence of an exercise in the history of that exercise.""" training_id: int | None = None + trainee_id: int | None = None date: date trainer: str | None = None splitted_weight: bool = False diff --git a/backend/app/core/dto/user.py b/backend/app/core/dto/user.py index 953a94b..209ceb2 100644 --- a/backend/app/core/dto/user.py +++ b/backend/app/core/dto/user.py @@ -23,3 +23,10 @@ class UserDTO(BaseModel): is_superuser: bool created_at: datetime last_login: datetime | None = None + + +class TrainerDTO(BaseModel): + """What any signed-in user may know about a trainer: enough to pick one in a form.""" + + id: int + name: str diff --git a/backend/app/core/security.py b/backend/app/core/security.py index d1e2203..f002fc0 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -1,16 +1,26 @@ -"""Password hashing with scrypt from the standard library (no extra dependencies). +"""Passwords and tokens. -Stored format: scrypt$$$

$$. The parameters are stored with -the hash, so they can be raised later without invalidating existing passwords. +Passwords are hashed with scrypt from the standard library. Stored format: +scrypt$$$

$$. The parameters are stored with the hash, so +they can be raised later without invalidating existing passwords. + +Access tokens are short-lived JWTs (HS256). Refresh tokens are opaque random strings, only their +hash is stored in the database. """ import base64 import hashlib import hmac import secrets +from datetime import datetime, timedelta, timezone + +import jwt + +from app.config import settings _N, _R, _P = 2**14, 8, 1 _KEY_LENGTH = 32 +JWT_ALGORITHM = "HS256" def _derive(password: str, salt: bytes, n: int, r: int, p: int) -> bytes: @@ -37,3 +47,37 @@ def verify_password(password: str, stored: str) -> bool: except ValueError: return False return hmac.compare_digest(actual, expected) + + +# Verified when the email is unknown, so that a login takes the same time either way +DUMMY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16)) + + +def create_access_token(user_id: int, role: str) -> str: + now = datetime.now(timezone.utc) + payload = { + "sub": str(user_id), + "role": role, + "iat": now, + "exp": now + timedelta(minutes=settings.ACCESS_TOKEN_TTL_MINUTES), + } + return jwt.encode(payload, settings.JWT_SECRET, algorithm=JWT_ALGORITHM) + + +def decode_access_token(token: str) -> dict: + """Payload of a valid access token, jwt.PyJWTError otherwise (bad signature, expired...).""" + return jwt.decode( + token, + settings.JWT_SECRET, + algorithms=[JWT_ALGORITHM], + options={"require": ["sub", "exp"]}, + ) + + +def new_refresh_token() -> str: + return secrets.token_urlsafe(48) + + +def hash_token(token: str) -> str: + # The token is random with 384 bits of entropy: a plain fast hash is enough + return hashlib.sha256(token.encode()).hexdigest() diff --git a/backend/app/main.py b/backend/app/main.py index 051a8bc..eac17ed 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,6 +15,8 @@ logging.basicConfig( @asynccontextmanager async def lifespan(app: FastAPI): + if len(settings.JWT_SECRET) < 32: + raise RuntimeError("JWT_SECRET must be set to a random string of 32+ characters") # Schema is managed by Alembic: run `alembic upgrade head` before starting the app engine = create_engine(settings.get_postgres_database_url()) app.state.session_factory = create_session_factory(engine) diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..7f1d99d --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,24 @@ +from typing import Literal, Optional + +from pydantic import BaseModel + + +class LoginRequest(BaseModel): + email: str + password: str + # "web": the refresh token goes only into an httpOnly cookie, script code never sees it. + # "native" (iOS...): it is returned in the body, the app keeps it in the Keychain. + client: Literal["web", "native"] = "native" + + +class RefreshRequest(BaseModel): + """Native clients send the refresh token in the body, the web client in the cookie.""" + + refresh_token: Optional[str] = None + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + expires_in: int # seconds + refresh_token: Optional[str] = None # None for the web client: it is in the cookie diff --git a/backend/app/schemas/exercises.py b/backend/app/schemas/exercises.py index 6bd4688..84c0729 100644 --- a/backend/app/schemas/exercises.py +++ b/backend/app/schemas/exercises.py @@ -16,6 +16,7 @@ class SessionMetricsResponse(BaseModel): class SessionRowResponse(BaseModel): training_id: Optional[int] + trainee_id: Optional[int] date: date trainer: Optional[str] splitted_weight: bool diff --git a/backend/migrations/versions/2026_10_23_1800-57f1f0337033_add_refresh_tokens.py b/backend/migrations/versions/2026_10_23_1800-57f1f0337033_add_refresh_tokens.py new file mode 100644 index 0000000..70b1d27 --- /dev/null +++ b/backend/migrations/versions/2026_10_23_1800-57f1f0337033_add_refresh_tokens.py @@ -0,0 +1,51 @@ +"""add refresh tokens + +Also makes the owner a superuser (the first one, who creates the others). + +Revision ID: 57f1f0337033 +Revises: b1c2d3e4f5a6 +Create Date: 2026-10-23 18:00:02.101967 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '57f1f0337033' +down_revision: Union[str, Sequence[str], None] = 'b1c2d3e4f5a6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('refresh_tokens', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('family_id', sa.String(length=32), nullable=False), + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('token_hash') + ) + op.create_index(op.f('ix_refresh_tokens_family_id'), 'refresh_tokens', ['family_id'], unique=False) + op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False) + # ### end Alembic commands ### + # Somebody has to be able to create users: the owner of the data is the first superuser + op.execute("UPDATE users SET is_superuser = true WHERE email = 'pro100ton@gmail.com'") + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens') + op.drop_index(op.f('ix_refresh_tokens_family_id'), table_name='refresh_tokens') + op.drop_table('refresh_tokens') + # ### end Alembic commands ### diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt index 9f0733d..dbe3915 100644 --- a/backend/requirements-dev.txt +++ b/backend/requirements-dev.txt @@ -5,3 +5,4 @@ pytest-asyncio==1.4.0 mypy==1.17.1 types-grpcio==1.0.0.20250703 types-protobuf==6.30.2.20250822 +httpx==0.28.1 diff --git a/backend/requirements.txt b/backend/requirements.txt index 582eb64..d044ddd 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,3 +6,4 @@ python-dotenv==1.1.1 sqlalchemy[asyncio]==2.1.6 asyncpg==0.32.0 alembic==1.20.1 +PyJWT==2.10.1 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 9f0b77f..ce03ef2 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -86,3 +86,20 @@ async def trainer(session): last_name="Степан", role=UserRole.trainer, ) + + +@pytest.fixture +async def client(session, monkeypatch): + """HTTP client of the app on the test database (needs the `session` fixture's clean DB).""" + from httpx import ASGITransport, AsyncClient + + from app.main import app + + monkeypatch.setattr(settings, "JWT_SECRET", "test-secret-" + "x" * 40) + engine = create_engine(TEST_DATABASE_URL) + app.state.session_factory = create_session_factory(engine) + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as http_client: + yield http_client + await engine.dispose() diff --git a/backend/tests/test_api/__init__.py b/backend/tests/test_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_api/test_auth.py b/backend/tests/test_api/test_auth.py new file mode 100644 index 0000000..69d1bb4 --- /dev/null +++ b/backend/tests/test_api/test_auth.py @@ -0,0 +1,289 @@ +"""Login, token rotation and who may do what, through the real HTTP API.""" + +from datetime import date + +import pytest + +from app.config import settings +from app.core.database.repository import TrainingRepository +from app.core.database.user_repository import UserRepository +from app.core.dto.training import ApproachDTO, ExerciseDTO, TrainingDTO +from app.core.dto.user import UserRole +from app.core.security import hash_password + +PASSWORD = "correct horse" + + +async def make_user(session, email, role=UserRole.trainee, superuser=False, **fields): + repository = UserRepository(session) + user = await repository.create( + email=email, password_hash=hash_password(PASSWORD), role=role, **fields + ) + if superuser: + user = await repository.update(user.id, {"is_superuser": True}) + return user + + +async def make_training(session, trainee, trainer=None, exercise="Жим", weight=50): + return await TrainingRepository(session).save( + TrainingDTO( + date=date(2025, 1, 1), + trainee_id=trainee.id, + trainer_id=trainer.id if trainer else None, + exercises=[ + ExerciseDTO(name=exercise, approaches=[ApproachDTO(weight=weight, reps=10)]) + ], + ) + ) + + +async def login(http, email, **extra): + return await http.post( + "/api/v1/auth/login/", json={"email": email, "password": PASSWORD, **extra} + ) + + +async def auth_headers(client, email): + token = (await login(client, email)).json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +# --- login and tokens --------------------------------------------------------------------- + + +async def test_login_returns_working_tokens(client, session): + await make_user(session, "a@example.com", first_name="Anna") + + response = await login(client, " A@Example.com ") + + assert response.status_code == 200 + body = response.json() + assert body["token_type"] == "bearer" and body["expires_in"] == 15 * 60 + assert body["refresh_token"] + me = await client.get( + "/api/v1/auth/me/", headers={"Authorization": f"Bearer {body['access_token']}"} + ) + assert me.json()["email"] == "a@example.com" and "password" not in me.text + assert (await UserRepository(session).list())[0].last_login is not None + + +async def test_wrong_password_unknown_email_and_inactive_user_are_rejected(client, session): + user = await make_user(session, "a@example.com") + + wrong = await client.post( + "/api/v1/auth/login/", json={"email": "a@example.com", "password": "nope"} + ) + unknown = await login(client, "nobody@example.com") + assert wrong.status_code == unknown.status_code == 401 + assert wrong.json() == unknown.json() # nothing tells which of the two was wrong + + await UserRepository(session).update(user.id, {"is_active": False}) + assert (await login(client, "a@example.com")).status_code == 401 + + +async def test_web_client_gets_the_refresh_token_only_in_an_httponly_cookie(client, session): + await make_user(session, "a@example.com") + + response = await login(client, "a@example.com", client="web") + + assert response.json()["refresh_token"] is None + cookie = response.headers["set-cookie"].lower() + assert "refresh_token=" in cookie and "httponly" in cookie and "samesite=strict" in cookie + assert "path=/api/v1/auth" in cookie + + refreshed = await client.post("/api/v1/auth/refresh/") # the cookie jar sends it + assert refreshed.status_code == 200 + assert refreshed.json()["refresh_token"] is None + assert refreshed.headers["set-cookie"] != response.headers["set-cookie"] # rotated + + +async def test_refresh_token_works_once_and_reuse_revokes_the_whole_chain(client, session): + await make_user(session, "a@example.com") + first = (await login(client, "a@example.com")).json()["refresh_token"] + + second = await client.post("/api/v1/auth/refresh/", json={"refresh_token": first}) + assert second.status_code == 200 + newest = second.json()["refresh_token"] + assert newest != first + + # The old token again: somebody has a copy. Everything issued from it dies. + replay = await client.post("/api/v1/auth/refresh/", json={"refresh_token": first}) + assert replay.status_code == 401 + after = await client.post("/api/v1/auth/refresh/", json={"refresh_token": newest}) + assert after.status_code == 401 + + +async def test_logout_revokes_the_refresh_token(client, session): + await make_user(session, "a@example.com") + refresh_token = (await login(client, "a@example.com")).json()["refresh_token"] + + await client.post("/api/v1/auth/logout/", json={"refresh_token": refresh_token}) + + response = await client.post("/api/v1/auth/refresh/", json={"refresh_token": refresh_token}) + assert response.status_code == 401 + + +async def test_refresh_fails_for_a_deactivated_user(client, session): + user = await make_user(session, "a@example.com") + refresh_token = (await login(client, "a@example.com")).json()["refresh_token"] + await UserRepository(session).update(user.id, {"is_active": False}) + + response = await client.post("/api/v1/auth/refresh/", json={"refresh_token": refresh_token}) + assert response.status_code == 401 + + +async def test_missing_bad_and_expired_access_tokens_are_401(client, session, monkeypatch): + await make_user(session, "a@example.com") + headers = await auth_headers(client, "a@example.com") + + assert (await client.get("/api/v1/trainings/")).status_code == 401 + bad = {"Authorization": headers["Authorization"][:-3] + "abc"} + assert (await client.get("/api/v1/trainings/", headers=bad)).status_code == 401 + assert (await client.get("/api/v1/trainings/", headers=headers)).status_code == 200 + + monkeypatch.setattr(settings, "ACCESS_TOKEN_TTL_MINUTES", -1) + expired = await auth_headers(client, "a@example.com") + assert (await client.get("/api/v1/trainings/", headers=expired)).status_code == 401 + + +async def test_deactivated_user_loses_access_before_the_token_expires(client, session): + user = await make_user(session, "a@example.com") + headers = await auth_headers(client, "a@example.com") + + await UserRepository(session).update(user.id, {"is_active": False}) + + assert (await client.get("/api/v1/trainings/", headers=headers)).status_code == 401 + + +async def test_health_is_open(client): + assert (await client.get("/api/v1/health/")).status_code == 200 + + +# --- who may do what ---------------------------------------------------------------------- + + +async def test_everyone_sees_only_their_trainings(client, session): + anna = await make_user(session, "anna@example.com") + boris = await make_user(session, "boris@example.com") + coach = await make_user(session, "coach@example.com", UserRole.trainer, first_name="Пан") + other_coach = await make_user(session, "coach2@example.com", UserRole.trainer) + admin = await make_user(session, "admin@example.com", superuser=True) + annas = await make_training(session, anna, trainer=coach) + boriss = await make_training(session, boris, trainer=other_coach) + + async def visible(email): + response = await client.get( + "/api/v1/trainings/", headers=await auth_headers(client, email) + ) + return {item["id"] for item in response.json()} + + assert await visible("anna@example.com") == {annas} + assert await visible("boris@example.com") == {boriss} + assert await visible("coach@example.com") == {annas} + assert await visible("admin@example.com") == {annas, boriss} + + # a training of someone else doesn't exist for the user + headers = await auth_headers(client, "anna@example.com") + assert (await client.get(f"/api/v1/trainings/{boriss}/", headers=headers)).status_code == 404 + assert (await client.get(f"/api/v1/trainings/{annas}/", headers=headers)).status_code == 200 + + +def update_body(trainee_id, trainer_id=None): + return { + "date": "2025-02-02", + "trainee_id": trainee_id, + "trainer_id": trainer_id, + "exercises": [{"name": "Тяга", "approaches": [{"weight": 30, "reps": 8}]}], + } + + +async def test_only_the_trainee_or_a_superuser_can_change_a_training(client, session): + anna = await make_user(session, "anna@example.com") + boris = await make_user(session, "boris@example.com") + coach = await make_user(session, "coach@example.com", UserRole.trainer) + await make_user(session, "admin@example.com", superuser=True) + training_id = await make_training(session, anna, trainer=coach) + url = f"/api/v1/trainings/{training_id}/" + + coach_headers = await auth_headers(client, "coach@example.com") + boris_headers = await auth_headers(client, "boris@example.com") + anna_headers = await auth_headers(client, "anna@example.com") + + assert (await client.put(url, json=update_body(anna.id, coach.id), headers=coach_headers)).status_code == 403 + assert (await client.delete(url, headers=coach_headers)).status_code == 403 + assert (await client.put(url, json=update_body(anna.id), headers=boris_headers)).status_code == 404 + assert (await client.delete(url, headers=boris_headers)).status_code == 404 + + # the trainee can't hand the training over to somebody else, the superuser can + assert (await client.put(url, json=update_body(boris.id), headers=anna_headers)).status_code == 403 + ok = await client.put(url, json=update_body(anna.id), headers=anna_headers) + assert ok.status_code == 200 and ok.json()["trainer"] is None + admin_headers = await auth_headers(client, "admin@example.com") + moved = await client.put(url, json=update_body(boris.id), headers=admin_headers) + assert moved.status_code == 200 and moved.json()["trainee_id"] == boris.id + + assert (await client.delete(url, headers=admin_headers)).status_code == 200 + + +async def test_user_management_is_for_superusers_only(client, session): + await make_user(session, "anna@example.com") + admin = await make_user(session, "admin@example.com", superuser=True) + anna_headers = await auth_headers(client, "anna@example.com") + admin_headers = await auth_headers(client, "admin@example.com") + new_user = {"email": "new@example.com", "password": "12345678"} + + assert (await client.get("/api/v1/users/", headers=anna_headers)).status_code == 403 + assert (await client.post("/api/v1/users/", json=new_user, headers=anna_headers)).status_code == 403 + assert (await client.post("/api/v1/users/", json=new_user)).status_code == 401 + assert (await client.post("/api/v1/users/", json=new_user, headers=admin_headers)).status_code == 201 + + # the new user can log in with the password the superuser chose + created = await client.post( + "/api/v1/auth/login/", json={"email": "new@example.com", "password": "12345678"} + ) + assert created.status_code == 200 + # a superuser can't lock themselves out + assert (await client.delete(f"/api/v1/users/{admin.id}/", headers=admin_headers)).status_code == 400 + + +async def test_any_user_can_list_trainers_but_only_by_name(client, session): + await make_user(session, "anna@example.com") + await make_user(session, "coach@example.com", UserRole.trainer, first_name="Пан", last_name="Степан") + + response = await client.get( + "/api/v1/users/trainers/", headers=await auth_headers(client, "anna@example.com") + ) + + assert response.status_code == 200 + assert [(t["name"], set(t)) for t in response.json()] == [("Пан Степан", {"id", "name"})] + + +async def test_exercise_statistics_are_about_the_users_own_trainings(client, session): + anna = await make_user(session, "anna@example.com") + boris = await make_user(session, "boris@example.com") + await make_training(session, anna, exercise="Жим", weight=50) + await make_training(session, boris, exercise="Жим", weight=90) + await make_training(session, boris, exercise="Тяга") + headers = await auth_headers(client, "anna@example.com") + + names = await client.get("/api/v1/exercises/", headers=headers) + progress = await client.get("/api/v1/exercises/progress/", params={"name": "Жим"}, headers=headers) + foreign = await client.get("/api/v1/exercises/progress/", params={"name": "Тяга"}, headers=headers) + other_trainee = await client.get( + "/api/v1/exercises/progress/", params={"name": "Жим", "trainee_id": boris.id}, headers=headers + ) + + assert [n["name"] for n in names.json()] == ["Жим"] + assert progress.json()["max_weight"] == 50 and len(progress.json()["rows"]) == 1 + assert foreign.status_code == 404 + assert other_trainee.status_code == 404 # Boris' trainings are not visible to Anna + + +async def test_parser_and_dev_endpoints_need_a_login(client, session): + await make_user(session, "anna@example.com") + headers = await auth_headers(client, "anna@example.com") + body = {"data": "# 07.01.2025 (solo-2)\n", "format": "obsidian"} + + assert (await client.post("/api/v1/parser/parse/", json=body)).status_code == 401 + assert (await client.post("/api/v1/parser/parse/", json=body, headers=headers)).status_code == 200 + assert (await client.get("/api/v1/trainings/obsidian/", headers=headers)).status_code == 403 diff --git a/compose.yaml b/compose.yaml index f979226..3364ff7 100644 --- a/compose.yaml +++ b/compose.yaml @@ -33,6 +33,7 @@ services: - ./frontend/exercises.html:/usr/share/nginx/html/exercises.html:ro - ./frontend/parse.html:/usr/share/nginx/html/parse.html:ro - ./frontend/users.html:/usr/share/nginx/html/users.html:ro + - ./frontend/login.html:/usr/share/nginx/html/login.html:ro - ./frontend/css:/usr/share/nginx/html/css:ro - ./frontend/js:/usr/share/nginx/html/js:ro - ./frontend/vendor:/usr/share/nginx/html/vendor:ro diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 6ce85d7..90e2096 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,7 +1,7 @@ FROM nginx:1.27-alpine COPY nginx.conf /etc/nginx/conf.d/default.conf -COPY index.html exercises.html parse.html users.html /usr/share/nginx/html/ +COPY index.html exercises.html parse.html users.html login.html /usr/share/nginx/html/ COPY css /usr/share/nginx/html/css COPY js /usr/share/nginx/html/js COPY vendor /usr/share/nginx/html/vendor diff --git a/frontend/exercises.html b/frontend/exercises.html index 8d570e4..c5a77bb 100644 --- a/frontend/exercises.html +++ b/frontend/exercises.html @@ -18,8 +18,9 @@

- + +
diff --git a/frontend/index.html b/frontend/index.html index 177d114..2504647 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,8 +18,9 @@ - + +
diff --git a/frontend/js/auth.js b/frontend/js/auth.js new file mode 100644 index 0000000..779d53d --- /dev/null +++ b/frontend/js/auth.js @@ -0,0 +1,101 @@ +// Session: the short-lived access token lives in sessionStorage; the refresh token is an +// httpOnly cookie that the browser sends to /auth/* by itself (script code can't read it). + +const API = '/api/v1'; +const STORAGE_KEY = 'access'; +// Refresh a bit before the expiry so that a request doesn't go out with a dying token +const MARGIN_MS = 30_000; + +function stored() { + try { + return JSON.parse(sessionStorage.getItem(STORAGE_KEY)); + } catch { + return null; + } +} + +function store(tokens) { + const access = { token: tokens.access_token, expiresAt: Date.now() + tokens.expires_in * 1000 }; + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(access)); + } catch { + // sessionStorage is unavailable: the token is then only valid for this call chain + } + return access; +} + +export function clearSession() { + try { + sessionStorage.removeItem(STORAGE_KEY); + } catch { + // nothing to clear + } +} + +async function post(path, body) { + return fetch(API + path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +/** Signs in; throws an Error with `status` (401: wrong email or password). */ +export async function login(email, password) { + const response = await post('/auth/login/', { email, password, client: 'web' }); + if (!response.ok) { + const error = new Error(`HTTP ${response.status}`); + error.status = response.status; + throw error; + } + store(await response.json()); +} + +// One refresh at a time: the refresh token works once, two parallel requests would +// make the second one look like a stolen token +let refreshing = null; + +/** Exchanges the cookie for a new access token. false: the session is over, sign in again. */ +export function refresh() { + refreshing ??= (async () => { + try { + const response = await post('/auth/refresh/'); + if (!response.ok) { + clearSession(); + return false; + } + store(await response.json()); + return true; + } finally { + refreshing = null; + } + })(); + return refreshing; +} + +/** A valid access token, refreshed when needed; null when there is no session. */ +export async function accessToken() { + let access = stored(); + if (!access || access.expiresAt - Date.now() < MARGIN_MS) { + if (!(await refresh())) { + return null; + } + access = stored(); + } + return access?.token ?? null; +} + +export async function logout() { + try { + await post('/auth/logout/'); + } finally { + clearSession(); + } +} + +/** Never settles: stops the page code that follows while the browser goes to the login page */ +export function redirectToLogin() { + const next = location.pathname + location.search; + location.href = `login.html?next=${encodeURIComponent(next)}`; + return new Promise(() => {}); +} diff --git a/frontend/js/common.js b/frontend/js/common.js index e64723e..9258f34 100644 --- a/frontend/js/common.js +++ b/frontend/js/common.js @@ -1,12 +1,31 @@ // Shared helpers: API access, DOM building, formatting +import { accessToken, redirectToLogin, refresh } from './auth.js'; + const API = '/api/v1'; /** What a training without a trainer is called */ export const SOLO = 'соло'; -export async function api(path, options) { - const response = await fetch(API + path, options); +async function send(path, options, token) { + const headers = { ...options.headers, Authorization: `Bearer ${token}` }; + return fetch(API + path, { ...options, headers }); +} + +/** JSON request to the API with the access token. Without a session the browser is sent to + * the login page; an expired token is refreshed and the request repeated once. */ +export async function api(path, options = {}) { + const token = await accessToken(); + if (!token) { + return redirectToLogin(); + } + let response = await send(path, options, token); + if (response.status === 401) { + if (!(await refresh())) { + return redirectToLogin(); + } + response = await send(path, options, await accessToken()); + } if (!response.ok) { const error = new Error(`HTTP ${response.status}`); error.status = response.status; @@ -37,9 +56,10 @@ export function formatDate(iso) { return `${day}.${month}.${year}`; } -/** "First Last" of a user, the email when the name is empty */ +/** "First Last" of a user, the email when the name is empty. Trainers in a list come with + * a ready `name`. */ export function userName(user) { - return `${user.first_name} ${user.last_name}`.trim() || user.email; + return user.name || `${user.first_name} ${user.last_name}`.trim() || user.email; } /** "2026-10-23T17:27:00+00:00" -> "23.10.2026 17:27" in the viewer's timezone */ diff --git a/frontend/js/edit-dialog.js b/frontend/js/edit-dialog.js index 1b14104..05d6cc6 100644 --- a/frontend/js/edit-dialog.js +++ b/frontend/js/edit-dialog.js @@ -3,6 +3,7 @@ import { alert, api, h } from './common.js'; import { editForm } from './edit-form.js'; import { openModal } from './modal.js'; +import { user } from './page.js'; /** "Изменить" button: opens the modal, the page reloads after a save to show fresh data */ export function editButton(trainingId) { @@ -18,8 +19,9 @@ export async function openEditDialog(trainingId, onChange) { const [training, exerciseNames, trainees, trainers] = await Promise.all([ api(`/trainings/${trainingId}/`), api('/exercises/'), - api('/users/?role=trainee'), - api('/users/?role=trainer'), + // only a superuser may hand a training to another trainee + user.is_superuser ? api('/users/?role=trainee') : null, + api('/users/trainers/'), ]); body.replaceChildren(editForm(training, exerciseNames, { trainees, trainers }, { onDone: () => { hide(); onChange(); }, diff --git a/frontend/js/edit-form.js b/frontend/js/edit-form.js index 3a1f9b3..9dee306 100644 --- a/frontend/js/edit-form.js +++ b/frontend/js/edit-form.js @@ -79,7 +79,7 @@ function userSelect(name, users, selectedId, emptyTitle) { } /** - * trainees / trainers: users to choose from. onDone() is called after the training was saved + * trainees / trainers: users to choose from (trainees is null when the trainee can't be changed). onDone() is called after the training was saved * or deleted, onCancel() on "Отмена". */ export function editForm(training, exerciseNames, { trainees, trainers }, { onDone, onCancel }) { @@ -101,7 +101,9 @@ export function editForm(training, exerciseNames, { trainees, trainers }, { onDo h('datalist', { id: 'edit-exercise-names' }, ...exerciseNames.map((item) => h('option', { value: item.name }))), h('div', { class: 'row g-3 mb-3' }, field('Дата', h('input', { type: 'date', class: 'form-control', name: 'date', required: true, value: training.date }), 'col-12 col-md-3'), - field('Тренирующийся', userSelect('trainee_id', trainees, training.trainee_id), 'col-12 col-md-3'), + trainees + ? field('Тренирующийся', userSelect('trainee_id', trainees, training.trainee_id), 'col-12 col-md-3') + : h('input', { type: 'hidden', name: 'trainee_id', value: training.trainee_id }), field('Тренер', userSelect('trainer_id', trainers, training.trainer_id, `${SOLO} (без тренера)`), 'col-12 col-md-3')), exercisesBox, addExercise, diff --git a/frontend/js/exercises.js b/frontend/js/exercises.js index 60d9be2..ab85bea 100644 --- a/frontend/js/exercises.js +++ b/frontend/js/exercises.js @@ -1,5 +1,7 @@ +import './page.js'; import { alert, api, approachChips, formatDate, h, num, perArmBadge, signed, SOLO, tip } from './common.js'; import { editButton } from './edit-dialog.js'; +import { canEditTraining } from './page.js'; import { createTable } from './table.js'; const HINTS = { @@ -116,7 +118,7 @@ function progressView(progress) { { title: 'Лучший подход', className: 'text-nowrap text-end', sort: (row) => row.metrics.best_reps, cell: (row) => `${row.metrics.best_reps}` }, { title: 'Всего повторов', className: 'text-nowrap text-end', sort: (row) => row.metrics.total_reps, cell: (row) => `${row.metrics.total_reps}` }, ]), - { title: '', cell: (row) => row.training_id ? editButton(row.training_id) : null }, + { title: '', cell: (row) => row.training_id && canEditTraining(row.trainee_id) ? editButton(row.training_id) : null }, ]; return [ diff --git a/frontend/js/login.js b/frontend/js/login.js new file mode 100644 index 0000000..554ff2d --- /dev/null +++ b/frontend/js/login.js @@ -0,0 +1,26 @@ +import { login } from './auth.js'; +import { alert, h } from './common.js'; + +const form = document.getElementById('login-form'); +const status = document.getElementById('status'); + +/** Where to go after signing in: the page that sent us here, but only a path of this site */ +function nextPage() { + const next = new URLSearchParams(location.search).get('next'); + return next && next.startsWith('/') && !next.startsWith('//') ? next : 'index.html'; +} + +form.addEventListener('submit', async (event) => { + event.preventDefault(); + const submit = form.querySelector('button'); + submit.disabled = true; + try { + await login(form.elements.email.value, form.elements.password.value); + location.href = nextPage(); + } catch (error) { + status.replaceChildren(h('div', { class: 'mb-3' }, alert('danger', error.status === 401 + ? 'Неверный email или пароль.' + : `Не удалось войти: ${error.message}`))); + submit.disabled = false; + } +}); diff --git a/frontend/js/page.js b/frontend/js/page.js new file mode 100644 index 0000000..6233701 --- /dev/null +++ b/frontend/js/page.js @@ -0,0 +1,28 @@ +// Every page imports this first: it requires a signed-in user and fills the sidebar. +// `user` is the current user (the module waits for it, so importers can use it at once). + +import { logout, redirectToLogin } from './auth.js'; +import { api, h, userName } from './common.js'; + +const ROLES = { trainer: 'Тренер', trainee: 'Тренирующийся' }; + +export const user = await api('/auth/me/'); + +/** A training can be changed by its trainee (a trainer only sees it) and by a superuser */ +export function canEditTraining(traineeId) { + return user.is_superuser || user.id === traineeId; +} + +for (const element of document.querySelectorAll('[data-superuser-only]')) { + element.hidden = !user.is_superuser; +} + +const signOut = h('button', { type: 'button', class: 'btn btn-sm btn-outline-light mt-2' }, 'Выйти'); +signOut.addEventListener('click', async () => { + await logout(); + await redirectToLogin(); +}); +document.getElementById('user-box')?.replaceChildren( + h('div', { class: 'text-white text-truncate' }, userName(user)), + h('div', {}, ROLES[user.role] + (user.is_superuser ? ', администратор' : '')), + signOut); diff --git a/frontend/js/parse.js b/frontend/js/parse.js index 37e1a0c..d419848 100644 --- a/frontend/js/parse.js +++ b/frontend/js/parse.js @@ -1,4 +1,5 @@ import { alert, api } from './common.js'; +import './page.js'; import { trainingsView } from './trainings-view.js'; const form = document.getElementById('parse-form'); diff --git a/frontend/js/trainings-view.js b/frontend/js/trainings-view.js index ad69065..a670799 100644 --- a/frontend/js/trainings-view.js +++ b/frontend/js/trainings-view.js @@ -2,6 +2,7 @@ import { alert, approachChips, formatDate, h, perArmBadge, SOLO } from './common.js'; import { editButton } from './edit-dialog.js'; +import { canEditTraining } from './page.js'; import { createTable } from './table.js'; export function trainingsView(trainings) { @@ -39,7 +40,7 @@ export function trainingsView(trainings) { { title: 'Подходы (вес × повторения)', cell: (row) => h('span', {}, ...(row.exercise ? approachChips(row.exercise.approaches) : [])) }, ...(editable ? [{ title: '', - cell: (row) => editButton(row.training.id), + cell: (row) => canEditTraining(row.training.trainee_id) ? editButton(row.training.id) : null, }] : []), ], })); diff --git a/frontend/js/trainings.js b/frontend/js/trainings.js index c2b30a4..474700e 100644 --- a/frontend/js/trainings.js +++ b/frontend/js/trainings.js @@ -1,4 +1,5 @@ import { alert, api } from './common.js'; +import './page.js'; import { trainingsView } from './trainings-view.js'; const container = document.getElementById('trainings'); diff --git a/frontend/js/users.js b/frontend/js/users.js index e1d2b94..d89a56d 100644 --- a/frontend/js/users.js +++ b/frontend/js/users.js @@ -1,4 +1,5 @@ import { alert, api, formatDateTime, h } from './common.js'; +import { user } from './page.js'; import { createTable } from './table.js'; import { openUserDialog } from './user-dialog.js'; @@ -7,6 +8,10 @@ const ROLES = { trainee: { title: 'Тренирующийся', badge: 'text-bg-secondary' }, }; +if (!user.is_superuser) { + location.href = 'index.html'; +} + const container = document.getElementById('users'); const reload = () => location.reload(); diff --git a/frontend/login.html b/frontend/login.html new file mode 100644 index 0000000..08066c2 --- /dev/null +++ b/frontend/login.html @@ -0,0 +1,30 @@ + + + + + + Вход - Fitness Parser + + + + + +
+

Fitness parser

+
+
+ + +
+
+ + +
+
+ +
+
+ + + + diff --git a/frontend/parse.html b/frontend/parse.html index 0a58ab7..657c50c 100644 --- a/frontend/parse.html +++ b/frontend/parse.html @@ -18,8 +18,9 @@ - + +
diff --git a/frontend/users.html b/frontend/users.html index 1588605..179c8f2 100644 --- a/frontend/users.html +++ b/frontend/users.html @@ -18,8 +18,9 @@ - + +