27 Commits

Author SHA1 Message Date
e871330041 Update flake.lock 2026-06-27 14:59:00 +02:00
2d64edde64 WIP add rpi nixos-config 2026-06-27 14:50:50 +02:00
17b99041db Add test for /api/v1/users/current 2026-06-24 16:01:36 +02:00
17842a14fd Fix tests using the new api root 2026-06-24 15:54:06 +02:00
bc663582b5 Add get current user
Fixes #7
2026-06-24 15:38:19 +02:00
5f0e5b1bbb Move .env.example to the right place, fix login to also be at /api/v1 2026-06-24 15:37:32 +02:00
62bbbec1d3 Move api to /api/v1
This includes docs, which are now at http://127.0.0.1:8000/api/v1/docs
and the openapi.json, now at http://127.0.0.1:8000/api/v1/openapi.json

Fixes #4
2026-06-24 15:16:13 +02:00
336c6aa34e Add CORS middleware 2026-06-12 13:52:44 +02:00
5e8c826714 Fix issue #3 : allow DISABLE_CARDS envvar 2026-06-12 13:32:55 +02:00
9985251584 Add testing keys in .env.example 2026-06-06 11:18:24 +02:00
871e3f4d0a Update current issues/todo 2026-06-04 15:16:11 +02:00
427e47534a Change status code of 'AA not assigned to group' 2026-06-04 15:05:42 +02:00
d00c5855b7 Add some debug functions for testing 2026-05-26 23:04:58 +02:00
44ea17d87a Improve logging a bit 2026-05-25 16:23:28 +02:00
9160b312c7 Fix the things i broke using logger.info 2026-05-23 20:35:12 +02:00
07669fc1fc I don't know how to test hardware functions 2026-05-23 20:27:16 +02:00
94d428c9d0 Change all instances of print() to logger.info() 2026-05-23 20:17:43 +02:00
a4bedba628 Remove mqtt 2026-05-23 20:09:12 +02:00
e6a248529b Fix test test_create_first_user() 2026-05-23 20:08:10 +02:00
713d41e81c Add door.py tests 2026-05-23 19:49:02 +02:00
6ec3bc5f14 Improve assign_accessauth test and change already assigned to 409 2026-05-23 18:58:59 +02:00
9e6510f465 Remove unused test 2026-05-23 18:58:20 +02:00
5a6cd970dd Fix change_accessauth() not changing timetables
Also fix the test not actually checking the response :/
2026-05-23 18:41:50 +02:00
3d1893d84e Fix door check db session 2026-05-23 17:32:28 +02:00
495535a6de Scanner write-read-delete workflow working! 2026-05-23 00:30:03 +02:00
30b86dad5e Increase login time 2026-05-22 23:14:55 +02:00
92eef82e0a Slight improvements 2026-05-22 21:56:30 +02:00
26 changed files with 676 additions and 311 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@ __pycache__
gatekeeper.db gatekeeper.db
.env .env
.coverage .coverage
result

View File

@@ -10,8 +10,19 @@ start prod server `uv run fastapi run`
You need to set services.pcscd.enable = true; for the smartcard reader to work You need to set services.pcscd.enable = true; for the smartcard reader to work
Issues: Issues:
- documentation missing
- `nix run` currently broken - `nix run` currently broken
- raspberry pi image not working
- no door state - no door state
- no door operations - no door operations
- card system is dummy until I get hardware - hardcoded secret key in auth.py -> centralise env var loading
- i don't like the error handling in the scanner - doesn't pass errors correctly
- cors for frontend: https://fastapi.tiangolo.com/tutorial/cors
- Load cors from env var or something
- BackgroundScanner shouldn't get a single session for the whole lifecycle
- input validation maybe
- too many imports
- inconsistent logging (request logging?)
- rate limiting maybe
- pretty sure the controllers are doing too much stuff
- hardware call tests or sth

9
app/.env.example Normal file
View File

@@ -0,0 +1,9 @@
MIFARE_APP_MASTER_KEY="7b195adb892073c9e34ebe7e0ca28b2b"
# 16 bytes AES key
MIFARE_ACL_READ_BASE_KEY="741a50f2b189cc9f151e0600f50a6e1a"
# 16 bytes AES key
MIFARE_ACL_WRITE_BASE_KEY="f1aa99f81cca268de98d422ee0ccb65c"
# 16 bytes AES key
SECRET_KEY="8b14d0b447bff7efa24d5019cc59a999786e31f6f865173bbd642bf18de5ad85"
#Key for oauth
#THESE ARE TESTING KEYS - DO NOT USE IN PROD

View File

@@ -1,4 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException import logging
logger = logging.getLogger(__name__)
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select from sqlmodel import Session, select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from typing import List from typing import List
@@ -8,11 +10,11 @@ from ..services.database import engine, get_session, add_and_refresh
from ..services.auth import auth_is_admin from ..services.auth import auth_is_admin
import uuid as gen_uuid import uuid as gen_uuid
aa_router = APIRouter(prefix="/aa", tags=["AccessAuth"]) aa_router = APIRouter(prefix="/api/v1/aa", tags=["AccessAuth"])
@aa_router.post("/", response_model=AccessAuthorizationResponse) @aa_router.post("/", response_model=AccessAuthorizationResponse)
def add_accessauth(*, db: Session = Depends(get_session), aa: AccessAuthorizationCreate, admin: bool = Depends(auth_is_admin)): def add_accessauth(*, db: Session = Depends(get_session), aa: AccessAuthorizationCreate, admin: bool = Depends(auth_is_admin)):
print("Creating accessauth with data: ", aa) logger.info(f"Creating accessauth with data: {aa}")
timetables = [Timetable.model_validate(t) for t in aa.timetables] timetables = [Timetable.model_validate(t) for t in aa.timetables]
db_aa = AccessAuthorizationDB( db_aa = AccessAuthorizationDB(
name=aa.name, name=aa.name,
@@ -44,7 +46,7 @@ def assign_accessauth(*, db: Session = Depends(get_session), group_id: int, aa_i
if db_aa is None: if db_aa is None:
raise HTTPException(status_code=404, detail="AA not found") raise HTTPException(status_code=404, detail="AA not found")
if db_aa in db_group.accessauths: if db_aa in db_group.accessauths:
raise HTTPException(status_code=200, detail="AA already assigned to group") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="AA already assigned to group")
db_group.accessauths.append(db_aa) db_group.accessauths.append(db_aa)
return add_and_refresh(db, db_group) return add_and_refresh(db, db_group)
@@ -57,7 +59,7 @@ def unassign_accessauth(*, db: Session = Depends(get_session), group_id: int, aa
if db_aa is None: if db_aa is None:
raise HTTPException(status_code=404, detail="AA not found") raise HTTPException(status_code=404, detail="AA not found")
if db_aa not in db_group.accessauths: if db_aa not in db_group.accessauths:
raise HTTPException(status_code=200, detail="AA not assigned to group") raise HTTPException(status_code=404, detail="AA not assigned to group")
db_group.accessauths.remove(db_aa) db_group.accessauths.remove(db_aa)
return add_and_refresh(db, db_group) return add_and_refresh(db, db_group)
@@ -66,7 +68,12 @@ def change_accessauth(*, db: Session = Depends(get_session), aa_id: int, aa: Acc
db_aa = db.get(AccessAuthorizationDB, aa_id) db_aa = db.get(AccessAuthorizationDB, aa_id)
if db_aa is None: if db_aa is None:
raise HTTPException(status_code=404, detail="AccessAuthorization not found") raise HTTPException(status_code=404, detail="AccessAuthorization not found")
aa_data = aa.dict(exclude_unset=True) aa_data = aa.model_dump(exclude_unset=True)
if "timetables" in aa_data and aa_data["timetables"] is not None:
db_aa.timetables.clear()
timetables = [Timetable.model_validate(t) for t in aa_data["timetables"]]
db_aa.timetables = timetables
aa_data.pop("timetables")
db_aa.sqlmodel_update(aa_data) db_aa.sqlmodel_update(aa_data)
return add_and_refresh(db, db_aa) return add_and_refresh(db, db_aa)

View File

@@ -1,6 +1,9 @@
import logging
logger = logging.getLogger(__name__)
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select from sqlmodel import Session, select
from typing import List from typing import List
from sqlalchemy.exc import NoResultFound
from ..model.models import Card from ..model.models import Card
from ..services.database import engine, get_session, add_and_refresh from ..services.database import engine, get_session, add_and_refresh
@@ -8,12 +11,12 @@ from ..services.auth import auth_is_admin
import uuid as gen_uuid import uuid as gen_uuid
from app.services.scanner import WriteNewCard, DeleteCard from app.services.scanner import WriteNewCard, DeleteCard
card_router = APIRouter(prefix="/cards", tags=["Card"]) card_router = APIRouter(prefix="/api/v1/cards", tags=["Card"])
def register_card(group_id: int): def register_card(group_id: int):
key = WriteNewCard() key = WriteNewCard()
if key == None: if key == None:
print("No card registered. Check logs!") logger.info("No card registered. Check logs!")
raise HTTPException(status.HTTP_417_EXPECTATION_FAILED, detail="No card registered. Check logs!") raise HTTPException(status.HTTP_417_EXPECTATION_FAILED, detail="No card registered. Check logs!")
card = Card(group_id=group_id, uuid=key) card = Card(group_id=group_id, uuid=key)
return card return card
@@ -26,13 +29,16 @@ def add_card(*, db: Session = Depends(get_session), group_id: int, admin: bool =
@card_router.get("/delete") @card_router.get("/delete")
def del_card(*, db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)): def del_card(*, db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)):
key = DeleteCard() key = DeleteCard()
# card = db.get(Card, card_id) logger.info(key)
# if card is None: try:
# raise HTTPException(status_code=404, detail="Card not found") card = db.exec(select(Card).where(Card.uuid == key)).one()
# db.delete(card) except NoResultFound:
# db.commit() logger.info(f"The key:'{key}' was not found in db!")
# return {"message": "Card deleted successfully"} raise HTTPException(status_code=500, detail="Key on card not found in DB. Please tell an admin about this. KEY={key}")
##TBH not a big fan of having creation using group_id but deletion using card_id db.delete(card)
db.commit()
return {"message": "Card deleted successfully"}
@card_router.get("/{group_id}", response_model=List[Card]) @card_router.get("/{group_id}", response_model=List[Card])
def get_cards(*, db: Session = Depends(get_session), group_id: int, admin: bool = Depends(auth_is_admin)): def get_cards(*, db: Session = Depends(get_session), group_id: int, admin: bool = Depends(auth_is_admin)):
cards = db.exec(select(Card).where(Card.group_id == group_id)).all() cards = db.exec(select(Card).where(Card.group_id == group_id)).all()

View File

@@ -0,0 +1,37 @@
import logging
logger = logging.getLogger(__name__)
from fastapi import APIRouter, Depends, HTTPException
from app.services.auth import auth_is_admin
from sqlmodel import Session, select
from app.model.models import *
from app.services.database import get_session, add_and_refresh
debug_router = APIRouter(
prefix="/api/v1/debug",
tags=["Debug items - maybe dont show this in UI"],
dependencies=[Depends(auth_is_admin)],
)
@debug_router.get("/addcard/{groupid}/{card_key}")
def add_card_manually(groupid: int, card_key: str, db: Session=Depends(get_session)):
"""Add cards manually (you also have to delete them manually)"""
logger.critical(f"Manual db change: adding a card with key: {card_key} to group: {groupid}")
card = Card(group_id=groupid, uuid=card_key)
add_and_refresh(db, card)
return card
@debug_router.get("/rmcard/{card_key}")
def remove_card_manually(card_key: str, db: Session = Depends(get_session)):
logger.critical(f"Manual db change: removing a card with key: {card_key}")
card = db.exec(select(Card).where(Card.uuid == card_key)).one()
add_and_refresh(db, card)
@debug_router.put("/getcards")
def list_all_cards(db: Session = Depends(get_session)):
logger.info(f"Debug Setting: Getting cards.")
cards = db.exec(select(Card)).all()
print(cards)
out = []
for i in cards:
out.append({i.uuid: i.group.name})
return out

View File

@@ -5,7 +5,7 @@ from app.services.database import get_session
from app.services.auth import auth_is_admin from app.services.auth import auth_is_admin
import app.services.door as doorService import app.services.door as doorService
door_router = APIRouter(prefix="/door",tags=["Door"]) door_router = APIRouter(prefix="/api/v1/door",tags=["Door"])
@door_router.put("/open") @door_router.put("/open")
def open_door(db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)): def open_door(db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)):

View File

@@ -6,7 +6,7 @@ from ..model.models import GroupDB, GroupResponse, GroupCreate
from ..services.database import engine, get_session, add_and_refresh from ..services.database import engine, get_session, add_and_refresh
from ..services.auth import auth_is_admin from ..services.auth import auth_is_admin
group_router = APIRouter(prefix="/groups", tags=["Group"]) group_router = APIRouter(prefix="/api/v1/groups", tags=["Group"])
@group_router.get("/", response_model=List[GroupResponse]) @group_router.get("/", response_model=List[GroupResponse])
def get_groups(*, db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)): def get_groups(*, db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)):

