Files
MyPOS/app/tenant/staff_auth/routes.py
T
2026-05-06 14:19:07 -04:00

114 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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"))