Compare commits

..

17 commits

Author SHA1 Message Date
Csaba 797eff5b2a Change bearer token expiry to 1 week
All checks were successful
Amarillo/amarillo-gitea/amarillo-core/pipeline/head This commit looks good
2024-05-22 14:53:10 +02:00
Csaba b9b47dfc2a Updated Jenkinfile to use grfs-exporter
All checks were successful
Amarillo/amarillo-gitea/amarillo-core/pipeline/head This commit looks good
2024-04-22 15:06:02 +02:00
Csaba a04397f59d Use verify_permission in routes
Some checks failed
Amarillo/amarillo-gitea/amarillo-core/pipeline/head There was a failure building this commit
2024-04-22 14:49:42 +02:00
Csaba c8acc46382 verify_permission function 2024-04-22 12:49:13 +02:00
Csaba 11d5849290 Get current user function
Some checks failed
Amarillo/amarillo-gitea/amarillo-core/pipeline/head There was a failure building this commit
2024-04-18 16:55:35 +02:00
Csaba 66cc746937 Use /data for region and agency configuration
All checks were successful
Amarillo/amarillo-gitea/amarillo-core/pipeline/head This commit looks good
2024-04-05 14:28:51 +02:00
Csaba 6af3250bea Renamed AgencyConf to User 2024-04-05 14:13:56 +02:00
Csaba 6f019020ea Added error logging to file in logging.conf
All checks were successful
Amarillo/amarillo-gitea/amarillo-core/pipeline/head This commit looks good
2024-03-06 09:50:49 +01:00
Csaba 3ed38a959d Added secret_key variable to Dockerfile
All checks were successful
Amarillo/amarillo-gitea/amarillo-core/pipeline/head This commit looks good
2024-03-06 09:20:28 +01:00
Csaba b1aeac37df [#4] load secret key from env variable
Some checks failed
Amarillo/amarillo-gitea/amarillo-core/pipeline/head There was a failure building this commit
2024-03-01 16:01:02 +01:00
Csaba 206f93ddde Use OAuth2 to authorize endpoints
Some checks failed
Amarillo/amarillo-gitea/amarillo-core/pipeline/head There was a failure building this commit
2024-03-01 15:09:52 +01:00
Csaba acd06b522a [#4] Make password and api_key optional in AgencyConf 2024-03-01 14:40:06 +01:00
Csaba abf5b071a4 Remove metrics plugin secrets 2024-03-01 14:22:29 +01:00
Csaba e81dbbc39c [#4] Fix KeyError in authenticate_agency
Some checks failed
Amarillo/amarillo-gitea/amarillo-core/pipeline/head There was a failure building this commit
2024-02-28 11:04:21 +01:00
Csaba bbba8de7ac [#4] Verify password from agencyconf 2024-02-28 10:53:05 +01:00
Csaba c03d5b1232 [#4] Passwords in AgencyConf model 2024-02-28 09:34:13 +01:00
Csaba 319be9f803 Basic OAuth2 authentication 2024-02-26 10:59:09 +01:00
25 changed files with 482 additions and 284 deletions

2
.gitignore vendored
View file

@ -144,6 +144,8 @@ data/trash/
data/gtfs/ data/gtfs/
data/grfs data/grfs
data/tmp data/tmp
data/users/**
data/**
#these files are under app/static but they get copied to the outside directory on startup #these files are under app/static but they get copied to the outside directory on startup
logging.conf logging.conf

View file

@ -17,6 +17,7 @@ RUN \
ENV ADMIN_TOKEN='' ENV ADMIN_TOKEN=''
ENV RIDE2GO_TOKEN='' ENV RIDE2GO_TOKEN=''
ENV SECRET_KEY=''
ENV METRICS_USER='' ENV METRICS_USER=''
ENV METRICS_PASSWORD='' ENV METRICS_PASSWORD=''

2
Jenkinsfile vendored
View file

@ -9,7 +9,7 @@ pipeline {
IMAGE_NAME = 'amarillo' IMAGE_NAME = 'amarillo'
AMARILLO_DISTRIBUTION = '0.2' AMARILLO_DISTRIBUTION = '0.2'
TAG = "${AMARILLO_DISTRIBUTION}.${BUILD_NUMBER}" TAG = "${AMARILLO_DISTRIBUTION}.${BUILD_NUMBER}"
PLUGINS = 'amarillo-metrics amarillo-enhancer amarillo-grfs-export' PLUGINS = 'amarillo-metrics amarillo-enhancer amarillo-grfs-exporter'
DEPLOY_WEBHOOK_URL = 'http://amarillo.mfdz.de:8888/mitanand' DEPLOY_WEBHOOK_URL = 'http://amarillo.mfdz.de:8888/mitanand'
DEPLOY_SECRET = credentials('AMARILLO-JENKINS-DEPLOY-SECRET') DEPLOY_SECRET = credentials('AMARILLO-JENKINS-DEPLOY-SECRET')
} }

View file

@ -29,14 +29,14 @@ There is a special *admin* user.
For this user, the API-Key must be passed in as an environment variable when For this user, the API-Key must be passed in as an environment variable when
Amarillo is started. Amarillo is started.
The admin can create additional API-Keys in the `/agencyconf` endpoint. This The admin can create additional API-Keys in the `/users` endpoint. This
endpoint is always available but not always shown in `/docs`, especially not endpoint is always available but not always shown in `/docs`, especially not
when running in production. when running in production.
The Swagger docs for `/agencyconf` can be seen on the MFDZ demo server. The Swagger docs for `/users` can be seen on the MFDZ demo server.
Permissions work this way Permissions work this way
- the admin is allowed to call all operations on all resources. Only the admin - the admin is allowed to call all operations on all resources. Only the admin
can create new API-Keys by POSTing an `AgencyConf` JSON object to `/agencyconf`. can create new API-Keys by POSTing an `users` JSON object to `/users`.
- API-Keys for agencies are allowed to POST/PUT/GET/DELETE their own - API-Keys for agencies are allowed to POST/PUT/GET/DELETE their own
resources and GET some public resources. resources and GET some public resources.

View file

@ -2,7 +2,7 @@
from amarillo.utils.container import container from amarillo.utils.container import container
import logging import logging
from amarillo.services.agencyconf import AgencyConfService, agency_conf_directory from amarillo.services.users import UserService, user_conf_directory
from amarillo.services.agencies import AgencyService from amarillo.services.agencies import AgencyService
from amarillo.services.regions import RegionService from amarillo.services.regions import RegionService
@ -27,12 +27,12 @@ def create_required_directories():
assert_folder_exists(f'data/{subdir}/{agency_id}') assert_folder_exists(f'data/{subdir}/{agency_id}')
# Agency configurations # Agency configurations
assert_folder_exists(agency_conf_directory) assert_folder_exists(user_conf_directory)
def configure_services(): def configure_services():
container['agencyconf'] = AgencyConfService() container['users'] = UserService()
logger.info("Loaded %d agency configuration(s)", len(container['agencyconf'].agency_id_to_agency_conf)) logger.info("Loaded %d user configuration(s)", len(container['users'].user_id_to_user_conf))
container['agencies'] = AgencyService() container['agencies'] = AgencyService()
logger.info("Loaded %d agencies", len(container['agencies'].agencies)) logger.info("Loaded %d agencies", len(container['agencies'].agencies))

View file

@ -12,10 +12,9 @@ copy_static_files(["conf", "static", "templates", "logging.conf", "config"])
import amarillo.plugins import amarillo.plugins
from amarillo.configuration import configure_services, configure_admin_token from amarillo.configuration import configure_services, configure_admin_token
from amarillo.routers import carpool, agency, agencyconf, region from amarillo.routers import carpool, agency, region, users
from fastapi import FastAPI, HTTPException import amarillo.services.oauth2 as oauth2
from fastapi.responses import JSONResponse from fastapi import FastAPI
import traceback
# https://pydantic-docs.helpmanual.io/usage/settings/ # https://pydantic-docs.helpmanual.io/usage/settings/
from amarillo.views import home from amarillo.views import home
@ -86,13 +85,10 @@ app = FastAPI(title="Amarillo - The Carpooling Intermediary",
app.include_router(carpool.router) app.include_router(carpool.router)
app.include_router(agency.router) app.include_router(agency.router)
app.include_router(agencyconf.router) app.include_router(users.router)
app.include_router(region.router) app.include_router(region.router)
app.include_router(oauth2.router)
@app.exception_handler(Exception)
async def log_exception(request, exc):
logger.error(f"500 Error: {exc} \n{traceback.format_exc()}")
return JSONResponse(status_code=500, content={"message": "Internal Server Error"})
def iter_namespace(ns_pkg): def iter_namespace(ns_pkg):
# Source: https://packaging.python.org/guides/creating-and-discovering-plugins/ # Source: https://packaging.python.org/guides/creating-and-discovering-plugins/

View file

@ -1,26 +0,0 @@
from pydantic import ConfigDict, BaseModel, Field
class AgencyConf(BaseModel):
agency_id: str = Field(
description="ID of the agency that uses this token.",
min_length=1,
max_length=20,
pattern='^[a-zA-Z0-9]+$',
examples=["mfdz"])
api_key: str = Field(
description="The agency's API key for using the API",
min_length=20,
max_length=256,
pattern=r'^[a-zA-Z0-9]+$',
examples=["d8yLuY4DqMEUCLcfJASi"])
model_config = ConfigDict(json_schema_extra={
"title": "Agency Configuration",
"description": "Configuration for an agency.",
"example":
{
"agency_id": "mfdz",
"api_key": "d8yLuY4DqMEUCLcfJASi"
}
})

38
amarillo/models/User.py Normal file
View file

@ -0,0 +1,38 @@
from typing import Annotated, Optional, List
from pydantic import ConfigDict, BaseModel, Field
class User(BaseModel):
#TODO: add attributes admin, permissions, fullname, email
user_id: str = Field(
description="ID of the agency that uses this token.",
min_length=1,
max_length=20,
pattern='^[a-zA-Z0-9]+$',
examples=["mfdz"])
api_key: Optional[str] = Field(None,
description="The agency's API key for using the API",
min_length=20,
max_length=256,
pattern=r'^[a-zA-Z0-9]+$',
examples=["d8yLuY4DqMEUCLcfJASi"])
password: Optional[str] = Field(None,
description="The agency's password for generating JWT tokens",
min_length=8,
max_length=256,
examples=["$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW"])
permissions: Optional[List[Annotated[str, Field(pattern=r'^[a-z0-9-]+(:[a-z]+)?$')]]] = Field([],
description="The permissions of this user, a list of strings in the format <agency:operation> or <operation>",
max_length=256,
# pattern=r'^[a-zA-Z0-9]+(:[a-zA-Z]+)?$', #TODO
examples=["ride2go:read", "all:read", "admin", "geojson"])
model_config = ConfigDict(json_schema_extra={
"title": "Agency Configuration",
"description": "Configuration for an agency.",
"example":
{
"agency_id": "mfdz",
"api_key": "d8yLuY4DqMEUCLcfJASi",
"password": "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW"
}
})

View file

@ -5,7 +5,8 @@ from typing import List
from fastapi import APIRouter, HTTPException, status, Depends from fastapi import APIRouter, HTTPException, status, Depends
from amarillo.models.Carpool import Carpool, Agency from amarillo.models.Carpool import Carpool, Agency
from amarillo.routers.agencyconf import verify_api_key, verify_admin_api_key, verify_permission_for_same_agency_or_admin from amarillo.models.User import User
from amarillo.services.oauth2 import get_current_user, verify_permission
# TODO should move this to service # TODO should move this to service
from amarillo.routers.carpool import store_carpool, delete_agency_carpools_older_than from amarillo.routers.carpool import store_carpool, delete_agency_carpools_older_than
from amarillo.services.agencies import AgencyService from amarillo.services.agencies import AgencyService
@ -32,7 +33,7 @@ router = APIRouter(
status.HTTP_404_NOT_FOUND: {"description": "Agency not found"}, status.HTTP_404_NOT_FOUND: {"description": "Agency not found"},
}, },
) )
async def get_agency(agency_id: str, admin_api_key: str = Depends(verify_api_key)) -> Agency: async def get_agency(agency_id: str, requesting_user: User = Depends(get_current_user)) -> Agency:
agencies: AgencyService = container['agencies'] agencies: AgencyService = container['agencies']
agency = agencies.get_agency(agency_id) agency = agencies.get_agency(agency_id)
agency_exists = agency is not None agency_exists = agency is not None
@ -61,8 +62,8 @@ async def get_agency(agency_id: str, admin_api_key: str = Depends(verify_api_key
status.HTTP_500_INTERNAL_SERVER_ERROR: { status.HTTP_500_INTERNAL_SERVER_ERROR: {
"description": "Import error"} "description": "Import error"}
}) })
async def sync(agency_id: str, requesting_agency_id: str = Depends(verify_api_key)) -> List[Carpool]: async def sync(agency_id: str, requesting_user: User = Depends(get_current_user)) -> List[Carpool]:
await verify_permission_for_same_agency_or_admin(agency_id, requesting_agency_id) verify_permission(f"{agency_id}:sync")
if agency_id == "ride2go": if agency_id == "ride2go":
import_function = import_ride2go import_function = import_ride2go

View file

@ -1,103 +0,0 @@
import logging
from typing import List
from fastapi import APIRouter, HTTPException, status, Header, Depends
from amarillo.models.AgencyConf import AgencyConf
from amarillo.services.agencyconf import AgencyConfService
from amarillo.services.config import config
from amarillo.utils.container import container
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/agencyconf",
tags=["agencyconf"]
)
# This endpoint is not shown in PROD installations, only in development
# TODO make this an explicit config option
include_in_schema = config.env != 'PROD'
# noinspection PyPep8Naming
# X_API_Key is upper case for OpenAPI
async def verify_admin_api_key(X_API_Key: str = Header(...)):
if X_API_Key != config.admin_token:
message="X-API-Key header invalid"
logger.error(message)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
return "admin"
# noinspection PyPep8Naming
# X_API_Key is upper case for OpenAPI
async def verify_api_key(X_API_Key: str = Header(...)):
agency_conf_service: AgencyConfService = container['agencyconf']
return agency_conf_service.check_api_key(X_API_Key)
# TODO Return code 403 Unauthoized (in response_status_codes as well...)
async def verify_permission_for_same_agency_or_admin(agency_id_in_path_or_body, agency_id_from_api_key):
"""Verifies that an agency is accessing something it owns or the user is admin
The agency_id is part of some paths, or when not in the path it is in the body, e.g. in PUT /carpool.
This function encapsulates the formula 'working with own stuff, or admin'.
"""
is_permitted = agency_id_in_path_or_body == agency_id_from_api_key or agency_id_from_api_key == "admin"
if not is_permitted:
message = f"Working with {agency_id_in_path_or_body} resources is not permitted for {agency_id_from_api_key}."
logger.error(message)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
@router.get("/",
include_in_schema=include_in_schema,
operation_id="getAgencyIdsWhichHaveAConfiguration",
summary="Get agency_ids which have a configuration",
response_model=List[str],
description="Returns the agency_ids but not the details.",
status_code=status.HTTP_200_OK)
async def get_agency_ids(admin_api_key: str = Depends(verify_api_key)) -> [str]:
return container['agencyconf'].get_agency_ids()
@router.post("/",
include_in_schema=include_in_schema,
operation_id="postNewAgencyConf",
summary="Post a new AgencyConf")
async def post_agency_conf(agency_conf: AgencyConf, admin_api_key: str = Depends(verify_admin_api_key)):
agency_conf_service: AgencyConfService = container['agencyconf']
agency_conf_service.add(agency_conf)
# TODO 400->403
@router.delete("/{agency_id}",
include_in_schema=include_in_schema,
operation_id="deleteAgencyConf",
status_code=status.HTTP_200_OK,
summary="Delete configuration of an agency. Returns true if the token for the agency existed, "
"false if it didn't exist."
)
async def delete_agency_conf(agency_id: str, requesting_agency_id: str = Depends(verify_api_key)):
agency_may_delete_own = requesting_agency_id == agency_id
admin_may_delete_everything = requesting_agency_id == "admin"
is_permitted = agency_may_delete_own or admin_may_delete_everything
if not is_permitted:
message = f"The API key for {requesting_agency_id} can not delete the configuration for {agency_id}"
logger.error(message)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
agency_conf_service: AgencyConfService = container['agencyconf']
agency_exists = agency_id in agency_conf_service.get_agency_ids()
if not agency_exists:
message = f"No config for {agency_id}"
logger.error(message)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
agency_conf_service.delete(agency_id)

View file

@ -5,11 +5,12 @@ import os.path
import re import re
from glob import glob from glob import glob
from fastapi import APIRouter, Body, Header, HTTPException, status, Depends from fastapi import APIRouter, Body, HTTPException, status, Depends
from datetime import datetime from datetime import datetime
from amarillo.models.Carpool import Carpool from amarillo.models.Carpool import Carpool
from amarillo.routers.agencyconf import verify_api_key, verify_permission_for_same_agency_or_admin from amarillo.models.User import User
from amarillo.services.oauth2 import get_current_user, verify_permission
from amarillo.tests.sampledata import examples from amarillo.tests.sampledata import examples
@ -32,8 +33,8 @@ router = APIRouter(
}) })
async def post_carpool(carpool: Carpool = Body(..., examples=examples), async def post_carpool(carpool: Carpool = Body(..., examples=examples),
requesting_agency_id: str = Depends(verify_api_key)) -> Carpool: requesting_user: User = Depends(get_current_user)) -> Carpool:
await verify_permission_for_same_agency_or_admin(carpool.agency, requesting_agency_id) verify_permission(f"{carpool.agency}:write", requesting_user)
logger.info(f"POST trip {carpool.agency}:{carpool.id}.") logger.info(f"POST trip {carpool.agency}:{carpool.id}.")
await assert_agency_exists(carpool.agency) await assert_agency_exists(carpool.agency)
@ -53,7 +54,9 @@ async def post_carpool(carpool: Carpool = Body(..., examples=examples),
status.HTTP_404_NOT_FOUND: {"description": "Carpool not found"}, status.HTTP_404_NOT_FOUND: {"description": "Carpool not found"},
}, },
) )
async def get_carpool(agency_id: str, carpool_id: str, api_key: str = Depends(verify_api_key)) -> Carpool: async def get_carpool(agency_id: str, carpool_id: str, requesting_user: User = Depends(get_current_user)) -> Carpool:
verify_permission(f"{agency_id}:read", requesting_user)
logger.info(f"Get trip {agency_id}:{carpool_id}.") logger.info(f"Get trip {agency_id}:{carpool_id}.")
await assert_agency_exists(agency_id) await assert_agency_exists(agency_id)
await assert_carpool_exists(agency_id, carpool_id) await assert_carpool_exists(agency_id, carpool_id)
@ -72,8 +75,8 @@ async def get_carpool(agency_id: str, carpool_id: str, api_key: str = Depends(ve
"description": "Carpool or agency not found"}, "description": "Carpool or agency not found"},
}, },
) )
async def delete_carpool(agency_id: str, carpool_id: str, requesting_agency_id: str = Depends(verify_api_key)): async def delete_carpool(agency_id: str, carpool_id: str, requesting_user: User = Depends(get_current_user)):
await verify_permission_for_same_agency_or_admin(agency_id, requesting_agency_id) verify_permission(f"{agency_id}:write", requesting_user)
logger.info(f"Delete trip {agency_id}:{carpool_id}.") logger.info(f"Delete trip {agency_id}:{carpool_id}.")
await assert_agency_exists(agency_id) await assert_agency_exists(agency_id)
@ -134,7 +137,7 @@ async def save_carpool(carpool, folder: str = 'data/carpool'):
async def assert_agency_exists(agency_id: str): async def assert_agency_exists(agency_id: str):
agency_exists = os.path.exists(f"conf/agency/{agency_id}.json") agency_exists = os.path.exists(f"data/agency/{agency_id}.json")
if not agency_exists: if not agency_exists:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,

View file

@ -5,10 +5,8 @@ from typing import List
from fastapi import APIRouter, HTTPException, status, Depends from fastapi import APIRouter, HTTPException, status, Depends
from amarillo.models.Carpool import Region from amarillo.models.Carpool import Region
from amarillo.routers.agencyconf import verify_admin_api_key
from amarillo.services.regions import RegionService from amarillo.services.regions import RegionService
from amarillo.utils.container import container from amarillo.utils.container import container
from fastapi.responses import FileResponse
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

71
amarillo/routers/users.py Normal file
View file

@ -0,0 +1,71 @@
import logging
from typing import List
from fastapi import APIRouter, HTTPException, status, Header, Depends
from amarillo.models.User import User
from amarillo.services.users import UserService
from amarillo.services.oauth2 import get_current_user, verify_permission
from amarillo.services.config import config
from amarillo.utils.container import container
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/users",
tags=["users"]
)
# This endpoint is not shown in PROD installations, only in development
# TODO make this an explicit config option
include_in_schema = config.env != 'PROD'
@router.get("/",
include_in_schema=include_in_schema,
operation_id="getUserIdsWhichHaveAConfiguration",
summary="Get user which have a configuration",
response_model=List[str],
description="Returns the user_ids but not the details.",
status_code=status.HTTP_200_OK)
async def get_user_ids(requesting_user: User = Depends(get_current_user)) -> [str]:
return container['users'].get_user_ids()
@router.post("/",
include_in_schema=include_in_schema,
operation_id="postNewUserConf",
summary="Post a new User")
async def post_user_conf(user_conf: User, requesting_user: User = Depends(get_current_user)):
verify_permission("admin", requesting_user)
user_service: UserService = container['users']
user_service.add(user_conf)
# TODO 400->403
@router.delete("/{user_id}",
include_in_schema=include_in_schema,
operation_id="deleteUser",
status_code=status.HTTP_200_OK,
summary="Delete configuration of a user. Returns true if the token for the user existed, "
"false if it didn't exist."
)
async def delete_user(user_id: str, requesting_user: User = Depends(get_current_user)):
user_may_delete_own = requesting_user.user_id == user_id
admin_may_delete_everything = "admin" in requesting_user.permissions
is_permitted = user_may_delete_own or admin_may_delete_everything
if not is_permitted:
message = f"User '{requesting_user.user_id} can not delete the configuration for {user_id}"
logger.error(message)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
user_service: UserService = container['users']
agency_exists = user_id in user_service.get_user_ids()
if not agency_exists:
message = f"No config for {user_id}"
logger.error(message)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
user_service.delete(user_id)

View file

@ -12,8 +12,7 @@ class AgencyService:
def __init__(self): def __init__(self):
self.agencies: Dict[str, Agency] = {} self.agencies: Dict[str, Agency] = {}
for agency_file_name in glob('data/agency/*.json'):
for agency_file_name in glob('conf/agency/*.json'):
with open(agency_file_name) as agency_file: with open(agency_file_name) as agency_file:
dict = json.load(agency_file) dict = json.load(agency_file)
agency = Agency(**dict) agency = Agency(**dict)

View file

@ -1,111 +0,0 @@
import json
import os
from glob import glob
from typing import Dict, List
import logging
from fastapi import HTTPException, status
from amarillo.models.AgencyConf import AgencyConf
from amarillo.services.config import config
logger = logging.getLogger(__name__)
agency_conf_directory = 'data/agencyconf'
class AgencyConfService:
def __init__(self):
# Both Dicts to be kept in sync always. The second api_key_to_agency_id is like a reverse
# cache for the first for fast lookup of valid api keys, which happens on *every* request.
self.agency_id_to_agency_conf: Dict[str, AgencyConf] = {}
self.api_key_to_agency_id: Dict[str, str] = {}
for agency_conf_file_name in glob(f'{agency_conf_directory}/*.json'):
with open(agency_conf_file_name) as agency_conf_file:
dictionary = json.load(agency_conf_file)
agency_conf = AgencyConf(**dictionary)
agency_id = agency_conf.agency_id
api_key = agency_conf.api_key
self.agency_id_to_agency_conf[agency_id] = agency_conf
self.api_key_to_agency_id[api_key] = agency_conf.agency_id
def get_agency_conf(self, agency_id: str) -> AgencyConf:
agency_conf = self.agency_id_to_agency_conf.get(agency_id)
return agency_conf
def check_api_key(self, api_key: str) -> str:
"""Check if the API key is valid
The agencies' api keys are checked first, and the admin's key.
The agency_id or "admin" is returned for further checks in the caller if the
request is permitted, like {agency_id} == agency_id.
"""
agency_id = self.api_key_to_agency_id.get(api_key)
is_agency = agency_id is not None
if is_agency:
return agency_id
is_admin = api_key == config.admin_token
if is_admin:
return "admin"
message = "X-API-Key header invalid"
logger.error(message)
raise HTTPException(status_code=400, detail=message)
def add(self, agency_conf: AgencyConf):
agency_id = agency_conf.agency_id
api_key = agency_conf.api_key
agency_id_exists_already = self.agency_id_to_agency_conf.get(agency_id) is not None
if agency_id_exists_already:
message = f"Agency {agency_id} exists already. To update, delete it first."
logger.error(message)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
agency_using_this_api_key_already = self.api_key_to_agency_id.get(api_key)
a_different_agency_is_using_this_api_key_already = \
agency_using_this_api_key_already is not None and \
agency_using_this_api_key_already != agency_id
if a_different_agency_is_using_this_api_key_already:
message = f"Duplicate API Key for {agency_id} not permitted. Use a different key."
logger.error(message)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
with open(f'{agency_conf_directory}/{agency_id}.json', 'w', encoding='utf-8') as f:
f.write(agency_conf.json())
self.agency_id_to_agency_conf[agency_id] = agency_conf
self.api_key_to_agency_id[api_key] = agency_id
logger.info(f"Added configuration for agency {agency_id}.")
def get_agency_ids(self) -> List[str]:
return list(self.agency_id_to_agency_conf.keys())
def delete(self, agency_id):
agency_conf = self.agency_id_to_agency_conf.get(agency_id)
api_key = agency_conf.api_key
del self.api_key_to_agency_id[api_key]
del self.agency_id_to_agency_conf[agency_id]
os.remove(f'{agency_conf_directory}/{agency_id}.json')
logger.info(f"Deleted configuration for agency {agency_id}.")

View file

@ -7,6 +7,6 @@ class Config(BaseSettings):
ride2go_query_data: str ride2go_query_data: str
env: str = 'DEV' env: str = 'DEV'
graphhopper_base_url: str = 'https://api.mfdz.de/gh' graphhopper_base_url: str = 'https://api.mfdz.de/gh'
stop_sources_file: str = 'conf/stop_sources.json' stop_sources_file: str = 'data/stop_sources.json'
config = Config(_env_file='config', _env_file_encoding='utf-8') config = Config(_env_file='config', _env_file_encoding='utf-8')

165
amarillo/services/oauth2.py Normal file
View file

@ -0,0 +1,165 @@
# OAuth2 authentication based on https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/#__tabbed_4_2
from datetime import datetime, timedelta, timezone
from typing import Annotated, Optional, Union
import logging
import logging.config
from fastapi import Depends, HTTPException, Header, status, APIRouter
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import JWTError, jwt
from pydantic import BaseModel
from amarillo.models.User import User
from amarillo.services.passwords import verify_password
from amarillo.utils.container import container
from amarillo.services.agencies import AgencyService
from amarillo.services.users import UserService
from amarillo.models.Carpool import Agency
from amarillo.services.secrets import secrets
SECRET_KEY = secrets.secret_key
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 7*24*60
logging.config.fileConfig('logging.conf', disable_existing_loggers=False)
logger = logging.getLogger("main")
router = APIRouter()
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
user_id: Union[str, None] = None
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
async def verify_optional_api_key(X_API_Key: Optional[str] = Header(None)):
if X_API_Key == None: return None
return await verify_api_key(X_API_Key)
def authenticate_user(user_id: str, password: str):
user_service : UserService = container['users']
user_conf = user_service.user_id_to_user_conf.get(user_id, None)
if not user_conf:
return False
if not verify_password(password, user_conf.password):
return False
return user_id
def create_access_token(data: dict, expires_delta: Union[timedelta, None] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
async def get_current_user(token: str = Depends(oauth2_scheme), user_from_api_key: str = Depends(verify_optional_api_key)) -> User:
if token:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate OAuth2 credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
token_data = TokenData(user_id=user_id)
except JWTError:
raise credentials_exception
user_id = token_data.user_id
if user_id is None:
raise credentials_exception
user_service : UserService = container['users']
return user_service.get_user(user_id)
elif user_from_api_key:
logger.info(f"API Key provided: {user_from_api_key}")
user_service : UserService = container['users']
return user_service.get_user(user_from_api_key)
else:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
raise credentials_exception
def verify_permission(permission: str, user: User):
def permissions_exception():
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"User '{user.user_id}' does not have the permission '{permission}'",
headers={"WWW-Authenticate": "Bearer"},
)
#user is admin
if "admin" in user.permissions: return
#permission is an operation
if ":" not in permission:
if permission not in user.permissions:
raise permissions_exception()
return
#permission is in agency:operation format
def permission_matches(permission, user_permission):
prescribed_agency, prescribed_operation = permission.split(":")
given_agency, given_operation = user_permission.split(":")
return (prescribed_agency == given_agency or given_agency == "all") and (prescribed_operation == given_operation or given_operation == "all")
if any(permission_matches(permission, p) for p in user.permissions if ":" in p): return
raise permissions_exception()
# noinspection PyPep8Naming
# X_API_Key is upper case for OpenAPI
async def verify_api_key(X_API_Key: str = Header(...)):
user_service: UserService = container['users']
return user_service.check_api_key(X_API_Key)
@router.post("/token")
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
) -> Token:
agency = authenticate_user(form_data.username, form_data.password)
if not agency:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": agency}, expires_delta=access_token_expires
)
return Token(access_token=access_token, token_type="bearer")
# TODO: eventually remove this
@router.get("/users/me/", response_model=Agency)
async def read_users_me(
current_agency: Annotated[Agency, Depends(get_current_user)]
):
agency_service : AgencyService = container['agencies']
return agency_service.get_agency(agency_id=current_agency)
# TODO: eventually remove this
@router.get("/users/me/items/")
async def read_own_items(
current_agency: Annotated[str, Depends(get_current_user)]
):
return [{"item_id": "Foo", "owner": current_agency}]

View file

@ -0,0 +1,10 @@
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)

View file

@ -9,8 +9,7 @@ class RegionService:
def __init__(self): def __init__(self):
self.regions: Dict[str, Region] = {} self.regions: Dict[str, Region] = {}
for region_file_name in glob('data/region/*.json'):
for region_file_name in glob('conf/region/*.json'):
with open(region_file_name) as region_file: with open(region_file_name) as region_file:
dict = json.load(region_file) dict = json.load(region_file)
region = Region(**dict) region = Region(**dict)

View file

@ -1,13 +1,10 @@
from typing import Dict from pydantic import Field, ConfigDict
from pydantic import Field
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from typing import Optional
# Example: secrets = { "mfdz": "some secret" } # Example: secrets = { "mfdz": "some secret" }
class Secrets(BaseSettings): class Secrets(BaseSettings):
model_config = ConfigDict(extra='allow')
ride2go_token: str = Field(None, env = 'RIDE2GO_TOKEN') ride2go_token: str = Field(None, env = 'RIDE2GO_TOKEN')
# TODO: define these as required if metrics plugin is installed secret_key: str = Field(None, env = 'SECRET_KEY')
metrics_user: Optional[str] = Field(None, env = 'METRICS_USER')
metrics_password: Optional[str] = Field(None, env = 'METRICS_PASSWORD')
# Read if file exists, otherwise no error (it's in .gitignore) # Read if file exists, otherwise no error (it's in .gitignore)

115
amarillo/services/users.py Normal file
View file

@ -0,0 +1,115 @@
import json
import os
from glob import glob
from typing import Dict, List
import logging
from fastapi import HTTPException, status
from amarillo.models.User import User
from amarillo.services.config import config
from amarillo.services.passwords import get_password_hash
logger = logging.getLogger(__name__)
user_conf_directory = 'data/users'
class UserService:
def __init__(self):
# Both Dicts to be kept in sync always. The second api_key_to_agency_id is like a reverse
# cache for the first for fast lookup of valid api keys, which happens on *every* request.
self.user_id_to_user_conf: Dict[str, User] = {}
self.api_key_to_user_id: Dict[str, str] = {}
for user_conf_file_name in glob(f'{user_conf_directory}/*.json'):
with open(user_conf_file_name) as user_conf_file:
dictionary = json.load(user_conf_file)
user_conf = User(**dictionary)
agency_id = user_conf.user_id
api_key = user_conf.api_key
self.user_id_to_user_conf[agency_id] = user_conf
if api_key is not None:
self.api_key_to_user_id[api_key] = user_conf.user_id
def get_user(self, user_id: str) -> User:
user_conf = self.user_id_to_user_conf.get(user_id)
return user_conf
def check_api_key(self, api_key: str) -> str:
"""Check if the API key is valid
The agencies' api keys are checked first, and the admin's key.
The agency_id or "admin" is returned for further checks in the caller if the
request is permitted, like {agency_id} == agency_id.
"""
agency_id = self.api_key_to_user_id.get(api_key)
is_agency = agency_id is not None
if is_agency:
return agency_id
is_admin = api_key == config.admin_token
if is_admin:
return "admin"
message = "X-API-Key header invalid"
logger.error(message)
raise HTTPException(status_code=400, detail=message)
def add(self, user_conf: User):
user_id = user_conf.user_id
api_key = user_conf.api_key
agency_id_exists_already = self.user_id_to_user_conf.get(user_id) is not None
if agency_id_exists_already:
message = f"Agency {user_id} exists already. To update, delete it first."
logger.error(message)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
agency_using_this_api_key_already = self.api_key_to_user_id.get(api_key)
a_different_agency_is_using_this_api_key_already = \
agency_using_this_api_key_already is not None and \
agency_using_this_api_key_already != user_id
if a_different_agency_is_using_this_api_key_already:
message = f"Duplicate API Key for {user_id} not permitted. Use a different key."
logger.error(message)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
user_conf.password = get_password_hash(user_conf.password)
with open(f'{user_conf_directory}/{user_id}.json', 'w', encoding='utf-8') as f:
f.write(user_conf.json())
self.user_id_to_user_conf[user_id] = user_conf
self.api_key_to_user_id[api_key] = user_id
logger.info(f"Added configuration for user {user_id}.")
def get_user_ids(self) -> List[str]:
return list(self.user_id_to_user_conf.keys())
def delete(self, user_id):
user_conf = self.user_id_to_user_conf.get(user_id)
api_key = user_conf.api_key
del self.api_key_to_user_id[api_key]
del self.user_id_to_user_conf[user_id]
os.remove(f'{user_conf_directory}/{user_id}.json')
logger.info(f"Deleted configuration for {user_id}.")

View file

@ -0,0 +1,37 @@
from fastapi import HTTPException
import pytest
from amarillo.services.oauth2 import verify_permission
from amarillo.models.User import User
test_user = User(user_id="test", password="testpassword", permissions=["all:read", "mfdz:write", "ride2go:all", "gtfs"])
admin_user = User(user_id="admin", password="testpassword", permissions=["admin"])
def test_operation():
verify_permission("gtfs", test_user)
with pytest.raises(HTTPException):
verify_permission("geojson", test_user)
def test_agency_permission():
verify_permission("mvv:read", test_user)
verify_permission("mfdz:read", test_user)
verify_permission("mfdz:write", test_user)
verify_permission("ride2go:write", test_user)
with pytest.raises(HTTPException):
verify_permission("mvv:write", test_user)
verify_permission("mvv:all", test_user)
def test_admin():
verify_permission("admin", admin_user)
verify_permission("gtfs", admin_user)
verify_permission("all:all", admin_user)
verify_permission("mvv:all", admin_user)
verify_permission("mfdz:read", admin_user)
verify_permission("mfdz:write", admin_user)
verify_permission("ride2go:write", admin_user)
with pytest.raises(HTTPException):
verify_permission("admin", test_user)
verify_permission("all:all", test_user)

View file

@ -16,6 +16,9 @@ dependencies = [
"pyproj==3.6.1", "pyproj==3.6.1",
"geojson-pydantic==1.0.1", "geojson-pydantic==1.0.1",
"watchdog==3.0.0", "watchdog==3.0.0",
"python-jose[cryptography]",
"bcrypt==4.0.1",
"passlib[bcrypt]"
] ]
[tool.setuptools.packages] [tool.setuptools.packages]

View file

@ -8,3 +8,6 @@ requests==2.31.0
pyproj==3.6.1 pyproj==3.6.1
geojson-pydantic==1.0.1 geojson-pydantic==1.0.1
pytest pytest
python-jose[cryptography]
bcrypt==4.0.1
passlib[bcrypt]