Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 0 additions & 14 deletions backend/prestart.sh

This file was deleted.

5 changes: 1 addition & 4 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Donate = "https://www.kiwix.org/en/support-us/"
cms-api = "cms_backend.api.main:app"
cms-mill = "cms_backend.mill.main:main"
cms-shuttle = "cms_backend.shuttle.main:main"
create-initial-user = "cms_backend.utils.database:create_initial_user"
create-initial-account = "cms_backend.utils.database:create_initial_account"
check-db-schema = "cms_backend.utils.database:check_if_schema_is_up_to_date"

[tool.hatch.version]
Expand Down Expand Up @@ -235,9 +235,6 @@ minversion = "8.3.5"
testpaths = ["tests"]
pythonpath = [".", "src"]
addopts = "--strict-markers"
markers = [
"num_users(num=10, *, permission=...): create num users in the database with permission (default: ADMIN)",
]


[tool.coverage.paths]
Expand Down
8 changes: 4 additions & 4 deletions backend/src/cms_backend/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from fastapi.responses import JSONResponse
from pydantic import ValidationError

from cms_backend.api.routes.account import router as account_router
from cms_backend.api.routes.auth import router as auth_router
from cms_backend.api.routes.books import router as books_router
from cms_backend.api.routes.collection import router as collection_router
Expand All @@ -18,7 +19,6 @@
from cms_backend.api.routes.http_errors import BadRequestError
from cms_backend.api.routes.staging import router as staging_router
from cms_backend.api.routes.titles import router as titles_router
from cms_backend.api.routes.user import router as user_router
from cms_backend.api.routes.zimfarm_notifications import (
router as zimfarm_notification_router,
)
Expand All @@ -30,7 +30,7 @@
)
from cms_backend.utils.database import (
check_if_schema_is_up_to_date,
create_initial_user,
create_initial_account,
upgrade_db_schema,
)

Expand All @@ -40,7 +40,7 @@ async def lifespan(_: FastAPI):
if Context.alembic_upgrade_head_on_start:
upgrade_db_schema()
check_if_schema_is_up_to_date()
create_initial_user()
create_initial_account()
yield


Expand Down Expand Up @@ -72,7 +72,7 @@ def create_app(*, debug: bool = True):
main_router.include_router(router=collection_router)
main_router.include_router(router=events_router)
main_router.include_router(router=auth_router)
main_router.include_router(router=user_router)
main_router.include_router(router=account_router)
main_router.include_router(router=staging_router)

app.include_router(router=main_router)
Expand Down
109 changes: 109 additions & 0 deletions backend/src/cms_backend/api/routes/account.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
from http import HTTPStatus
from typing import Annotated

from fastapi import APIRouter, Depends, Path, Response
from sqlalchemy.orm import Session as OrmSession
from werkzeug.security import check_password_hash, generate_password_hash

from cms_backend.api.routes.dependencies import get_current_account, require_permission
from cms_backend.api.routes.fields import NotEmptyString
from cms_backend.api.routes.http_errors import BadRequestError, UnauthorizedError
from cms_backend.db import gen_dbsession
from cms_backend.db.account import (
check_account_permission,
create_account_schema,
get_account_by_username,
)
from cms_backend.db.account import create_account as db_create_account
from cms_backend.db.account import delete_account as db_delete_account
from cms_backend.db.account import update_account_password as db_update_account_password
from cms_backend.db.models import Account
from cms_backend.roles import RoleEnum
from cms_backend.schemas import BaseModel
from cms_backend.schemas.orms import AccountSchema

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


class AccountCreateSchema(BaseModel):
"""
Schema for creating an account
"""

username: NotEmptyString
password: NotEmptyString
role: RoleEnum


class PasswordUpdateSchema(BaseModel):
"""
Schema for updating an account's password
"""

# account with elevated permissions can omit the current password
current: NotEmptyString | None = None
new: NotEmptyString


@router.post(
"",
dependencies=[Depends(require_permission(namespace="account", name="create"))],
)
def create_account(
account_schema: AccountCreateSchema,
db_session: Annotated[OrmSession, Depends(gen_dbsession)],
) -> AccountSchema:
account = db_create_account(
db_session,
username=account_schema.username,
password_hash=generate_password_hash(account_schema.password),
role=account_schema.role,
)

return create_account_schema(account)


@router.delete(
"/{username}",
dependencies=[Depends(require_permission(namespace="account", name="delete"))],
)
def delete_account(
username: Annotated[str, Path()],
db_session: Annotated[OrmSession, Depends(gen_dbsession)],
) -> Response:
"""Delete a specific account"""
account = get_account_by_username(db_session, username=username)
db_delete_account(db_session, account_id=account.id)
return Response(status_code=HTTPStatus.NO_CONTENT)


