modified: web/app.py
modified: web/blueprints/auth.py modified: web/blueprints/group_admin.py modified: web/blueprints/site_admin.py new file: web/limiter.py modified: web/panel_db.py modified: web/requirements.txt new file: web/templates/429.html new file: web/templates/admin/audit_log.html modified: web/templates/admin/base.html
This commit is contained in:
@@ -5,18 +5,21 @@ Getrennte Login-Seiten für Site-Admins und normale Nutzer/Gruppen-Admins.
|
||||
import json
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
|
||||
from panel_db import accept_group_invite, check_login, get_invite_by_token, get_user_groups
|
||||
from panel_db import accept_group_invite, check_login, get_invite_by_token, get_user_groups, log_audit_event
|
||||
from limiter import limiter
|
||||
|
||||
auth = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
@auth.route("/login", methods=["GET", "POST"])
|
||||
@limiter.limit("15 per minute", methods=["POST"])
|
||||
def login():
|
||||
if session.get("user_id"):
|
||||
return redirect(url_for("panel.dashboard"))
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
user = check_login(request.form.get("username", ""), request.form.get("password", ""))
|
||||
username = request.form.get("username", "")
|
||||
user = check_login(username, request.form.get("password", ""))
|
||||
if user and user["is_site_admin"]:
|
||||
flash("Please use the Site Admin login.", "warning")
|
||||
return redirect(url_for("auth.admin_login"))
|
||||
@@ -26,29 +29,56 @@ def login():
|
||||
error = "You are not assigned to any group. Please contact an admin."
|
||||
else:
|
||||
_set_user_session(user, groups)
|
||||
log_audit_event(
|
||||
user["id"], user["username"], "user.login",
|
||||
entity_type="user", entity_id=user["id"],
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
return redirect(url_for("panel.dashboard"))
|
||||
else:
|
||||
log_audit_event(
|
||||
None, None, "user.login_failed",
|
||||
details={"username": username},
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
error = "Incorrect username or password."
|
||||
return render_template("auth/login.html", error=error)
|
||||
|
||||
|
||||
@auth.route("/admin/login", methods=["GET", "POST"])
|
||||
@limiter.limit("10 per minute", methods=["POST"])
|
||||
def admin_login():
|
||||
if session.get("is_site_admin"):
|
||||
return redirect(url_for("site_admin.dashboard"))
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
user = check_login(request.form.get("username", ""), request.form.get("password", ""))
|
||||
username = request.form.get("username", "")
|
||||
user = check_login(username, request.form.get("password", ""))
|
||||
if user and user["is_site_admin"]:
|
||||
session["user_id"] = user["id"]
|
||||
session["username"] = user["username"]
|
||||
session["is_site_admin"] = True
|
||||
session["group_id"] = None
|
||||
session["permissions"] = {}
|
||||
log_audit_event(
|
||||
user["id"], user["username"], "admin.login",
|
||||
entity_type="user", entity_id=user["id"],
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
return redirect(url_for("site_admin.dashboard"))
|
||||
elif user:
|
||||
log_audit_event(
|
||||
user["id"], user["username"], "admin.login_failed",
|
||||
details={"reason": "no_admin_privileges"},
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
error = "No Site Admin privileges."
|
||||
else:
|
||||
log_audit_event(
|
||||
None, None, "admin.login_failed",
|
||||
details={"username": username},
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
error = "Incorrect username or password."
|
||||
return render_template("auth/admin_login.html", error=error)
|
||||
|
||||
@@ -74,6 +104,7 @@ def switch_group(group_id):
|
||||
|
||||
|
||||
@auth.route("/invite/<token>", methods=["GET", "POST"])
|
||||
@limiter.limit("20 per minute", methods=["POST"])
|
||||
def accept_invite(token):
|
||||
if session.get("user_id"):
|
||||
return redirect(url_for("panel.dashboard"))
|
||||
@@ -103,6 +134,14 @@ def accept_invite(token):
|
||||
if result.get("error") == "username_or_email_taken":
|
||||
error = "The invited username or email is already in use. Please contact your administrator."
|
||||
else:
|
||||
log_audit_event(
|
||||
result.get("user_id"), invite["invited_username"],
|
||||
"invite.accepted",
|
||||
entity_type="invite", entity_id=invite["id"],
|
||||
details={"group_id": invite.get("group_id"), "role": invite.get("role")},
|
||||
group_id=invite.get("group_id"),
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Your account has been created. You can now sign in.", "success")
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from config import Config
|
||||
from mailer import send_mail, build_invite_email, force_https_url
|
||||
import panel_db as db
|
||||
from roles import GROUP_MANAGEMENT_ROLES, GROUP_ROLE_OPTIONS, GROUP_ROLE_SET, OWNER_ONLY_ROLES, role_label
|
||||
from limiter import limiter
|
||||
|
||||
group_admin = Blueprint("group_admin", __name__, url_prefix="/group-admin")
|
||||
|
||||
@@ -95,12 +96,20 @@ def member_add():
|
||||
return redirect(url_for("group_admin.members"))
|
||||
if user_id:
|
||||
db.add_group_member(user_id, group_id, role)
|
||||
target_user = db.get_user_by_id(user_id)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "member.added",
|
||||
entity_type="user", entity_id=user_id,
|
||||
details={"role": role, "target": target_user["username"] if target_user else str(user_id)},
|
||||
group_id=group_id, ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Member added.", "success")
|
||||
return redirect(url_for("group_admin.members"))
|
||||
|
||||
|
||||
@group_admin.route("/members/invite", methods=["POST"])
|
||||
@group_admin_required
|
||||
@limiter.limit("30 per hour", methods=["POST"])
|
||||
def member_invite():
|
||||
group_id = session["group_id"]
|
||||
username = request.form.get("username", "").strip()
|
||||
@@ -146,6 +155,12 @@ def member_invite():
|
||||
token = db.create_group_invite(group_id, username, email, role, session["user_id"])
|
||||
invite = db.get_invite_by_token(token)
|
||||
invite_url = force_https_url(url_for("auth.accept_invite", token=token, _external=True))
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "invite.created",
|
||||
entity_type="invite", entity_id=invite["id"] if invite else None,
|
||||
details={"username": username, "email": email, "role": role},
|
||||
group_id=group_id, ip_address=request.remote_addr,
|
||||
)
|
||||
mail_settings = db.get_site_mail_settings()
|
||||
|
||||
if mail_settings:
|
||||
@@ -171,6 +186,7 @@ def member_invite():
|
||||
|
||||
@group_admin.route("/invites/<int:invite_id>/resend", methods=["POST"])
|
||||
@group_admin_required
|
||||
@limiter.limit("20 per hour", methods=["POST"])
|
||||
def resend_invite(invite_id):
|
||||
group_id = session["group_id"]
|
||||
invite = db.get_group_invite_by_id(invite_id, group_id)
|
||||
@@ -204,6 +220,12 @@ def resend_invite(invite_id):
|
||||
try:
|
||||
send_mail(mail_settings, invite["invited_email"], subject, text_body, html_body=html_body)
|
||||
db.mark_group_invite_sent(invite_id, group_id)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "invite.resent",
|
||||
entity_type="invite", entity_id=invite_id,
|
||||
details={"to": invite["invited_email"], "username": invite["invited_username"]},
|
||||
group_id=group_id, ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Invitation email resent.", "success")
|
||||
except Exception:
|
||||
flash("Resend failed. Please verify SMTP settings and try again.", "danger")
|
||||
@@ -213,7 +235,14 @@ def resend_invite(invite_id):
|
||||
@group_admin.route("/invites/<int:invite_id>/revoke", methods=["POST"])
|
||||
@group_admin_required
|
||||
def revoke_invite(invite_id):
|
||||
invite = db.get_group_invite_by_id(invite_id, session["group_id"])
|
||||
db.revoke_group_invite(invite_id, session["group_id"])
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "invite.revoked",
|
||||
entity_type="invite", entity_id=invite_id,
|
||||
details={"username": invite["invited_username"] if invite else None},
|
||||
group_id=session["group_id"], ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Invitation revoked.", "success")
|
||||
return redirect(url_for("group_admin.members"))
|
||||
|
||||
@@ -241,7 +270,14 @@ def member_edit(user_id):
|
||||
flash("Invalid role selected.", "danger")
|
||||
return redirect(url_for("group_admin.members"))
|
||||
new_perms = {key: bool(request.form.get(f"perm_{key}")) for key, _ in ALL_PERMISSIONS}
|
||||
old_role = member.get("role")
|
||||
db.update_member(user_id, group_id, role, new_perms)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "member.updated",
|
||||
entity_type="user", entity_id=user_id,
|
||||
details={"target": user["username"], "old_role": old_role, "new_role": role},
|
||||
group_id=group_id, ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Permissions updated.", "success")
|
||||
return redirect(url_for("group_admin.members"))
|
||||
|
||||
@@ -258,7 +294,14 @@ def member_remove(user_id):
|
||||
if user_id == session["user_id"]:
|
||||
flash("You cannot remove yourself.", "danger")
|
||||
else:
|
||||
target_user = db.get_user_by_id(user_id)
|
||||
db.remove_group_member(user_id, session["group_id"])
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "member.removed",
|
||||
entity_type="user", entity_id=user_id,
|
||||
details={"target": target_user["username"] if target_user else str(user_id)},
|
||||
group_id=session["group_id"], ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Member removed.", "success")
|
||||
return redirect(url_for("group_admin.members"))
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from config import Config
|
||||
from mailer import send_mail, build_invite_email, force_https_url
|
||||
import panel_db as db
|
||||
from roles import GROUP_MANAGEMENT_ROLES, GROUP_ROLE_OPTIONS, GROUP_ROLE_SET, role_label
|
||||
from limiter import limiter
|
||||
|
||||
site_admin = Blueprint("site_admin", __name__, url_prefix="/admin")
|
||||
|
||||
@@ -52,6 +53,7 @@ def dashboard():
|
||||
|
||||
@site_admin.route("/mail", methods=["GET", "POST"])
|
||||
@admin_required
|
||||
@limiter.limit("20 per hour", methods=["POST"])
|
||||
def mail_settings():
|
||||
settings = db.get_site_mail_settings()
|
||||
error = None
|
||||
@@ -101,6 +103,10 @@ def mail_settings():
|
||||
"Your SMTP settings were verified successfully and have been saved.",
|
||||
)
|
||||
db.set_site_mail_settings(host, port, username, password, from_email, from_name, use_tls)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "mail.settings_saved",
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Mail settings saved and verified.", "success")
|
||||
return redirect(url_for("site_admin.mail_settings"))
|
||||
except Exception as exc:
|
||||
@@ -123,6 +129,10 @@ def mail_settings():
|
||||
@admin_required
|
||||
def mail_settings_delete():
|
||||
db.delete_site_mail_settings()
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "mail.settings_deleted",
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Mail settings removed.", "success")
|
||||
return redirect(url_for("site_admin.mail_settings"))
|
||||
|
||||
@@ -151,7 +161,13 @@ def group_new():
|
||||
elif db.get_group_by_name(name):
|
||||
flash("A group with that name already exists.", "danger")
|
||||
else:
|
||||
db.create_group(name, desc)
|
||||
gid = db.create_group(name, desc)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "group.created",
|
||||
entity_type="group", entity_id=gid,
|
||||
details={"name": name},
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
flash(f"Group '{name}' created.", "success")
|
||||
return redirect(url_for("site_admin.groups"))
|
||||
return render_template("admin/group_edit.html", group=None)
|
||||
@@ -171,6 +187,12 @@ def group_edit(group_id):
|
||||
flash("Group name must not be empty.", "danger")
|
||||
else:
|
||||
db.update_group(group_id, name, desc)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "group.updated",
|
||||
entity_type="group", entity_id=group_id,
|
||||
details={"name": name},
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Group updated.", "success")
|
||||
return redirect(url_for("site_admin.groups"))
|
||||
return render_template("admin/group_edit.html", group=group)
|
||||
@@ -179,7 +201,14 @@ def group_edit(group_id):
|
||||
@site_admin.route("/groups/<int:group_id>/delete", methods=["POST"])
|
||||
@admin_required
|
||||
def group_delete(group_id):
|
||||
group = db.get_group_by_id(group_id)
|
||||
db.delete_group(group_id)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "group.deleted",
|
||||
entity_type="group", entity_id=group_id,
|
||||
details={"name": group["name"] if group else None},
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Group deleted.", "success")
|
||||
return redirect(url_for("site_admin.groups"))
|
||||
|
||||
@@ -211,6 +240,13 @@ def group_member_add(group_id):
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
if user_id:
|
||||
db.add_group_member(user_id, group_id, role)
|
||||
target = db.get_user_by_id(user_id)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "member.added",
|
||||
entity_type="user", entity_id=user_id,
|
||||
details={"target": target["username"] if target else str(user_id), "role": role},
|
||||
group_id=group_id, ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Member added.", "success")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
|
||||
@@ -218,7 +254,14 @@ def group_member_add(group_id):
|
||||
@site_admin.route("/groups/<int:group_id>/members/<int:user_id>/remove", methods=["POST"])
|
||||
@admin_required
|
||||
def group_member_remove(group_id, user_id):
|
||||
target = db.get_user_by_id(user_id)
|
||||
db.remove_group_member(user_id, group_id)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "member.removed",
|
||||
entity_type="user", entity_id=user_id,
|
||||
details={"target": target["username"] if target else str(user_id)},
|
||||
group_id=group_id, ip_address=request.remote_addr,
|
||||
)
|
||||
flash("Member removed.", "success")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
|
||||
@@ -234,7 +277,15 @@ def group_member_set_role(group_id, user_id):
|
||||
flash("Invalid role selected.", "danger")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
perms = member["permissions"] if isinstance(member["permissions"], dict) else (_json.loads(member["permissions"]) if member["permissions"] else {})
|
||||
old_role = member.get("role")
|
||||
db.update_member(user_id, group_id, new_role, perms)
|
||||
target = db.get_user_by_id(user_id)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "member.role_changed",
|
||||
entity_type="user", entity_id=user_id,
|
||||
details={"target": target["username"] if target else str(user_id), "old_role": old_role, "new_role": new_role},
|
||||
group_id=group_id, ip_address=request.remote_addr,
|
||||
)
|
||||
flash(f"Role changed to '{new_role}'.", "success")
|
||||
return redirect(url_for("site_admin.group_members", group_id=group_id))
|
||||
|
||||
@@ -507,6 +558,12 @@ def user_edit(user_id):
|
||||
if new_password:
|
||||
db.change_password(user_id, new_password)
|
||||
flash("Password changed.", "info")
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "user.updated",
|
||||
entity_type="user", entity_id=user_id,
|
||||
details={"username": username, "is_site_admin": is_site_admin},
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
flash("User updated.", "success")
|
||||
return redirect(url_for("site_admin.users"))
|
||||
return render_template("admin/user_edit.html", user=user)
|
||||
@@ -518,7 +575,14 @@ def user_delete(user_id):
|
||||
if user_id == session.get("user_id"):
|
||||
flash("You cannot delete yourself.", "danger")
|
||||
else:
|
||||
target = db.get_user_by_id(user_id)
|
||||
db.delete_user(user_id)
|
||||
db.log_audit_event(
|
||||
session["user_id"], session["username"], "user.deleted",
|
||||
entity_type="user", entity_id=user_id,
|
||||
details={"username": target["username"] if target else str(user_id)},
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
flash("User deleted.", "success")
|
||||
return redirect(url_for("site_admin.users"))
|
||||
|
||||
@@ -568,3 +632,41 @@ def stop_view():
|
||||
session.pop("permissions", None)
|
||||
session.pop("admin_viewing", None)
|
||||
return redirect(url_for("site_admin.dashboard"))
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Audit-Log
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@site_admin.route("/audit")
|
||||
@admin_required
|
||||
def audit_log():
|
||||
page = request.args.get("page", 1, type=int)
|
||||
action_f = request.args.get("action", "").strip() or None
|
||||
group_f = request.args.get("group_id", None, type=int)
|
||||
actor_f = request.args.get("actor", "").strip() or None
|
||||
per_page = 50
|
||||
|
||||
rows, total = db.get_audit_log(
|
||||
page=page, per_page=per_page,
|
||||
action_filter=action_f,
|
||||
group_id_filter=group_f,
|
||||
actor_filter=actor_f,
|
||||
)
|
||||
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||
all_groups = db.list_all_groups() or []
|
||||
actions = db.get_audit_log_distinct_actions()
|
||||
|
||||
return render_template(
|
||||
"admin/audit_log.html",
|
||||
rows=rows,
|
||||
total=total,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
per_page=per_page,
|
||||
action_filter=action_f or "",
|
||||
group_filter=group_f,
|
||||
actor_filter=actor_f or "",
|
||||
all_groups=all_groups,
|
||||
actions=actions,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user