05/06/2026 Initial commit

This commit is contained in:
2026-05-06 14:19:07 -04:00
parent 9da1dffd9a
commit dde18a2cd2
116 changed files with 4276 additions and 7 deletions
View File
+113
View File
@@ -0,0 +1,113 @@
"""
app/tenant/staff_auth/routes.py — Staff portal authentication (phone + passcode).
Routes: /staff-login, /staff-logout
Brute-force: 5 failures → passcode_locked_until for LOGIN_LOCKOUT_MINUTES.
"""
import logging
from datetime import datetime, timezone, timedelta
from flask import (
Blueprint, render_template, redirect, url_for,
request, current_app, session, flash,
)
from flask_login import login_user, logout_user, login_required, current_user
import bcrypt
from app.extensions import db, limiter
from app.models.salon import Staff
from app.models.platform import Tenant
logger = logging.getLogger(__name__)
staff_auth_bp = Blueprint("staff_auth", __name__)
@staff_auth_bp.route("/staff-login", methods=["GET", "POST"])
@limiter.limit("10 per minute")
def staff_login():
if current_user.is_authenticated:
return redirect(url_for("staff_portal.index"))
error = None
if request.method == "POST":
phone = request.form.get("phone", "").strip()
passcode = request.form.get("passcode", "").strip()
max_attempts = current_app.config.get("MAX_LOGIN_ATTEMPTS", 5)
lockout_minutes = current_app.config.get("LOGIN_LOCKOUT_MINUTES", 15)
min_len = current_app.config.get("STAFF_PASSCODE_MIN_LENGTH", 4)
max_len = current_app.config.get("STAFF_PASSCODE_MAX_LENGTH", 6)
# Basic input validation
if not phone or not passcode:
error = "Phone number and passcode are required."
elif not passcode.isdigit() or not (min_len <= len(passcode) <= max_len):
error = f"Passcode must be {min_len}{max_len} digits."
else:
# Look up staff by phone (phone is unique within tenant)
# Platform-wide: find first matching phone, then validate tenant
staff = Staff.query.filter_by(phone=phone, is_active=True)\
.filter(Staff.deleted_at.is_(None)).first()
if staff and staff.is_passcode_locked():
logger.warning(
"Staff passcode login blocked — locked: staff=%s", staff.id
)
error = f"Account locked. Try again in {lockout_minutes} minutes."
elif staff and bcrypt.checkpw(
passcode.encode(), staff.passcode_hash.encode()
):
# Verify the tenant is active
tenant = Tenant.query.get(staff.tenant_id)
if not tenant or not tenant.is_active_status():
error = "Your salon account is not active."
else:
# Success
staff.passcode_failed_attempts = 0
staff.passcode_locked_until = None
db.session.commit()
login_user(staff, remember=False)
session.permanent = True
# Set active location to staff's primary assigned location
from app.models.salon import StaffLocation
assignment = StaffLocation.query.filter_by(
staff_id=staff.id,
tenant_id=staff.tenant_id,
).first()
if assignment:
session["active_location_id"] = assignment.location_id
logger.info(
"Staff login success: staff=%s tenant=%s",
staff.id, staff.tenant_id,
)
return redirect(url_for("staff_portal.index"))
else:
if staff:
staff.passcode_failed_attempts += 1
if staff.passcode_failed_attempts >= max_attempts:
staff.passcode_locked_until = datetime.now(timezone.utc) + \
timedelta(minutes=lockout_minutes)
logger.warning(
"Staff passcode locked after %d failures: staff=%s",
max_attempts, staff.id,
)
db.session.commit()
logger.warning("Staff login failed for phone: %s", phone)
error = "Invalid phone number or passcode."
return render_template("tenant/staff_auth/login.html", error=error)
@staff_auth_bp.route("/staff-logout")
@login_required
def staff_logout():
logout_user()
session.clear()
logger.info("Staff logout")
return redirect(url_for("staff_auth.staff_login"))