Metadata-Version: 2.4
Name: django-email-telegram-auth
Version: 0.4.3
Summary: Simple email, telegram token auth django app
Home-page: 
Description-Content-Type: text/markdown
Dynamic: description
Dynamic: description-content-type
Dynamic: summary

# django-email-token-auth

Passwordless Django authentication: users log in with a one-time link (or
6-digit code) sent to their email, instead of a password. A Telegram-based
login path is also included.

## Features

* Email "magic link" login — no password to remember or leak
* 6-digit numeric code as an alternate/secondary token
* Optional Telegram login (`TelegramUser`, matched by `tg_user_id`)
* Automatically merges duplicate `User` accounts that share an email address
* Simple per-user key/value option storage (`get_option` / `set_option`)
* Login-attempt rate limiting
* Django admin integration (inlines for `EmailUser` / `TelegramUser`)

## Installation

1. Install the app and its dependencies:

       pip install .

   (or `pip install -r requirements.txt` during development).

2. Add `'emailuser'` to `INSTALLED_APPS`.

3. Add `'emailuser.auth.EmailAuthBackend'` to `AUTHENTICATION_BACKENDS`.

4. Include the app's URLs in your project, under the `emailuser` namespace:

       from django.urls import include, path

       urlpatterns = [
           path('accounts/', include(('emailuser.urls', 'emailuser'), namespace='emailuser')),
           ...
       ]

5. Set the required settings:

       SITE_URL = 'https://example.com'        # used to build the emailed login link
       SERVER_EMAIL = 'support@example.com'     # From: address, and shown to users on error
       # SITE_LOGIN_URL = 'https://login.example.com'  # optional: override SITE_URL just for the link

6. Run migrations:

       python manage.py migrate

7. The shipped templates (`user/login.html`, `user/auth.html`) `{% extends "base.html" %}`
   and expect a `{% block content %}`, and are written in Russian — provide a
   matching `base.html`, or override both templates under your own
   `templates/user/` directory.

That's it — `emailuser.views.login_view`, `auth`, and `logout_view` handle
account creation/lookup, token generation, emailing the link, and validating
it, so no view code of your own is required.

## How login works

* A visitor submits their email at `{% url 'emailuser:login' %}`.
* The backend looks up (or creates) a `User` with that email — see "Merging
  duplicate accounts" below — and generates a token pair via
  `EmailUser.generate_token()`:
  * `auth_token`: a long hash, valid for `token_timelife` (1 day), embedded
    in the emailed link (`{% url 'emailuser:auth' user_id token %}`).
  * `int_token`: a 6-digit numeric code, valid for `int_token_timelife`
    (3 minutes), for shorter-lived, manually-entered flows.
* Visiting the link (or otherwise calling
  `authenticate(email=user.email, token=token)`) validates the token and
  logs the user in.

### Telegram login

`TelegramUser` mirrors `EmailUser` but is keyed on `tg_user_id` instead of
email. Create one with `manual_create_telegram_user(user, tg_user)`, then
authenticate with:

    from django.contrib.auth import authenticate
    user = authenticate(tg_user_id=tg_user.id, token=token)

### Merging duplicate accounts

`django.contrib.auth.models.User` has no unique constraint on `email`, so
it's possible for two accounts to end up sharing an address. `emailuser`'s
built-in views use `emailuser.utils.get_or_merge_user(email)` instead of a
plain `User.objects.get(email=...)`: if duplicates are found, every related
row (from any app, via Django's own FK/O2O introspection) is reassigned to
the oldest account and the duplicate(s) are deleted, so login doesn't fail
with `MultipleObjectsReturned`.

### Rate limiting

Every call to `do_auth()` records a `LoginAttempt` for that user, viewable
in the Django admin. More than 11 attempts within the past hour raises
`TokenException` instead of checking the token.

### User options

Store arbitrary per-user key/value pairs:

    def some_view(request):
        request.user.emailuser.set_option('some_option', 'some_value')

    def another_view(request):
        value = request.user.emailuser.get_option('some_option')  # 'some_value', or False if unset

