feat: email verification, merchant/store password reset, seed gap fix
Some checks failed
CI / ruff (push) Successful in 10s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled

- Add EmailVerificationToken and UserPasswordResetToken models with migration
- Add email verification flow: verify-email page route, resend-verification API
- Block login for unverified users (EmailNotVerifiedException in auth_service)
- Add forgot-password/reset-password endpoints for merchant and store auth
- Add "Forgot Password?" links to merchant and store login pages
- Send welcome email with verification link on merchant creation
- Seed email_verification and merchant_password_reset email templates
- Fix db-reset Makefile to run all init-prod seed scripts
- Add UserAuthService to satisfy architecture validation rules
- Add 52 new tests (unit + integration) with full coverage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 23:22:46 +01:00
parent a8b29750a5
commit d9fc52d47a
30 changed files with 2574 additions and 29 deletions

View File

@@ -28,6 +28,7 @@ from app.modules.tenancy.models.admin import (
PlatformAlert,
)
from app.modules.tenancy.models.admin_platform import AdminPlatform
from app.modules.tenancy.models.email_verification_token import EmailVerificationToken
from app.modules.tenancy.models.merchant import Merchant
from app.modules.tenancy.models.merchant_domain import MerchantDomain
from app.modules.tenancy.models.platform import Platform
@@ -36,6 +37,7 @@ from app.modules.tenancy.models.store import Role, Store, StoreUser, StoreUserTy
from app.modules.tenancy.models.store_domain import StoreDomain
from app.modules.tenancy.models.store_platform import StorePlatform
from app.modules.tenancy.models.user import User, UserRole
from app.modules.tenancy.models.user_password_reset_token import UserPasswordResetToken
__all__ = [
# Admin models
@@ -54,6 +56,9 @@ __all__ = [
# User
"User",
"UserRole",
# Tokens
"EmailVerificationToken",
"UserPasswordResetToken",
# Store
"Store",
"StoreUser",

View File

@@ -0,0 +1,91 @@
# app/modules/tenancy/models/email_verification_token.py
"""
Email verification token model for user accounts.
Security features:
- Tokens are stored as SHA256 hashes, not plaintext
- Tokens expire after 24 hours
- Only one active token per user (old tokens invalidated on new request)
"""
import hashlib
import secrets
from datetime import datetime, timedelta
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Session, relationship
from app.core.database import Base
class EmailVerificationToken(Base):
"""Email verification token for user accounts."""
__tablename__ = "email_verification_tokens"
# Token expiry in hours
TOKEN_EXPIRY_HOURS = 24
id = Column(Integer, primary_key=True, index=True)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
token_hash = Column(String(64), nullable=False, index=True)
expires_at = Column(DateTime, nullable=False)
used_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User")
def __repr__(self):
return f"<EmailVerificationToken(id={self.id}, user_id={self.user_id}, expires_at={self.expires_at})>"
@staticmethod
def hash_token(token: str) -> str:
"""Hash a token using SHA256."""
return hashlib.sha256(token.encode()).hexdigest()
@classmethod
def create_for_user(cls, db: Session, user_id: int) -> str:
"""Create a new email verification token for a user.
Invalidates any existing tokens for the user.
Returns the plaintext token (to be sent via email).
"""
# Invalidate existing tokens for this user
db.query(cls).filter(
cls.user_id == user_id,
cls.used_at.is_(None),
).delete()
# Generate new token
plaintext_token = secrets.token_urlsafe(32)
token_hash = cls.hash_token(plaintext_token)
# Create token record
token = cls(
user_id=user_id,
token_hash=token_hash,
expires_at=datetime.utcnow() + timedelta(hours=cls.TOKEN_EXPIRY_HOURS),
)
db.add(token)
db.flush()
return plaintext_token
@classmethod
def find_valid_token(cls, db: Session, plaintext_token: str) -> "EmailVerificationToken | None":
"""Find a valid (not expired, not used) token."""
token_hash = cls.hash_token(plaintext_token)
return db.query(cls).filter(
cls.token_hash == token_hash,
cls.expires_at > datetime.utcnow(),
cls.used_at.is_(None),
).first()
def mark_used(self, db: Session) -> None:
"""Mark this token as used."""
self.used_at = datetime.utcnow()
db.flush()

View File

@@ -0,0 +1,91 @@
# app/modules/tenancy/models/user_password_reset_token.py
"""
Password reset token model for user accounts (merchants/store users).
Security features:
- Tokens are stored as SHA256 hashes, not plaintext
- Tokens expire after 1 hour
- Only one active token per user (old tokens invalidated on new request)
"""
import hashlib
import secrets
from datetime import datetime, timedelta
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Session, relationship
from app.core.database import Base
class UserPasswordResetToken(Base):
"""Password reset token for user accounts (merchants/store team)."""
__tablename__ = "user_password_reset_tokens"
# Token expiry in hours
TOKEN_EXPIRY_HOURS = 1
id = Column(Integer, primary_key=True, index=True)
user_id = Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
token_hash = Column(String(64), nullable=False, index=True)
expires_at = Column(DateTime, nullable=False)
used_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User")
def __repr__(self):
return f"<UserPasswordResetToken(id={self.id}, user_id={self.user_id}, expires_at={self.expires_at})>"
@staticmethod
def hash_token(token: str) -> str:
"""Hash a token using SHA256."""
return hashlib.sha256(token.encode()).hexdigest()
@classmethod
def create_for_user(cls, db: Session, user_id: int) -> str:
"""Create a new password reset token for a user.
Invalidates any existing tokens for the user.
Returns the plaintext token (to be sent via email).
"""
# Invalidate existing tokens for this user
db.query(cls).filter(
cls.user_id == user_id,
cls.used_at.is_(None),
).delete()
# Generate new token
plaintext_token = secrets.token_urlsafe(32)
token_hash = cls.hash_token(plaintext_token)
# Create token record
token = cls(
user_id=user_id,
token_hash=token_hash,
expires_at=datetime.utcnow() + timedelta(hours=cls.TOKEN_EXPIRY_HOURS),
)
db.add(token)
db.flush()
return plaintext_token
@classmethod
def find_valid_token(cls, db: Session, plaintext_token: str) -> "UserPasswordResetToken | None":
"""Find a valid (not expired, not used) token."""
token_hash = cls.hash_token(plaintext_token)
return db.query(cls).filter(
cls.token_hash == token_hash,
cls.expires_at > datetime.utcnow(),
cls.used_at.is_(None),
).first()
def mark_used(self, db: Session) -> None:
"""Mark this token as used."""
self.used_at = datetime.utcnow()
db.flush()