View File

@@ -1,33 +1,39 @@
import logging
logger = logging.getLogger(__name__)
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends
from sqlmodel import Session, select from sqlmodel import Session, select
from typing import List from typing import List
from ..model.models import UserResponse, UserCreate, UserDB, UserUpdate from ..model.models import UserResponse, UserCreate, UserDB, UserUpdate
from ..services.database import engine, get_session, add_and_refresh from ..services.database import engine, get_session, add_and_refresh
from ..services.auth import get_password_hash, get_current_user, auth_is_admin from ..services.auth import get_password_hash, get_current_user as auth_user, auth_is_admin
user_router = APIRouter(tags=["Users"]) user_router = APIRouter(tags=["Users"], prefix="/api/v1/users")
@user_router.post("/users/", response_model=UserResponse) @user_router.post("/", response_model=UserResponse)
def create_user(*, db: Session = Depends(get_session), user: UserCreate, admin: bool = Depends(auth_is_admin)): def create_user(*, db: Session = Depends(get_session), user: UserCreate, admin: bool = Depends(auth_is_admin)):
print("creating user with data ", user) logger.info(f"creating user with data: {user}")
hashed_password = {"passwordhash": get_password_hash(user.password)} hashed_password = {"passwordhash": get_password_hash(user.password)}
db_user = UserDB.model_validate(user, update=hashed_password) db_user = UserDB.model_validate(user, update=hashed_password)
return add_and_refresh(db, db_user) return add_and_refresh(db, db_user)
@user_router.get("/users/", response_model=List[UserResponse]) @user_router.get("/", response_model=List[UserResponse])
def read_users(*, db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)): def read_users(*, db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)):
users = db.exec(select(UserDB)).all() users = db.exec(select(UserDB)).all()
return users return users
@user_router.get("/users/{user_id}", response_model=UserResponse) @user_router.get("/current", response_model=UserResponse)
def get_current_user(db: Session = Depends(get_session), user: UserDB = Depends(auth_user)):
return user
@user_router.get("/{user_id}", response_model=UserResponse)
def read_user(*, db: Session = Depends(get_session), user_id: int, admin: bool = Depends(auth_is_admin)): def read_user(*, db: Session = Depends(get_session), user_id: int, admin: bool = Depends(auth_is_admin)):
db_user = db.get(UserDB, user_id) db_user = db.get(UserDB, user_id)
if db_user is None: if db_user is None:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
return db_user return db_user
@user_router.patch("/users/{user_id}", response_model=UserResponse) @user_router.patch("/{user_id}", response_model=UserResponse)
def update_user(*, db: Session = Depends(get_session), user_id: int, user: UserUpdate, admin: bool = Depends(auth_is_admin)): def update_user(*, db: Session = Depends(get_session), user_id: int, user: UserUpdate, admin: bool = Depends(auth_is_admin)):
db_user = db.get(UserDB, user_id) db_user = db.get(UserDB, user_id)
if db_user is None: if db_user is None:
@@ -40,11 +46,12 @@ def update_user(*, db: Session = Depends(get_session), user_id: int, user: UserU
db_user.sqlmodel_update(user_data, update=hashed_password) db_user.sqlmodel_update(user_data, update=hashed_password)
return add_and_refresh(db, db_user) return add_and_refresh(db, db_user)
@user_router.delete("/users/{user_id}") @user_router.delete("/{user_id}")
def delete_user(*, db: Session = Depends(get_session), user_id: int, admin: bool = Depends(auth_is_admin)): def delete_user(*, db: Session = Depends(get_session), user_id: int, admin: bool = Depends(auth_is_admin)):
db_user = db.get(UserDB, user_id) db_user = db.get(UserDB, user_id)
if db_user is None: if db_user is None:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
db.delete(db_user) db.delete(db_user)
db.commit() db.commit()
return {"message": "User deleted successfully"} return {"message": "User deleted successfully"}

View File

@@ -1,33 +1,65 @@
import logging
logger = logging.getLogger(__name__)
import os
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dotenv import load_dotenv from dotenv import load_dotenv
from .controllers import userManager, cardManager, groupManager, aaManager, doorManager from .controllers import userManager, cardManager, groupManager, aaManager, doorManager, debugManager
from .services.database import create_db_and_tables, get_session from .services.database import create_db_and_tables, get_db_session
from .services.auth import token_router, create_first_user from .services.auth import token_router, create_first_user
from app.services.scanner import BackgroundScanner from app.services.scanner import BackgroundScanner
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
scanner = BackgroundScanner(db=get_session()) scanner = BackgroundScanner(db=get_db_session())
logging.basicConfig(level=logging.INFO)
def checkDeps():
load_dotenv()
MIFARE_APP_MASTER_KEY = os.getenv('MIFARE_APP_MASTER_KEY')
if not MIFARE_APP_MASTER_KEY:
logger.critical(f"MIFARE APP MASTER KEY not found!")
logger.critical("Writing and reading cards is disabled!")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
load_dotenv() checkDeps()
create_db_and_tables() create_db_and_tables()
create_first_user() create_first_user(db=get_db_session())
print("Database created and tables initialized.") logger.info("Database created and tables initialized.")
scanner.start() disableCards = os.getenv("DISABLE_CARDS")
if not disableCards:
scanner.start()
yield yield
#scanner.stop() #scanner.stop()
app = FastAPI(lifespan=lifespan) app = FastAPI(
lifespan=lifespan,
docs_url="/api/v1/docs",
openapi_url="/api/v1/openapi.json"
)
origins = [
"http://127.0.0.1",
"http://localhost",
"http://localhost:8080",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["GET" "PUT" "POST" "DELETE" "PATCH"],
allow_headers=["*"],
)
app.include_router(token_router) app.include_router(token_router)
app.include_router(userManager.user_router) app.include_router(userManager.user_router)
app.include_router(groupManager.group_router) app.include_router(groupManager.group_router)
app.include_router(cardManager.card_router) app.include_router(cardManager.card_router)
app.include_router(aaManager.aa_router) app.include_router(aaManager.aa_router)
app.include_router(doorManager.door_router) app.include_router(doorManager.door_router)
app.include_router(debugManager.debug_router)

View File

@@ -1,3 +1,5 @@
import logging
logger = logging.getLogger(__name__)
from typing import Annotated from typing import Annotated
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException, Depends, status from fastapi import APIRouter, HTTPException, Depends, status
@@ -8,15 +10,18 @@ import jwt
from jwt.exceptions import InvalidTokenError from jwt.exceptions import InvalidTokenError
from ..model.models import UserDB, Token, TokenData, UserCreate from ..model.models import UserDB, Token, TokenData, UserCreate
from ..services.database import * from ..services.database import *
import secrets, string import secrets, string, os
SECRET_KEY = "8b14d0b447bff7efa24d5019cc59a999786e31f6f865173bbd642bf18de5ad85" #Encrypt and change later or store in env file or somehthing from dotenv import load_dotenv
load_dotenv()
SECRET_KEY = os.getenv("SECRET_KEY", default="ff"*16)
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30 ACCESS_TOKEN_EXPIRE_MINUTES = 120
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/token")
token_router = APIRouter(tags=["Token"]) token_router = APIRouter(tags=["Token"], prefix="/api/v1")
password_hash = PasswordHash.recommended() password_hash = PasswordHash.recommended()
@@ -83,20 +88,19 @@ def auth_is_admin(
) )
return True return True
def create_first_user(): def create_first_user(db: Session):
print("Checking for admin user") logger.info("Checking for admin user")
with Session(engine) as db: admin_user = db.exec(select(UserDB)).first()
admin_user = db.exec(select(UserDB)).first() if admin_user is None:
if admin_user is None: password = ''.join(secrets.choice(string.digits) for i in range(8))
password = ''.join(secrets.choice(string.digits) for i in range(8)) logger.info(f"Creating first admin user with password: {password}")
print("Creating first admin user with password", password) user = UserDB(
user = UserDB( name="admin",
name="admin", passwordhash=get_password_hash(password),
passwordhash=get_password_hash(password), is_admin=True
is_admin=True )
) return add_and_refresh(db, user)
return add_and_refresh(db, user) logger.info(f"Admin user already exists: {admin_user.name}")
print(f"Admin user already exists: {admin_user.name}")
@token_router.post("/token") @token_router.post("/token")

