From 583f5aac42e10709c1149921956b35b679f85171 Mon Sep 17 00:00:00 2001 From: Christian Krakau-Louis Date: Mon, 14 Apr 2025 06:14:35 +0200 Subject: [PATCH] Enhance user experience by implementing password change functionality, adding validation for password strength and matching, and improving error messaging in the change password form. --- app/templates/auth/change_password.html | 215 ++++++++++++++++++++++++ app/templates/auth/profile.html | 12 +- app/templates/redeem.html | 5 +- app/views/auth.py | 163 +++++++++++++++++- app/views/redeem.py | 64 ++++++- 5 files changed, 445 insertions(+), 14 deletions(-) create mode 100644 app/templates/auth/change_password.html diff --git a/app/templates/auth/change_password.html b/app/templates/auth/change_password.html new file mode 100644 index 0000000..3363c78 --- /dev/null +++ b/app/templates/auth/change_password.html @@ -0,0 +1,215 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+

Change Password

+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + + {% if message %} +
+ {{ message }} +
+ {% endif %} + +
+
+ + +
+ +
+ + +
+
+
+
+

Password strength: Too weak

+
+
    +
  • At least 8 characters
  • +
  • At least one lowercase letter
  • +
  • At least one uppercase letter
  • +
  • At least one number
  • +
  • At least one special character
  • +
+
+ +
+ + + +
+ + + +
+ + + Cancel + +
+
+
+
+
+ + +{% endblock %} diff --git a/app/templates/auth/profile.html b/app/templates/auth/profile.html index df4157d..e5f1656 100644 --- a/app/templates/auth/profile.html +++ b/app/templates/auth/profile.html @@ -2,6 +2,13 @@ {% block content %}
+ + {% if request.query_params.message %} +
+ {{ request.query_params.message }} +
+ {% endif %} +
@@ -37,10 +44,9 @@

Change Password

Update your password to keep your account secure.

- +
diff --git a/app/templates/redeem.html b/app/templates/redeem.html index 464cfcd..1fbb72e 100644 --- a/app/templates/redeem.html +++ b/app/templates/redeem.html @@ -17,13 +17,14 @@
- + +

Only teams you are a member of are shown

Select Your Team

-

Choose which team should receive these points.

+

Choose which of your teams should receive these points.

