Files
orion/app/modules/tenancy/models/email_verification_token.py
Samir Boulahtit d9fc52d47a
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
feat: email verification, merchant/store password reset, seed gap fix
- 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>
2026-02-18 23:22:46 +01:00

92 lines
2.8 KiB
Python

# 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()