Compare commits

...
Sign in to create a new pull request.

30 commits

Author SHA1 Message Date
affe80fced Update mcr.microsoft.com/devcontainers/python Docker tag to v3.13
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
ci/woodpecker/cron/docker Pipeline was successful
2025-04-28 18:36:59 +00:00
7c29e2e7ef Update python Docker tag to v3.13
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
2025-04-28 18:24:22 +00:00
bff04a1a8d
Update dependencies
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
2025-04-28 17:56:37 +00:00
e6406ffce2 Update dependency fastapi to v0.115.12
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
2025-04-28 11:00:37 +02:00
eb9f39406e Update dependency alembic to v1.15.2
Some checks failed
ci/woodpecker/push/docker Pipeline failed
2025-04-28 11:00:11 +02:00
8e45de9f44 Update dependency sqlalchemy to v2.0.40
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
2025-04-28 10:36:07 +02:00
528f411c57 Update dependency @eslint/compat to v1.2.8
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
2025-04-27 20:21:53 +00:00
043a3d49a1 Add renovate.json
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
2025-04-27 14:21:35 +00:00
f0eff338ff
Add possibility for signed firmware updates
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
ci/woodpecker/cron/docker Pipeline was successful
2025-03-23 18:54:48 +00:00
b59aeeb5e5
Add firmware update logic 2025-03-23 18:54:36 +00:00
486977f828
Fix id token get endpoint
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
ci/woodpecker/cron/docker Pipeline was successful
2025-03-14 00:19:22 +00:00
65183e26c0
Fix logout endpoint
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
2025-03-14 00:11:39 +00:00
46b5040099
Fix user limiting filter condition
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
2025-03-14 00:03:12 +00:00
e5c5e94989
Load dotenv in cli.py before model import
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
2025-03-13 23:36:57 +00:00
44e6f85da1
Fix migration for PostgreSQL 2025-03-13 23:36:21 +00:00
50972c209e
Check if database URL is set in env 2025-03-13 23:09:36 +00:00
5b62099b3d
Add skeleton frontend 2025-03-13 22:47:06 +00:00
60542935c8
Update dev container configuration for monorepo 2025-03-13 21:47:18 +00:00
938582155d
Prepare monorepo 2025-03-13 22:11:20 +01:00
a1ddb43ed0
Handle abnormal closure of websocket 2025-03-13 16:55:56 +00:00
49f84755f1
Configure alembic to use dotenv 2025-03-13 16:47:29 +00:00
14bcf93be3
Allow administrative user update 2025-03-13 16:38:21 +00:00
ac8303378a
Implement user authentication and permissions 2025-03-13 16:34:41 +00:00
5e9d90ed0b
Customize OpenAPI schema
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
2025-03-12 23:48:13 +00:00
00dff24767
Update dependencies
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
2025-03-12 23:42:02 +00:00
be039e7376
Add dev container configuration
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
2025-03-12 23:06:31 +00:00
fa8d6b25b1
Fix FROM / AS casing
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
ci/woodpecker/cron/docker Pipeline was successful
2025-02-02 21:15:52 +01:00
6da948ff33
Update runtime to Python 3.12
All checks were successful
ci/woodpecker/push/docker Pipeline was successful
2025-02-02 21:07:50 +01:00
b132591730
Fix poetry install 2025-02-02 21:07:08 +01:00
f92b72a335
Fix secret usage for plugin-docker-buildx
Some checks failed
ci/woodpecker/push/docker Pipeline was successful
ci/woodpecker/cron/docker Pipeline failed
2024-11-26 19:53:35 +01:00
97 changed files with 7451 additions and 1545 deletions

View file

@ -0,0 +1,24 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
{
"name": "Python 3.12",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/simple-ocpp-cs",
"shutdownAction": "stopCompose",
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
8000
],
// Configure tool-specific properties.
"customizations": {
"vscode": {
"extensions": [
"eamodio.gitlens"
]
}
}
}

View file

@ -0,0 +1,19 @@
services:
app:
image: mcr.microsoft.com/devcontainers/python:3.13-bookworm
volumes:
- ../..:/simple-ocpp-cs:cached
command: sleep infinity
db:
image: postgres:17
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
POSTGRES_USER: postgres
POSTGRES_DB: postgres
POSTGRES_PASSWORD: postgres
volumes:
postgres-data:

View file

@ -0,0 +1,28 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
{
"name": "Node.js",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/simple-ocpp-cs",
"shutdownAction": "stopCompose",
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
5173
],
// Configure tool-specific properties.
"customizations": {
"vscode": {
"extensions": [
"svelte.svelte-vscode",
"Lokalise.i18n-ally",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"eamodio.gitlens"
]
}
}
}

View file

@ -0,0 +1,6 @@
services:
app:
image: mcr.microsoft.com/devcontainers/javascript-node:1-22-bookworm
volumes:
- ../..:/simple-ocpp-cs:cached
command: sleep infinity

View file

