Files
Aegis/backend/app/services/password_setup_service.py
T
kitos 1d0d880929
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
feat(email): generic webhook for all notification emails, hide SMTP UI
- Renamed the password-setup webhook (email_webhook.*, /system/email-webhook-config)
  and made it the single transport for all notification emails.
- New webhook_email_service.py shared by password_setup_service and
  notification_service.
- Wired test validated/rejected, campaign completed, and new-MITRE-technique
  notifications through the webhook (previously dead SMTP code, never triggered).
- Added POST /system/email-webhook-test to send a real test email.
- Hid the Email/SMTP settings tab from the UI (SMTP code kept intact, unused).
- Redact email_webhook.api_key on config export.
2026-07-20 10:24:15 +02:00

102 lines
3.6 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."
)
send_webhook_email(db, to=user.email, subject=subject, message=message, full_name=user.full_name)
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