Metadata-Version: 2.4
Name: django-azure-sso
Version: 0.1.0
Summary: Add your description here
Requires-Python: >=3.12
Description-Content-Type: text/markdown
Requires-Dist: requests>=2.32.5

# SSO - Single Sign-On con Azure AD

Modulo de autenticacion mediante **Azure Active Directory** usando el protocolo **OAuth2/OpenID Connect (OIDC)** con
soporte **PKCE** (Proof Key for Code Exchange).

No utiliza librerias externas de autenticacion como `msal` o `django-allauth`. La implementacion es manual sobre
`requests` y los endpoints estandar de Microsoft Identity Platform.

Esta es una libreria **generica** que puede adaptarse a cualquier proyecto Django. La forma de obtener y validar
usuarios se configura via una funcion personalizada.

## Estructura del modulo

```
src/sso/
├── __init__.py
├── apps.py              # Configuracion de la app Django
├── constants.py         # Mapeo de codigos de error Azure AD → mensajes en español
├── rate_limit.py        # Rate limiting basado en cache de Django
├── services.py          # Servicio AzureADService (OAuth2/OIDC)
├── urls.py              # Rutas: /sso/login/, /sso/callback/, /sso/logout/
├── views.py             # Vistas: sso_login, sso_callback, sso_logout
├── migrations/          # Vacio - el modulo no tiene modelos propios
└── tests/
    ├── test_services.py # Tests unitarios del servicio y PKCE
    └── test_views.py    # Tests de integracion de las vistas
```

## Dependencias

| Paquete                   | Uso                                                         |
|---------------------------|-------------------------------------------------------------|
| `requests` (2.31.0)       | Llamadas HTTP a los endpoints de Azure AD (token, userinfo) |
| `django.core.cache`       | Backend de rate limiting                                    |
| `django.contrib.auth`     | Login/logout de Django                                      |
| `django.contrib.sessions` | Almacenamiento de state, code_verifier y next_url           |

No se usan `msal`, `azure-identity`, `django-allauth` ni ninguna otra libreria de autenticacion.

## Flujo de autenticacion

```
┌──────────┐     1. GET /sso/login/      ┌──────────────┐
│          │ ───────────────────────────→ │              │
│          │     2. Redirect a Azure AD   │   Servidor   │
│          │ ←─────────────────────────── │   Django     │
│          │                              │              │
│ Navegador│     3. Usuario se autentica  │              │
│          │ ───────→ Azure AD ──────────→│              │
│          │                              │              │
│          │     4. GET /sso/callback/    │              │
│          │        ?code=...&state=...   │              │
│          │ ───────────────────────────→ │              │
│          │                              │  5. Token    │
│          │                              │     exchange │
│          │                              │  6. Userinfo │
│          │     7. Redirect a home       │  7. Login    │
│          │ ←─────────────────────────── │              │
└──────────┘                              └──────────────┘
```

### Paso a paso

1. **`GET /sso/login/`** - El usuario hace clic en "Iniciar con Microsoft"
    - Se genera un `state` aleatorio (CSRF prevention)
    - Se genera un par PKCE (`code_verifier` + `code_challenge`)
    - Se guardan en la sesion: `sso_state`, `sso_code_verifier`, `sso_next`
    - Se redirige al endpoint de autorizacion de Azure AD

2. **Azure AD** - El usuario se autentica con sus credenciales de Microsoft

3. **`GET /sso/callback/?code=...&state=...`** - Azure AD redirige de vuelta
    - Se valida el `state` contra el guardado en sesion (anti-CSRF)
    - Se intercambia el `code` por tokens (POST al token endpoint con `code_verifier`)
    - Se obtiene el email del usuario desde Microsoft Graph (`/oidc/userinfo`)
    - Se busca el usuario en la base de datos usando la funcion configurada en `SSO_GET_OR_VALIDATE_USER_METHOD`
    - Se verifica que el usuario este activo
    - Se inicia sesion en Django con `login(request, user)`
    - Se redirige a la URL original o al home (`/`)

