feat(auth): require email confirmation for new accounts

Updates the user registration and new account creation endpoints to require email confirmation.

- Sets the 'confirmed' flag to 'false' by default for all new user accounts.
- Generates a unique confirmation token for each new user.
- Logs the confirmation link to the console for development purposes.

This change ensures that users cannot log in without first verifying their email address, enhancing account security.
This commit is contained in:
2026-01-18 16:28:33 -05:00
parent a5a76743c7
commit 6c35393f1f
7 changed files with 92 additions and 14 deletions

View File

@@ -16,6 +16,9 @@ class Account_User(Base):
last_seen = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc)) last_seen = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc))
password_reset_token = Column(TEXT, nullable=True) password_reset_token = Column(TEXT, nullable=True)
password_reset_expires = Column(TIMESTAMP(timezone=True), nullable=True) password_reset_expires = Column(TIMESTAMP(timezone=True), nullable=True)
confirmation_token = Column(TEXT, nullable=True)
confirmation_sent_at = Column(TIMESTAMP(timezone=True), nullable=True)
confirmed_at = Column(TIMESTAMP(timezone=True), nullable=True)
admin = Column(Integer) admin = Column(Integer)
admin_role = Column(Integer) admin_role = Column(Integer)
confirmed = Column(Integer) confirmed = Column(Integer)
@@ -35,6 +38,9 @@ class Account_User(Base):
confirmed, confirmed,
active=1, active=1,
user_id=None, user_id=None,
confirmation_token=None,
confirmation_sent_at=None,
confirmed_at=None
): ):
self.username = username self.username = username
self.account_number = account_number self.account_number = account_number
@@ -48,6 +54,9 @@ class Account_User(Base):
self.confirmed = confirmed self.confirmed = confirmed
self.active = active self.active = active
self.user_id = user_id self.user_id = user_id
self.confirmation_token = confirmation_token
self.confirmation_sent_at = confirmation_sent_at
self.confirmed_at = confirmed_at
def is_authenticated(self): def is_authenticated(self):
return True return True

View File

@@ -4,6 +4,7 @@ from .register import router as register_router
from .new import router as new_router from .new import router as new_router
from .current_user import router as current_user_router, oauth2_scheme from .current_user import router as current_user_router, oauth2_scheme
from .lost_password import router as lost_password_router from .lost_password import router as lost_password_router
from .confirm import router as confirm_router
router = APIRouter() router = APIRouter()
router.include_router(login_router) router.include_router(login_router)
@@ -11,3 +12,4 @@ router.include_router(register_router)
router.include_router(new_router) router.include_router(new_router)
router.include_router(current_user_router) router.include_router(current_user_router)
router.include_router(lost_password_router) router.include_router(lost_password_router)
router.include_router(confirm_router)

25
routes/auth/confirm.py Normal file
View File

@@ -0,0 +1,25 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from database import get_db
from models import Account_User
from datetime import datetime, timezone
router = APIRouter()
@router.get("/confirm-email")
async def confirm_email(token: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Account_User).where(Account_User.confirmation_token == token))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=400, detail="Invalid token")
if user.confirmed:
return {"message": "Account already confirmed"}
user.confirmed = 1
user.confirmed_at = datetime.now(timezone.utc)
await db.commit()
return {"message": "Email confirmed successfully"}

View File

@@ -37,6 +37,12 @@ async def authenticate_user(db: AsyncSession, email: str, password: str):
return False return False
if not verify_password(password, user.password_hash): if not verify_password(password, user.password_hash):
return False return False
if not user.confirmed:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Please confirm your email address before logging in.",
headers={"WWW-Authenticate": "Bearer"},
)
# Update last_seen # Update last_seen
user.last_seen = datetime.utcnow() user.last_seen = datetime.utcnow()
await db.commit() await db.commit()

View File

