28 KiB
User Model
Autenticación Django
- Sistema de autenticación built-in
- Framework para características básicas
- Registro
- Login
- Autorización
- Se integran con el panel de administración de Django
Django user model
- Es la fundación del sistema de autenticación de Django
- Django incorpora por defecto un modelo de usuario
- Utiliza un nombre de usuario en vez de un email
- No es facil de personalizar
- Creación de modelo de usuario personalizado para nuevos proyectos
- Permite el uso de email en vz de nombre de usuario
- Asegura compatibilidad del proyecto con posibles cambios del modelo usuario en versiones futuras
Creación del modelo
- Basado en la clase
AbstractBaseUser
yPermissionsMixin
- Creación de administrador personalizado
- Se establece
AUTH_USER_MODEL
ensettings.py
para utlizar este modelo - Creación y ejecución de las migraciones
AbstractBaseUser
- Proporciona las características de autenticación
- No incluye campos
PermissionsMixin
- Soporte para el sistema de permisos de Django
- Incuye todos los campos y métodos necesarios
Problemas comunes
- Correr migraciones antes de crear el modelo personalizado
- Crear el modelo personalizado primero
- Tipeo
- Indentación
User Model personalizado
Campos de usuario
- email
EmailField
- name
CharField
- is_active
BooleanField
- is_staff
BooleanField
User Model administrador
- Usado para administar objetos
- Lógica personalizada para crear objetos
- Hash passwords
- Metodos para el CLI de Django
- Create superuser
BaseUserManager
- Clase base para administrar usuarios
- Métodos útliles de ayuda
normalize_email
para almacenar emails de forma consistente
- Métodos a definir
create_user
llamado al crear usuarioscreate_superuser
usado por el CLI para crear un super usuario (admin)
Agregando Unitetst para el modelo usuario personalizado
from django.test import TestCase
from django.contrib.auth import get_user_model
class ModelTests(TestCase):
def test_create_user_with_email_sucessfull(self):
email = 'test@example.com'
password = 'testpass123'
user = get_user_model().objects.create_user(
email=email,
password=password,
)
self.assertEqual(user.email, email)
self.assertTrue(user.check_password(password))
Agregar usuario personalizado al proyecto
from django.db import models
from django.contrib.auth.models import (
AbstractBaseUser,
BaseUserManager,
PermissionsMixin,
)
class UserManager(BaseUserManager):
"""Manager for users."""
def create_user(self, email, password=None, **extra_fields):
"""Create, save and return a new user."""
user = self.model(email=email, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
class User(AbstractBaseUser, PermissionsMixin):
"""User in the system."""
email = models.EmailField(max_length=255, unique=True)
name = models.CharField(max_length=255)
is_active = models.BooleanField(default=True)
is_staff = models.BooleanField(default=False)
# Asignar el UserManager a esta clase User
objects = UserManager()
USERNAME_FIELD = 'email'
Actualizar settings.py
para que Django utilize este modelo de autenticación
agregando al final del archivo lo sgte.
...
AUTH_USER_MODEL = 'core.User'
Crear migraciones
docker compose run --rm app sh -c "python manage.py makemigrations"
[+] Creating 1/0
✔ Container recipes_api_django-db-1 Running 0.0s
Migrations for 'core':
core/migrations/0001_initial.py
- Create model User
Codigo autogenerado 0001_initial.py
para app core
Aplicar migraciones
docker compose run --rm app sh -c "python manage.py wait_for_db && python manage.py migrate"
[+] Creating 1/0
✔ Container recipes_api_django-db-1 Running 0.0s
Waiting for database...
Database available!
Operations to perform:
Apply all migrations: admin, auth, contenttypes, core, sessions
Running migrations:
Applying contenttypes.0001_initial... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0001_initial... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying auth.0008_alter_user_username_max_length... OK
Applying auth.0009_alter_user_last_name_max_length... OK
Applying auth.0010_alter_group_name_max_length... OK
Applying auth.0011_update_proxy_permissions... OK
Applying auth.0012_alter_user_first_name_max_length... OK
Applying core.0001_initial... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying admin.0003_logentry_add_action_flag_choices... OK
Applying sessions.0001_initial... OK
En caso arrojar error por haber aplicado alguna migración previa se puede correr
docker rm <db_volume>
, si "esta en uso" primero correr docker compose down
.
Los nombres se pueden ver con docker volume ls
Al correr los tests nuevamente docker compose run --rm app sh -c "python manage.py test"
[+] Creating 1/0
✔ Container recipes_api_django-db-1 Running 0.0s
Found 5 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...Waiting for database...
Database unavailable, waiting 1 second...
Database unavailable, waiting 1 second...
Database unavailable, waiting 1 second...
Database unavailable, waiting 1 second...
Database unavailable, waiting 1 second...
Database available!
.Waiting for database...
Database available!
.
----------------------------------------------------------------------
Ran 5 tests in 0.675s
OK
Destroying test database for alias 'default'...
Normalización de direcciones de email
Test normalize email addresses
def test_new_user_email_normalized(self):
"""Test email is normalized for new users."""
sample_emails = [
['test1@EXAMPLE.com', 'test1@example.com'],
['test2@Example.com', 'test2@example.com'],
['TEST3@EXAMPLE.COM', 'TEST3@example.com'],
['test4@example.COM', 'test4@example.com'],
]
for email, expected in sample_emails:
user = get_user_model().objects.create_user(email, 'sample123')
self.assertEqual(user.email, expected)
Modificar el ceate_user
de app/core/models.py
para utilizar el método normalize_email
que provee la clase BaseUserManager
- user = self.model(email=email, **extra_fields)
+ user = self.model(email=self.normalize_email(email), **extra_fields)
Requerir email
Test requerir email
def test_new_user_withouth_email_raises_error(self):
"""Test that creating a user withouth an email raises a ValueError."""
with self.assertRaises(ValueError):
get_user_model().objects.create_user('', 'test123')
Modificar el ceate_user
de app/core/models.py
y levantar excepción ValueError
si usuario no ingresa un email
def create_user(self, email, password=None, **extra_fields):
"""Create, save and return a new user."""
if not email:
raise ValueError('User must have an email address.')
user = self.model(email=self.normalize_email(email), **extra_fields)
user.set_password(password)
user.save(using=self._db)
Funcionalidad super usuario
Test creación de super usuario
def test_create_superuser(self):
"""Test creating a superuser."""
user = get_user_model().objects.create_superuser(
'test@example.com',
'test123',
)
self.assertTrue(user.is_superuser)
self.assertTrue(user.is_staff)
Creación del método create_superuser
para la clase UserManager
en
app/core/models.py
def create_superuser(self, email, password):
"""Create and return a new superuser."""
user = self.create_user(email, password)
user.is_staff = True
user.is_superuser = True
user.save(using=self._db)
return user
Probando el modelo de usuario
Correr docker compose up
y en otra terminal
docker compose run --rm app sh -c "python manage.py createsuperuser"
[+] Creating 1/0
✔ Container recipes_api_django-db-1 Running 0.0s
Email: admin@example.com
Password:
Password (again):
Superuser created successfully
Django Admin
Es la interfáz gráfica para los modelos creados en el proyecto, permite la administración basica C.R.U.D. Requiere muy poco cóidgo para ser usado
Se activa por modelo, en admin.py
Personalización del administrador
Se crea una clase basada en ModelAdmin
o UserAdmin
donde se sobrescribe o
establecen variables de clase
ejemplo
class UserAdmin(BaseUserAdmin):
"""Define de admin pages for users."""
ordering = ['id']
list_display = ['emial', 'name']
fieldsets = (
(None, {'fields': ('email', 'password')}),
)
readonly_files = ['last_login']
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': (
'email',
),
})
)
Creando test para el administrador
class AdminSiteTests(TestCase):
"""Tests for Django admin."""
def setUp(self):
"""Create user and client."""
self.client = Client()
self.admin_user = get_user_model().objects.create_superuser(
email='admin@example.com',
password='testpass123',
)
self.client.force_login(self.admin_user)
self.user = get_user_model().objects.create_user(
email='user@example.com',
password='testpass123',
name='Test User'
)
def test_users_list(self):
"""Test that users are listed on page."""
url = reverse('admin:core_user_changelist')
res = self.client.get(url)
self.assertContains(res, self.user.name)
self.assertContains(res, self.user.email)
- Django docs reversing admin urls
- Django docs testing tools
Correr test docker compose run --rm app sh -c "python manage.py test"
Activar admin para core app
En admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from core import models
class UserAdmin(BaseUserAdmin):
"""Define the admin pages for users."""
ordering = ['id']
list_display = ['email', 'name']
admin.site.register(models.User, UserAdmin)
Modificar admin para que use los campos de usuario personalizado
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.utils.translation import gettext_lazy as _
from core import models
class UserAdmin(BaseUserAdmin):
"""Define the admin pages for users."""
ordering = ['id']
list_display = ['email', 'name']
filedsets = (
(None, {'fields': ('email', 'password')}),
(
_('Permissions'),
{
'fields': (
'is_active',
'is_staff',
'is_superuser',
)
}
),
(_('Important dates', {'fields': ('last_login',)})),
)
readonly_fields = ['last_login']
admin.site.register(models.User, UserAdmin)
-
Requiere modificar pues espera campos que el modelo no tiene
Test página de creación de usuario
def test_create_user_page(self):
"""Test the create user page works."""
url = reverse('admin:core_user_add')
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
Actualizar clase UserAdmin
para que use los campos personalizados
class UserAdmin(BaseUserAdmin):
...
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': (
'email',
'password1',
'password2',
'name',
'is_active',
'is_staff',
'is_superuser',
)
}),
)
...
Documentación de la API
Es necesario tener acceso a una buena documentación para que los desarrolladores puedan saber como usarla. Se documenta todo lo que sea necesario para usar la API
- Endopoints disponibles (paths)
- Métodos soportados
GET
,POST
,PUT
,PATCH
,DELETE
... - Formateo de payloads (inputs). Parametros, Post en formato JSON
- Formateo de respuestas (outputs). Respuesta en formato JSON
- Proceso Autenticación
Opiones de documentación
- Manual
- Documento de texto
- Markdown
- Automatizada
- Usa la metadata del código (comments)
- Genera páginas de documentación
Autodocs de DRF
- Documentación Autogenerada (3rd party library)
drf-spectacular
- Genera el "schema"
- Interfaz web navegable
- Test requests
- Maneja la autenticación
Como funciona
- Creación del archivo
schema
- Pasa el schema al GUI
Open API Schema
- Estandar para describir APIs
- Popular en la industria
- Soportada por la mayoría de herramientas de documentación de API
Ejemplo Schema
fragmento
/api/recipe/ingredients/:
get:
oprationId: recipe_ingredients_list
description: Manage ingredients in the database.
parameteres:
- in: query
name: assigned_only
schema:
type: integer
enum:
- 0
- 1
description: Filter by item assigned to recipies
tags:
- recipe
security:
- tokenAuth: []
responses:
'200':
content:
application/json
schema:
type: array
items:
$ref: '#/components/schemas/Ingredient'
description: ''
...
Implementación DRF
Se agrega dependencia en drf-spectacular>=0.16
en requirements.txt
Instalar app, en settings.py
INSTALLED_APPS = [
...
'core',
'rest_framework',
'drf_spectacular',
]
...
REST_FRAMEWORK = {
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
}
Activar las URLS
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
from django.contrib import admin
from django.urls import path
urlpatterns = [
path('admin/', admin.site.urls),
path('api/schema/', SpectacularAPIView.as_view(), name='api-schema'),
path(
'api/docs',
SpectacularSwaggerView.as_view(url_name='api-schema'),
name='api-docs'),
]
docker compose run
127.0.0.1:8000/api/docs
User API
Diseño
- Registro de usario
- Creación de token de autenticación
- Consultar y actualizar perfil
Endpoins
Endpoint | Method | Descripción |
---|---|---|
user/create |
POST |
Registrar nuevo usuario |
user/token |
POST |
Crea un nuevo token |
user/me/ |
PUT/PATCH |
Actualizar perfíl |
Creación user app
docker compose run --rm app sh -c "python manage.py startapp user"
[+] Creating 1/0
✔ Container recipes_api_django-db-1 Created 0.0s
[+] Running 1/1
✔ Container recipes_api_django-db-1 Started 0.2s
Activar user
app en settings.py
Test User API
app/user/tests/test_user_api.py
CREATE_USER_URL = reverse('user:create')
def create_user(**params):
"""Create and return a new user."""
return get_user_model().objects.crate_user(**params)
class PublicUserApiTest(TestCase):
"""Test the public features of the user API."""
def setUp(self):
self.client = APIClient()
def test_create_user_success(self):
"""Tests creating a user is successful."""
payload = {
'email': 'test@example.com',
'password':'testpass123',
'name': 'TestName',
}
res = self.client.post(CREATE_USER_URL, payload)
self.assertEqual(res.status_code, status.HTTP_201_CREATED)
user = get_user_model().objects.get(email=payload['email'])
self.assertTrue(user.check_password(payload['password']))
self.assertNotIn('password', res.data)
def test_user_with_email_exists_error(self):
"""Test error returned if user with email exists."""
payload = {
'email': 'test@example.com',
'password':'testpass123',
'name': 'Test Name',
}
create_user(**payload)
res = self.client.post(CREATE_USER_URL, payload)
self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
def test_password_too_short_error(self):
"""Test an error is returned if password less than 5 chars."""
payload = {
'email': 'test@example.com',
'password':'pw',
'name': 'Test Name',
}
res = self.client.post(CREATE_USER_URL, payload)
self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
user_exists = get_user_model().objects.filter(
email=payload['email']
).exists()
self.assertFalse(user_exists)
Creando funcionalidad de User API
-
from django.contrib.auth import get_user_model from rest_framework import serializers class UserSerializer(serializers.ModelSerializer): """Seralizer for the model object.""" class Meta: model = get_user_model() fields = ['email', 'password', 'name'] extra_kwargs = {'password': {'write_only': True, 'min_length': 5}} def create(self, validated_data): """Create and return a user with encrypted password""" return get_user_model().objects.create_user(**validated_data)
-
from rest_framework import generics from user.serializers import UserSerializer class CreateUserView(generics.CreateAPIView): """Create a new user in the system.""" serializer_class = UserSerializer
-
from django.urls import path from user import views app_name='user' urlpatterns = [ path('create/', views.CreateUserView.as_view(), name='create') ]
-
... from django.urls import include, path urlpatterns = [ ... path('api/user/', include('user.urls')), ]
Autenticación
Tipo de autenticación | Descripción |
---|---|
Básica | Envía usuario y password en cada request |
Token | Usa un token en el encabezado HTTP |
JSON Web Token (JWT) | Usa un token de acceso |
Sesión | Usa cookies |
En esta app se utilza Token por:
- Balance entre simplicidad y seguridad
- Soporte por defecto por DRF
- Bién soportada por la mayoria de clientes
%%{init: {'theme': 'dark','themeVariables': {'clusterBkg': '#2b2f38'}, 'flowchart': {'curve': 'basis'}}}%%
flowchart
subgraph " "
CT["<b>Create token</b>
(Post username/password)"]
STOC["<b>Store token on client</b>"]
ITIH["<b>Include token in HTTP headers</b>"]
CT .-> STOC .-> ITIH
end
Pros del uso de Token
- Soporte por defecto
- Simple de usar
- Soportada por todos los clientes
- Evita enviar datos de usuario/password en cada request
Cons del uso de Token
- El token debe ser seguro
- Requiere hacer peticiones a la base de datos
Login out
- Sucede en el lado del cliente
- Borra el token
Test token API
Agregar tests en
app/user/tests/test_user_api.py
...
TOKEN_URL = reverse('user:token')
...
class PublicUserApiTest(TestCase):
"""Test the public features of the user API."""
...
def test_create_token_for_user(self):
"""Test generate token for valid credentials."""
user_details = {
'name': 'Test Name',
'email': 'test@example.com',
'password':'test-user-password123',
}
create_user(**user_details)
payload = {
'email': user_details['email'],
'password': user_details['password'],
}
res = self.client.post(TOKEN_URL, payload)
self.assertIn('token', res.data)
self.assertEqual(res.status_code, status.HTTP_200_OK)
def test_create_token_bad_credentials(self):
"""Test returns error if credentials invalid."""
create_user(email='test@example.com', password='goodpass')
payload = {'email': 'test@example.com' ,'password': 'badpass'}
res = self.client.post(TOKEN_URL, payload)
self.assertNotIn('token', res.data)
self.assertNotIn(res.status_code, status.HTTP_400_BAD_REQUEST)
def test_create_token_blank_password(self):
"""Test posting a blank password returns an error."""
payload = {'email': 'test@example.com' , 'password': ''}
res = self.client.post(TOKEN_URL, payload)
self.assertNotIn('token', res.data)
self.assertNotIn(res.status_code, status.HTTP_400_BAD_REQUEST)
Implementación Token API
-
Añadir app
rest_framework.authtoken
en settings.pyINSTALLED_APPS = [ ... 'rest_framework', 'rest_framework.authtoken', # <--- 'drf_spectacular', 'user', ]
Creación del serlizador para token api
-
... class AuthTokenSerializer(serializers.Serializer): """Serializer for the user auth token.""" email = serializer.EmailField() password = serializer.CharField( style={'input_type': 'password'}, trim_whitespace=False, ) def validate(self, attrs): """Validate and authenticate the user.""" email = attrs.get('email') password = attrs.get('password') user = authenticate( request=self.context.get('request'), username=email, password=password, ) if not user: msg = _('Unable to authenticate with provided credentials.') raise serializers.ValidationError(msg, code='authorization') attrs['user'] = user return attrs
-
vista user/views.py
... class CreateTokenView(ObtainAuthToken): """Create a new auth token for user.""" serializer_class = AuthTokenSerializer renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES
-
urls user/urls.py
urlpatterns = [ ... path('token/', views.CreateTokenView.as_view(), name='token'), ]
Test administrar usuario
-
... ME_URL = reverse('user:me') ... class PrivateUserApiTests(TestCase): """Test API requests that require authentication.""" def setUp(self): self.user = create_user( email='test@example.com', password='testpass123', name='Test Name', ) self.client = APIClient() self.client.force_authenticate(user=self.user) def test_retrive_profile_success(self): """Test retrieving profile for logged in user.""" res = self.client.get(ME_URL) self.assertEqual(res.status_code, status.HTTP_200_OK) self.assertEqual( res.data, { 'name': self.user.name, 'email': self.user.email, }) def test_post_me_not_allowed(self): """Test POST is not allowed for the 'me' endpoint.""" res = self.client.post(ME_URL, {}) self.assertAlmostEqual( res.status_code, status.HTTP_405_METHOD_NOT_ALLOWED ) def test_update_user_profile(self): """Test updating the user profile for the autenticated user.""" payload = { 'name': 'Updated Name', 'password': 'newpassword123' } res = self.client.patch(ME_URL, payload) self.user.refresh_from_db() self.assertEqual(self.user.name, payload['name']) self.assertTrue(self.user.check_password(payload['password'])) self.assertEqual(res.status_code, status.HTTP_200_OK)
Implementación API actualizar usuario
me
endpoint
-
creación (sobrescritura) del método update serializer.py
... class UserSerializer(serializers.ModelSerializer): ... def create(self, validated_data): """Create and return a user with encrypted password""" return get_user_model().objects.create_user(**validated_data) def update(self, instance, validated_data): """Update and return user.""" password = validated_data.pop('password', None) user = super().update(instance, validated_data) if password: user.set_password(password) user.save() return user ...
-
vistas views.py
from rest_framework import generics, authentication, permissions ... class ManageUserView(generics.RetrieveUpdateAPIView): """Manage the autenticated user.""" serializer_class = UserSerializer authentication_classes = [authentication.TokenAuthentication] permission_classes = [permissions.IsAuthenticated] def get_object(self): """Retrieve and return the authenticated user.""" return self.request.user
-
urls urls.py
urlpatterns = [ ... path('me/', views.ManageUserView.as_view(), name='me'), ]
Pruebas en navegador
Ruta localhost:8000/api/docs