Auth: Add basic JWT authorization
This commit is contained in:
parent
001fdb5a73
commit
55a7168fcc
44 changed files with 1197 additions and 74 deletions
|
|
@ -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
|
||||
|
|
|
|||
10
CLAUDE.md
10
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".
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
123
backend/app/api/v1/auth.py
Normal file
123
backend/app/api/v1/auth.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
)
|
||||
|
|
|
|||
96
backend/app/core/database/token_repository.py
Normal file
96
backend/app/core/database/token_repository.py
Normal file
|
|
@ -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())
|
||||
)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,16 +1,26 @@
|
|||
"""Password hashing with scrypt from the standard library (no extra dependencies).
|
||||
"""Passwords and tokens.
|
||||
|
||||
Stored format: scrypt$<n>$<r>$<p>$<salt, base64>$<hash, base64>. 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$<n>$<r>$<p>$<salt, base64>$<hash, base64>. 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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
24
backend/app/schemas/auth.py
Normal file
24
backend/app/schemas/auth.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ###
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
0
backend/tests/test_api/__init__.py
Normal file
0
backend/tests/test_api/__init__.py
Normal file
289
backend/tests/test_api/test_auth.py
Normal file
289
backend/tests/test_api/test_auth.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@
|
|||
<li class="nav-item"><a class="nav-link text-white" href="index.html">Тренировки</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-white" href="parse.html">Разобрать заметку</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-white active" href="exercises.html">Упражнения</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-white" href="users.html">Пользователи</a></li>
|
||||
<li class="nav-item" data-superuser-only hidden><a class="nav-link text-white" href="users.html">Пользователи</a></li>
|
||||
</ul>
|
||||
<div id="user-box" class="mt-4 small text-white-50"></div>
|
||||
</aside>
|
||||
|
||||
<main class="flex-grow-1 p-4 content" id="content">
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@
|
|||
<li class="nav-item"><a class="nav-link text-white active" href="index.html">Тренировки</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-white" href="parse.html">Разобрать заметку</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-white" href="exercises.html">Упражнения</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-white" href="users.html">Пользователи</a></li>
|
||||
<li class="nav-item" data-superuser-only hidden><a class="nav-link text-white" href="users.html">Пользователи</a></li>
|
||||
</ul>
|
||||
<div id="user-box" class="mt-4 small text-white-50"></div>
|
||||
</aside>
|
||||
|
||||
<main class="flex-grow-1 p-4 content" id="content">
|
||||
|
|
|
|||
101
frontend/js/auth.js
Normal file
101
frontend/js/auth.js
Normal file
|
|
@ -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(() => {});
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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(); },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
26
frontend/js/login.js
Normal file
26
frontend/js/login.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
28
frontend/js/page.js
Normal file
28
frontend/js/page.js
Normal file
|
|
@ -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);
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}] : []),
|
||||
],
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { alert, api } from './common.js';
|
||||
import './page.js';
|
||||
import { trainingsView } from './trainings-view.js';
|
||||
|
||||
const container = document.getElementById('trainings');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
30
frontend/login.html
Normal file
30
frontend/login.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Вход - Fitness Parser</title>
|
||||
|
||||
<link href="vendor/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="css/main.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<main class="container" style="max-width: 24rem; padding-top: 15vh;">
|
||||
<h1 class="h3 mb-4 text-center">Fitness parser</h1>
|
||||
<form id="login-form" class="card card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-muted mb-1" for="email">Email</label>
|
||||
<input class="form-control" id="email" name="email" type="email" required autofocus autocomplete="username">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small text-muted mb-1" for="password">Пароль</label>
|
||||
<input class="form-control" id="password" name="password" type="password" required autocomplete="current-password">
|
||||
</div>
|
||||
<div id="status"></div>
|
||||
<button class="btn btn-primary w-100" type="submit">Войти</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<script type="module" src="js/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -18,8 +18,9 @@
|
|||
<li class="nav-item"><a class="nav-link text-white" href="index.html">Тренировки</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-white active" href="parse.html">Разобрать заметку</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-white" href="exercises.html">Упражнения</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-white" href="users.html">Пользователи</a></li>
|
||||
<li class="nav-item" data-superuser-only hidden><a class="nav-link text-white" href="users.html">Пользователи</a></li>
|
||||
</ul>
|
||||
<div id="user-box" class="mt-4 small text-white-50"></div>
|
||||
</aside>
|
||||
|
||||
<main class="flex-grow-1 p-4 content" id="content">
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@
|
|||
<li class="nav-item"><a class="nav-link text-white" href="index.html">Тренировки</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-white" href="parse.html">Разобрать заметку</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-white" href="exercises.html">Упражнения</a></li>
|
||||
<li class="nav-item"><a class="nav-link text-white active" href="users.html">Пользователи</a></li>
|
||||
<li class="nav-item" data-superuser-only hidden><a class="nav-link text-white active" href="users.html">Пользователи</a></li>
|
||||
</ul>
|
||||
<div id="user-box" class="mt-4 small text-white-50"></div>
|
||||
</aside>
|
||||
|
||||
<main class="flex-grow-1 p-4 content" id="content">
|
||||
|
|
|
|||
Loading…
Reference in a new issue