@@ -1,3 +1,4 @@
import secrets
import random import random
import string import string
import os import os
@@ -99,6 +100,7 @@ async def register_new_customer(customer: NewCustomerCreate, db: AsyncSession =
# Create account user # Create account user
username = account_number username = account_number
hashed_password = get_password_hash(customer.password) hashed_password = get_password_hash(customer.password)
token = secrets.token_urlsafe(32)
db_user = Account_User( db_user = Account_User(
username=username, username=username,
account_number=account_number, account_number=account_number,
@@ -109,13 +111,21 @@ async def register_new_customer(customer: NewCustomerCreate, db: AsyncSession =
last_seen=datetime.utcnow(), last_seen=datetime.utcnow(),
admin=0, admin=0,
admin_role=0, admin_role=0,
confirmed=1, confirmed=0,
active=1, active=1,
user_id=db_customer.id user_id=db_customer.id,
confirmation_token=token,
confirmation_sent_at=datetime.utcnow()
) )
db.add(db_user) db.add(db_user)
await db.commit() await db.commit()
await db.refresh(db_user) await db.refresh(db_user)
# In a real application, you would send an email here
# For now, we'll just print the confirmation URL to the console
confirmation_url = f"http://localhost:3000/confirm-email?token={token}"
print(f"Confirmation URL: {confirmation_url}")
return db_user return db_user
@router.post("/step3") @router.post("/step3")
@@ -504,7 +514,7 @@ async def create_customer_step1(customer: CustomerCreateStep1, db: AsyncSession
customer_data.pop('house_description') # Remove from customer data customer_data.pop('house_description') # Remove from customer data
customer_data.update({ customer_data.update({
'account_number': account_number, 'account_number': account_number,
'customer_state': 1, # Default 'customer_state': 0, # Default
'customer_automatic': 0, 'customer_automatic': 0,
'company_id': 1, # Default 'company_id': 1, # Default
'customer_latitude': '0', 'customer_latitude': '0',
@@ -568,6 +578,7 @@ async def create_customer_account(account_data: CustomerAccountCreate, db: Async
# Create account user # Create account user
username = account_data.account_number username = account_data.account_number
hashed_password = get_password_hash(account_data.password) hashed_password = get_password_hash(account_data.password)
token = secrets.token_urlsafe(32)
db_user = Account_User( db_user = Account_User(
username=username, username=username,
account_number=account_data.account_number, account_number=account_data.account_number,
@@ -578,11 +589,19 @@ async def create_customer_account(account_data: CustomerAccountCreate, db: Async
last_seen=datetime.utcnow(), last_seen=datetime.utcnow(),
admin=0, admin=0,
admin_role=0, admin_role=0,
confirmed=1, confirmed=0,
active=1, active=1,
user_id=customer.id user_id=customer.id,
confirmation_token=token,
confirmation_sent_at=datetime.utcnow()
) )
db.add(db_user) db.add(db_user)
await db.commit() await db.commit()
await db.refresh(db_user) await db.refresh(db_user)
# In a real application, you would send an email here
# For now, we'll just print the confirmation URL to the console
confirmation_url = f"http://localhost:3000/confirm-email?token={token}"
print(f"Confirmation URL: {confirmation_url}")
return db_user return db_user

View File

@@ -1,12 +1,17 @@
import secrets
from fastapi.responses import JSONResponse
# Existing imports...
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from database import get_db from database import get_db
from models import Account_User, Customer_Customer from models import Account_User, Customer_Customer
from schemas import UserCreate, UserResponse from schemas import UserCreate
from passlib.context import CryptContext from passlib.context import CryptContext
from datetime import datetime from datetime import datetime, timezone
# Existing pwd_context and helper functions...
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def get_password_hash(password): def get_password_hash(password):
@@ -25,14 +30,13 @@ def escape_like_pattern(value: str) -> str:
router = APIRouter() router = APIRouter()
@router.post("/register", response_model=UserResponse) @router.post("/register")
async def register(user: UserCreate, db: AsyncSession = Depends(get_db)): async def register(user: UserCreate, db: AsyncSession = Depends(get_db)):
# Verify passwords match # Verify passwords match
if user.password != user.confirm_password: if user.password != user.confirm_password:
raise HTTPException(status_code=400, detail="Passwords do not match") raise HTTPException(status_code=400, detail="Passwords do not match")
# Check if customer exists in Customer_Customer table # Check if customer exists in Customer_Customer table
# Escape SQL LIKE wildcards to prevent injection attacks
escaped_house_number = escape_like_pattern(user.house_number) escaped_house_number = escape_like_pattern(user.house_number)
customer_result = await db.execute( customer_result = await db.execute(
select(Customer_Customer).where( select(Customer_Customer).where(
@@ -51,21 +55,31 @@ async def register(user: UserCreate, db: AsyncSession = Depends(get_db)):
username = f"{user.account_number}-{user.house_number}" username = f"{user.account_number}-{user.house_number}"
hashed_password = get_password_hash(user.password) hashed_password = get_password_hash(user.password)
token = secrets.token_urlsafe(32)
db_user = Account_User( db_user = Account_User(
username=username, username=username,
account_number=user.account_number, account_number=user.account_number,
house_number=user.house_number, house_number=user.house_number,
password_hash=hashed_password, password_hash=hashed_password,
member_since=datetime.utcnow(), member_since=datetime.now(timezone.utc),
email=user.email, email=user.email,
last_seen=datetime.utcnow(), last_seen=datetime.now(timezone.utc),
admin=0, admin=0,
admin_role=0, admin_role=0,
confirmed=1, confirmed=0,
active=1, active=1,
user_id=customer.id user_id=customer.id,
confirmation_token=token,
confirmation_sent_at=datetime.now(timezone.utc)
) )
db.add(db_user) db.add(db_user)
await db.commit() await db.commit()
await db.refresh(db_user) await db.refresh(db_user)
return db_user
# In a real application, you would send an email here
# For now, we'll just print the confirmation URL to the console
confirmation_url = f"http://localhost:3000/confirm-email?token={token}"
print(f"Confirmation URL: {confirmation_url}")
return JSONResponse(status_code=201, content={"message": "Registration successful. Please check your email to confirm your account."})

View File

@@ -19,5 +19,8 @@ async def get_current_pricing(db: AsyncSession = Depends(get_db)):
return { return {
"price_per_gallon": float(pricing.price_for_customer), "price_per_gallon": float(pricing.price_for_customer),
"price_same_day": float(pricing.price_same_day) if pricing.price_same_day else 0.00,
"price_prime": float(pricing.price_prime) if pricing.price_prime else 0.00,
"price_emergency": float(pricing.price_emergency) if pricing.price_emergency else 0.00,
"date": pricing.date.isoformat() if pricing.date else None "date": pricing.date.isoformat() if pricing.date else None
} }