diff --git a/app/views/auth.py b/app/views/auth.py index 30738c9..8d6ad34 100644 --- a/app/views/auth.py +++ b/app/views/auth.py @@ -5,13 +5,16 @@ from typing import Optional import secrets import os import uuid +import re from starlette.status import HTTP_303_SEE_OTHER, HTTP_302_FOUND from sqlalchemy.orm import Session +from datetime import datetime from ..db import get_db from ..models import User from ..auth.oauth import authentik_oauth from ..templates_config import templates +from ..security import verify_password, get_password_hash router = APIRouter(prefix="/auth", tags=["Auth"]) @@ -33,12 +36,53 @@ async def login_post( db: Session = Depends(get_db) ): """Handle login form submission""" - # This is a placeholder - implement real login logic here - error = "This login method is not fully implemented yet" - return templates.TemplateResponse( - "auth/login.html", - {"request": request, "error": error, "show_oauth": True, "oauth_provider_name": "Authentik"} - ) + error = None + + # Look up the user by username or email + user = db.query(User).filter( + (User.username == username) | (User.email == username) + ).first() + + # Check if user exists and password is correct + if not user: + error = "Invalid username or email" + elif user.is_oauth_user and not user.hashed_password: + error = "This account uses OAuth for login. Please use the OAuth login option." + elif not verify_password(password, user.hashed_password): + error = "Invalid password" + elif not user.is_active: + error = "This account has been deactivated" + + # If there was an error, re-render the login page + if error: + return templates.TemplateResponse( + "auth/login.html", + { + "request": request, + "error": error, + "show_oauth": True, + "oauth_provider_name": "Authentik" + } + ) + + # Update the last login timestamp + user.last_login = datetime.utcnow() + db.commit() + + # Set session data + request.session["user_id"] = user.id + request.session["username"] = user.username + request.session["is_authenticated"] = True + request.session["is_admin"] = user.is_admin + + # If remember me is checked, set session expiry to a longer time (30 days) + if remember: + # Session middleware handles this through cookies, so we just need to set the flag + request.session["remember_me"] = True + + # Redirect to dashboard or previously requested page + next_page = request.query_params.get("next", "/dashboard") + return RedirectResponse(next_page, status_code=HTTP_303_SEE_OTHER) @router.get("/register", response_class=HTMLResponse) async def register_page(request: Request, error: Optional[str] = None): @@ -206,3 +250,110 @@ async def profile_page(request: Request): "auth/profile.html", {"request": request, "user": user} ) + +@router.get("/change-password", response_class=HTMLResponse) +async def change_password_page(request: Request, error: Optional[str] = None, message: Optional[str] = None): + """Change password page""" + # Check if user is logged in + user_id = request.session.get("user_id") + if not user_id: + return RedirectResponse("/auth/login?next=/auth/change-password", status_code=HTTP_303_SEE_OTHER) + + return templates.TemplateResponse( + "auth/change_password.html", + {"request": request, "error": error, "message": message} + ) + +@router.post("/change-password", response_class=HTMLResponse) +async def change_password_post( + request: Request, + current_password: str = Form(...), + new_password: str = Form(...), + confirm_password: str = Form(...), + db: Session = Depends(get_db) +): + """Handle change password form submission""" + # Check if user is logged in + user_id = request.session.get("user_id") + if not user_id: + return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER) + + # Validate form data + if new_password != confirm_password: + return templates.TemplateResponse( + "auth/change_password.html", + {"request": request, "error": "New passwords do not match"} + ) + + # Get user from database + user = db.query(User).filter(User.id == user_id).first() + if not user: + request.session.clear() + return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER) + + # Check if this is an OAuth user without a password + if user.is_oauth_user and not user.hashed_password: + return templates.TemplateResponse( + "auth/change_password.html", + {"request": request, "error": "OAuth users cannot change passwords this way"} + ) + + # Verify current password + if not verify_password(current_password, user.hashed_password): + return templates.TemplateResponse( + "auth/change_password.html", + {"request": request, "error": "Current password is incorrect"} + ) + + # Check if new password is the same as current password + if current_password == new_password: + return templates.TemplateResponse( + "auth/change_password.html", + {"request": request, "error": "New password must be different from your current password"} + ) + + # Server-side password strength validation + password_validation_error = validate_password_strength(new_password) + if password_validation_error: + return templates.TemplateResponse( + "auth/change_password.html", + {"request": request, "error": password_validation_error} + ) + + # Update password + user.hashed_password = get_password_hash(new_password) + db.commit() + + # Redirect to profile page with success message + return RedirectResponse( + "/auth/profile?message=Password+changed+successfully", + status_code=HTTP_303_SEE_OTHER + ) + +def validate_password_strength(password: str) -> Optional[str]: + """ + Validates password strength based on the following criteria: + - At least 8 characters long + - Contains at least one lowercase letter + - Contains at least one uppercase letter + - Contains at least one digit + - Contains at least one special character + + Returns error message if validation fails, None if password is valid + """ + if len(password) < 8: + return "Password must be at least 8 characters long" + + if not re.search(r"[a-z]", password): + return "Password must contain at least one lowercase letter" + + if not re.search(r"[A-Z]", password): + return "Password must contain at least one uppercase letter" + + if not re.search(r"\d", password): + return "Password must contain at least one number" + + if not re.search(r"[!@#$%^&*(),.?\":{}|<>]", password): + return "Password must contain at least one special character" + + return None diff --git a/app/views/redeem.py b/app/views/redeem.py index 8c3adbc..c0252dd 100644 --- a/app/views/redeem.py +++ b/app/views/redeem.py @@ -73,13 +73,43 @@ def redeem_code(code: str, request: Request, db: Session = Depends(get_db)): } ) - # Get all available teams - all_teams = db.query(Team).all() + # Get only teams the user is a member of, if logged in + user_teams = [] + if user: + # Query teams where the user is a member using TeamMembership relation + user_teams = ( + db.query(Team) + .join(TeamMembership, Team.id == TeamMembership.team_id) + .filter(TeamMembership.user_id == user.id) + .all() + ) + + # If user is not logged in or has no teams, instruct them to log in or join teams + if not user: + return templates.TemplateResponse( + "error.html", + { + "request": request, + "error_title": "Login Required", + "error_message": "You must be logged in to redeem QR codes. Please log in and try again.", + "user": None + } + ) + elif not user_teams: + return templates.TemplateResponse( + "error.html", + { + "request": request, + "error_title": "No Teams Available", + "error_message": "You are not a member of any teams. Please join or create a team before redeeming QR codes.", + "user": user + } + ) return templates.TemplateResponse("redeem.html", { "request": request, "ticket": qr_code, # Using the same template variable name for compatibility - "user_teams": all_teams, + "user_teams": user_teams, "has_achievement": bool(qr_code.achievement_name), "base_url": BASE_URL, "user": user # Add user to the context @@ -115,6 +145,16 @@ async def apply_code( user_id = request.session.get("user_id") if user_id: user = db.query(User).get(user_id) + else: + return templates.TemplateResponse( + "error.html", + { + "request": request, + "error_title": "Login Required", + "error_message": "You must be logged in to redeem QR codes.", + "user": None + } + ) # Get form data form_data = await request.form() @@ -168,6 +208,7 @@ async def apply_code( } ) + # Verify the team exists team = db.query(Team).filter_by(id=team_id).first() if not team: return templates.TemplateResponse( @@ -179,6 +220,23 @@ async def apply_code( "user": user # Add user to the context } ) + + # Verify the user is a member of the selected team + is_team_member = db.query(TeamMembership).filter_by( + user_id=user.id, + team_id=team.id + ).first() is not None + + if not is_team_member: + return templates.TemplateResponse( + "error.html", + { + "request": request, + "error_title": "Not a Team Member", + "error_message": "You can only redeem points for teams you are a member of.", + "user": user # Add user to the context + } + ) # Mark the QR code as redeemed qr_code.redeemed_at_team = team.id