diff --git a/auth/email/__init__.py b/auth/email/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/auth/email/provider.py b/auth/email/provider.py new file mode 100644 index 0000000..1dae7e0 --- /dev/null +++ b/auth/email/provider.py @@ -0,0 +1,273 @@ +""" +Email magic link authentication provider. + +Users enter their email, receive a magic link, click it and they're logged in. +No passwords needed. Domain restriction ensures only allowed users can access. + +Email delivery modes: +1. SMTP relay (recommended) - configure SMTP_HOST, SMTP_PORT, etc. in .env +2. Console mode (development) - link printed to server log, shown in browser +""" + +import logging +import smtplib +import time +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +from flask import ( + Blueprint, + current_app, + flash, + redirect, + render_template, + request, + session, + url_for, +) +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer + +from auth import AuthProvider +from webapp.auth import validate_email_domain +from webapp.config import Config + +logger = logging.getLogger(__name__) + +email_bp = Blueprint("email_auth", __name__) + +# SVG envelope icon for the login button +_EMAIL_ICON_HTML = ( + '' + '' + '' + "" +) + + +def _get_serializer() -> URLSafeTimedSerializer: + """Create token serializer using the app secret key.""" + return URLSafeTimedSerializer(Config.SECRET_KEY, salt="email-magic-link") + + +def _generate_magic_token(email: str) -> str: + """Generate a signed, time-limited token containing the email.""" + s = _get_serializer() + return s.dumps({"email": email.lower(), "t": int(time.time())}) + + +def _verify_magic_token(token: str, max_age_seconds: int = 900) -> str | None: + """Verify magic link token. Returns email if valid, None otherwise. + + Args: + token: The signed token from the magic link URL. + max_age_seconds: Token validity period (default 15 minutes). + + Returns: + Email address if token is valid, None otherwise. + """ + s = _get_serializer() + try: + data = s.loads(token, max_age=max_age_seconds) + return data.get("email") + except SignatureExpired: + logger.warning("Magic link token expired") + return None + except BadSignature: + logger.warning("Invalid magic link token") + return None + + +def _send_magic_email(email: str, magic_url: str) -> bool: + """Send magic link email via SMTP relay. + + Returns True if sent successfully, False otherwise. + """ + smtp_host = Config.SMTP_HOST + if not smtp_host: + return False + + msg = MIMEMultipart("alternative") + msg["Subject"] = f"Sign in to {Config.INSTANCE_NAME}" + msg["From"] = Config.SMTP_FROM + msg["To"] = email + + text_body = ( + f"Sign in to {Config.INSTANCE_NAME}\n\n" + f"Click the link below to sign in:\n{magic_url}\n\n" + f"This link expires in 15 minutes.\n" + f"If you didn't request this, ignore this email." + ) + + html_body = f""" + + +

Sign in to {Config.INSTANCE_NAME}

+

Click the button below to sign in:

+

+ + Sign In + +

+

+ This link expires in 15 minutes.
+ If you didn't request this, ignore this email. +

+
+

+ Or copy and paste this URL into your browser:
+ {magic_url} +

