114 lines
4.4 KiB
Python
114 lines
4.4 KiB
Python
"""
|
||
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"))
|