mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-16 08:05:48 -07:00
Revert from Gitea Mirror Due to Catastrophic Destruction in Github
This commit is contained in:
146
Data/Engine/services/API/access_management/github.py
Normal file
146
Data/Engine/services/API/access_management/github.py
Normal file
@@ -0,0 +1,146 @@
|
||||
# ======================================================
|
||||
# Data\Engine\services\API\access_management\github.py
|
||||
# Description: GitHub API token management endpoints for Engine access-management parity.
|
||||
#
|
||||
# API Endpoints (if applicable):
|
||||
# - GET /api/github/token (Token Authenticated (Admin)) - Returns stored GitHub API token details and verification status.
|
||||
# - POST /api/github/token (Token Authenticated (Admin)) - Updates the stored GitHub API token and triggers verification.
|
||||
# ======================================================
|
||||
|
||||
"""GitHub token administration endpoints for the Borealis Engine."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple
|
||||
|
||||
from flask import Blueprint, Flask, jsonify, request, session
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing helper
|
||||
from .. import EngineServiceAdapters
|
||||
|
||||
|
||||
def _now_ts() -> int:
|
||||
return int(time.time())
|
||||
|
||||
|
||||
class GitHubTokenService:
|
||||
"""Admin endpoints for storing and validating GitHub REST API tokens."""
|
||||
|
||||
def __init__(self, app: Flask, adapters: "EngineServiceAdapters") -> None:
|
||||
self.app = app
|
||||
self.adapters = adapters
|
||||
self.github = adapters.github_integration
|
||||
self.logger = adapters.context.logger
|
||||
|
||||
def _token_serializer(self) -> URLSafeTimedSerializer:
|
||||
secret = self.app.secret_key or "borealis-dev-secret"
|
||||
return URLSafeTimedSerializer(secret, salt="borealis-auth")
|
||||
|
||||
def _current_user(self) -> Optional[Dict[str, Any]]:
|
||||
username = session.get("username")
|
||||
role = session.get("role") or "User"
|
||||
if username:
|
||||
return {"username": username, "role": role}
|
||||
|
||||
token = None
|
||||
auth_header = request.headers.get("Authorization") or ""
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
if not token:
|
||||
token = request.cookies.get("borealis_auth")
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
data = self._token_serializer().loads(
|
||||
token,
|
||||
max_age=int(os.environ.get("BOREALIS_TOKEN_TTL_SECONDS", 60 * 60 * 24 * 30)),
|
||||
)
|
||||
username = data.get("u")
|
||||
role = data.get("r") or "User"
|
||||
if username:
|
||||
return {"username": username, "role": role}
|
||||
except (BadSignature, SignatureExpired, Exception):
|
||||
return None
|
||||
return None
|
||||
|
||||
def _require_admin(self) -> Optional[Tuple[Dict[str, Any], int]]:
|
||||
user = self._current_user()
|
||||
if not user:
|
||||
return {"error": "unauthorized"}, 401
|
||||
if (user.get("role") or "").lower() != "admin":
|
||||
return {"error": "forbidden"}, 403
|
||||
return None
|
||||
|
||||
def get_token(self):
|
||||
requirement = self._require_admin()
|
||||
if requirement:
|
||||
payload, status = requirement
|
||||
return jsonify(payload), status
|
||||
|
||||
token = self.github.load_token(force_refresh=True)
|
||||
verification = self.github.verify_token(token)
|
||||
message = verification.get("message") or ("API Token Invalid" if token else "API Token Not Configured")
|
||||
payload = {
|
||||
"token": token or "",
|
||||
"has_token": bool(token),
|
||||
"valid": bool(verification.get("valid")),
|
||||
"message": message,
|
||||
"status": verification.get("status") or ("missing" if not token else "unknown"),
|
||||
"rate_limit": verification.get("rate_limit"),
|
||||
"error": verification.get("error"),
|
||||
"checked_at": _now_ts(),
|
||||
}
|
||||
return jsonify(payload)
|
||||
|
||||
def update_token(self):
|
||||
requirement = self._require_admin()
|
||||
if requirement:
|
||||
payload, status = requirement
|
||||
return jsonify(payload), status
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
token = str(data.get("token") or "").strip()
|
||||
try:
|
||||
self.github.store_token(token or None)
|
||||
except RuntimeError as exc:
|
||||
self.logger.debug("Failed to store GitHub token", exc_info=True)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
|
||||
verification = self.github.verify_token(token or None)
|
||||
message = verification.get("message") or ("API Token Invalid" if token else "API Token Not Configured")
|
||||
|
||||
try:
|
||||
self.github.refresh_default_repo_hash(force=True)
|
||||
except Exception:
|
||||
self.logger.debug("Failed to refresh default repo hash after token update", exc_info=True)
|
||||
|
||||
payload = {
|
||||
"token": token,
|
||||
"has_token": bool(token),
|
||||
"valid": bool(verification.get("valid")),
|
||||
"message": message,
|
||||
"status": verification.get("status") or ("missing" if not token else "unknown"),
|
||||
"rate_limit": verification.get("rate_limit"),
|
||||
"error": verification.get("error"),
|
||||
"checked_at": _now_ts(),
|
||||
}
|
||||
return jsonify(payload)
|
||||
|
||||
|
||||
def register_github_token_management(app: Flask, adapters: "EngineServiceAdapters") -> None:
|
||||
"""Register GitHub API token administration endpoints."""
|
||||
|
||||
service = GitHubTokenService(app, adapters)
|
||||
blueprint = Blueprint("github_access", __name__)
|
||||
|
||||
@blueprint.route("/api/github/token", methods=["GET"])
|
||||
def _github_token_get():
|
||||
return service.get_token()
|
||||
|
||||
@blueprint.route("/api/github/token", methods=["POST"])
|
||||
def _github_token_post():
|
||||
return service.update_token()
|
||||
|
||||
app.register_blueprint(blueprint)
|
||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
@@ -37,6 +38,13 @@ except Exception: # pragma: no cover - optional dependency
|
||||
if TYPE_CHECKING: # pragma: no cover - typing helper
|
||||
from Data.Engine.services.API import EngineServiceAdapters
|
||||
|
||||
from .github import register_github_token_management
|
||||
from .multi_factor_authentication import register_mfa_management
|
||||
from .users import register_user_management
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_qr_logger_warning_emitted = False
|
||||
|
||||
|
||||
def _now_ts() -> int:
|
||||
return int(time.time())
|
||||
@@ -71,7 +79,13 @@ def _totp_provisioning_uri(secret: str, username: str) -> Optional[str]:
|
||||
|
||||
|
||||
def _totp_qr_data_uri(payload: str) -> Optional[str]:
|
||||
if not payload or qrcode is None:
|
||||
global _qr_logger_warning_emitted
|
||||
if not payload:
|
||||
return None
|
||||
if qrcode is None:
|
||||
if not _qr_logger_warning_emitted:
|
||||
_logger.warning("MFA QR generation skipped: 'qrcode' dependency not available.")
|
||||
_qr_logger_warning_emitted = True
|
||||
return None
|
||||
try:
|
||||
image = qrcode.make(payload, box_size=6, border=4)
|
||||
@@ -79,7 +93,10 @@ def _totp_qr_data_uri(payload: str) -> Optional[str]:
|
||||
image.save(buffer, format="PNG")
|
||||
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
|
||||
return f"data:image/png;base64,{encoded}"
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
if not _qr_logger_warning_emitted:
|
||||
_logger.warning("Failed to generate MFA QR code: %s", exc, exc_info=True)
|
||||
_qr_logger_warning_emitted = True
|
||||
return None
|
||||
|
||||
|
||||
@@ -416,4 +433,7 @@ def register_auth(app: Flask, adapters: "EngineServiceAdapters") -> None:
|
||||
return service.me()
|
||||
|
||||
app.register_blueprint(blueprint)
|
||||
register_user_management(app, adapters)
|
||||
register_mfa_management(app, adapters)
|
||||
register_github_token_management(app, adapters)
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# ======================================================
|
||||
# Data\Engine\services\API\access_management\multi_factor_authentication.py
|
||||
# Description: Multifactor administration endpoints for enabling, disabling, or resetting operator MFA state.
|
||||
#
|
||||
# API Endpoints (if applicable):
|
||||
# - POST /api/users/<username>/mfa (Token Authenticated (Admin)) - Toggles MFA and optionally resets shared secrets.
|
||||
# ======================================================
|
||||
|
||||
"""Multifactor administrative endpoints for the Borealis Engine."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple
|
||||
|
||||
from flask import Blueprint, Flask, jsonify, request, session
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing helper
|
||||
from .. import EngineServiceAdapters
|
||||
|
||||
|
||||
def _now_ts() -> int:
|
||||
return int(time.time())
|
||||
|
||||
|
||||
class MultiFactorAdministrationService:
|
||||
"""Admin-focused MFA utility wrapper."""
|
||||
|
||||
def __init__(self, app: Flask, adapters: "EngineServiceAdapters") -> None:
|
||||
self.app = app
|
||||
self.adapters = adapters
|
||||
self.db_conn_factory = adapters.db_conn_factory
|
||||
self.logger = adapters.context.logger
|
||||
|
||||
def _db_conn(self) -> sqlite3.Connection:
|
||||
return self.db_conn_factory()
|
||||
|
||||
def _token_serializer(self) -> URLSafeTimedSerializer:
|
||||
secret = self.app.secret_key or "borealis-dev-secret"
|
||||
return URLSafeTimedSerializer(secret, salt="borealis-auth")
|
||||
|
||||
def _current_user(self) -> Optional[Dict[str, Any]]:
|
||||
username = session.get("username")
|
||||
role = session.get("role") or "User"
|
||||
if username:
|
||||
return {"username": username, "role": role}
|
||||
|
||||
token = None
|
||||
auth_header = request.headers.get("Authorization") or ""
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
if not token:
|
||||
token = request.cookies.get("borealis_auth")
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
data = self._token_serializer().loads(
|
||||
token,
|
||||
max_age=int(os.environ.get("BOREALIS_TOKEN_TTL_SECONDS", 60 * 60 * 24 * 30)),
|
||||
)
|
||||
username = data.get("u")
|
||||
role = data.get("r") or "User"
|
||||
if username:
|
||||
return {"username": username, "role": role}
|
||||
except (BadSignature, SignatureExpired, Exception):
|
||||
return None
|
||||
return None
|
||||
|
||||
def _require_admin(self) -> Optional[Tuple[Dict[str, Any], int]]:
|
||||
user = self._current_user()
|
||||
if not user:
|
||||
return {"error": "unauthorized"}, 401
|
||||
if (user.get("role") or "").lower() != "admin":
|
||||
return {"error": "forbidden"}, 403
|
||||
return None
|
||||
|
||||
def toggle_mfa(self, username: str):
|
||||
requirement = self._require_admin()
|
||||
if requirement:
|
||||
payload, status = requirement
|
||||
return jsonify(payload), status
|
||||
|
||||
username_norm = (username or "").strip()
|
||||
if not username_norm:
|
||||
return jsonify({"error": "invalid username"}), 400
|
||||
|
||||
payload = request.get_json(silent=True) or {}
|
||||
enabled = bool(payload.get("enabled"))
|
||||
reset_secret = bool(payload.get("reset_secret", False))
|
||||
|
||||
conn: Optional[sqlite3.Connection] = None
|
||||
try:
|
||||
conn = self._db_conn()
|
||||
cur = conn.cursor()
|
||||
now_ts = _now_ts()
|
||||
|
||||
if enabled:
|
||||
if reset_secret:
|
||||
cur.execute(
|
||||
"UPDATE users SET mfa_enabled=1, mfa_secret=NULL, updated_at=? WHERE LOWER(username)=LOWER(?)",
|
||||
(now_ts, username_norm),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"UPDATE users SET mfa_enabled=1, updated_at=? WHERE LOWER(username)=LOWER(?)",
|
||||
(now_ts, username_norm),
|
||||
)
|
||||
else:
|
||||
if reset_secret:
|
||||
cur.execute(
|
||||
"UPDATE users SET mfa_enabled=0, mfa_secret=NULL, updated_at=? WHERE LOWER(username)=LOWER(?)",
|
||||
(now_ts, username_norm),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"UPDATE users SET mfa_enabled=0, updated_at=? WHERE LOWER(username)=LOWER(?)",
|
||||
(now_ts, username_norm),
|
||||
)
|
||||
|
||||
if cur.rowcount == 0:
|
||||
return jsonify({"error": "user not found"}), 404
|
||||
|
||||
conn.commit()
|
||||
|
||||
me = self._current_user()
|
||||
if me and me.get("username", "").lower() == username_norm.lower() and not enabled:
|
||||
session.pop("mfa_pending", None)
|
||||
|
||||
return jsonify({"status": "ok"})
|
||||
except Exception as exc:
|
||||
self.logger.debug("Failed to update MFA for %s", username_norm, exc_info=True)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
def register_mfa_management(app: Flask, adapters: "EngineServiceAdapters") -> None:
|
||||
"""Register MFA administration endpoints."""
|
||||
|
||||
service = MultiFactorAdministrationService(app, adapters)
|
||||
blueprint = Blueprint("access_mgmt_mfa", __name__)
|
||||
|
||||
@blueprint.route("/api/users/<username>/mfa", methods=["POST"])
|
||||
def _toggle_mfa(username: str):
|
||||
return service.toggle_mfa(username)
|
||||
|
||||
app.register_blueprint(blueprint)
|
||||
@@ -1,8 +1,317 @@
|
||||
# ======================================================
|
||||
# Data\Engine\services\API\access_management\users.py
|
||||
# Description: Placeholder for operator user management endpoints (not yet implemented).
|
||||
# Description: Operator user CRUD endpoints for the Engine auth group, mirroring the legacy server behaviour.
|
||||
#
|
||||
# API Endpoints (if applicable): None
|
||||
# API Endpoints (if applicable):
|
||||
# - GET /api/users (Token Authenticated (Admin)) - Lists operator accounts.
|
||||
# - POST /api/users (Token Authenticated (Admin)) - Creates a new operator account.
|
||||
# - DELETE /api/users/<username> (Token Authenticated (Admin)) - Deletes an operator account.
|
||||
# - POST /api/users/<username>/reset_password (Token Authenticated (Admin)) - Resets an operator password hash.
|
||||
# - POST /api/users/<username>/role (Token Authenticated (Admin)) - Updates an operator role.
|
||||
# ======================================================
|
||||
|
||||
"""Placeholder for users API module."""
|
||||
"""Operator user management endpoints for the Borealis Engine."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Sequence, Tuple
|
||||
|
||||
from flask import Blueprint, Flask, jsonify, request, session
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing helper
|
||||
from .. import EngineServiceAdapters
|
||||
|
||||
|
||||
def _now_ts() -> int:
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def _row_to_user(row: Sequence[Any]) -> Mapping[str, Any]:
|
||||
"""Convert a database row into a user payload."""
|
||||
return {
|
||||
"id": row[0],
|
||||
"username": row[1],
|
||||
"display_name": row[2] or row[1],
|
||||
"role": row[3] or "User",
|
||||
"last_login": row[4] or 0,
|
||||
"created_at": row[5] or 0,
|
||||
"updated_at": row[6] or 0,
|
||||
"mfa_enabled": 1 if (row[7] or 0) else 0,
|
||||
}
|
||||
|
||||
|
||||
class UserManagementService:
|
||||
"""Utility wrapper that performs admin-authenticated user CRUD operations."""
|
||||
|
||||
def __init__(self, app: Flask, adapters: "EngineServiceAdapters") -> None:
|
||||
self.app = app
|
||||
self.adapters = adapters
|
||||
self.db_conn_factory = adapters.db_conn_factory
|
||||
self.logger = adapters.context.logger
|
||||
|
||||
def _db_conn(self) -> sqlite3.Connection:
|
||||
return self.db_conn_factory()
|
||||
|
||||
def _token_serializer(self) -> URLSafeTimedSerializer:
|
||||
secret = self.app.secret_key or "borealis-dev-secret"
|
||||
return URLSafeTimedSerializer(secret, salt="borealis-auth")
|
||||
|
||||
def _current_user(self) -> Optional[Dict[str, Any]]:
|
||||
username = session.get("username")
|
||||
role = session.get("role") or "User"
|
||||
if username:
|
||||
return {"username": username, "role": role}
|
||||
|
||||
token = None
|
||||
auth_header = request.headers.get("Authorization") or ""
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
if not token:
|
||||
token = request.cookies.get("borealis_auth")
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
data = self._token_serializer().loads(
|
||||
token,
|
||||
max_age=int(os.environ.get("BOREALIS_TOKEN_TTL_SECONDS", 60 * 60 * 24 * 30)),
|
||||
)
|
||||
username = data.get("u")
|
||||
role = data.get("r") or "User"
|
||||
if username:
|
||||
return {"username": username, "role": role}
|
||||
except (BadSignature, SignatureExpired, Exception):
|
||||
return None
|
||||
return None
|
||||
|
||||
def _require_admin(self) -> Optional[Tuple[Dict[str, Any], int]]:
|
||||
user = self._current_user()
|
||||
if not user:
|
||||
return {"error": "unauthorized"}, 401
|
||||
if (user.get("role") or "").lower() != "admin":
|
||||
return {"error": "forbidden"}, 403
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Endpoint implementations
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def list_users(self):
|
||||
requirement = self._require_admin()
|
||||
if requirement:
|
||||
payload, status = requirement
|
||||
return jsonify(payload), status
|
||||
|
||||
conn: Optional[sqlite3.Connection] = None
|
||||
try:
|
||||
conn = self._db_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id, username, display_name, role, last_login, created_at, updated_at, "
|
||||
"COALESCE(mfa_enabled, 0) FROM users ORDER BY LOWER(username) ASC"
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
users: List[Mapping[str, Any]] = [_row_to_user(row) for row in rows]
|
||||
return jsonify({"users": users})
|
||||
except Exception as exc:
|
||||
self.logger.debug("Failed to list users", exc_info=True)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def create_user(self):
|
||||
requirement = self._require_admin()
|
||||
if requirement:
|
||||
payload, status = requirement
|
||||
return jsonify(payload), status
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
username = (data.get("username") or "").strip()
|
||||
display_name = (data.get("display_name") or username).strip()
|
||||
role = (data.get("role") or "User").strip().title()
|
||||
password_sha512 = (data.get("password_sha512") or "").strip().lower()
|
||||
|
||||
if not username or not password_sha512:
|
||||
return jsonify({"error": "username and password_sha512 are required"}), 400
|
||||
if role not in ("User", "Admin"):
|
||||
return jsonify({"error": "invalid role"}), 400
|
||||
|
||||
now_ts = _now_ts()
|
||||
conn: Optional[sqlite3.Connection] = None
|
||||
try:
|
||||
conn = self._db_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"INSERT INTO users(username, display_name, password_sha512, role, created_at, updated_at) "
|
||||
"VALUES(?,?,?,?,?,?)",
|
||||
(username, display_name or username, password_sha512, role, now_ts, now_ts),
|
||||
)
|
||||
conn.commit()
|
||||
return jsonify({"status": "ok"})
|
||||
except sqlite3.IntegrityError:
|
||||
return jsonify({"error": "username already exists"}), 409
|
||||
except Exception as exc:
|
||||
self.logger.debug("Failed to create user %s", username, exc_info=True)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def delete_user(self, username: str):
|
||||
requirement = self._require_admin()
|
||||
if requirement:
|
||||
payload, status = requirement
|
||||
return jsonify(payload), status
|
||||
|
||||
username_norm = (username or "").strip()
|
||||
if not username_norm:
|
||||
return jsonify({"error": "invalid username"}), 400
|
||||
|
||||
conn: Optional[sqlite3.Connection] = None
|
||||
try:
|
||||
conn = self._db_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
me = self._current_user()
|
||||
if me and (me.get("username", "").lower() == username_norm.lower()):
|
||||
return (
|
||||
jsonify({"error": "You cannot delete the user you are currently logged in as."}),
|
||||
400,
|
||||
)
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM users")
|
||||
total_users = cur.fetchone()[0] or 0
|
||||
if total_users <= 1:
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": "There is only one user currently configured, you cannot delete this user until you have created another."
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
|
||||
cur.execute("DELETE FROM users WHERE LOWER(username)=LOWER(?)", (username_norm,))
|
||||
deleted = cur.rowcount or 0
|
||||
conn.commit()
|
||||
if deleted == 0:
|
||||
return jsonify({"error": "user not found"}), 404
|
||||
return jsonify({"status": "ok"})
|
||||
except Exception as exc:
|
||||
self.logger.debug("Failed to delete user %s", username_norm, exc_info=True)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def reset_password(self, username: str):
|
||||
requirement = self._require_admin()
|
||||
if requirement:
|
||||
payload, status = requirement
|
||||
return jsonify(payload), status
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
password_sha512 = (data.get("password_sha512") or "").strip().lower()
|
||||
if not password_sha512 or len(password_sha512) != 128:
|
||||
return jsonify({"error": "invalid password hash"}), 400
|
||||
|
||||
conn: Optional[sqlite3.Connection] = None
|
||||
try:
|
||||
conn = self._db_conn()
|
||||
cur = conn.cursor()
|
||||
now_ts = _now_ts()
|
||||
cur.execute(
|
||||
"UPDATE users SET password_sha512=?, updated_at=? WHERE LOWER(username)=LOWER(?)",
|
||||
(password_sha512, now_ts, username),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
return jsonify({"error": "user not found"}), 404
|
||||
conn.commit()
|
||||
return jsonify({"status": "ok"})
|
||||
except Exception as exc:
|
||||
self.logger.debug("Failed to reset password for %s", username, exc_info=True)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def change_role(self, username: str):
|
||||
requirement = self._require_admin()
|
||||
if requirement:
|
||||
payload, status = requirement
|
||||
return jsonify(payload), status
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
role = (data.get("role") or "").strip().title()
|
||||
if role not in ("User", "Admin"):
|
||||
return jsonify({"error": "invalid role"}), 400
|
||||
|
||||
conn: Optional[sqlite3.Connection] = None
|
||||
try:
|
||||
conn = self._db_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
if role == "User":
|
||||
cur.execute("SELECT COUNT(*) FROM users WHERE LOWER(role)='admin'")
|
||||
admin_count = cur.fetchone()[0] or 0
|
||||
cur.execute(
|
||||
"SELECT LOWER(role) FROM users WHERE LOWER(username)=LOWER(?)",
|
||||
(username,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
current_role = (row[0] or "").lower() if row else ""
|
||||
if current_role == "admin" and admin_count <= 1:
|
||||
return jsonify({"error": "cannot demote the last admin"}), 400
|
||||
|
||||
now_ts = _now_ts()
|
||||
cur.execute(
|
||||
"UPDATE users SET role=?, updated_at=? WHERE LOWER(username)=LOWER(?)",
|
||||
(role, now_ts, username),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
return jsonify({"error": "user not found"}), 404
|
||||
conn.commit()
|
||||
|
||||
me = self._current_user()
|
||||
if me and me.get("username", "").lower() == (username or "").lower():
|
||||
session["role"] = role
|
||||
|
||||
return jsonify({"status": "ok"})
|
||||
except Exception as exc:
|
||||
self.logger.debug("Failed to update role for %s", username, exc_info=True)
|
||||
return jsonify({"error": str(exc)}), 500
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
def register_user_management(app: Flask, adapters: "EngineServiceAdapters") -> None:
|
||||
"""Register user management endpoints."""
|
||||
|
||||
service = UserManagementService(app, adapters)
|
||||
blueprint = Blueprint("access_mgmt_users", __name__)
|
||||
|
||||
@blueprint.route("/api/users", methods=["GET"])
|
||||
def _list_users():
|
||||
return service.list_users()
|
||||
|
||||
@blueprint.route("/api/users", methods=["POST"])
|
||||
def _create_user():
|
||||
return service.create_user()
|
||||
|
||||
@blueprint.route("/api/users/<username>", methods=["DELETE"])
|
||||
def _delete_user(username: str):
|
||||
return service.delete_user(username)
|
||||
|
||||
@blueprint.route("/api/users/<username>/reset_password", methods=["POST"])
|
||||
def _reset_password(username: str):
|
||||
return service.reset_password(username)
|
||||
|
||||
@blueprint.route("/api/users/<username>/role", methods=["POST"])
|
||||
def _change_role(username: str):
|
||||
return service.change_role(username)
|
||||
|
||||
app.register_blueprint(blueprint)
|
||||
|
||||
Reference in New Issue
Block a user