From f93793a1d8d70acbc5f71a5c9332fa85ad55fe73 Mon Sep 17 00:00:00 2001 From: Oliver Hofmann Date: Mon, 27 Apr 2026 09:42:33 +0200 Subject: [PATCH] feat: add auth service with local bcrypt password authentication --- app/modules/auth/schemas.py | 18 ++++++++++ app/modules/auth/service.py | 45 ++++++++++++++++++++++++ tests/test_auth_service.py | 69 +++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 app/modules/auth/schemas.py create mode 100644 app/modules/auth/service.py create mode 100644 tests/test_auth_service.py diff --git a/app/modules/auth/schemas.py b/app/modules/auth/schemas.py new file mode 100644 index 0000000..024928a --- /dev/null +++ b/app/modules/auth/schemas.py @@ -0,0 +1,18 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class UserOut(BaseModel): + id: int + username: str + full_name: str + email: str | None + department: str | None + role: str | None + is_active: bool + is_admin: bool + last_login: datetime | None + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/app/modules/auth/service.py b/app/modules/auth/service.py new file mode 100644 index 0000000..a220ac9 --- /dev/null +++ b/app/modules/auth/service.py @@ -0,0 +1,45 @@ +from datetime import datetime, timezone +from typing import Optional + +from passlib.context import CryptContext +from sqlalchemy.orm import Session + +from app.modules.auth.models import User + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(plain: str) -> str: + return pwd_context.hash(plain) + + +def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + +def get_user(db: Session, username: str) -> Optional[User]: + return db.query(User).filter(User.username == username).first() + + +def authenticate_user( + db: Session, username: str, password: str, ldap_enabled: bool +) -> Optional[User]: + user = get_user(db, username) + if user is None or not user.is_active: + return None + + local_ok = user.pw_hash is not None and verify_password(password, user.pw_hash) + if local_ok: + _touch_last_login(db, user) + return user + + if ldap_enabled: + # LDAP auth implemented in Part 2 + pass + + return None + + +def _touch_last_login(db: Session, user: User) -> None: + user.last_login = datetime.now(timezone.utc) + db.commit() diff --git a/tests/test_auth_service.py b/tests/test_auth_service.py new file mode 100644 index 0000000..52da537 --- /dev/null +++ b/tests/test_auth_service.py @@ -0,0 +1,69 @@ +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.core.database import Base +from app.modules.auth.models import User +from app.modules.auth.service import authenticate_user, get_user, hash_password, verify_password + + +@pytest.fixture +def db(): + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) + Base.metadata.create_all(bind=engine) + Session = sessionmaker(bind=engine) + session = Session() + yield session + session.close() + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture +def alice(db): + user = User(username="alice", full_name="Alice Smith", pw_hash=hash_password("secret123")) + db.add(user) + db.commit() + db.refresh(user) + return user + + +def test_hash_and_verify_password(): + hashed = hash_password("mypassword") + assert verify_password("mypassword", hashed) is True + assert verify_password("wrong", hashed) is False + + +def test_get_user_found(db, alice): + assert get_user(db, "alice") is not None + + +def test_get_user_not_found(db): + assert get_user(db, "nobody") is None + + +def test_authenticate_correct_password(db, alice): + user = authenticate_user(db, "alice", "secret123", ldap_enabled=False) + assert user is not None + assert user.last_login is not None + + +def test_authenticate_wrong_password(db, alice): + assert authenticate_user(db, "alice", "wrong", ldap_enabled=False) is None + + +def test_authenticate_unknown_user(db): + assert authenticate_user(db, "ghost", "any", ldap_enabled=False) is None + + +def test_authenticate_inactive_user(db): + user = User(username="inactive", full_name="X", pw_hash=hash_password("pw"), is_active=False) + db.add(user) + db.commit() + assert authenticate_user(db, "inactive", "pw", ldap_enabled=False) is None + + +def test_authenticate_no_pw_hash_no_ldap(db): + user = User(username="ldaponly", full_name="Y", pw_hash=None) + db.add(user) + db.commit() + assert authenticate_user(db, "ldaponly", "any", ldap_enabled=False) is None