admin / Strike
publicWeb-Based UK Cyber Compliance Tool with Reporting
Strike / strikexi-v2 / backend / app / routers / users.py
13140 B · main
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 | """ User administration & self-service account management. Admins can create, update, disable and delete users, force password changes, and require/reset MFA. Any authenticated user can change their own password and set up / disable their own MFA (unless an admin has marked it required). Every change is written to the audit log with the acting user's username. """ import csv import io from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, status, Request from fastapi.responses import StreamingResponse, FileResponse from sqlalchemy.orm import Session from .. import models, schemas, user_report from ..database import get_db from ..security import ( get_current_user, require_admin, hash_password, verify_password, client_ip, write_audit, password_problem, generate_mfa_secret, verify_totp, provisioning_uri, qr_data_uri, ) router = APIRouter(prefix="/api/users", tags=["users"]) def _count_active_admins(db: Session, exclude_id=None) -> int: q = db.query(models.User).filter(models.User.role == "admin", models.User.is_active.is_(True)) if exclude_id is not None: q = q.filter(models.User.id != exclude_id) return q.count() # =========================================================================== # Self-service (any authenticated user) # =========================================================================== @router.post("/me/password") def change_my_password(payload: schemas.PasswordChange, request: Request, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)): if not verify_password(payload.current_password, user.password_hash): raise HTTPException(status_code=400, detail="Current password is incorrect.") prob = password_problem(payload.new_password) if prob: raise HTTPException(status_code=400, detail=prob) user.password_hash = hash_password(payload.new_password) user.must_change_password = False db.commit() write_audit(db, user.username, "PASSWORD_CHANGED", "User changed own password", client_ip(request)) return {"detail": "Password updated"} @router.post("/me/mfa/setup", response_model=schemas.MfaSetupOut) def setup_my_mfa(request: Request, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)): """Generate a pending secret + QR. MFA is not enabled until confirmed.""" secret = generate_mfa_secret() user.mfa_secret = secret user.mfa_enabled = False db.commit() uri = provisioning_uri(user.username, secret) write_audit(db, user.username, "MFA_SETUP_BEGIN", "Started MFA enrolment", client_ip(request)) return schemas.MfaSetupOut(secret=secret, uri=uri, qr=qr_data_uri(uri)) @router.post("/me/mfa/confirm") def confirm_my_mfa(payload: schemas.MfaConfirm, request: Request, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)): if not user.mfa_secret: raise HTTPException(status_code=400, detail="No pending MFA enrolment found.") if not verify_totp(user.mfa_secret, payload.code): raise HTTPException(status_code=400, detail="Incorrect code. Try again.") user.mfa_enabled = True # A self-service user enrolling MFA on first login completes their # first-login hardening here. if user.first_login_pending: user.first_login_pending = False db.commit() write_audit(db, user.username, "MFA_ENABLED", "User enabled MFA", client_ip(request)) return {"detail": "MFA enabled"} @router.post("/me/mfa/disable") def disable_my_mfa(request: Request, db: Session = Depends(get_db), user: models.User = Depends(get_current_user)): if user.mfa_required: raise HTTPException( status_code=403, detail="MFA is required for your account by an administrator and " "cannot be disabled.") user.mfa_enabled = False user.mfa_secret = None db.commit() write_audit(db, user.username, "MFA_DISABLED", "User disabled MFA", client_ip(request)) return {"detail": "MFA disabled"} # =========================================================================== # Admin: user administration # =========================================================================== @router.get("", response_model=list[schemas.UserOut]) def list_users(db: Session = Depends(get_db), admin: models.User = Depends(require_admin)): return db.query(models.User).order_by(models.User.username).all() # --------------------------------------------------------------------------- # Admin-only: Users report (CSV / PDF) — all admin-created and self-service users # --------------------------------------------------------------------------- _REPORT_COLUMNS = [ ("username", "Username"), ("surname", "Surname"), ("first_name", "First Name"), ("full_name", "Full Name"), ("company_name", "Company"), ("email", "Email"), ("contact_number", "Contact Number"), ("role", "Role"), ("signup_origin", "Account Type"), ("is_active", "Active"), ("mfa_enabled", "MFA"), ("last_login", "Last Login"), ("created_at", "Created"), ] def _origin_label(origin: str) -> str: return "Self-service signup" if origin == "self_service" else "Admin created" @router.get("/report.csv") def users_report_csv(request: Request, db: Session = Depends(get_db), admin: models.User = Depends(require_admin)): users = db.query(models.User).order_by(models.User.username).all() buf = io.StringIO() writer = csv.writer(buf) writer.writerow([h for _, h in _REPORT_COLUMNS]) for u in users: row = [] for key, _ in _REPORT_COLUMNS: val = getattr(u, key) if key == "signup_origin": val = _origin_label(val) elif key in ("is_active", "mfa_enabled"): val = "Yes" if val else "No" elif key in ("last_login", "created_at") and val: val = val.strftime("%Y-%m-%d %H:%M") row.append(val if val is not None else "") writer.writerow(row) buf.seek(0) write_audit(db, admin.username, "USERS_REPORT_EXPORT", f"format=csv count={len(users)}", client_ip(request)) return StreamingResponse( iter([buf.getvalue()]), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=StrikeXi_Users_Report.csv"}) @router.get("/report.pdf") def users_report_pdf(request: Request, db: Session = Depends(get_db), admin: models.User = Depends(require_admin)): users = db.query(models.User).order_by(models.User.username).all() path = user_report.generate_users_report(users, admin.username) write_audit(db, admin.username, "USERS_REPORT_EXPORT", f"format=pdf count={len(users)}", client_ip(request)) return FileResponse(path, media_type="application/pdf", filename="StrikeXi_Users_Report.pdf") @router.post("", response_model=schemas.UserOut, status_code=201) def create_user(payload: schemas.UserCreate, request: Request, db: Session = Depends(get_db), admin: models.User = Depends(require_admin)): username = (payload.username or "").strip() if not username: raise HTTPException(status_code=400, detail="Username is required.") if payload.role not in ("admin", "assessor", "user"): raise HTTPException(status_code=400, detail="Role must be 'admin', 'assessor' or 'user'.") prob = password_problem(payload.password) if prob: raise HTTPException(status_code=400, detail=prob) if db.query(models.User).filter(models.User.username == username).first(): raise HTTPException(status_code=409, detail=f"User '{username}' already exists.") full_name = payload.full_name if not full_name and (payload.first_name or payload.surname): full_name = f"{(payload.first_name or '').strip()} {(payload.surname or '').strip()}".strip() user = models.User( username=username, password_hash=hash_password(payload.password), full_name=full_name, role=payload.role, is_active=True, must_change_password=payload.must_change_password, mfa_required=payload.mfa_required, first_name=payload.first_name, surname=payload.surname, company_name=payload.company_name, email=payload.email, contact_number=payload.contact_number, signup_origin="admin", ) db.add(user) db.commit() db.refresh(user) write_audit(db, admin.username, "USER_CREATED", f"Created user '{username}' role={payload.role} " f"mfa_required={payload.mfa_required}", client_ip(request)) return user @router.patch("/{user_id}", response_model=schemas.UserOut) def update_user(user_id: str, payload: schemas.UserUpdate, request: Request, db: Session = Depends(get_db), admin: models.User = Depends(require_admin)): user = db.query(models.User).filter(models.User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found.") # Guard the last active admin against demotion/disabling. demoting = payload.role is not None and payload.role != "admin" and user.role == "admin" disabling = payload.is_active is False and user.is_active if (demoting or disabling) and _count_active_admins(db, exclude_id=user.id) == 0: raise HTTPException(status_code=400, detail="Refusing to remove the last active administrator.") changes = [] if payload.full_name is not None: user.full_name = payload.full_name; changes.append("full_name") if payload.role is not None: if payload.role not in ("admin", "assessor", "user"): raise HTTPException(status_code=400, detail="Role must be 'admin', 'assessor' or 'user'.") user.role = payload.role; changes.append("role") if payload.is_active is not None: user.is_active = payload.is_active; changes.append("is_active") if payload.password: prob = password_problem(payload.password) if prob: raise HTTPException(status_code=400, detail=prob) user.password_hash = hash_password(payload.password) user.must_change_password = True changes.append("password(reset)") if payload.mfa_required is not None: user.mfa_required = payload.mfa_required; changes.append("mfa_required") if payload.must_change_password is not None: user.must_change_password = payload.must_change_password changes.append("must_change_password") if not changes: raise HTTPException(status_code=400, detail="No changes supplied.") db.commit() db.refresh(user) write_audit(db, admin.username, "USER_UPDATED", f"Updated '{user.username}': {', '.join(changes)}", client_ip(request)) return user @router.post("/{user_id}/mfa/reset", response_model=schemas.UserOut) def admin_reset_mfa(user_id: str, request: Request, db: Session = Depends(get_db), admin: models.User = Depends(require_admin)): """Admin disables/clears a user's MFA (they re-enrol if MFA is required).""" user = db.query(models.User).filter(models.User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found.") user.mfa_enabled = False user.mfa_secret = None db.commit() db.refresh(user) write_audit(db, admin.username, "USER_MFA_RESET", f"Reset MFA for '{user.username}'", client_ip(request)) return user @router.delete("/{user_id}") def delete_user(user_id: str, request: Request, db: Session = Depends(get_db), admin: models.User = Depends(require_admin)): user = db.query(models.User).filter(models.User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found.") if user.id == admin.id: raise HTTPException(status_code=400, detail="You cannot delete your own account.") if user.role == "admin" and _count_active_admins(db, exclude_id=user.id) == 0: raise HTTPException(status_code=400, detail="Refusing to delete the last active administrator.") # Block deletion if the user owns assessments (referential integrity). owned = db.query(models.Assessment).filter(models.Assessment.user_id == user.id).count() if owned: raise HTTPException( status_code=400, detail=f"User owns {owned} assessment(s); disable the account instead.") uname = user.username db.delete(user) db.commit() write_audit(db, admin.username, "USER_DELETED", f"Deleted user '{uname}'", client_ip(request)) return {"detail": "User deleted"} |