4. **`GET /sso/logout/`** - Cierra la sesion de Django
    - **No** cierra la sesion en Azure AD (el usuario sigue autenticado en Microsoft)

## Configuracion

### Variables de entorno

Agregar en el archivo `.env`:

```ini
# SSO Azure AD
SSO_ENABLED = True
AZURE_AD_TENANT_ID = xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
AZURE_AD_CLIENT_ID = xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
AZURE_AD_CLIENT_SECRET = tu-client-secret
AZURE_AD_REDIRECT_URI = https://tudominio.com/sso/callback/
AZURE_AD_AUTHORITY_URL = https://login.microsoftonline.com
AZURE_AD_USERINFO_URL = https://graph.microsoft.com/oidc/userinfo
```

| Variable                 | Requerida | Default                                     | Descripcion                                                                                                                 |
|--------------------------|-----------|---------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|
| `SSO_ENABLED`            | Si        | `False`                                     | Habilita/deshabilita el SSO. Cuando es `False`, se muestra el formulario de login tradicional ademas del boton de Microsoft |
| `AZURE_AD_TENANT_ID`     | Si        | `''`                                        | ID del tenant (directorio) de Azure AD                                                                                      |
| `AZURE_AD_CLIENT_ID`     | Si        | `''`                                        | ID de la aplicacion registrada en Azure AD                                                                                  |
| `AZURE_AD_CLIENT_SECRET` | Si        | `''`                                        | Secret de la aplicacion                                                                                                     |
| `AZURE_AD_REDIRECT_URI`  | Si        | `http://localhost:8000/sso/callback/`       | URL de callback. Debe coincidir exactamente con la configurada en Azure AD                                                  |
| `AZURE_AD_AUTHORITY_URL` | No        | `https://login.microsoftonline.com`         | URL base de autoridad de Microsoft                                                                                          |
| `AZURE_AD_USERINFO_URL`  | No        | `https://graph.microsoft.com/oidc/userinfo` | Endpoint de informacion del usuario                                                                                         |

### Configuracion en Azure AD (Portal de Azure)

1. Ir a **Azure Active Directory** → **App registrations** → **New registration**
2. Configurar:
    - **Name**: Nombre de la aplicacion (ej: `MI app con SSO`)
    - **Supported account types**: "Accounts in this organizational directory only" (Single tenant)
    - **Redirect URI**: `https://tudominio.com/sso/callback/` (tipo Web)
3. Anotar el **Application (client) ID** → `AZURE_AD_CLIENT_ID`
4. Anotar el **Directory (tenant) ID** → `AZURE_AD_TENANT_ID`
5. Ir a **Certificates & secrets** → **New client secret** → Copiar el valor → `AZURE_AD_CLIENT_SECRET`
6. Ir a **API permissions** → Agregar:
    - `openid` (delegated)
    - `profile` (delegated)
    - `email` (delegated)
7. Hacer clic en **Grant admin consent** para el tenant

### Comportamiento segun SSO_ENABLED

| `SSO_ENABLED` | Login tradicional (email/password) | Boton "Iniciar con Microsoft" |
|---------------|------------------------------------|-------------------------------|
| `True`        | Oculto                             | Visible                       |
| `False`       | Visible                            | Visible                       |

Para entornos de desarrollo, puede usarse (`SSO_ENABLED=False`), lo que disponibiliza la variuable de entorno que permite un login dual para facilitar el testing.

## Funcion de obtencion y validacion de usuarios

Esta libreria es **generica** y no asume ninguna estructura de datos especifica. La busqueda y validacion del usuario
se delegate a una funcion configurable que debe definir el proyecto que usa la libreria.

### Configuracion

En el archivo `settings.py` de tu proyecto, agrega:

```python
# SSO - Metodo para obtener/validar usuario
# Debe ser una funcion que reciba el email y retorne un usuario de Django o None
SSO_GET_OR_VALIDATE_USER_METHOD = 'myapp.sso_utils.get_or_validate_user'
```

### Funcion personalizada

