diff --git a/.github/scripts/bump_version.py b/.github/scripts/bump_version.py new file mode 100644 index 0000000..e86054a --- /dev/null +++ b/.github/scripts/bump_version.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python +"""Bump the patch component of the project version in pyproject.toml. + +Prints the new version to stdout. Used by the PyPI publish workflow to advance +the version after each release, so the next merge to main publishes a new one. +A minor/major release is still done by editing the version in pyproject.toml by +hand; this script only ever increments the patch number. +""" + +import pathlib +import re + +PYPROJECT = pathlib.Path(__file__).resolve().parents[2] / "pyproject.toml" +VERSION_RE = r'^(version\s*=\s*")(\d+)\.(\d+)\.(\d+)(")' + + +def main() -> str: + text = PYPROJECT.read_text() + match = re.search(VERSION_RE, text, flags=re.MULTILINE) + if not match: + raise SystemExit('Could not find a version = "X.Y.Z" line in pyproject.toml') + + major, minor, patch = (int(match.group(i)) for i in (2, 3, 4)) + new_version = f"{major}.{minor}.{patch + 1}" + + text = re.sub( + VERSION_RE, + rf"\g<1>{new_version}\g<5>", + text, + count=1, + flags=re.MULTILINE, + ) + PYPROJECT.write_text(text) + return new_version + + +if __name__ == "__main__": + print(main()) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 5e133a7..203f23c 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,30 +1,70 @@ +name: Publish to PyPI -name: Upload Python Package +# On every merge to main: run the tests, publish the version currently in +# pyproject.toml to PyPI, then auto-bump the patch version and commit it back so +# the next merge publishes a new version. The bump commit is tagged "[skip ci]" +# so it does not re-trigger this workflow. +# +# For a minor/major release, bump the version in pyproject.toml by hand in your +# PR; that version is published as-is and patch auto-bumping continues from there. on: push: - branches: - - main + branches: [main] + workflow_dispatch: -jobs: - deploy: +concurrency: + group: publish-pypi + cancel-in-progress: false + +permissions: + contents: write +jobs: + test: runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install Django pytest pytest-django + - name: Run tests + run: pytest + publish: + needs: test + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine - - name: Build and publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - python setup.py sdist bdist_wheel - twine upload --skip-existing dist/* + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Install build tooling + run: | + python -m pip install --upgrade pip + python -m pip install build twine + - name: Build distributions + run: python -m build + - name: Check distributions + run: twine check dist/* + - name: Publish to PyPI + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: twine upload --skip-existing dist/* + - name: Bump patch version for the next release + id: bump + run: echo "version=$(python .github/scripts/bump_version.py)" >> "$GITHUB_OUTPUT" + - name: Commit and push version bump + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add pyproject.toml + git commit -m "chore: bump version to ${{ steps.bump.outputs.version }} [skip ci]" + git push diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..f89423a --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,43 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + django-version: ["4.2", "5.0", "5.1", "5.2"] + exclude: + # Django 5.x requires Python >= 3.10 + - python-version: "3.8" + django-version: "5.0" + - python-version: "3.8" + django-version: "5.1" + - python-version: "3.8" + django-version: "5.2" + - python-version: "3.9" + django-version: "5.0" + - python-version: "3.9" + django-version: "5.1" + - python-version: "3.9" + django-version: "5.2" + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install "Django~=${{ matrix.django-version }}.0" pytest pytest-django + - name: Run tests + run: pytest diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..748b1ad --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,73 @@ +# Changelog + +All notable changes to this project are documented here. This project adheres to +[Semantic Versioning](https://semver.org/). + +## [3.1.0] + +This is a backwards-compatible maintenance release. Public imports, URL names, +template paths and settings names are unchanged. + +### Security +- **Account-enumeration fix.** The public "request a new verification email" + form now returns an identical response whether or not the submitted address + belongs to a real account, and regardless of whether that account is already + active. Previously it returned a `404 "User Not Found"`, an "Already Verified" + page, or a success page depending on the account's state, which let anyone + probe which emails were registered and their status. +- The verification view no longer surfaces internal error details unless the + project is running with `DEBUG = True`. +- **Already-active accounts are no longer re-activated.** The activation path now + explicitly refuses an already-active account (raising `UserAlreadyActive` and + showing an "already verified" page) instead of resetting `last_login`. The + token check already covered the normal case; this is defense-in-depth for + accounts activated out-of-band. +- Documented operational hardening in the README (bounding the resend window via + `PASSWORD_RESET_TIMEOUT`, and adding IP-based throttling) following a security + audit of the verification flow. + +### Fixed +- **Restored the documented public API.** `send_verification_email(request, form)` + — the entry point shown in the README/quick-start — no longer existed after the + 3.0 refactor; calling it raised `ImportError`. It is back as a thin wrapper over + `ActivationMailManager.send_verification_link` and is importable both from + `verify_email` and `verify_email.email_handler`. Covered by a regression test. +- The verification link is now built with `reverse('verify-email', ...)` instead + of a hard-coded `/verification/` path, so mounting `verify_email.urls` under any + prefix works correctly. +- `TokenManager.get_user_by_token` could raise `InvalidToken` after checking only + the first user when multiple accounts shared an email address. It now checks + every candidate before deciding the token is invalid. +- Removed unreachable/incorrect branches in `TokenManager._get_seconds` (a code + path returned a `WrongTimeInterval` instance instead of raising it). +- `GetFieldFromSettings.get` had a dead guard that meant `raise_exception` never + fired; it now correctly raises when a required setting resolves to `None`. + +### Changed +- **No more global user signal.** Previous versions registered a `post_save` + handler on the project's user model that created a `LinkCounter` row for + *every* user (and re-saved it on every user save). This polluted the database + and added a query to every user save in the host project. The signal has been + removed; the counter is now created lazily the first time a user requests a + resend. **No migration or data change is required** — existing `LinkCounter` + rows are reused untouched, and resend-limit behaviour is unchanged. +- **Namespaced the base template.** The shared base template moved from the + un-namespaced `email_index.html` to `verify_email/base.html`, so it can no + longer collide with a host project's own `email_index.html`. The old + `email_index.html` remains as a deprecated forwarding shim, so any custom + template that still `{% extends "email_index.html" %}` keeps working. +- `LinkCounter.requester` now references `settings.AUTH_USER_MODEL` directly + instead of a module-level `get_user_model()` call (migration-neutral). +- Added a common base exception, `verify_email.errors.VerifyEmailError`; every + package exception now inherits from it, so integrators can catch the whole + family with a single `except`. +- Declared `Django>=4.2` as an install dependency and verified compatibility with + Django 4.2, 5.0, 5.1 and 5.2 on Python 3.8–3.12. + +### Packaging / tooling +- Consolidated packaging metadata into `pyproject.toml` (PEP 621); removed + `setup.cfg`/`setup.py`. Fixed the author email and stale/incorrect classifiers. +- Added a test suite harness (`tests/settings.py`, `tests/urls.py`), a `tox` + matrix and a GitHub Actions test workflow across supported Python/Django + versions. +- Modernised the publish workflow to use `python -m build` + `twine check`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cee7264 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,59 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`Django-Verify-Email` is a reusable Django **app** (not a standalone project) published to PyPI as `Django-Verify-Email`. It handles two-step email verification for new signups: it sets a new user to `is_active=False`, emails them a signed verification link, and activates the account when the link is visited. Version lives in `setup.cfg` (`version = ...`); the current major is 3.x. + +The package is installed into a host project that supplies `settings.py`, the user model, and a mail backend. A minimal test harness lives under `tests/` (see [Tests](#tests)). + +## Public API + +The intended public entry point is re-exported from the package root (`verify_email/__init__.py` → `email_handler`): + +- `send_verification_email(request, form)` — the documented signup helper. It is a thin wrapper around `ActivationMailManager.send_verification_link(form=..., request=...)`. Saves the form's user as inactive and emails the link; returns the inactive user (with `.cleaned_data`). If sending fails, the user is **deleted** so signup can be retried. (This wrapper was missing in 3.0.x — the documented import raised `ImportError` — and was restored in 3.1.0; keep it and its regression test, `test_public_api_send_verification_email`.) + +Verification and "resend link" flows are fully handled by the app's own views/URLs — host projects only `include('verify_email.urls')`; they do not write verification views. + +## Architecture + +The flow is layered; each layer has a single responsibility: + +- **`email_handler.py` — `ActivationMailManager`** (frozen dataclass): orchestration layer. `send_verification_link` (inactivate + save user, build URL, render template, send) and `resend_verification_link` (decode prior link or look up by email, re-issue). Composes a `TokenManager` and `GetFieldFromSettings`. +- **`token_manager.py`** — all crypto/signing logic. `TokenManager` subclasses `django.core.signing.TimestampSigner` **and** `GeneralConfig` (multiple inheritance; `__post_init__` must call `GeneralConfig.__post_init__` then `TimestampSigner.__init__`). Key pieces: + - `SafeURL` — urlsafe base64 encode/decode of email and token for the URL. + - `ActivationLinkManager` — builds the link via `reverse('verify-email', ...)` (so the mount prefix is not hard-coded), and enforces resend limits via `can_request_new_link` / sent-count. + - Token = Django's `default_token_generator` token, optionally wrapped by the timestamp signer **only when `EXPIRE_AFTER` (`max_age`) is set**. This is the central branch: with no `max_age` the link "expires after one use"; with `max_age` set it expires by time and `SignatureExpired` triggers the resend path. `decrypt_token_and_get_user` is the verification workhorse and distinguishes `SignatureExpired` (offer new link) from `BadSignature` (tampered — refuse). +- **`confirm.py` — `UserActivationProcess.activate_user`** — verifies token via `TokenManager`, sets `is_active=True` and `last_login=now()`. +- **`views.py`** — `verify_and_activate_user` (GET only) and `request_new_link`. These map the many domain exceptions (`InvalidToken`, `MaxRetriesExceeded`, `UserAlreadyActive`, `UserNotFound`, `SignatureExpired`, `BadSignature`) to specific templates/HTTP statuses. This exception-to-template mapping is the bulk of view logic — preserve it when editing. +- **`app_configurations.py` — `GetFieldFromSettings`** — the single source of truth for every setting the app reads. All settings have defaults and are accessed through `.get("")`. **Add any new configurable setting here**, not via direct `settings.` access elsewhere. Special case: `VERIFICATION_SUCCESS_TEMPLATE = None` skips the success page and redirects to `LOGIN_URL`. +- **`models.py` — `LinkCounter`** — one-to-one with the user (`settings.AUTH_USER_MODEL`), tracks `sent_count` for resend-limit enforcement (`MAX_RETRIES`). The counter is created **lazily** by `ActivationLinkManager._get_or_create_counter` on the first resend (`sent_count` starts at 1). There is no `signals.py` and no global `post_save` handler — earlier versions created a counter for every user in the project; that was removed. Existing rows are reused untouched, so no migration is needed. +- **`errors.py`** — domain exceptions raised by lower layers and caught in `views.py`. +- **`custom_types.py`** — `User` type alias. + +URL structure (`urls.py`): verification link is `user/verify-email///`; resend has both a from-link form and a from-email form variant. + +## Settings consumed + +Read `GetFieldFromSettings.defaults_configs` for the authoritative list. Key ones: `EXPIRE_AFTER` (link lifetime; int = seconds, or suffix `s`/`m`/`h`/`d` — note `m` is minutes), `MAX_RETRIES`, `EMAIL_FIELD_NAME`, `HTML_MESSAGE_TEMPLATE`, `HASHING_KEY`/`HASH_SALT`/`SEPARATOR` (signer config), `SUBJECT`, and the `*_TEMPLATE` / `*_MSG` overrides. Host project must also configure a Django mail backend and `DEFAULT_FROM_EMAIL`. + +## Tests + +Tests live **outside** the shipped package, in the root `tests/` package (`tests/test_verify_email.py` plus the `tests/settings.py` / `tests/urls.py` harness), so nothing test-related is published to PyPI. They use `django.test.TestCase` and exercise the real send/verify flow against the configured user model and mail outbox. Run them with: + +``` +pytest # config (settings module, pythonpath, testpaths) comes from pyproject.toml +tox # full Python x Django matrix +``` + +`pyproject.toml` sets `pythonpath = ["."]` so the `tests` package resolves under the `pytest` console script (pytest-django reads `DJANGO_SETTINGS_MODULE = tests.settings` early, before pytest's own path insertion — without `pythonpath` it fails in CI with `No module named 'tests'`). `tests/urls.py` mounts `verify_email.urls` at `verification/` and defines a `login` URL (the success view reverses `LOGIN_URL`). Several tests use `time.sleep` to assert expiry behaviour, so the suite is intentionally slow. + +## Build & release + +Packaging metadata lives entirely in `pyproject.toml` (PEP 621; there is no longer a `setup.py`/`setup.cfg`). `Django>=4.2` is a declared dependency. Build with `python -m build`; validate with `twine check dist/*`. Releases publish to PyPI via `.github/workflows/python-publish.yml` (on push to `main`, on a published GitHub release, or manual dispatch). `.github/workflows/tests.yml` runs the test matrix on push/PR. Bump `version` in `pyproject.toml` for a release and add a `CHANGELOG.md` entry. + +## Conventions + +- Most core classes are `@dataclass`es that compose their collaborators via `field(default_factory=...)`. Follow this pattern rather than instantiating dependencies inline. +- All settings access goes through `GetFieldFromSettings`; lower layers raise typed exceptions from `errors.py` and let `views.py` decide the user-facing response. diff --git a/MANIFEST.in b/MANIFEST.in index edac6cf..38e203f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,5 @@ include README.md include LICENSE -recursive-include verify_email/templates * \ No newline at end of file +include CHANGELOG.md +recursive-include verify_email/templates * +recursive-exclude tests * diff --git a/README.md b/README.md index 00ee391..7c74c92 100644 --- a/README.md +++ b/README.md @@ -1,394 +1,287 @@ +# Django-Verify-Email +[![PyPI version](https://img.shields.io/pypi/v/Django-Verify-Email.svg)](https://pypi.org/project/Django-Verify-Email/) +[![Python versions](https://img.shields.io/pypi/pyversions/Django-Verify-Email.svg)](https://pypi.org/project/Django-Verify-Email/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -

Email-Verification for Django

+Drop-in **two-step email verification** for Django sign-ups. On registration the +app deactivates the new account, emails the user a signed, single-use +verification link, and re-activates the account when that link is opened — all +without you writing any verification views. -Email verification for new signups or new users is a two-step verification process and adds a layer for security for valid users. +- **Compatible with** Django 4.2, 5.0, 5.1, 5.2 on Python 3.8–3.12. +- Works with any `AUTH_USER_MODEL` (uses `get_user_model()`). +- Signed, expiring links built on Django's own token machinery. +- Built-in "resend verification email" flow (by form or from an expired link). +- Every page and email is template-overridable. - verify_email is a django app that provides this functionality right of the bat without any complex implementation. +> **Upgrading?** See [`CHANGELOG.md`](CHANGELOG.md). `3.1.0` is a +> backwards-compatible release; public imports, URL names, template paths and +> settings are unchanged. -
+--- -## Version Update (2.0.0): +## How it works -
+1. You call `send_verification_email(request, form)` from your signup view. +2. The app saves the user with `is_active = False` and emails a verification link. +3. The user clicks the link; the app validates the signed token, sets + `is_active = True` and `last_login = now()`, and shows a success page (or + redirects straight to login). +4. Used, tampered, or expired links are rejected and routed to the appropriate + page; expired links can offer the user a new one. -> This version contains breaking changes and is not compatible with the previous version 1.0.9 - -### What's in this update -**Features:** -* Added feature for **re-requesting email** in case the previous email was lost or deleted by mistake -* Added a variable `REQUEST_NEW_EMAIL_TEMPLATE` where user can specify his custom template for requesting email again. More on this here. -* Added a Django form for requesting email with a field `email`. - -Read about this feature here - -**Bug Fixes:** -* Fixed a bug where the user was not able to request a new email using the previous link in case if the link expires. - - **Others** - * Using exceptions instead of normal string errors - * code cleanup - -

- -## The app takes care of : -* Settings user's is_active status to False. -* Generate hashed token for each user. -* Generate a verification link and send it to the user's email. -* Recieve a request from the verification link and verify for its validity. -* Activating the user's account. - -## What you have to implement is : -* Three steps in Quick start below... - -Note : The app is designed to be used right of the bat, however, further customizations options are also provided in Advance section below. +You do **not** write any verification view — the app ships its own URLs and views. +--- ## Installation -NOTE: Don't forget to activate the virtual environment if you have one. - -``` +```bash pip install Django-Verify-Email ``` -

-

Quick start


-

- -The steps to getting started are very simple. Like any other app, this can be installed easily by adding "verify_email" in your installed apps like: - -Note: This documentation assumes that you already have a mail server configured for your project to send mails. - -if not, then your first step should be Step 0: - -### Step 0 :- +### 1. Configure email (skip if your project already sends mail) ---- Bypass this step if you already have these things set up for your project. --- - -In your settings.py : -``` -EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -EMAIL_HOST = 'smtp.gmail.com' +```python +# settings.py +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +EMAIL_HOST = "smtp.gmail.com" EMAIL_PORT = 587 EMAIL_USE_TLS = True -EMAIL_HOST_USER = os.environ.get('EMAIL_ID') -EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_PW') +EMAIL_HOST_USER = os.environ["EMAIL_ID"] +EMAIL_HOST_PASSWORD = os.environ["EMAIL_PW"] -DEFAULT_FROM_EMAIL = 'noreply' +DEFAULT_FROM_EMAIL = "noreply " ``` -## Main steps...
+### 2. Add the app -### Step 1 :- -Add "verify_email" to your INSTALLED_APPS setting like this: -``` - INSTALLED_APPS = [ - ... - "verify_email.apps.VerifyEmailConfig", - ] +```python +INSTALLED_APPS = [ + # ... + "verify_email.apps.VerifyEmailConfig", +] ``` -

-

Step 2 :-

- -Include the "verify_email" URLconf in your project urls.py like this: +### 3. Include the URLs -``` - +```python +# project/urls.py urlpatterns = [ - ... - path('verification/', include('verify_email.urls')), - + # ... + path("verification/", include("verify_email.urls")), ] ``` -

-

-

Step 3 :-

+### 4. Run migrations -Apply migrations... - - -``` +```bash python manage.py migrate ``` -