@ -5,10 +5,13 @@ when:
steps:
docker:
image: woodpeckerci/plugin-docker-buildx
secrets: [docker_username, docker_password]
settings:
pull_image: true
registry: git.bluemedia.dev
repo: git.bluemedia.dev/bluemedia/simple-ocpp-cs
username:
from_secret: docker_username
password:
from_secret: docker_password
platforms: linux/amd64
tag: latest

View file

@ -1,5 +1,5 @@
# `python-base` sets up all our shared environment variables
FROM python:3.11-slim as python-base
FROM python:3.13-slim AS python-base
# python
ENV PYTHONUNBUFFERED=1 \
@ -32,7 +32,7 @@ ENV PATH="$POETRY_HOME/bin:$VENV_PATH/bin:$PATH"
# `builder-base` stage is used to build deps + create our virtual environment
FROM python-base as builder-base
FROM python-base AS builder-base
RUN apt-get update \
&& apt-get install --no-install-recommends -y \
# deps for installing poetry
@ -45,18 +45,18 @@ RUN curl -sSL https://install.python-poetry.org | python3 -
# copy project requirement files here to ensure they will be cached.
WORKDIR $PYSETUP_PATH
COPY poetry.lock pyproject.toml ./
COPY backend/poetry.lock backend/pyproject.toml ./
# install runtime deps - uses $POETRY_VIRTUALENVS_IN_PROJECT internally
RUN poetry install --no-dev
RUN poetry install
# `production` image used for runtime
FROM python-base as production
FROM python-base AS production
ENV FASTAPI_ENV=production
COPY --from=builder-base $PYSETUP_PATH $PYSETUP_PATH
COPY alembic.ini /usr/src/
COPY backend/alembic.ini /usr/src/
COPY start.sh /usr/src/
COPY ./alembic /usr/src/alembic
COPY ./app /usr/src/app
COPY backend/alembic /usr/src/alembic
COPY backend/app /usr/src/app
WORKDIR /usr/src
CMD ["bash", "start.sh"]

View file

@ -1,40 +0,0 @@
from dotenv import load_dotenv
from fastapi import FastAPI
from starlette.middleware.authentication import AuthenticationMiddleware
load_dotenv()
from app.routers import (
chargepoint_v1,
id_token_v1,
meter_value_v1,
ocpp_v1,
transaction_v1,
user_v1
)
from app.util.websocket_auth_backend import BasicAuthBackend
def create_ocpp_app():
app_ocpp = FastAPI(
responses={404: {"description": "Not found"}},
)
app_ocpp.include_router(ocpp_v1.router)
app_ocpp.add_middleware(AuthenticationMiddleware, backend=BasicAuthBackend())
return app_ocpp
def create_app():
app = FastAPI(
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")
app.mount(path="/v1/ocpp", app=create_ocpp_app())
return app
app = create_app()

View file

@ -1,15 +0,0 @@
import uuid
from sqlalchemy import Uuid, Boolean, Column, String
from sqlalchemy.orm import relationship
from app.database import Base
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)
id_tokens = relationship("IdToken", back_populates="owner", cascade="delete, delete-orphan")
transactions = relationship("Transaction", cascade="delete, delete-orphan")

View file

@ -1,66 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.params import Security
from sqlalchemy.orm import Session
from ocpp.v201.call import RequestStopTransactionPayload
from app.ocpp_proto import chargepoint_manager
from app.security import get_api_key
from app.database import get_db
from app.schemas.transaction import Transaction, RemoteTransactionStartStopResponse, TransactionStatus, RemoteTransactionStartStopStatus
from app.models.transaction import Transaction as DbTransaction
router = APIRouter(
prefix="/transactions",
tags=["Transaction (v1)"]
)
@router.get(path="", response_model=list[Transaction])
async def get_transactions(
skip: int = 0,
limit: int = 20,
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
return db.query(DbTransaction).offset(skip).limit(limit).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)
):
transaction = db.get(DbTransaction, transaction_id)
if transaction == None:
raise HTTPException(404, "Transaction not found")
return transaction
@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)
):
transaction = db.get(DbTransaction, transaction_id)
if transaction == None:
raise HTTPException(404, "Transaction not found")
if transaction.status != TransactionStatus.ONGOING:
raise HTTPException(status_code=422, detail=[{
"loc": ["path", "transaction_id"],
"msg": "Transaction is not ongoing",
"type": "invalid_transaction_state"
}])
if chargepoint_manager.is_connected(transaction.chargepoint_id) == False:
raise HTTPException(status_code=503, detail="Chargepoint not connected.")
try:
result = await chargepoint_manager.call(
transaction.chargepoint_id,
payload=RequestStopTransactionPayload(
transaction_id=transaction.id
)
)
if RemoteTransactionStartStopStatus(result.status) != RemoteTransactionStartStopStatus.REJECTED:
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.")

View file

