""" 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"))