From ac8303378a7813a1f21e69bf491ecd4c9e9f4712 Mon Sep 17 00:00:00 2001 From: BluemediaDev Date: Thu, 13 Mar 2025 16:34:41 +0000 Subject: [PATCH] Implement user authentication and permissions --- ...13_c7f72154c90b-add_user_authentication.py | 59 +++++ app/cli.py | 88 +++++++ app/main.py | 39 ++- app/models/__init__.py | 1 + app/models/session.py | 14 ++ app/models/user.py | 10 +- app/routers/auth_v1.py | 72 ++++++ app/routers/chargepoint_v1.py | 59 ++--- app/routers/id_token_v1.py | 59 +++-- app/routers/me_v1.py | 111 +++++++++ app/routers/meter_value_v1.py | 13 +- app/routers/transaction_v1.py | 60 ++++- app/routers/user_v1.py | 127 ++++++---- app/schemas/auth_token.py | 21 ++ app/schemas/session.py | 11 + app/schemas/user.py | 23 +- app/security.py | 34 --- app/security/__init__.py | 0 app/security/jwt_bearer.py | 47 ++++ .../websocket_auth_backend.py | 0 app/services/session_service.py | 83 +++++++ app/services/token_service.py | 69 ++++++ app/services/user_service.py | 111 +++++++++ app/util/errors.py | 14 ++ poetry.lock | 227 +++++++++++++++++- pyproject.toml | 2 + 26 files changed, 1182 insertions(+), 172 deletions(-) create mode 100644 alembic/versions/20250313_c7f72154c90b-add_user_authentication.py create mode 100644 app/cli.py create mode 100644 app/models/session.py create mode 100644 app/routers/auth_v1.py create mode 100644 app/routers/me_v1.py create mode 100644 app/schemas/auth_token.py create mode 100644 app/schemas/session.py delete mode 100644 app/security.py create mode 100644 app/security/__init__.py create mode 100644 app/security/jwt_bearer.py rename app/{util => security}/websocket_auth_backend.py (100%) create mode 100644 app/services/session_service.py create mode 100644 app/services/token_service.py create mode 100644 app/services/user_service.py create mode 100644 app/util/errors.py diff --git a/alembic/versions/20250313_c7f72154c90b-add_user_authentication.py b/alembic/versions/20250313_c7f72154c90b-add_user_authentication.py new file mode 100644 index 0000000..7e2c00b --- /dev/null +++ b/alembic/versions/20250313_c7f72154c90b-add_user_authentication.py @@ -0,0 +1,59 @@ +"""Add user authentication + +Revision ID: c7f72154c90b +Revises: 097d427dfa07 +Create Date: 2025-03-13 14:57:05.805469+00:00 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'c7f72154c90b' +down_revision: Union[str, None] = '097d427dfa07' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('sessions', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(), nullable=True), + sa.Column('refresh_token', sa.String(), nullable=False), + sa.Column('last_used', sa.DateTime(timezone=True), nullable=True), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_sessions_refresh_token'), 'sessions', ['refresh_token'], unique=True) + op.create_index(op.f('ix_sessions_user_id'), 'sessions', ['user_id'], unique=False) + op.add_column('users', sa.Column('email', sa.String(), nullable=True)) + op.add_column('users', sa.Column('password', sa.String(), nullable=True)) + op.add_column('users', sa.Column('role', sa.Enum('MEMBER', 'ADMINISTRATOR', name='role'), nullable=True)) + op.execute('UPDATE users SET email = id || \'@example.com\'') + op.execute('UPDATE users SET password = \'invalid\'') + op.execute('UPDATE users SET role = \'MEMBER\'') + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.alter_column('email', nullable=False) + batch_op.alter_column('password', nullable=False) + batch_op.alter_column('role', nullable=False) + op.drop_index('ix_users_friendly_name', table_name='users') + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_users_email'), table_name='users') + op.create_index('ix_users_friendly_name', 'users', ['friendly_name'], unique=1) + op.drop_column('users', 'role') + op.drop_column('users', 'password') + op.drop_column('users', 'email') + op.drop_index(op.f('ix_sessions_user_id'), table_name='sessions') + op.drop_index(op.f('ix_sessions_refresh_token'), table_name='sessions') + op.drop_table('sessions') + # ### end Alembic commands ### diff --git a/app/cli.py b/app/cli.py new file mode 100644 index 0000000..7ce93b6 --- /dev/null +++ b/app/cli.py @@ -0,0 +1,88 @@ +import secrets +import sys +import os +sys.path.append(os.path.join(os.path.dirname(__file__), "..")) + +import argparse +from dotenv import load_dotenv + +from sqlalchemy import select +from sqlalchemy.orm import Session +from argon2 import PasswordHasher + +from app.models import * + +load_dotenv() + +from app.database import SessionLocal + +def __get_user_by_email(db: Session, email: str): + stmt = select(user.User).where(user.User.email == email) + result = db.execute(stmt) + return result.scalars().first() + +def create_user(args): + hasher = PasswordHasher(memory_cost=102400) + with SessionLocal() as db: + db_user = __get_user_by_email(db, args.email) + if db_user is not None: + print(f'Error: A user with email \'{args.email}\' already exists.') + sys.exit(1) + hashed_password = hasher.hash(args.password) + db_user = user.User( + friendly_name=args.name, email=args.email, password=hashed_password + ) + db.add(db_user) + db.commit() + print(f'Success: Created user \'{args.email}\'.') + +def reset_password(args): + hasher = PasswordHasher(memory_cost=102400) + with SessionLocal() as db: + db_user = __get_user_by_email(db, args.email) + if db_user is None: + print(f'Error: No user with email \'{args.email}\' found.') + sys.exit(1) + db_user.password = hasher.hash(args.password) + db.commit() + print(f'Success: Changed password for user \'{args.email}\'.') + +def set_role(args): + with SessionLocal() as db: + db_user = __get_user_by_email(db, args.email) + if db_user is None: + print(f'Error: No user with email \'{args.email}\' found.') + sys.exit(1) + db_user.role = user.Role(args.role) + db.commit() + print(f'Success: Role of user \'{args.email}\' changed to \'{db_user.role}\'.') + +def generate_secret(args): + print(f'Your secret: {secrets.token_urlsafe(64)}') + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(help="subcommands", required=True) + + p_create_user = subparsers.add_parser('user_create', help='Create a new user') + p_create_user.add_argument('--email', type=str, required=True, help='Email of the new user') + p_create_user.add_argument('--password', type=str, required=True, help='Password for the new user') + p_create_user.add_argument('--name', type=str, required=False, help='Display name of the new user') + p_create_user.add_argument('--role', type=str, choices=('member', 'administrator'), default='member', required=False, help='Role of the new user') + p_create_user.set_defaults(func=create_user) + + p_reset_password = subparsers.add_parser('user_reset_password', help='Reset password for a user') + p_reset_password.add_argument('--email', type=str, required=True, help='Email of the user to modify') + p_reset_password.add_argument('--password', type=str, required=True, help='New password for the specified user') + p_reset_password.set_defaults(func=reset_password) + + p_set_role = subparsers.add_parser('user_set_role', help='Update role of a user') + p_set_role.add_argument('--email', type=str, required=True, help='Email of the user to modify') + p_set_role.add_argument('--role', type=str, choices=('member', 'administrator'), required=True, help='New role for the specified user') + p_set_role.set_defaults(func=set_role) + + p_set_role = subparsers.add_parser('generate_secret', help='Generate safe to use secret for token signing') + p_set_role.set_defaults(func=generate_secret) + + args = parser.parse_args() + args.func(args) diff --git a/app/main.py b/app/main.py index c9d8efe..7e92b37 100644 --- a/app/main.py +++ b/app/main.py @@ -1,19 +1,20 @@ from dotenv import load_dotenv -from fastapi import FastAPI -from fastapi.openapi.utils import get_openapi +from fastapi import APIRouter, FastAPI from starlette.middleware.authentication import AuthenticationMiddleware load_dotenv() from app.routers import ( + auth_v1, chargepoint_v1, id_token_v1, + me_v1, meter_value_v1, ocpp_v1, transaction_v1, user_v1 ) -from app.util.websocket_auth_backend import BasicAuthBackend +from app.security.websocket_auth_backend import BasicAuthBackend def create_ocpp_app(): app_ocpp = FastAPI( @@ -24,30 +25,26 @@ def create_ocpp_app(): return app_ocpp -def custom_openapi(): - if app.openapi_schema: - return app.openapi_schema - openapi_schema = get_openapi( - title="simple-ocpp-cs", - version="0.1.0", - summary="Simple implementation of a basic OCPP 2.0.1 compliant central system (backend) for EV charging stations", - routes=app.routes, - ) - app.openapi_schema = openapi_schema - return app.openapi_schema - def create_app(): app = FastAPI( + title="simple-ocpp-cs", + summary="Simple implementation of a basic OCPP 2.0.1 compliant central system (backend) for EV charging stations", responses={404: {"description": "Not found"}}, ) - app.include_router(chargepoint_v1.router, prefix="/v1") - app.include_router(id_token_v1.router, prefix="/v1") - app.include_router(user_v1.router, prefix="/v1") - app.include_router(meter_value_v1.router, prefix="/v1") - app.include_router(transaction_v1.router, prefix="/v1") + api_v1_router = APIRouter( + prefix="/api/v1" + ) + api_v1_router.include_router(auth_v1.router) + api_v1_router.include_router(chargepoint_v1.router) + api_v1_router.include_router(id_token_v1.router) + api_v1_router.include_router(me_v1.router) + api_v1_router.include_router(user_v1.router) + api_v1_router.include_router(meter_value_v1.router) + api_v1_router.include_router(transaction_v1.router) + + app.include_router(api_v1_router) app.mount(path="/v1/ocpp", app=create_ocpp_app()) - app.openapi = custom_openapi return app diff --git a/app/models/__init__.py b/app/models/__init__.py index b6ddc91..26e5c03 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -4,6 +4,7 @@ __all__ = [ "connector", "id_token", "meter_value", + "session", "transaction", "user" ] \ No newline at end of file diff --git a/app/models/session.py b/app/models/session.py new file mode 100644 index 0000000..b1c2213 --- /dev/null +++ b/app/models/session.py @@ -0,0 +1,14 @@ +import uuid +from sqlalchemy import Column, DateTime, ForeignKey, String, Uuid + +from app.database import Base + +class Session(Base): + __tablename__ = "sessions" + + id = Column(Uuid, primary_key=True, default=uuid.uuid4) + name = Column(String) + refresh_token = Column(String, unique=True, index=True) + last_used = Column(DateTime(timezone=True)) + + user_id = Column(Uuid, ForeignKey("users.id"), index=True) \ No newline at end of file diff --git a/app/models/user.py b/app/models/user.py index 80bff7d..75cf5da 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -1,15 +1,19 @@ import uuid -from sqlalchemy import Uuid, Boolean, Column, String +from sqlalchemy import Enum, Uuid, Boolean, Column, String from sqlalchemy.orm import relationship from app.database import Base +from app.schemas.user import Role class User(Base): __tablename__ = "users" id = Column(Uuid, primary_key=True, default=uuid.uuid4) - friendly_name = Column(String, unique=True, index=True) - is_active = Column(Boolean, default=True) + friendly_name = Column(String, nullable=True) + is_active = Column(Boolean, nullable=False, default=True) + email = Column(String, nullable=False, unique=True, index=True) + password = Column(String, nullable=False) + role = Column(Enum(Role), nullable=False, default=Role.MEMBER) id_tokens = relationship("IdToken", back_populates="owner", cascade="delete, delete-orphan") transactions = relationship("Transaction", cascade="delete, delete-orphan") diff --git a/app/routers/auth_v1.py b/app/routers/auth_v1.py new file mode 100644 index 0000000..3abc4de --- /dev/null +++ b/app/routers/auth_v1.py @@ -0,0 +1,72 @@ +from uuid import UUID +from fastapi import APIRouter, HTTPException +from fastapi.params import Depends +from sqlalchemy.orm import Session + +from app.database import get_db +from app.schemas.auth_token import ( + AccessToken, + TokenRefreshRequest, + TokenResponse, +) +from app.schemas.user import LoginRequest +from app.security.jwt_bearer import JWTBearer +from app.services import session_service, token_service, user_service +from app.util.errors import NotFoundError + +router = APIRouter(prefix="/auth", tags=["Authentication (v1)"]) + +@router.post(path="/login", response_model=TokenResponse) +async def login( + login_request: LoginRequest, db: Session = Depends(get_db) +): + """ + Login to a existing account. Creates a new session and returns a access and refresh token. + """ + user = await user_service.validate_login( + db=db, login=login_request + ) + if not user: + raise HTTPException(status_code=403, detail="invalid_email_or_password") + session = await session_service.create_session(db=db, user=user, useragent="") + token, expire = await token_service.create_access_token( + user=user, session_id=session.id + ) + return TokenResponse( + access_token=token, refresh_token=session.refresh_token, not_after=expire + ) + + +@router.post(path="/logout", response_model=list[None]) +async def logout( + db: Session = Depends(get_db), token: AccessToken = Depends(JWTBearer()) +): + """ + Remove the current session based on the access token, effectively invalidating the current refresh token. + """ + await session_service.remove_session( + db=db, id=UUID(token.session), initiator=f"user:{token.subject}" + ) + return list() + +@router.post(path="/refresh", response_model=TokenResponse) +async def refresh_access_token( + token_request: TokenRefreshRequest, + db: Session = Depends(get_db), +): + """ + Use an existing refresh token to generate a new access token and a new refresh token. + """ + try: + session = await session_service.validate_and_rotate_refresh_token( + db=db, refresh_token=token_request.refresh_token + ) + user = await user_service.get_user(db=db, id=session.user_id) + token, expire = await token_service.create_access_token( + user=user, session_id=session.id + ) + return TokenResponse( + access_token=token, refresh_token=session.refresh_token, not_after=expire + ) + except NotFoundError: + raise HTTPException(status_code=403, detail="invalid_refresh_token") \ No newline at end of file diff --git a/app/routers/chargepoint_v1.py b/app/routers/chargepoint_v1.py index ab995de..ff22bfd 100644 --- a/app/routers/chargepoint_v1.py +++ b/app/routers/chargepoint_v1.py @@ -2,7 +2,7 @@ import random import string from datetime import datetime, timedelta, UTC from uuid import UUID -from fastapi import APIRouter, HTTPException, Security +from fastapi import APIRouter, HTTPException from fastapi.params import Depends from sqlalchemy.orm import Session @@ -10,6 +10,7 @@ from ocpp.v201.call import Reset, SetVariables from app.database import get_db from app.ocpp_proto import chargepoint_manager +from app.schemas.auth_token import AccessToken from app.schemas.chargepoint import ( ChargePoint, ChargePointCreate, @@ -30,7 +31,7 @@ from app.schemas.chargepoint_variable import ( from app.models.chargepoint import ChargePoint as DbChargePoint from app.models.user import User as DbUser from app.models.chargepoint_variable import ChargepointVariable as DbChargepointVariable -from app.security import get_api_key +from app.security.jwt_bearer import JWTBearer router = APIRouter( prefix="/chargepoints", @@ -41,16 +42,16 @@ router = APIRouter( async def get_chargepoints( skip: int = 0, limit: int = 20, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + token: AccessToken = Depends(JWTBearer()), ): - return db.query(DbChargePoint).offset(skip).limit(limit).all() + return db.query(DbChargePoint).order_by(DbChargePoint.identity).offset(skip).limit(limit).all() @router.get(path="/{chargepoint_id}", response_model=ChargePoint) async def get_chargepoint( chargepoint_id: UUID, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + token: AccessToken = Depends(JWTBearer()), ): chargepoint = db.get(DbChargePoint, chargepoint_id) if chargepoint is None: @@ -60,8 +61,8 @@ async def get_chargepoint( @router.get(path="/{chargepoint_id}/password", response_model=ChargePointPassword) async def get_chargepoint_password( chargepoint_id: UUID, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])), ): chargepoint = db.get(DbChargePoint, chargepoint_id) if chargepoint is None: @@ -71,8 +72,8 @@ async def get_chargepoint_password( @router.delete(path="/{chargepoint_id}/password", response_model=ChargePointPassword) async def reset_chargepoint_password( chargepoint_id: UUID, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])), ): chargepoint = db.get(DbChargePoint, chargepoint_id) if chargepoint is None: @@ -84,8 +85,8 @@ async def reset_chargepoint_password( @router.post(path="", status_code=201, response_model=ChargePoint) async def create_chargepoint( chargepoint: ChargePointCreate, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])), ): chargepoint_db = DbChargePoint( identity=chargepoint.identity, @@ -102,8 +103,8 @@ async def create_chargepoint( async def update_chargepoint( chargepoint_id: UUID, chargepoint_update: ChargePointUpdate, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])), ): chargepoint = db.get(DbChargePoint, chargepoint_id) if chargepoint is None: @@ -116,8 +117,8 @@ async def update_chargepoint( @router.delete(path="/{chargepoint_id}", response_model=None) async def delete_chargepoint( chargepoint_id: UUID, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])), ): chargepoint = db.get(DbChargePoint, chargepoint_id) if chargepoint is None: @@ -129,7 +130,7 @@ async def delete_chargepoint( @router.get(path="/{chargepoint_id}/status", response_model=ChargePointConnectionInfo) async def get_chargepoint_status( chargepoint_id: UUID, - api_key: str = Security(get_api_key) + token: AccessToken = Depends(JWTBearer()), ): return ChargePointConnectionInfo( connected=chargepoint_manager.is_connected(chargepoint_id) @@ -139,7 +140,7 @@ async def get_chargepoint_status( async def reset_chargepoint( chargepoint_id: UUID, reset_request: ChargePointResetRequest, - api_key: str = Security(get_api_key) + token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])), ): if chargepoint_manager.is_connected(chargepoint_id) == False: raise HTTPException(status_code=503, detail="Chargepoint not connected.") @@ -156,8 +157,8 @@ async def reset_chargepoint( async def create_id_token_learn_request( chargepoint_id: UUID, learn_request: IdTokenLearnRequest, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])), ): chargepoint = db.get(DbChargePoint, chargepoint_id) if chargepoint is None: @@ -188,8 +189,8 @@ async def create_id_token_learn_request( @router.get(path="/{chargepoint_id}/token-learning", response_model=IdTokenLearnResponse) async def get_id_token_learn_request( chargepoint_id: UUID, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])), ): chargepoint = db.get(DbChargePoint, chargepoint_id) if chargepoint is None: @@ -206,8 +207,8 @@ async def get_id_token_learn_request( @router.delete(path="/{chargepoint_id}/token-learning", response_model=[]) async def get_id_token_learn_request( chargepoint_id: UUID, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])), ): chargepoint = db.get(DbChargePoint, chargepoint_id) if chargepoint is None: @@ -225,8 +226,8 @@ async def get_id_token_learn_request( @router.get(path="/{chargepoint_id}/variables", response_model=list[ChargepointVariable]) async def get_chargepoint_variables( chargepoint_id: UUID, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])), ): chargepoint = db.get(DbChargePoint, chargepoint_id) if chargepoint is None: @@ -239,8 +240,8 @@ async def update_chargepoint_variable( chargepoint_id: UUID, variable_id: UUID, variable_update: ChargepointVariableUpdate, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])), ): chargepoint = db.get(DbChargePoint, chargepoint_id) if chargepoint is None: diff --git a/app/routers/id_token_v1.py b/app/routers/id_token_v1.py index 017aaaa..2b89c73 100644 --- a/app/routers/id_token_v1.py +++ b/app/routers/id_token_v1.py @@ -1,14 +1,16 @@ from uuid import UUID -from fastapi import APIRouter, HTTPException, Security -from fastapi.exceptions import RequestValidationError +from fastapi import APIRouter, HTTPException from fastapi.params import Depends +from sqlalchemy import select from sqlalchemy.orm import Session from app.database import get_db +from app.schemas.auth_token import AccessToken from app.schemas.id_token import IdToken, IdTokenCreate, IdTokenUpdate from app.models.id_token import IdToken as DbIdToken from app.models.user import User as DbUser -from app.security import get_api_key +from app.schemas.user import Role +from app.security.jwt_bearer import JWTBearer router = APIRouter( prefix="/id-tokens", @@ -16,32 +18,43 @@ router = APIRouter( ) @router.get(path="", response_model=list[IdToken]) -async def get_it_tokens( +async def get_id_tokens( skip: int = 0, limit: int = 20, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + token: AccessToken = Depends(JWTBearer()), ): - return db.query(DbIdToken).offset(skip).limit(limit).all() + stmt = select(Session) + if token.role != Role.ADMINISTRATOR: + stmt = stmt.where(DbIdToken.owner_id == token.subject) + stmt = stmt.order_by(DbIdToken.id).offset(skip).limit(limit) + result = db.execute(stmt) + return result.scalars().all() @router.get(path="/{id_token_id}", response_model=IdToken) async def get_id_token( id_token_id: UUID, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + token: AccessToken = Depends(JWTBearer()), ): - id_token = db.get(DbIdToken, id_token_id) + stmt = select(DbIdToken).where(DbIdToken.id == id_token_id) + result = db.execute(stmt) + id_token = result.scalars().first() if id_token == None: raise HTTPException(status_code=404, detail="IdToken not found") + if token.role != Role.ADMINISTRATOR & id_token.owner_id != token.subject: + raise HTTPException(status_code=404, detail="IdToken not found") return id_token @router.post(path="", status_code=201, response_model=IdToken) async def create_id_token( create_id_token: IdTokenCreate, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])), ): - owner = db.get(DbUser, create_id_token.owner_id) + stmt = select(DbUser).where(DbUser.id == create_id_token.owner_id) + result = db.execute(stmt) + owner = result.scalars().first() if owner == None: raise HTTPException(status_code=422, detail=[{ "loc": ["body", "owner_id"], @@ -63,15 +76,19 @@ async def create_id_token( async def update_id_token( id_token_id: UUID, id_token_update: IdTokenUpdate, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])), ): - id_token = db.get(DbIdToken, id_token_id) + stmt = select(DbIdToken).where(DbIdToken.id == id_token_id) + result = db.execute(stmt) + id_token = result.scalars().first() if id_token is None: raise HTTPException(status_code=404, detail="IdToken not found") for key, value in id_token_update.model_dump(exclude_unset=True).items(): if key == "owner_id": - owner = db.get(DbUser, value) + stmt = select(DbUser).where(DbUser.id == id_token_update.owner_id) + result = db.execute(stmt) + owner = result.scalars().first() if owner == None: raise HTTPException(status_code=422, detail=[{ "loc": ["body", "owner_id"], @@ -85,10 +102,12 @@ async def update_id_token( @router.delete(path="/{id_token_id}", response_model=None) async def delete_id_token( id_token_id: UUID, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])), ): - id_token = db.get(DbIdToken, id_token_id) + stmt = select(DbIdToken).where(DbIdToken.id == id_token_id) + result = db.execute(stmt) + id_token = result.scalars().first() if id_token == None: raise HTTPException(status_code=404, detail="IdToken not found") db.delete(id_token) diff --git a/app/routers/me_v1.py b/app/routers/me_v1.py new file mode 100644 index 0000000..635f9ed --- /dev/null +++ b/app/routers/me_v1.py @@ -0,0 +1,111 @@ +from uuid import UUID +from fastapi import APIRouter, HTTPException +from fastapi.params import Depends +from sqlalchemy.orm import Session as DbSession + +from app.database import get_db +from app.schemas.session import Session +from app.schemas.auth_token import AccessToken +from app.schemas.user import PasswordUpdate, UserUpdate, User +from app.security.jwt_bearer import JWTBearer +from app.services import session_service, user_service +from app.util.errors import InvalidStateError, NotFoundError + +router = APIRouter(prefix="/me", tags=["Me (v1)"]) + + +@router.get(path="", response_model=User) +async def get_myself( + db: DbSession = Depends(get_db), token: AccessToken = Depends(JWTBearer()) +): + """ + Get the currently authenticated user. + """ + user = await user_service.get_user(db=db, id=UUID(token.subject)) + if not user: + raise HTTPException(status_code=404, detail="user_not_found") + else: + return user + + +@router.patch(path="", response_model=User) +async def update_myself( + user_update: UserUpdate, + db: DbSession = Depends(get_db), + token: AccessToken = Depends(JWTBearer()), +): + """ + Update the currently authenticated user. Changing the email address automatically marks it as not verified + and starts a new verification workflow. + """ + try: + return await user_service.update_user( + db, UUID(token.subject), user_update + ) + except NotFoundError: + raise HTTPException(status_code=404, detail="user_not_found") + + +@router.post(path="/password", response_model=list[None]) +async def change_password( + update: PasswordUpdate, + db: DbSession = Depends(get_db), + token: AccessToken = Depends(JWTBearer()), +): + """ + Change the password of the currently authenticated user. + """ + try: + await user_service.change_user_password( + db=db, id=UUID(token.subject), update=update + ) + return list() + except NotFoundError: + raise HTTPException(status_code=404, detail="user_not_found") + except InvalidStateError: + raise HTTPException(status_code=409, detail="incorrect_password") + + +@router.get(path="/sessions", response_model=list[Session]) +async def get_user_sessions( + db: DbSession = Depends(get_db), token: AccessToken = Depends(JWTBearer()) +): + """ + List the active sessions of the currently authenticated user. + """ + return await session_service.get_sessions_by_user( + db=db, user_id=UUID(token.subject) + ) + + +@router.delete(path="/sessions", response_model=list[None]) +async def clear_user_sessions( + db: DbSession = Depends(get_db), token: AccessToken = Depends(JWTBearer()) +): + """ + Clear all sessions of the currently authenticated user. + """ + await session_service.remove_all_sessions_for_user( + db=db, user_id=UUID(token.subject), + ) + return list() + + +@router.delete(path="/sessions/{session_id}", response_model=list[None]) +async def delete_user_session( + session_id: UUID, + db: DbSession = Depends(get_db), + token: AccessToken = Depends(JWTBearer()), +): + """ + Invalidate a specific session of the currently authenticated user. + """ + try: + await session_service.remove_session_for_user( + db=db, + id=session_id, + user_id=UUID(token.subject), + ) + except NotFoundError: + raise HTTPException(status_code=404, detail="session_not_found") + return list() \ No newline at end of file diff --git a/app/routers/meter_value_v1.py b/app/routers/meter_value_v1.py index 3558600..1b91eaf 100644 --- a/app/routers/meter_value_v1.py +++ b/app/routers/meter_value_v1.py @@ -1,11 +1,12 @@ from fastapi import APIRouter, Depends -from fastapi.params import Security +from sqlalchemy import select from sqlalchemy.orm import Session -from app.security import get_api_key +from app.schemas.auth_token import AccessToken from app.database import get_db from app.schemas.meter_value import MeterValue from app.models.meter_value import MeterValue as DbMeterValue +from app.security.jwt_bearer import JWTBearer router = APIRouter( prefix="/meter-values", @@ -16,7 +17,9 @@ router = APIRouter( async def get_meter_values( skip: int = 0, limit: int = 20, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])), ): - return db.query(DbMeterValue).offset(skip).limit(limit).all() \ No newline at end of file + stmt = select(DbMeterValue).order_by(DbMeterValue.timestamp).offset(skip).limit(limit) + result = db.execute(stmt) + return result.scalars().all() \ No newline at end of file diff --git a/app/routers/transaction_v1.py b/app/routers/transaction_v1.py index 60b09ee..4c6feb2 100644 --- a/app/routers/transaction_v1.py +++ b/app/routers/transaction_v1.py @@ -1,14 +1,18 @@ from fastapi import APIRouter, Depends, HTTPException -from fastapi.params import Security +from sqlalchemy import select from sqlalchemy.orm import Session from ocpp.v201.call import RequestStopTransaction from app.ocpp_proto import chargepoint_manager -from app.security import get_api_key +from app.schemas.auth_token import AccessToken from app.database import get_db +from app.schemas.meter_value import MeterValue from app.schemas.transaction import Transaction, RemoteTransactionStartStopResponse, TransactionStatus, RemoteTransactionStartStopStatus from app.models.transaction import Transaction as DbTransaction +from app.models.meter_value import MeterValue as DbMeterValue +from app.schemas.user import Role +from app.security.jwt_bearer import JWTBearer router = APIRouter( prefix="/transactions", @@ -19,31 +23,61 @@ router = APIRouter( async def get_transactions( skip: int = 0, limit: int = 20, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + token: AccessToken = Depends(JWTBearer()), ): - return db.query(DbTransaction).offset(skip).limit(limit).all() + stmt = select(DbTransaction) + if (token.role != Role.ADMINISTRATOR): + stmt = stmt.where(DbTransaction.user_id == token.subject) + stmt = stmt.order_by(DbTransaction.started_at).offset(skip).limit(limit) + result = db.execute(stmt) + return result.scalars().all() @router.get(path="/{transaction_id}", response_model=Transaction) async def get_transaction( transaction_id: str, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + token: AccessToken = Depends(JWTBearer()), ): - transaction = db.get(DbTransaction, transaction_id) + stmt = select(DbTransaction).where(DbTransaction.id == transaction_id) + result = db.execute(stmt) + transaction = result.scalars().first() if transaction == None: raise HTTPException(404, "Transaction not found") + if token.role != Role.ADMINISTRATOR & transaction.user_id != token.subject: + raise HTTPException(404, "Transaction not found") return transaction +@router.get(path="/{transaction_id}/meter-values", response_model=list[MeterValue]) +async def get_transaction_meter_values( + transaction_id: str, + db: Session = Depends(get_db), + token: AccessToken = Depends(JWTBearer()), +): + stmt = select(DbTransaction).where(DbTransaction.id == transaction_id) + result = db.execute(stmt) + transaction = result.scalars().first() + if transaction == None: + raise HTTPException(404, "Transaction not found") + if token.role != Role.ADMINISTRATOR & transaction.user_id != token.subject: + raise HTTPException(404, "Transaction not found") + stmt = select(DbMeterValue).where(DbMeterValue.transaction_id == transaction_id).order_by(DbMeterValue.timestamp) + result = db.execute(stmt) + return result.scalars().all() + @router.post(path="/{transaction_id}/remote-stop", response_model=RemoteTransactionStartStopResponse) async def remote_stop_transaction( transaction_id: str, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: Session = Depends(get_db), + token: AccessToken = Depends(JWTBearer()), ): - transaction = db.get(DbTransaction, transaction_id) + stmt = select(DbTransaction).where(DbTransaction.id == transaction_id) + result = db.execute(stmt) + transaction = result.scalars().first() if transaction == None: raise HTTPException(404, "Transaction not found") + if token.role != Role.ADMINISTRATOR & transaction.user_id != token.subject: + raise HTTPException(404, "Transaction not found") if transaction.status != TransactionStatus.ONGOING: raise HTTPException(status_code=422, detail=[{ "loc": ["path", "transaction_id"], @@ -51,7 +85,7 @@ async def remote_stop_transaction( "type": "invalid_transaction_state" }]) if chargepoint_manager.is_connected(transaction.chargepoint_id) == False: - raise HTTPException(status_code=503, detail="Chargepoint not connected.") + raise HTTPException(status_code=503, detail="chargepoint_offline") try: result = await chargepoint_manager.call( transaction.chargepoint_id, @@ -63,4 +97,4 @@ async def remote_stop_transaction( raise HTTPException(status_code=500, detail=result.status) return RemoteTransactionStartStopResponse(status=result.status) except TimeoutError: - raise HTTPException(status_code=503, detail="Chargepoint didn't respond in time.") + raise HTTPException(status_code=503, detail="chargepoint_operation_timeout") diff --git a/app/routers/user_v1.py b/app/routers/user_v1.py index 5655dad..7239ece 100644 --- a/app/routers/user_v1.py +++ b/app/routers/user_v1.py @@ -1,12 +1,15 @@ from uuid import UUID -from fastapi import APIRouter, HTTPException, Security +from fastapi import APIRouter, HTTPException from fastapi.params import Depends -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session as DbSession from app.database import get_db +from app.schemas.session import Session +from app.schemas.auth_token import AccessToken from app.schemas.user import User, UserCreate, UserUpdate -from app.models.user import User as DbUser -from app.security import get_api_key +from app.security.jwt_bearer import JWTBearer +from app.services import session_service, user_service +from app.util.errors import NotFoundError router = APIRouter( prefix="/users", @@ -15,63 +18,99 @@ router = APIRouter( @router.get(path="", response_model=list[User]) async def get_users( + email: str = None, skip: int = 0, limit: int = 20, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: DbSession = Depends(get_db), + token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])), ): - return db.query(DbUser).offset(skip).limit(limit).all() - -@router.get(path="/{user_id}", response_model=User) -async def get_user( - user_id: UUID, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) -): - user = db.get(DbUser, user_id) - if user == None: - raise HTTPException(status_code=404, detail="User not found") - return user + return await user_service.get_users(db, skip, limit, email) @router.post(path="", status_code=201, response_model=User) async def create_user( create_user: UserCreate, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: DbSession = Depends(get_db), + token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])), ): - db_user = DbUser( - friendly_name=create_user.friendly_name, - is_active=create_user.is_active + user = await user_service.create_user( + db=db, user=create_user ) - db.add(db_user) - db.commit() - db.refresh(db_user) - return db_user + return user @router.patch(path="/{user_id}", response_model=User) async def update_user( user_id: UUID, user_update: UserUpdate, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: DbSession = Depends(get_db), + token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])), ): - user = db.get(DbUser, user_id) - if user is None: - raise HTTPException(status_code=404, detail="User not found") - for key, value in user_update.model_dump(exclude_unset=True).items(): - setattr(user, key, value) - db.commit() - return user + try: + return await user_service.update_user( + db, user_id, user_update + ) + except NotFoundError: + raise HTTPException(status_code=404, detail="user_not_found") @router.delete(path="/{user_id}", response_model=None) async def delete_user( user_id: UUID, - api_key: str = Security(get_api_key), - db: Session = Depends(get_db) + db: DbSession = Depends(get_db), + token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])), ): - user = db.get(DbUser, user_id) - if user == None: - raise HTTPException(status_code=404, detail="User not found") - db.delete(user) - db.commit() - return [] + try: + await user_service.remove_user(db, user_id) + return list() + except NotFoundError: + raise HTTPException(status_code=404, detail="user_not_found") + +@router.get( + path="/{user_id}/sessions", response_model=list[Session] +) +async def get_user_sessions( + user_id: UUID, + db: DbSession = Depends(get_db), + token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])), +): + """ + Query sessions of the specified user. Requires the "administrator" role. + """ + return await session_service.get_sessions_by_user(db=db, user_id=user_id) + + +@router.delete( + path="/{user_id}/sessions", response_model=list[None] +) +async def remove_all_user_session( + user_id: UUID, + db: DbSession = Depends(get_db), + token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])), +): + """ + Delete all sessions of the specified user. Requires the "administrator" role. + """ + await session_service.remove_all_sessions_for_user( + db=db, user_id=user_id + ) + return list() + + +@router.delete( + path="/{user_id}/sessions/{session_id}", + response_model=list[None], +) +async def remove_user_session( + user_id: UUID, + session_id: UUID, + db: DbSession = Depends(get_db), + token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])), +): + """ + Delete the specified session of the specified user. Requires the "administrator" role. + """ + try: + await session_service.remove_session_for_user( + db=db, id=session_id, user_id=user_id + ) + except NotFoundError: + raise HTTPException(status_code=404, detail="session_not_found") + return list() diff --git a/app/schemas/auth_token.py b/app/schemas/auth_token.py new file mode 100644 index 0000000..bb9ee60 --- /dev/null +++ b/app/schemas/auth_token.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from datetime import datetime +from pydantic import BaseModel + +from app.schemas.user import Role + + +@dataclass +class AccessToken: + subject: str + role: Role + session: str + +class TokenRefreshRequest(BaseModel): + refresh_token: str + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + not_after: datetime diff --git a/app/schemas/session.py b/app/schemas/session.py new file mode 100644 index 0000000..b6a320b --- /dev/null +++ b/app/schemas/session.py @@ -0,0 +1,11 @@ +from datetime import datetime +from uuid import UUID +from pydantic import BaseModel + + +class Session(BaseModel): + id: UUID + name: str + last_used: datetime + + model_config = {"from_attributes": True} \ No newline at end of file diff --git a/app/schemas/user.py b/app/schemas/user.py index d9c3349..8737aa4 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -1,20 +1,39 @@ +import enum from typing import Optional from uuid import UUID -from pydantic import BaseModel +from pydantic import BaseModel, EmailStr, Field + +class Role(enum.StrEnum): + MEMBER = "member" + ADMINISTRATOR = "administrator" class UserBase(BaseModel): + email: EmailStr = Field(max_length=60) friendly_name: str is_active: bool class UserUpdate(BaseModel): + email: Optional[str] = None friendly_name: Optional[str] = None + +class AdministrativeUserUpdate(UserUpdate): is_active: Optional[bool] = None class UserCreate(UserBase): + password: str = Field(max_length=100) pass class User(UserBase): id: UUID + role: Role class Config: - from_attributes = True \ No newline at end of file + from_attributes = True + +class PasswordUpdate(BaseModel): + old_password: str = Field(max_length=100) + new_password: str = Field(max_length=100) + +class LoginRequest(BaseModel): + email: EmailStr = Field(max_length=60) + password: str = Field(max_length=100) \ No newline at end of file diff --git a/app/security.py b/app/security.py deleted file mode 100644 index 8d8e1ff..0000000 --- a/app/security.py +++ /dev/null @@ -1,34 +0,0 @@ -import os -from fastapi import HTTPException, Security, status -from fastapi.security import APIKeyHeader, HTTPBasic - -basic_auth = HTTPBasic() - -api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) - -def get_api_key( - api_key_header: str = Security(api_key_header), -) -> str: - """Retrieve and validate an API key from the HTTP header. - - Args: - api_key_header: The API key passed in the HTTP header. - - Returns: - The validated API key. - - Raises: - HTTPException: If the API key is invalid or missing. - """ - api_key = os.getenv("CS_API_KEY", "default") - if api_key == "default": - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="API key not set. Authentication not possible.", - ) - if api_key_header == api_key: - return api_key_header - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid or missing API Key", - ) \ No newline at end of file diff --git a/app/security/__init__.py b/app/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/security/jwt_bearer.py b/app/security/jwt_bearer.py new file mode 100644 index 0000000..c82a297 --- /dev/null +++ b/app/security/jwt_bearer.py @@ -0,0 +1,47 @@ +from typing import Optional +from fastapi import Request, HTTPException +from fastapi.params import Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.services import token_service +from app.util.errors import InsufficientPermissionsError, InvalidTokenAudienceError +from app.schemas.auth_token import AccessToken + + +class JWTBearer(HTTPBearer): + __required_roles: list[str] | None + + def __init__( + self, required_roles: Optional[list[str]] = None, auto_error: bool = True + ): + self.__required_roles = required_roles + super(JWTBearer, self).__init__(auto_error=auto_error) + + async def __call__( + self, request: Request, db: AsyncSession = Depends(get_db) + ) -> AccessToken: + credentials: HTTPAuthorizationCredentials | None = await super( + JWTBearer, self + ).__call__(request) + if credentials: + if not credentials.scheme == "Bearer": + raise HTTPException( + status_code=403, detail="authentication_scheme_invalid" + ) + try: + token = await token_service.verify_access_token( + credentials.credentials, self.__required_roles + ) + if not token: + raise HTTPException( + status_code=403, detail="token_invalid_or_expired" + ) + return token + except InsufficientPermissionsError: + raise HTTPException(status_code=403, detail="insufficient_permissions") + except InvalidTokenAudienceError: + raise HTTPException(status_code=403, detail="invalid_token_audience") + else: + raise HTTPException(status_code=403, detail="authorization_code_invalid") \ No newline at end of file diff --git a/app/util/websocket_auth_backend.py b/app/security/websocket_auth_backend.py similarity index 100% rename from app/util/websocket_auth_backend.py rename to app/security/websocket_auth_backend.py diff --git a/app/services/session_service.py b/app/services/session_service.py new file mode 100644 index 0000000..f46ccd3 --- /dev/null +++ b/app/services/session_service.py @@ -0,0 +1,83 @@ +from datetime import datetime, UTC +import secrets +from uuid import UUID +from sqlalchemy import select, delete +from sqlalchemy.orm import Session as SqlaSession + +from app.models.session import Session +from app.models.user import User +from app.util.errors import NotFoundError + + +async def get_sessions( + db: SqlaSession, skip: int = 0, limit: int = 20 +) -> tuple[Session]: + stmt = select(Session).offset(skip).limit(limit) + result = db.execute(stmt) + return result.scalars().all() + + +async def get_sessions_by_user(db: SqlaSession, user_id: UUID) -> tuple[Session]: + stmt = select(Session).where(Session.user_id == user_id) + result = db.execute(stmt) + return result.scalars().all() + + +async def create_session(db: SqlaSession, user: User, useragent: str) -> Session: + session = Session( + name=useragent, + refresh_token=secrets.token_urlsafe(64), + last_used=datetime.now(UTC), + user_id=user.id, + ) + db.add(session) + db.commit() + db.refresh(session) + return session + + +async def remove_session(db: SqlaSession, id: UUID): + session = db.get(Session, id) + if not session: + raise NotFoundError + db.delete(session) + db.commit() + + +async def remove_session_for_user( + db: SqlaSession, id: UUID, user_id: UUID +): + stmt = select(Session).where(Session.id == id and Session.user_id == user_id) + result = db.execute(stmt) + session = result.scalars().first() + if not session: + raise NotFoundError + db.delete(session) + db.commit() + + +async def remove_all_sessions_for_user(db: SqlaSession, user_id: UUID): + stmt = delete(Session).where(Session.user_id == user_id) + db.execute(stmt) + db.commit() + + +async def remove_all_sessions(db: SqlaSession): + stmt = delete(Session) + db.execute(stmt) + db.commit() + + +async def validate_and_rotate_refresh_token( + db: SqlaSession, refresh_token: str +) -> Session: + stmt = select(Session).where(Session.refresh_token == refresh_token) + result = db.execute(stmt) + session = result.scalars().first() + if not session: + raise NotFoundError + session.refresh_token = secrets.token_urlsafe(64) + session.last_used = datetime.now(UTC) + + db.commit() + return session \ No newline at end of file diff --git a/app/services/token_service.py b/app/services/token_service.py new file mode 100644 index 0000000..360adb0 --- /dev/null +++ b/app/services/token_service.py @@ -0,0 +1,69 @@ +import json +import os +import secrets +from typing import Optional +from uuid import UUID +from jwcrypto import jwt, jwk +from datetime import datetime, timedelta, UTC + +from app.models.user import User +from app.schemas.auth_token import AccessToken +from app.schemas.user import Role +from app.util.errors import InsufficientPermissionsError, InvalidTokenAudienceError + +__signing_key = jwk.JWK.from_password(os.getenv("CS_TOKEN_SECRET", secrets.token_urlsafe(64))) + +async def __create_token(claims: dict) -> str: + default_claims = { + "iss": os.getenv("CS_TOKEN_ISSUER", "https://localhost:8000"), + "iat": datetime.now(UTC).timestamp(), + } + header = {"alg": "HS256", "typ": "JWT", "kid": "default"} + token = jwt.JWT(header=header, claims=(claims | default_claims)) + token.make_signed_token(__signing_key) + return token.serialize() + + +async def __verify_token(token: str, audience: str) -> dict | None: + try: + token = jwt.JWT(jwt=token, key=__signing_key) + claims = json.loads(token.claims) + if claims.get("aud") == audience: + return claims + else: + raise InvalidTokenAudienceError + except Exception: + return None + + +async def create_access_token( + user: User, session_id: UUID +) -> tuple[str, datetime]: + token_lifetime = float(os.getenv("CS_ACCESS_TOKEN_LIFETIME_SECONDS", "300")) + exp_time = datetime.now(UTC) + timedelta(seconds=token_lifetime) + claims = { + "aud": "access", + "sub": str(user.id), + "exp": exp_time.timestamp(), + "session": str(session_id), + "role": str(user.role), + } + return await __create_token(claims=claims), exp_time + +async def verify_access_token( + token: str, required_roles: Optional[list[str]] = None +) -> AccessToken | None: + try: + claims = await __verify_token(token=token, audience="access") + if not claims: + return None + if not required_roles or claims.get("role") in required_roles: + return AccessToken( + subject=claims.get("sub"), + role=Role(claims.get("role")), + session=claims.get("session"), + ) + else: + raise InsufficientPermissionsError + except InvalidTokenAudienceError: + pass diff --git a/app/services/user_service.py b/app/services/user_service.py new file mode 100644 index 0000000..b33eaa4 --- /dev/null +++ b/app/services/user_service.py @@ -0,0 +1,111 @@ +from uuid import UUID +import uuid + +from sqlalchemy import select +from sqlalchemy.orm import Session +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError + +from app.models.user import User +from app.schemas.user import ( + UserCreate, + UserUpdate, + AdministrativeUserUpdate, + PasswordUpdate, + LoginRequest, +) +from app.util.errors import InvalidStateError, NotFoundError + +hasher = PasswordHasher(memory_cost=102400) + +async def get_user(db: Session, id: UUID): + return db.get(User, id) + + +async def get_user_by_email(db: Session, email: str): + stmt = select(User).where(User.email == email) + result = db.execute(stmt) + return result.scalars().first() + + +async def get_users( + db: Session, skip: int = 0, limit: int = 20, email: str = None +): + stmt = select(User) + if email is not None: + stmt = stmt.where(User.email.like(email)) + stmt = stmt.offset(skip).limit(limit) + result = db.execute(stmt) + return result.scalars().all() + + +async def create_user(db: Session, user: UserCreate) -> User: + if await get_user_by_email(db=db, email=user.email): + raise InvalidStateError + hashed_password = hasher.hash(user.password) + db_user = User( + friendly_name=user.friendly_name, email=user.email, password=hashed_password + ) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + + +async def update_user( + db: Session, + id: UUID, + update: UserUpdate | AdministrativeUserUpdate, +) -> User: + db_user = await get_user(db, id) + if db_user is None: + raise NotFoundError + + changed_attributes = dict() + for key, value in update.model_dump(exclude_unset=True).items(): + changed_attributes[key] = {"old": getattr(db_user, key), "new": value} + setattr(db_user, key, value) + db.commit() + return db_user + + +async def change_user_password(db: Session, id: UUID, update: PasswordUpdate): + db_user = await get_user(db, id) + if db_user is None: + raise NotFoundError + try: + hasher.verify(hash=db_user.password, password=update.old_password) + db_user.password = hasher.hash(update.new_password) + db.commit() + except VerifyMismatchError: + raise InvalidStateError + + +async def remove_user(db: Session, id: UUID): + db_user = await get_user(db, id) + if db_user is None: + raise NotFoundError + db.delete(db_user) + db.commit() + + +async def validate_login(db: Session, login: LoginRequest) -> User | None: + stmt = select(User).where(User.email == login.email) + result = db.execute(stmt) + db_user = result.scalars().first() + if db_user is None: + db.commit() + return None + try: + hasher.verify(hash=db_user.password, password=login.password) + if hasher.check_needs_rehash(db_user.password): + db_user.password = hasher.hash(login.password) + if db_user.is_active: + db.commit() + return db_user + else: + db.commit() + return None + except VerifyMismatchError: + db.commit() + return None diff --git a/app/util/errors.py b/app/util/errors.py new file mode 100644 index 0000000..062bca0 --- /dev/null +++ b/app/util/errors.py @@ -0,0 +1,14 @@ +class NotFoundError(Exception): + pass + + +class InvalidStateError(Exception): + pass + + +class InsufficientPermissionsError(Exception): + pass + + +class InvalidTokenAudienceError(Exception): + pass \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 268a0fe..084e2b7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -54,6 +54,65 @@ doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] +[[package]] +name = "argon2-cffi" +version = "23.1.0" +description = "Argon2 for Python" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "argon2_cffi-23.1.0-py3-none-any.whl", hash = "sha256:c670642b78ba29641818ab2e68bd4e6a78ba53b7eff7b4c3815ae16abf91c7ea"}, + {file = "argon2_cffi-23.1.0.tar.gz", hash = "sha256:879c3e79a2729ce768ebb7d36d4609e3a78a4ca2ec3a9f12286ca057e3d0db08"}, +] + +[package.dependencies] +argon2-cffi-bindings = "*" + +[package.extras] +dev = ["argon2-cffi[tests,typing]", "tox (>4)"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-copybutton", "sphinx-notfound-page"] +tests = ["hypothesis", "pytest"] +typing = ["mypy"] + +[[package]] +name = "argon2-cffi-bindings" +version = "21.2.0" +description = "Low-level CFFI bindings for Argon2" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "argon2-cffi-bindings-21.2.0.tar.gz", hash = "sha256:bb89ceffa6c791807d1305ceb77dbfacc5aa499891d2c55661c6459651fc39e3"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ccb949252cb2ab3a08c02024acb77cfb179492d5701c7cbdbfd776124d4d2367"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9524464572e12979364b7d600abf96181d3541da11e23ddf565a32e70bd4dc0d"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b746dba803a79238e925d9046a63aa26bf86ab2a2fe74ce6b009a1c3f5c8f2ae"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58ed19212051f49a523abb1dbe954337dc82d947fb6e5a0da60f7c8471a8476c"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:bd46088725ef7f58b5a1ef7ca06647ebaf0eb4baff7d1d0d177c6cc8744abd86"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_i686.whl", hash = "sha256:8cd69c07dd875537a824deec19f978e0f2078fdda07fd5c42ac29668dda5f40f"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f1152ac548bd5b8bcecfb0b0371f082037e47128653df2e8ba6e914d384f3c3e"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-win32.whl", hash = "sha256:603ca0aba86b1349b147cab91ae970c63118a0f30444d4bc80355937c950c082"}, + {file = "argon2_cffi_bindings-21.2.0-cp36-abi3-win_amd64.whl", hash = "sha256:b2ef1c30440dbbcba7a5dc3e319408b59676e2e039e2ae11a8775ecf482b192f"}, + {file = "argon2_cffi_bindings-21.2.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e415e3f62c8d124ee16018e491a009937f8cf7ebf5eb430ffc5de21b900dad93"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3e385d1c39c520c08b53d63300c3ecc28622f076f4c2b0e6d7e796e9f6502194"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c3e3cc67fdb7d82c4718f19b4e7a87123caf8a93fde7e23cf66ac0337d3cb3f"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a22ad9800121b71099d0fb0a65323810a15f2e292f2ba450810a7316e128ee5"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f9f8b450ed0547e3d473fdc8612083fd08dd2120d6ac8f73828df9b7d45bb351"}, + {file = "argon2_cffi_bindings-21.2.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:93f9bf70084f97245ba10ee36575f0c3f1e7d7724d67d8e5b08e61787c320ed7"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:3b9ef65804859d335dc6b31582cad2c5166f0c3e7975f324d9ffaa34ee7e6583"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4966ef5848d820776f5f562a7d45fdd70c2f330c961d0d745b784034bd9f48d"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20ef543a89dee4db46a1a6e206cd015360e5a75822f76df533845c3cbaf72670"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed2937d286e2ad0cc79a7087d3c272832865f779430e0cc2b4f3718d3159b0cb"}, + {file = "argon2_cffi_bindings-21.2.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:5e00316dabdaea0b2dd82d141cc66889ced0cdcbfa599e8b471cf22c620c329a"}, +] + +[package.dependencies] +cffi = ">=1.0.1" + +[package.extras] +dev = ["cogapp", "pre-commit", "pytest", "wheel"] +tests = ["pytest"] + [[package]] name = "attrs" version = "25.2.0" @@ -86,6 +145,86 @@ files = [ {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, ] +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "click" version = "8.1.8" @@ -114,6 +253,64 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "cryptography" +version = "44.0.2" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.7" +groups = ["main"] +files = [ + {file = "cryptography-44.0.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:efcfe97d1b3c79e486554efddeb8f6f53a4cdd4cf6086642784fa31fc384e1d7"}, + {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1"}, + {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb"}, + {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843"}, + {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5"}, + {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c"}, + {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a"}, + {file = "cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308"}, + {file = "cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688"}, + {file = "cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7"}, + {file = "cryptography-44.0.2-cp37-abi3-win32.whl", hash = "sha256:51e4de3af4ec3899d6d178a8c005226491c27c4ba84101bfb59c901e10ca9f79"}, + {file = "cryptography-44.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:c505d61b6176aaf982c5717ce04e87da5abc9a36a5b39ac03905c4aafe8de7aa"}, + {file = "cryptography-44.0.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e0ddd63e6bf1161800592c71ac794d3fb8001f2caebe0966e77c5234fa9efc3"}, + {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639"}, + {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd"}, + {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181"}, + {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea"}, + {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699"}, + {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9"}, + {file = "cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23"}, + {file = "cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922"}, + {file = "cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4"}, + {file = "cryptography-44.0.2-cp39-abi3-win32.whl", hash = "sha256:3dc62975e31617badc19a906481deacdeb80b4bb454394b4098e3f2525a488c5"}, + {file = "cryptography-44.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:5f6f90b72d8ccadb9c6e311c775c8305381db88374c65fa1a68250aa8a9cb3a6"}, + {file = "cryptography-44.0.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:af4ff3e388f2fa7bff9f7f2b31b87d5651c45731d3e8cfa0944be43dff5cfbdb"}, + {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0529b1d5a0105dd3731fa65680b45ce49da4d8115ea76e9da77a875396727b41"}, + {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7ca25849404be2f8e4b3c59483d9d3c51298a22c1c61a0e84415104dacaf5562"}, + {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:268e4e9b177c76d569e8a145a6939eca9a5fec658c932348598818acf31ae9a5"}, + {file = "cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9eb9d22b0a5d8fd9925a7764a054dca914000607dff201a24c791ff5c799e1fa"}, + {file = "cryptography-44.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2bf7bf75f7df9715f810d1b038870309342bff3069c5bd8c6b96128cb158668d"}, + {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:909c97ab43a9c0c0b0ada7a1281430e4e5ec0458e6d9244c0e821bbf152f061d"}, + {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:96e7a5e9d6e71f9f4fca8eebfd603f8e86c5225bb18eb621b2c1e50b290a9471"}, + {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d1b3031093a366ac767b3feb8bcddb596671b3aaff82d4050f984da0c248b615"}, + {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:04abd71114848aa25edb28e225ab5f268096f44cf0127f3d36975bdf1bdf3390"}, + {file = "cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0) ; python_version >= \"3.8\""] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_version >= \"3.8\""] +pep8test = ["check-sdist ; python_version >= \"3.8\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +sdist = ["build (>=1.0.0)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi (>=2024)", "cryptography-vectors (==44.0.2)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "dnspython" version = "2.7.0" @@ -493,6 +690,22 @@ files = [ [package.dependencies] referencing = ">=0.31.0" +[[package]] +name = "jwcrypto" +version = "1.5.6" +description = "Implementation of JOSE Web standards" +optional = false +python-versions = ">= 3.8" +groups = ["main"] +files = [ + {file = "jwcrypto-1.5.6-py3-none-any.whl", hash = "sha256:150d2b0ebbdb8f40b77f543fb44ffd2baeff48788be71f67f03566692fd55789"}, + {file = "jwcrypto-1.5.6.tar.gz", hash = "sha256:771a87762a0c081ae6166958a954f80848820b2ab066937dc8b8379d65b1b039"}, +] + +[package.dependencies] +cryptography = ">=3.4" +typing-extensions = ">=4.5.0" + [[package]] name = "mako" version = "1.3.9" @@ -726,6 +939,18 @@ files = [ {file = "psycopg2_binary-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5"}, ] +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + [[package]] name = "pydantic" version = "2.10.6" @@ -1544,4 +1769,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "bb1221c903a3a45a36d44cd58bdf4d5794f7f8b653d3efb80ad2af8baffb4022" +content-hash = "ae84368de3a8c75f6b38f405b9a356311c6282a9c23c2c2c1fa471d29d8d1ee2" diff --git a/pyproject.toml b/pyproject.toml index b65ed9d..c56e0c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,8 @@ sqlalchemy = "^2.0.28" alembic = "^1.15.1" gunicorn = "^22.0.0" psycopg2-binary = "^2.9.10" +argon2-cffi = "^23.1.0" +jwcrypto = "^1.5.6" [build-system] requires = ["poetry-core"]