@router.patch("/{username}/password")
def update_account_password(
username: Annotated[str, Path()],
password_update: PasswordUpdateSchema,
db_session: Annotated[OrmSession, Depends(gen_dbsession)],
current_account: Annotated[Account, Depends(get_current_account)],
) -> Response:
"""Update an account's password"""
account = get_account_by_username(db_session, username=username)

if current_account.username == username:
if password_update.current is None:
raise BadRequestError("You must enter your current password.")

if not check_password_hash(
current_account.password_hash or "", password_update.current
):
raise BadRequestError()

elif not check_account_permission(
current_account, namespace="account", name="update"
):
raise UnauthorizedError("You are not allowed to access this resource")

db_update_account_password(
db_session,
account_id=account.id,
password_hash=generate_password_hash(password_update.new),
)
return Response(status_code=HTTPStatus.NO_CONTENT)
40 changes: 21 additions & 19 deletions backend/src/cms_backend/api/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,21 @@
from werkzeug.security import check_password_hash

from cms_backend.api.context import Context
from cms_backend.api.routes.dependencies import get_current_user
from cms_backend.api.routes.dependencies import get_current_account
from cms_backend.api.routes.http_errors import UnauthorizedError
from cms_backend.api.token import generate_access_token
from cms_backend.db import gen_dbsession
from cms_backend.db.account import create_account_schema, get_account_by_username
from cms_backend.db.exceptions import RecordDoesNotExistError
from cms_backend.db.models import User
from cms_backend.db.models import Account
from cms_backend.db.refresh_token import (
create_refresh_token,
delete_refresh_token,
expire_refresh_tokens,
get_refresh_token,
)
from cms_backend.db.user import create_user_schema, get_user_by_username
from cms_backend.schemas import BaseModel
from cms_backend.schemas.orms import UserSchema
from cms_backend.schemas.orms import AccountSchema
from cms_backend.utils.datetime import getnow

router = APIRouter(prefix="/auth", tags=["auth"])
Expand All @@ -45,17 +45,19 @@ class Token(BaseModel):
refresh_token: str


def _access_token_response(db_session: OrmSession, db_user: User, response: Response):
def _access_token_response(
db_session: OrmSession, db_account: Account, response: Response
):
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
issue_time = getnow()
return Token(
access_token=generate_access_token(
user_id=str(db_user.id),
account_id=str(db_account.id),
issue_time=issue_time,
),
refresh_token=str(
create_refresh_token(session=db_session, user_id=db_user.id).token
create_refresh_token(session=db_session, account_id=db_account.id).token
),
expires_time=issue_time
+ datetime.timedelta(seconds=Context.jwt_token_expiry_duration),
Expand All @@ -65,19 +67,19 @@ def _access_token_response(db_session: OrmSession, db_user: User, response: Resp
def _auth_with_credentials(
db_session: OrmSession, credentials: CredentialsIn, response: Response
):
"""Authorize a user with username and password."""
"""Authorize an account with username and password."""
try:
db_user = get_user_by_username(db_session, username=credentials.username)
db_account = get_account_by_username(db_session, username=credentials.username)
except RecordDoesNotExistError as exc:
raise UnauthorizedError() from exc

if not (
db_user.password_hash
and check_password_hash(db_user.password_hash, credentials.password)
db_account.password_hash
and check_password_hash(db_account.password_hash, credentials.password)
):
raise UnauthorizedError("Invalid credentials")

return _access_token_response(db_session, db_user, response)
return _access_token_response(db_session, db_account, response)


def _refresh_access_token(
Expand All @@ -96,7 +98,7 @@ def _refresh_access_token(
delete_refresh_token(db_session, token=refresh_token)
expire_refresh_tokens(db_session, expire_time=now)

return _access_token_response(db_session, db_refresh_token.user, response)
return _access_token_response(db_session, db_refresh_token.account, response)


@router.post("/authorize")
Expand All @@ -105,7 +107,7 @@ def auth_with_credentials(
response: Response,
db_session: Annotated[OrmSession, Depends(gen_dbsession)],
) -> Token:
"""Authorize a user with username and password."""
"""Authorize an account with username and password."""
return _auth_with_credentials(db_session, credentials, response)


Expand All @@ -119,8 +121,8 @@ def refresh_access_token(


@router.get("/me")
def get_current_user_info(
current_user: Annotated[User, Depends(get_current_user)],
) -> UserSchema:
"""Get the current authenticated user's information including scopes."""
return create_user_schema(current_user)
def get_current_account_info(
current_account: Annotated[Account, Depends(get_current_account)],
) -> AccountSchema:
"""Get the current authenticated account's information including scopes."""
return create_account_schema(current_account)
Loading
Loading