View File

@@ -13,6 +13,9 @@ def get_session():
with Session(engine) as db: with Session(engine) as db:
yield db yield db
def get_db_session():
return Session(engine)
def add_and_refresh(db: Session, obj): def add_and_refresh(db: Session, obj):
db.add(obj) db.add(obj)
db.commit() db.commit()

View File

@@ -1,7 +1,5 @@
import logging
import paho.mqtt.client as mqttClient logger = logging.getLogger(__name__)
import paho.mqtt.publish as publish
from sqlmodel import select from sqlmodel import select
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -12,43 +10,43 @@ from app.services.database import Session, get_session
from app.model.models import * from app.model.models import *
doorIsOpen = True doorIsOpen = True
client = mqttClient.Client(client_id="", userdata=None, protocol=mqttClient.MQTTv5)
client.tls_set(tls_version=mqttClient.ssl.PROTOCOL_TLS)
client.username_pw_set("username", "passwort")
#client.connect("host", port=8883)
# I think this could also be gpio controlled # I think this could also be gpio controlled
#See: https://github.com/technyon/nuki_hub#gpio-lock-control-optional #See: https://github.com/technyon/nuki_hub#gpio-lock-control-optional
def openDoor(): def openDoor():
global doorIsOpen
doorIsOpen = True doorIsOpen = True
publish.single(topic="/lock/action", payload="unlock") logger.info("Still needs gpio out")
pass pass
def closeDoor(): def closeDoor():
global doorIsOpen
doorIsOpen = False doorIsOpen = False
publish.single(topic="/lock/action", payload="lock") logger.info("Still needs gpio out")
pass pass
def isDoorOpen(): def isDoorOpen():
return doorIsOpen return doorIsOpen
def checkAccess(uuid: str, db: Session = Depends(get_session)): def checkAccess(uuid: str, db: Session):
try: try:
current_weekday = datetime.datetime.weekday(datetime.date.today()) current_weekday = datetime.datetime.weekday(datetime.date.today())
current_time = datetime.datetime.now() current_time = datetime.datetime.now()
card = db.exec(select(Card).where(Card.uuid == uuid)).one() card = db.exec(select(Card).where(Card.uuid == uuid)).one()
for auth in card.group.accessauths: for auth in card.group.accessauths:
print(f"checking auth: {auth.name}") logger.info(f"checking auth: {auth.name}")
for timetable in auth.timetables: for timetable in auth.timetables:
print(f" checking timetable {timetable.id}") logger.info(f" checking timetable {timetable.id}")
print(f" comparing weekday: CUR:{current_weekday} TT:{timetable.weekday}") logger.info(f" comparing weekday: CUR:{current_weekday} TT:{timetable.weekday}")
if current_weekday == timetable.weekday: if current_weekday == timetable.weekday:
starttime = datetime.datetime.combine(datetime.date.today(), timetable.starttime) starttime = datetime.datetime.combine(datetime.date.today(), timetable.starttime)
endtime = starttime + datetime.timedelta(minutes=timetable.duration) endtime = starttime + datetime.timedelta(minutes=timetable.duration)
print(f" comparing time: Start:{starttime} Current:{current_time} End:{endtime}") logger.info(f" comparing time: Start:{starttime} Current:{current_time} End:{endtime}")
if starttime < current_time < endtime: if starttime < current_time < endtime:
logger.info("Access Valid!")
return True return True
logger.info("No more auths found")
return False return False
except exc.NoResultFound: except exc.NoResultFound:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise Exception("No Access with that key found, this might be a db error")

View File

