05/06/2026 Initial commit
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
app/tenant/auth/routes.py — Tenant portal authentication (email + password).
|
||||
Routes: /login, /logout, /password-reset, /suspended, /cancelled
|
||||
Roles: tenant_admin, tenant_manager
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from flask import (
|
||||
Blueprint, render_template, redirect, url_for,
|
||||
flash, request, current_app, session, g,
|
||||
)
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
import bcrypt
|
||||
|
||||
from app.extensions import db, limiter, mail
|
||||
from app.models.platform import Tenant
|
||||
from app.models.salon import User
|
||||
from flask_mail import Message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
tenant_auth_bp = Blueprint("tenant_auth", __name__)
|
||||
|
||||
|
||||
@tenant_auth_bp.route("/login", methods=["GET", "POST"])
|
||||
@limiter.limit("10 per minute")
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("dashboard.index"))
|
||||
|
||||
error = None
|
||||
|
||||
if request.method == "POST":
|
||||
email = request.form.get("email", "").strip().lower()
|
||||
password = request.form.get("password", "")
|
||||
|
||||
user = User.query.filter_by(email=email, is_active=True)\
|
||||
.filter(User.deleted_at.is_(None)).first()
|
||||
|
||||
max_attempts = current_app.config.get("MAX_LOGIN_ATTEMPTS", 5)
|
||||
lockout_minutes = current_app.config.get("LOGIN_LOCKOUT_MINUTES", 15)
|
||||
|
||||
if user and user.is_locked():
|
||||
logger.warning("Tenant login blocked — account locked: %s", email)
|
||||
error = f"Account locked. Try again in {lockout_minutes} minutes."
|
||||
|
||||
elif user and bcrypt.checkpw(password.encode(), user.password_hash.encode()):
|
||||
# Verify tenant is accessible
|
||||
tenant = Tenant.query.get(user.tenant_id)
|
||||
if not tenant or tenant.status == "cancelled":
|
||||
error = "This account is no longer active."
|
||||
else:
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
db.session.commit()
|
||||
|
||||
login_user(user, remember=False)
|
||||
session.permanent = True
|
||||
|
||||
logger.info(
|
||||
"Tenant login success: user=%s tenant=%s",
|
||||
user.id, user.tenant_id,
|
||||
)
|
||||
return redirect(url_for("dashboard.index"))
|
||||
else:
|
||||
if user:
|
||||
user.failed_login_attempts += 1
|
||||
if user.failed_login_attempts >= max_attempts:
|
||||
user.locked_until = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=lockout_minutes
|
||||
)
|
||||
logger.warning(
|
||||
"Tenant account locked after %d failures: %s",
|
||||
max_attempts, email,
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
logger.warning("Tenant login failed: %s", email)
|
||||
error = "Invalid email or password."
|
||||
|
||||
return render_template("tenant/auth/login.html", error=error)
|
||||
|
||||
|
||||
@tenant_auth_bp.route("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
session.clear()
|
||||
logger.info("Tenant logout")
|
||||
return redirect(url_for("tenant_auth.login"))
|
||||
|
||||
|
||||
@tenant_auth_bp.route("/password-reset", methods=["GET", "POST"])
|
||||
@limiter.limit("5 per hour")
|
||||
def password_reset_request():
|
||||
"""Step 1: submit email → send reset link."""
|
||||
message = None
|
||||
|
||||
if request.method == "POST":
|
||||
email = request.form.get("email", "").strip().lower()
|
||||
user = User.query.filter_by(email=email)\
|
||||
.filter(User.deleted_at.is_(None)).first()
|
||||
|
||||
if user:
|
||||
token = secrets.token_urlsafe(32)
|
||||
user.password_reset_token = token
|
||||
user.password_reset_expires_at = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
db.session.commit()
|
||||
|
||||
reset_url = url_for(
|
||||
"tenant_auth.password_reset_confirm",
|
||||
token=token,
|
||||
_external=True,
|
||||
)
|
||||
try:
|
||||
msg = Message(
|
||||
subject="Reset your password",
|
||||
recipients=[email],
|
||||
body=f"Click the link below to reset your password (valid 1 hour):\n\n{reset_url}",
|
||||
)
|
||||
mail.send(msg)
|
||||
logger.info("Password reset email sent to %s", email)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send reset email to %s: %s", email, exc)
|
||||
|
||||
# Always show the same message to prevent email enumeration
|
||||
message = "If that email is registered, a reset link has been sent."
|
||||
|
||||
return render_template(
|
||||
"tenant/auth/password_reset_request.html", message=message
|
||||
)
|
||||
|
||||
|
||||
@tenant_auth_bp.route("/password-reset/<token>", methods=["GET", "POST"])
|
||||
def password_reset_confirm(token):
|
||||
"""Step 2: confirm token → set new password."""
|
||||
user = User.query.filter_by(password_reset_token=token)\
|
||||
.filter(User.deleted_at.is_(None)).first()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if not user or not user.password_reset_expires_at:
|
||||
flash("Invalid or expired reset link.", "danger")
|
||||
return redirect(url_for("tenant_auth.login"))
|
||||
|
||||
expires = user.password_reset_expires_at
|
||||
if expires.tzinfo is None:
|
||||
expires = expires.replace(tzinfo=timezone.utc)
|
||||
|
||||
if now > expires:
|
||||
flash("This reset link has expired. Please request a new one.", "danger")
|
||||
return redirect(url_for("tenant_auth.password_reset_request"))
|
||||
|
||||
error = None
|
||||
|
||||
if request.method == "POST":
|
||||
new_password = request.form.get("password", "")
|
||||
confirm = request.form.get("confirm_password", "")
|
||||
|
||||
if new_password != confirm:
|
||||
error = "Passwords do not match."
|
||||
elif len(new_password) < 10:
|
||||
error = "Password must be at least 10 characters."
|
||||
elif not any(c.isupper() for c in new_password):
|
||||
error = "Password must contain at least one uppercase letter."
|
||||
elif not any(c.islower() for c in new_password):
|
||||
error = "Password must contain at least one lowercase letter."
|
||||
elif not any(c.isdigit() for c in new_password):
|
||||
error = "Password must contain at least one digit."
|
||||
else:
|
||||
hashed = bcrypt.hashpw(new_password.encode(), bcrypt.gensalt(rounds=12))
|
||||
user.password_hash = hashed.decode()
|
||||
user.password_reset_token = None
|
||||
user.password_reset_expires_at = None
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
db.session.commit()
|
||||
|
||||
logger.info("Password reset completed for user %s", user.id)
|
||||
flash("Password updated. Please log in.", "success")
|
||||
return redirect(url_for("tenant_auth.login"))
|
||||
|
||||
return render_template(
|
||||
"tenant/auth/password_reset_confirm.html", error=error, token=token
|
||||
)
|
||||
|
||||
|
||||
@tenant_auth_bp.route("/suspended")
|
||||
def suspended():
|
||||
return render_template("tenant/auth/suspended.html"), 403
|
||||
|
||||
|
||||
@tenant_auth_bp.route("/cancelled")
|
||||
def cancelled():
|
||||
return render_template("tenant/auth/cancelled.html"), 403
|
||||
Reference in New Issue
Block a user