+### 5. Send the verification email from your signup view - -### Step 4 :- -For sending email from a signup form, in your views.py import: - -``` -... +```python from verify_email.email_handler import send_verification_email -``` -Now in the function where you are validating the form: - -``` -... def register_user(request): - ... - + form = MySignupForm(request.POST) if form.is_valid(): - inactive_user = send_verification_email(request, form) + # `inactive_user` is the saved user (is_active=False). + # Access submitted data via inactive_user.cleaned_data['email'], etc. + ... ``` -Attention : "send_verification_email()" takes two arguments, requests and form in order to set user's active status. - -The "inactive_user" that is returned by "send_verification_email()" contains a saved user object just like form.save() would do(with is_active status set as False), which you can further use to extract user information from cleaned_data dictionary, as shown below : - -``` -inactive_user.cleaned_data['email'] - -# Output: test-user123@gmail.com +`send_verification_email(request, form)` saves the user as inactive and sends the +link — you don't call `form.save()` yourself. **If sending the email fails, the +user is rolled back (deleted)** so the visitor can retry. + +> Your form must have an `email` field. If it's named differently, set +> [`EMAIL_FIELD_NAME`](#configuration). + +That's it — verification is fully handled by the app from here. + +--- + +## Configuration + +All settings are optional and read from your project's `settings.py`. + +| Setting | Default | Purpose | +| --- | --- | --- | +| `SUBJECT` | `"Email Verification Mail"` | Subject line of the verification email. | +| `EMAIL_FIELD_NAME` | `"email"` | Name of the email field on your signup form. | +| `HTML_MESSAGE_TEMPLATE` | `verify_email/email_verification_msg.html` | Template for the email body. Must render `{{ link }}`. | +| `DEFAULT_FROM_EMAIL` | Django's default | From address for the email. | +| `LOGIN_URL` | `"accounts_login"` | URL name the success page links to. Also used by Django. | +| `VERIFICATION_SUCCESS_TEMPLATE` | `verify_email/email_verification_successful.html` | Success page. Set to `None` to skip it and redirect straight to `LOGIN_URL`. | +| `VERIFICATION_SUCCESS_MSG` | *(sensible default)* | Message shown on success. | +| `VERIFICATION_FAILED_TEMPLATE` | `verify_email/email_verification_failed.html` | Page shown for invalid/failed links. | +| `LINK_EXPIRED_TEMPLATE` | `verify_email/link_expired.html` | Page shown when a link has expired. | +| `VERIFICATION_FAILED_MSG` | *(sensible default)* | Message shown on failure. | +| `REQUEST_NEW_EMAIL_TEMPLATE` | `verify_email/request_new_email.html` | Page hosting the "request a new link" form. | +| `NEW_EMAIL_SENT_TEMPLATE` | `verify_email/new_email_sent.html` | Confirmation page after a new link is requested. | +| `EXPIRE_AFTER` | `None` | Link lifetime. See [Link expiry](#link-expiry). | +| `MAX_RETRIES` | `2` | How many times a user may request a new link. | +| `HASHING_KEY` | project `SECRET_KEY` | Key used to sign links. | +| `HASH_SALT` | `None` | Optional salt for the signer. | +| `SEPARATOR` | `":"` | Separator used by the signer. Must not be in the URL-safe base64 alphabet. | + +--- + +## Link expiry + +By default a link stays valid until it is used (subject to Django's +`PASSWORD_RESET_TIMEOUT`, which the underlying token honours — 3 days by +default). To set an explicit expiry, define `EXPIRE_AFTER`: + +```python +EXPIRE_AFTER = "1d" # 1 day +EXPIRE_AFTER = "2h" # 2 hours +EXPIRE_AFTER = "30m" # 30 minutes (m = minutes, not months) +EXPIRE_AFTER = 90 # bare integer = seconds ``` -The user is already being saved as inactive and you don't have to .save() it explicitly. - -If anything goes wrong in sending the verification link email, the user will not be saved, so that the user can try again. - - - -### At this point, you are good to go... - Start the development server and signup with an email and you should be getting an email on the entered email with the default template for account activation. (You can provide your own HTML template. see Advance Section) - - Note : The app comes with default email templates which can be overriden. See Custom Email Templates - -# Verifying User's email : - -

Nothing...


- -That's right! , you don't have to implement any other code for validating users with their respective unique tokens and emails. - -The app takes care of everything in the background. - -* When the user clicks on the verification link, it comes to : - ``` - path('verification/', include('verify_email')), - ``` - which you defined in your project's urls.py in step 2 above. -* This pattern is further extended in this app's urls.py where it accepts encoded email and encoded hashed tokens from the verification link. -* It then checks for users by that email. -* If the user exists, it then checks for a token if it is valid for that user or not. -* If the token is valid, it activates the user's account by setting is_active attribute to True and last_login to timezone.now(). -* If the token is already been redeemed or modified, you'll be redirected to a "verification failed" page. - -#### This whole process from generating HMAC hashed token for each user to verify it for a unique user, is abstracted within the app 😃. - - -

+Supported suffixes: `s` (seconds), `m` (minutes), `h` (hours), `d` (days). A +bare integer is treated as seconds. -

Advance

+--- -

Expiration of link and Resending emails :

-If you want your link to expire after a certain amount of time, you can use signed links, All you have to do is just set a variable in the settings.py file and BAMM! you got yourself a link that will expire after the specified time.
-It's that simple, just setting a variable.

-If you don't set this variable, the link will expire after being used at least once. -
+## Resending verification emails -The link, by default, does not expire until it has been used at least once, however, you can -**change** this behavior by specifying the time as -"EXPIRE_AFTER" in settings.py. The variable can be set as : -* By default the time is considered in seconds, so if you set "EXPIRE_AFTER" as an integer, that will be considered in seconds. -* You can specify time unit for large times, max unit is days. -* **Its very simple** just suffix the "EXPIRE_AFTER" variable's value with a time unit from ["s", "m", "h", "d"]. (Keep in mind, the "m" here is minutes, not month) +A user may request a new link up to `MAX_RETRIES` times (default `2`). After +that they are shown a "maxed out" page. -**Example** +**From an expired link.** The expired-link page includes a button to request a +new email — no extra setup needed. -* If I have to make a link expire after **one-day**, then I'd do: - * EXPIRE_AFTER = "1d" # Will expire after one day from link generation - -* If I have to make a link expire after **one-hour**, then I'd do: - * EXPIRE_AFTER = "1h" # Will expire after one hour from link generation - -* If I have to make a link expire after **one-minute**, then I'd do: - * EXPIRE_AFTER = "1m" # Will expire after 1 minute from link generation - -**Note:** By default, if you do not specify a unit, it'll be considered in seconds. -

- -

-

Re-Sending Email


-

- -A user can request a new verification link **For a specific no. of times** in case the previous one has expired. By default, a user can request -new link **two times** which, obviously can be modified by you. - -Set a "MAX_RETRIES" variable in settings.py specifying the no. of times a user is allowed to request a new link. - -After that no. is exceeded, the user will be automatically redirected to an error page showing that you have maxed out. - -

Re-Sending Email using previous link

-

-When the link expires, the user will be redirected to a page displaying that the link is expired and has a button to request a new email, now as long as the user hasn't exceeded max retries, the user can request a new email simply by clicking on that button. - -

-

Resend Email using Email Form

-

- -In case when previous email/link is lost or deleted by the client, they can request a new email by specifying their email. - -The path for that is `https://yourdomain/verification/user/verify-email/request-new-link/`, at this path, there will be a form that will ask for the email of the registered user. - -The pathname is `request-new-link-from-email` which you can use to create a button on your front end and redirect traffic to the request email page. -Something like: +**From a form.** Link users to the request form via its URL name: ```html - +Resend verification email ``` -This will redirect you to full path `/verification/user/verify-email/request-new-link/` - -There are several checks done before sending an email again: -* if the email is registered and the user's account is not been activated -* the user hasn't exceeded max retry limit(set by you), - -Then a new email will be sent to the given email. - -The form template is supposed to be changed unless you are okay with the default template provided with the package. - -To set your own custom template for form, set a variable name `REQUEST_NEW_EMAIL_TEMPLATE` in settings.py with the path of template you want to use. Example: -```py -REQUEST_NEW_EMAIL_TEMPLATE = 'mytemplates/mycustomtemplate.html' -``` -and then your template will be displayed at the path. - -**Making Form:** while making your custom template, keep in mind that the view will pass a variable named `form` to the provided template, this form will contain only 1 field `email`. Sample code that you can use while making your template is here: +This serves a form with a single `email` field. To customise it, point +`REQUEST_NEW_EMAIL_TEMPLATE` at your own template (the view passes a `form` in the +context): ```html -
- {% csrf_token %} - -
- {{form}} -
- -
- -
+ + {% csrf_token %} +
{{ form }}
+
``` -You can apply your styles or whatever you want. (this code is used in the default template) - - -**NOTE:** This info is stored in the database so you have to apply migrations (step 3) to use this feature. -

-

