diff --git a/.env.example b/.env.example index ce2bb45..08718c1 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,14 @@ LINKLOG_APP_NAME=LinkLog LINKLOG_VERSION=0.1.0 LINKLOG_SECRET_KEY=replace-with-a-long-random-secret LINKLOG_TOKEN_EXPIRY_DAYS=30 +LINKLOG_PUBLIC_URL=http://localhost:8000 +LINKLOG_SMTP_HOST= +LINKLOG_SMTP_PORT=587 +LINKLOG_SMTP_USERNAME= +LINKLOG_SMTP_PASSWORD= +LINKLOG_SMTP_FROM=LinkLog +LINKLOG_SMTP_USE_TLS=true +LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS=24 # Optional comma-separated override. Leave empty to use the built-in list. LINKLOG_TRACKING_PARAMS= # Keep the default path when using the named linklog_data volume. diff --git a/README.md b/README.md index 3e5d49f..ec696c2 100644 --- a/README.md +++ b/README.md @@ -175,10 +175,20 @@ The main configurable values are: | `LINKLOG_SECRET_KEY` | token signing/security secret | required in Docker | | `LINKLOG_DATABASE_PATH` | SQLite file path inside the container | `/app/backend/data/linklog.db` | | `LINKLOG_TOKEN_EXPIRY_DAYS` | access-token lifetime | `30` | +| `LINKLOG_PUBLIC_URL` | Base URL used in email verification links | `http://localhost:8000` | +| `LINKLOG_SMTP_HOST` | SMTP server hostname; empty disables delivery in local development | empty | +| `LINKLOG_SMTP_PORT` | SMTP server port | `587` | +| `LINKLOG_SMTP_USERNAME` | SMTP login username | empty | +| `LINKLOG_SMTP_PASSWORD` | SMTP login password | empty | +| `LINKLOG_SMTP_FROM` | Sender address for verification mail | `LinkLog ` | +| `LINKLOG_SMTP_USE_TLS` | Use STARTTLS for SMTP | `true` | +| `LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS` | Verification-link lifetime | `24` | | `LINKLOG_TRACKING_PARAMS` | comma-separated tracking parameters (stripped from logged URLs) | built-in list | | `TRAEFIK_HOST` | hostname routed by Traefik | `localhost` | | `APP_PORT` | direct host port for FastAPI | `8000` | +New users created by an administrator are email-unverified and cannot sign in until they follow the verification link sent to their address. The link is valid for `LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS` hours and is handled by `/api/auth/verify-email`. Configure `LINKLOG_SMTP_HOST`, `LINKLOG_SMTP_FROM`, and the SMTP credentials for delivery; local development may leave the SMTP host empty, in which case accounts remain pending verification and no message is sent. + The full set of supported variables is listed in `.env.example`. Application variables are passed into the container by Compose; Docker and Traefik variables are used by Compose itself. Build and start the application and local Traefik proxy: diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index c38ccc8..52c95fb 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -10,6 +10,9 @@ from pydantic import BaseModel from backend.app.api.dependencies import require_admin from backend.app.database import get_connection, hash_password from backend.app.services.link_service import delete_label +from backend.app.services.email_service import send_verification_email, smtp_configured +from backend.app.services.email_verification import create_verification_token +from backend.app.core.config import settings router = APIRouter() @@ -39,6 +42,7 @@ def public_user(row): 'avatar_url': row['avatar_url'], 'bio': row['bio'], 'created_at': row['created_at'], + 'email_verified': bool(row['email_verified']), } @@ -46,7 +50,7 @@ def public_user(row): def list_users(_: dict = Depends(require_admin)): with get_connection() as conn: rows = conn.execute( - 'SELECT id, username, email, is_admin, avatar_url, bio, created_at FROM users ORDER BY username' + 'SELECT id, username, email, is_admin, avatar_url, bio, created_at, email_verified FROM users ORDER BY username' ).fetchall() return [public_user(row) for row in rows] @@ -62,8 +66,8 @@ def create_user(payload: AdminUserCreate, _: dict = Depends(require_admin)): try: cursor = conn.execute( ''' - INSERT INTO users (id, username, email, password_hash, is_admin) - VALUES (?, ?, ?, ?, ?) + INSERT INTO users (id, username, email, password_hash, is_admin, email_verified) + VALUES (?, ?, ?, ?, ?, 0) ''', (str(uuid4()), username, email, hash_password(payload.password), int(payload.is_admin)), ) @@ -74,8 +78,15 @@ def create_user(payload: AdminUserCreate, _: dict = Depends(require_admin)): raise row = conn.execute( - 'SELECT id, username, email, is_admin, avatar_url, bio, created_at FROM users WHERE rowid = last_insert_rowid()' + 'SELECT id, username, email, is_admin, avatar_url, bio, created_at, email_verified FROM users WHERE rowid = last_insert_rowid()' ).fetchone() + token = create_verification_token(row['id']) + verification_url = f'{settings.public_url}/api/auth/verify-email?token={token}' + if smtp_configured(): + try: + send_verification_email(row['email'], row['username'], verification_url) + except Exception as error: + raise HTTPException(status_code=503, detail=f'User created but verification email could not be sent: {error}') from error return public_user(row) @@ -109,7 +120,7 @@ def update_user_privileges( ) conn.commit() row = conn.execute( - 'SELECT id, username, email, is_admin, avatar_url, bio, created_at FROM users WHERE id = ?', + 'SELECT id, username, email, is_admin, avatar_url, bio, created_at, email_verified FROM users WHERE id = ?', (user_id,), ).fetchone() return public_user(row) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index f343c9d..b591101 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -8,6 +8,7 @@ from pydantic import BaseModel from backend.app.database import get_connection, init_db from backend.app.services.auth_service import authenticate_user +from backend.app.services.email_verification import verify_email from backend.app.services.token_service import issue_token, revoke_token, validate_token router = APIRouter() @@ -25,6 +26,8 @@ def login(payload: LoginRequest): user = authenticate_user(payload.username, payload.password) if user is None: raise HTTPException(status_code=401, detail='Invalid username or password') + if not user['email_verified']: + raise HTTPException(status_code=403, detail='Email address is not verified') token_data = issue_token(user['id'], user['username']) return { @@ -36,6 +39,13 @@ def login(payload: LoginRequest): } +@router.get('/verify-email') +def verify_email_address(token: str): + if not verify_email(token): + raise HTTPException(status_code=400, detail='Verification link is invalid or expired') + return {'status': 'verified', 'message': 'Email address verified. You can now sign in.'} + + @router.post('/logout') def logout(payload: dict): token = payload.get('token') diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 187780f..908418a 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -17,6 +17,14 @@ class Settings: database_url: str = os.getenv('LINKLOG_DATABASE_URL', f'sqlite:///{DB_PATH}') secret_key: str = os.getenv('LINKLOG_SECRET_KEY', 'dev-secret-key-change-me') token_expiry_days: int = int(os.getenv('LINKLOG_TOKEN_EXPIRY_DAYS', '30')) + public_url: str = os.getenv('LINKLOG_PUBLIC_URL', 'http://localhost:8000').rstrip('/') + smtp_host: str = os.getenv('LINKLOG_SMTP_HOST', '') + smtp_port: int = int(os.getenv('LINKLOG_SMTP_PORT', '587')) + smtp_username: str = os.getenv('LINKLOG_SMTP_USERNAME', '') + smtp_password: str = os.getenv('LINKLOG_SMTP_PASSWORD', '') + smtp_from: str = os.getenv('LINKLOG_SMTP_FROM', 'LinkLog ') + smtp_use_tls: bool = os.getenv('LINKLOG_SMTP_USE_TLS', 'true').lower() in {'1', 'true', 'yes'} + email_verification_expiry_hours: int = int(os.getenv('LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS', '24')) tracking_params: list[str] = None def __post_init__(self): diff --git a/backend/app/database.py b/backend/app/database.py index ebfb4cb..a228285 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -101,6 +101,21 @@ UPDATE tags SET name = '#' || name WHERE name NOT LIKE '#%'; (4, ''' ALTER TABLE tags ADD COLUMN created_by TEXT REFERENCES users(id) ON DELETE SET NULL; CREATE INDEX IF NOT EXISTS idx_tags_created_by ON tags(created_by); +'''), + (5, ''' +ALTER TABLE users ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0; +UPDATE users SET email_verified = 1; + +CREATE TABLE IF NOT EXISTS email_verification_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user_id + ON email_verification_tokens(user_id); '''), ] diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..86ca9cc --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,33 @@ +## Copyright © 2026 Olaf Kolkman +## SPDX-License-Identifier: GPL-3.0-or-later + +from email.message import EmailMessage +from smtplib import SMTP + +from backend.app.core.config import settings + + +def smtp_configured() -> bool: + return bool(settings.smtp_host and settings.smtp_from) + + +def send_verification_email(email: str, username: str, verification_url: str) -> None: + if not smtp_configured(): + raise RuntimeError('SMTP is not configured; set LINKLOG_SMTP_HOST and LINKLOG_SMTP_FROM') + + message = EmailMessage() + message['Subject'] = 'Verify your LinkLog email address' + message['From'] = settings.smtp_from + message['To'] = email + message.set_content( + f'Hello {username},\n\n' + f'Verify your LinkLog email address by opening this link:\n{verification_url}\n\n' + f'This link expires in {settings.email_verification_expiry_hours} hours.\n' + ) + + with SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp: + if settings.smtp_use_tls: + smtp.starttls() + if settings.smtp_username: + smtp.login(settings.smtp_username, settings.smtp_password) + smtp.send_message(message) \ No newline at end of file diff --git a/backend/app/services/email_verification.py b/backend/app/services/email_verification.py new file mode 100644 index 0000000..291e557 --- /dev/null +++ b/backend/app/services/email_verification.py @@ -0,0 +1,44 @@ +## Copyright © 2026 Olaf Kolkman +## SPDX-License-Identifier: GPL-3.0-or-later + +from datetime import datetime, timedelta, timezone +from hashlib import sha256 +from secrets import token_urlsafe +from uuid import uuid4 + +from backend.app.core.config import settings +from backend.app.database import get_connection + + +def hash_verification_token(token: str) -> str: + return sha256(token.encode('utf-8')).hexdigest() + + +def create_verification_token(user_id: str) -> str: + token = token_urlsafe(32) + expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.email_verification_expiry_hours) + with get_connection() as conn: + conn.execute('DELETE FROM email_verification_tokens WHERE user_id = ?', (user_id,)) + conn.execute( + '''INSERT INTO email_verification_tokens + (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)''', + (str(uuid4()), user_id, hash_verification_token(token), expires_at.isoformat()), + ) + conn.commit() + return token + + +def verify_email(token: str) -> bool: + now = datetime.now(timezone.utc).isoformat() + with get_connection() as conn: + row = conn.execute( + '''SELECT user_id FROM email_verification_tokens + WHERE token_hash = ? AND expires_at > ?''', + (hash_verification_token(token), now), + ).fetchone() + if row is None: + return False + conn.execute('UPDATE users SET email_verified = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (row['user_id'],)) + conn.execute('DELETE FROM email_verification_tokens WHERE user_id = ?', (row['user_id'],)) + conn.commit() + return True \ No newline at end of file diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 2d9c4c9..66e67f4 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -9,6 +9,7 @@ from uuid import uuid4 from fastapi.testclient import TestClient from backend.app.main import app +from backend.app.services.token_service import issue_token client = TestClient(app) @@ -74,6 +75,41 @@ def test_admin_can_add_list_and_remove_users(): assert client.put('/api/admin/users/user-1', headers=headers, json={'is_admin': False}).status_code == 400 +def test_new_user_must_verify_email_before_login(): + headers = login_headers() + username = f'unverified-{uuid4().hex}' + created = client.post('/api/admin/users', headers=headers, json={ + 'username': username, + 'email': f'{username}@example.com', + 'password': 'secret123', + }) + assert created.status_code == 201 + assert created.json()['email_verified'] is False + + login = client.post('/api/auth/login', json={'username': username, 'password': 'secret123'}) + assert login.status_code == 403 + assert login.json()['detail'] == 'Email address is not verified' + + +def test_email_verification_link_enables_login(): + headers = login_headers() + username = f'verifiable-{uuid4().hex}' + created = client.post('/api/admin/users', headers=headers, json={ + 'username': username, + 'email': f'{username}@example.com', + 'password': 'secret123', + }) + assert created.status_code == 201 + user_id = created.json()['id'] + from backend.app.services.email_verification import create_verification_token + verification_token = create_verification_token(user_id) + + verified = client.get('/api/auth/verify-email', params={'token': verification_token}) + assert verified.status_code == 200 + assert client.post('/api/auth/login', json={'username': username, 'password': 'secret123'}).status_code == 200 + assert client.get('/api/auth/verify-email', params={'token': verification_token}).status_code == 400 + + def test_admin_can_remove_user_with_owned_data(): headers = login_headers() username = f'data-owner-{uuid4().hex}' @@ -84,7 +120,8 @@ def test_admin_can_remove_user_with_owned_data(): }) assert create_response.status_code == 201 user_id = create_response.json()['id'] - user_headers = login_headers(username) + user_token = issue_token(user_id, username)['access_token'] + user_headers = {'Authorization': f'Bearer {user_token}'} link_response = client.post('/api/links', headers=user_headers, json={ 'title': 'Owned link', diff --git a/backend/tests/test_database.py b/backend/tests/test_database.py index 4ca77fd..b451efa 100644 --- a/backend/tests/test_database.py +++ b/backend/tests/test_database.py @@ -10,7 +10,7 @@ def test_database_migrations_are_versioned_and_idempotent(): connection = sqlite3.connect(':memory:') apply_migrations(connection) - assert get_schema_version(connection) == 4 + assert get_schema_version(connection) == 5 tables = { row[0] for row in connection.execute( @@ -27,6 +27,6 @@ def test_database_migrations_are_versioned_and_idempotent(): assert set(DEFAULT_TAGS) <= seeded_tags apply_migrations(connection) - assert get_schema_version(connection) == 4 + assert get_schema_version(connection) == 5 connection.close() \ No newline at end of file diff --git a/backend/tests/test_email_service.py b/backend/tests/test_email_service.py new file mode 100644 index 0000000..5ec8aed --- /dev/null +++ b/backend/tests/test_email_service.py @@ -0,0 +1,28 @@ +## Copyright © 2026 Olaf Kolkman +## SPDX-License-Identifier: GPL-3.0-or-later + +from unittest.mock import patch + +from backend.app.services.email_service import send_verification_email + + +def test_send_verification_email_uses_smtp_settings(monkeypatch): + from backend.app.core.config import settings + + monkeypatch.setattr(settings, 'smtp_host', 'smtp.example.com') + monkeypatch.setattr(settings, 'smtp_port', 587) + monkeypatch.setattr(settings, 'smtp_from', 'LinkLog ') + monkeypatch.setattr(settings, 'smtp_username', 'mailer') + monkeypatch.setattr(settings, 'smtp_password', 'secret') + monkeypatch.setattr(settings, 'smtp_use_tls', True) + + with patch('backend.app.services.email_service.SMTP') as smtp_class: + smtp = smtp_class.return_value.__enter__.return_value + send_verification_email('user@example.com', 'user', 'https://linklog.example/verify') + + smtp_class.assert_called_once_with('smtp.example.com', 587, timeout=10) + smtp.starttls.assert_called_once_with() + smtp.login.assert_called_once_with('mailer', 'secret') + message = smtp.send_message.call_args.args[0] + assert message['To'] == 'user@example.com' + assert 'https://linklog.example/verify' in message.get_content() \ No newline at end of file diff --git a/docker-compose-example.yml b/docker-compose-example.yml index 9631187..f2e9299 100644 --- a/docker-compose-example.yml +++ b/docker-compose-example.yml @@ -14,6 +14,14 @@ services: LINKLOG_DATABASE_PATH: ${LINKLOG_DATABASE_PATH:-/app/backend/data/linklog.db} LINKLOG_SECRET_KEY: ${LINKLOG_SECRET_KEY:?Set LINKLOG_SECRET_KEY in .env} LINKLOG_TOKEN_EXPIRY_DAYS: ${LINKLOG_TOKEN_EXPIRY_DAYS:-30} + LINKLOG_PUBLIC_URL: ${LINKLOG_PUBLIC_URL:-https://linklog.example.com} + LINKLOG_SMTP_HOST: ${LINKLOG_SMTP_HOST:-smtp.example.com} + LINKLOG_SMTP_PORT: ${LINKLOG_SMTP_PORT:-587} + LINKLOG_SMTP_USERNAME: ${LINKLOG_SMTP_USERNAME:?Set LINKLOG_SMTP_USERNAME in .env} + LINKLOG_SMTP_PASSWORD: ${LINKLOG_SMTP_PASSWORD:?Set LINKLOG_SMTP_PASSWORD in .env} + LINKLOG_SMTP_FROM: ${LINKLOG_SMTP_FROM:-LinkLog } + LINKLOG_SMTP_USE_TLS: ${LINKLOG_SMTP_USE_TLS:-true} + LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS: ${LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS:-24} LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-} restart: ${APP_RESTART_POLICY:-unless-stopped} healthcheck: diff --git a/docker-compose.yml b/docker-compose.yml index ffcfbb0..1ea808c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,14 @@ services: LINKLOG_DATABASE_PATH: ${LINKLOG_DATABASE_PATH:-/app/backend/data/linklog.db} LINKLOG_SECRET_KEY: ${LINKLOG_SECRET_KEY:?Set LINKLOG_SECRET_KEY in .env} LINKLOG_TOKEN_EXPIRY_DAYS: ${LINKLOG_TOKEN_EXPIRY_DAYS:-30} + LINKLOG_PUBLIC_URL: ${LINKLOG_PUBLIC_URL:-http://localhost:8000} + LINKLOG_SMTP_HOST: ${LINKLOG_SMTP_HOST:-} + LINKLOG_SMTP_PORT: ${LINKLOG_SMTP_PORT:-587} + LINKLOG_SMTP_USERNAME: ${LINKLOG_SMTP_USERNAME:-} + LINKLOG_SMTP_PASSWORD: ${LINKLOG_SMTP_PASSWORD:-} + LINKLOG_SMTP_FROM: ${LINKLOG_SMTP_FROM:-LinkLog } + LINKLOG_SMTP_USE_TLS: ${LINKLOG_SMTP_USE_TLS:-true} + LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS: ${LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS:-24} LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-} restart: ${APP_RESTART_POLICY:-unless-stopped} healthcheck: