Files
Aegis/backend/app/services/password_setup_service.py
kitos f13764d9e2
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled
fix(email): detect and surface webhook send failures instead of claiming success
send_webhook_email() only returned False on a network exception — a
webhook that responded with a non-2xx status (bad URL, wrong API key,
misconfigured Power Automate flow) was still reported as delivered. Now
checks response.ok and returns False on rejection too.

send_password_setup_email() also never checked the return value at all,
so the admin's 'Send Email' action (including resending a password-reset
email after the user already set their password) always showed success
even when nothing was actually sent. Now raises a clear error instead.
2026-07-24 16:45:32 +02:00

106 lines
3.8 KiB
Python

"""Passwordless-user onboarding — one-time set-password links sent by webhook.
An admin creates a user with just a name/email/role (see
``user_service.create_user_without_password``). To let that user in, the
admin clicks "Send Email": this service issues a one-time token and sends
it via the shared ``webhook_email_service`` (POSTs to an admin-configured
Power Automate webhook). The same mechanism, and the same button, is
reused for password resets on existing users.
"""
from __future__ import annotations
import secrets
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from app.config import settings
from app.domain.errors import BusinessRuleViolation, EntityNotFoundError
from app.models.password_setup_token import PasswordSetupToken
from app.models.user import User
from app.services.webhook_email_service import get_webhook_url, send_webhook_email
_TOKEN_TTL = timedelta(hours=24)
def create_setup_token(db: Session, user: User) -> PasswordSetupToken:
"""Issue a fresh one-time token for *user*, invalidating any unused ones.
Does not commit; caller commits.
"""
db.query(PasswordSetupToken).filter(
PasswordSetupToken.user_id == user.id,
PasswordSetupToken.used_at.is_(None),
).delete()
token = PasswordSetupToken(
user_id=user.id,
token=secrets.token_urlsafe(32),
expires_at=datetime.utcnow() + _TOKEN_TTL,
)
db.add(token)
db.flush()
return token
def send_password_setup_email(db: Session, user: User) -> None:
"""Issue a token and email it via the configured webhook.
Raises BusinessRuleViolation if no webhook is configured yet.
Does not commit; caller commits (the token must be persisted even if
the webhook call itself fails, so a fresh "Send Email" retry works).
"""
if not get_webhook_url(db):
raise BusinessRuleViolation(
"No email webhook is configured yet — set one in Settings first."
)
token = create_setup_token(db, user)
set_password_url = f"{settings.PLATFORM_URL}/set-password?token={token.token}"
is_reset = not user.must_change_password
subject = "Reset Your Password" if is_reset else "Set Your Password"
message = (
f'Click the link below to {"reset" if is_reset else "set"} your Aegis password:\n\n'
f"{set_password_url}\n\n"
"This link expires in 24 hours and can only be used once."
)
sent = send_webhook_email(db, to=user.email, subject=subject, message=message, full_name=user.full_name)
if not sent:
raise BusinessRuleViolation(
"Failed to send the email — check the webhook URL/API key in Settings and the server logs."
)
def _get_valid_token_or_raise(db: Session, token: str) -> PasswordSetupToken:
row = db.query(PasswordSetupToken).filter(PasswordSetupToken.token == token).first()
if row is None:
raise EntityNotFoundError("Password setup token", token)
if row.used_at is not None:
raise BusinessRuleViolation("This link has already been used")
if row.expires_at < datetime.utcnow():
raise BusinessRuleViolation("This link has expired — ask an admin to send a new one")
return row
def validate_token(db: Session, token: str) -> User:
"""Return the token's owning user if the token is valid, else raise."""
row = _get_valid_token_or_raise(db, token)
return row.user
def consume_token_and_set_password(db: Session, token: str, new_password: str) -> User:
"""Set *new_password* for the token's user and mark the token used.
Does not commit; caller commits.
"""
from app.auth import hash_password
row = _get_valid_token_or_raise(db, token)
user = row.user
user.hashed_password = hash_password(new_password)
user.must_change_password = False
row.used_at = datetime.utcnow()
db.flush()
return user