La funcion debe cumplir con la siguiente firma:

```python
from django.contrib.auth import get_user_model

User = get_user_model()

def get_or_validate_user(email: str):
    """
    Busca y valida un usuario en el sistema basado en el email de Microsoft.

    Args:
        email: Email del usuario obtenido de Azure AD (campo 'email' de /oidc/userinfo)

    Returns:
        User: Instancia del usuario de Django si existe y esta activo
        None: Si el usuario no existe o no esta vinculado
    """
    try:
        # Ejemplo: buscar por campo email en modelo User
        user = User.objects.get(email__iexact=email, is_active=True)
        return user
    except User.DoesNotExist:
        return None
    except User.MultipleObjectsReturned:
        # Manejar caso de emails duplicados
        return User.objects.filter(email__iexact=email, is_active=True).first()
```

### Comportamiento esperado

1. La funcion recibe el **email** obtenido de Azure AD
2. Debe buscar el usuario en tu modelo de datos (puede ser el modelo User de Django u otro modelo)
3. Debe retornar:
   - El objeto `User` de Django si el usuario existe y esta activo
   - `None` si el usuario no existe, no esta activo, o no esta vinculado
4. El login fallara con el mensaje: *"Su cuenta de Microsoft no esta vinculada a ningun usuario del sistema."*

### Ejemplo con modelo personalizado

Si tu proyecto usa un modelo diferente para vincular usuarios (por ejemplo, `Empleado`, `Profile`, etc.):

```python
# myapp/sso_utils.py
from django.contrib.auth import get_user_model

User = get_user_model()

def get_or_validate_user(email: str):
    """Busca usuario vinculado a traves de un modelo Empleado."""
    try:
        # Suponiendo un modelo Empleado con campo 'correo'
        from myapp.models import Empleado
        empleado = Empleado.objects.get(correo__iexact=email, activo=True)
        return empleado.user  # Suponiendo que Empleado tiene ForeignKey a User
    except Empleado.DoesNotExist:
        return None
```

### Validacion adicional

Si necesitas validaciones adicionales (por ejemplo, verificar que el usuario pertenezca a un grupo especifico,
que tenga permisos ciertos, etc.), puedes incluir esa logica dentro de la funcion personalizada.

### Ejemplo con WebService externo

La funcion tambien puede realizar llamadas a un webservice externo para obtener o sincronizar el usuario:

```python
# myapp/sso_utils.py
import requests
from django.contrib.auth import get_user_model

User = get_user_model()

def get_or_validate_user(email: str):
    """
    Busca usuario via WebService externo y lo sincroniza localmente.
    """
    # Llamar al webservice externo
    response = requests.get(
        'https://mi-sistema-externo.com/api/usuarios',
        params={'email': email},
        timeout=10
    )
    
    if response.status_code != 200:
        return None
    
    usuario_externo = response.json()
    
    if not usuario_externo.get('activo'):
        return None
    
    # Sincronizar con usuario local
    user, created = User.objects.get_or_create(
        email=email,
        defaults={
            'username': usuario_externo.get('username', email.split('@')[0]),
            'first_name': usuario_externo.get('nombre', ''),
            'last_name': usuario_externo.get('apellido', ''),
            'is_active': True,
        }
    )
    
    if not created:
        # Actualizar datos si el usuario ya existe
        user.first_name = usuario_externo.get('nombre', user.first_name)
        user.last_name = usuario_externo.get('apellido', user.last_name)
        user.save()
    
    return user
```

Esto permite que la libreria se integre con sistemas externos (ERP, HR, etc.) sin acoplamiento directo a modelos de Django.

## Rate limiting

El modulo incluye proteccion contra intentos masivos de login.

| Parametro            | Valor        |
|----------------------|--------------|
| Intentos maximos     | 300          |
| Duracion del bloqueo | 5 minutos    |
| Backend              | Django cache |

La clave de rate limiting se genera combinando:

- IP del cliente (soporta `X-Forwarded-For` para proxies)
- User-Agent
- Session ID

