Files
kiddo/backend/auth.py
2025-12-15 12:07:32 +01:00

108 lines
3.6 KiB
Python

import datetime as dt
import grp
import pwd
from typing import List, Set
import jwt
import pam
from fastapi import Depends, HTTPException, Request, status
from backend.settings import Settings, get_settings
def _is_member_of(username: str, groups: Set[str]) -> bool:
if not groups:
return False
try:
user_entry = pwd.getpwnam(username)
except KeyError:
return False
user_groups = {g.gr_name for g in grp.getgrall() if username in g.gr_mem}
primary_group = grp.getgrgid(user_entry.pw_gid).gr_name
user_groups.add(primary_group)
return bool(user_groups & groups)
def is_authorized_admin(username: str, settings: Settings) -> bool:
# UID 0 always allowed
try:
entry = pwd.getpwnam(username)
except KeyError:
return False
if entry.pw_uid == 0:
return True
allowed_users = set(settings.auth_allowed_users)
allowed_groups = set(settings.auth_allowed_groups)
if username in allowed_users:
return True
if _is_member_of(username, allowed_groups):
return True
return False
def authenticate_admin_user(username: str, password: str, settings: Settings) -> None:
if not is_authorized_admin(username, settings):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User not authorized to log in",
)
pam_client = pam.pam()
if not pam_client.authenticate(username, password, service=settings.auth_pam_service):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
)
def issue_token(username: str, settings: Settings) -> str:
payload = {
"sub": username,
"exp": dt.datetime.utcnow() + dt.timedelta(seconds=settings.token_ttl_seconds),
"iat": dt.datetime.utcnow(),
}
return jwt.encode(payload, settings.auth_secret, algorithm="HS256")
def decode_token(token: str, settings: Settings) -> str:
try:
payload = jwt.decode(token, settings.auth_secret, algorithms=["HS256"])
except jwt.ExpiredSignatureError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") from exc
except jwt.PyJWTError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
username = payload.get("sub")
if not username or not is_authorized_admin(username, settings):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Unauthorized user")
return username
def get_current_admin(
request: Request,
settings: Settings = Depends(get_settings),
) -> str:
auth_header = request.headers.get("authorization")
if not auth_header or not auth_header.lower().startswith("bearer "):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
token = auth_header.split(" ", 1)[1].strip()
return decode_token(token, settings)
def list_manageable_users(settings: Settings) -> List[str]:
"""List system users we are allowed to manage (uid >= 1000, real shells, optional allowlist)."""
allowed = set(settings.allowed_users) if settings.allowed_users else None
candidates: List[str] = []
for entry in pwd.getpwall():
if entry.pw_uid == 0:
# Never operate on root accounts
continue
if entry.pw_uid < 1000:
continue
if entry.pw_shell in ("/usr/sbin/nologin", "/bin/false"):
continue
if allowed is not None and entry.pw_name not in allowed:
continue
candidates.append(entry.pw_name)
candidates.sort()
return candidates