486e1288bf
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
177 lines
5.7 KiB
Python
177 lines
5.7 KiB
Python
"""Authentication service - password hashing, API key management."""
|
|
import hashlib
|
|
import secrets
|
|
from datetime import datetime, timedelta
|
|
from src.backend.utils.tempo import utcnow
|
|
|
|
import bcrypt
|
|
from fastapi import HTTPException, status
|
|
from sqlalchemy import delete, select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src.backend.config import settings
|
|
from src.backend.models.orm.api_key import ApiKey
|
|
from src.backend.models.orm.user import User
|
|
|
|
|
|
def hash_password(password: str) -> str:
|
|
"""Hash a password using bcrypt."""
|
|
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
|
|
|
|
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
"""Verify a password against its hash."""
|
|
return bcrypt.checkpw(
|
|
plain_password.encode("utf-8"), hashed_password.encode("utf-8")
|
|
)
|
|
|
|
|
|
def generate_api_key() -> str:
|
|
"""Generate a secure 64-character API key."""
|
|
return secrets.token_urlsafe(48) # 64 chars base64
|
|
|
|
|
|
# A precomputed, unused bcrypt hash. When the username does not match any
|
|
# user, authenticate_user still runs bcrypt against this instead of
|
|
# returning immediately, so "unknown username" costs the same as "wrong
|
|
# password" and a caller cannot use response time to enumerate usernames.
|
|
_DUMMY_PASSWORD_HASH = "$2b$12$BLtwrR2COyd5W/UWfxp7BOc7OHua9jazo6zZdWOwRD9PV.pyaqKZC"
|
|
|
|
|
|
async def authenticate_user(
|
|
db: AsyncSession, username: str, password: str
|
|
) -> User | None:
|
|
"""Authenticate user by username and password."""
|
|
result = await db.execute(
|
|
select(User).where(User.username == username, User.active == True)
|
|
)
|
|
user = result.scalar_one_or_none()
|
|
if user is None:
|
|
verify_password(password, _DUMMY_PASSWORD_HASH)
|
|
return None
|
|
if not verify_password(password, user.password_hash):
|
|
return None
|
|
return user
|
|
|
|
|
|
async def authorise_supervisor(db: AsyncSession, username: str, password: str) -> User:
|
|
"""Check the credentials of the supervisor authorising something.
|
|
|
|
Used wherever the shop floor needs a capoturno: a line stop, a closure, an
|
|
out-of-tolerance value allowed to stand. Lives here rather than beside any one
|
|
of them because it is the same question every time - who is this, and are they
|
|
allowed to say yes.
|
|
|
|
Uses authenticate_user rather than a full login: logging in would issue a
|
|
needless API key for the supervisor.
|
|
"""
|
|
supervisor = await authenticate_user(db, username, password)
|
|
if supervisor is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid supervisor credentials",
|
|
)
|
|
if not supervisor.has_role("Supervisor") and not supervisor.is_admin:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="User is not authorised as supervisor",
|
|
)
|
|
return supervisor
|
|
|
|
|
|
def hash_api_key(key: str) -> str:
|
|
"""SHA-256 hex digest of an API key; this is all the DB ever stores."""
|
|
return hashlib.sha256(key.encode("utf-8")).hexdigest()
|
|
|
|
|
|
async def issue_api_key(db: AsyncSession, user: User) -> str:
|
|
"""Create a new session key for the user and return it in clear (once)."""
|
|
key = generate_api_key()
|
|
now = utcnow()
|
|
# Housekeeping: drop this user's expired keys so the table does not grow forever.
|
|
await db.execute(
|
|
delete(ApiKey).where(ApiKey.user_id == user.id, ApiKey.expires_at <= now)
|
|
)
|
|
db.add(
|
|
ApiKey(
|
|
user_id=user.id,
|
|
key_hash=hash_api_key(key),
|
|
created_at=now,
|
|
expires_at=now + timedelta(hours=settings.api_key_ttl_hours),
|
|
last_used_at=now,
|
|
)
|
|
)
|
|
await db.flush()
|
|
return key
|
|
|
|
|
|
async def revoke_all_keys(db: AsyncSession, user_id: int) -> None:
|
|
"""Invalidate every session key of a user."""
|
|
await db.execute(delete(ApiKey).where(ApiKey.user_id == user_id))
|
|
await db.flush()
|
|
|
|
|
|
async def login_user(db: AsyncSession, user: User) -> str:
|
|
"""Issue a new API key for this login and update last_login.
|
|
|
|
One key per login: the same account used from several stations at once
|
|
(#25) keeps every station working, because no login revokes another's key.
|
|
"""
|
|
api_key = await issue_api_key(db, user)
|
|
await db.execute(
|
|
update(User).where(User.id == user.id).values(last_login=utcnow())
|
|
)
|
|
await db.flush()
|
|
return api_key
|
|
|
|
|
|
async def logout_user(
|
|
db: AsyncSession, user: User, key: str | None = None, all_sessions: bool = False
|
|
) -> None:
|
|
"""Log out. Deletes only the key of this session; all_sessions=True
|
|
deletes every key of the user."""
|
|
if all_sessions:
|
|
await revoke_all_keys(db, user.id)
|
|
return
|
|
if key:
|
|
await db.execute(
|
|
delete(ApiKey).where(
|
|
ApiKey.user_id == user.id, ApiKey.key_hash == hash_api_key(key)
|
|
)
|
|
)
|
|
await db.flush()
|
|
|
|
|
|
async def create_user(
|
|
db: AsyncSession,
|
|
username: str,
|
|
password: str,
|
|
display_name: str,
|
|
email: str | None = None,
|
|
roles: list[str] | None = None,
|
|
is_admin: bool = False,
|
|
language_pref: str = "it",
|
|
theme_pref: str = "light",
|
|
) -> User:
|
|
"""Create a new user with hashed password."""
|
|
user = User(
|
|
username=username,
|
|
password_hash=hash_password(password),
|
|
display_name=display_name,
|
|
email=email,
|
|
roles=roles or [],
|
|
is_admin=is_admin,
|
|
language_pref=language_pref,
|
|
theme_pref=theme_pref,
|
|
)
|
|
db.add(user)
|
|
await db.flush()
|
|
await db.refresh(user)
|
|
return user
|
|
|
|
|
|
async def regenerate_api_key(db: AsyncSession, user: User) -> str:
|
|
"""Revoke every key of the user and issue a fresh one."""
|
|
await revoke_all_keys(db, user.id)
|
|
return await issue_api_key(db, user)
|