05/06/2026 Initial commit
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
app/admin/__init__.py — Admin app factory (admin.mydomain.com).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from flask import Flask
|
||||
from app.extensions import db, migrate, csrf, mail, scheduler, admin_login_manager, admin_jwt, limiter
|
||||
from app.security import apply_security_headers, check_admin_ip
|
||||
from config import get_config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_admin_app(config_override=None):
|
||||
flask_app = Flask(
|
||||
__name__,
|
||||
template_folder="../templates",
|
||||
static_folder="../static",
|
||||
static_url_path="/static/admin",
|
||||
)
|
||||
|
||||
# ── Config ────────────────────────────────────────────────
|
||||
cfg = config_override or get_config()
|
||||
flask_app.config.from_object(cfg)
|
||||
|
||||
# Admin portal uses a separate secret key and session cookie name
|
||||
import os
|
||||
flask_app.config["SECRET_KEY"] = os.environ.get(
|
||||
"ADMIN_SECRET_KEY", flask_app.config["SECRET_KEY"]
|
||||
)
|
||||
flask_app.config["SESSION_COOKIE_NAME"] = "salon_pos_admin_session"
|
||||
flask_app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(
|
||||
seconds=int(os.environ.get("SESSION_TIMEOUT_ADMIN", 3600))
|
||||
)
|
||||
flask_app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||
flask_app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||
|
||||
# ── Extensions ────────────────────────────────────────────
|
||||
db.init_app(flask_app)
|
||||
migrate.init_app(flask_app, db)
|
||||
csrf.init_app(flask_app)
|
||||
mail.init_app(flask_app)
|
||||
limiter.init_app(flask_app)
|
||||
|
||||
# Admin login manager
|
||||
admin_login_manager.login_view = "admin_auth.login"
|
||||
admin_login_manager.login_message_category = "warning"
|
||||
admin_login_manager.session_protection = "strong"
|
||||
admin_login_manager.init_app(flask_app)
|
||||
|
||||
# Admin JWT
|
||||
admin_jwt.init_app(flask_app)
|
||||
|
||||
# ── User loader ───────────────────────────────────────────
|
||||
from app.models.platform import SystemUser
|
||||
|
||||
@admin_login_manager.user_loader
|
||||
def load_admin_user(user_id: str):
|
||||
if not user_id.startswith("system:"):
|
||||
return None
|
||||
try:
|
||||
uid = int(user_id.split(":")[1])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
return SystemUser.query.get(uid)
|
||||
|
||||
# ── Security ──────────────────────────────────────────────
|
||||
apply_security_headers(flask_app)
|
||||
check_admin_ip(flask_app)
|
||||
|
||||
# ── Blueprints ────────────────────────────────────────────
|
||||
from app.admin.auth.routes import admin_auth_bp
|
||||
flask_app.register_blueprint(admin_auth_bp)
|
||||
|
||||
# Placeholder blueprints registered in later phases:
|
||||
# system_users, tenants, plans, billing, settings_override, audit_log, analytics
|
||||
|
||||
# ── Import all models for Migrate ─────────────────────────
|
||||
import app.models # noqa: F401
|
||||
|
||||
logger.info("Admin app created (env=%s)", flask_app.config.get("FLASK_ENV"))
|
||||
return flask_app
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/admin/analytics/routes.py
|
||||
Phase 2 implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
analytics_bp = Blueprint("analytics", __name__, url_prefix="/analytics")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/admin/audit_log/routes.py
|
||||
Phase 2 implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
audit_log_bp = Blueprint("audit_log", __name__, url_prefix="/audit-log")
|
||||
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
app/admin/auth/routes.py — Admin portal authentication.
|
||||
Routes: /admin/login, /admin/logout
|
||||
Brute-force lockout: 5 failures → locked for LOGIN_LOCKOUT_MINUTES.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from flask import (
|
||||
Blueprint, render_template, redirect, url_for,
|
||||
flash, request, current_app, session,
|
||||
)
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
import bcrypt
|
||||
|
||||
from app.extensions import db, limiter
|
||||
from app.models.platform import SystemUser, AuditLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
admin_auth_bp = Blueprint("admin_auth", __name__, url_prefix="/admin")
|
||||
|
||||
|
||||
@admin_auth_bp.route("/login", methods=["GET", "POST"])
|
||||
@limiter.limit("10 per minute")
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("admin_auth.dashboard_redirect"))
|
||||
|
||||
error = None
|
||||
|
||||
if request.method == "POST":
|
||||
email = request.form.get("email", "").strip().lower()
|
||||
password = request.form.get("password", "")
|
||||
|
||||
user = SystemUser.query.filter_by(email=email, is_active=True).first()
|
||||
|
||||
max_attempts = current_app.config.get("MAX_LOGIN_ATTEMPTS", 5)
|
||||
lockout_minutes = current_app.config.get("LOGIN_LOCKOUT_MINUTES", 15)
|
||||
|
||||
if user and user.is_locked():
|
||||
logger.warning("Admin login blocked — account locked: %s", email)
|
||||
error = f"Account locked. Try again in {lockout_minutes} minutes."
|
||||
elif user and bcrypt.checkpw(password.encode(), user.password_hash.encode()):
|
||||
# Success — reset lockout counters
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
|
||||
login_user(user, remember=False)
|
||||
session.permanent = True
|
||||
|
||||
AuditLog.log(
|
||||
actor_id=user.id,
|
||||
actor_type="system_user",
|
||||
action="auth.login",
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
logger.info("Admin login success: %s", email)
|
||||
next_url = request.args.get("next") or url_for("admin_auth.dashboard_redirect")
|
||||
return redirect(next_url)
|
||||
else:
|
||||
# Failed attempt
|
||||
if user:
|
||||
user.failed_login_attempts += 1
|
||||
if user.failed_login_attempts >= max_attempts:
|
||||
user.locked_until = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=lockout_minutes
|
||||
)
|
||||
logger.warning(
|
||||
"Admin account locked after %d failures: %s",
|
||||
max_attempts, email,
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
logger.warning("Admin login failed: %s", email)
|
||||
error = "Invalid email or password."
|
||||
|
||||
return render_template("admin/auth/login.html", error=error)
|
||||
|
||||
|
||||
@admin_auth_bp.route("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
AuditLog.log(
|
||||
actor_id=current_user.id,
|
||||
actor_type="system_user",
|
||||
action="auth.logout",
|
||||
ip_address=request.remote_addr,
|
||||
)
|
||||
db.session.commit()
|
||||
logout_user()
|
||||
session.clear()
|
||||
logger.info("Admin logout")
|
||||
return redirect(url_for("admin_auth.login"))
|
||||
|
||||
|
||||
@admin_auth_bp.route("/")
|
||||
@login_required
|
||||
def dashboard_redirect():
|
||||
# Will route to the analytics dashboard in Phase 2
|
||||
return redirect(url_for("admin_auth.login"))
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/admin/billing/routes.py
|
||||
Phase 2 implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
billing_bp = Blueprint("billing", __name__, url_prefix="/billing")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/admin/plans/routes.py
|
||||
Phase 2 implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
plans_bp = Blueprint("plans", __name__, url_prefix="/plans")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/admin/settings_override/routes.py
|
||||
Phase 2 implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
settings_override_bp = Blueprint("settings_override", __name__, url_prefix="/settings-override")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/admin/system_users/routes.py
|
||||
Phase 2 implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
system_users_bp = Blueprint("system_users", __name__, url_prefix="/system-users")
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
app/admin/tenants/routes.py
|
||||
Phase 2 implementation.
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
tenants_bp = Blueprint("tenants", __name__, url_prefix="/tenants")
|
||||
Reference in New Issue
Block a user