@ -1,77 +0,0 @@
from uuid import UUID
from fastapi import APIRouter, HTTPException, Security
from fastapi.params import Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.schemas.user import User, UserCreate, UserUpdate
from app.models.user import User as DbUser
from app.security import get_api_key
router = APIRouter(
prefix="/users",
tags=["User (v1)"],
)
@router.get(path="", response_model=list[User])
async def get_users(
skip: int = 0,
limit: int = 20,
api_key: str = Security(get_api_key),
db: Session = Depends(get_db)
):
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
@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_user = DbUser(
friendly_name=create_user.friendly_name,
is_active=create_user.is_active
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_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)
):
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
@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)
):
user = db.get(DbUser, user_id)
if user == None:
raise HTTPException(status_code=404, detail="User not found")
db.delete(user)
db.commit()
return []

View file

@ -1,20 +0,0 @@
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
class UserBase(BaseModel):
friendly_name: str
is_active: bool
class UserUpdate(BaseModel):
friendly_name: Optional[str] = None
is_active: Optional[bool] = None
class UserCreate(UserBase):
pass
class User(UserBase):
id: UUID
class Config:
from_attributes = True

View file

@ -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",
)

View file

View file

@ -1,9 +1,12 @@
from logging.config import fileConfig
import os
from dotenv import load_dotenv
from sqlalchemy import create_engine
from alembic import context
load_dotenv()
# Import models for autogenerate support
from app.database import Base
from app.models import *

View file