@@ -1,4 +1,6 @@
import logging import logging
logger = logging.getLogger(__name__)
import threading import threading
import time import time
import os import os
@@ -18,6 +20,8 @@ from desfire.enums import DESFireCommunicationMode, DESFireFileType, DESFireKeyS
from desfire.schemas import FilePermissions, FileSettings, KeySettings from desfire.schemas import FilePermissions, FileSettings, KeySettings
import desfire.exceptions as desExceptions import desfire.exceptions as desExceptions
from app.services.door import openDoor, closeDoor, isDoorOpen, checkAccess
#ENV vars #ENV vars
load_dotenv() load_dotenv()
@@ -32,10 +36,6 @@ MIFARE_ACL_WRITE_BASE_KEY_ID = 0x2
MIFARE_SYS_ID = "FF0000" # 3 bytes, can essentially be anything MIFARE_SYS_ID = "FF0000" # 3 bytes, can essentially be anything
MIFARE_ENCRYPTED_FILE_ID = 0x1 MIFARE_ENCRYPTED_FILE_ID = 0x1
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def checkForKey(): def checkForKey():
if MIFARE_APP_MASTER_KEY == None: if MIFARE_APP_MASTER_KEY == None:
logger.critical("NO MASTER KEY LOADED") logger.critical("NO MASTER KEY LOADED")
@@ -44,34 +44,83 @@ def checkForKey():
def getCardService(timeout: int = 10): def getCardService(timeout: int = 10):
cardtype = AnyCardType() cardtype = AnyCardType()
cardrequest = CardRequest(timeout=timeout, cardType=cardtype) cardrequest = CardRequest(timeout=timeout, cardType=cardtype)
print("Please present DESfire tag...") cardservice = cardrequest.waitforcard()
try: cardservice.connection.connect()
cardservice = cardrequest.waitforcard()
except CardRequestTimeoutException:
logger.error("No tag detected within the timeout.")
raise Exception
return cardservice return cardservice
def readFileOnCard(desfire: DESFire):
if not MIFARE_ACL_READ_BASE_KEY:
logger.critical("MIFARE_ACL_READ_BASE_KEY not found! Reading skipped!")
return
#create keys
#desfire = DESFire(PCSCDevice(cardservice.connection.component))
aes_keysettings = KeySettings(key_type=DESFireKeyType.DF_KEY_AES)
keysettings = desfire.get_key_setting()
desKey = DESFireKey(keysettings, "00" * 8)
# Get real UID
desfire.authenticate(0x0, desKey)
#To get the uid you have to auth with an empty (default) key
uid = desfire.get_real_uid()
applications = desfire.get_application_ids()
try:
assert len(applications) == 1
assert applications[0] == get_list(MIFARE_APP_ID)
except AssertionError:
logger.error("No application found!")
time.sleep(4)
return
#Then use the key derivation with that uid, the appid, the sysid
diversification_data = [0x01] + uid + get_list(MIFARE_APP_ID) + get_list(MIFARE_SYS_ID)
read_div_key_bytes = diversify_key(get_list(MIFARE_ACL_READ_BASE_KEY), diversification_data, pad_to_32=False)
#Log in with derived read key
logger.debug("Start auth")
aes_app_read_key = DESFireKey(aes_keysettings, read_div_key_bytes)
desfire.select_application(MIFARE_APP_ID)
desfire.authenticate(MIFARE_ACL_READ_BASE_KEY_ID, aes_app_read_key)
logger.debug(f"Read data from {MIFARE_ENCRYPTED_FILE_ID}")
file_data = desfire.get_file_settings(MIFARE_ENCRYPTED_FILE_ID)
rdata = desfire.read_file_data(MIFARE_ENCRYPTED_FILE_ID, file_data)
#convert list of int to str
rdata = to_hex_string(rdata).replace(" ", "").lower()
logger.debug(f"Data on card: {rdata}")
return rdata
def DeleteCard(): def DeleteCard():
try: try:
checkForKey() checkForKey()
from app.main import scanner as scannerThread from app.main import scanner as scannerThread
scannerThread.stop() scannerThread.stop()
cardservice = getCardService(15) cardservice = getCardService(15)
cardservice.connection.connect()
# Create Desfire object # Create Desfire object
desfire = DESFire(PCSCDevice(cardservice.connection.component)) desfire = DESFire(PCSCDevice(cardservice.connection.component))
rdata = readFileOnCard(desfire=desfire)
# Create Key objects # Create Key objects
AES_NULL_KEY_DATA = "00" * 8 aes_keysettings = KeySettings(key_type=DESFireKeyType.DF_KEY_AES)
aes_keysettings = KeySettings( des_keysettings = KeySettings(key_type=DESFireKeyType.DF_KEY_2K3DES)
key_type=DESFireKeyType.DF_KEY_AES, desKey = DESFireKey(des_keysettings, "00" * 8)
)
key_settings = desfire.get_key_setting()
aes_null_key = DESFireKey(key_settings, AES_NULL_KEY_DATA)
aes_master_key = DESFireKey(aes_keysettings, MIFARE_APP_MASTER_KEY) aes_master_key = DESFireKey(aes_keysettings, MIFARE_APP_MASTER_KEY)
desfire.authenticate(0x0, aes_null_key) aes_null_key = DESFireKey(aes_keysettings, "00" * 16)
desfire.select_application(0x0)
try:
try:
logger.debug("Auth1")#
desfire.authenticate(0x0, aes_master_key)
except:
logger.debug("Auth2")
desfire.authenticate(0x0, aes_null_key)
except:
logger.debug("Auth3")
desfire.authenticate(0x0, desKey)
applications = desfire.get_application_ids() applications = desfire.get_application_ids()
logger.debug(f"Applications: {applications}") logger.debug(f"Applications: {applications}")
if len(applications) == 0: if len(applications) == 0:
@@ -79,13 +128,15 @@ def DeleteCard():
desfire.select_application(MIFARE_APP_ID) desfire.select_application(MIFARE_APP_ID)
desfire.authenticate(0x0, aes_master_key) desfire.authenticate(0x0, aes_master_key)
try: try:
desfire.delete_application(MIFARE_APP_ID) desfire.delete_application(MIFARE_APP_ID)
logger.info("App deleted!")
except Exception: except Exception:
pass pass
scannerThread.start() scannerThread.start()
return rdata
except Exception as e: except(Exception, AssertionError) as e:
logger.error(f"Error in deletion function: {e}", exc_info=True) logger.error(f"Error in deletion function: {e}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error: {e}") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error: {e}")
@@ -95,43 +146,29 @@ def WriteNewCard():
from app.main import scanner as scannerThread from app.main import scanner as scannerThread
scannerThread.stop() scannerThread.stop()
cardtype = AnyCardType() cardservice = getCardService(20)
cardrequest = CardRequest(timeout=10, cardType=cardtype)
print("Please present DESfire tag...")
try:
cardservice = cardrequest.waitforcard()
except CardRequestTimeoutException:
logger.error("No tag detected within the timeout.")
return None
cardservice.connection.connect()
# Create Desfire object
desfire = DESFire(PCSCDevice(cardservice.connection.component)) desfire = DESFire(PCSCDevice(cardservice.connection.component))
# Create Key objects # Create Key objects
AES_NULL_KEY_DATA = "00" * 16 aes_keysettings = KeySettings(key_type=DESFireKeyType.DF_KEY_AES)
aes_keysettings = KeySettings( aes_null_key = DESFireKey(aes_keysettings, "00" * 16)
key_type=DESFireKeyType.DF_KEY_AES,
)
aes_null_key = DESFireKey(aes_keysettings, AES_NULL_KEY_DATA)
aes_master_key = DESFireKey(aes_keysettings, MIFARE_APP_MASTER_KEY) aes_master_key = DESFireKey(aes_keysettings, MIFARE_APP_MASTER_KEY)
keysetting = desfire.get_key_setting() desKey = DESFireKey(desfire.get_key_setting(), "00" * 8)
desKey = DESFireKey(keysetting, "00" * 8)
# Authenticate with default DES key # Authenticate with default DES key
print("Authenticating with default DES key...") logger.debug("Authenticating with default DES key...")
desfire.authenticate(0x0, desKey) desfire.authenticate(0x0, desKey)
#get uid #get uid
uid = desfire.get_real_uid() uid = desfire.get_real_uid()
# Set default key # Set default key
print("Setting default key...") logger.debug("Setting default key...")
desfire.change_default_key(aes_null_key, 0x0) desfire.change_default_key(aes_null_key, 0x0)
# Create application # Create application
print("Creating application...") logger.debug("Creating application...")
app_settings = KeySettings( app_settings = KeySettings(
settings=[ settings=[
DESFireKeySettings.KS_ALLOW_CHANGE_MK, DESFireKeySettings.KS_ALLOW_CHANGE_MK,
@@ -147,34 +184,34 @@ def WriteNewCard():
applications = desfire.get_application_ids() applications = desfire.get_application_ids()
assert len(applications) == 1 assert len(applications) == 1
assert applications[0] == get_list(MIFARE_APP_ID) assert applications[0] == get_list(MIFARE_APP_ID)
print(" - Application created successfully.") logger.debug(" - Application created successfully.")
# Select application # Select application
desfire.select_application(MIFARE_APP_ID) desfire.select_application(MIFARE_APP_ID)
#Auth again as 0key
aes_null_auth_key = DESFireKey(aes_keysettings, AES_NULL_KEY_DATA)
desfire.authenticate(0x0, aes_null_auth_key)
# Authenticate with AES key, as this has been set as the default key
aes_app_mk = DESFireKey(aes_keysettings, MIFARE_APP_MASTER_KEY)
desfire.change_key(0x0, aes_null_key, aes_app_mk, 0x1)
print("new key auth") #recreate key object
desfire.authenticate(0x0, aes_app_mk) desfire.authenticate(0x0, aes_null_key)
desfire.change_key(0x0, aes_null_key, aes_master_key, 0x1)
logger.debug("new key auth")
desfire.authenticate(0x0, aes_master_key)
aes_null_key = DESFireKey(aes_keysettings, "00" * 16)
#generate div data #generate div data
diversification_data = [0x01] + uid + get_list(MIFARE_APP_ID) + get_list(MIFARE_SYS_ID) diversification_data = [0x01] + uid + get_list(MIFARE_APP_ID) + get_list(MIFARE_SYS_ID)
read_div_key_bytes = diversify_key(get_list(MIFARE_ACL_READ_BASE_KEY), diversification_data, pad_to_32=False) read_div_key_bytes = diversify_key(get_list(MIFARE_ACL_READ_BASE_KEY), diversification_data, pad_to_32=False)
write_div_key_bytes = diversify_key(get_list(MIFARE_ACL_WRITE_BASE_KEY), diversification_data, pad_to_32=False) write_div_key_bytes = diversify_key(get_list(MIFARE_ACL_WRITE_BASE_KEY), diversification_data, pad_to_32=False)
print("Changing file read key...") logger.debug("Changing file read key...")
aes_file_read_key = DESFireKey(aes_keysettings, read_div_key_bytes) aes_file_read_key = DESFireKey(aes_keysettings, read_div_key_bytes)
desfire.change_key(MIFARE_ACL_READ_BASE_KEY_ID, aes_null_key, aes_file_read_key, 0x1) desfire.change_key(MIFARE_ACL_READ_BASE_KEY_ID, aes_null_key, aes_file_read_key, 0x1)
print("Changing file write key...") logger.debug("Changing file write key...")
aes_file_write_key = DESFireKey(aes_keysettings, write_div_key_bytes) aes_file_write_key = DESFireKey(aes_keysettings, write_div_key_bytes)
desfire.change_key(MIFARE_ACL_WRITE_BASE_KEY_ID, aes_null_key, aes_file_write_key, 0x1) desfire.change_key(MIFARE_ACL_WRITE_BASE_KEY_ID, aes_null_key, aes_file_write_key, 0x1)
print("Create encrypted file containing UUID...") logger.debug("Create encrypted file containing key...")
file_settings = FileSettings( file_settings = FileSettings(
file_size=16, file_size=16,
encryption=DESFireCommunicationMode.ENCRYPTED, encryption=DESFireCommunicationMode.ENCRYPTED,
@@ -185,25 +222,16 @@ def WriteNewCard():
file_type=DESFireFileType.MDFT_STANDARD_DATA_FILE, file_type=DESFireFileType.MDFT_STANDARD_DATA_FILE,
) )
desfire.create_standard_file(MIFARE_ENCRYPTED_FILE_ID, file_settings) desfire.create_standard_file(MIFARE_ENCRYPTED_FILE_ID, file_settings)
print("Read and verify file settings again...")
file_data = desfire.get_file_settings(MIFARE_ENCRYPTED_FILE_ID) file_data = desfire.get_file_settings(MIFARE_ENCRYPTED_FILE_ID)
assert file_data.file_size == 16
assert file_data.encryption == DESFireCommunicationMode.ENCRYPTED
assert file_data.permissions is not None
assert file_data.permissions.read_access == MIFARE_ACL_READ_BASE_KEY_ID
assert file_data.permissions.write_access == MIFARE_ACL_WRITE_BASE_KEY_ID
assert file_data.file_type == DESFireFileType.MDFT_STANDARD_DATA_FILE
print(" - File created successfully.")
print("Writing UID to encrypted file...") logger.debug("Writing UID to encrypted file...")
key = secrets.token_hex(16) key = secrets.token_hex(16)
desfire.write_file_data(MIFARE_ENCRYPTED_FILE_ID, 0x0, file_data.encryption, get_list(key)) desfire.write_file_data(MIFARE_ENCRYPTED_FILE_ID, 0x0, file_data.encryption, get_list(key))
print("Reading from encrypted file...") logger.debug("Reading from encrypted file...")
rdata = desfire.read_file_data(MIFARE_ENCRYPTED_FILE_ID, file_data) rdata = desfire.read_file_data(MIFARE_ENCRYPTED_FILE_ID, file_data)
assert rdata == get_list(key) assert rdata == get_list(key)
print(" - Data written successfully.") logger.debug(" - Data written successfully.")
scannerThread.start() scannerThread.start()
return key return key
@@ -220,7 +248,7 @@ class BackgroundScanner:
def start(self): def start(self):
if self.is_running: if self.is_running:
logger.info("Scanner already running") logger.error("Scanner already running")
return return
self.is_running = True self.is_running = True
self.thread = threading.Thread(target=self._scan_loop, daemon=True) self.thread = threading.Thread(target=self._scan_loop, daemon=True)
@@ -238,12 +266,11 @@ class BackgroundScanner:
try: try:
card_content = self._read_card() card_content = self._read_card()
if card_content: if card_content:
logger.info(to_hex_string(card_content)) self._check_db(card_content)
time.sleep(5) time.sleep(5)
logger.debug("READY after success") logger.debug("READY after success")
#self._check_db(card_content)
else: else:
time.sleep(0.5) time.sleep(0.1)
logger.debug("READY after timout") logger.debug("READY after timout")
except Exception as e: except Exception as e:
@@ -251,53 +278,25 @@ class BackgroundScanner:
time.sleep(6) time.sleep(6)
def _read_card(self): def _read_card(self):
cardtype = AnyCardType() time.sleep(0.5)
cardrequest = CardRequest(timeout=5, cardType=cardtype)
try: try:
cardservice = cardrequest.waitforcard() cardservice = getCardService(3)
except CardRequestTimeoutException: except CardRequestTimeoutException:
logger.debug("No tag detected within the timeout.") logger.debug("No tag detected within the timeout.")
return return
# Create Desfire object
desfire = DESFire(PCSCDevice(cardservice.connection.component))
try: try:
cardservice.connection.connect() rdata = readFileOnCard(desfire=desfire)
# Create Desfire object
desfire = DESFire(PCSCDevice(cardservice.connection.component))
aes_keysettings = KeySettings(
key_type=DESFireKeyType.DF_KEY_AES,
)
# Get real UID
mk = DESFireKey(desfire.get_key_setting(), "00" * 8)
desfire.authenticate(0x0, mk)
#To get the uid you have to auth with an empty (default) key
uid = desfire.get_real_uid()
applications = desfire.get_application_ids()
try:
assert len(applications) == 1
assert applications[0] == get_list(MIFARE_APP_ID)
except AssertionError:
logger.error("No application found!")
time.sleep(4)
return None
#Then use the key derivation with that uid, the appid, the sysid
diversification_data = [0x01] + uid + get_list(MIFARE_APP_ID) + get_list(MIFARE_SYS_ID)
read_div_key_bytes = diversify_key(get_list(MIFARE_ACL_READ_BASE_KEY), diversification_data, pad_to_32=False)
#Log in with derived read key
logger.info("Start auth")
aes_app_read_key = DESFireKey(aes_keysettings, read_div_key_bytes)
desfire.select_application(MIFARE_APP_ID)
desfire.authenticate(MIFARE_ACL_READ_BASE_KEY_ID, aes_app_read_key)
logger.info("Read data")
file_data = desfire.get_file_settings(MIFARE_ENCRYPTED_FILE_ID)
logger.info(f"File settings: {file_data}")
rdata = desfire.read_file_data(MIFARE_ENCRYPTED_FILE_ID, file_data)
return rdata return rdata
except Exception as e: except Exception as e:
logger.error(f"something went wrong: {e}") logger.error(f"something went wrong: {e}")
time.sleep(5) time.sleep(5)
def _check_db(self, key):
check = checkAccess(key, self.db)
if check == True:
openDoor()
logger.info("Access granted!")
else:
logger.error("Access denied!")

128
flake.lock generated
View File

@@ -1,12 +1,114 @@
{ {
"nodes": { "nodes": {
"argononed": {
"flake": false,
"locked": {
"lastModified": 1729566243,
"narHash": "sha256-DPNI0Dpk5aym3Baf5UbEe5GENDrSmmXVdriRSWE+rgk=",
"owner": "nvmd",
"repo": "argononed",
"rev": "16dbee54d49b66d5654d228d1061246b440ef7cf",
"type": "github"
},
"original": {
"owner": "nvmd",
"repo": "argononed",
"type": "github"
}
},
"flake-compat": {
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"nixos-hardware": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1782562157,
"narHash": "sha256-a7+T6QSeowynwZ1ZJJbP8T8ntAytvrui8kFGJmIZt2c=",
"owner": "NixOS",
"repo": "nixos-hardware",
"rev": "a9cf7546a938c737b079e738de73934a13de9784",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "master",
"repo": "nixos-hardware",
"type": "github"
}
},
"nixos-images": {
"inputs": {
"nixos-stable": [
"nixos-raspberrypi",
"nixpkgs"
],
"nixos-unstable": [
"nixos-raspberrypi",
"nixpkgs"
]
},
"locked": {
"lastModified": 1747747741,
"narHash": "sha256-LUOH27unNWbGTvZFitHonraNx0JF/55h30r9WxqrznM=",
"owner": "nvmd",
"repo": "nixos-images",
"rev": "cbbd6db325775096680b65e2a32fb6187c09bbb4",
"type": "github"
},
"original": {
"owner": "nvmd",
"ref": "sdimage-installer",
"repo": "nixos-images",
"type": "github"
}
},
"nixos-raspberrypi": {
"inputs": {
"argononed": "argononed",
"flake-compat": "flake-compat",
"nixos-images": "nixos-images",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1779023229,
"narHash": "sha256-MInilg7B/06c34SwOuGSBho4l0H1EZcmvxTkSWCs5pE=",
"owner": "nvmd",
"repo": "nixos-raspberrypi",
"rev": "06c6e3513e1ee64b651913193fc6ac38aa4963f5",
"type": "github"
},
"original": {
"owner": "nvmd",
"ref": "main",
"repo": "nixos-raspberrypi",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1778869304, "lastModified": 1782467914,
"narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=", "narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "d233902339c02a9c334e7e593de68855ad26c4cb", "rev": "e73de5be04e0eff4190a1432b946d469c794e7b4",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -29,11 +131,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1776659114, "lastModified": 1782093830,
"narHash": "sha256-qapCOQmR++yZSY43dzrp3wCrkOTLpod+ONtJWBk6iKU=", "narHash": "sha256-6gmEVe69+KlRkZD4PEEV5xAlB9CB0Y9TiuEgQjDrKTQ=",
"owner": "pyproject-nix", "owner": "pyproject-nix",
"repo": "build-system-pkgs", "repo": "build-system-pkgs",
"rev": "ffaa2161dd5d63e0e94591f86b54fc239660fb2e", "rev": "430680a19bc85a3bda55f12e4cc1a1aadcf2e478",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -49,11 +151,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1778901413, "lastModified": 1782089418,
"narHash": "sha256-GSKXTAnFqRAMlZkJrIPcQMYf+lpMr66K3i60mB9STvc=", "narHash": "sha256-LRD1SuQWr49fGq3A+8GLXfsLE2xqIpQA440YDZwms3M=",
"owner": "pyproject-nix", "owner": "pyproject-nix",
"repo": "pyproject.nix", "repo": "pyproject.nix",
"rev": "a228447c3e179d477c1b6246ef3efa8cfe3c469a", "rev": "43f0b40edd0a74c63f66b7b48d969ae6b740d611",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -64,6 +166,8 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"nixos-hardware": "nixos-hardware",
"nixos-raspberrypi": "nixos-raspberrypi",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"pyproject-build-systems": "pyproject-build-systems", "pyproject-build-systems": "pyproject-build-systems",
"pyproject-nix": "pyproject-nix", "pyproject-nix": "pyproject-nix",
@@ -80,11 +184,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1779269674, "lastModified": 1782100052,
"narHash": "sha256-P1LHCRdYpdtHAEzuEsNHrI6d9mVPl5a2fyFDZGHNVbI=", "narHash": "sha256-UfyLY3Hfwb3JqxCcj0953GxTFI5dEL85EKEe6DAFiVs=",
"owner": "pyproject-nix", "owner": "pyproject-nix",
"repo": "uv2nix", "repo": "uv2nix",
"rev": "69aec536f6d1acc415ed2e20299312802aba98c6", "rev": "920fc6dfaf9f10ec56de93b184055e1a9f380d5e",
"type": "github" "type": "github"
}, },
"original": { "original": {

127
flake.nix
View File

@@ -21,6 +21,15 @@
inputs.uv2nix.follows = "uv2nix"; inputs.uv2nix.follows = "uv2nix";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
nixos-hardware = {
url = "github:NixOS/nixos-hardware/master";
inputs.nixpkgs.follows = "nixpkgs";
};
nixos-raspberrypi = {
url = "github:nvmd/nixos-raspberrypi/main";
inputs.nixpkgs.follows = "nixpkgs";
};
}; };
outputs = outputs =
@@ -29,6 +38,7 @@
pyproject-nix, pyproject-nix,
uv2nix, uv2nix,
pyproject-build-systems, pyproject-build-systems,
nixos-hardware,
... ...
}: }:
let let
@@ -105,5 +115,122 @@
packages = forAllSystems (system: { packages = forAllSystems (system: {
default = pythonSets.${system}.mkVirtualEnv "gatekeeper" workspace.deps.default; default = pythonSets.${system}.mkVirtualEnv "gatekeeper" workspace.deps.default;
}); });
nixosConfigurations = {
gatekeeper-rpi = nixpkgs.lib.nixosSystem {
system = "aarch64-linux";
specialArgs = {
inherit workspace overlay;
};
modules = [
"${nixpkgs}/nixos/modules/profiles/headless.nix"
"${nixpkgs}/nixos/modules/profiles/minimal.nix"
"${nixpkgs}/nixos/modules/installer/sd-card/sd-image-aarch64.nix"
nixos-hardware.nixosModules.raspberry-pi-3
({ pkgs, ... }: {
nix.nixPath = [
"nixpkgs=${pkgs.path}"
];
nix.settings = {
trusted-users = [ "root" "@wheel" ];
experimental-features = [ "nix-command" "flakes" ];
auto-optimise-store = true;
};
})
({ config, pkgs, packages, ...}:
let
gatekeeper-app = pkgs.python3Packages.buildPythonPackage {
pname = "gatekeeper-app";
version = "0.0.1";
propagatedBuildInputs = [packages.gatekeeper];
src = ./app;
format = "pyproject";
};
in
{
boot.loader.grub.enable = false;
boot.loader.generic-extlinux-compatible.enable = true;
boot.kernelPackages = pkgs.linuxPackages;
boot.tmp.cleanOnBoot = true;
hardware.enableRedistributableFirmware = true;
networking.hostName = "gatekeeper-rpi";
#minimal
documentation.man.enable = false;
xdg.icons.enable = false;
xdg.mime.enable = false;
xdg.sounds.enable = false;
users.users.root.openssh.authorizedKeys.keys = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICKaEcGaSKU0xC5qCwzj2oCLLG4PYjWHZ7/CXHw4urVk atlan@nixos"];
users.groups = { gatekeeper = {}; };
users.users.admin = {
isNormalUser = true;
extraGroups = [ "wheel" "networkmanager" "gatekeeper" ];
};
users.users.gatekeeper = {
isNormalUser = true;
extraGroups = ["gatekeeper"];
};
services.openssh = {
enable = true;
ports = [ 22 ];
settings = {
PasswordAuthentication = false;
};
};
environment.systemPackages = with pkgs; [
git
htop
pcsclite
gatekeeper-app
];
environment.etc."gatekeeper-env".source =
pythonSets.aarch64-linux.mkVirtualEnv "gatekeeper" workspace.deps.default;
services.pcscd.enable = true;
systemd.services.gatekeeper = {
enable = true;
description = "Gatekeeper service";
after = [ "network.target" "pcscd.service"];
wantedBy = [ "multi-user.target" ];
environment = {
LD_LIBRARY_PATH = "${lib.getLib pkgs.pcsclite}/lib";
PYTHONPATH = "/etc/gatekeeper-env/lib/python3.x/site-packages";
};
serviceConfig = {
Type = "simple";
User = "gatekeeper";
Group = "gatekeeper";
WorkingDirectory = "/var/lib/gatekeeper";
ExecStart = "${pythonSets.aarch64-linux.mkVirtualEnv "gatekeeper" workspace.deps.default}/bin/python -m uvicorn main:app --host 0.0.0.0 --port 8000";
Restart = "always";
RestartSec = "10";
};
# Create working directory and log directory
preStart = ''
mkdir -p /var/lib/gatekeeper
mkdir -p /var/log/gatekeeper
chown -R gatekeeper:gatekeeper /var/lib/gatekeeper
chown -R gatekeeper:gatekeeper /var/log/gatekeeper
'';
};
networking.firewall.allowedTCPPorts = [ 8000 ];
time.timeZone = "Europe/Berlin";
system.stateVersion = "25.11";
})
];
};
};
}; };
} }

View File

@@ -9,7 +9,6 @@ dependencies = [
"sqlmodel>=0.0.38", "sqlmodel>=0.0.38",
"poetry>=2.3.4", "poetry>=2.3.4",
"python-desfire", "python-desfire",
"paho-mqtt>=2.1.0",
"pyjwt[crypto]>=2.12.1", "pyjwt[crypto]>=2.12.1",
"pwdlib[argon2]>=0.3.0", "pwdlib[argon2]>=0.3.0",
"pytest>=9.0.3", "pytest>=9.0.3",

View File

@@ -68,7 +68,7 @@ def regular_user(db_session):
def auth_headers(client, admin_user): def auth_headers(client, admin_user):
"""Get authentication headers for admin user.""" """Get authentication headers for admin user."""
response = client.post( response = client.post(
"/token", "/api/v1/token",
data={"username": admin_user.name, "password": "admin123"} data={"username": admin_user.name, "password": "admin123"}
) )
token = response.json()["access_token"] token = response.json()["access_token"]
@@ -79,7 +79,7 @@ def auth_headers(client, admin_user):
def user_auth_headers(client, regular_user): def user_auth_headers(client, regular_user):
"""Get authentication headers for regular user.""" """Get authentication headers for regular user."""
response = client.post( response = client.post(
"/token", "/api/v1/token",
data={"username": regular_user.name, "password": "user123"} data={"username": regular_user.name, "password": "user123"}
) )
token = response.json()["access_token"] token = response.json()["access_token"]

View File

@@ -4,13 +4,6 @@ def test_app_startup(client):
# Application should respond (even if it's a 404) # Application should respond (even if it's a 404)
assert response.status_code in [404, 200] assert response.status_code in [404, 200]
def test_health_check(client):
"""Test basic health check endpoint if it exists."""
# Note: This would require adding a health check endpoint
pass
def test_router_includes(): def test_router_includes():
"""Test that all routers are included in the app.""" """Test that all routers are included in the app."""
from app.main import app from app.main import app

View File

@@ -13,7 +13,7 @@ def test_create_access_auth(client, auth_headers):
] ]
} }
response = client.post("/aa/", json=aa_data, headers=auth_headers) response = client.post("/api/v1/aa/", json=aa_data, headers=auth_headers)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -25,7 +25,7 @@ def test_create_access_auth(client, auth_headers):
def test_get_all_access_auths(client, auth_headers, test_aa): def test_get_all_access_auths(client, auth_headers, test_aa):
"""Test retrieving all access authorizations.""" """Test retrieving all access authorizations."""
response = client.get("/aa/", headers=auth_headers) response = client.get("/api/v1/aa/", headers=auth_headers)
assert response.status_code == 200 assert response.status_code == 200
aa_list = response.json() aa_list = response.json()
@@ -37,7 +37,7 @@ def test_get_all_access_auths(client, auth_headers, test_aa):
def test_get_access_auth_by_id(client, auth_headers, test_aa): def test_get_access_auth_by_id(client, auth_headers, test_aa):
"""Test retrieving a specific access authorization by ID.""" """Test retrieving a specific access authorization by ID."""
response = client.get(f"/aa/{test_aa.id}", headers=auth_headers) response = client.get(f"/api/v1/aa/{test_aa.id}", headers=auth_headers)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -47,14 +47,14 @@ def test_get_access_auth_by_id(client, auth_headers, test_aa):
def test_get_nonexistent_access_auth(client, auth_headers): def test_get_nonexistent_access_auth(client, auth_headers):
"""Test retrieving a non-existent access authorization.""" """Test retrieving a non-existent access authorization."""
response = client.get("/aa/99999", headers=auth_headers) response = client.get("/api/v1/aa/99999", headers=auth_headers)
assert response.status_code == 404 assert response.status_code == 404
def test_assign_access_auth_to_group(client, auth_headers, test_group, test_aa): def test_assign_access_auth_to_group(client, auth_headers, test_group, test_aa):
"""Test assigning an access authorization to a group.""" """Test assigning an access authorization to a group."""
response = client.put( response = client.put(
f"/aa/assign/{test_group.id}/{test_aa.id}", f"/api/v1/aa/assign/{test_group.id}/{test_aa.id}",
headers=auth_headers headers=auth_headers
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -68,25 +68,26 @@ def test_assign_access_auth_to_group(client, auth_headers, test_group, test_aa):
def test_assign_already_assigned_access_auth(client, auth_headers, test_group, test_aa): def test_assign_already_assigned_access_auth(client, auth_headers, test_group, test_aa):
"""Test assigning an already assigned access authorization.""" """Test assigning an already assigned access authorization."""
# First assignment # First assignment
client.put(f"/aa/assign/{test_group.id}/{test_aa.id}", headers=auth_headers) client.put(f"/api/v1/aa/assign/{test_group.id}/{test_aa.id}", headers=auth_headers)
# Second assignment should indicate it's already assigned # Second assignment should indicate it's already assigned
response = client.put( response = client.put(
f"/aa/assign/{test_group.id}/{test_aa.id}", f"/api/v1/aa/assign/{test_group.id}/{test_aa.id}",
headers=auth_headers headers=auth_headers
) )
# According to the code, this returns 200 with "already assigned" message # According to the code, this returns 409 with "already assigned" message
assert response.status_code == 200 assert response.status_code == 409
assert "already assigned" in response.json()["detail"].lower()
def test_unassign_access_auth_from_group(client, auth_headers, test_group, test_aa): def test_unassign_access_auth_from_group(client, auth_headers, test_group, test_aa):
"""Test unassigning an access authorization from a group.""" """Test unassigning an access authorization from a group."""
# First assign # First assign
client.put(f"/aa/assign/{test_group.id}/{test_aa.id}", headers=auth_headers) client.put(f"/api/v1/aa/assign/{test_group.id}/{test_aa.id}", headers=auth_headers)
# Then unassign # Then unassign
response = client.put( response = client.put(
f"/aa/unassign/{test_group.id}/{test_aa.id}", f"/api/v1/aa/unassign/{test_group.id}/{test_aa.id}",
headers=auth_headers headers=auth_headers
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -95,22 +96,21 @@ def test_unassign_access_auth_from_group(client, auth_headers, test_group, test_
def test_unassign_nonexistent_assignment(client, auth_headers, test_group, test_aa): def test_unassign_nonexistent_assignment(client, auth_headers, test_group, test_aa):
"""Test unassigning a non-existent assignment.""" """Test unassigning a non-existent assignment."""
response = client.put( response = client.put(
f"/aa/unassign/{test_group.id}/{test_aa.id}", f"/api/v1/aa/unassign/{test_group.id}/{test_aa.id}",
headers=auth_headers headers=auth_headers
) )
# According to the code, this returns 200 with "not assigned" message assert response.status_code == 404
assert response.status_code == 200
def test_assign_to_nonexistent_group(client, auth_headers, test_aa): def test_assign_to_nonexistent_group(client, auth_headers, test_aa):
"""Test assigning an AA to a non-existent group.""" """Test assigning an AA to a non-existent group."""
response = client.put(f"/aa/assign/99999/{test_aa.id}", headers=auth_headers) response = client.put(f"/api/v1/aa/assign/99999/{test_aa.id}", headers=auth_headers)
assert response.status_code == 404 assert response.status_code == 404
def test_assign_nonexistent_aa(client, auth_headers, test_group): def test_assign_nonexistent_aa(client, auth_headers, test_group):
"""Test assigning a non-existent AA to a group.""" """Test assigning a non-existent AA to a group."""
response = client.put(f"/aa/assign/{test_group.id}/99999", headers=auth_headers) response = client.put(f"/api/v1/aa/assign/{test_group.id}/99999", headers=auth_headers)
assert response.status_code == 404 assert response.status_code == 404
@@ -122,7 +122,7 @@ def test_update_access_auth(client, auth_headers, test_aa):
} }
response = client.patch( response = client.patch(
f"/aa/{test_aa.id}", f"/api/v1/aa/{test_aa.id}",
json=update_data, json=update_data,
headers=auth_headers headers=auth_headers
) )
@@ -142,34 +142,39 @@ def test_update_access_auth_with_timetables(client, auth_headers, test_aa):
} }
response = client.patch( response = client.patch(
f"/aa/{test_aa.id}", f"/api/v1/aa/{test_aa.id}",
json=update_data, json=update_data,
headers=auth_headers headers=auth_headers
) )
assert response.status_code == 200 assert response.status_code == 200
jresponse = response.json()
assert len(jresponse["timetables"]) == 1
assert jresponse["timetables"][0]["weekday"] == 5
assert jresponse["timetables"][0]["starttime"] == "10:00:00"
assert jresponse["timetables"][0]["duration"] == 120
def test_update_nonexistent_access_auth(client, auth_headers): def test_update_nonexistent_access_auth(client, auth_headers):
"""Test updating a non-existent access authorization.""" """Test updating a non-existent access authorization."""
update_data = {"name": "Updated"} update_data = {"name": "Updated"}
response = client.patch("/aa/99999", json=update_data, headers=auth_headers) response = client.patch("/api/v1/aa/99999", json=update_data, headers=auth_headers)
assert response.status_code == 404 assert response.status_code == 404
def test_delete_access_auth(client, auth_headers, test_aa): def test_delete_access_auth(client, auth_headers, test_aa):
"""Test deleting an access authorization.""" """Test deleting an access authorization."""
response = client.delete(f"/aa/{test_aa.id}", headers=auth_headers) response = client.delete(f"/api/v1/aa/{test_aa.id}", headers=auth_headers)
assert response.status_code == 200 assert response.status_code == 200
assert "deleted successfully" in response.json()["message"].lower() assert "deleted successfully" in response.json()["message"].lower()
# Verify AA is deleted # Verify AA is deleted
response = client.get(f"/aa/{test_aa.id}", headers=auth_headers) response = client.get(f"/api/v1/aa/{test_aa.id}", headers=auth_headers)
assert response.status_code == 404 assert response.status_code == 404
def test_delete_nonexistent_access_auth(client, auth_headers): def test_delete_nonexistent_access_auth(client, auth_headers):
"""Test deleting a non-existent access authorization.""" """Test deleting a non-existent access authorization."""
response = client.delete("/aa/99999", headers=auth_headers) response = client.delete("/api/v1/aa/99999", headers=auth_headers)
assert response.status_code == 404 assert response.status_code == 404
@@ -177,16 +182,16 @@ def test_aa_operations_by_non_admin(client, test_aa, user_auth_headers):
"""Test that non-admin users cannot perform AA operations.""" """Test that non-admin users cannot perform AA operations."""
# Try to create an AA # Try to create an AA
response = client.post( response = client.post(
"/aa/", "/api/v1/aa/",
json={"name": "test", "is_active": True, "timetables": []}, json={"name": "test", "is_active": True, "timetables": []},
headers=user_auth_headers headers=user_auth_headers
) )
assert response.status_code == 403 assert response.status_code == 403
# Try to get all AAs # Try to get all AAs
response = client.get("/aa/", headers=user_auth_headers) response = client.get("/api/v1/aa/", headers=user_auth_headers)
assert response.status_code == 403 assert response.status_code == 403
# Try to assign AA # Try to assign AA
response = client.put(f"/aa/assign/1/{test_aa.id}", headers=user_auth_headers) response = client.put(f"/api/v1/aa/assign/1/{test_aa.id}", headers=user_auth_headers)
assert response.status_code == 403 assert response.status_code == 403

View File

@@ -131,7 +131,6 @@ def test_auth_is_admin(db_session, admin_user, regular_user):
def test_create_first_user(db_session): def test_create_first_user(db_session):
"""Test automatic creation of first admin user.""" """Test automatic creation of first admin user."""
#Currently broken because this uses the prod db because of how i wrote the create_first_user function
# Clear any existing users # Clear any existing users
from sqlmodel import select from sqlmodel import select
db_session.exec(select(UserDB)).all() db_session.exec(select(UserDB)).all()
@@ -140,7 +139,7 @@ def test_create_first_user(db_session):
db_session.commit() db_session.commit()
# Create first user # Create first user
result = create_first_user() result = create_first_user(db=db_session)
assert result is not None assert result is not None
assert result.name == "admin" assert result.name == "admin"
assert result.is_admin is True assert result.is_admin is True
@@ -151,7 +150,7 @@ def test_create_first_user(db_session):
assert user.is_admin is True assert user.is_admin is True
# Test that it doesn't create another admin if one exists # Test that it doesn't create another admin if one exists
second_result = create_first_user() second_result = create_first_user(db=db_session)
assert second_result is None # Should print "Admin user already exists" assert second_result is None # Should print "Admin user already exists"
@@ -159,7 +158,7 @@ def test_token_endpoint(client, admin_user):
"""Test the token endpoint for login.""" """Test the token endpoint for login."""
# Test successful login # Test successful login
response = client.post( response = client.post(
"/token", "/api/v1/token",
data={"username": admin_user.name, "password": "admin123"} data={"username": admin_user.name, "password": "admin123"}
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -169,14 +168,14 @@ def test_token_endpoint(client, admin_user):
# Test failed login with wrong password # Test failed login with wrong password
response = client.post( response = client.post(
"/token", "/api/v1/token",
data={"username": admin_user.name, "password": "wrongpassword"} data={"username": admin_user.name, "password": "wrongpassword"}
) )
assert response.status_code == 401 assert response.status_code == 401
# Test failed login with non-existent user # Test failed login with non-existent user
response = client.post( response = client.post(
"/token", "/api/v1/token",
data={"username": "nonexistent", "password": "password"} data={"username": "nonexistent", "password": "password"}
) )
assert response.status_code == 401 assert response.status_code == 401
@@ -185,12 +184,12 @@ def test_token_endpoint(client, admin_user):
def test_test_login_endpoint(client, admin_user, auth_headers): def test_test_login_endpoint(client, admin_user, auth_headers):
"""Test the test login endpoint.""" """Test the test login endpoint."""
# Test with valid token # Test with valid token
response = client.get("/test/login", headers=auth_headers) response = client.get("/api/v1/test/login", headers=auth_headers)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["name"] == admin_user.name assert data["name"] == admin_user.name
assert data["is_admin"] is True assert data["is_admin"] is True
# Test without token # Test without token
response = client.get("/test/login") response = client.get("/api/v1/test/login")
assert response.status_code == 401 assert response.status_code == 401

View File

@@ -1,44 +1,9 @@
import pytest import pytest
from fastapi import status from fastapi import status
def test_add_card(client, auth_headers, test_group):
"""Test adding a card to a group."""
response = client.post(f"/cards/{test_group.id}", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert "id" in data
assert "uuid" in data
assert data["group_id"] == test_group.id
assert len(data["uuid"]) > 0 # UUID should be generated
def test_add_card_to_nonexistent_group(client, auth_headers):
"""Test adding a card to a non-existent group."""
response = client.post("/cards/99999", headers=auth_headers)
# This might succeed and create a card with a non-existent group_id
# or fail depending on foreign key constraints
# For now, let's assume it might fail
# assert response.status_code == 404
def test_delete_card(client, auth_headers, test_card):
"""Test deleting a card."""
response = client.delete(f"/cards/{test_card.id}", headers=auth_headers)
assert response.status_code == 200
assert "deleted successfully" in response.json()["message"].lower()
def test_delete_nonexistent_card(client, auth_headers):
"""Test deleting a non-existent card."""
response = client.delete("/cards/99999", headers=auth_headers)
assert response.status_code == 404
def test_get_cards_for_group(client, auth_headers, test_group, test_card): def test_get_cards_for_group(client, auth_headers, test_group, test_card):
"""Test getting all cards for a group.""" """Test getting all cards for a group."""
response = client.get(f"/cards/{test_group.id}", headers=auth_headers) response = client.get(f"/api/v1/cards/{test_group.id}", headers=auth_headers)
assert response.status_code == 200 assert response.status_code == 200
cards = response.json() cards = response.json()
@@ -48,7 +13,7 @@ def test_get_cards_for_group(client, auth_headers, test_group, test_card):
def test_get_cards_for_nonexistent_group(client, auth_headers): def test_get_cards_for_nonexistent_group(client, auth_headers):
"""Test getting cards for a non-existent group.""" """Test getting cards for a non-existent group."""
response = client.get("/cards/99999", headers=auth_headers) response = client.get("/api/v1/cards/99999", headers=auth_headers)
assert response.status_code == 200 assert response.status_code == 200
cards = response.json() cards = response.json()
@@ -58,9 +23,9 @@ def test_get_cards_for_nonexistent_group(client, auth_headers):
def test_card_operations_by_non_admin(client, test_group, user_auth_headers): def test_card_operations_by_non_admin(client, test_group, user_auth_headers):
"""Test that non-admin users cannot perform card operations.""" """Test that non-admin users cannot perform card operations."""
# Try to add a card # Try to add a card
response = client.post(f"/cards/{test_group.id}", headers=user_auth_headers) response = client.post(f"/api/v1/cards/{test_group.id}", headers=user_auth_headers)
assert response.status_code == 403 assert response.status_code == 403
# Try to get cards # Try to get cards
response = client.get(f"/cards/{test_group.id}", headers=user_auth_headers) response = client.get(f"/api/v1/cards/{test_group.id}", headers=user_auth_headers)
assert response.status_code == 403 assert response.status_code == 403

View File

@@ -0,0 +1,61 @@
import pytest
import datetime
from app.services.door import checkAccess
from app.model.models import Card, GroupDB, AccessAuthorizationDB, Timetable
def test_check_access_with_valid_timetable(db_session):
# Setup: create card with valid access
group = GroupDB(name="Test Group")
db_session.add(group)
db_session.commit()
card = Card(uuid="test-uuid-123", group_id=group.id)
db_session.add(card)
timetable = Timetable(
weekday=datetime.datetime.weekday(datetime.date.today()),
starttime=datetime.datetime.now().time(),
duration=120 # 2 hours
)
db_session.add(timetable)
aa = AccessAuthorizationDB(name="Test AA", is_active=True)
db_session.add(aa)
aa.timetables = [timetable]
group.accessauths = [aa]
db_session.commit()
# Test: access should be granted within time window
result = checkAccess("test-uuid-123", db_session)
assert result == True
def test_check_access_outside_hours(db_session):
# Test when current time is outside valid hours
group = GroupDB(name="Test Group")
db_session.add(group)
db_session.commit()
card = Card(uuid="test-uuid-123", group_id=group.id)
db_session.add(card)
timetable = Timetable(
weekday=datetime.datetime.weekday(datetime.date.today()),
starttime=datetime.time(1, 0),
duration=1 # 2 hours
)
db_session.add(timetable)
aa = AccessAuthorizationDB(name="Test AA", is_active=True)
db_session.add(aa)
aa.timetables = [timetable]
group.accessauths = [aa]
db_session.commit()
result = checkAccess("test-uuid-123", db_session)
assert result == False
def test_check_access_invalid_card(db_session):
# Should raise exception for non-existent card
with pytest.raises(Exception):
checkAccess("non-existent-uuid", db_session)

View File

@@ -6,7 +6,7 @@ def test_create_group(client, auth_headers):
"""Test creating a new group.""" """Test creating a new group."""
group_data = {"name": "New Test Group"} group_data = {"name": "New Test Group"}
response = client.post("/groups/", json=group_data, headers=auth_headers) response = client.post("/api/v1/groups/", json=group_data, headers=auth_headers)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -18,14 +18,14 @@ def test_create_duplicate_group(client, auth_headers, test_group):
"""Test creating a group with a duplicate name.""" """Test creating a group with a duplicate name."""
group_data = {"name": test_group.name} group_data = {"name": test_group.name}
response = client.post("/groups/", json=group_data, headers=auth_headers) response = client.post("/api/v1/groups/", json=group_data, headers=auth_headers)
# This should fail due to unique constraint # This should fail due to unique constraint
assert response.status_code == 409 # Validation error assert response.status_code == 409 # Validation error
def test_get_groups(client, auth_headers, test_group): def test_get_groups(client, auth_headers, test_group):
"""Test retrieving all groups.""" """Test retrieving all groups."""
response = client.get("/groups/", headers=auth_headers) response = client.get("/api/v1/groups/", headers=auth_headers)
assert response.status_code == 200 assert response.status_code == 200
groups = response.json() groups = response.json()
@@ -37,19 +37,19 @@ def test_get_groups(client, auth_headers, test_group):
def test_delete_group(client, auth_headers, test_group): def test_delete_group(client, auth_headers, test_group):
"""Test deleting a group.""" """Test deleting a group."""
response = client.delete(f"/groups/{test_group.id}", headers=auth_headers) response = client.delete(f"/api/v1/groups/{test_group.id}", headers=auth_headers)
assert response.status_code == 200 assert response.status_code == 200
assert "deleted successfully" in response.json()["message"].lower() assert "deleted successfully" in response.json()["message"].lower()
# Verify group is deleted # Verify group is deleted
response = client.get("/groups/", headers=auth_headers) response = client.get("/api/v1/groups/", headers=auth_headers)
groups = response.json() groups = response.json()
assert not any(group["id"] == test_group.id for group in groups) assert not any(group["id"] == test_group.id for group in groups)
def test_delete_nonexistent_group(client, auth_headers): def test_delete_nonexistent_group(client, auth_headers):
"""Test deleting a non-existent group.""" """Test deleting a non-existent group."""
response = client.delete("/groups/99999", headers=auth_headers) response = client.delete("/api/v1/groups/99999", headers=auth_headers)
assert response.status_code == 404 assert response.status_code == 404
@@ -57,12 +57,12 @@ def test_group_operations_by_non_admin(client, user_auth_headers):
"""Test that non-admin users cannot perform group operations.""" """Test that non-admin users cannot perform group operations."""
# Try to create a group # Try to create a group
response = client.post( response = client.post(
"/groups/", "/api/v1/groups/",
json={"name": "test"}, json={"name": "test"},
headers=user_auth_headers headers=user_auth_headers
) )
assert response.status_code == 403 assert response.status_code == 403
# Try to get groups # Try to get groups
response = client.get("/groups/", headers=user_auth_headers) response = client.get("/api/v1/groups/", headers=user_auth_headers)
assert response.status_code == 403 assert response.status_code == 403

View File

@@ -11,7 +11,7 @@ def test_create_user(client, auth_headers):
"password": "newpassword123" "password": "newpassword123"
} }
response = client.post("/users/", json=user_data, headers=auth_headers) response = client.post("/api/v1/users/", json=user_data, headers=auth_headers)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
@@ -30,13 +30,13 @@ def test_create_user_unauthorized(client):
"password": "password123" "password": "password123"
} }
response = client.post("/users/", json=user_data) response = client.post("/api/v1/users/", json=user_data)
assert response.status_code == 401 assert response.status_code == 401
def test_get_users(client, auth_headers, admin_user, regular_user): def test_get_users(client, auth_headers, admin_user, regular_user):
"""Test retrieving all users.""" """Test retrieving all users."""
response = client.get("/users/", headers=auth_headers) response = client.get("/api/v1/users/", headers=auth_headers)
assert response.status_code == 200 assert response.status_code == 200
users = response.json() users = response.json()
@@ -49,17 +49,26 @@ def test_get_users(client, auth_headers, admin_user, regular_user):
def test_get_user_by_id(client, auth_headers, regular_user): def test_get_user_by_id(client, auth_headers, regular_user):
"""Test retrieving a specific user by ID.""" """Test retrieving a specific user by ID."""
response = client.get(f"/users/{regular_user.id}", headers=auth_headers) response = client.get(f"/api/v1/users/{regular_user.id}", headers=auth_headers)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["id"] == regular_user.id assert data["id"] == regular_user.id
assert data["name"] == regular_user.name assert data["name"] == regular_user.name
def test_get_current_user(client, auth_headers, admin_user):
"""Test getting the special url current"""
response = client.get("/api/v1/users/current", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["id"] == admin_user.id
assert data["name"] == admin_user.name
assert data["is_admin"] == admin_user.is_admin
def test_get_nonexistent_user(client, auth_headers): def test_get_nonexistent_user(client, auth_headers):
"""Test retrieving a non-existent user.""" """Test retrieving a non-existent user."""
response = client.get("/users/99999", headers=auth_headers) response = client.get("/api/v1/users/99999", headers=auth_headers)
assert response.status_code == 404 assert response.status_code == 404
assert "not found" in response.json()["detail"].lower() assert "not found" in response.json()["detail"].lower()
@@ -72,7 +81,7 @@ def test_update_user(client, auth_headers, regular_user):
} }
response = client.patch( response = client.patch(
f"/users/{regular_user.id}", f"/api/v1/users/{regular_user.id}",
json=update_data, json=update_data,
headers=auth_headers headers=auth_headers
) )
@@ -92,7 +101,7 @@ def test_update_user_password(client, auth_headers, regular_user):
} }
response = client.patch( response = client.patch(
f"/users/{regular_user.id}", f"/api/v1/users/{regular_user.id}",
json=update_data, json=update_data,
headers=auth_headers headers=auth_headers
) )
@@ -100,7 +109,7 @@ def test_update_user_password(client, auth_headers, regular_user):
# Verify password can be used for login # Verify password can be used for login
login_response = client.post( login_response = client.post(
"/token", "/api/v1/token",
data={"username": regular_user.name, "password": "new_password_456"} data={"username": regular_user.name, "password": "new_password_456"}
) )
assert login_response.status_code == 200 assert login_response.status_code == 200
@@ -109,24 +118,24 @@ def test_update_user_password(client, auth_headers, regular_user):
def test_update_nonexistent_user(client, auth_headers): def test_update_nonexistent_user(client, auth_headers):
"""Test updating a non-existent user.""" """Test updating a non-existent user."""
update_data = {"name": "updated"} update_data = {"name": "updated"}
response = client.patch("/users/99999", json=update_data, headers=auth_headers) response = client.patch("/api/v1/users/99999", json=update_data, headers=auth_headers)
assert response.status_code == 404 assert response.status_code == 404
def test_delete_user(client, auth_headers, regular_user): def test_delete_user(client, auth_headers, regular_user):
"""Test deleting a user.""" """Test deleting a user."""
response = client.delete(f"/users/{regular_user.id}", headers=auth_headers) response = client.delete(f"/api/v1/users/{regular_user.id}", headers=auth_headers)
assert response.status_code == 200 assert response.status_code == 200
assert "deleted successfully" in response.json()["message"].lower() assert "deleted successfully" in response.json()["message"].lower()
# Verify user is deleted # Verify user is deleted
response = client.get(f"/users/{regular_user.id}", headers=auth_headers) response = client.get(f"/api/v1/users/{regular_user.id}", headers=auth_headers)
assert response.status_code == 404 assert response.status_code == 404
def test_delete_nonexistent_user(client, auth_headers): def test_delete_nonexistent_user(client, auth_headers):
"""Test deleting a non-existent user.""" """Test deleting a non-existent user."""
response = client.delete("/users/99999", headers=auth_headers) response = client.delete("/api/v1/users/99999", headers=auth_headers)
assert response.status_code == 404 assert response.status_code == 404
@@ -134,17 +143,17 @@ def test_user_operations_by_non_admin(client, user_auth_headers):
"""Test that non-admin users cannot perform admin operations.""" """Test that non-admin users cannot perform admin operations."""
# Try to create a user # Try to create a user
response = client.post( response = client.post(
"/users/", "/api/v1/users/",
json={"name": "test", "password": "pass"}, json={"name": "test", "password": "pass"},
headers=user_auth_headers headers=user_auth_headers
) )
assert response.status_code == 403 assert response.status_code == 403
# Try to get users # Try to get users
response = client.get("/users/", headers=user_auth_headers) response = client.get("/api/v1/users/", headers=user_auth_headers)
assert response.status_code == 403 assert response.status_code == 403
# Try to delete the admin user (if ID is known) # Try to delete the admin user (if ID is known)
# This would require knowing the admin user ID # This would require knowing the admin user ID
# response = client.delete(f"/users/{admin_id}", headers=user_auth_headers) # response = client.delete(f"/api/v1/users/{admin_id}", headers=user_auth_headers)
# assert response.status_code == 403 # assert response.status_code == 403

11
uv.lock generated
View File

@@ -609,7 +609,6 @@ version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "paho-mqtt" },
{ name = "poetry" }, { name = "poetry" },
{ name = "pwdlib", extra = ["argon2"] }, { name = "pwdlib", extra = ["argon2"] },
{ name = "pyjwt", extra = ["crypto"] }, { name = "pyjwt", extra = ["crypto"] },
@@ -625,7 +624,6 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" },
{ name = "paho-mqtt", specifier = ">=2.1.0" },
{ name = "poetry", specifier = ">=2.3.4" }, { name = "poetry", specifier = ">=2.3.4" },
{ name = "pwdlib", extras = ["argon2"], specifier = ">=0.3.0" }, { name = "pwdlib", extras = ["argon2"], specifier = ">=0.3.0" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.1" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.1" },
@@ -952,15 +950,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
] ]
[[package]]
name = "paho-mqtt"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" },
]
[[package]] [[package]]
name = "pbs-installer" name = "pbs-installer"
version = "2026.4.7" version = "2026.4.7"