+> The resend form is **enumeration-safe**: it returns the same response whether +> or not the address is registered, and whether or not the account is already +> active. It will not reveal which emails exist. (See +> [Security](#security-notes).) -

Custom Email Templates :

+> This feature stores per-user counters in the database, so make sure you've run +> migrations (step 4). -The app is packed with default HTML templates to handle the web pages but if you want to provide your own template you can do it by setting an attribute in settings.py : +--- -``` -HTML_MESSAGE_TEMPLATE = "path/to/html_template.html" - -VERIFICATION_SUCCESS_TEMPLATE = "path/to/success.html" - -VERIFICATION_FAILED_TEMPLATE = "path/to/failed.html" +## Customising templates -REQUEST_NEW_EMAIL_TEMPLATE = "path/to/email.html" +Override any page or the email body by pointing the relevant setting at your own +template (see the [Configuration](#configuration) table). -LINK_EXPIRED_TEMPLATE = 'path/to/expired.html' - -NEW_EMAIL_SENT_TEMPLATE = 'path/to/new_email_sent.html' -``` -``` -SUBJECT = 'subject of email' +**Email body** (`HTML_MESSAGE_TEMPLATE`) — context: `{{ request }}`, `{{ link }}`. +You **must** include `{{ link }}` or the email won't contain a working link: -# default subject is: Email Verification Mail +```html +Verify your email ``` -

- -## Inside Templates :
- -### Custom HTML Message Template : - -Two variables are passed in context dict of "HTML_MESSAGE_TEMPLATE" : - -* ```{{request}}``` : Which is the same request passed in to send_verification_email. -* ```{{link}}``` : Which contains verification link - -IMPORTANT : if you are using custom html message template for email that has to be sent to user, provide a {{link}} as a template tag to contain verification link. - -You Must Pass This In Your Template. Otherwise, the sent mail will not contain the verification link. +**Success page** (`VERIFICATION_SUCCESS_TEMPLATE`) — context: `{{ msg }}`, +`{{ link }}` (login URL), `{{ status }}`: -For Ex : - -```my_custom_email_message.html : ``` - -``` -
- Verify # ----> The "link" variable is passed by the app's backend containing verification link. -
+```html +

{{ msg }}

+Login ``` -----> "link" is a variable, that contains a verification link, and is passed in an HTML message template during sending the email to the user. - - -### Custom HTML Verification Success and Failed pages : -
- -Success : - -Two variables are passed in the context dictionary of "VERIFICATION_SUCCESS_TEMPLATE" : - -* ```{{mgs}}```: Which contains the message to be displayed on successful verification. -* ```{{link}}```: Which contains a redirect link to the login page. - -In template : - +**Failed / expired pages** — context includes `{{ msg }}` and `{{ status }}`. + +### Redirecting to login after success + +- **Show a success page** that links to login: set `LOGIN_URL` to your login URL + name. +- **Skip the success page** and go straight to login: set + `VERIFICATION_SUCCESS_TEMPLATE = None`. + +--- + +## Security notes + +- Links are signed with Django's `TimestampSigner` using your `SECRET_KEY` (or a + custom `HASHING_KEY`). Tampered links produce a `BadSignature` and are rejected + outright — a modified link cannot be used to request a fresh one. +- The verification token is Django's `default_token_generator` token, bound to + the user's password hash and `last_login`. Activating the account updates + `last_login`, which **invalidates the link**, making links effectively + single-use even without `EXPIRE_AFTER`. +- The "request a new email" form is enumeration-safe (identical response for + unknown / pending / already-active accounts). +- Already-active accounts are never re-activated, even if a still-valid token is + presented. +- Internal error details are only shown to end users when the project runs with + `DEBUG = True`. + +### Operational hardening (recommended) + +- **Bound the resend window with `PASSWORD_RESET_TIMEOUT`.** By design, the + "request a new link" button on an *expired*-link page accepts the expired + token to mint a fresh link (that's the feature). The window in which an old + link can still be exchanged for a new one is therefore governed by Django's + `PASSWORD_RESET_TIMEOUT` (default **3 days**), independently of `EXPIRE_AFTER`, + and is capped per user by `MAX_RETRIES`. If you set a short `EXPIRE_AFTER`, + also lower `PASSWORD_RESET_TIMEOUT` to match your intended link lifetime. +- **Add request throttling.** The package caps resends per user (`MAX_RETRIES`) + but does not rate-limit by IP, and the resend form is not constant-time (it + sends an email only for valid pending accounts, a subtle timing signal). For + internet-facing signups, put a throttle (e.g. `django-ratelimit`) in front of + the resend endpoints and consider sending email asynchronously. + +--- + +## Public API + +```python +from verify_email.email_handler import send_verification_email # primary entry point +from verify_email.errors import VerifyEmailError # base of all package exceptions ``` -

- {{msg}} # __--> message variable -

- - # __--> Link of login page - Login - -``` +URL names you can `reverse`/link to: `verify-email`, +`request-new-link-from-token`, `request-new-link-from-email`. -Failed : +--- -Only "{{msg}}" is passed for failed msg in the template. +## Development +```bash +# install dev dependencies +pip install -e ".[dev]" -In template : +# run the test suite (config + settings come from pyproject.toml / tests/) +pytest +# or across all supported Python/Django versions +tox ``` -

- {{msg}} -

-``` - - - -## Successful Verification : -After verification is successful, you might want to redirect the user to the login page. You can do this in two ways : - -* 1 Redirect from success webpage. - The user will be prompted to show a success page with a button on it to navigate to the Login page. - ``` - LOGIN_URL = 'name of your login pattern' - Note: This variable is also used by Django. - ``` -* 2 Redirect directly to the login page without stopping at the success message page. - The user will be directly sent to the login page, bypassing the success page. - ``` - VERIFICATION_SUCCESS_TEMPLATE = None - ``` -

+--- +## Contributing -> There is always room for improvements and new ideas, feel free to raise PR or Issues +Issues and pull requests are welcome at +. There is always room for +improvement — feel free to open an issue or PR. +## License +Released under the [MIT License](LICENSE). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9085b7d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,72 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "Django-Verify-Email" +version = "3.1.0" +description = "A Django app for two-step email verification of new sign-ups." +readme = "README.md" +requires-python = ">=3.8" +license = { text = "MIT" } +authors = [{ name = "Nitin Sharma", email = "ns290670@gmail.com" }] +keywords = ["django", "email", "verification", "signup", "authentication"] +dependencies = ["Django>=4.2"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: Django", + "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", + "Framework :: Django :: 5.1", + "Framework :: Django :: 5.2", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Software Development :: Libraries :: Python Modules", +] + +[project.urls] +Homepage = "https://github.com/foo290/Django-Verify-Email/" +Source = "https://github.com/foo290/Django-Verify-Email/" +Issues = "https://github.com/foo290/Django-Verify-Email/issues" + +[project.optional-dependencies] +dev = ["pytest", "pytest-django", "tox", "build", "twine"] + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +include = ["verify_email*"] +exclude = ["tests*"] + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "tests.settings" +# Put the repo root on sys.path early (before pytest-django reads the settings +# module) so `tests.settings` resolves under the `pytest` console script, not +# just `python -m pytest`. +pythonpath = ["."] +testpaths = ["tests"] +python_files = ["test_*.py"] + +[tool.black] +line-length = 88 +target-version = ["py38"] + +[tool.ruff] +line-length = 88 +target-version = "py38" + +[tool.ruff.lint] +select = ["E", "F", "W", "I"] +# Line length is handled by the formatter (black); long string literals and +# docstrings are allowed to exceed it. +ignore = ["E501"] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 10aec78..0000000 --- a/setup.cfg +++ /dev/null @@ -1,20 +0,0 @@ -[metadata] -name = Django-Verify-Email -version = 3.0.3 -author = Nitin -author_email = ns290670@gamil.com -description = A Django app for email verification. -url = https://github.com/foo290/Django-Verify-Email/ -packages =setuptools.find_packages() -classifiers = - Environment :: Web Environment - Framework :: Django CMS :: 3.8 - Intended Audience :: Developers - Programming Language :: Python :: 3.6 - License :: OSI Approved :: MIT License - Operating System :: OS Independent -python_requires = >=3.6 - -[options] -include_package_data = true -packages = find: diff --git a/setup.py b/setup.py deleted file mode 100644 index 8ecafc6..0000000 --- a/setup.py +++ /dev/null @@ -1,9 +0,0 @@ -import setuptools - -with open("README.md", "r") as fh: - long_description = fh.read() - -setuptools.setup( - long_description=long_description, - long_description_content_type="text/markdown", -) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/settings.py b/tests/settings.py new file mode 100644 index 0000000..0fb41e5 --- /dev/null +++ b/tests/settings.py @@ -0,0 +1,49 @@ +"""Minimal Django settings used to run the verify_email test suite.""" + +SECRET_KEY = "test-secret-key-not-for-production" + +DEBUG = False + +INSTALLED_APPS = [ + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.messages", + "verify_email.apps.VerifyEmailConfig", +] + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } +} + +ROOT_URLCONF = "tests.urls" + +MIDDLEWARE = [ + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", +] + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": True, + "DIRS": [], + "OPTIONS": { + "context_processors": [ + "django.contrib.messages.context_processors.messages", + ], + }, + } +] + +EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" +DEFAULT_FROM_EMAIL = "noreply@example.com" + +LOGIN_URL = "login" + +USE_TZ = True + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/tests/test_verify_email.py b/tests/test_verify_email.py new file mode 100644 index 0000000..8922c3c --- /dev/null +++ b/tests/test_verify_email.py @@ -0,0 +1,267 @@ +import time + +from django import forms +from django.contrib.auth import get_user_model +from django.core import mail +from django.test import RequestFactory, TestCase, override_settings +from django.urls import reverse + +import verify_email +from verify_email.confirm import UserActivationProcess +from verify_email.email_handler import ActivationMailManager, send_verification_email +from verify_email.errors import InvalidToken, UserAlreadyActive, VerifyEmailError +from verify_email.token_manager import ActivationLinkManager, SafeURL, TokenManager + +User = get_user_model() + + +class SignupForm(forms.ModelForm): + class Meta: + model = User + fields = ["username", "email"] + + +class VerifyEmailTests(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username="testuser", + email="testuser@example.com", + password="testpass", + ) + self.user.is_active = False + self.user.save() + + # --- Sending / verifying happy paths ------------------------------------- + + def test_send_verification_email(self): + """A verification email is sent for an inactive user.""" + response = ActivationMailManager.send_verification_link(self.user) + self.assertIsNotNone(response) + self.assertEqual(len(mail.outbox), 1) + + def test_public_api_send_verification_email(self): + """ + The documented public entry point ``send_verification_email(request, form)`` + must exist, be importable from the package root, save the user as + inactive, and send exactly one email. + """ + self.assertIs(verify_email.send_verification_email, send_verification_email) + + request = RequestFactory().post("/signup/") + form = SignupForm(data={"username": "fresh", "email": "fresh@example.com"}) + self.assertTrue(form.is_valid()) + + inactive_user = send_verification_email(request, form) + + self.assertFalse(inactive_user.is_active) + self.assertEqual(inactive_user.email, "fresh@example.com") + self.assertEqual(len(mail.outbox), 1) + + def test_verification_view_by_token_and_email(self): + user_token = TokenManager().generate_token_for_user(self.user) + user_email = SafeURL.perform_encoding(self.user.email) + response = self.client.get( + reverse("verify-email", args=[user_email, user_token]) + ) + self.assertEqual(response.status_code, 200) + + def test_verification_link(self): + user_token = TokenManager().generate_token_for_user(self.user) + link = ActivationLinkManager.generate_link(user_token, self.user.email) + resp = self.client.get(f"http://testserver{link}") + self.assertEqual(resp.status_code, 200) + + def test_activation_activates_user_and_sets_last_login(self): + user_token = TokenManager().generate_token_for_user(self.user) + link = ActivationLinkManager.generate_link(user_token, self.user.email) + self.client.get(f"http://testserver{link}") + + self.user.refresh_from_db() + self.assertTrue(self.user.is_active) + self.assertIsNotNone(self.user.last_login) + + def test_used_link_cannot_be_replayed(self): + """Once a link activates the account, the same link no longer works.""" + user_token = TokenManager().generate_token_for_user(self.user) + link = ActivationLinkManager.generate_link(user_token, self.user.email) + + first = self.client.get(f"http://testserver{link}") + self.assertEqual(first.status_code, 200) + + second = self.client.get(f"http://testserver{link}") + self.assertEqual(second.status_code, 401) + + # --- Token expiry -------------------------------------------------------- + + def test_process(self): + user_token = TokenManager().generate_token_for_user(self.user) + time.sleep(1) + link = ActivationLinkManager.generate_link(user_token, self.user.email) + resp = self.client.get(f"http://testserver{link}") + self.assertEqual(resp.status_code, 200) + + @override_settings(EXPIRE_AFTER="1s") + def test_timestamp_invalid_link(self): + """With EXPIRE_AFTER set, a stale link is rejected.""" + user_token = TokenManager().generate_token_for_user(self.user) + time.sleep(3) + link = ActivationLinkManager.generate_link(user_token, self.user.email) + resp = self.client.get(f"http://testserver{link}") + self.assertEqual(resp.status_code, 401) + + def test_timestamp_valid_link(self): + """Without EXPIRE_AFTER, the link stays valid (until used).""" + user_token = TokenManager().generate_token_for_user(self.user) + time.sleep(3) + link = ActivationLinkManager.generate_link(user_token, self.user.email) + resp = self.client.get(f"http://testserver{link}") + self.assertEqual(resp.status_code, 200) + + # --- Token lookup with duplicate emails ---------------------------------- + + def test_get_user_by_token_with_duplicate_emails(self): + """ + Django's default user model does not enforce unique emails. The token + must still resolve to the correct user even when another user shares the + same address (regression test for the early-return loop bug). + """ + other = User.objects.create_user( + username="other", email=self.user.email, password="x" + ) + other.is_active = False + other.save() + + raw_token = TokenManager().generate_token_for_user(other, get_url_encoded=False) + resolved = TokenManager.get_user_by_token(self.user.email, raw_token) + self.assertEqual(resolved.pk, other.pk) + + def test_get_user_by_token_invalid(self): + with self.assertRaises(InvalidToken): + TokenManager.get_user_by_token(self.user.email, "not-a-real-token") + # InvalidToken is part of the public error hierarchy. + self.assertTrue(issubclass(InvalidToken, VerifyEmailError)) + + def test_already_active_account_is_not_reactivated(self): + """ + An already-active account presenting a still-valid token must be refused + (defense-in-depth), not silently re-activated. + """ + # Active user whose token still validates because last_login is None. + active = User.objects.create_user( + username="already", email="already@example.com", password="x" + ) + active.is_active = True + active.last_login = None + active.save() + + token = TokenManager().generate_token_for_user(active) + email = SafeURL.perform_encoding(active.email) + + with self.assertRaises(UserAlreadyActive): + UserActivationProcess.activate_user(email, token) + + active.refresh_from_db() + self.assertIsNone(active.last_login) # untouched + + +class RequestNewLinkEnumerationTests(TestCase): + """The public resend form must not reveal whether an email is registered.""" + + def setUp(self): + self.url = reverse("request-new-link-from-email") + self.pending = User.objects.create_user( + username="pending", email="pending@example.com", password="x" + ) + self.pending.is_active = False + self.pending.save() + + self.active = User.objects.create_user( + username="active", email="active@example.com", password="x" + ) + + def _post(self, email): + return self.client.post(self.url, data={"email": email}) + + def test_pending_user_gets_email(self): + resp = self._post("pending@example.com") + self.assertEqual(resp.status_code, 200) + self.assertEqual(len(mail.outbox), 1) + + def test_unknown_email_same_response_no_mail(self): + resp = self._post("nobody@example.com") + self.assertEqual(resp.status_code, 200) + self.assertEqual(len(mail.outbox), 0) + + def test_active_user_same_response_no_mail(self): + resp = self._post("active@example.com") + self.assertEqual(resp.status_code, 200) + self.assertEqual(len(mail.outbox), 0) + + def test_responses_are_indistinguishable(self): + """Body for unknown vs. active vs. pending must be identical.""" + bodies = { + self._post(email).content + for email in ("pending@example.com", "active@example.com", "x@example.com") + } + self.assertEqual(len(bodies), 1) + + +class LinkCounterTests(TestCase): + """The LinkCounter is created lazily (no global signal) and caps resends.""" + + def setUp(self): + self.url = reverse("request-new-link-from-email") + self.user = User.objects.create_user( + username="pending", email="pending@example.com", password="x" + ) + self.user.is_active = False + self.user.save() + + def test_no_counter_created_at_signup(self): + """Creating a user must NOT create a LinkCounter (signal removed).""" + from verify_email.models import LinkCounter + + self.assertFalse(LinkCounter.objects.filter(requester=self.user).exists()) + + def test_counter_created_lazily_on_first_resend(self): + from verify_email.models import LinkCounter + + self._resend() + counter = LinkCounter.objects.get(requester=self.user) + # sent_count starts at 1 (initial email) and is incremented once. + self.assertEqual(counter.sent_count, 2) + + @override_settings(MAX_RETRIES=2) + def test_resend_limit_is_enforced(self): + # cap = MAX_RETRIES + 1 = 3; counter starts at 1 -> 2 resends allowed. + self.assertEqual(self._resend().status_code, 200) + self.assertEqual(self._resend().status_code, 200) + self.assertEqual(len(mail.outbox), 2) + + # Third resend is over the limit: still a generic 200, but no new email. + self.assertEqual(self._resend().status_code, 200) + self.assertEqual(len(mail.outbox), 2) + + def _resend(self): + return self.client.post(self.url, data={"email": self.user.email}) + + +class TemplateTests(TestCase): + """Templates use a namespaced base; the old name stays as a compat shim.""" + + def test_request_new_email_form_page_renders(self): + resp = self.client.get(reverse("request-new-link-from-email")) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, " verify_email/base.html. + """ + from django.template import engines + + template = engines["django"].from_string( + '{% extends "email_index.html" %}{% block content %}SHIM_OK{% endblock %}' + ) + self.assertIn("SHIM_OK", template.render({"status": "x"})) diff --git a/tests/urls.py b/tests/urls.py new file mode 100644 index 0000000..7c6075b --- /dev/null +++ b/tests/urls.py @@ -0,0 +1,12 @@ +from django.http import HttpResponse +from django.urls import include, path + + +def login_view(request): + return HttpResponse("login page") + + +urlpatterns = [ + path("accounts/login/", login_view, name="login"), + path("verification/", include("verify_email.urls")), +] diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..d4b6cb7 --- /dev/null +++ b/tox.ini @@ -0,0 +1,27 @@ +[tox] +envlist = + py38-django42 + py39-django42 + py310-django{42,50,51,52} + py311-django{42,50,51,52} + py312-django{42,50,51,52} +skip_missing_interpreters = true + +[gh-actions] +python = + 3.8: py38 + 3.9: py39 + 3.10: py310 + 3.11: py311 + 3.12: py312 + +[testenv] +deps = + pytest + pytest-django + django42: Django>=4.2,<4.3 + django50: Django>=5.0,<5.1 + django51: Django>=5.1,<5.2 + django52: Django>=5.2,<5.3 +commands = + pytest {posargs} diff --git a/verify_email/__init__.py b/verify_email/__init__.py index 70f4a37..d0aab96 100644 --- a/verify_email/__init__.py +++ b/verify_email/__init__.py @@ -1 +1,3 @@ -from .email_handler import * +from .email_handler import ActivationMailManager, send_verification_email + +__all__ = ["ActivationMailManager", "send_verification_email"] diff --git a/verify_email/admin.py b/verify_email/admin.py index daa45ca..1ba98af 100644 --- a/verify_email/admin.py +++ b/verify_email/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin + from .models import LinkCounter admin.site.register(LinkCounter) diff --git a/verify_email/app_configurations.py b/verify_email/app_configurations.py index 01f89f4..1a59f3f 100644 --- a/verify_email/app_configurations.py +++ b/verify_email/app_configurations.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from django.conf import settings + from .interface import DefaultConfig @@ -79,16 +80,17 @@ def __post_init__(self): "max_retries": DefaultConfig(setting_field="MAX_RETRIES", default_value=2), } - def get(self, field_name, raise_exception=True, default_type=str): - attr = getattr( - settings, - self.defaults_configs[field_name].setting_field, # get field from settings - self.defaults_configs[ - field_name - ].default_value, # get default value if field not defined - ) - if not attr and not isinstance(field_name, default_type) and raise_exception: - if field_name == "verification_success_template" and attr is None: - return None - raise AttributeError + def get(self, field_name, raise_exception=True): + config = self.defaults_configs[field_name] + attr = getattr(settings, config.setting_field, config.default_value) + + # Documented feature: setting VERIFICATION_SUCCESS_TEMPLATE = None + # intentionally skips the success page and redirects to LOGIN_URL. + if field_name == "verification_success_template" and attr is None: + return None + + if attr is None and raise_exception: + raise AttributeError( + f"Set the '{config.setting_field}' value in your project's settings.py." + ) return attr diff --git a/verify_email/apps.py b/verify_email/apps.py index c800025..05ea98e 100644 --- a/verify_email/apps.py +++ b/verify_email/apps.py @@ -1,13 +1,6 @@ -import logging - from django.apps import AppConfig -logger = logging.getLogger(__name__) - class VerifyEmailConfig(AppConfig): name = "verify_email" - - def ready(self): - logger.info("[Email Verification] : importing signals - OK.") - import verify_email.signals + default_auto_field = "django.db.models.BigAutoField" diff --git a/verify_email/confirm.py b/verify_email/confirm.py index 8f4dc8e..483c8de 100644 --- a/verify_email/confirm.py +++ b/verify_email/confirm.py @@ -1,10 +1,11 @@ import logging from dataclasses import dataclass, field -from .token_manager import TokenManager -from .custom_types import User from django.utils import timezone +from .custom_types import User +from .errors import UserAlreadyActive +from .token_manager import TokenManager logger = logging.getLogger(__name__) @@ -81,6 +82,15 @@ def activate_user(cls, encoded_email: str, encoded_token: str) -> User: encoded_email, encoded_token ) + # Defense-in-depth: never re-activate an already-active account. + # Normally the token check already fails for active users (it is + # bound to last_login), but an account activated out-of-band could + # still present a valid token; refuse rather than reset last_login. + if user.is_active: + raise UserAlreadyActive( + f"The user with email for token {encoded_email} is already active" + ) + # Activate the user account user.is_active = True user.last_login = timezone.now() diff --git a/verify_email/custom_types.py b/verify_email/custom_types.py index e55324d..45c5e30 100644 --- a/verify_email/custom_types.py +++ b/verify_email/custom_types.py @@ -1,3 +1,12 @@ -from typing import TypeVar - -User = TypeVar("User") +"""Shared type aliases for the verify_email package.""" + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + # Only imported for static type checking. Importing a Django model at + # runtime here would touch the app registry before it is ready. + from django.contrib.auth.models import AbstractBaseUser as User +else: + User = Any + +__all__ = ["User"] diff --git a/verify_email/email_handler.py b/verify_email/email_handler.py index ffab606..943655a 100644 --- a/verify_email/email_handler.py +++ b/verify_email/email_handler.py @@ -1,17 +1,19 @@ -from dataclasses import dataclass, field import logging +from dataclasses import dataclass, field from django.core.mail import send_mail from django.template.loader import render_to_string from django.utils.html import strip_tags from .app_configurations import GetFieldFromSettings +from .custom_types import User from .errors import InvalidTokenOrEmail from .token_manager import TokenManager -from .custom_types import User logger = logging.getLogger(__name__) +__all__ = ["ActivationMailManager", "send_verification_email"] + @dataclass(frozen=True) class ActivationMailManager: @@ -142,3 +144,16 @@ def resend_verification_link(cls, request, email, **kwargs): f"Error occurred during re sending the email with verification link: {err}" ) raise err + + +def send_verification_email(request, form): + """ + Primary entry point: save ``form``'s user as inactive and email them a + verification link. + + This is the documented public API used from a signup view. It is a thin + wrapper around :meth:`ActivationMailManager.send_verification_link` and + returns the saved (inactive) user, so you do not need to call ``form.save()`` + yourself. If sending fails the user is rolled back so the visitor can retry. + """ + return ActivationMailManager.send_verification_link(form=form, request=request) diff --git a/verify_email/errors.py b/verify_email/errors.py index b7c902b..bdf8be7 100644 --- a/verify_email/errors.py +++ b/verify_email/errors.py @@ -1,33 +1,49 @@ -class UserAlreadyActive(Exception): - def __init__(self, *args: object) -> None: - super().__init__(*args) +"""Exceptions raised by verify_email. +All exceptions inherit from :class:`VerifyEmailError`, so integrators can catch +the whole family with a single ``except VerifyEmailError`` if they prefer. +""" -class MaxRetriesExceeded(Exception): - def __init__(self, *args: object) -> None: - super().__init__(*args) +class VerifyEmailError(Exception): + """Base class for every error raised by this package.""" -class UserNotFound(Exception): - def __init__(self, *args: object) -> None: - super().__init__(*args) +class UserAlreadyActive(VerifyEmailError): + pass -class InvalidToken(Exception): - def __init__(self, *args: object) -> None: - super().__init__(*args) +class MaxRetriesExceeded(VerifyEmailError): + pass -class InvalidTokenOrEmail(Exception): - def __init__(self, *args: object) -> None: - super().__init__(*args) +class UserNotFound(VerifyEmailError): + pass -class WrongTimeInterval(Exception): - def __init__(self, *args: object) -> None: - super().__init__(*args) +class InvalidToken(VerifyEmailError): + pass -class DecodingFailed(Exception): - def __init__(self, *args: object) -> None: - super().__init__(*args) + +class InvalidTokenOrEmail(VerifyEmailError): + pass + + +class WrongTimeInterval(VerifyEmailError): + pass + + +class DecodingFailed(VerifyEmailError): + pass + + +__all__ = [ + "VerifyEmailError", + "UserAlreadyActive", + "MaxRetriesExceeded", + "UserNotFound", + "InvalidToken", + "InvalidTokenOrEmail", + "WrongTimeInterval", + "DecodingFailed", +] diff --git a/verify_email/interface.py b/verify_email/interface.py index cac3b07..6f44881 100644 --- a/verify_email/interface.py +++ b/verify_email/interface.py @@ -1,5 +1,5 @@ -from typing import Union, Any from dataclasses import dataclass +from typing import Any, Union @dataclass diff --git a/verify_email/migrations/0001_initial.py b/verify_email/migrations/0001_initial.py index bcaed17..92044b1 100644 --- a/verify_email/migrations/0001_initial.py +++ b/verify_email/migrations/0001_initial.py @@ -1,8 +1,8 @@ # Generated by Django 4.0.3 on 2022-04-11 07:00 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): diff --git a/verify_email/models.py b/verify_email/models.py index 40f2f67..6ac79bf 100644 --- a/verify_email/models.py +++ b/verify_email/models.py @@ -1,50 +1,24 @@ +from django.conf import settings from django.db import models -from django.contrib.auth import get_user_model - -USER = get_user_model() class LinkCounter(models.Model): """ - Represents a count of links sent by a user. - - Attributes - ---------- - requester : OneToOneField - A one-to-one relationship with the user who made the request. - sent_count : int - The total number of links sent by the requester. - - Methods - ------- - __str__() -> str: - Returns the username of the requester as a string. + Tracks how many verification links have been sent to a user, so the resend + limit (``MAX_RETRIES``) can be enforced. - __repr__() -> str: - Returns the username of the requester for representation. + A row is created lazily the first time a user requests a resend (see + ``ActivationLinkManager``); there is no longer a global ``post_save`` signal + creating one for every user in the project. """ - requester = models.OneToOneField(USER, on_delete=models.CASCADE) + requester = models.OneToOneField( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE + ) sent_count = models.IntegerField() def __str__(self) -> str: - """ - Returns the username of the requester when converting the object to a string. - - Returns - ------- - str - The username of the requester. - """ return str(self.requester.get_username()) def __repr__(self) -> str: - """ - Returns a string representation of the LinkCounter instance. - - Returns - ------- - str - The username of the requester. - """ return str(self.requester.get_username()) diff --git a/verify_email/signals.py b/verify_email/signals.py deleted file mode 100644 index b5677a0..0000000 --- a/verify_email/signals.py +++ /dev/null @@ -1,22 +0,0 @@ -import logging - -from django.db.models.signals import post_save -from django.dispatch import receiver - -from .models import LinkCounter, USER - -logger = logging.getLogger(__name__) - - -@receiver(post_save, sender=USER) -def increase_count(sender, instance, created, **kwargs): - if created: - LinkCounter.objects.create(requester=instance, sent_count=1) - - -@receiver(post_save, sender=USER) -def save_count(sender, instance, **kwargs): - try: - instance.linkcounter.save() - except Exception as err: - logger.error(err) diff --git a/verify_email/templates/email_index.html b/verify_email/templates/email_index.html index 7859e21..2689df7 100644 --- a/verify_email/templates/email_index.html +++ b/verify_email/templates/email_index.html @@ -1,42 +1,8 @@ -{%load static%} - - - - - - - - - - - - - - {{status}} - - - - {% if messages%} - {% for message in messages %} -
- {{message}} - {% endfor %} -
- {% endif %} - - - {%block content%}{%endblock%} - - - - - - - - \ No newline at end of file +{% extends "verify_email/base.html" %}{% comment %} +DEPRECATED: kept only for backwards compatibility. + +The canonical base template is now "verify_email/base.html" (namespaced so it +cannot collide with a host project's own templates). Custom templates should +extend "verify_email/base.html" instead. This file forwards to it so any +existing overrides that still `{% extends "email_index.html" %}` keep working. +{% endcomment %} diff --git a/verify_email/templates/verify_email/base.html b/verify_email/templates/verify_email/base.html new file mode 100644 index 0000000..7859e21 --- /dev/null +++ b/verify_email/templates/verify_email/base.html @@ -0,0 +1,42 @@ +{%load static%} + + + + + + + + + + + + + + {{status}} + + + + {% if messages%} + {% for message in messages %} +
+ {{message}} + {% endfor %} +
+ {% endif %} + + + {%block content%}{%endblock%} + + + + + + + + \ No newline at end of file diff --git a/verify_email/templates/verify_email/email_verification_failed.html b/verify_email/templates/verify_email/email_verification_failed.html index 8a0ecb1..ca8ad8a 100644 --- a/verify_email/templates/verify_email/email_verification_failed.html +++ b/verify_email/templates/verify_email/email_verification_failed.html @@ -1,4 +1,4 @@ -{% extends "email_index.html" %} +{% extends "verify_email/base.html" %} {%block content%} diff --git a/verify_email/templates/verify_email/email_verification_successful.html b/verify_email/templates/verify_email/email_verification_successful.html index 0eb7d15..48e7809 100644 --- a/verify_email/templates/verify_email/email_verification_successful.html +++ b/verify_email/templates/verify_email/email_verification_successful.html @@ -1,4 +1,4 @@ -{% extends "email_index.html" %} +{% extends "verify_email/base.html" %} {%block content%} diff --git a/verify_email/templates/verify_email/link_expired.html b/verify_email/templates/verify_email/link_expired.html index 09e7ae2..40c04b0 100644 --- a/verify_email/templates/verify_email/link_expired.html +++ b/verify_email/templates/verify_email/link_expired.html @@ -1,4 +1,4 @@ -{% extends "email_index.html" %} +{% extends "verify_email/base.html" %} {%block content%} diff --git a/verify_email/templates/verify_email/new_email_sent.html b/verify_email/templates/verify_email/new_email_sent.html index c511024..2a08100 100644 --- a/verify_email/templates/verify_email/new_email_sent.html +++ b/verify_email/templates/verify_email/new_email_sent.html @@ -1,4 +1,4 @@ -{% extends "email_index.html" %} +{% extends "verify_email/base.html" %} {%block content%} diff --git a/verify_email/templates/verify_email/request_new_email.html b/verify_email/templates/verify_email/request_new_email.html index 82896bd..9fc73a2 100644 --- a/verify_email/templates/verify_email/request_new_email.html +++ b/verify_email/templates/verify_email/request_new_email.html @@ -1,4 +1,4 @@ -{% extends "email_index.html" %} +{% extends "verify_email/base.html" %} {%block content%} diff --git a/verify_email/tests.py b/verify_email/tests.py deleted file mode 100644 index d957943..0000000 --- a/verify_email/tests.py +++ /dev/null @@ -1,76 +0,0 @@ -# verify_email_tests/test_verify_email.py -import time - -from django.test import TestCase, override_settings -from django.urls import reverse -from django.contrib.auth import get_user_model -from verify_email.email_handler import ActivationMailManager -from verify_email.token_manager import TokenManager, SafeURL, ActivationLinkManager -from django.core import mail -from django.conf import settings -from verify_email.app_configurations import GetFieldFromSettings - - -User = get_user_model() - - -class VerifyEmailTests(TestCase): - def setUp(self): - self.user = User.objects.create_user( - username='testuser', - email='testuser@example.com', - password='testpass' - ) - self.user.is_active = False - self.user.save() - - def test_send_verification_email(self): - """Test that verification email is sent.""" - response = ActivationMailManager.send_verification_link(self.user) - self.assertIsNotNone(response) - self.assertEquals(len(mail.outbox), 1) - - def test_verification_view_by_token_and_email(self): - """Test the email verification view.""" - user_token = TokenManager().generate_token_for_user(self.user) - user_email = SafeURL.perform_encoding(self.user.email) - response = self.client.get(reverse('verify-email', args=[user_email, user_token])) - self.assertEqual(response.status_code, 200) - - def test_verification_link(self): - user_token = TokenManager().generate_token_for_user(self.user) - user_email = self.user.email - link = ActivationLinkManager.generate_link(user_token, user_email) - - full_url = f"http://testserver{link}" - - resp = self.client.get(full_url) - - self.assertEquals(resp.status_code, 200) - - def test_process(self): - user_token = TokenManager().generate_token_for_user(self.user) - time.sleep(1) - - link = ActivationLinkManager.generate_link(user_token, self.user.email) - full_url = f"http://testserver{link}" - resp = self.client.get(full_url) - self.assertEquals(resp.status_code, 200) - - def test_timestamp_invalid_link(self): - user_token = TokenManager().generate_token_for_user(self.user) - time.sleep(3) - - link = ActivationLinkManager().generate_link(user_token, self.user.email) - full_url = f"http://testserver{link}" - resp = self.client.get(full_url) - self.assertEquals(resp.status_code, 401) - - def test_timestamp_valid_link(self): - user_token = TokenManager().generate_token_for_user(self.user) - time.sleep(3) - - link = ActivationLinkManager.generate_link(user_token, self.user.email) - full_url = f"http://testserver{link}" - resp = self.client.get(full_url) - self.assertEquals(resp.status_code, 200) diff --git a/verify_email/token_manager.py b/verify_email/token_manager.py index c300598..bbef704 100644 --- a/verify_email/token_manager.py +++ b/verify_email/token_manager.py @@ -1,23 +1,24 @@ import logging +from base64 import urlsafe_b64decode, urlsafe_b64encode +from binascii import Error as BASE64ERROR from dataclasses import dataclass, field -from typing import Union, List from datetime import timedelta -from binascii import Error as BASE64ERROR -from base64 import urlsafe_b64encode, urlsafe_b64decode +from typing import List, Union -from django.core import signing from django.contrib.auth import get_user_model from django.contrib.auth.tokens import default_token_generator +from django.core import signing +from django.urls import reverse -from .custom_types import User from .app_configurations import GetFieldFromSettings +from .custom_types import User from .errors import ( - UserAlreadyActive, + DecodingFailed, + InvalidToken, MaxRetriesExceeded, + UserAlreadyActive, UserNotFound, WrongTimeInterval, - InvalidToken, - DecodingFailed, ) __all__ = ["TokenManager"] @@ -53,26 +54,39 @@ def perform_decoding(encoded_entity): class ActivationLinkManager(GeneralConfig): @staticmethod - def _get_sent_count(user: User): + def _get_or_create_counter(user: User): """ - Returns the no. of times email has already been sent to a user. + Return the user's :class:`LinkCounter`, creating it on first use. + + The counter is created lazily (the first time a resend is attempted) + rather than eagerly for every user in the project. ``sent_count`` starts + at ``1`` to account for the initial verification email, preserving the + historical resend-limit behaviour. Existing rows (created by older + versions' signal) are reused untouched. """ - return int(user.linkcounter.sent_count) + # Imported lazily: importing models at module load would touch the app + # registry before it is ready. + from .models import LinkCounter - @staticmethod - def _increment_sent_counter(user: User) -> None: + counter, _ = LinkCounter.objects.get_or_create( + requester=user, defaults={"sent_count": 1} + ) + return counter + + def _increment_sent_counter(self, user: User) -> None: """ - Increment count by one after resending the verification link. + Increment the sent counter by one after resending the verification link. """ - user.linkcounter.sent_count += 1 - user.linkcounter.save() + counter = self._get_or_create_counter(user) + counter.sent_count += 1 + counter.save(update_fields=["sent_count"]) def can_request_new_link(self, user: User) -> bool: """ - Checks if the user has remaining attempts to request a new link. + Return ``True`` if the user still has resend attempts remaining. - Compares the user's current request count with the maximum allowed retries. - Returns False if the maximum retries have been reached, True otherwise. + Compares the user's current sent count against ``MAX_RETRIES``; returns + ``False`` once the maximum has been reached. Parameters ---------- @@ -84,7 +98,7 @@ def can_request_new_link(self, user: User) -> bool: bool True if the user has remaining attempts, False if the maximum is exceeded. """ - attempts = self._get_sent_count(user) + attempts = self._get_or_create_counter(user).sent_count if attempts and attempts >= self.max_retries: return False return True @@ -92,29 +106,31 @@ def can_request_new_link(self, user: User) -> bool: @staticmethod def generate_link(token, user_email): """ - Generates an email verification link for an inactive user. + Build the relative verification URL for an inactive user. - This method creates a signed token for the user and encodes the user's email - to construct a unique verification link. + The URL is resolved via ``reverse('verify-email', ...)`` so it honours + wherever the project mounts ``verify_email.urls`` instead of assuming a + hard-coded ``/verification/`` prefix. Parameters ---------- - request : HttpRequest - The HTTP request object, used to build the absolute URL for the link. token : str - user encrpted encode token + The encoded verification token for the user. user_email : str - The email address of the user, which will be included in the verification link. + The user's email address; base64-url-encoded into the link. Returns ------- str - The absolute URL of the verification link. + The relative URL (path) of the verification link. """ encoded_email = urlsafe_b64encode(str(user_email).encode("utf-8")).decode( "utf-8" ) - return f"/verification/user/verify-email/{encoded_email}/{token}/" + return reverse( + "verify-email", + kwargs={"user_email": encoded_email, "user_token": token}, + ) def get_absolute_verification_url(self, request, token, user_email): return request.build_absolute_uri(self.generate_link(token, user_email)) @@ -248,14 +264,13 @@ def _get_seconds(self, interval): if isinstance(interval, int): return interval if isinstance(interval, str): - unit = [i for i in self.time_units if interval.endswith(i)] - if not unit: + matched_units = [u for u in self.time_units if interval.endswith(u)] + if matched_units: + unit = matched_units[0] + else: + # No recognised suffix: treat the whole value as seconds. unit = "s" interval += unit - else: - unit = unit[ - 0 - ] # TODO: look into this, this might cook my ass in some cases try: digit_time = int(interval[:-1]) if digit_time <= 0: @@ -267,12 +282,8 @@ def _get_seconds(self, interval): return timedelta(minutes=digit_time).total_seconds() if unit == "h": return timedelta(hours=digit_time).total_seconds() - if unit == "d": - return timedelta(days=digit_time).total_seconds() - else: - return WrongTimeInterval( - f"Time unit must be from : {self.time_units}" - ) + # unit == "d" + return timedelta(days=digit_time).total_seconds() except ValueError: raise WrongTimeInterval(f"Time unit must be from : {self.time_units}") @@ -362,27 +373,28 @@ def generate_token_for_user(self, user: User, get_url_encoded: bool = True) -> s @staticmethod def get_user_by_token(plain_email, encrypted_token): """ - returns either a bool or user itself which fits the token and is not active. + Return the inactive user whose token matches, or raise. + Exceptions Raised ----------------- - UserAlreadyActive - InvalidToken - UserNotFound """ - inactive_users = get_user_model().objects.filter(email=plain_email) - encrypted_token = encrypted_token.split(":")[0] - for unique_user in inactive_users: - valid = default_token_generator.check_token(unique_user, encrypted_token) - if valid: - if unique_user.is_active: + users = get_user_model().objects.filter(email=plain_email) + if not users: + raise UserNotFound(f"User with {plain_email} not found") + + token = encrypted_token.split(":")[0] + for user in users: + if default_token_generator.check_token(user, token): + if user.is_active: raise UserAlreadyActive( f"The user with email: {plain_email} is already active" ) - return unique_user - else: - raise InvalidToken("Token is invalid") - else: - raise UserNotFound(f"User with {plain_email} not found") + return user + # No user matched the token (checked every user sharing this email). + raise InvalidToken("Token is invalid") def decrypt_token_and_get_user( self, diff --git a/verify_email/urls.py b/verify_email/urls.py index f3981c3..1f9a810 100644 --- a/verify_email/urls.py +++ b/verify_email/urls.py @@ -1,5 +1,6 @@ from django.urls import path -from .views import verify_and_activate_user, request_new_link + +from .views import request_new_link, verify_and_activate_user urlpatterns = [ path( diff --git a/verify_email/views.py b/verify_email/views.py index 9b0a96a..c1812c1 100644 --- a/verify_email/views.py +++ b/verify_email/views.py @@ -1,25 +1,24 @@ import logging -from django.http import Http404, HttpResponse -from django.urls import reverse -from django.shortcuts import render, redirect -from django.contrib.auth import get_user_model from django.contrib import messages -from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned -from django.core.signing import SignatureExpired, BadSignature -from django.views.decorators.http import require_GET, require_POST - +from django.contrib.auth import get_user_model +from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist +from django.core.signing import BadSignature, SignatureExpired +from django.http import Http404 +from django.shortcuts import redirect, render +from django.urls import reverse +from django.views.decorators.http import require_GET, require_http_methods from .app_configurations import GetFieldFromSettings from .confirm import UserActivationProcess from .email_handler import ActivationMailManager -from .forms import RequestNewVerificationEmail from .errors import ( InvalidToken, MaxRetriesExceeded, UserAlreadyActive, UserNotFound, ) +from .forms import RequestNewVerificationEmail logger = logging.getLogger(__name__) @@ -47,9 +46,7 @@ def verify_and_activate_user(request, user_email, user_token): verify the user's email and token and redirect'em accordingly. """ try: - verified_activated_user = UserActivationProcess.activate_user( - user_email, user_token - ) + UserActivationProcess.activate_user(user_email, user_token) if login_page and not success_template: messages.success(request, success_msg) return redirect(to=login_page) @@ -59,7 +56,7 @@ def verify_and_activate_user(request, user_email, user_token): template_name=success_template, context={ "msg": success_msg, - "status": f"Verification Successful!", + "status": "Verification Successful!", "link": reverse(login_page), }, ) @@ -124,6 +121,16 @@ def verify_and_activate_user(request, user_email, user_token): except UserNotFound: raise Http404("404 User not found") + except UserAlreadyActive: + return render( + request, + template_name=failed_template, + context={ + "msg": "This account is already verified. You can log in.", + "status": "Already Verified!", + }, + ) + except Exception as err: logger.exception(err) flash_msg = "Something went wrong during this process!" @@ -144,118 +151,107 @@ def verify_and_activate_user(request, user_email, user_token): +def _email_sent_response(request): + """ + Enumeration-safe confirmation page. + + Rendered identically whether or not the email belongs to a real account + and regardless of that account's state, so this endpoint cannot be used to + probe which addresses are registered or already verified. + """ + return render( + request, + template_name=new_email_sent_template, + context={ + "msg": "If an account with that email exists and still needs " + "verification, a new link has been sent.", + "minor_msg": "Check your inbox (and your spam folder).", + "status": "Email Sent!", + }, + ) + + +def _failed_response(request, msg, status_label, http_status=403): + flash_msg = msg + if debug: + flash_msg = ( + f"{msg} (You are seeing extra details because the project runs in DEBUG mode.)" + ) + return render( + request, + status=http_status, + template_name=failed_template, + context={"msg": flash_msg, "status": status_label}, + ) + + +@require_http_methods(["GET", "POST"]) def request_new_link(request, user_email=None, user_token=None): - try: - if user_email is None or user_token is None: - # request came from re-request email page - if request.method == "POST": - form = RequestNewVerificationEmail(request.POST) # do not inflate data - if form.is_valid(): - form_data: dict = form.cleaned_data - email = form_data["email"] + """ + Re-issue a verification link. + Two entry points share this view: + * The public "request a new email" form (no token in the URL). The + response here is deliberately uniform to avoid account enumeration. + * The "request new link" button on an expired-link page (email + token in + the URL). The caller already holds a token, so specific feedback is safe. + """ + # --- Path 1: public form, no token. Must not leak account existence. --- + if user_email is None or user_token is None: + if request.method == "POST": + form = RequestNewVerificationEmail(request.POST) + if form.is_valid(): + email = form.cleaned_data["email"] + try: inactive_user = get_user_model().objects.get(email=email) - if inactive_user.is_active: - raise UserAlreadyActive("User is already active") - else: - # resend email + if not inactive_user.is_active: ActivationMailManager.resend_verification_link( request, email, user=inactive_user, encoded=False ) - return render( - request, - template_name=new_email_sent_template, - context={ - "msg": "You have requested another verification email!", - "minor_msg": "Your verification link has been sent", - "status": "Email Sent!", - }, - ) - else: - form = RequestNewVerificationEmail() - return render( - request, - template_name=request_new_email_template, - context={"form": form}, - ) + except ( + ObjectDoesNotExist, + MultipleObjectsReturned, + UserAlreadyActive, + MaxRetriesExceeded, + ) as error: + # Swallow: revealing any of these would enable enumeration. + logger.info("Resend request not fulfilled silently: %s", error) + except Exception as err: # noqa: BLE001 - log but stay generic + logger.exception(err) + return _email_sent_response(request) else: - # request came from previously sent link - status = ActivationMailManager.resend_verification_link( - request, user_email, token=user_token - ) - if status: - return render( - request, - template_name=new_email_sent_template, - context={ - "msg": "You have requested another verification email!", - "minor_msg": "Your verification link has been sent", - "status": "Email Sent!", - }, - ) - else: - messages.info(request, "Something went wrong during sending email :(") - logger.error("something went wrong during sending email") - - except ObjectDoesNotExist as error: - messages.warning(request, "User not found associated with given email!") - logger.error(f"[ERROR]: User not found. exception: {error}") - return HttpResponse(b"User Not Found", status=404) - - except MultipleObjectsReturned as error: - logger.error(f"[ERROR]: Multiple users found. exception: {error}") - return HttpResponse(b"Internal server error!", status=500) - - except KeyError as error: - logger.error(f"[ERROR]: Key error for email in your form: {error}") - return HttpResponse(b"Internal server error!", status=500) - - except MaxRetriesExceeded as error: - logger.error( - f"[ERROR]: Maximum retries for link has been reached. exception: {error}" - ) + form = RequestNewVerificationEmail() return render( request, - status=403, - template_name=failed_template, - context={ - "msg": "You have exceeded the maximum verification requests! Contact admin.", - "status": "Maxed out!", - }, + template_name=request_new_email_template, + context={"form": form}, ) - except InvalidToken: - return render( + + # --- Path 2: from an expired link (email + token present in the URL). --- + try: + ActivationMailManager.resend_verification_link( + request, user_email, token=user_token + ) + return _email_sent_response(request) + except MaxRetriesExceeded as error: + logger.error("Maximum retries for link reached: %s", error) + return _failed_response( request, - template_name=failed_template, - context={ - "msg": "This link is invalid or been used already, we cannot verify using this link.", - "status": "Invalid Link", - }, + "You have exceeded the maximum verification requests! Contact admin.", + "Maxed out!", ) except UserAlreadyActive: - return render( + return _failed_response( + request, "This account is already active.", "Already Verified!" + ) + except (InvalidToken, UserNotFound): + return _failed_response( request, - status=403, - template_name=failed_template, - context={ - "msg": "This user's account is already active", - "status": "Already Verified!", - }, + "This link is invalid or has already been used; we cannot verify it.", + "Invalid Link", ) except Exception as err: logger.exception(err) - flash_msg = "Something went wrong during this process!" - if debug: - flash_msg = f"""{flash_msg} Developer should look into this. - Error Details: {err} - (You are seeing error details because app is running in debug mode) - """ - return render( - request, - status=403, - template_name=failed_template, - context={ - "msg": flash_msg, - "status": "Failed!", - }, + return _failed_response( + request, "Something went wrong during this process!", "Failed!" )