first commit

This commit is contained in:
2025-12-15 12:07:32 +01:00
commit 6f49528bbd
21 changed files with 1258 additions and 0 deletions

107
backend/auth.py Normal file
View File

@ -0,0 +1,107 @@
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