331 lines
9.4 KiB
Python
331 lines
9.4 KiB
Python
from typing import Any
|
||
|
||
from fastapi import HTTPException, Request, status
|
||
from passlib.context import CryptContext
|
||
|
||
from app.db.database import get_connection
|
||
from app.db.migrations import run_migrations
|
||
|
||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||
|
||
|
||
def _table_columns(con, table_name: str) -> set[str]:
|
||
rows = con.execute(f"PRAGMA table_info({table_name})").fetchall()
|
||
return {row["name"] for row in rows}
|
||
|
||
|
||
def _user_select_columns(con) -> str:
|
||
columns = _table_columns(con, "users")
|
||
enabled_select = "COALESCE(is_enabled, 1) AS is_enabled" if "is_enabled" in columns else "1 AS is_enabled"
|
||
optional_columns = []
|
||
for column in (
|
||
"last_login_at",
|
||
"auth_provider",
|
||
"provider_subject",
|
||
"avatar_url",
|
||
"gitea_user_id",
|
||
"gitea_sync_status",
|
||
"gitea_sync_error",
|
||
"gitea_synced_at",
|
||
):
|
||
if column in columns:
|
||
optional_columns.append(column)
|
||
else:
|
||
optional_columns.append(f"NULL AS {column}")
|
||
return (
|
||
"id, username, display_name, email, password_hash, role, "
|
||
f"is_active, {enabled_select}, created_at, updated_at, "
|
||
+ ", ".join(optional_columns)
|
||
)
|
||
|
||
|
||
def mark_last_login(user_id: int) -> None:
|
||
run_migrations()
|
||
con = get_connection()
|
||
columns = _table_columns(con, "users")
|
||
if "last_login_at" in columns:
|
||
con.execute(
|
||
"""
|
||
UPDATE users
|
||
SET last_login_at = CURRENT_TIMESTAMP,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = ?
|
||
""",
|
||
(user_id,),
|
||
)
|
||
con.commit()
|
||
con.close()
|
||
|
||
|
||
def hash_password(password: str) -> str:
|
||
return pwd_context.hash(password)
|
||
|
||
|
||
def verify_password(plain_password: str, password_hash: str) -> bool:
|
||
if not password_hash:
|
||
return False
|
||
|
||
return pwd_context.verify(plain_password, password_hash)
|
||
|
||
|
||
def get_user_by_username(username: str) -> dict[str, Any] | None:
|
||
run_migrations()
|
||
con = get_connection()
|
||
|
||
row = con.execute(
|
||
f"""
|
||
SELECT {_user_select_columns(con)}
|
||
FROM users
|
||
WHERE username = ?
|
||
""",
|
||
(username,),
|
||
).fetchone()
|
||
|
||
con.close()
|
||
return dict(row) if row else None
|
||
|
||
|
||
def find_or_create_oauth_user(username: str, display_name: str, email: str) -> dict[str, Any]:
|
||
username = username.strip()
|
||
display_name = (display_name or username).strip() or username
|
||
email = (email or "").strip()
|
||
|
||
run_migrations()
|
||
con = get_connection()
|
||
row = con.execute(
|
||
f"""
|
||
SELECT {_user_select_columns(con)}
|
||
FROM users
|
||
WHERE username = ?
|
||
""",
|
||
(username,),
|
||
).fetchone()
|
||
|
||
if row:
|
||
con.execute(
|
||
"""
|
||
UPDATE users
|
||
SET display_name = ?,
|
||
email = ?,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = ?
|
||
""",
|
||
(display_name, email, row["id"]),
|
||
)
|
||
else:
|
||
con.execute(
|
||
"""
|
||
INSERT INTO users (
|
||
username,
|
||
display_name,
|
||
email,
|
||
password_hash,
|
||
role,
|
||
is_active,
|
||
created_at,
|
||
updated_at
|
||
)
|
||
VALUES (?, ?, ?, '', 'developer', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||
""",
|
||
(username, display_name, email),
|
||
)
|
||
|
||
con.commit()
|
||
user_id = row["id"] if row else con.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||
user = con.execute(
|
||
f"""
|
||
SELECT {_user_select_columns(con)}
|
||
FROM users
|
||
WHERE id = ?
|
||
""",
|
||
(user_id,),
|
||
).fetchone()
|
||
|
||
con.close()
|
||
return dict(user)
|
||
|
||
|
||
def find_or_create_google_user(email: str, display_name: str, provider_subject: str, avatar_url: str = "") -> tuple[dict[str, Any], bool]:
|
||
email = email.strip().lower()
|
||
display_name = (display_name or email).strip() or email
|
||
provider_subject = (provider_subject or "").strip()
|
||
avatar_url = (avatar_url or "").strip()
|
||
|
||
run_migrations()
|
||
con = get_connection()
|
||
columns = _table_columns(con, "users")
|
||
params: list[Any] = [email, email]
|
||
provider_clause = ""
|
||
if provider_subject:
|
||
provider_clause = " OR (auth_provider = 'google' AND provider_subject = ?)"
|
||
params.append(provider_subject)
|
||
|
||
row = con.execute(
|
||
f"""
|
||
SELECT {_user_select_columns(con)}
|
||
FROM users
|
||
WHERE LOWER(COALESCE(email, '')) = ?
|
||
OR LOWER(COALESCE(username, '')) = ?
|
||
{provider_clause}
|
||
ORDER BY
|
||
CASE
|
||
WHEN LOWER(COALESCE(email, '')) = ? THEN 0
|
||
WHEN LOWER(COALESCE(username, '')) = ? THEN 1
|
||
ELSE 2
|
||
END,
|
||
id
|
||
LIMIT 1
|
||
""",
|
||
(*params, email, email),
|
||
).fetchone()
|
||
|
||
if row:
|
||
updates = [
|
||
"display_name = ?",
|
||
"email = ?",
|
||
"auth_provider = 'google'",
|
||
"provider_subject = ?",
|
||
"updated_at = CURRENT_TIMESTAMP",
|
||
]
|
||
values: list[Any] = [display_name, email, provider_subject]
|
||
if "avatar_url" in columns:
|
||
updates.append("avatar_url = ?")
|
||
values.append(avatar_url)
|
||
values.append(row["id"])
|
||
con.execute(f"UPDATE users SET {', '.join(updates)} WHERE id = ?", values)
|
||
created = False
|
||
user_id = row["id"]
|
||
else:
|
||
role = "admin" if con.execute("SELECT COUNT(*) AS count FROM users").fetchone()["count"] == 0 else "viewer"
|
||
insert_columns = [
|
||
"username",
|
||
"display_name",
|
||
"email",
|
||
"password_hash",
|
||
"role",
|
||
"is_active",
|
||
"is_enabled",
|
||
"auth_provider",
|
||
"provider_subject",
|
||
"created_at",
|
||
"updated_at",
|
||
]
|
||
values = [email, display_name, email, "", role, 1, 1, "google", provider_subject]
|
||
placeholders = ["?", "?", "?", "?", "?", "?", "?", "?", "?", "CURRENT_TIMESTAMP", "CURRENT_TIMESTAMP"]
|
||
if "avatar_url" in columns:
|
||
insert_columns.append("avatar_url")
|
||
values.append(avatar_url)
|
||
placeholders.append("?")
|
||
con.execute(
|
||
f"""
|
||
INSERT INTO users ({', '.join(insert_columns)})
|
||
VALUES ({', '.join(placeholders)})
|
||
""",
|
||
values,
|
||
)
|
||
created = True
|
||
user_id = con.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||
|
||
con.commit()
|
||
user = con.execute(
|
||
f"""
|
||
SELECT {_user_select_columns(con)}
|
||
FROM users
|
||
WHERE id = ?
|
||
""",
|
||
(user_id,),
|
||
).fetchone()
|
||
|
||
con.close()
|
||
return dict(user), created
|
||
|
||
|
||
def get_user_by_id(user_id: int) -> dict[str, Any] | None:
|
||
run_migrations()
|
||
con = get_connection()
|
||
|
||
row = con.execute(
|
||
f"""
|
||
SELECT {_user_select_columns(con)}
|
||
FROM users
|
||
WHERE id = ?
|
||
""",
|
||
(user_id,),
|
||
).fetchone()
|
||
|
||
con.close()
|
||
return dict(row) if row else None
|
||
|
||
|
||
def authenticate_user(username: str, password: str) -> dict[str, Any] | None:
|
||
user = get_user_by_username(username.strip())
|
||
|
||
if not user or not user.get("is_active") or not user.get("is_enabled", 1):
|
||
return None
|
||
|
||
if not verify_password(password, user.get("password_hash") or ""):
|
||
return None
|
||
|
||
mark_last_login(int(user["id"]))
|
||
return user
|
||
|
||
|
||
def current_user(request: Request) -> dict[str, Any] | None:
|
||
user_id = request.session.get("user_id")
|
||
if not user_id:
|
||
return None
|
||
|
||
try:
|
||
user_id = int(user_id)
|
||
except (TypeError, ValueError):
|
||
request.session.pop("user_id", None)
|
||
return None
|
||
|
||
user = get_user_by_id(user_id)
|
||
if not user or not user.get("is_active") or not user.get("is_enabled", 1):
|
||
request.session.pop("user_id", None)
|
||
return None
|
||
|
||
return user
|
||
|
||
|
||
def require_user(request: Request) -> dict[str, Any]:
|
||
user = current_user(request)
|
||
if user:
|
||
return user
|
||
|
||
raise HTTPException(
|
||
status_code=status.HTTP_303_SEE_OTHER,
|
||
headers={"Location": "/portal/login"},
|
||
)
|
||
|
||
|
||
# --- Role-based access control ----------------------------------------------
|
||
# Portal roles, from least to most privileged:
|
||
# viewer – read-only: sees which services run and their documentation, nothing more.
|
||
# developer – everything a viewer sees + operational service workflows (Git/clone, deploy,
|
||
# variables, resources, metadata). Cannot delete services or access admin areas.
|
||
# admin – full access, including deleting services and the Administration / Users area.
|
||
|
||
def is_admin(user: dict | None) -> bool:
|
||
return bool(user) and (user.get("role") or "").lower() == "admin"
|
||
|
||
|
||
def is_developer(user: dict | None) -> bool:
|
||
"""True for developers and admins — the roles allowed to operate on services."""
|
||
return bool(user) and (user.get("role") or "").lower() in ("admin", "developer")
|
||
|
||
|
||
def require_admin(request: Request) -> dict[str, Any]:
|
||
user = require_user(request)
|
||
if not is_admin(user):
|
||
raise HTTPException(status_code=403, detail="Tato akce je dostupná jen administrátorům.")
|
||
return user
|
||
|
||
|
||
def require_developer(request: Request) -> dict[str, Any]:
|
||
user = require_user(request)
|
||
if not is_developer(user):
|
||
raise HTTPException(status_code=403, detail="Tato akce je dostupná jen vývojářům a administrátorům.")
|
||
return user
|