Toolbox da Bode.io para projetos em Django — JWT authentication, utility models, helpers and services.
- JWT authentication via
djangorestframework-simplejwt - DRF endpoints: login, logout, register, password change, password reset request/confirm, e-mail confirmation and Google social login
- OTP strategy for email confirmation and password reset (configurable — magic link is the default)
- Passwordless login via email OTP or magic link (opt-in)
- Abstract base models:
BaseModel(timestamps) andLogicDeletable(soft deletion) - Built-in models:
UserAuth,Pais,LoginRecord,ConsultaCEP,OptimizedImageWithTinyPNG - CEP lookup service with ViaCEP / AwesomeAPI fallback and database caching
- Login tracking via Django signals
- Utility functions: file cleaners, date/string/number formatters, workday calculator, email obfuscation, MX-validating form field, PBKDF2 hashing, pagination fix, MySQL
ROUND - Template tags:
grana(R$ formatting),multiply,roi,url_replace - Sentry metrics helpers (optional)
- Management commands: country import, TinyPNG image compression
- Standardized success and error responses across all endpoints
- Python: 3.12, 3.13, 3.14, 3.15 (pre-release)
- Django: 5.1, 5.2, 6.0
These are the versions validated by CI (see .github/workflows/ci.yml). Django 4.2 (LTS) is no longer supported.
pip install git+https://github.com/bodedev/bodepontoio.git@mainOptional extras:
pip install "django-bodepontoio[auth]" # JWT auth endpoints (DRF + simplejwt)
pip install "django-bodepontoio[tinify]" # TinyPNG image compression
pip install "django-bodepontoio[sentry]" # Sentry metrics helpersNote: The
authextra is required if you use any of the authentication views, serializers, renderers or pagination classes.
# settings.py
INSTALLED_APPS = [
...
"rest_framework",
"rest_framework_simplejwt",
"rest_framework_simplejwt.token_blacklist", # required for LogoutView
"bodepontoio",
]
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
"EXCEPTION_HANDLER": "bodepontoio.exceptions.exception_handler",
"DEFAULT_RENDERER_CLASSES": ["bodepontoio.renderers.SuccessJSONRenderer"],
}
SIMPLE_JWT = {
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
}
# Optional bodepontoio overrides
BODEPONTOIO = {
"FRONTEND_URL": "https://app.example.com", # default: "http://localhost:3000"
"PASSWORD_RESET_URL_PATH": "/reset/{uid}/{token}/", # default: "/reset-password/{uid}/{token}/"
"LOGIN_MAGIC_LINK_URL_PATH": "/auth/magic/{uid}/{token}/", # default: "/login/magic/{uid}/{token}/"
"GOOGLE_CLIENT_ID": "your-client-id.apps.googleusercontent.com", # required for Google login
# OTP strategies (default: "magic_link" — backward compatible)
"EMAIL_CONFIRM_STRATEGY": "otp", # "magic_link" or "otp"
"PASSWORD_RESET_STRATEGY": "otp", # "magic_link" or "otp"
# OTP options
"OTP_LENGTH": 6, # digits in the generated code
"OTP_EXPIRY_SECONDS": 900, # 15 minutes
"OTP_MAX_ATTEMPTS": 5, # wrong attempts before the code is burned
# Login strategy (default: "password")
"LOGIN_STRATEGY": "otp", # "password", "otp" or "magic_link"
"LOGIN_AUTO_SIGNUP": True, # default: False — see Passwordless Login
}# urls.py
path("api/auth/", include("bodepontoio.urls", namespace="bodepontoio")),python manage.py migrate| Method | URL | Permission | Description |
|---|---|---|---|
| POST | login/ |
Public | Obtain tokens (password), request OTP code, or send magic link (strategy-dependent) |
| POST | login/otp/confirm/ |
Public | Exchange OTP code for tokens ¹ |
| POST | login/magic/confirm/ |
Public | Exchange magic link uid + token for tokens ³ |
| POST | login/resend/ |
Public | Re-send OTP code or magic link ⁴ |
| POST | token/refresh/ |
Public | Refresh access token |
| POST | logout/ |
Authenticated | Blacklist refresh token |
| POST | register/ |
Public | Create account, sends confirmation email |
| POST | password/change/ |
Authenticated | Change password |
| POST | password/reset/ |
Public | Request reset link or code |
| POST | password/reset/confirm/ |
Public | Confirm reset with uid + token (magic link) |
| POST | password/reset/confirm/otp/ |
Public | Confirm reset with email + OTP code ² |
| GET | email/confirm/<uid>/<token>/ |
Public | Confirm email address (magic link) |
| POST | email/confirm/otp/ |
Public | Confirm email address with OTP code ² |
| POST | email/confirm/resend/ |
Public | Re-send confirmation email or code |
| POST | social/google/ |
Public | Login or register via Google ID token |
¹ Returns 404 unless LOGIN_STRATEGY = "otp". login/ always exists but its behaviour changes with the strategy.
² Returns 404 unless the matching strategy is set to "otp".
³ Returns 404 unless LOGIN_STRATEGY = "magic_link".
⁴ Returns 404 unless LOGIN_STRATEGY is "otp" or "magic_link".
POST login/ accepts:
| Field | Required | Notes |
|---|---|---|
login |
Yes | Username or email address |
password |
Yes |
POST register/ accepts:
| Field | Required | Notes |
|---|---|---|
username |
Yes | Must be unique |
email |
Yes | Must be unique |
password |
Yes | Minimum 8 characters |
first_name |
No | |
last_name |
No |
A confirmation email is sent automatically. The user cannot log in until the email is confirmed.
POST social/google/ accepts {"id_token": "<google-oauth2-id-token>"}.
Pass the ID token issued by Google's OAuth 2.0 flow (e.g. from Google Sign-In for Web or a mobile SDK). The endpoint verifies the token against the configured client ID, then:
- Creates a new user (with email, first_name, last_name from the token) if one doesn't exist yet
- Marks the email as verified automatically
- Returns the same
access+refreshtokens as regular login
Requires BODEPONTOIO["GOOGLE_CLIENT_ID"] to be set.
When a user registers, a confirmation email is sent automatically. Login is blocked until the email address is confirmed.
- User registers → confirmation email sent
- User clicks the link in the email →
GET email/confirm/<uid>/<token>/→ account activated - User can now log in
POST email/confirm/resend/ accepts {"email": "..."}. It silently does nothing if the address is unknown or already confirmed (anti-enumeration).
Django template override (no config needed): create a file at the same path inside any directory listed in TEMPLATES[0]['DIRS']:
your_project/
templates/
bodepontoio/
email_confirmation_email.html
| Variable | Description |
|---|---|
user |
The User instance |
confirm_url |
Full confirmation URL |
Email confirmation and password reset both support two delivery strategies, configured independently:
BODEPONTOIO = {
"EMAIL_CONFIRM_STRATEGY": "otp", # or "magic_link" (default)
"PASSWORD_RESET_STRATEGY": "otp", # or "magic_link" (default)
}The user receives an email with a signed URL. They click it or paste it in a browser. No new endpoints required — backward compatible.
The user receives a short numeric code (default: 6 digits, 15 minutes expiry). They submit it via a POST endpoint:
- Email confirmation:
POST email/confirm/otp/with{"email": "...", "code": "..."} - Password reset:
POST password/reset/confirm/otp/with{"email": "...", "code": "...", "new_password": "..."}
The magic-link endpoints remain registered but return 404 when the OTP strategy is active, and vice-versa.
| Setting | Default | Description |
|---|---|---|
OTP_LENGTH |
6 |
Number of digits in the generated code |
OTP_EXPIRY_SECONDS |
900 |
Seconds until the code expires (15 minutes) |
OTP_MAX_ATTEMPTS |
5 |
Wrong attempts before the code is burned |
| Setting | Default | Description |
|---|---|---|
LOGIN_MAGIC_LINK_REUSE_WINDOW_SECONDS |
0 (off) |
Seconds the login link stays reusable. 0 keeps it single-use |
The login link is single-use by default, because last_login is part of Django's
token hash. That breaks logins in practice: whatever opens the URL before the
user burns it (provider link scanners, mail-app webviews, prerender), and since
last_login is shared by every outstanding token, it kills their other pending
links too, so re-sending does not help.
Setting a window drops last_login from the hash and keeps the link valid, any
number of times, until the window closes:
BODEPONTOIO = {
"LOGIN_MAGIC_LINK_REUSE_WINDOW_SECONDS": 900, # 15 minutes
}The tradeoff is a replay window instead of a used-once guarantee, so keep it
short. It is capped by PASSWORD_RESET_TIMEOUT (Django's default is 3 days),
which stays the ceiling in either mode. Values below 0 clamp to single-use.
Upgrading. With no window the hash is Django's, so links issued by earlier
versions keep working. Turning a window on invalidates pending links for users
who have logged in before; users whose last_login is still NULL are
unaffected, since Django leaves that part of the hash empty for them.
Revoking. Password and email changes invalidate pending links in either mode,
since both stay in the hash. That is the only early revocation a window leaves
you: user.set_unusable_password() picks a new random value on every call, so it
works even on a passwordless deployment. Logging out does not revoke anything —
once issued, a link lives until its window closes or the password or email
changes.
Where single-use comes from. It rides on last_login moving, since that is
part of the token hash. Django's update_last_login receiver, connected to
user_logged_in, normally does the writing. The confirm view checks afterwards
and writes last_login itself if the receiver did not, so disconnecting it costs
you the last_login bookkeeping on other login flows but never the single-use
guarantee.
| Template | Strategy | Flow |
|---|---|---|
bodepontoio/email_confirmation_email.html |
magic_link |
Email confirmation |
bodepontoio/email_confirmation_otp.html |
otp |
Email confirmation |
bodepontoio/password_reset_email.html |
magic_link |
Password reset |
bodepontoio/password_reset_otp.html |
otp |
Password reset |
OTP templates receive {{ otp_code }} instead of a URL.
Every email that mentions a deadline gets {{ expiry_phrase }} — already worded
and pluralized, in minutes below two hours and in hours above, always rounded up
so the email never promises less time than the link or code really has. Prefer it
over the raw {{ expiry_hours }} and {{ expiry_minutes }}, which stay in the
context only for templates that already read them. The deadline itself comes from
PASSWORD_RESET_TIMEOUT for the link emails and OTP_EXPIRY_SECONDS for the OTP
ones; the magic-link login email follows its reuse window when one is set.
Users can log in with just their email address — no password required. Two delivery strategies are available:
BODEPONTOIO = {
"LOGIN_STRATEGY": "otp", # "password" (default), "otp", or "magic_link"
}login/ is always the entry point — its behaviour depends on the active strategy:
| Strategy | POST login/ accepts |
Returns |
|---|---|---|
"password" (default) |
{"login": "...", "password": "..."} |
{access, refresh} tokens |
"otp" |
{"email": "..."} |
200 message, sends OTP code by email |
"magic_link" |
{"email": "..."} |
200 message, sends a signed login link by email |
The user then completes login with:
POST login/otp/confirm/with{"email": "...", "code": "..."}→ returns{access, refresh}(OTP strategy)POST login/magic/confirm/with{"uid": "...", "token": "..."}→ returns{access, refresh}(magic link strategy)
Each confirm endpoint returns 404 when its matching strategy is not active.
When LOGIN_STRATEGY is "otp" or "magic_link" and LOGIN_AUTO_SIGNUP is True, POST login/ for an unknown email creates the account on the fly (with an unusable password) and sends the code/link to the new address. The same endpoint doubles as registration — no separate signup step is required.
If the user model has a username field, a unique username is auto-generated from the email's local part.
LOGIN_AUTO_SIGNUP defaults to False so that login/ only mails known accounts. Enable it explicitly to opt into the login-doubles-as-signup flow:
BODEPONTOIO = {
"LOGIN_AUTO_SIGNUP": True, # default: False
}login/ is throttled out of the box by IP and by email to protect against abuse (especially relevant when auto-signup is enabled, since it can otherwise be used to mass-create user rows). Defaults are 20/hour per IP and 10/hour per email; tune or disable via:
BODEPONTOIO = {
"LOGIN_THROTTLE_IP_RATE": "20/hour", # set to None to disable
"LOGIN_THROTTLE_EMAIL_RATE": "10/hour", # set to None to disable
}
⚠️ The IP throttle requiresNUM_PROXIESto be configured. DRF derives the client IP fromX-Forwarded-For, which the client controls. Unless you tell DRF how many trusted proxies sit in front of the app, an attacker can rotate that header and get a fresh throttle bucket per request, bypassingLOGIN_THROTTLE_IP_RATEentirely. Set it to match your deployment:REST_FRAMEWORK = { "NUM_PROXIES": 1, # number of trusted proxies (nginx, LB, CDN…); use 0 if none }If you don't know the proxy depth,
0is the safe default (uses the unspoofableREMOTE_ADDR). The per-email throttle is unaffected — it keys on the request body.
The magic-link request accepts an optional next field — a relative path the frontend should navigate to after exchanging the link for tokens:
POST login/
{"email": "user@example.com", "next": "/dashboard"}The backend appends ?next=<url-encoded> to the link in the email. The frontend route at /login/magic/<uid>/<token>/ should read next from location.search, POST {uid, token} to login/magic/confirm/, store the JWT, then navigate to the validated next.
next must be a relative path starting with /. Absolute URLs (https://..., //evil.com), scheme-prefixed values (javascript:), and backslash-prefixed values are rejected with HTTP 400 to prevent open-redirect attacks. The field survives cross-device flows (request on phone, click on laptop) because the destination travels inside the link itself.
POST login/resend/ reissues the OTP code or magic link for an existing account. Shape mirrors login/ ({"email": "...", "next": "..."}) and the response is always 200 to avoid leaking which addresses are registered.
POST login/resend/
{"email": "user@example.com"}Notes:
- Resend never auto-creates an account, even when
LOGIN_AUTO_SIGNUP=True. Unknown emails silently no-op. - Returns 404 when
LOGIN_STRATEGY = "password". - Throttle counters are shared with
POST login/(LOGIN_THROTTLE_IP_RATE,LOGIN_THROTTLE_EMAIL_RATE), so alternating endpoints does not reset the budget. - For OTP, the new code invalidates any prior unused code for that user (the prior one stops working immediately).
- For magic links, the previously emailed link remains usable until the next successful confirm (which updates
last_loginand breaks every outstanding token at once).
A magic link is invalidated as soon as it is used: the confirm view updates last_login, which is part of the token's hash, so the same uid + token pair cannot be replayed.
A successful OTP or magic-link login automatically sets is_email_verified = True if the user's email was not yet confirmed — possession of the inbox is sufficient proof.
login/ always returns 200 even for inactive addresses, but no email is sent. The confirm endpoints reject inactive users with 401.
| Template | Strategy | Description |
|---|---|---|
bodepontoio/login_otp.html |
otp |
Login OTP code email |
bodepontoio/login_magic_link.html |
magic_link |
Login magic link email |
Context variables:
login_otp.html—{{ user }},{{ otp_code }},{{ expiry_minutes }},{{ brand_color }}login_magic_link.html—{{ user }},{{ login_url }},{{ brand_color }}
A styled HTML email (with plain-text fallback) is sent automatically when a user requests a password reset.
Django template override (no config needed): create a file at the same path inside any directory listed in TEMPLATES[0]['DIRS']:
your_project/
templates/
bodepontoio/
password_reset_email.html
| Variable | Description |
|---|---|
user |
The User instance requesting the reset |
reset_url |
Full URL the user should click to reset their password |
All responses share a consistent envelope.
Success:
{
"success": true,
"pagination": false,
"data": { ... }
}Success — empty body (e.g. logout):
{
"success": true
}Success — paginated list (any pagination class using BodePaginationMixin):
{
"success": true,
"pagination": true,
"data": { ... }
}The shape of data depends on the pagination class. For StandardPagination:
{
"success": true,
"pagination": true,
"data": {
"items": [ ... ],
"pagination": {
"page": 1,
"limit": 20,
"total": 100,
"totalPages": 5,
"hasNext": true,
"hasPrev": false
}
}
}Validation error (HTTP 400):
{
"success": false,
"type": "validation_error",
"errors": [
{"field": "email", "message": "Este campo é obrigatório."},
{"field": "non_field_errors", "message": "Credenciais inválidas."}
]
}All other errors:
{
"success": false,
"type": "authentication_error",
"error": "As credenciais de autenticação não foram fornecidas."
}Possible type values:
validation_errorauthentication_errorpermission_errornot_foundparse_errormethod_not_allowednot_acceptableunsupported_media_typethrottled
Unknown exception types fall back to the class name in snake_case.
Mixin that marks paginated responses so SuccessJSONRenderer can wrap them correctly. Any custom pagination class can opt in by inheriting from it:
from bodepontoio.pagination import BodePaginationMixin
from rest_framework.pagination import PageNumberPagination
class MyPagination(BodePaginationMixin, PageNumberPagination):
...A PageNumberPagination subclass (with BodePaginationMixin) with a consistent response shape.
from bodepontoio.pagination import StandardPagination
class MyView(ListAPIView):
pagination_class = StandardPagination| Query param | Default | Max | Description |
|---|---|---|---|
page |
1 |
— | Page number |
limit |
20 |
100 |
Items per page |
from bodepontoio.models import BaseModel, LogicDeletable
class Invoice(LogicDeletable):
...Adds created and updated (both DateTimeField, auto-managed) to any model.
Extends BaseModel with soft deletion. Fields added: excluido (bool), excluido_em (datetime), excluido_por (FK to auth.User, nullable).
# Querying
Invoice.objects.all() # live rows only (default manager)
Invoice.com_excluidos.all() # includes soft-deleted rows
# Instance operations
invoice.delete() # soft delete (excluido=True, excluido_em=now)
invoice.logic_delete(user) # soft delete, records who did it in excluido_por
invoice.reativar() # undo — clears excluido, excluido_em, excluido_porExtends Django's built-in auth.User via a OneToOneField to store auth-related state that doesn't belong on the User model itself. A UserAuth record is created automatically for every new user via a post_save signal — no manual setup required.
Access it via the auth reverse relation:
user.auth.is_email_verified # bool| Field | Type |
|---|---|
user |
OneToOneField to AUTH_USER_MODEL |
is_email_verified |
BooleanField (default: False) |
Country model with 195 pre-loaded entries (see Management Commands).
| Field | Type |
|---|---|
nome |
CharField (unique) |
capital |
CharField |
codigo_3 |
CharField (unique, 3-letter ISO) |
codigo_2 |
CharField (unique, 2-letter ISO) |
Automatically created on every user_logged_in signal via a built-in signal handler. Tracks the user and their IP address.
| Field | Type |
|---|---|
user |
FK to auth.User (nullable) |
ip |
GenericIPAddressField |
Cache for CEP lookups. Populated automatically by the CEP Service.
| Field | Type |
|---|---|
cep |
CharField (unique, formatted XXXXX-XXX) |
logradouro |
CharField |
complemento |
CharField |
bairro |
CharField |
localidade |
CharField |
uf |
CharField |
ibge |
CharField |
ddd |
CharField |
localidade_slug |
SlugField (auto-generated) |
fonte |
CharField (viacep or awesomeapi) |
Tracks images compressed by the compress_images_with_tinify command. Inherits LogicDeletable.
| Field | Type |
|---|---|
path |
CharField |
Looks up Brazilian postal codes with automatic fallback and database caching.
from bodepontoio.services.cep_service import cep_service
dados = cep_service.consultar("01001-000")
# DadosCEP(cep='01001-000', localidade='São Paulo', uf='SP', ...)
# Search cached CEPs by city slug
resultados = cep_service.buscar_por_slug("sao-paulo")Lookup order:
- Local database cache (
ConsultaCEP) - ViaCEP API
- AwesomeAPI (fallback)
Results are saved to the database automatically for future lookups.
Exceptions:
CEPInvalidoError— malformed CEPCEPNaoEncontradoError— not found in any provider
All utilities live under bodepontoio.utils.
from bodepontoio.utils.cleaners import (
file_name_cleaner, # slugify filename, preserve extension
file_extension, # extract file extension
extract_name_and_surname, # split "João Silva" → ("João", "Silva")
get_client_ip, # extract IP from request (X-Forwarded-For aware)
)from bodepontoio.utils.strings import trim_string
trim_string(" hello world ") # "hello world"from bodepontoio.utils.dates import month_to_string
month_to_string(3) # "Março"from bodepontoio.utils.numbers import grana
grana(1234.56) # "1.234,56"
grana(1000, prefixo="R$") # "R$ 1.000,00"Brazilian workday calculator with holidays from 2023 to 2028.
from bodepontoio.utils.workdays import workday, num_workdays
workday(start_date, 5) # 5 business days forward
num_workdays(start_date, end_date) # count business days in rangefrom bodepontoio.utils.email.ofuscate import obfuscate_email
obfuscate_email("user@gmail.com") # "us**@g****.com"from bodepontoio.utils.forms.fields import ValidatingEmailField
# Django form field that checks MX records (requires dnspython)from bodepontoio.utils.pagination import LastPageFixPaginator
# Paginator that returns the last page instead of raising EmptyPagefrom bodepontoio.utils.database.mysql import Round
# Django Func for ROUND(expr, 2)from bodepontoio.utils.passwords.generate_hash import (
hash_password_pbkdf2, # returns "pbkdf2$iterations$salt_hex$hash_hex"
verify_password_pbkdf2, # constant-time verification
){% load bodepontoio_tags %}
{{ valor|grana }} {# 1.234,56 #}
{{ valor|grana:"R$" }} {# R$ 1.234,56 #}
{{ valor|multiply:2 }} {# valor * 2 #}
{{ valor|roi:custo }} {# ((valor - custo) / custo) * 100 #}
{% url_replace request "page" 2 %} {# preserves other query params #}Optional Sentry metrics wrappers. Requires sentry-sdk (pip install "django-bodepontoio[sentry]").
from bodepontoio.metrics import count, distribution, gauge
count("my_event")
distribution("response_time", 0.45, unit="second")
gauge("queue_size", 42)All calls are wrapped in try/except — they silently log errors if Sentry is unavailable.
Imports 195 countries from the bundled paises.csv into the Pais model.
python manage.py bpio_importar_paisesCompresses images larger than 250 KB using TinyPNG. Requires TINYPNG_KEY in settings and the tinify extra.
pip install "django-bodepontoio[tinify]"
python manage.py compress_images_with_tinify --folders media/uploads/Already-compressed images are tracked in OptimizedImageWithTinyPNG and skipped on subsequent runs.