05/06/2026 Initial commit
This commit is contained in:
@@ -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"))
|
||||
Reference in New Issue
Block a user