# django-ecp-auth

[![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue.svg)](https://www.python.org/downloads/)
[![Django 5.0+](https://img.shields.io/badge/django-5.0%2B-green.svg)](https://www.djangoproject.com/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

**Seamless Electronic Digital Signature (ECP/ЕЦП) Authentication for Django.**

`django-ecp-auth` is a reusable Django application providing robust user authentication and registration via X.509 digital certificates. It features a fully integrated "single-page" authentication flow, allowing users to log in with standard credentials or PKCS#12 (`.p12`/`.pfx`) files simultaneously.

## Features

- 🔐 **PKCS#12 Authentication** — Log in effortlessly using ECP files (without separating normal and ECP login pages).
- 🛠️ **Mixin-based Integration** — Zero-friction integration into existing codebases via Django View and Form Mixins.
- 📝 **Dynamic Certificate Generation** — Let users generate a personalized ECP key during registration.
- 🛡️ **Cryptographic Verification** — Embedded nonce-based challenge/response signatures using `cryptography`.
- 🧩 **Django Auth Backend** — Fully compatible with standard Django authentication flows.
- 📡 **Signals Engine** — Hook into `ecp_login_success` and `ecp_user_registered` events.

## Installation

Install via `pip`:

```bash
pip install django-ecp-auth
```

## Інструкція зі швидкого старту (Full Copy-Paste) 🚀

Найпростіший спосіб інтегрувати бібліотеку — використати файли з нашого тестового робочого проекту. Нижче наведені повністю готові файли (скопіюйте та вставте у свій проект те, що вам потрібно).

<details>
<summary><b>1. 📁 Вставляти у: <code>/settings.py</code> (Налаштування)</b></summary>

```python
"""
Django settings for backend project.

Generated by 'django-admin startproject' using Django 6.0.3.

For more information on this file, see
https://docs.djangoproject.com/en/6.0/topics/settings/

For the full list of settings and their values, see
https://docs.djangoproject.com/en/6.0/ref/settings/
"""

import os
from pathlib import Path
from urllib.parse import unquote, urlparse

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent


# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get(
    'DJANGO_SECRET_KEY',
    'django-insecure-4cd8phcl_g)j$0wy!@zl5!sv*f$3ddv*7@jke(#p!uhw88%$s&',
)

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.environ.get('DEBUG', 'False').lower() in {'1', 'true', 'yes', 'on'}

ALLOWED_HOSTS = [host.strip() for host in os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') if host.strip()]


# Application definition

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'users',
    'ecp_auth',
]

AUTH_USER_MODEL = 'users.User'

AUTHENTICATION_BACKENDS = [
    'ecp_auth.backends.ECPAuthenticationBackend',
    'django.contrib.auth.backends.ModelBackend',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'ecp_auth.middleware.ECPSessionMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

ROOT_URLCONF = 'backend.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.jinja2.Jinja2',
        'DIRS': [BASE_DIR / 'templates'],
        'APP_DIRS': True,
        'OPTIONS': {
            'environment': 'backend.jinja2.environment',
            'context_processors': [
                'django.template.context_processors.request',
                'django.template.context_processors.csrf',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [BASE_DIR / 'templates'],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.request',
                'django.template.context_processors.csrf',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

WSGI_APPLICATION = 'backend.wsgi.application'


# Database
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases

DATABASE_URL = os.environ.get('DATABASE_URL', '')

if DATABASE_URL:
    parsed_db = urlparse(DATABASE_URL)
    db_engines = {
        'postgres': 'django.db.backends.postgresql',
        'postgresql': 'django.db.backends.postgresql',
        'sqlite': 'django.db.backends.sqlite3',
    }
    db_engine = db_engines.get(parsed_db.scheme)
    if not db_engine:
        raise ValueError(f'Unsupported DATABASE_URL scheme: {parsed_db.scheme}')

    if db_engine == 'django.db.backends.sqlite3':
        db_name = parsed_db.path.lstrip('/') or str(BASE_DIR / 'db.sqlite3')
        DATABASES = {
            'default': {
                'ENGINE': db_engine,
                'NAME': db_name,
            }
        }
    else:
        DATABASES = {
            'default': {
                'ENGINE': db_engine,
                'NAME': parsed_db.path.lstrip('/'),
                'USER': unquote(parsed_db.username or ''),
                'PASSWORD': unquote(parsed_db.password or ''),
                'HOST': parsed_db.hostname or '',
                'PORT': str(parsed_db.port or ''),
            }
        }
else:
    DATABASES = {
        'default': {
            'ENGINE': 'django.db.backends.sqlite3',
            'NAME': BASE_DIR / 'db.sqlite3',
        }
    }


# Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
    },
]


# Internationalization
# https://docs.djangoproject.com/en/6.0/topics/i18n/

LANGUAGE_CODE = 'en-us'

TIME_ZONE = 'UTC'

USE_I18N = True

USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.0/howto/static-files/

STATIC_URL = 'static/'
STATICFILES_DIRS = [BASE_DIR / 'static']

LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'dashboard'
LOGOUT_REDIRECT_URL = 'login'

```
</details>

<details>
<summary><b>2. 📁 Вставляти у: <code>ваша_головна_папка/urls.py</code> (Головний файл роутингу проекту)</b></summary>

```python
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    # Ваші існуючі шляхи...
    path("", include("users.urls")),
    
    # Обов'язковий шлях до нашої бібліотеки
    path('auth/', include('ecp_auth.urls')),
    
    path('admin/', admin.site.urls),
]
```
</details>

<details>
<summary><b>3. 📁 Вставляти у: <code>додаток_користувачів/views.py</code> (Вікна входу та реєстрації проекту)</b></summary>

```python
from django.contrib.auth.views import LoginView as DjangoLoginView
from django.urls import reverse_lazy
from django.views.generic import CreateView

from ecp_auth.mixins import ECPLoginViewMixin, ECPRegistrationViewMixin
from ecp_auth.forms import ECPLoginForm, ECPRegistrationFormMixin
from .forms import RegisterForm

# 1. Створюємо форму реєстрації, підмішавши ECPRegistrationFormMixin
class MyRegisterForm(ECPRegistrationFormMixin, RegisterForm):
    pass

# 2. Представлення (View) реєстрації
class RegisterView(ECPRegistrationViewMixin, CreateView):
    form_class = MyRegisterForm
    template_name = "auth/register.html"
    success_url = reverse_lazy("login")

# 3. Представлення (View) входу
class LoginView(ECPLoginViewMixin, DjangoLoginView):
    # ECPLoginForm містить всі необхідні поля та логіку перевірки
    form_class = ECPLoginForm
    template_name = "auth/login.html"
    redirect_authenticated_user = True
```
</details>

<details>
<summary><b>4. 📁 Вставляти у: <code>templates/auth/login.html</code> (Шаблон сторінки входу)</b></summary>

```html
<!DOCTYPE html>
<html lang="uk">
<body class="bg-slate-100">
  <main class="mx-auto flex min-h-screen items-center justify-center p-6">
    <section class="w-full max-w-md rounded-2xl bg-white p-8 shadow-xl">
      <h1 class="text-2xl font-semibold">Вхід у систему</h1>
      
      <!-- Сповіщення про помилки (якщо є) -->
      {% if form.non_field_errors %}
        <div class="mt-4 rounded-lg bg-rose-50 text-rose-700 p-3">
          {% for error in form.non_field_errors %}
            <div>{{ error }}</div>
          {% endfor %}
        </div>
      {% endif %}

      <!-- Обов'язково додайте enctype="multipart/form-data" -->
      <form action="{% url 'login' %}" method="post" enctype="multipart/form-data" class="mt-6 space-y-4">
        {% csrf_token %}

        <!-- Блок звичайного логіна/пароля -->
        <div>
          <label for="id_username">Логін</label>
          <input id="id_username" name="username" type="text" class="w-full border rounded p-2">
        </div>
        <div>
          <label for="id_password">Пароль</label>
          <input id="id_password" name="password" type="password" class="w-full border rounded p-2">
        </div>

        <hr class="my-6">
        <p class="text-sm font-semibold text-slate-500 mb-2">АБО УВІЙДІТЬ ЗА ЕЦП КЛЮЧЕМ</p>

        <!-- Блок ЕЦП-авторизації -->
        <div>
          <label for="id_pkcs12_file">Файл з ключем (.p12 / .pfx)</label>
          {{ form.pkcs12_file }}
          {% if form.pkcs12_file.errors %}
            <div class="text-xs text-rose-600">{{ form.pkcs12_file.errors.0 }}</div>
          {% endif %}
        </div>

        <div>
          <label for="id_pkcs12_password">Пароль ключа</label>
          {{ form.pkcs12_password }}
          {% if form.pkcs12_password.errors %}
            <div class="text-xs text-rose-600">{{ form.pkcs12_password.errors.0 }}</div>
          {% endif %}
        </div>

        <button type="submit" class="mt-4 w-full bg-slate-900 text-white p-2 rounded">
          Увійти
        </button>
      </form>
    </section>
  </main>
</body>
</html>
```
</details>

<details>
<summary><b>5. 📁 Вставляти у: <code>templates/auth/register.html</code> (Шаблон сторінки реєстрації)</b></summary>

```html
<!DOCTYPE html>
<html lang="uk">
<body class="bg-slate-100">
  <main class="mx-auto flex min-h-screen items-center justify-center p-6">
    <section class="w-full max-w-md rounded-2xl bg-white p-8 shadow-xl">
      <h1 class="text-2xl font-semibold">Реєстрація</h1>

      <!-- Обов'язково додайте enctype="multipart/form-data" -->
      <form action="{% url 'register' %}" method="post" enctype="multipart/form-data" class="mt-6 space-y-4">
        {% csrf_token %}

        <!-- Ваші стандартні поля реєстрації (username, password тощо) -->
        <div>
          <label for="id_reg_username">Логін</label>
          <input id="id_reg_username" name="username" type="text" value="{{ form.username.value|default_if_none:'' }}" class="w-full border rounded p-2">
        </div>
        <div>
          <label for="id_reg_password1">Пароль</label>
          <input id="id_reg_password1" name="password1" type="password" class="w-full border rounded p-2">
        </div>
        <div>
          <label for="id_reg_password2">Підтвердження пароля</label>
          <input id="id_reg_password2" name="password2" type="password" class="w-full border rounded p-2">
        </div>

        <hr class="my-6">
        <p class="text-sm font-semibold text-slate-500 mb-2">ОПЦІОНАЛЬНО: ЕЦП (ДЛЯ ШВИДКОГО ВХОДУ)</p>

        <!-- Блок генерації ЕЦП -->
        <div class="flex items-center">
          {{ form.generate_ecp }}
          <label for="id_generate_ecp" class="ml-2 text-sm text-slate-700">Згенерувати ЕЦП ключ</label>
        </div>
        <p class="text-xs text-slate-500 mb-4">{{ form.generate_ecp.help_text }}</p>

        <div>
          <label for="id_ecp_password">Пароль до ключа</label>
          {{ form.ecp_password }}
          {% if form.ecp_password.errors %}
            <div class="text-xs text-rose-600">{{ form.ecp_password.errors.0 }}</div>
          {% endif %}
        </div>

        <button type="submit" class="mt-4 w-full bg-slate-900 text-white p-2 rounded">
          Зареєструватися
        </button>
      </form>
    </section>
  </main>
</body>
</html>
```
</details>

Після додавання цих файлів:
```bash
python manage.py migrate
python manage.py runserver
```

## Signal Hooks

Extend functionality using our built-in signals:

```python
from django.dispatch import receiver
from ecp_auth.signals import ecp_login_success, ecp_user_registered

@receiver(ecp_login_success)
def handle_ecp_login(sender, request, user, certificate_info, **kwargs):
    print(f"Welcome {user.username}! Assured by cert: {certificate_info.fingerprint_sha256}")
```

## Development & Testing

```bash
git clone https://github.com/ecp-auth/django-ecp-auth.git
cd django-ecp-auth
pip install -e ".[dev]"
pytest tests/ -v
```

## License

MIT License. See [LICENSE](LICENSE) for details.

## Configuration

Add these settings to your `settings.py` (all are optional):

```python
# Challenge-Response
ECP_AUTH_CHALLENGE_TIMEOUT = 300        # Nonce timeout in seconds (default: 5 min)
ECP_AUTH_CHALLENGE_LENGTH = 32          # Nonce length in bytes (default: 32)

# Certificate Validation
ECP_AUTH_REQUIRE_KEY_USAGE = True       # Require digitalSignature key usage
ECP_AUTH_ALLOW_SELF_SIGNED = True       # Allow self-signed certificates
ECP_AUTH_VALIDATE_CHAIN = False         # Enable certificate chain validation
ECP_AUTH_TRUSTED_CA_DIR = '/path/to/ca/certs'  # Trusted CA directory

# Redirects
ECP_AUTH_LOGIN_REDIRECT_URL = '/'              # After login
ECP_AUTH_LOGOUT_REDIRECT_URL = '/auth/login/'  # After logout
```

## Authentication Flow

```
1. User uploads PKCS#12 file (.p12/.pfx) with password
2. Server extracts X.509 certificate and private key
3. Server validates the certificate (expiration, key usage, etc.)
4. Server generates a random challenge nonce
5. Server signs the nonce with the user's private key
6. User confirms the signature
7. Server verifies the signature against the certificate
8. User is authenticated and logged in
```

## Signals

Connect to authentication events:

```python
from ecp_auth.signals import ecp_login_success, ecp_user_registered

@receiver(ecp_login_success)
def on_ecp_login(sender, request, user, certificate_info, **kwargs):
    print(f"User {user.username} logged in via ECP")

@receiver(ecp_user_registered)
def on_ecp_register(sender, request, user, certificate_info, **kwargs):
    print(f"New user registered: {user.username}")
```

## Available Signals

| Signal | Arguments |
|--------|-----------|
| `ecp_login_success` | `request`, `user`, `certificate_info` |
| `ecp_login_failed` | `request`, `reason`, `certificate_info` |
| `ecp_user_registered` | `request`, `user`, `certificate_info` |
| `ecp_logout` | `request`, `user` |
| `ecp_certificate_linked` | `user`, `certificate_info` |

## Development

```bash
# Clone and install with dev dependencies
git clone https://github.com/ecp-auth/django-ecp-auth.git
cd django-ecp-auth
pip install -e ".[dev]"

# Run tests
pytest tests/ -v

# Run with coverage
pytest tests/ -v --cov=ecp_auth --cov-report=term-missing
```

## Dependencies

- **Django** ≥ 5.0
- **cryptography** ≥ 42.0
- **pycryptodome** ≥ 3.20
- **asn1crypto** ≥ 1.5
- **certvalidator** ≥ 0.11
- **Jinja2** ≥ 3.1

## License

MIT License. See [LICENSE](LICENSE) for details.
