from __future__ import annotations from datetime import timedelta, timezone import hashlib import re import secrets from sqlalchemy import select from sqlalchemy.orm import Session from app.core.security.jwt_tokens import utcnow from app.core.security.passwords import hash_password from app.core.settings import get_settings from app.modules.core.iam.models import User from app.modules.core.iam.password_flows_models import InviteToken, PasswordResetToken def _hash_token(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() def validate_password_policy(password: str) -> str | None: s = get_settings() if len(password or "") < s.PASSWORD_MIN_LENGTH: return f"Password must be at least {s.PASSWORD_MIN_LENGTH} characters long." if not re.search(r"[A-Za-z]", password or ""): return "Password must include at least one letter." if not re.search(r"\d", password or ""): return "Password must include at least one number." return None def issue_invite_token(db: Session, user: User) -> str: plain = secrets.token_urlsafe(32) now = utcnow() token = InviteToken( user_id=user.id, token_hash=_hash_token(plain), created_at_utc=now, expires_at_utc=now + timedelta(hours=get_settings().INVITE_TOKEN_HOURS), used_at_utc=None, ) db.add(token) user.must_change_password = True db.commit() return plain def issue_password_reset_token(db: Session, user: User) -> str: plain = secrets.token_urlsafe(32) now = utcnow() token = PasswordResetToken( user_id=user.id, token_hash=_hash_token(plain), created_at_utc=now, expires_at_utc=now + timedelta(hours=get_settings().PASSWORD_RESET_HOURS), used_at_utc=None, ) db.add(token) db.commit() return plain def _validate_unused(record) -> bool: if not record or record.used_at_utc is not None: return False now = utcnow() exp = record.expires_at_utc if getattr(exp, "tzinfo", None) is None: exp = exp.replace(tzinfo=timezone.utc) return exp >= now def accept_invite(db: Session, token: str, password: str) -> User | None: err = validate_password_policy(password) if err: raise ValueError(err) record = db.execute(select(InviteToken).where(InviteToken.token_hash == _hash_token(token))).scalar_one_or_none() if not _validate_unused(record): return None user = db.execute(select(User).where(User.id == record.user_id)).scalar_one_or_none() if not user: return None user.password_hash = hash_password(password) user.must_change_password = False user.password_changed_at_utc = utcnow().replace(tzinfo=None) user.allow_login = True user.is_active = True record.used_at_utc = utcnow().replace(tzinfo=None) db.commit() return user def reset_password_with_token(db: Session, token: str, password: str) -> User | None: err = validate_password_policy(password) if err: raise ValueError(err) record = db.execute(select(PasswordResetToken).where(PasswordResetToken.token_hash == _hash_token(token))).scalar_one_or_none() if not _validate_unused(record): return None user = db.execute(select(User).where(User.id == record.user_id)).scalar_one_or_none() if not user: return None user.password_hash = hash_password(password) user.must_change_password = False user.password_changed_at_utc = utcnow().replace(tzinfo=None) record.used_at_utc = utcnow().replace(tzinfo=None) db.commit() return user def force_change_password(db: Session, user: User, new_password: str) -> None: err = validate_password_policy(new_password) if err: raise ValueError(err) user.password_hash = hash_password(new_password) user.must_change_password = False user.password_changed_at_utc = utcnow().replace(tzinfo=None) db.commit()