@ -0,0 +1,67 @@
"""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
from sqlalchemy.dialects import postgresql
# 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))
role_enum = postgresql.ENUM('MEMBER', 'ADMINISTRATOR', name='role')
role_enum.create(op.get_bind(), checkfirst=False)
op.add_column('users', sa.Column('role', type_=role_enum, 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 ###

View file

@ -0,0 +1,50 @@
"""Add firmware_update table
Revision ID: 00edfb13e611
Revises: c7f72154c90b
Create Date: 2025-03-23 14:01:14.029527+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '00edfb13e611'
down_revision: Union[str, None] = 'c7f72154c90b'
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('firmware_updates',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('request_id', sa.Integer(), nullable=True),
sa.Column('status', sa.Enum('CREATED', 'SUBMITTED', 'DOWNLOADED', 'DOWNLOAD_FAILED', 'DOWNLOADING', 'DOWNLOAD_SCHEDULED', 'DOWNLOAD_PAUSED', 'IDLE', 'INSTALLATION_FAILED', 'INSTALLING', 'INSTALLED', 'INSTALL_REBOOTING', 'INSTALL_SCHEDULED', 'INSTALL_VERIFICATION_FAILED', 'INVALID_SIGNATURE', 'SIGNATURE_VERIFIED', name='firmwareupdatestatus'), nullable=True),
sa.Column('retries', sa.Integer(), nullable=True),
sa.Column('retry_interval', sa.Integer(), nullable=True),
sa.Column('location', sa.String(), nullable=True),
sa.Column('retrieve_date_time', sa.DateTime(), nullable=True),
sa.Column('install_date_time', sa.DateTime(), nullable=True),
sa.Column('chargepoint_id', sa.Uuid(), nullable=True),
sa.ForeignKeyConstraint(['chargepoint_id'], ['chargepoints.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_firmware_updates_chargepoint_id'), 'firmware_updates', ['chargepoint_id'], unique=False)
op.alter_column('users', 'is_active',
existing_type=sa.BOOLEAN(),
nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('users', 'is_active',
existing_type=sa.BOOLEAN(),
nullable=True)
op.drop_index(op.f('ix_firmware_updates_chargepoint_id'), table_name='firmware_updates')
op.drop_table('firmware_updates')
# ### end Alembic commands ###

View file

@ -0,0 +1,32 @@
"""Add signature fields to firmware_update table
Revision ID: 506cc8d086c9
Revises: 00edfb13e611
Create Date: 2025-03-23 14:49:42.662564+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '506cc8d086c9'
down_revision: Union[str, None] = '00edfb13e611'
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.add_column('firmware_updates', sa.Column('signing_certificate', sa.Text(), nullable=True))
op.add_column('firmware_updates', sa.Column('signature', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('firmware_updates', 'signature')
op.drop_column('firmware_updates', 'signing_certificate')
# ### end Alembic commands ###

87
backend/app/cli.py Normal file
View file

@ -0,0 +1,87 @@
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
load_dotenv()
from app.models import *
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)

View file

@ -3,7 +3,10 @@ from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
SQLALCHEMY_DATABASE_URL = os.getenv("CS_DATABASE_URL", "sqlite:///./simple-ocpp-cs.db")
if os.getenv("CS_DATABASE_URL", "invalid") == "invalid":
raise SystemExit('ERROR: Invalid CS_DATABASE_URL environment variable')
SQLALCHEMY_DATABASE_URL = os.getenv("CS_DATABASE_URL")
if SQLALCHEMY_DATABASE_URL.startswith("sqlite"):
engine = create_engine(

52
backend/app/main.py Normal file
View file

@ -0,0 +1,52 @@
from dotenv import load_dotenv
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.security.websocket_auth_backend import BasicAuthBackend
def create_ocpp_app():
app_ocpp = FastAPI(
responses={404: {"description": "Not found"}},
)
app_ocpp.include_router(ocpp_v1.router)
app_ocpp.add_middleware(AuthenticationMiddleware, backend=BasicAuthBackend())
return app_ocpp
def create_app():
app = FastAPI(
title="simple-ocpp-cs",
version="0.2.0",
summary="Simple implementation of a basic OCPP 2.0.1 compliant central system (backend) for EV charging stations",
responses={404: {"description": "Not found"}},
)
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())
return app
app = create_app()

View file

@ -2,8 +2,10 @@ __all__ = [
"chargepoint_variable",
"chargepoint",
"connector",
"firmware_update",
"id_token",
"meter_value",
"session",
"transaction",
"user"
]

View file

@ -0,0 +1,22 @@
import uuid
from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String, Text, Uuid
from app.database import Base
from app.schemas.firmware_update import FirmwareUpdateStatus
class FirmwareUpdate(Base):
__tablename__ = "firmware_updates"
id = Column(Uuid, primary_key=True, default=uuid.uuid4)
request_id = Column(Integer)
status = Column(Enum(FirmwareUpdateStatus))
retries = Column(Integer)
retry_interval = Column(Integer)
location = Column(String)
retrieve_date_time = Column(DateTime)
install_date_time = Column(DateTime, nullable=True)
signing_certificate = Column(Text, nullable=True)
signature = Column(String, nullable=True)
chargepoint_id = Column(Uuid, ForeignKey("chargepoints.id"), index=True)

View file

@ -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, nullable=False, unique=True, index=True)
last_used = Column(DateTime(timezone=True))
user_id = Column(Uuid, ForeignKey("users.id"), nullable=False, index=True)

View file

@ -0,0 +1,19 @@
import uuid
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, 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")

View file

@ -4,10 +4,11 @@ import os
from ocpp.routing import on, after
from ocpp.v201 import ChargePoint as cp
from ocpp.v201 import call_result
from ocpp.v201.enums import Action, RegistrationStatusType, TransactionEventType
from ocpp.v201.call import GetBaseReportPayload
from ocpp.v201.enums import Action, RegistrationStatusEnumType, TransactionEventEnumType
from ocpp.v201.call import GetBaseReport
from app.services import (
firmware_service,
variable_service,
id_token_service,
chargepoint_service,
@ -16,42 +17,42 @@ from app.services import (
class ChargePoint(cp):
@on(Action.BootNotification)
@on(Action.boot_notification)
async def on_boot_notification(self, charging_station, **kwargs):
await chargepoint_service.update_attributes(
chargepoint_identity=self.id,
charging_station=charging_station
)
return call_result.BootNotificationPayload(
return call_result.BootNotification(
current_time=datetime.now(UTC).isoformat(),
interval=int(os.getenv("CS_HEARTBEAT_INTERVAL", "1800")),
status=RegistrationStatusType.accepted
status=RegistrationStatusEnumType.accepted
)
@after(Action.BootNotification)
@after(Action.boot_notification)
async def after_boot_notification(self, **kwargs):
await self.call(payload=GetBaseReportPayload(request_id=0, report_base="FullInventory"))
await self.call(payload=GetBaseReport(request_id=0, report_base="FullInventory"))
@on(Action.NotifyReport)
@on(Action.notify_report)
async def on_notify_report(self, report_data, **kwargs):
for entry in report_data:
await variable_service.create_or_update_variable(
chargepoint_identity=self.id,
report_entry=entry
)
return call_result.NotifyReportPayload()
return call_result.NotifyReport()
@on(Action.Heartbeat)
@on(Action.heartbeat)
async def on_heartbeat_request(self):
return call_result.HeartbeatPayload(
return call_result.Heartbeat(
current_time=datetime.now(UTC).isoformat()
)
@after(Action.Heartbeat)
@after(Action.heartbeat)
async def after_heartbeat_request(self):
await chargepoint_service.update_last_seen(chargepoint_identity=self.id)
@on(Action.StatusNotification)
@on(Action.status_notification)
async def on_status_notification(self, evse_id: int, connector_id: int, connector_status: str, **kwargs):
await chargepoint_service.create_or_update_connector(
chargepoint_identity=self.id,
@ -59,14 +60,14 @@ class ChargePoint(cp):
connector_id=connector_id,
connector_status=connector_status
)
return call_result.StatusNotificationPayload()
return call_result.StatusNotification()
@on(Action.Authorize)
@on(Action.authorize)
async def on_authorize(self, id_token, **kwargs):
id_token_info, _ = await id_token_service.get_id_token_info(chargepoint_id=self.id, id_token=id_token)
return call_result.AuthorizePayload(id_token_info)
return call_result.Authorize(id_token_info)
@on(Action.TransactionEvent)
@on(Action.transaction_event)
async def on_transaction_event(
self,
event_type,
@ -82,7 +83,7 @@ class ChargePoint(cp):
id_token_info = None
token_owner_id = None
if event_type == str(TransactionEventType.started):
if event_type == str(TransactionEventEnumType.started):
await transaction_service.create_transaction(
chargepoint_identity=self.id,
user_id=token_owner_id,
@ -90,12 +91,12 @@ class ChargePoint(cp):
transaction_info=transaction_info,
transaction_data=kwargs
)
elif event_type == str(TransactionEventType.updated):
elif event_type == str(TransactionEventEnumType.updated):
await transaction_service.update_transaction(
transaction_id=transaction_info["transaction_id"],
transaction_data=kwargs
)
elif event_type == str(TransactionEventType.ended):
elif event_type == str(TransactionEventEnumType.ended):
await transaction_service.end_transaction(
transaction_id=transaction_info["transaction_id"],
timestamp=datetime.fromisoformat(timestamp),
@ -105,14 +106,19 @@ class ChargePoint(cp):
)
if id_token_info == None:
return call_result.TransactionEventPayload()
return call_result.TransactionEvent()
else:
return call_result.TransactionEventPayload(id_token_info=id_token_info)
return call_result.TransactionEvent(id_token_info=id_token_info)
@on(Action.MeterValues)
@on(Action.firmware_status_notification)
async def on_firmware_status_notification(self, status, request_id, **kwargs):
await firmware_service.update_firmware_status(self.id, request_id, status)
return call_result.FirmwareStatusNotification()
@on(Action.meter_values)
async def on_meter_values(self, **kwargs):
return call_result.MeterValuesPayload()
return call_result.MeterValues()
@on(Action.SecurityEventNotification)
@on(Action.security_event_notification)
async def on_security_event_notification(self, **kwargs):
return call_result.SecurityEventNotificationPayload()
return call_result.SecurityEventNotification()

View file

@ -3,6 +3,7 @@ from typing import Any, Coroutine, Dict
from uuid import UUID
from websockets import ConnectionClosed
from starlette.websockets import WebSocketDisconnect
from app.ocpp_proto.chargepoint import ChargePoint
@ -12,7 +13,7 @@ async def start(id: UUID, cp: ChargePoint):
try:
__active_connections[id] = cp
await cp.start()
except ConnectionClosed:
except (ConnectionClosed, WebSocketDisconnect):
logging.info("Charging station '%s' (%s) disconnected", cp.id, id)
__active_connections.pop(id, None)

View file

@ -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)
)
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")

View file

@ -2,14 +2,15 @@ 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
from ocpp.v201.call import ResetPayload, SetVariablesPayload
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,
@ -27,10 +28,13 @@ from app.schemas.chargepoint_variable import (
MutabilityType,
SetVariableStatusType
)
from app.schemas.firmware_update import FirmwareUpdate, FirmwareUpdateCreate, FirmwareUpdateSubmissionResponse
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.models.firmware_update import FirmwareUpdate as DbFirmwareUpdate
from app.security.jwt_bearer import JWTBearer
from app.services import firmware_service
router = APIRouter(
prefix="/chargepoints",
@ -41,16 +45,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 +64,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 +75,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 +88,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 +106,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 +120,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 +133,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,14 +143,14 @@ 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.")
try:
response = await chargepoint_manager.call(
chargepoint_id,
payload=ResetPayload(type=reset_request.type, evse_id=reset_request.evse_id)
payload=Reset(type=reset_request.type, evse_id=reset_request.evse_id)
)
return ChargePointResetResponse(status=response.status)
except TimeoutError:
@ -156,8 +160,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 +192,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 +210,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 +229,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 +243,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:
@ -269,7 +273,7 @@ async def update_chargepoint_variable(
evse['connectorId'] = variable.connector_id
result = await chargepoint_manager.call(
chargepoint_id,
payload=SetVariablesPayload(set_variable_data=[
payload=SetVariables(set_variable_data=[
{
'attributeType': variable.type.value,
'attributeValue': variable_update.value,
@ -292,3 +296,67 @@ async def update_chargepoint_variable(
return ChargepointVariableResponse(status=status)
except TimeoutError:
raise HTTPException(status_code=503, detail="Chargepoint didn't respond in time.")
@router.get(path="/{chargepoint_id}/firmware-updates", response_model=list[FirmwareUpdate])
async def get_firmware_updates(
chargepoint_id: UUID,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
):
chargepoint = db.get(DbChargePoint, chargepoint_id)
if chargepoint is None:
raise HTTPException(status_code=404, detail="Chargepoint not found")
firmware_updates = db.query(DbFirmwareUpdate).filter(
DbFirmwareUpdate.chargepoint_id == chargepoint_id
).all()
return firmware_updates
@router.get(path="/{chargepoint_id}/firmware-updates/{firmware_update_id}", response_model=FirmwareUpdate)
async def get_firmware_update(
chargepoint_id: UUID,
firmware_update_id: UUID,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
):
chargepoint = db.get(DbChargePoint, chargepoint_id)
if chargepoint is None:
raise HTTPException(status_code=404, detail="Chargepoint not found")
firmware_update = db.query(DbFirmwareUpdate).filter(
DbFirmwareUpdate.chargepoint_id == chargepoint_id,
DbFirmwareUpdate.id == firmware_update_id
).first()
if firmware_update is None:
raise HTTPException(status_code=404, detail="FirmwareUpdate not found")
return firmware_update
@router.post(path="/{chargepoint_id}/firmware-updates", response_model=FirmwareUpdate)
async def create_firmware_update(
chargepoint_id: UUID,
firmware_update: FirmwareUpdateCreate,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
):
chargepoint = db.get(DbChargePoint, chargepoint_id)
if chargepoint is None:
raise HTTPException(status_code=404, detail="Chargepoint not found")
firmware_update = await firmware_service.create_firmware_update(chargepoint_id, firmware_update)
return firmware_update
@router.post(path="/{chargepoint_id}/firmware-updates/{firmware_update_id}/submit", response_model=ChargePointResetResponse)
async def submit_firmware_update(
chargepoint_id: UUID,
firmware_update_id: UUID,
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
):
if chargepoint_manager.is_connected(chargepoint_id) == False:
raise HTTPException(status_code=503, detail="Chargepoint not connected.")
try:
_, status = await firmware_service.submit_firmware_update(firmware_update_id)
return FirmwareUpdateSubmissionResponse(status=status)
except TimeoutError:
raise HTTPException(status_code=503, detail="Chargepoint didn't respond in time.")

View file

@ -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(DbIdToken)
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 and 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)

View file

@ -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()

View file

@ -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()
stmt = select(DbMeterValue).order_by(DbMeterValue.timestamp).offset(skip).limit(limit)
result = db.execute(stmt)
return result.scalars().all()

View file

@ -0,0 +1,100 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from ocpp.v201.call import RequestStopTransaction
from app.ocpp_proto import chargepoint_manager
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",
tags=["Transaction (v1)"]
)
@router.get(path="", response_model=list[Transaction])
async def get_transactions(
skip: int = 0,
limit: int = 20,
db: Session = Depends(get_db),
token: AccessToken = Depends(JWTBearer()),
):
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,
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 and 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 and 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,
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 and 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"],
"msg": "Transaction is not ongoing",
"type": "invalid_transaction_state"
}])
if chargepoint_manager.is_connected(transaction.chargepoint_id) == False:
raise HTTPException(status_code=503, detail="chargepoint_offline")
try:
result = await chargepoint_manager.call(
transaction.chargepoint_id,
payload=RequestStopTransaction(
transaction_id=transaction.id
)
)
if RemoteTransactionStartStopStatus(result.status) != RemoteTransactionStartStopStatus.REJECTED:
raise HTTPException(status_code=500, detail=result.status)
return RemoteTransactionStartStopResponse(status=result.status)
except TimeoutError:
raise HTTPException(status_code=503, detail="chargepoint_operation_timeout")

View file

@ -0,0 +1,116 @@
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 AdministrativeUserUpdate, User, UserCreate
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",
tags=["User (v1)"],
)
@router.get(path="", response_model=list[User])
async def get_users(
email: str = None,
skip: int = 0,
limit: int = 20,
db: DbSession = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
):
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,
db: DbSession = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
):
user = await user_service.create_user(
db=db, user=create_user
)
return user
@router.patch(path="/{user_id}", response_model=User)
async def update_user(
user_id: UUID,
user_update: AdministrativeUserUpdate,
db: DbSession = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
):
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,
db: DbSession = Depends(get_db),
token: AccessToken = Depends(JWTBearer(required_roles=["administrator"])),
):
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()

View file

@ -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

View file

@ -6,7 +6,7 @@ from pydantic import BaseModel
from app.schemas.connector import Connector
from ocpp.v201.enums import ResetType, ResetStatusType
from ocpp.v201.enums import ResetEnumType, ResetStatusEnumType
class ChargePointBase(BaseModel):
identity: str
@ -40,8 +40,8 @@ class ChargePointConnectionInfo(BaseModel):
connected: bool
class ChargePointResetRequest(BaseModel):
type: ResetType
type: ResetEnumType
evse_id: Optional[int] = None
class ChargePointResetResponse(BaseModel):
status: ResetStatusType
status: ResetStatusEnumType

View file

@ -0,0 +1,43 @@
from datetime import datetime
import enum
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
class FirmwareUpdateStatus(enum.Enum):
CREATED = "xCreated"
SUBMITTED = "xSubmitted"
DOWNLOADED = "Downloaded"
DOWNLOAD_FAILED = "DownloadFailed"
DOWNLOADING = "Downloading"
DOWNLOAD_SCHEDULED = "DownloadScheduled"
DOWNLOAD_PAUSED = "DownloadPaused"
IDLE = "Idle"
INSTALLATION_FAILED = "InstallationFailed"
INSTALLING = "Installing"
INSTALLED = "Installed"
INSTALL_REBOOTING = "InstallRebooting"
INSTALL_SCHEDULED = "InstallScheduled"
INSTALL_VERIFICATION_FAILED = "InstallVerificationFailed"
INVALID_SIGNATURE = "InvalidSignature"
SIGNATURE_VERIFIED = "SignatureVerified"
class FirmwareUpdateBase(BaseModel):
retries: int
retry_interval: int
location: str
retrieve_date_time: datetime
install_date_time: Optional[datetime]
signing_certificate: Optional[str]
signature: Optional[str]
class FirmwareUpdate(FirmwareUpdateBase):
id: UUID
request_id: int
status: FirmwareUpdateStatus
class FirmwareUpdateCreate(FirmwareUpdateBase):
pass
class FirmwareUpdateSubmissionResponse(BaseModel):
status: str

View file

@ -3,8 +3,6 @@ from typing import Optional
from uuid import UUID
from pydantic import BaseModel
from app.schemas.user import User
class IdTokenBase(BaseModel):
friendly_name: str
is_active: bool

View file

@ -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}

View file

@ -0,0 +1,39 @@
import enum
from typing import Optional
from uuid import UUID
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
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)

View file

@ -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")

View file

@ -0,0 +1,66 @@
from uuid import UUID
from ocpp.v201.call import UpdateFirmware
from ocpp.v201.call_result import UpdateFirmware as UpdateFirmwareResult
from ocpp.v201.datatypes import FirmwareType
from app.database import SessionLocal
from app.models.chargepoint import ChargePoint
from app.models.firmware_update import FirmwareUpdate
from app.ocpp_proto import chargepoint_manager
from app.schemas.firmware_update import FirmwareUpdateCreate, FirmwareUpdateStatus
async def create_firmware_update(chargepoint_id: UUID, firmware_update: FirmwareUpdateCreate) -> FirmwareUpdate:
with SessionLocal() as db:
db_chargepoint = db.get(ChargePoint, chargepoint_id)
latest_firmware_update = db.query(FirmwareUpdate).filter(FirmwareUpdate.chargepoint_id == db_chargepoint.id).order_by(FirmwareUpdate.request_id.desc()).first()
new_request_id = latest_firmware_update.request_id + 1 if latest_firmware_update else 1
db_firmware_update = FirmwareUpdate(
request_id=new_request_id,
status=FirmwareUpdateStatus.CREATED,
retries=firmware_update.retries,
retry_interval=firmware_update.retry_interval,
location=firmware_update.location,
retrieve_date_time=firmware_update.retrieve_date_time,
install_date_time=firmware_update.install_date_time,
chargepoint_id=db_chargepoint.id,
signing_certificate=firmware_update.signing_certificate,
signature=firmware_update.signature
)
db.add(db_firmware_update)
db.commit()
db.refresh(db_firmware_update)
return db_firmware_update
async def submit_firmware_update(firmware_update_id: UUID) -> tuple[FirmwareUpdate, str]:
with SessionLocal() as db:
db_firmware_update = db.get(FirmwareUpdate, firmware_update_id)
try:
result: UpdateFirmwareResult = await chargepoint_manager.call(
db_firmware_update.chargepoint_id,
payload=UpdateFirmware(
request_id=db_firmware_update.request_id,
retries=db_firmware_update.retries,
retry_interval=db_firmware_update.retry_interval,
firmware=FirmwareType(
location=db_firmware_update.location,
retrieve_date_time=db_firmware_update.retrieve_date_time.isoformat(),
install_date_time=db_firmware_update.install_date_time.isoformat(),
signing_certificate=db_firmware_update.signing_certificate,
signature=db_firmware_update.signature
)
))
if result.status == "Accepted" or result.status == "AcceptedCanceled":
db_firmware_update.status = FirmwareUpdateStatus.SUBMITTED
db.commit()
return db_firmware_update, result.status
except TimeoutError as e:
raise e
async def update_firmware_status(chargepoint_identity: str, request_id: int, status: FirmwareUpdateStatus):
with SessionLocal() as db:
db_chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_identity).first()
db_firmware_update = db.query(FirmwareUpdate).filter(FirmwareUpdate.chargepoint_id == db_chargepoint.id).filter(FirmwareUpdate.request_id == request_id).first()
db_firmware_update.status = FirmwareUpdateStatus(status)
db.commit()

View file

@ -1,7 +1,7 @@
from datetime import datetime, UTC
from ocpp.v201.datatypes import IdTokenInfoType
from ocpp.v201.enums import AuthorizationStatusType
from ocpp.v201.enums import AuthorizationStatusEnumType
from app.database import SessionLocal
from app.models.id_token import IdToken
@ -11,14 +11,14 @@ async def get_id_token_info(chargepoint_id: str, id_token: str):
owner_id = None
if id_token["type"] not in ["ISO14443", "ISO15693"]:
return IdTokenInfoType(
status=AuthorizationStatusType.invalid
status=AuthorizationStatusEnumType.invalid
), owner_id
with SessionLocal() as db:
db_id_token = db.query(IdToken).filter(IdToken.token == id_token["id_token"]).first()
if db_id_token == None:
id_token_info = IdTokenInfoType(
status=AuthorizationStatusType.unknown
status=AuthorizationStatusEnumType.unknown
)
db_chargepoint = db.query(ChargePoint).filter(ChargePoint.identity == chargepoint_id).first()
# Learn token if requested
@ -32,7 +32,7 @@ async def get_id_token_info(chargepoint_id: str, id_token: str):
db.add(db_id_token)
id_token_info=IdTokenInfoType(
status=AuthorizationStatusType.accepted
status=AuthorizationStatusEnumType.accepted
)
owner_id = db_id_token.owner_id
db_chargepoint.learn_user_id = None
@ -42,10 +42,10 @@ async def get_id_token_info(chargepoint_id: str, id_token: str):
owner_id = db_id_token.owner_id
if db_id_token.is_active == False:
id_token_info=IdTokenInfoType(
status=AuthorizationStatusType.blocked
status=AuthorizationStatusEnumType.blocked
)
else:
id_token_info=IdTokenInfoType(
status=AuthorizationStatusType.accepted
status=AuthorizationStatusEnumType.accepted
)
return id_token_info, owner_id

View file

@ -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

View file

@ -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

View file

@ -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

View file

View file

@ -0,0 +1,14 @@
class NotFoundError(Exception):
pass
class InvalidStateError(Exception):
pass
class InsufficientPermissionsError(Exception):
pass
class InvalidTokenAudienceError(Exception):
pass

1780
backend/poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,21 +1,23 @@
[tool.poetry]
name = "simple-ocpp-cs"
version = "0.1.0"
version = "0.2.0"
description = "A simple OCPP 2.0.1 compliant central system"
authors = ["Bluemedia <hi@bluemedia.dev>"]
readme = "README.md"
package-mode = false
[tool.poetry.dependencies]
python = "^3.11"
ocpp = "^0.26.0"
fastapi = "^0.110.0"
uvicorn = {extras = ["standard"], version = "^0.28.0"}
websockets = "^12.0"
python = "^3.12"
ocpp = "^2.0.0"
fastapi = {extras = ["standard"], version = "^0.115.11"}
uvicorn = {extras = ["standard"], version = "^0.34.0"}
websockets = "^15.0"
sqlalchemy = "^2.0.28"
alembic = "^1.13.1"
alembic = "^1.15.1"
gunicorn = "^22.0.0"
psycopg2-binary = "^2.9.9"
psycopg2-binary = "^2.9.10"
argon2-cffi = "^23.1.0"
jwcrypto = "^1.5.6"
[build-system]
requires = ["poetry-core"]

View file

@ -0,0 +1,12 @@
{
"folders": [
{
"name": "Backend",
"path": "../backend"
},
{
"name": "Root",
"path": "../"
}
]
}

23
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
frontend/.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

4
frontend/.prettierignore Normal file
View file

@ -0,0 +1,4 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock

11
frontend/.prettierrc Normal file
View file

@ -0,0 +1,11 @@
{
"trailingComma": "es5",
"semi": false,
"singleQuote": true,
"useTabs": false,
"tabWidth": 2,
"quoteProps": "consistent",
"bracketSpacing": true,
"arrowParens": "always",
"printWidth": 100
}

38
frontend/README.md Normal file
View file

@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

39
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,39 @@
import prettier from 'eslint-config-prettier'
import js from '@eslint/js'
import { includeIgnoreFile } from '@eslint/compat'
import svelte from 'eslint-plugin-svelte'
import globals from 'globals'
import { fileURLToPath } from 'node:url'
import ts from 'typescript-eslint'
import svelteConfig from './svelte.config.js'
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url))
export default ts.config(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
},
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
ignores: ['eslint.config.js', 'svelte.config.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig,
},
},
}
)

3854
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

40
frontend/package.json Normal file
View file

@ -0,0 +1,40 @@
{
"name": "frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^6.0.0"
},
"dependencies": {
"@tailwindcss/vite": "^4.0.14",
"daisyui": "^5.0.3",
"tailwindcss": "^4.0.14"
}
}

View file

@ -0,0 +1,17 @@
{
"folders": [
{
"name": "Frontend",
"path": "../frontend",
},
{
"name": "Root",
"path": "../",
},
],
"settings": {
"editor.formatOnSave": true,
"editor.tabSize": 2,
"editor.detectIndentation": false,
},
}

13
frontend/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {}

12
frontend/src/app.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View file

@ -0,0 +1 @@
export const ssr = false

View file

@ -0,0 +1,6 @@
<script>
import '../style.css'
</script>
<h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>

6
frontend/src/style.css Normal file
View file

@ -0,0 +1,6 @@
@import 'tailwindcss';
@plugin "daisyui" {
themes:
light --default,
dark --prefersdark;
}

BIN
frontend/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

17
frontend/svelte.config.js Normal file
View file

@ -0,0 +1,17 @@
import adapter from '@sveltejs/adapter-static'
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
fallback: 'index.html',
}),
},
}
export default config

19
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

10
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,10 @@
import tailwindcss from '@tailwindcss/vite'
import { sveltekit } from '@sveltejs/kit/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
host: '127.0.0.1',
},
})

1180
poetry.lock generated

File diff suppressed because it is too large Load diff

3
renovate.json Normal file
View file

@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}