Hasheado con SHA-256 para no exponer datos sensibles en cache.

Despues de un login exitoso, el contador se resetea automaticamente.

## Seguridad

### PKCE (RFC 7636)

Previene la interceptacion del codigo de autorizacion. Se genera un `code_verifier` aleatorio de 64 bytes y un
`code_challenge` con SHA-256. El verifier se envia solo en el token exchange, nunca al navegador.

### State token (CSRF)

Cada flujo de login genera un token `state` aleatorio de 32 bytes que se valida en el callback. Previene ataques de
replay y CSRF.

### Timeouts

Todas las llamadas HTTP a Azure AD tienen un timeout de 30 segundos para evitar bloqueos indefinidos.

### Sesiones

## Manejo de errores

Los errores de Azure AD se mapean a mensajes en español para el usuario. El mapeo esta en `constants.py`:

| Codigo          | Mensaje                                                            |
|-----------------|--------------------------------------------------------------------|
| `AADSTS50105`   | No tiene acceso a la aplicacion. Contacte al administrador.        |
| `AADSTS70043`   | Su sesion ha expirado. Intente iniciar sesion nuevamente.          |
| `AADSTS53003`   | Acceso bloqueado por politicas de seguridad.                       |
| `AADSTS65004`   | El usuario rechazo el consentimiento para acceder a la aplicacion. |
| `AADSTS700016`  | Aplicacion no encontrada en el directorio.                         |
| `access_denied` | Acceso denegado.                                                   |
| `invalid_grant` | El codigo de autorizacion ha expirado o es invalido.               |
| `server_error`  | Error del servidor. Intente nuevamente mas tarde.                  |

Cualquier error no mapeado muestra: *"Error de autenticacion. Intente nuevamente o contacte al administrador."*

Todos los errores se registran en el log con nivel `ERROR` o `WARNING`.

## URLs

| Metodo | URL              | Vista          | Descripcion                                                           |
|--------|------------------|----------------|-----------------------------------------------------------------------|
| GET    | `/sso/login/`    | `sso_login`    | Inicia el flujo OAuth2. Acepta `?next=/url/` para redirect post-login |
| GET    | `/sso/callback/` | `sso_callback` | Callback de Azure AD. Recibe `code` y `state`                         |
| GET    | `/sso/logout/`   | `sso_logout`   | Cierra sesion Django. Redirige al login                               |

Namespace: `sso`. Ejemplo en templates: `{% url 'sso:login' %}`

## Tests

Ejecutar los tests del modulo:

```bash
pytest -vvv src/sso/
```

### Cobertura de tests

**test_services.py:**

- Generacion de pares PKCE (longitud, formato, unicidad)
- Mapeo de errores Azure AD a mensajes en español
- Generacion de URL de autorizacion (con y sin PKCE)
- Intercambio de codigo por tokens (exito y error)
- Obtencion de user info
- Busqueda de usuario por email

**test_views.py:**

- Redireccion a Azure AD con parametros correctos
- Almacenamiento de state y next_url en sesion
- Manejo de errores de Azure AD en callback
- Rechazo de state invalido (CSRF)
- Rechazo de callback sin codigo de autorizacion
- Login exitoso con usuario valido
- Rechazo de usuario inexistente
- Rechazo de usuario inactivo
- Logout y redireccion
- Rate limiting (incremento y bloqueo)

## Logging

El modulo usa los siguientes loggers:

- `sso.services` - Errores de token exchange y userinfo
- `sso.views` - Errores de Azure AD, login fallidos, excepciones
- `sso.rate_limit` - Alertas de rate limiting con IP

## Notas

- El modulo **no tiene modelos de base de datos** propios. Todo el estado se maneja via sesiones y cache de Django.
- El logout solo cierra la sesion de Django. El usuario sigue autenticado en su cuenta de Microsoft. Esto es intencional
  para no interferir con otras aplicaciones del tenant.
- La libreria `requests` se usa directamente en lugar de `msal` para mantener control total sobre el flujo y reducir
  dependencias.