+ +""" + + msg.attach(MIMEText(text_body, "plain")) + msg.attach(MIMEText(html_body, "html")) + + try: + smtp_port = Config.SMTP_PORT + use_tls = Config.SMTP_USE_TLS + + if smtp_port == 465: + server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=10) + else: + server = smtplib.SMTP(smtp_host, smtp_port, timeout=10) + if use_tls: + server.starttls() + + smtp_user = Config.SMTP_USER + smtp_password = Config.SMTP_PASSWORD + if smtp_user and smtp_password: + server.login(smtp_user, smtp_password) + + server.sendmail(Config.SMTP_FROM, [email], msg.as_string()) + server.quit() + logger.info("Magic link email sent to %s via SMTP", email) + return True + + except Exception as e: + logger.error("Failed to send magic link email to %s: %s", email, e) + return False + + +# --- Routes --- + + +@email_bp.route("/login/email") +def login_email_form(): + """Show email input form.""" + return render_template( + "login_magic_link.html", + allowed_domain=Config.ALLOWED_DOMAIN, + ) + + +@email_bp.route("/login/email/send", methods=["POST"]) +def send_magic_link(): + """Validate email domain and send magic link.""" + email = request.form.get("email", "").strip().lower() + + if not email: + flash("Please enter your email address.", "error") + return redirect(url_for("email_auth.login_email_form")) + + if not validate_email_domain(email): + flash( + f"Only @{Config.ALLOWED_DOMAIN} email addresses are allowed.", + "error", + ) + return redirect(url_for("email_auth.login_email_form")) + + # Generate magic link + token = _generate_magic_token(email) + magic_url = url_for("email_auth.verify_magic_link", token=token, _external=True) + + # Try SMTP first, fall back to console mode + smtp_sent = _send_magic_email(email, magic_url) + + if smtp_sent: + flash("Check your email for the sign-in link.", "info") + return render_template( + "login_magic_link_sent.html", + email=email, + console_mode=False, + ) + else: + # Console/development mode - show link directly + logger.info("MAGIC LINK for %s: %s", email, magic_url) + return render_template( + "login_magic_link_sent.html", + email=email, + magic_url=magic_url, + console_mode=True, + ) + + +@email_bp.route("/login/email/verify/") +def verify_magic_link(token: str): + """Verify magic link token and log user in.""" + email = _verify_magic_token(token) + + if not email: + flash("This sign-in link has expired or is invalid. Please try again.", "error") + return redirect(url_for("email_auth.login_email_form")) + + # Double-check domain (in case config changed since token was issued) + if not validate_email_domain(email): + flash("Your email is no longer authorized.", "error") + return redirect(url_for("auth.login")) + + # Set session (shared contract across all auth providers) + name = email.split("@")[0].replace(".", " ").title() + session["user"] = { + "email": email, + "name": name, + "picture": "", + } + + logger.info("User logged in via magic link: %s", email) + return redirect(url_for("dashboard")) + + +# --- Provider class --- + + +class EmailAuthProvider(AuthProvider): + """Email magic link authentication provider.""" + + def get_name(self) -> str: + return "email" + + def get_display_name(self) -> str: + return "Email" + + def get_blueprint(self) -> Blueprint: + return email_bp + + def get_login_button(self) -> dict: + domain = Config.ALLOWED_DOMAIN + subtitle = f'For @{domain} email addresses.' if domain else "" + return { + "text": "Sign in with Email", + "url": "/login/email", + "icon_html": _EMAIL_ICON_HTML, + "subtitle": subtitle, + "order": 20, + "css_class": "btn-email", + "visible": True, + } + + def is_available(self) -> bool: + """Available when allowed_domain is configured.""" + return bool(Config.ALLOWED_DOMAIN) + + def init_app(self, app) -> None: + """No additional initialization needed.""" + pass + + +# Module-level provider instance for auto-discovery +provider = EmailAuthProvider() diff --git a/config/instance.yaml.example b/config/instance.yaml.example index be0e971..acf4c90 100644 --- a/config/instance.yaml.example +++ b/config/instance.yaml.example @@ -32,11 +32,15 @@ deployment: branch: "main" # --- Authentication --- +# At minimum, set allowed_domain and webapp_secret_key. +# Email magic link auth works out of the box (no external service needed). +# Google OAuth is optional - add credentials to enable it. auth: - allowed_domain: "" # Google OAuth domain (e.g., "acme.com") + allowed_domain: "" # Email domain for login (e.g., "acme.com") + webapp_secret_key: "${WEBAPP_SECRET_KEY}" + # Optional: Google OAuth (if not set, only email magic link is available) google_client_id: "${GOOGLE_CLIENT_ID}" google_client_secret: "${GOOGLE_CLIENT_SECRET}" - webapp_secret_key: "${WEBAPP_SECRET_KEY}" # --- Data source --- data_source: @@ -46,11 +50,16 @@ data_source: stack_url: "" # e.g., "https://connection.keboola.com" project_id: "" -# --- Email (optional, for password auth) --- +# --- Email delivery (optional, for magic link auth) --- +# Without SMTP, magic links are shown directly in browser (development mode). +# For production, configure any SMTP relay (Gmail, Mailgun, SendGrid SMTP, etc.) email: from_address: "noreply@example.com" from_name: "AI Data Analyst" - sendgrid_api_key: "${SENDGRID_API_KEY}" + smtp_host: "${SMTP_HOST}" # e.g., "smtp.gmail.com" + smtp_port: 587 # 587 for STARTTLS, 465 for SSL + smtp_user: "${SMTP_USER}" + smtp_password: "${SMTP_PASSWORD}" # --- Desktop app (optional) --- desktop: diff --git a/tests/test_email_auth.py b/tests/test_email_auth.py new file mode 100644 index 0000000..af64098 --- /dev/null +++ b/tests/test_email_auth.py @@ -0,0 +1,95 @@ +"""Tests for the email magic link authentication provider.""" + +import pytest + +from auth.email.provider import ( + EmailAuthProvider, + _generate_magic_token, + _verify_magic_token, +) + + +# --------------------------------------------------------------------------- +# Token tests +# --------------------------------------------------------------------------- + +class TestMagicTokens: + """Test magic link token generation and verification.""" + + @pytest.fixture(autouse=True) + def setup_env(self, monkeypatch): + """Set required env vars for Config.""" + monkeypatch.setenv("WEBAPP_SECRET_KEY", "test-secret-key-for-tokens") + + def test_generate_and_verify_token(self): + """Valid token should return the email.""" + token = _generate_magic_token("user@acme.com") + email = _verify_magic_token(token, max_age_seconds=60) + assert email == "user@acme.com" + + def test_token_normalizes_email(self): + """Email should be lowercased in the token.""" + token = _generate_magic_token("User@ACME.com") + email = _verify_magic_token(token, max_age_seconds=60) + assert email == "user@acme.com" + + def test_expired_token_returns_none(self): + """Expired token should return None.""" + import time + token = _generate_magic_token("user@acme.com") + time.sleep(2) + email = _verify_magic_token(token, max_age_seconds=1) + assert email is None + + def test_invalid_token_returns_none(self): + """Tampered token should return None.""" + email = _verify_magic_token("not-a-valid-token", max_age_seconds=60) + assert email is None + + def test_empty_token_returns_none(self): + """Empty token should return None.""" + email = _verify_magic_token("", max_age_seconds=60) + assert email is None + + +# --------------------------------------------------------------------------- +# Provider tests +# --------------------------------------------------------------------------- + +class TestEmailAuthProvider: + """Test the EmailAuthProvider class.""" + + @pytest.fixture(autouse=True) + def setup_env(self, monkeypatch): + """Set required env vars.""" + monkeypatch.setenv("WEBAPP_SECRET_KEY", "test-secret") + + def test_provider_name(self): + provider = EmailAuthProvider() + assert provider.get_name() == "email" + + def test_provider_display_name(self): + provider = EmailAuthProvider() + assert provider.get_display_name() == "Email" + + def test_login_button_properties(self, monkeypatch): + # Reload config with allowed domain set + monkeypatch.setattr("webapp.config.Config.ALLOWED_DOMAIN", "acme.com") + provider = EmailAuthProvider() + button = provider.get_login_button() + assert button["text"] == "Sign in with Email" + assert button["url"] == "/login/email" + assert button["visible"] is True + assert button["order"] == 20 + assert "btn-email" in button["css_class"] + assert "acme.com" in button["subtitle"] + + def test_provider_available_with_domain(self, monkeypatch): + monkeypatch.setattr("webapp.config.Config.ALLOWED_DOMAIN", "acme.com") + provider = EmailAuthProvider() + assert provider.is_available() is True + + def test_provider_unavailable_without_domain(self, monkeypatch): + monkeypatch.setattr("webapp.config.Config.ALLOWED_DOMAIN", "") + provider = EmailAuthProvider() + assert provider.is_available() is False diff --git a/webapp/config.py b/webapp/config.py index 47d1a26..24aea35 100644 --- a/webapp/config.py +++ b/webapp/config.py @@ -57,7 +57,17 @@ class Config: os.environ.get("PASSWORD_USERS_FILE", "/data/auth/password_users.json") ) - # SendGrid email service + # SMTP email delivery (for magic link auth) + SMTP_HOST = os.environ.get("SMTP_HOST", "") + SMTP_PORT = int(os.environ.get("SMTP_PORT", "587")) + SMTP_USER = os.environ.get("SMTP_USER", "") + SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "") + SMTP_FROM = os.environ.get("SMTP_FROM", + os.environ.get("SMTP_USER", + _get(_instance, "email", "from_address", default="noreply@example.com"))) + SMTP_USE_TLS = os.environ.get("SMTP_USE_TLS", "true").lower() == "true" + + # SendGrid email service (legacy, for password auth) SENDGRID_API_KEY = os.environ.get("SENDGRID_API_KEY", "") EMAIL_FROM_ADDRESS = os.environ.get("EMAIL_FROM_ADDRESS", _get(_instance, "email", "from_address", default="noreply@example.com")) diff --git a/webapp/static/style-custom.css b/webapp/static/style-custom.css index 0f07743..3f9583a 100644 --- a/webapp/static/style-custom.css +++ b/webapp/static/style-custom.css @@ -1771,6 +1771,33 @@ a.slack-badge:hover { transform: scale(0.98); } +/* Email magic link button styling */ +.login-page .btn-email { + background-color: #4361ee; + color: white; + border: 1px solid #3a56d4; + padding: 14px 28px; + font-size: var(--text-base); + font-weight: var(--font-medium); + border-radius: var(--radius-md); + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 12px; + transition: all 0.2s ease; + cursor: pointer; +} + +.login-page .btn-email:hover { + background-color: #3a56d4; + box-shadow: 0 2px 8px rgba(67, 97, 238, 0.3); +} + +.login-page .btn-email:active { + transform: scale(0.98); +} + .login-page .btn-secondary { background-color: transparent; color: var(--text-secondary); diff --git a/webapp/templates/login_magic_link.html b/webapp/templates/login_magic_link.html new file mode 100644 index 0000000..1f0b7e3 --- /dev/null +++ b/webapp/templates/login_magic_link.html @@ -0,0 +1,59 @@ +{% extends "base_login.html" %} + +{% block title %}Sign in with Email - {{ config.INSTANCE_NAME }}{% endblock %} + +{% block content %} +
+ +
+{% endblock %} diff --git a/webapp/templates/login_magic_link_sent.html b/webapp/templates/login_magic_link_sent.html new file mode 100644 index 0000000..1de3cc3 --- /dev/null +++ b/webapp/templates/login_magic_link_sent.html @@ -0,0 +1,64 @@ +{% extends "base_login.html" %} + +{% block title %}Check Your Email - {{ config.INSTANCE_NAME }}{% endblock %} + +{% block content %} +
+ +
+{% endblock %}