user
This commit is contained in:
commit
54d5891edf
15
.editorconfig
Normal file
15
.editorconfig
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.py]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
27
.env.example
Normal file
27
.env.example
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
# ── Application ─────────────────────────────────────────────────────────────
|
||||||
|
APP_ENV=development
|
||||||
|
|
||||||
|
# ── Frontend (Next.js) ──────────────────────────────────────────────────────
|
||||||
|
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1
|
||||||
|
|
||||||
|
# ── Backend (Django) ────────────────────────────────────────────────────────
|
||||||
|
# development | production
|
||||||
|
DJANGO_ENV=development
|
||||||
|
DJANGO_SECRET_KEY=change-me-in-production
|
||||||
|
DJANGO_DEBUG=true
|
||||||
|
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0,backend,frontend
|
||||||
|
# host "postgres"/"redis" match docker-compose service names
|
||||||
|
DATABASE_URL=postgres://identity:identity@postgres:5432/identity
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:3000
|
||||||
|
CSRF_TRUSTED_ORIGINS=http://localhost:3000
|
||||||
|
|
||||||
|
# ── PostgreSQL ───────────────────────────────────────────────────────────────
|
||||||
|
POSTGRES_DB=identity
|
||||||
|
POSTGRES_USER=identity
|
||||||
|
POSTGRES_PASSWORD=identity
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
|
# ── Redis ────────────────────────────────────────────────────────────────────
|
||||||
|
REDIS_PORT=6379
|
||||||
49
.github/workflows/ci.yml
vendored
Normal file
49
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Django Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: apps/api
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: test_usermanager
|
||||||
|
POSTGRES_USER: test
|
||||||
|
POSTGRES_PASSWORD: test
|
||||||
|
ports: ["5432:5432"]
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Run Django tests
|
||||||
|
env:
|
||||||
|
DJANGO_SETTINGS_MODULE: config.settings
|
||||||
|
DATABASE_URL: postgres://test:test@localhost:5432/test_usermanager
|
||||||
|
SECRET_KEY: test-secret-key-for-testing-only
|
||||||
|
JWT_PUBLIC_KEY: ${{ secrets.JWT_PUBLIC_KEY }}
|
||||||
|
run: |
|
||||||
|
python -m pytest apps/authentication/tests.py -v
|
||||||
52
.gitignore
vendored
Normal file
52
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# Environment & secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Static files (collected)
|
||||||
|
staticfiles/
|
||||||
|
media/
|
||||||
|
|
||||||
|
# Test / coverage
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
coverage.xml
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker/postgres/data/
|
||||||
181
README.md
Normal file
181
README.md
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
# Identity Platform
|
||||||
|
|
||||||
|
**One Identity. Every Product.**
|
||||||
|
|
||||||
|
Central identity, authentication, and authorization infrastructure for your product ecosystem. Built as an independent platform — not owned by any single product.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Docker 27+ and Docker Compose
|
||||||
|
- Node 20+ (for local frontend dev)
|
||||||
|
- Python 3.12+ (for local backend dev)
|
||||||
|
|
||||||
|
### Run with Docker Compose (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy env and adjust if needed
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Build and start all services
|
||||||
|
docker compose up --build -d
|
||||||
|
|
||||||
|
# Run migrations (first time only)
|
||||||
|
docker compose exec api python manage.py migrate
|
||||||
|
|
||||||
|
# Create superuser (optional)
|
||||||
|
docker compose exec api python manage.py createsuperuser
|
||||||
|
```
|
||||||
|
|
||||||
|
**Services:**
|
||||||
|
- Frontend: http://localhost:3000
|
||||||
|
- API (DRF): http://localhost:8000/api/v1/
|
||||||
|
- API Docs (OpenAPI/Swagger): http://localhost:8000/api/docs/
|
||||||
|
- Admin: http://localhost:8000/admin/
|
||||||
|
- PostgreSQL: localhost:5432
|
||||||
|
- Redis: localhost:6379
|
||||||
|
|
||||||
|
### Local Development (Without Docker)
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
```bash
|
||||||
|
cd apps/api
|
||||||
|
python -m venv .venv && source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp ../../.env.example .env # adjust DATABASE_URL to sqlite:///dev.db for quick start
|
||||||
|
python manage.py migrate
|
||||||
|
python manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
```bash
|
||||||
|
cd apps/web
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ IDENTITY PLATFORM │
|
||||||
|
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||||
|
│ │ Users │ │ Auth │ │ Org │ │ Session │ ... │
|
||||||
|
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||||
|
│ └────────────┴────────────┴────────────┘ │
|
||||||
|
│ PRIVATE KEY (RS256) │
|
||||||
|
└────────────────────────────┬────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────┼──────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||||
|
│Bermooda │ │ Hamsoo │ │ Future │
|
||||||
|
│ (ERP) │ │ (Network)│ │ Apps │
|
||||||
|
└─────────┘ └─────────┘ └─────────┘
|
||||||
|
▲ ▲ ▲
|
||||||
|
│ PUBLIC KEY (verify only) │
|
||||||
|
└──────────────┴──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data Ownership Boundary:**
|
||||||
|
| Data | Owner |
|
||||||
|
|------|-------|
|
||||||
|
| User ID (UUID) | Identity |
|
||||||
|
| Email & Phone | Identity |
|
||||||
|
| Authentication & Sessions | Identity |
|
||||||
|
| Organizations & Memberships | Identity |
|
||||||
|
| Applications & Service Credentials | Identity |
|
||||||
|
| Employee & Payroll | Bermooda |
|
||||||
|
| Profile & Resume | Hamsoo |
|
||||||
|
| Projects & Listings | Product |
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- **OAuth 2.0 & OpenID Connect** — Standard authorize/token/refresh with PKCE
|
||||||
|
- **Sessions & Devices** — Track, revoke remotely, device detection, token rotation
|
||||||
|
- **Organizations & Memberships** — Unified org model reusable as company, team, workspace
|
||||||
|
- **Applications & Service Credentials** — Per-product `client_id`/`client_secret` with explicit ownership
|
||||||
|
- **Security Events & Audit Log** — Every login, logout, revocation recorded
|
||||||
|
- **Rate Limiting & Brute-force Protection** — Auto-lockout after failed attempts
|
||||||
|
- **RS256 Signing** — Private key never leaves platform; products verify with public key
|
||||||
|
- **OIDC Discovery** — `.well-known/openid-configuration` and JWKS endpoints
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
D:\Projects\UserManager\
|
||||||
|
├── docker/ # Dockerfiles
|
||||||
|
│ ├── Dockerfile.api # Django + Gunicorn
|
||||||
|
│ └── Dockerfile.web # Next.js multi-stage
|
||||||
|
├── docker-compose.yml # All services
|
||||||
|
├── .env.example # Environment template
|
||||||
|
├── apps/
|
||||||
|
│ ├── api/ # Django 5.2 backend
|
||||||
|
│ │ ├── config/ # Settings, URLs, WSGI/ASGI
|
||||||
|
│ │ └── apps/ # 11 modular apps
|
||||||
|
│ └── web/ # Next.js 14 frontend
|
||||||
|
│ ├── app/ # App Router pages
|
||||||
|
│ ├── components/ # React components
|
||||||
|
│ └── lib/ # Utilities, content, i18n
|
||||||
|
└── packages/
|
||||||
|
└── shared/ # Shared TS constants/types
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints (v1)
|
||||||
|
|
||||||
|
| Category | Endpoints |
|
||||||
|
|----------|-----------|
|
||||||
|
| **Auth** | `POST /auth/login`, `POST /auth/refresh`, `POST /auth/logout` |
|
||||||
|
| **Users** | `GET /users/me`, `GET /users/{id}` |
|
||||||
|
| **Organizations** | `GET /organizations`, `POST /organizations`, `GET /organizations/{id}` |
|
||||||
|
| **Memberships** | `GET /organizations/{id}/memberships`, `POST /organizations/{id}/memberships` |
|
||||||
|
| **Applications** | `GET /applications`, `POST /applications` |
|
||||||
|
| **Sessions** | `GET /sessions`, `DELETE /sessions/{id}` |
|
||||||
|
| **Security** | `GET /events` |
|
||||||
|
| **OIDC** | `GET /.well-known/openid-configuration`, `GET /.well-known/jwks.json` |
|
||||||
|
|
||||||
|
Full OpenAPI spec at `/api/docs/`.
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Key variables (see `.env.example` for full list):
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `DJANGO_SECRET_KEY` | **Required in production** |
|
||||||
|
| `DATABASE_URL` | Postgres connection string |
|
||||||
|
| `REDIS_URL` | Redis connection string |
|
||||||
|
| `CORS_ALLOWED_ORIGINS` | Frontend origin(s) |
|
||||||
|
| `NEXT_PUBLIC_SITE_NAME` | Brand name (default: "Identity Platform") |
|
||||||
|
| `NEXT_PUBLIC_API_URL` | API base URL for frontend |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
```bash
|
||||||
|
cd apps/api
|
||||||
|
python manage.py test # 21 tests
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
```bash
|
||||||
|
cd apps/web
|
||||||
|
npm run build # TypeScript + ESLint + Next build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- No "military-grade" claims — real security only
|
||||||
|
- Private RS256 key stays in Identity Platform
|
||||||
|
- Products hold only public JWKS
|
||||||
|
- Sessions and refresh tokens revocable instantly
|
||||||
|
- Rate limits per-user and per-IP
|
||||||
|
- Security events immutable audit log
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Proprietary — Independent business unit within the holding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Built with Django 5.2 + DRF, Next.js 14, PostgreSQL, Redis, Tailwind, TypeScript.
|
||||||
0
apps/api/apps/__init__.py
Normal file
0
apps/api/apps/__init__.py
Normal file
0
apps/api/apps/access/__init__.py
Normal file
0
apps/api/apps/access/__init__.py
Normal file
25
apps/api/apps/access/admin.py
Normal file
25
apps/api/apps/access/admin.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.access.models import Permission, Role, ProductAccess
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Permission)
|
||||||
|
class PermissionAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("key", "name", "category")
|
||||||
|
list_filter = ("category",)
|
||||||
|
search_fields = ("key", "name")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Role)
|
||||||
|
class RoleAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("key", "name", "is_system")
|
||||||
|
list_filter = ("is_system",)
|
||||||
|
search_fields = ("key", "name")
|
||||||
|
filter_horizontal = ("permissions",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ProductAccess)
|
||||||
|
class ProductAccessAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "organization", "product", "role", "status", "granted_at")
|
||||||
|
list_filter = ("status", "product")
|
||||||
|
search_fields = ("user__email", "organization__name", "product__name")
|
||||||
6
apps/api/apps/access/apps.py
Normal file
6
apps/api/apps/access/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AccessConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.access"
|
||||||
76
apps/api/apps/access/migrations/0001_initial.py
Normal file
76
apps/api/apps/access/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# Generated by Django 5.2.17 on 2026-08-19 08:15
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('organization', '0001_initial'),
|
||||||
|
('product', '0002_alter_product_key_alter_product_name_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Permission',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('key', models.SlugField(max_length=128, unique=True)),
|
||||||
|
('name', models.CharField(max_length=200)),
|
||||||
|
('category', models.CharField(blank=True, default='', max_length=64)),
|
||||||
|
('description', models.TextField(blank=True, default='')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['category', 'key'],
|
||||||
|
'indexes': [models.Index(fields=['category'], name='access_perm_categor_18ed2f_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Role',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('key', models.SlugField(max_length=64, unique=True)),
|
||||||
|
('name', models.CharField(max_length=200)),
|
||||||
|
('description', models.TextField(blank=True, default='')),
|
||||||
|
('is_system', models.BooleanField(default=False)),
|
||||||
|
('permissions', models.ManyToManyField(blank=True, related_name='roles', to='access.permission')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ProductAccess',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Pending'), ('active', 'Active'), ('suspended', 'Suspended'), ('revoked', 'Revoked')], default='pending', max_length=16)),
|
||||||
|
('granted_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('expires_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('revoked_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('revoked_reason', models.CharField(blank=True, default='', max_length=100)),
|
||||||
|
('granted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='granted_accesses', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('organization', models.ForeignKey(help_text='Business (کسب\u200cوکار) context', on_delete=django.db.models.deletion.CASCADE, related_name='product_accesses', to='organization.organization')),
|
||||||
|
('product', models.ForeignKey(help_text='Product (محصول) context, e.g. bermooda, hamsoo', on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='product.product')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='product_accesses', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('role', models.ForeignKey(blank=True, help_text='Role (نقش) assigned within this product', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='accesses', to='access.role')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'indexes': [models.Index(fields=['user', 'status'], name='access_prod_user_id_0de04d_idx'), models.Index(fields=['organization', 'product'], name='access_prod_organiz_8f6e71_idx'), models.Index(fields=['product', 'status'], name='access_prod_product_64a00c_idx')],
|
||||||
|
'constraints': [models.UniqueConstraint(fields=('user', 'organization', 'product'), name='unique_product_access')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/api/apps/access/migrations/__init__.py
Normal file
0
apps/api/apps/access/migrations/__init__.py
Normal file
136
apps/api/apps/access/models.py
Normal file
136
apps/api/apps/access/models.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.common.models import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Permission(BaseModel):
|
||||||
|
"""A fine-grained capability, e.g. 'bermooda:order:create'."""
|
||||||
|
|
||||||
|
key = models.SlugField(max_length=128, unique=True)
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
category = models.CharField(max_length=64, blank=True, default="")
|
||||||
|
description = models.TextField(blank=True, default="")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["category", "key"]
|
||||||
|
indexes = [models.Index(fields=["category"])]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.key}"
|
||||||
|
|
||||||
|
|
||||||
|
class Role(BaseModel):
|
||||||
|
"""A named set of permissions (نقش), e.g. 'مدیر ارشد', 'مدیر', 'کارمند'."""
|
||||||
|
|
||||||
|
key = models.SlugField(max_length=64, unique=True)
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
description = models.TextField(blank=True, default="")
|
||||||
|
is_system = models.BooleanField(default=False)
|
||||||
|
permissions = models.ManyToManyField(
|
||||||
|
Permission,
|
||||||
|
related_name="roles",
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class AccessStatus(models.TextChoices):
|
||||||
|
PENDING = "pending", "Pending"
|
||||||
|
ACTIVE = "active", "Active"
|
||||||
|
SUSPENDED = "suspended", "Suspended"
|
||||||
|
REVOKED = "revoked", "Revoked"
|
||||||
|
|
||||||
|
|
||||||
|
class ProductAccess(BaseModel):
|
||||||
|
"""Entitlement mapping a user to a product within a business (دسترسی).
|
||||||
|
|
||||||
|
Mirrors the architecture: Business -> Product -> (active access, role).
|
||||||
|
Owner is tracked separately via membership.Ownership.
|
||||||
|
"""
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
"identity.User",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="product_accesses",
|
||||||
|
)
|
||||||
|
organization = models.ForeignKey(
|
||||||
|
"organization.Organization",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="product_accesses",
|
||||||
|
help_text="Business (کسبوکار) context",
|
||||||
|
)
|
||||||
|
product = models.ForeignKey(
|
||||||
|
"product.Product",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="accesses",
|
||||||
|
help_text="Product (محصول) context, e.g. bermooda, hamsoo",
|
||||||
|
)
|
||||||
|
role = models.ForeignKey(
|
||||||
|
Role,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="accesses",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Role (نقش) assigned within this product",
|
||||||
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=16,
|
||||||
|
choices=AccessStatus.choices,
|
||||||
|
default=AccessStatus.PENDING,
|
||||||
|
)
|
||||||
|
granted_by = models.ForeignKey(
|
||||||
|
"identity.User",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="granted_accesses",
|
||||||
|
)
|
||||||
|
granted_at = models.DateTimeField(default=timezone.now)
|
||||||
|
expires_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
revoked_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
revoked_reason = models.CharField(max_length=100, blank=True, default="")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "organization", "product"],
|
||||||
|
name="unique_product_access",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "status"]),
|
||||||
|
models.Index(fields=["organization", "product"]),
|
||||||
|
models.Index(fields=["product", "status"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user} → {self.product} @ {self.organization} ({self.status})"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
return self.status == AccessStatus.ACTIVE
|
||||||
|
|
||||||
|
def activate(self):
|
||||||
|
self.status = AccessStatus.ACTIVE
|
||||||
|
self.granted_at = timezone.now()
|
||||||
|
self.save(update_fields=["status", "granted_at", "updated_at"])
|
||||||
|
|
||||||
|
def suspend(self, reason="manual"):
|
||||||
|
self.status = AccessStatus.SUSPENDED
|
||||||
|
self.revoked_reason = reason
|
||||||
|
self.save(update_fields=["status", "revoked_reason", "updated_at"])
|
||||||
|
|
||||||
|
def revoke(self, reason="manual"):
|
||||||
|
self.status = AccessStatus.REVOKED
|
||||||
|
self.revoked_at = timezone.now()
|
||||||
|
self.revoked_reason = reason
|
||||||
|
self.save(
|
||||||
|
update_fields=["status", "revoked_at", "revoked_reason", "updated_at"]
|
||||||
|
)
|
||||||
52
apps/api/apps/access/serializers.py
Normal file
52
apps/api/apps/access/serializers.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.access.models import Permission, Role, ProductAccess
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Permission
|
||||||
|
fields = ("id", "key", "name", "category", "description", "created_at")
|
||||||
|
read_only_fields = ("id", "created_at")
|
||||||
|
|
||||||
|
|
||||||
|
class RoleSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Role
|
||||||
|
fields = (
|
||||||
|
"id",
|
||||||
|
"key",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"is_system",
|
||||||
|
"permissions",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
read_only_fields = ("id", "is_system", "created_at")
|
||||||
|
|
||||||
|
|
||||||
|
class ProductAccessSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ProductAccess
|
||||||
|
fields = (
|
||||||
|
"id",
|
||||||
|
"user",
|
||||||
|
"organization",
|
||||||
|
"product",
|
||||||
|
"role",
|
||||||
|
"status",
|
||||||
|
"granted_by",
|
||||||
|
"granted_at",
|
||||||
|
"expires_at",
|
||||||
|
"revoked_at",
|
||||||
|
"revoked_reason",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
)
|
||||||
|
read_only_fields = (
|
||||||
|
"id",
|
||||||
|
"granted_at",
|
||||||
|
"revoked_at",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
)
|
||||||
30
apps/api/apps/access/services.py
Normal file
30
apps/api/apps/access/services.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
from apps.access.models import AccessStatus, ProductAccess
|
||||||
|
from apps.membership.models import Membership, MembershipStatus, Ownership
|
||||||
|
|
||||||
|
|
||||||
|
def user_has_business_access(user, organization_id, product_key):
|
||||||
|
"""Decisions 6/7: a user may operate a Business within a Product only when
|
||||||
|
they hold an active ProductAccess, are an active member, or are an owner.
|
||||||
|
|
||||||
|
``product_key`` is the Application's product key; ``organization_id`` is the
|
||||||
|
Active Business the user wants to operate in for that product.
|
||||||
|
"""
|
||||||
|
if ProductAccess.objects.filter(
|
||||||
|
user=user,
|
||||||
|
organization_id=organization_id,
|
||||||
|
product__key=product_key,
|
||||||
|
status=AccessStatus.ACTIVE,
|
||||||
|
).exists():
|
||||||
|
return True
|
||||||
|
|
||||||
|
if Membership.objects.filter(
|
||||||
|
user=user,
|
||||||
|
organization_id=organization_id,
|
||||||
|
status=MembershipStatus.ACTIVE,
|
||||||
|
).exists():
|
||||||
|
return True
|
||||||
|
|
||||||
|
if Ownership.objects.filter(organization_id=organization_id, owner=user).exists():
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
10
apps/api/apps/access/urls.py
Normal file
10
apps/api/apps/access/urls.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from apps.access.views import PermissionViewSet, RoleViewSet, ProductAccessViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"permissions", PermissionViewSet, basename="permission")
|
||||||
|
router.register(r"roles", RoleViewSet, basename="role")
|
||||||
|
router.register(r"product-access", ProductAccessViewSet, basename="product-access")
|
||||||
|
|
||||||
|
urlpatterns = router.urls
|
||||||
65
apps/api/apps/access/views.py
Normal file
65
apps/api/apps/access/views.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
from rest_framework import viewsets, permissions
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from apps.access.models import Permission, Role, ProductAccess, AccessStatus
|
||||||
|
from apps.access.serializers import (
|
||||||
|
PermissionSerializer,
|
||||||
|
RoleSerializer,
|
||||||
|
ProductAccessSerializer,
|
||||||
|
)
|
||||||
|
from apps.common.models import OutboxEvent
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Permission.objects.all()
|
||||||
|
serializer_class = PermissionSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
class RoleViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Role.objects.all()
|
||||||
|
serializer_class = RoleSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
class ProductAccessViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = ProductAccess.objects.all()
|
||||||
|
serializer_class = ProductAccessSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = super().get_queryset()
|
||||||
|
if self.request.user.is_staff:
|
||||||
|
return qs
|
||||||
|
return qs.filter(user=self.request.user)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"])
|
||||||
|
def activate(self, request, pk=None):
|
||||||
|
access = self.get_object()
|
||||||
|
access.activate()
|
||||||
|
OutboxEvent.objects.publish(
|
||||||
|
OutboxEvent.EventType.MEMBERSHIP,
|
||||||
|
user=request.user,
|
||||||
|
title="Product access activated",
|
||||||
|
metadata={"access_id": str(access.id), "product": str(access.product_id)},
|
||||||
|
)
|
||||||
|
return Response(self.get_serializer(access).data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"])
|
||||||
|
def suspend(self, request, pk=None):
|
||||||
|
access = self.get_object()
|
||||||
|
access.suspend(reason=request.data.get("reason", "manual"))
|
||||||
|
return Response(self.get_serializer(access).data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"])
|
||||||
|
def revoke(self, request, pk=None):
|
||||||
|
access = self.get_object()
|
||||||
|
access.revoke(reason=request.data.get("reason", "manual"))
|
||||||
|
OutboxEvent.objects.publish(
|
||||||
|
OutboxEvent.EventType.MEMBERSHIP,
|
||||||
|
user=request.user,
|
||||||
|
title="Product access revoked",
|
||||||
|
metadata={"access_id": str(access.id), "product": str(access.product_id)},
|
||||||
|
)
|
||||||
|
return Response(self.get_serializer(access).data)
|
||||||
0
apps/api/apps/application/__init__.py
Normal file
0
apps/api/apps/application/__init__.py
Normal file
11
apps/api/apps/application/admin.py
Normal file
11
apps/api/apps/application/admin.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.application.models import Application
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Application)
|
||||||
|
class ApplicationAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "product_key", "client_id", "status", "created_by", "created_at")
|
||||||
|
list_filter = ("status",)
|
||||||
|
search_fields = ("name", "product_key", "client_id")
|
||||||
|
readonly_fields = ("id", "client_id", "client_secret_hash", "created_at", "updated_at")
|
||||||
44
apps/api/apps/application/migrations/0001_initial.py
Normal file
44
apps/api/apps/application/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Generated by Django 5.2.17 on 2026-08-13 13:33
|
||||||
|
|
||||||
|
import apps.common.utils
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Application',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('client_id', models.CharField(default=apps.common.utils.generate_client_id, editable=False, max_length=64, unique=True)),
|
||||||
|
('client_secret_hash', models.CharField(blank=True, default='', max_length=128)),
|
||||||
|
('product_key', models.SlugField(max_length=64, unique=True)),
|
||||||
|
('name', models.CharField(max_length=200)),
|
||||||
|
('description', models.TextField(blank=True, default='')),
|
||||||
|
('website_url', models.URLField(blank=True, default='', max_length=500)),
|
||||||
|
('logo_url', models.URLField(blank=True, default='', max_length=500)),
|
||||||
|
('redirect_uris', models.JSONField(blank=True, default=list)),
|
||||||
|
('allowed_origins', models.JSONField(blank=True, default=list)),
|
||||||
|
('grant_types', models.JSONField(blank=True, default=list)),
|
||||||
|
('response_types', models.JSONField(blank=True, default=list)),
|
||||||
|
('token_endpoint_auth_method', models.CharField(default='client_secret_post', max_length=32)),
|
||||||
|
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('pending', 'Pending')], default='active', max_length=16)),
|
||||||
|
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='applications', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
33
apps/api/apps/application/migrations/0002_initial.py
Normal file
33
apps/api/apps/application/migrations/0002_initial.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Generated by Django 5.2.17 on 2026-08-13 13:33
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('application', '0001_initial'),
|
||||||
|
('oauth', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='application',
|
||||||
|
name='scopes',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='applications', to='oauth.oauthscope'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='application',
|
||||||
|
index=models.Index(fields=['client_id'], name='application_client__7986f7_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='application',
|
||||||
|
index=models.Index(fields=['product_key'], name='application_product_14a9fe_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='application',
|
||||||
|
index=models.Index(fields=['status'], name='application_status_034738_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/api/apps/application/migrations/__init__.py
Normal file
0
apps/api/apps/application/migrations/__init__.py
Normal file
66
apps/api/apps/application/models.py
Normal file
66
apps/api/apps/application/models.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from apps.common.models import BaseModel
|
||||||
|
from apps.common.utils import generate_client_id
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationStatus(models.TextChoices):
|
||||||
|
ACTIVE = "active", "Active"
|
||||||
|
INACTIVE = "inactive", "Inactive"
|
||||||
|
PENDING = "pending", "Pending"
|
||||||
|
|
||||||
|
|
||||||
|
class GrantType(models.TextChoices):
|
||||||
|
AUTHORIZATION_CODE = "authorization_code", "Authorization Code"
|
||||||
|
REFRESH_TOKEN = "refresh_token", "Refresh Token"
|
||||||
|
CLIENT_CREDENTIALS = "client_credentials", "Client Credentials"
|
||||||
|
|
||||||
|
|
||||||
|
class Application(BaseModel):
|
||||||
|
client_id = models.CharField(
|
||||||
|
max_length=64,
|
||||||
|
unique=True,
|
||||||
|
default=generate_client_id,
|
||||||
|
editable=False,
|
||||||
|
)
|
||||||
|
client_secret_hash = models.CharField(max_length=128, blank=True, default="")
|
||||||
|
product_key = models.SlugField(max_length=64, unique=True)
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
description = models.TextField(blank=True, default="")
|
||||||
|
website_url = models.URLField(max_length=500, blank=True, default="")
|
||||||
|
logo_url = models.URLField(max_length=500, blank=True, default="")
|
||||||
|
redirect_uris = models.JSONField(default=list, blank=True)
|
||||||
|
allowed_origins = models.JSONField(default=list, blank=True)
|
||||||
|
grant_types = models.JSONField(
|
||||||
|
default=list,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
response_types = models.JSONField(default=list, blank=True)
|
||||||
|
scopes = models.ManyToManyField("oauth.OAuthScope", related_name="applications", blank=True)
|
||||||
|
token_endpoint_auth_method = models.CharField(
|
||||||
|
max_length=32,
|
||||||
|
default="client_secret_post",
|
||||||
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=16,
|
||||||
|
choices=ApplicationStatus.choices,
|
||||||
|
default=ApplicationStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
created_by = models.ForeignKey(
|
||||||
|
"identity.User",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="applications",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["client_id"]),
|
||||||
|
models.Index(fields=["product_key"]),
|
||||||
|
models.Index(fields=["status"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
89
apps/api/apps/application/serializers.py
Normal file
89
apps/api/apps/application/serializers.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.application.models import Application, GrantType
|
||||||
|
from apps.common.utils import generate_client_secret, hash_token
|
||||||
|
from apps.oauth.models import OAuthScope
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthScopeSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = OAuthScope
|
||||||
|
fields = ("id", "code", "description", "is_default")
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationSerializer(serializers.ModelSerializer):
|
||||||
|
scopes = serializers.SlugRelatedField(
|
||||||
|
many=True,
|
||||||
|
read_only=True,
|
||||||
|
slug_field="code",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Application
|
||||||
|
fields = (
|
||||||
|
"id",
|
||||||
|
"client_id",
|
||||||
|
"product_key",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"website_url",
|
||||||
|
"logo_url",
|
||||||
|
"redirect_uris",
|
||||||
|
"allowed_origins",
|
||||||
|
"grant_types",
|
||||||
|
"response_types",
|
||||||
|
"token_endpoint_auth_method",
|
||||||
|
"status",
|
||||||
|
"scopes",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
)
|
||||||
|
read_only_fields = ("id", "client_id", "created_at", "updated_at")
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationCreateSerializer(serializers.ModelSerializer):
|
||||||
|
scopes = serializers.SlugRelatedField(
|
||||||
|
queryset=OAuthScope.objects.all(),
|
||||||
|
many=True,
|
||||||
|
required=False,
|
||||||
|
slug_field="code",
|
||||||
|
)
|
||||||
|
client_secret = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Application
|
||||||
|
fields = (
|
||||||
|
"client_id",
|
||||||
|
"client_secret",
|
||||||
|
"product_key",
|
||||||
|
"name",
|
||||||
|
"description",
|
||||||
|
"website_url",
|
||||||
|
"logo_url",
|
||||||
|
"redirect_uris",
|
||||||
|
"allowed_origins",
|
||||||
|
"grant_types",
|
||||||
|
"response_types",
|
||||||
|
"token_endpoint_auth_method",
|
||||||
|
"status",
|
||||||
|
"scopes",
|
||||||
|
)
|
||||||
|
read_only_fields = ("client_id",)
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
scopes = validated_data.pop("scopes", [])
|
||||||
|
raw_secret = generate_client_secret()
|
||||||
|
application = Application.objects.create(
|
||||||
|
**validated_data,
|
||||||
|
client_secret_hash=hash_token(raw_secret),
|
||||||
|
grant_types=validated_data.get(
|
||||||
|
"grant_types",
|
||||||
|
[GrantType.AUTHORIZATION_CODE, GrantType.REFRESH_TOKEN],
|
||||||
|
),
|
||||||
|
response_types=validated_data.get("response_types", ["code"]),
|
||||||
|
created_by=self.context["request"].user,
|
||||||
|
)
|
||||||
|
if scopes:
|
||||||
|
application.scopes.set(scopes)
|
||||||
|
application.client_secret = raw_secret
|
||||||
|
return application
|
||||||
41
apps/api/apps/application/tests.py
Normal file
41
apps/api/apps/application/tests.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from apps.application.models import Application, ApplicationStatus
|
||||||
|
from apps.common.utils import generate_client_id, generate_client_secret, hash_token
|
||||||
|
from apps.oauth.models import OAuthScope
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
email="dev@example.com", password="S3cure-Pass-123", status="active"
|
||||||
|
)
|
||||||
|
self.scope = OAuthScope.objects.create(code="openid", is_system=True)
|
||||||
|
|
||||||
|
def test_client_id_generated_and_secret_hashed(self):
|
||||||
|
raw_secret = generate_client_secret()
|
||||||
|
app = Application.objects.create(
|
||||||
|
product_key="bermooda",
|
||||||
|
name="Bermooda",
|
||||||
|
client_secret_hash=hash_token(raw_secret),
|
||||||
|
status=ApplicationStatus.ACTIVE,
|
||||||
|
created_by=self.user,
|
||||||
|
)
|
||||||
|
self.assertTrue(app.client_id.startswith("app_"))
|
||||||
|
self.assertNotEqual(app.client_secret_hash, raw_secret)
|
||||||
|
self.assertIsInstance(app.id, uuid.UUID)
|
||||||
|
|
||||||
|
def test_application_scopes(self):
|
||||||
|
app = Application.objects.create(
|
||||||
|
product_key="hamsoo",
|
||||||
|
name="Hamsoo",
|
||||||
|
client_secret_hash=hash_token(generate_client_secret()),
|
||||||
|
created_by=self.user,
|
||||||
|
)
|
||||||
|
app.scopes.add(self.scope)
|
||||||
|
self.assertEqual(list(app.scopes.values_list("code", flat=True)), ["openid"])
|
||||||
11
apps/api/apps/application/urls.py
Normal file
11
apps/api/apps/application/urls.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
from django.urls import include, path
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from apps.application.views import ApplicationViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"", ApplicationViewSet, basename="applications")
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", include(router.urls)),
|
||||||
|
]
|
||||||
69
apps/api/apps/application/views.py
Normal file
69
apps/api/apps/application/views.py
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
from rest_framework import status, viewsets
|
||||||
|
from rest_framework.exceptions import NotFound
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from apps.application.models import Application, ApplicationStatus
|
||||||
|
from apps.application.serializers import (
|
||||||
|
ApplicationCreateSerializer,
|
||||||
|
ApplicationSerializer,
|
||||||
|
)
|
||||||
|
from apps.common.pagination import DefaultPagination
|
||||||
|
from apps.common.permissions import IsStaffOrReadOnly
|
||||||
|
from apps.security.models import SecurityEventType, record_security_event
|
||||||
|
|
||||||
|
|
||||||
|
class ApplicationViewSet(viewsets.ModelViewSet):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
pagination_class = DefaultPagination
|
||||||
|
serializer_class = ApplicationSerializer
|
||||||
|
search_fields = ("name", "product_key", "client_id")
|
||||||
|
filterset_fields = ("status",)
|
||||||
|
ordering_fields = ("created_at", "name")
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
user = self.request.user
|
||||||
|
if user.is_staff:
|
||||||
|
return Application.objects.all()
|
||||||
|
return Application.objects.filter(status=ApplicationStatus.ACTIVE)
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.request.method in ("POST", "PATCH", "PUT"):
|
||||||
|
return ApplicationCreateSerializer
|
||||||
|
return ApplicationSerializer
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
if not request.user.is_staff:
|
||||||
|
return Response(
|
||||||
|
{"detail": "Only platform administrators can register applications."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
serializer = ApplicationCreateSerializer(
|
||||||
|
data=request.data, context={"request": request}
|
||||||
|
)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
application = serializer.save()
|
||||||
|
record_security_event(
|
||||||
|
SecurityEventType.APPLICATION_CREATED,
|
||||||
|
user=request.user,
|
||||||
|
application=application,
|
||||||
|
metadata={"product_key": application.product_key},
|
||||||
|
)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
if not request.user.is_staff:
|
||||||
|
return Response(
|
||||||
|
{"detail": "Only platform administrators can delete applications."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
application = self.get_object()
|
||||||
|
product_key = application.product_key
|
||||||
|
application.delete()
|
||||||
|
record_security_event(
|
||||||
|
SecurityEventType.APPLICATION_REVOKED,
|
||||||
|
user=request.user,
|
||||||
|
metadata={"product_key": product_key},
|
||||||
|
)
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
0
apps/api/apps/authentication/__init__.py
Normal file
0
apps/api/apps/authentication/__init__.py
Normal file
19
apps/api/apps/authentication/auth.py
Normal file
19
apps/api/apps/authentication/auth.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||||
|
|
||||||
|
from apps.authentication.services import is_token_blacklisted
|
||||||
|
|
||||||
|
|
||||||
|
class JwtBlacklistAuthentication(JWTAuthentication):
|
||||||
|
def authenticate(self, request):
|
||||||
|
result = super().authenticate(request)
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
user, token = result
|
||||||
|
if is_token_blacklisted(str(token)):
|
||||||
|
return None
|
||||||
|
# Decision 8/9: a bumped token_version (global logout / suspend)
|
||||||
|
# invalidates every outstanding access token.
|
||||||
|
token_version = token.get("token_version")
|
||||||
|
if token_version is not None and token_version != user.token_version:
|
||||||
|
return None
|
||||||
|
return user, token
|
||||||
38
apps/api/apps/authentication/migrations/0001_initial.py
Normal file
38
apps/api/apps/authentication/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Generated by Django 5.2.17 on 2026-08-15 08:17
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Credential',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('credential_type', models.CharField(choices=[('password', 'Password'), ('phone', 'Phone'), ('email', 'Email'), ('passkey', 'Passkey'), ('oauth', 'OAuth Provider')], max_length=16)),
|
||||||
|
('provider', models.CharField(blank=True, default='', max_length=128)),
|
||||||
|
('value_hash', models.CharField(blank=True, default='', max_length=128)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('is_default', models.BooleanField(default=False)),
|
||||||
|
('last_used_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('times_used', models.IntegerField(default=0)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='credentials', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-is_default', '-created_at'],
|
||||||
|
'indexes': [models.Index(fields=['user', 'credential_type'], name='authenticat_user_id_fe6d82_idx'), models.Index(fields=['user', 'is_default'], name='authenticat_user_id_1df860_idx'), models.Index(fields=['is_active'], name='authenticat_is_acti_693057_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
38
apps/api/apps/authentication/migrations/0002_passkey.py
Normal file
38
apps/api/apps/authentication/migrations/0002_passkey.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Generated by Django 5.2.17 on 2026-08-15 08:23
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('authentication', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Passkey',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('name', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('credential_id', models.CharField(max_length=255, unique=True)),
|
||||||
|
('public_key', models.TextField()),
|
||||||
|
('sign_count', models.IntegerField(default=0)),
|
||||||
|
('last_used_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('touch_enabled', models.BooleanField(default=True)),
|
||||||
|
('user_verification', models.CharField(choices=[('required', 'Required'), ('preferred', 'Preferred'), ('discouraged', 'Discouraged')], default='preferred', max_length=16)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='passkeys', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-is_active', '-created_at'],
|
||||||
|
'indexes': [models.Index(fields=['user', 'is_active'], name='authenticat_user_id_7a323d_idx'), models.Index(fields=['credential_id'], name='authenticat_credent_02748c_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
# Generated by Django 5.2.17 on 2026-08-15 08:46
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('authentication', '0002_passkey'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='RecoveryCode',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('code_hash', models.CharField(max_length=128)),
|
||||||
|
('used', models.BooleanField(default=False)),
|
||||||
|
('used_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('expires_at', models.DateTimeField()),
|
||||||
|
('purpose', models.CharField(choices=[('mfa_bypass', 'MFA Bypass'), ('account_recovery', 'Account Recovery')], default='mfa_bypass', max_length=32)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recovery_codes', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'indexes': [models.Index(fields=['user', 'used'], name='authenticat_user_id_992c7c_idx'), models.Index(fields=['expires_at'], name='authenticat_expires_c9693e_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TrustedDevice',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('fingerprint', models.CharField(max_length=128)),
|
||||||
|
('expires_at', models.DateTimeField()),
|
||||||
|
('purpose', models.CharField(choices=[('mfa_bypass', 'MFA Bypass'), ('session_remember', 'Session Remember')], default='mfa_bypass', max_length=32)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trusted_devices', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-is_active', '-created_at'],
|
||||||
|
'indexes': [models.Index(fields=['user', 'is_active'], name='authenticat_user_id_8a4165_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/api/apps/authentication/migrations/__init__.py
Normal file
0
apps/api/apps/authentication/migrations/__init__.py
Normal file
157
apps/api/apps/authentication/models.py
Normal file
157
apps/api/apps/authentication/models.py
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
from apps.common.models import BaseModel
|
||||||
|
from apps.identity.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialType(models.TextChoices):
|
||||||
|
PASSWORD = "password", "Password"
|
||||||
|
PHONE = "phone", "Phone"
|
||||||
|
EMAIL = "email", "Email"
|
||||||
|
PASSKEY = "passkey", "Passkey"
|
||||||
|
OAUTH = "oauth", "OAuth Provider"
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialStatus(models.TextChoices):
|
||||||
|
ACTIVE = "active", "Active"
|
||||||
|
INACTIVE = "inactive", "Inactive"
|
||||||
|
REVOKED = "revoked", "Revoked"
|
||||||
|
|
||||||
|
|
||||||
|
class Credential(BaseModel):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="credentials",
|
||||||
|
)
|
||||||
|
credential_type = models.CharField(
|
||||||
|
max_length=16,
|
||||||
|
choices=CredentialType.choices,
|
||||||
|
)
|
||||||
|
provider = models.CharField(max_length=128, blank=True, default="")
|
||||||
|
value_hash = models.CharField(max_length=128, blank=True, default="")
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
is_default = models.BooleanField(default=False)
|
||||||
|
last_used_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
times_used = models.IntegerField(default=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-is_default", "-created_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "credential_type"]),
|
||||||
|
models.Index(fields=["user", "is_default"]),
|
||||||
|
models.Index(fields=["is_active"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user} — {self.get_credential_type_display()}"
|
||||||
|
|
||||||
|
def mark_used(self):
|
||||||
|
self.last_used_at = timezone.now()
|
||||||
|
self.times_used += 1
|
||||||
|
self.save(update_fields=["last_used_at", "times_used", "updated_at"])
|
||||||
|
|
||||||
|
|
||||||
|
class Passkey(BaseModel):
|
||||||
|
"""WebAuthn Passkey for passwordless authentication."""
|
||||||
|
|
||||||
|
class PasskeyType(models.TextChoices):
|
||||||
|
PLATFORM = "platform", "Platform (device-bound, e.g. built-in Fingerprint/Face)"
|
||||||
|
CREDENTIAL = "credential", "Cross-Platform (roaming, e.g. YubiKey, Feitian)"
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="passkeys",
|
||||||
|
)
|
||||||
|
name = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
credential_id = models.CharField(max_length=255, unique=True)
|
||||||
|
public_key = models.TextField()
|
||||||
|
sign_count = models.IntegerField(default=0)
|
||||||
|
last_used_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
touch_enabled = models.BooleanField(default=True)
|
||||||
|
user_verification = models.CharField(
|
||||||
|
max_length=16,
|
||||||
|
choices=[("required", "Required"), ("preferred", "Preferred"), ("discouraged", "Discouraged")],
|
||||||
|
default="preferred",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-is_active", "-created_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "is_active"]),
|
||||||
|
models.Index(fields=["credential_id"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user} — {self.name or 'Passkey'}"
|
||||||
|
|
||||||
|
def mark_used(self):
|
||||||
|
self.last_used_at = timezone.now()
|
||||||
|
self.sign_count += 1
|
||||||
|
self.save(update_fields=["last_used_at", "sign_count", "updated_at"])
|
||||||
|
|
||||||
|
|
||||||
|
class RecoveryCode(BaseModel):
|
||||||
|
"""One-time recovery code for MFA bypass."""
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="recovery_codes",
|
||||||
|
)
|
||||||
|
code_hash = models.CharField(max_length=128)
|
||||||
|
used = models.BooleanField(default=False)
|
||||||
|
used_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
expires_at = models.DateTimeField()
|
||||||
|
purpose = models.CharField(
|
||||||
|
max_length=32,
|
||||||
|
choices=[("mfa_bypass", "MFA Bypass"), ("account_recovery", "Account Recovery")],
|
||||||
|
default="mfa_bypass",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "used"]),
|
||||||
|
models.Index(fields=["expires_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Recovery code for {self.user}"
|
||||||
|
|
||||||
|
def is_valid(self):
|
||||||
|
from django.utils import timezone
|
||||||
|
return not self.used and self.expires_at > timezone.now()
|
||||||
|
|
||||||
|
|
||||||
|
class TrustedDevice(BaseModel):
|
||||||
|
"""Device trusted for MFA bypass."""
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="trusted_devices",
|
||||||
|
)
|
||||||
|
fingerprint = models.CharField(max_length=128)
|
||||||
|
expires_at = models.DateTimeField()
|
||||||
|
purpose = models.CharField(
|
||||||
|
max_length=32,
|
||||||
|
choices=[("mfa_bypass", "MFA Bypass"), ("session_remember", "Session Remember")],
|
||||||
|
default="mfa_bypass",
|
||||||
|
)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-is_active", "-created_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "is_active"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Trusted device for {self.user}"
|
||||||
|
|
||||||
|
def is_valid(self):
|
||||||
|
from django.utils import timezone
|
||||||
|
return self.is_active and self.expires_at > timezone.now()
|
||||||
122
apps/api/apps/authentication/serializers.py
Normal file
122
apps/api/apps/authentication/serializers.py
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.authentication.models import Credential, Passkey
|
||||||
|
from apps.identity.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class LoginSerializer(serializers.Serializer):
|
||||||
|
email = serializers.CharField()
|
||||||
|
password = serializers.CharField(trim_whitespace=False)
|
||||||
|
product_key = serializers.CharField(
|
||||||
|
required=False, allow_null=True, allow_blank=True
|
||||||
|
)
|
||||||
|
organization_id = serializers.UUIDField(required=False, allow_null=True)
|
||||||
|
mfa_code = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshSerializer(serializers.Serializer):
|
||||||
|
refresh = serializers.CharField()
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutSerializer(serializers.Serializer):
|
||||||
|
LOGOUT_TYPE_CHOICES = (
|
||||||
|
("global", "Global SSO logout (all products)"),
|
||||||
|
("local", "Local logout (this session only)"),
|
||||||
|
)
|
||||||
|
refresh = serializers.CharField()
|
||||||
|
logout_type = serializers.ChoiceField(
|
||||||
|
choices=LOGOUT_TYPE_CHOICES,
|
||||||
|
default="global",
|
||||||
|
help_text="Global (SSO) logout is the default and terminates every "
|
||||||
|
"session/token across all products; local revokes only this session.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordSerializer(serializers.Serializer):
|
||||||
|
current_password = serializers.CharField(trim_whitespace=False)
|
||||||
|
new_password = serializers.CharField(min_length=10, trim_whitespace=False)
|
||||||
|
new_password_confirm = serializers.CharField(trim_whitespace=False)
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
if attrs["new_password"] != attrs["new_password_confirm"]:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"new_password_confirm": "Passwords do not match."}
|
||||||
|
)
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetRequestSerializer(serializers.Serializer):
|
||||||
|
email = serializers.EmailField()
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetConfirmSerializer(serializers.Serializer):
|
||||||
|
token = serializers.CharField()
|
||||||
|
new_password = serializers.CharField(min_length=10, trim_whitespace=False)
|
||||||
|
new_password_confirm = serializers.CharField(trim_whitespace=False)
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
if attrs["new_password"] != attrs["new_password_confirm"]:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"new_password_confirm": "Passwords do not match."}
|
||||||
|
)
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterSerializer(serializers.Serializer):
|
||||||
|
email = serializers.EmailField()
|
||||||
|
full_name = serializers.CharField(required=False, allow_blank=True, default="")
|
||||||
|
username = serializers.CharField(required=False, allow_blank=True, default="")
|
||||||
|
password = serializers.CharField(min_length=10, trim_whitespace=False)
|
||||||
|
password_confirm = serializers.CharField(trim_whitespace=False)
|
||||||
|
|
||||||
|
def validate_email(self, value):
|
||||||
|
if User.objects.filter(email__iexact=value).exists():
|
||||||
|
raise serializers.ValidationError("A user with this email already exists.")
|
||||||
|
return value.lower()
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
if attrs["password"] != attrs["password_confirm"]:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"password_confirm": "Passwords do not match."}
|
||||||
|
)
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeySerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Passkey
|
||||||
|
fields = (
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"credential_id",
|
||||||
|
"public_key",
|
||||||
|
"sign_count",
|
||||||
|
"last_used_at",
|
||||||
|
"is_active",
|
||||||
|
"touch_enabled",
|
||||||
|
"user_verification",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
read_only_fields = (
|
||||||
|
"id",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Credential
|
||||||
|
fields = (
|
||||||
|
"id",
|
||||||
|
"credential_type",
|
||||||
|
"provider",
|
||||||
|
"is_active",
|
||||||
|
"is_default",
|
||||||
|
"last_used_at",
|
||||||
|
"times_used",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
read_only_fields = (
|
||||||
|
"id",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
252
apps/api/apps/authentication/services.py
Normal file
252
apps/api/apps/authentication/services.py
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework_simplejwt.tokens import AccessToken
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.db.models import F
|
||||||
|
|
||||||
|
from apps.authentication.models import RecoveryCode
|
||||||
|
from apps.common.utils import client_ip, device_name, generate_token, hash_token
|
||||||
|
from apps.identity.models import User
|
||||||
|
from apps.oauth.models import AccessToken as OAuthAccessToken, RefreshToken
|
||||||
|
from apps.session.models import Session, SessionStatus, SessionType
|
||||||
|
from apps.token_blacklist.models import TokenBlacklist
|
||||||
|
|
||||||
|
|
||||||
|
def _get_jwt_algorithm():
|
||||||
|
"""Return the JWT algorithm from settings (default HS256)."""
|
||||||
|
return settings.SIMPLE_JWT.get("ALGORITHM", "HS256")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_jwt_signing_key():
|
||||||
|
"""Return the appropriate signing key based on algorithm."""
|
||||||
|
algorithm = _get_jwt_algorithm()
|
||||||
|
if algorithm in ("RS256", "RS384", "RS512"):
|
||||||
|
key_path = settings.SIMPLE_JWT.get("JWT_PRIVATE_KEY_PATH")
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
with open(key_path, "rb") as f:
|
||||||
|
return f.read()
|
||||||
|
return settings.SIMPLE_JWT["SIGNING_KEY"]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_jwt_verifying_key():
|
||||||
|
"""Return the appropriate verifying key based on algorithm."""
|
||||||
|
algorithm = _get_jwt_algorithm()
|
||||||
|
if algorithm in ("RS256", "RS384", "RS512"):
|
||||||
|
key_path = settings.SIMPLE_JWT.get("JWT_PUBLIC_KEY_PATH")
|
||||||
|
if key_path and os.path.exists(key_path):
|
||||||
|
with open(key_path, "rb") as f:
|
||||||
|
return f.read()
|
||||||
|
return settings.SIMPLE_JWT["SIGNING_KEY"]
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(user, product_key=None, organization_id=None, scopes=None):
|
||||||
|
from apps.verification.services import TrustEngine
|
||||||
|
from apps.verification.models import TrustState
|
||||||
|
|
||||||
|
algorithm = _get_jwt_algorithm()
|
||||||
|
signing_key = _get_jwt_signing_key()
|
||||||
|
verifying_key = _get_jwt_verifying_key()
|
||||||
|
|
||||||
|
trust_state = TrustEngine.compute_trust_state(user)
|
||||||
|
trust_score = TrustEngine.get_trust_score(user)
|
||||||
|
amr = list(
|
||||||
|
TrustEngine.get_person_evidence(user).values_list("evidence_type", flat=True)
|
||||||
|
) or ["pwd"]
|
||||||
|
|
||||||
|
access = AccessToken()
|
||||||
|
access.payload.update(
|
||||||
|
{
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"user_status": user.status,
|
||||||
|
"identity_key": user.identity_key,
|
||||||
|
"token_type": "access",
|
||||||
|
"trust_state": trust_state,
|
||||||
|
"trust_score": trust_score,
|
||||||
|
"acr": f"urn:openid:params:acr:{trust_state}",
|
||||||
|
"amr": amr,
|
||||||
|
"token_version": user.token_version,
|
||||||
|
"scope": " ".join(scopes) if scopes else "openid",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if product_key:
|
||||||
|
access.payload["product_key"] = product_key
|
||||||
|
if organization_id:
|
||||||
|
access.payload["organization_id"] = str(organization_id)
|
||||||
|
return access
|
||||||
|
|
||||||
|
|
||||||
|
def create_auth_tokens(
|
||||||
|
user, request, application=None, product_key=None, organization_id=None
|
||||||
|
):
|
||||||
|
refresh_lifetime = settings.SIMPLE_JWT["REFRESH_TOKEN_LIFETIME"]
|
||||||
|
user_agent = request.META.get("HTTP_USER_AGENT", "")[:500]
|
||||||
|
|
||||||
|
session = Session.objects.create(
|
||||||
|
user=user,
|
||||||
|
application=application,
|
||||||
|
session_type=SessionType.BROWSER,
|
||||||
|
ip_address=client_ip(request),
|
||||||
|
user_agent=user_agent,
|
||||||
|
device_name=device_name(user_agent),
|
||||||
|
expires_at=timezone.now() + refresh_lifetime,
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_refresh = generate_token()
|
||||||
|
RefreshToken.objects.create(
|
||||||
|
user=user,
|
||||||
|
application=application,
|
||||||
|
session=session,
|
||||||
|
token_hash=hash_token(raw_refresh),
|
||||||
|
expires_at=session.expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
access = create_access_token(
|
||||||
|
user, product_key=product_key, organization_id=organization_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access": str(access),
|
||||||
|
"refresh": raw_refresh,
|
||||||
|
"expires_in": int(settings.SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"].total_seconds()),
|
||||||
|
"token_type": "bearer",
|
||||||
|
"session_id": str(session.id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_refresh_tokens(user, session, application=None):
|
||||||
|
RefreshToken.objects.filter(session=session, revoked=False).update(
|
||||||
|
revoked=True,
|
||||||
|
replaced_by="rotated",
|
||||||
|
)
|
||||||
|
raw_refresh = generate_token()
|
||||||
|
new_token = RefreshToken.objects.create(
|
||||||
|
user=user,
|
||||||
|
application=application,
|
||||||
|
session=session,
|
||||||
|
token_hash=hash_token(raw_refresh),
|
||||||
|
expires_at=timezone.now() + settings.SIMPLE_JWT["REFRESH_TOKEN_LIFETIME"],
|
||||||
|
)
|
||||||
|
return raw_refresh, new_token
|
||||||
|
|
||||||
|
|
||||||
|
def blacklist_token(token, token_type="access"):
|
||||||
|
"""Add token to blacklist for instant revocation checking."""
|
||||||
|
token_hash = hash_token(token)
|
||||||
|
access_lifetime = settings.SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"]
|
||||||
|
expires_at = timezone.now() + access_lifetime
|
||||||
|
TokenBlacklist.objects.update_or_create(
|
||||||
|
token_hash=token_hash,
|
||||||
|
defaults={
|
||||||
|
"token_type": token_type,
|
||||||
|
"expires_at": expires_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_token_blacklisted(token):
|
||||||
|
"""Check if token is in blacklist."""
|
||||||
|
token_hash = hash_token(token)
|
||||||
|
try:
|
||||||
|
entry = TokenBlacklist.objects.get(token_hash=token_hash)
|
||||||
|
return not entry.is_expired
|
||||||
|
except TokenBlacklist.DoesNotExist:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _flush_django_sessions(user):
|
||||||
|
"""Delete every Django browser session (the ``sessionid`` SSO cookie) for
|
||||||
|
the user so a suspended or globally-logged-out account can no longer use
|
||||||
|
single sign-on on any device."""
|
||||||
|
from django.contrib.sessions.models import Session as DjangoSession
|
||||||
|
|
||||||
|
target = str(user.id)
|
||||||
|
for session in DjangoSession.objects.all():
|
||||||
|
try:
|
||||||
|
if session.get_decoded().get("_auth_user_id") == target:
|
||||||
|
session.delete()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_all_user_sessions(user, reason="global_logout"):
|
||||||
|
"""Global (SSO) termination: revoke every active session, refresh token
|
||||||
|
and OAuth access token for the user, then bump ``token_version`` so any
|
||||||
|
outstanding stateless JWT access tokens are rejected on next use.
|
||||||
|
|
||||||
|
Used by global logout (decision 8) and by suspend enforcement (decision 9).
|
||||||
|
"""
|
||||||
|
now = timezone.now()
|
||||||
|
Session.objects.filter(user=user, status=SessionStatus.ACTIVE).update(
|
||||||
|
status=SessionStatus.REVOKED,
|
||||||
|
revoked_at=now,
|
||||||
|
revoked_reason=reason,
|
||||||
|
)
|
||||||
|
RefreshToken.objects.filter(user=user, revoked=False).update(
|
||||||
|
revoked=True,
|
||||||
|
replaced_by="terminated",
|
||||||
|
)
|
||||||
|
OAuthAccessToken.objects.filter(user=user, revoked=False).update(revoked=True)
|
||||||
|
_flush_django_sessions(user)
|
||||||
|
User.objects.filter(pk=user.pk).update(token_version=F("token_version") + 1)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_recovery_codes(user, count=10, ttl_days=365):
|
||||||
|
"""Generate a fresh set of one-time MFA recovery codes (Google-style).
|
||||||
|
|
||||||
|
Any previously unused codes are invalidated. Returns the plaintext codes
|
||||||
|
(shown to the user once); only their hashes are persisted.
|
||||||
|
"""
|
||||||
|
RECOVERY_PURPOSE = "mfa_bypass"
|
||||||
|
RecoveryCode.objects.filter(
|
||||||
|
user=user, purpose=RECOVERY_PURPOSE, used=False
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
expires_at = now + timedelta(days=ttl_days)
|
||||||
|
plaintext = []
|
||||||
|
for _ in range(count):
|
||||||
|
raw = secrets.token_hex(4).upper()
|
||||||
|
code = f"{raw[:4]}-{raw[4:]}"
|
||||||
|
RecoveryCode.objects.create(
|
||||||
|
user=user,
|
||||||
|
purpose=RECOVERY_PURPOSE,
|
||||||
|
code_hash=hash_token(code),
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
plaintext.append(code)
|
||||||
|
return plaintext
|
||||||
|
|
||||||
|
|
||||||
|
def count_remaining_recovery_codes(user):
|
||||||
|
RECOVERY_PURPOSE = "mfa_bypass"
|
||||||
|
return RecoveryCode.objects.filter(
|
||||||
|
user=user,
|
||||||
|
purpose=RECOVERY_PURPOSE,
|
||||||
|
used=False,
|
||||||
|
expires_at__gt=timezone.now(),
|
||||||
|
).count()
|
||||||
|
|
||||||
|
|
||||||
|
def redeem_recovery_code(user, code):
|
||||||
|
"""Consume a single recovery code. Returns True if it was valid + unused."""
|
||||||
|
RECOVERY_PURPOSE = "mfa_bypass"
|
||||||
|
if not code:
|
||||||
|
return False
|
||||||
|
normalized = code.strip().upper()
|
||||||
|
code_hash = hash_token(normalized)
|
||||||
|
rc = RecoveryCode.objects.filter(
|
||||||
|
user=user,
|
||||||
|
purpose=RECOVERY_PURPOSE,
|
||||||
|
code_hash=code_hash,
|
||||||
|
used=False,
|
||||||
|
expires_at__gt=timezone.now(),
|
||||||
|
).first()
|
||||||
|
if not rc:
|
||||||
|
return False
|
||||||
|
rc.used = True
|
||||||
|
rc.used_at = timezone.now()
|
||||||
|
rc.save(update_fields=["used", "used_at", "updated_at"])
|
||||||
|
return True
|
||||||
873
apps/api/apps/authentication/tests.py
Normal file
873
apps/api/apps/authentication/tests.py
Normal file
|
|
@ -0,0 +1,873 @@
|
||||||
|
import cbor2
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ec
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from fido2.utils import websafe_decode, websafe_encode
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from rest_framework_simplejwt.tokens import AccessToken
|
||||||
|
|
||||||
|
from apps.common.utils import generate_token, hash_token
|
||||||
|
from apps.oauth.models import AccessToken as OAuthAccessToken, RefreshToken
|
||||||
|
from apps.security.models import SecurityEvent
|
||||||
|
from apps.session.models import Session, SessionStatus
|
||||||
|
from apps.token_blacklist.models import TokenBlacklist
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationFlowTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
email="flow@example.com",
|
||||||
|
password="S3cure-Pass-123",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
def test_login_success_returns_tokens(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{"email": "flow@example.com", "password": "S3cure-Pass-123"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn("access", response.data)
|
||||||
|
self.assertIn("refresh", response.data)
|
||||||
|
self.assertEqual(response.data["token_type"], "bearer")
|
||||||
|
self.assertTrue(
|
||||||
|
Session.objects.filter(user=self.user, status="active").exists()
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
SecurityEvent.objects.filter(
|
||||||
|
user=self.user, event_type="login_success"
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_login_populates_credential_and_publishes_event(self):
|
||||||
|
from apps.authentication.models import Credential, CredentialType
|
||||||
|
from apps.common.models import OutboxEvent
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{"email": "flow@example.com", "password": "S3cure-Pass-123"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
# FIX 4: a password credential record now exists for the user
|
||||||
|
self.assertTrue(
|
||||||
|
Credential.objects.filter(
|
||||||
|
user=self.user, credential_type=CredentialType.PASSWORD
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
# FIX 3: the outbox event bus actually fired and recorded an event
|
||||||
|
self.assertTrue(
|
||||||
|
OutboxEvent.objects.filter(event_type=OutboxEvent.EventType.AUTH).exists()
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
self.user.notifications.filter(
|
||||||
|
event_type=OutboxEvent.EventType.AUTH
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_login_wrong_password_records_event(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{"email": "flow@example.com", "password": "Wrong-Pass-123"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
self.assertTrue(
|
||||||
|
SecurityEvent.objects.filter(
|
||||||
|
user=self.user, event_type="login_failed"
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_refresh_rotation(self):
|
||||||
|
login = self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{"email": "flow@example.com", "password": "S3cure-Pass-123"},
|
||||||
|
)
|
||||||
|
refresh = login.data["refresh"]
|
||||||
|
first = RefreshToken.objects.get(token_hash=hash_token(refresh))
|
||||||
|
|
||||||
|
response = self.client.post(reverse("auth-refresh"), {"refresh": refresh})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn("access", response.data)
|
||||||
|
self.assertNotEqual(response.data["refresh"], refresh)
|
||||||
|
|
||||||
|
first.refresh_from_db()
|
||||||
|
self.assertTrue(first.revoked)
|
||||||
|
|
||||||
|
def test_logout_revokes_session(self):
|
||||||
|
login = self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{"email": "flow@example.com", "password": "S3cure-Pass-123"},
|
||||||
|
)
|
||||||
|
refresh = login.data["refresh"]
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {login.data['access']}")
|
||||||
|
response = self.client.post(reverse("auth-logout"), {"refresh": refresh})
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
self.assertTrue(
|
||||||
|
Session.objects.filter(user=self.user, status="revoked").exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rate_limiting_locks_login(self):
|
||||||
|
for i in range(5):
|
||||||
|
self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{"email": "flow@example.com", "password": "Wrong-Pass-123"},
|
||||||
|
)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{"email": "flow@example.com", "password": "Wrong-Pass-123"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 429)
|
||||||
|
self.assertTrue(
|
||||||
|
SecurityEvent.objects.filter(event_type="login_locked").exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_authenticated_endpoint_requires_jwt(self):
|
||||||
|
response = self.client.get(reverse("identity-summary"))
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
|
login = self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{"email": "flow@example.com", "password": "S3cure-Pass-123"},
|
||||||
|
)
|
||||||
|
token = login.data["access"]
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||||
|
response = self.client.get(reverse("identity-summary"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["user"]["email"], "flow@example.com")
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutTypeTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
email="logout@example.com",
|
||||||
|
password="S3cure-Pass-123",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.other = User.objects.create_user(
|
||||||
|
email="other@example.com",
|
||||||
|
password="S3cure-Pass-123",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
def _login(self, user):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{"email": user.email, "password": "S3cure-Pass-123"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200, response.data)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def test_global_logout_revokes_all_sessions(self):
|
||||||
|
# Two separate sessions/products for the same user.
|
||||||
|
s1 = self._login(self.user)
|
||||||
|
s2 = self._login(self.user)
|
||||||
|
self.assertEqual(
|
||||||
|
Session.objects.filter(user=self.user, status=SessionStatus.ACTIVE).count(),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {s1.data['access']}")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("auth-logout"),
|
||||||
|
{"refresh": s1.data["refresh"], "logout_type": "global"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
self.assertFalse(
|
||||||
|
Session.objects.filter(user=self.user, status=SessionStatus.ACTIVE).exists()
|
||||||
|
)
|
||||||
|
# The other session's refresh token is also revoked.
|
||||||
|
other_refresh = RefreshToken.objects.get(
|
||||||
|
token_hash=hash_token(s2.data["refresh"])
|
||||||
|
)
|
||||||
|
self.assertTrue(other_refresh.revoked)
|
||||||
|
|
||||||
|
def test_local_logout_only_revokes_current_session(self):
|
||||||
|
s1 = self._login(self.user)
|
||||||
|
s2 = self._login(self.user)
|
||||||
|
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {s1.data['access']}")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("auth-logout"),
|
||||||
|
{"refresh": s1.data["refresh"], "logout_type": "local"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
# The untouched session remains active.
|
||||||
|
self.assertTrue(
|
||||||
|
Session.objects.filter(user=self.user, status=SessionStatus.ACTIVE).exists()
|
||||||
|
)
|
||||||
|
other_refresh = RefreshToken.objects.get(
|
||||||
|
token_hash=hash_token(s2.data["refresh"])
|
||||||
|
)
|
||||||
|
self.assertFalse(other_refresh.revoked)
|
||||||
|
|
||||||
|
def test_global_logout_invalidates_outstanding_access_token(self):
|
||||||
|
s1 = self._login(self.user)
|
||||||
|
s2 = self._login(self.user)
|
||||||
|
old_access = s2.data["access"]
|
||||||
|
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {s1.data['access']}")
|
||||||
|
self.client.post(
|
||||||
|
reverse("auth-logout"),
|
||||||
|
{"refresh": s1.data["refresh"], "logout_type": "global"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# The access token issued before global logout must now be rejected.
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {old_access}")
|
||||||
|
response = self.client.get(reverse("identity-summary"))
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
|
|
||||||
|
class SuspendEnforcementTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
email="suspend@example.com",
|
||||||
|
password="S3cure-Pass-123",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
def test_suspend_revokes_all_sessions_and_tokens(self):
|
||||||
|
login = self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{"email": "suspend@example.com", "password": "S3cure-Pass-123"},
|
||||||
|
)
|
||||||
|
self.assertEqual(login.status_code, 200)
|
||||||
|
access = login.data["access"]
|
||||||
|
refresh = login.data["refresh"]
|
||||||
|
|
||||||
|
# Suspend the user -> decision 9: kill everything.
|
||||||
|
self.user.status = "suspended"
|
||||||
|
self.user.save()
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
Session.objects.filter(user=self.user, status=SessionStatus.ACTIVE).exists()
|
||||||
|
)
|
||||||
|
rt = RefreshToken.objects.get(token_hash=hash_token(refresh))
|
||||||
|
self.assertTrue(rt.revoked)
|
||||||
|
|
||||||
|
# Outstanding access token is now rejected via token_version bump.
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access}")
|
||||||
|
response = self.client.get(reverse("identity-summary"))
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
|
|
||||||
|
class ActiveContextJwtTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
email="context@example.com",
|
||||||
|
password="S3cure-Pass-123",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
def _login(self, **extra):
|
||||||
|
payload = {
|
||||||
|
"email": "context@example.com",
|
||||||
|
"password": "S3cure-Pass-123",
|
||||||
|
}
|
||||||
|
payload.update(extra)
|
||||||
|
response = self.client.post(reverse("auth-login"), payload)
|
||||||
|
self.assertEqual(response.status_code, 200, response.data)
|
||||||
|
return AccessToken(response.data["access"])
|
||||||
|
|
||||||
|
def test_jwt_contains_identity_claims(self):
|
||||||
|
token = self._login()
|
||||||
|
self.assertEqual(token["user_id"], str(self.user.id))
|
||||||
|
self.assertEqual(token["user_status"], "active")
|
||||||
|
self.assertEqual(token["identity_key"], self.user.identity_key)
|
||||||
|
|
||||||
|
def test_jwt_with_product_key(self):
|
||||||
|
token = self._login(product_key="bermooda")
|
||||||
|
self.assertEqual(token["product_key"], "bermooda")
|
||||||
|
|
||||||
|
def test_jwt_with_organization_id(self):
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
org_id = uuid.uuid4()
|
||||||
|
token = self._login(organization_id=str(org_id))
|
||||||
|
self.assertEqual(token["organization_id"], str(org_id))
|
||||||
|
|
||||||
|
def test_jwt_without_product_key_omits_claim(self):
|
||||||
|
token = self._login()
|
||||||
|
self.assertNotIn("product_key", token)
|
||||||
|
|
||||||
|
def test_jwt_without_organization_omits_claim(self):
|
||||||
|
token = self._login()
|
||||||
|
self.assertNotIn("organization_id", token)
|
||||||
|
|
||||||
|
|
||||||
|
class BlacklistedTokenRejectionTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
email="bl@example.com",
|
||||||
|
password="S3cure-Pass-123",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
def test_blacklisted_access_token_rejected(self):
|
||||||
|
login = self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{"email": "bl@example.com", "password": "S3cure-Pass-123"},
|
||||||
|
)
|
||||||
|
access = login.data["access"]
|
||||||
|
refresh = login.data["refresh"]
|
||||||
|
|
||||||
|
self.client.post(
|
||||||
|
reverse("auth-logout"),
|
||||||
|
{"refresh": refresh},
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {access}",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access}")
|
||||||
|
response = self.client.get(reverse("identity-summary"))
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
|
def test_non_blacklisted_token_still_works(self):
|
||||||
|
login = self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{"email": "bl@example.com", "password": "S3cure-Pass-123"},
|
||||||
|
)
|
||||||
|
access = login.data["access"]
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access}")
|
||||||
|
response = self.client.get(reverse("identity-summary"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetFlowTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
email="reset@example.com",
|
||||||
|
password="S3cure-Pass-123",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
def test_reset_request_existing_email(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("auth-password-reset-request"),
|
||||||
|
{"email": "reset@example.com"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn("reset link has been sent", response.data["detail"])
|
||||||
|
|
||||||
|
def test_reset_request_nonexistent_email(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("auth-password-reset-request"),
|
||||||
|
{"email": "nonexistent@example.com"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_reset_confirm_invalid_token(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("auth-password-reset-confirm"),
|
||||||
|
{
|
||||||
|
"token": "invalid-token",
|
||||||
|
"new_password": "New-Pass-12345",
|
||||||
|
"new_password_confirm": "New-Pass-12345",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_reset_confirm_password_mismatch(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("auth-password-reset-confirm"),
|
||||||
|
{
|
||||||
|
"token": "some-token",
|
||||||
|
"new_password": "New-Pass-12345",
|
||||||
|
"new_password_confirm": "Different-Pass-12345",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_full_reset_flow(self):
|
||||||
|
self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{"email": "reset@example.com", "password": "S3cure-Pass-123"},
|
||||||
|
)
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from apps.common.utils import hash_token
|
||||||
|
|
||||||
|
raw_token = secrets.token_urlsafe(32)
|
||||||
|
token_hash = hash_token(raw_token)
|
||||||
|
expiry = timezone.now() + timezone.timedelta(hours=1)
|
||||||
|
User.objects.filter(pk=self.user.pk).update(
|
||||||
|
password_reset_token=token_hash,
|
||||||
|
password_reset_expires=expiry,
|
||||||
|
)
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertTrue(self.user.password_reset_token)
|
||||||
|
|
||||||
|
new_password = "BrandNew-Pass-99"
|
||||||
|
|
||||||
|
confirm = self.client.post(
|
||||||
|
reverse("auth-password-reset-confirm"),
|
||||||
|
{
|
||||||
|
"token": raw_token,
|
||||||
|
"new_password": new_password,
|
||||||
|
"new_password_confirm": new_password,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(confirm.status_code, 200)
|
||||||
|
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertTrue(self.user.check_password(new_password))
|
||||||
|
self.assertEqual(self.user.password_reset_token, "")
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
Session.objects.filter(user=self.user, status="revoked").exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reset_confirm_clears_active_sessions(self):
|
||||||
|
login = self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{"email": "reset@example.com", "password": "S3cure-Pass-123"},
|
||||||
|
)
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from apps.common.utils import hash_token
|
||||||
|
|
||||||
|
raw_token = secrets.token_urlsafe(32)
|
||||||
|
token_hash = hash_token(raw_token)
|
||||||
|
expiry = timezone.now() + timezone.timedelta(hours=1)
|
||||||
|
User.objects.filter(pk=self.user.pk).update(
|
||||||
|
password_reset_token=token_hash,
|
||||||
|
password_reset_expires=expiry,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.post(
|
||||||
|
reverse("auth-password-reset-confirm"),
|
||||||
|
{
|
||||||
|
"token": raw_token,
|
||||||
|
"new_password": "NewPassword-99",
|
||||||
|
"new_password_confirm": "NewPassword-99",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
active_sessions = Session.objects.filter(user=self.user, status="active")
|
||||||
|
self.assertFalse(active_sessions.exists())
|
||||||
|
|
||||||
|
|
||||||
|
class PasskeyWebAuthnTests(TestCase):
|
||||||
|
"""FIX 1: passkey registration/authentication must perform REAL WebAuthn
|
||||||
|
cryptographic verification, not just accept any client-supplied id."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
email="passkey@example.com",
|
||||||
|
password="S3cure-Pass-123",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.client = APIClient()
|
||||||
|
self.client.force_authenticate(self.user)
|
||||||
|
|
||||||
|
def _make_credential(self):
|
||||||
|
from fido2.cose import ES256 as CoseES256
|
||||||
|
from fido2.webauthn import Aaguid, AttestedCredentialData
|
||||||
|
|
||||||
|
priv = ec.generate_private_key(ec.SECP256R1())
|
||||||
|
cose = CoseES256.from_cryptography_key(priv.public_key())
|
||||||
|
cred_id = os.urandom(16)
|
||||||
|
acd = AttestedCredentialData.create(Aaguid.NONE, cred_id, cose)
|
||||||
|
return priv, cred_id, acd
|
||||||
|
|
||||||
|
def _reg_response(self, challenge, cred_id, acd):
|
||||||
|
from fido2.webauthn import AttestationObject, CollectedClientData
|
||||||
|
|
||||||
|
ridh = hashlib.sha256(b"localhost").digest()
|
||||||
|
auth_data = ridh + bytes([0x41]) + (0).to_bytes(4, "big") + bytes(acd)
|
||||||
|
att = AttestationObject(
|
||||||
|
cbor2.dumps({"fmt": "none", "authData": auth_data, "attStmt": {}})
|
||||||
|
)
|
||||||
|
cd = CollectedClientData(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "webauthn.create",
|
||||||
|
"challenge": websafe_encode(challenge),
|
||||||
|
"origin": "http://localhost:3000",
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": websafe_encode(cred_id),
|
||||||
|
"rawId": websafe_encode(cred_id),
|
||||||
|
"response": {
|
||||||
|
"clientDataJSON": websafe_encode(bytes(cd)),
|
||||||
|
"attestationObject": websafe_encode(bytes(att)),
|
||||||
|
},
|
||||||
|
"type": "public-key",
|
||||||
|
"clientExtensionResults": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _auth_response(self, challenge, cred_id, priv, counter=1):
|
||||||
|
from fido2.webauthn import CollectedClientData
|
||||||
|
|
||||||
|
ridh = hashlib.sha256(b"localhost").digest()
|
||||||
|
auth_data = ridh + bytes([0x01]) + counter.to_bytes(4, "big")
|
||||||
|
cd = CollectedClientData(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "webauthn.get",
|
||||||
|
"challenge": websafe_encode(challenge),
|
||||||
|
"origin": "http://localhost:3000",
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
)
|
||||||
|
sig = priv.sign(
|
||||||
|
auth_data + hashlib.sha256(bytes(cd)).digest(),
|
||||||
|
ec.ECDSA(hashes.SHA256()),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": websafe_encode(cred_id),
|
||||||
|
"rawId": websafe_encode(cred_id),
|
||||||
|
"response": {
|
||||||
|
"clientDataJSON": websafe_encode(bytes(cd)),
|
||||||
|
"authenticatorData": websafe_encode(auth_data),
|
||||||
|
"signature": websafe_encode(sig),
|
||||||
|
},
|
||||||
|
"type": "public-key",
|
||||||
|
"clientExtensionResults": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_register_and_authenticate_with_real_webauthn(self):
|
||||||
|
from apps.authentication.models import Passkey
|
||||||
|
|
||||||
|
# --- Registration ---
|
||||||
|
begin = self.client.post(reverse("passkey-register-begin"))
|
||||||
|
self.assertEqual(begin.status_code, 200)
|
||||||
|
state = self.client.session["passkey_register_state"]
|
||||||
|
challenge = websafe_decode(state["challenge"])
|
||||||
|
|
||||||
|
priv, cred_id, acd = self._make_credential()
|
||||||
|
reg = self.client.post(
|
||||||
|
reverse("passkey-register-finish"),
|
||||||
|
self._reg_response(challenge, cred_id, acd),
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(reg.status_code, 201, reg.data)
|
||||||
|
self.assertTrue(Passkey.objects.filter(user=self.user).exists())
|
||||||
|
|
||||||
|
# --- Authentication (passwordless) ---
|
||||||
|
self.client.force_authenticate(None)
|
||||||
|
begin_auth = self.client.post(
|
||||||
|
reverse("passkey-authenticate-begin"),
|
||||||
|
{"email": "passkey@example.com"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(begin_auth.status_code, 200)
|
||||||
|
state2 = self.client.session["passkey_auth_state"]
|
||||||
|
challenge2 = websafe_decode(state2["challenge"])
|
||||||
|
|
||||||
|
fin = self.client.post(
|
||||||
|
reverse("passkey-authenticate-finish"),
|
||||||
|
self._auth_response(challenge2, cred_id, priv),
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(fin.status_code, 200, fin.data)
|
||||||
|
self.assertIn("access", fin.data)
|
||||||
|
|
||||||
|
def test_authenticate_rejects_tampered_signature(self):
|
||||||
|
from apps.authentication.models import Passkey
|
||||||
|
|
||||||
|
begin = self.client.post(reverse("passkey-register-begin"))
|
||||||
|
self.assertEqual(begin.status_code, 200)
|
||||||
|
state = self.client.session["passkey_register_state"]
|
||||||
|
challenge = websafe_decode(state["challenge"])
|
||||||
|
|
||||||
|
priv, cred_id, acd = self._make_credential()
|
||||||
|
reg = self.client.post(
|
||||||
|
reverse("passkey-register-finish"),
|
||||||
|
self._reg_response(challenge, cred_id, acd),
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(reg.status_code, 201, reg.data)
|
||||||
|
|
||||||
|
self.client.force_authenticate(None)
|
||||||
|
begin_auth = self.client.post(
|
||||||
|
reverse("passkey-authenticate-begin"),
|
||||||
|
{"email": "passkey@example.com"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
state2 = self.client.session["passkey_auth_state"]
|
||||||
|
challenge2 = websafe_decode(state2["challenge"])
|
||||||
|
|
||||||
|
auth = self._auth_response(challenge2, cred_id, priv)
|
||||||
|
bad_sig = auth["response"]["signature"]
|
||||||
|
# flip a byte in the base64url signature
|
||||||
|
raw = websafe_decode(bad_sig)
|
||||||
|
corrupted = raw[:-1] + bytes([raw[-1] ^ 1])
|
||||||
|
auth["response"]["signature"] = websafe_encode(corrupted)
|
||||||
|
|
||||||
|
fin = self.client.post(
|
||||||
|
reverse("passkey-authenticate-finish"), auth, format="json"
|
||||||
|
)
|
||||||
|
self.assertEqual(fin.status_code, 401)
|
||||||
|
|
||||||
|
def test_register_rejects_wrong_challenge(self):
|
||||||
|
from apps.authentication.models import Passkey
|
||||||
|
|
||||||
|
self.client.post(reverse("passkey-register-begin"))
|
||||||
|
# Use a challenge that does NOT match the session state
|
||||||
|
priv, cred_id, acd = self._make_credential()
|
||||||
|
reg = self.client.post(
|
||||||
|
reverse("passkey-register-finish"),
|
||||||
|
self._reg_response(b"not-the-real-challenge", cred_id, acd),
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(reg.status_code, 400)
|
||||||
|
self.assertFalse(Passkey.objects.filter(user=self.user).exists())
|
||||||
|
|
||||||
|
|
||||||
|
class RecoveryCodeTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
from apps.authentication.models import CredentialType
|
||||||
|
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
email="recovery@example.com",
|
||||||
|
password="S3cure-Pass-123",
|
||||||
|
status="active",
|
||||||
|
mfa_enabled=True,
|
||||||
|
totp_secret="JBSWY3DPEHPK3PXP",
|
||||||
|
)
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
def test_generate_requires_current_password(self):
|
||||||
|
self.client.force_authenticate(self.user)
|
||||||
|
resp = self.client.post(reverse("auth-recovery-codes-generate"), {})
|
||||||
|
self.assertEqual(resp.status_code, 400)
|
||||||
|
|
||||||
|
def test_generate_requires_mfa_enabled(self):
|
||||||
|
self.user.mfa_enabled = False
|
||||||
|
self.user.save()
|
||||||
|
self.client.force_authenticate(self.user)
|
||||||
|
resp = self.client.post(
|
||||||
|
reverse("auth-recovery-codes-generate"),
|
||||||
|
{"current_password": "S3cure-Pass-123"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 400)
|
||||||
|
|
||||||
|
def test_generate_returns_ten_codes(self):
|
||||||
|
self.client.force_authenticate(self.user)
|
||||||
|
resp = self.client.post(
|
||||||
|
reverse("auth-recovery-codes-generate"),
|
||||||
|
{"current_password": "S3cure-Pass-123"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 201)
|
||||||
|
self.assertEqual(len(resp.data["recovery_codes"]), 10)
|
||||||
|
# Codes are shown only once: a second generation returns a new set.
|
||||||
|
resp2 = self.client.post(
|
||||||
|
reverse("auth-recovery-codes-generate"),
|
||||||
|
{"current_password": "S3cure-Pass-123"},
|
||||||
|
)
|
||||||
|
self.assertEqual(len(resp2.data["recovery_codes"]), 10)
|
||||||
|
self.assertNotEqual(
|
||||||
|
resp.data["recovery_codes"][0], resp2.data["recovery_codes"][0]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_login_with_recovery_code_succeeds(self):
|
||||||
|
from apps.authentication.services import generate_recovery_codes
|
||||||
|
|
||||||
|
codes = generate_recovery_codes(self.user)
|
||||||
|
resp = self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{
|
||||||
|
"email": "recovery@example.com",
|
||||||
|
"password": "S3cure-Pass-123",
|
||||||
|
"mfa_code": codes[0],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertIn("access", resp.data)
|
||||||
|
|
||||||
|
def test_recovery_code_is_single_use(self):
|
||||||
|
from apps.authentication.services import generate_recovery_codes
|
||||||
|
|
||||||
|
codes = generate_recovery_codes(self.user)
|
||||||
|
first = self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{
|
||||||
|
"email": "recovery@example.com",
|
||||||
|
"password": "S3cure-Pass-123",
|
||||||
|
"mfa_code": codes[0],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(first.status_code, 200)
|
||||||
|
second = self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{
|
||||||
|
"email": "recovery@example.com",
|
||||||
|
"password": "S3cure-Pass-123",
|
||||||
|
"mfa_code": codes[0],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(second.status_code, 401)
|
||||||
|
|
||||||
|
def test_status_reports_remaining_count(self):
|
||||||
|
from apps.authentication.services import generate_recovery_codes
|
||||||
|
|
||||||
|
generate_recovery_codes(self.user)
|
||||||
|
self.client.force_authenticate(self.user)
|
||||||
|
resp = self.client.get(reverse("auth-recovery-codes-status"))
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
self.assertEqual(resp.data["remaining"], 10)
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
self.user_data = {
|
||||||
|
"email": "register-test@example.com",
|
||||||
|
"password": "TestPass123!",
|
||||||
|
"password_confirm": "TestPass123!",
|
||||||
|
"full_name": "Test User",
|
||||||
|
"username": "registertest",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_register_successful(self):
|
||||||
|
response = self.client.post(reverse("auth-register"), self.user_data)
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
self.assertIn("access", response.data)
|
||||||
|
self.assertIn("refresh", response.data)
|
||||||
|
self.assertEqual(response.data["token_type"], "bearer")
|
||||||
|
|
||||||
|
def test_register_duplicate_email(self):
|
||||||
|
# Register first time
|
||||||
|
self.client.post(reverse("auth-register"), self.user_data)
|
||||||
|
# Try again with same email
|
||||||
|
response = self.client.post(reverse("auth-register"), self.user_data)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_register_mismatched_passwords(self):
|
||||||
|
data = self.user_data.copy()
|
||||||
|
data["password_confirm"] = "DifferentPass123!"
|
||||||
|
response = self.client.post(reverse("auth-register"), data)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
|
||||||
|
class MeTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
email="me-test@example.com",
|
||||||
|
password="S3cure-Pass-123",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
def test_me_returns_user_data(self):
|
||||||
|
login = self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{"email": "me-test@example.com", "password": "S3cure-Pass-123"},
|
||||||
|
)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {login.data['access']}")
|
||||||
|
response = self.client.get(reverse("auth-me"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["email"], "me-test@example.com")
|
||||||
|
self.assertEqual(response.data["status"], "active")
|
||||||
|
|
||||||
|
def test_me_unauthenticated_redirects(self):
|
||||||
|
response = self.client.get(reverse("auth-me"))
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginPhoneTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
email="phoneuser@example.com",
|
||||||
|
password="S3cure-Pass-123",
|
||||||
|
phone="09136777707",
|
||||||
|
status="active",
|
||||||
|
phone_verified=True,
|
||||||
|
)
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
def test_login_with_phone_succeeds(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{"email": "09136777707", "password": "S3cure-Pass-123"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn("access", response.data)
|
||||||
|
|
||||||
|
def test_login_with_email_still_works(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{"email": "phoneuser@example.com", "password": "S3cure-Pass-123"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn("access", response.data)
|
||||||
|
|
||||||
|
def test_me_returns_phone(self):
|
||||||
|
login = self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{"email": "09136777707", "password": "S3cure-Pass-123"},
|
||||||
|
)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {login.data['access']}")
|
||||||
|
response = self.client.get(reverse("auth-me"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["phone"], "09136777707")
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileUpdateTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
email="profile@example.com",
|
||||||
|
password="S3cure-Pass-123",
|
||||||
|
phone="09311112222",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
def _auth(self):
|
||||||
|
login = self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{"email": "profile@example.com", "password": "S3cure-Pass-123"},
|
||||||
|
)
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {login.data['access']}")
|
||||||
|
|
||||||
|
def test_patch_updates_profile_fields(self):
|
||||||
|
self._auth()
|
||||||
|
response = self.client.patch(
|
||||||
|
reverse("auth-me"),
|
||||||
|
{"full_name": "Updated Name", "city": "Karaj", "skills": ["python"]},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["full_name"], "Updated Name")
|
||||||
|
self.assertEqual(response.data["city"], "Karaj")
|
||||||
|
self.assertEqual(response.data["skills"], ["python"])
|
||||||
|
|
||||||
|
def test_patch_cannot_change_email_or_phone(self):
|
||||||
|
self._auth()
|
||||||
|
response = self.client.patch(
|
||||||
|
reverse("auth-me"),
|
||||||
|
{"email": "hacker@example.com", "phone": "09999999999"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["email"], "profile@example.com")
|
||||||
|
self.assertEqual(response.data["phone"], "09311112222")
|
||||||
95
apps/api/apps/authentication/urls.py
Normal file
95
apps/api/apps/authentication/urls.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.authentication.views import (
|
||||||
|
ChangePasswordView,
|
||||||
|
CredentialDeleteView,
|
||||||
|
CredentialListCreateView,
|
||||||
|
CredentialUpdateView,
|
||||||
|
DisableMFAView,
|
||||||
|
EnableMFAView,
|
||||||
|
LoginView,
|
||||||
|
LogoutView,
|
||||||
|
MeView,
|
||||||
|
PasskeyAuthenticateBeginView,
|
||||||
|
PasskeyAuthenticateFinishView,
|
||||||
|
PasskeyListView,
|
||||||
|
PasskeyRegisterFinishView,
|
||||||
|
PasskeyRegisterBeginView,
|
||||||
|
PasswordResetConfirmView,
|
||||||
|
PasswordResetRequestView,
|
||||||
|
RecoveryCodeGenerateView,
|
||||||
|
RecoveryCodeStatusView,
|
||||||
|
RefreshView,
|
||||||
|
RegisterView,
|
||||||
|
VerifyMFASetupView,
|
||||||
|
)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("register/", RegisterView.as_view(), name="auth-register"),
|
||||||
|
path("login/", LoginView.as_view(), name="auth-login"),
|
||||||
|
path("me/", MeView.as_view(), name="auth-me"),
|
||||||
|
path("refresh/", RefreshView.as_view(), name="auth-refresh"),
|
||||||
|
path("logout/", LogoutView.as_view(), name="auth-logout"),
|
||||||
|
path("change-password/", ChangePasswordView.as_view(), name="auth-change-password"),
|
||||||
|
path(
|
||||||
|
"password/reset/",
|
||||||
|
PasswordResetRequestView.as_view(),
|
||||||
|
name="auth-password-reset-request",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"password/reset/confirm/",
|
||||||
|
PasswordResetConfirmView.as_view(),
|
||||||
|
name="auth-password-reset-confirm",
|
||||||
|
),
|
||||||
|
path("mfa/setup/", EnableMFAView.as_view(), name="auth-mfa-setup"),
|
||||||
|
path("mfa/verify/", VerifyMFASetupView.as_view(), name="auth-mfa-verify"),
|
||||||
|
path("mfa/disable/", DisableMFAView.as_view(), name="auth-mfa-disable"),
|
||||||
|
path(
|
||||||
|
"mfa/recovery-codes/",
|
||||||
|
RecoveryCodeGenerateView.as_view(),
|
||||||
|
name="auth-recovery-codes-generate",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"mfa/recovery-codes/status/",
|
||||||
|
RecoveryCodeStatusView.as_view(),
|
||||||
|
name="auth-recovery-codes-status",
|
||||||
|
),
|
||||||
|
# Passkey / WebAuthn endpoints
|
||||||
|
path(
|
||||||
|
"passkeys/register/begin/",
|
||||||
|
PasskeyRegisterBeginView.as_view(),
|
||||||
|
name="passkey-register-begin",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"passkeys/register/finish/",
|
||||||
|
PasskeyRegisterFinishView.as_view(),
|
||||||
|
name="passkey-register-finish",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"passkeys/authenticate/begin/",
|
||||||
|
PasskeyAuthenticateBeginView.as_view(),
|
||||||
|
name="passkey-authenticate-begin",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"passkeys/authenticate/finish/",
|
||||||
|
PasskeyAuthenticateFinishView.as_view(),
|
||||||
|
name="passkey-authenticate-finish",
|
||||||
|
),
|
||||||
|
path("passkeys/", PasskeyListView.as_view(), name="passkey-list"),
|
||||||
|
# Credential management endpoints
|
||||||
|
path(
|
||||||
|
"credentials/",
|
||||||
|
CredentialListCreateView.as_view(),
|
||||||
|
name="credential-list-create",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"credentials/<uuid:credential_id>/",
|
||||||
|
CredentialUpdateView.as_view(),
|
||||||
|
name="credential-update",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"credentials/<uuid:credential_id>/delete/",
|
||||||
|
CredentialDeleteView.as_view(),
|
||||||
|
name="credential-delete",
|
||||||
|
),
|
||||||
|
]
|
||||||
1022
apps/api/apps/authentication/views.py
Normal file
1022
apps/api/apps/authentication/views.py
Normal file
File diff suppressed because it is too large
Load Diff
82
apps/api/apps/authentication/webauthn_utils.py
Normal file
82
apps/api/apps/authentication/webauthn_utils.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from fido2.server import Fido2Server
|
||||||
|
from fido2.utils import websafe_decode, websafe_encode
|
||||||
|
from fido2.webauthn import (
|
||||||
|
AttestedCredentialData,
|
||||||
|
PublicKeyCredentialRpEntity,
|
||||||
|
PublicKeyCredentialUserEntity,
|
||||||
|
)
|
||||||
|
|
||||||
|
RP_ID = getattr(settings, "WEBAUTHN_RP_ID", "localhost")
|
||||||
|
RP_NAME = getattr(settings, "WEBAUTHN_RP_NAME", "Identity Platform")
|
||||||
|
|
||||||
|
_server = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_server():
|
||||||
|
"""Return a cached Fido2Server bound to the configured RP."""
|
||||||
|
global _server
|
||||||
|
if _server is None:
|
||||||
|
rp = PublicKeyCredentialRpEntity(name=RP_NAME, id=RP_ID)
|
||||||
|
_server = Fido2Server(rp)
|
||||||
|
return _server
|
||||||
|
|
||||||
|
|
||||||
|
def get_rp_id():
|
||||||
|
return RP_ID
|
||||||
|
|
||||||
|
|
||||||
|
def user_entity(user):
|
||||||
|
"""Build the WebAuthn user entity for a registered user."""
|
||||||
|
return PublicKeyCredentialUserEntity(
|
||||||
|
id=str(user.id).encode(),
|
||||||
|
name=user.email or user.username or str(user.id),
|
||||||
|
display_name=user.full_name or user.email or str(user.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def passkey_to_credential(passkey):
|
||||||
|
"""Reconstruct an AttestedCredentialData from a stored Passkey."""
|
||||||
|
raw = base64.urlsafe_b64decode(passkey.public_key.encode())
|
||||||
|
return AttestedCredentialData(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_credential_data(credential):
|
||||||
|
"""Serialize an AttestedCredentialData to a storable string."""
|
||||||
|
return base64.urlsafe_b64encode(bytes(credential)).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decode_credential_id(credential_id):
|
||||||
|
"""Decode a client-supplied base64url credential id into bytes."""
|
||||||
|
return websafe_decode(credential_id)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_credential_id(credential_id_bytes):
|
||||||
|
"""Encode raw credential id bytes to a base64url string for storage.
|
||||||
|
|
||||||
|
fido2's websafe_encode returns a str, which is what we store.
|
||||||
|
"""
|
||||||
|
return websafe_encode(credential_id_bytes)
|
||||||
|
|
||||||
|
|
||||||
|
def options_to_client(options):
|
||||||
|
"""Recursively convert fido2 options objects into JSON-serializable dict.
|
||||||
|
|
||||||
|
fido2 2.x options are Mappings whose bytes fields must be base64url
|
||||||
|
encoded for the browser's WebAuthn API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def conv(value):
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
return websafe_encode(value)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {k: conv(v) for k, v in value.items()}
|
||||||
|
if isinstance(value, (list, tuple)):
|
||||||
|
return [conv(v) for v in value]
|
||||||
|
if hasattr(value, "items") and not isinstance(value, (str, bytes)):
|
||||||
|
return {k: conv(v) for k, v in value.items()}
|
||||||
|
return value
|
||||||
|
|
||||||
|
return conv(options)
|
||||||
0
apps/api/apps/common/__init__.py
Normal file
0
apps/api/apps/common/__init__.py
Normal file
33
apps/api/apps/common/exceptions.py
Normal file
33
apps/api/apps/common/exceptions.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.core.exceptions import (
|
||||||
|
ObjectDoesNotExist,
|
||||||
|
PermissionDenied,
|
||||||
|
ValidationError as DjangoValidationError,
|
||||||
|
)
|
||||||
|
from django.http import Http404
|
||||||
|
from rest_framework import exceptions, status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import exception_handler as drf_exception_handler
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def api_exception_handler(exc, context):
|
||||||
|
if isinstance(exc, DjangoValidationError):
|
||||||
|
exc = exceptions.ValidationError(exc.messages)
|
||||||
|
elif isinstance(exc, Http404):
|
||||||
|
exc = exceptions.NotFound()
|
||||||
|
elif isinstance(exc, PermissionDenied):
|
||||||
|
exc = exceptions.PermissionDenied()
|
||||||
|
|
||||||
|
response = drf_exception_handler(exc, context)
|
||||||
|
|
||||||
|
if response is not None:
|
||||||
|
return response
|
||||||
|
|
||||||
|
logger.exception("Unhandled API exception", exc_info=exc)
|
||||||
|
return Response(
|
||||||
|
{"detail": "An unexpected error occurred."},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
)
|
||||||
54
apps/api/apps/common/migrations/0001_initial.py
Normal file
54
apps/api/apps/common/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# Generated by Django 5.2.17 on 2026-08-15 10:04
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='OutboxEvent',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('event_type', models.CharField(choices=[('security', 'Security'), ('session', 'Session'), ('auth', 'Authentication'), ('verification', 'Verification'), ('organization', 'Organization'), ('membership', 'Membership'), ('credential', 'Credential'), ('passkey', 'Passkey'), ('mfa', 'MFA')], max_length=32)),
|
||||||
|
('occurred_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('delivered_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('error_message', models.TextField(blank=True, default='')),
|
||||||
|
('metadata', models.JSONField(blank=True, default=dict)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-occurred_at'],
|
||||||
|
'indexes': [models.Index(fields=['event_type', 'delivered_at'], name='common_outb_event_t_f44eb4_idx'), models.Index(fields=['occurred_at'], name='common_outb_occurre_b33d30_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Notification',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('event_type', models.CharField(max_length=32)),
|
||||||
|
('title', models.CharField(max_length=200)),
|
||||||
|
('message', models.TextField()),
|
||||||
|
('metadata', models.JSONField(blank=True, default=dict)),
|
||||||
|
('status', models.CharField(choices=[('unread', 'Unread'), ('read', 'Read'), ('archived', 'Archived')], default='unread', max_length=16)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('read_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'indexes': [models.Index(fields=['user', 'status'], name='common_noti_user_id_974fc7_idx'), models.Index(fields=['user', 'created_at'], name='common_noti_user_id_c7bc7b_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 5.2.17 on 2026-08-15 14:58
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('common', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='outboxevent',
|
||||||
|
name='message',
|
||||||
|
field=models.TextField(blank=True, default=''),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='outboxevent',
|
||||||
|
name='title',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=200),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/api/apps/common/migrations/__init__.py
Normal file
0
apps/api/apps/common/migrations/__init__.py
Normal file
152
apps/api/apps/common/models.py
Normal file
152
apps/api/apps/common/models.py
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class BaseModel(models.Model):
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
class OutboxManager(models.Manager):
|
||||||
|
def publish(
|
||||||
|
self,
|
||||||
|
event_type,
|
||||||
|
user=None,
|
||||||
|
metadata=None,
|
||||||
|
title=None,
|
||||||
|
message="",
|
||||||
|
security_event_type=None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""Publish an event: create OutboxEvent and deliver an in-app Notification.
|
||||||
|
|
||||||
|
The OutboxEvent taxonomy (EventType) is intentionally separate from the
|
||||||
|
SecurityEvent taxonomy. When a related SecurityEvent should also be recorded,
|
||||||
|
pass ``security_event_type`` explicitly rather than coercing one taxonomy
|
||||||
|
into the other (the previous implementation did ``SecurityEventType(event_type)``
|
||||||
|
which raised ValueError because the two taxonomies do not share values).
|
||||||
|
"""
|
||||||
|
from apps.security.models import record_security_event, Severity
|
||||||
|
|
||||||
|
metadata = {**({"user": str(user.id)} if user else {}), **(metadata or {})}
|
||||||
|
event = self.create(
|
||||||
|
event_type=event_type,
|
||||||
|
metadata=metadata,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
# Deliver: create in-app notification (only when a user is targeted)
|
||||||
|
if user is not None:
|
||||||
|
Notification.objects.create(
|
||||||
|
user=user,
|
||||||
|
event_type=event_type,
|
||||||
|
title=title or event_type,
|
||||||
|
message=message,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
# Record a security event only when an explicit type is supplied
|
||||||
|
if user is not None and security_event_type is not None:
|
||||||
|
record_security_event(
|
||||||
|
security_event_type,
|
||||||
|
user=user,
|
||||||
|
severity=Severity.INFO,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
return event
|
||||||
|
|
||||||
|
def mark_event_delivered(self, event_id):
|
||||||
|
"""Mark an outbox event as delivered."""
|
||||||
|
return self.filter(pk=event_id).mark_delivered()
|
||||||
|
|
||||||
|
def mark_event_error(self, event_id, error):
|
||||||
|
"""Mark an outbox event as having an error."""
|
||||||
|
return self.filter(pk=event_id).mark_error(error)
|
||||||
|
|
||||||
|
|
||||||
|
class OutboxEvent(BaseModel):
|
||||||
|
"""Outbox model for reliable event publishing (at-least-once delivery)."""
|
||||||
|
|
||||||
|
class EventType(models.TextChoices):
|
||||||
|
SECURITY = "security", "Security"
|
||||||
|
SESSION = "session", "Session"
|
||||||
|
AUTH = "auth", "Authentication"
|
||||||
|
VERIFICATION = "verification", "Verification"
|
||||||
|
ORGANIZATION = "organization", "Organization"
|
||||||
|
MEMBERSHIP = "membership", "Membership"
|
||||||
|
CREDENTIAL = "credential", "Credential"
|
||||||
|
PASSKEY = "passkey", "Passkey"
|
||||||
|
MFA = "mfa", "MFA"
|
||||||
|
|
||||||
|
objects = OutboxManager()
|
||||||
|
|
||||||
|
event_type = models.CharField(max_length=32, choices=EventType.choices)
|
||||||
|
title = models.CharField(max_length=200, blank=True, default="")
|
||||||
|
message = models.TextField(blank=True, default="")
|
||||||
|
occurred_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
delivered_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
error_message = models.TextField(blank=True, default="")
|
||||||
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-occurred_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["event_type", "delivered_at"]),
|
||||||
|
models.Index(fields=["occurred_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def mark_delivered(self):
|
||||||
|
self.delivered_at = timezone.now()
|
||||||
|
self.save(update_fields=["delivered_at", "updated_at"])
|
||||||
|
|
||||||
|
def mark_error(self, error: str):
|
||||||
|
self.error_message = error
|
||||||
|
self.save(update_fields=["error_message", "updated_at"])
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(BaseModel):
|
||||||
|
"""In-app notification for a user."""
|
||||||
|
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
UNREAD = "unread", "Unread"
|
||||||
|
READ = "read", "Read"
|
||||||
|
ARCHIVED = "archived", "Archived"
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
"identity.User",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="notifications",
|
||||||
|
)
|
||||||
|
event_type = models.CharField(max_length=32)
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
message = models.TextField()
|
||||||
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=16,
|
||||||
|
choices=Status.choices,
|
||||||
|
default=Status.UNREAD,
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
read_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "status"]),
|
||||||
|
models.Index(fields=["user", "created_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def mark_read(self):
|
||||||
|
self.status = self.Status.READ
|
||||||
|
self.read_at = timezone.now()
|
||||||
|
self.save(update_fields=["status", "read_at", "updated_at"])
|
||||||
|
|
||||||
|
def mark_archived(self):
|
||||||
|
self.status = self.Status.ARCHIVED
|
||||||
|
self.save(update_fields=["status", "updated_at"])
|
||||||
7
apps/api/apps/common/pagination.py
Normal file
7
apps/api/apps/common/pagination.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
from rest_framework.pagination import PageNumberPagination
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultPagination(PageNumberPagination):
|
||||||
|
page_size = 20
|
||||||
|
page_size_query_param = "page_size"
|
||||||
|
max_page_size = 100
|
||||||
13
apps/api/apps/common/permissions.py
Normal file
13
apps/api/apps/common/permissions.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||||
|
|
||||||
|
|
||||||
|
class IsStaffOrReadOnly(BasePermission):
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if request.method in SAFE_METHODS:
|
||||||
|
return bool(request.user and request.user.is_authenticated)
|
||||||
|
return bool(request.user and request.user.is_staff)
|
||||||
|
|
||||||
|
|
||||||
|
class IsAdminUser(BasePermission):
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
return bool(request.user and request.user.is_staff)
|
||||||
35
apps/api/apps/common/ratelimit.py
Normal file
35
apps/api/apps/common/ratelimit.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import time
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimiter:
|
||||||
|
def __init__(self, prefix, max_attempts=5, window_seconds=300):
|
||||||
|
self.prefix = prefix
|
||||||
|
self.max_attempts = max_attempts
|
||||||
|
self.window_seconds = window_seconds
|
||||||
|
|
||||||
|
def _keys(self, identifier):
|
||||||
|
window_start = int(time.time()) // self.window_seconds
|
||||||
|
return (
|
||||||
|
f"ratelimit:{self.prefix}:{window_start}:{identifier}",
|
||||||
|
f"ratelimit:{self.prefix}:window:{identifier}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def hit(self, identifier):
|
||||||
|
counter_key, window_key = self._keys(identifier)
|
||||||
|
current_window = int(time.time()) // self.window_seconds
|
||||||
|
stored_window = cache.get(window_key)
|
||||||
|
if stored_window != current_window:
|
||||||
|
cache.set(window_key, current_window, self.window_seconds)
|
||||||
|
cache.set(counter_key, 0, self.window_seconds)
|
||||||
|
count = cache.incr(counter_key)
|
||||||
|
return count
|
||||||
|
|
||||||
|
def is_allowed(self, identifier):
|
||||||
|
return self.hit(identifier) <= self.max_attempts
|
||||||
|
|
||||||
|
def remaining(self, identifier):
|
||||||
|
counter_key, _ = self._keys(identifier)
|
||||||
|
count = cache.get(counter_key) or 0
|
||||||
|
return max(0, self.max_attempts - count)
|
||||||
26
apps/api/apps/common/tests.py
Normal file
26
apps/api/apps/common/tests.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
|
||||||
|
class HealthTests(TestCase):
|
||||||
|
def test_health_endpoint(self):
|
||||||
|
response = APIClient().get(reverse("health"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["status"], "ok")
|
||||||
|
self.assertEqual(response.data["service"], "identity-platform-api")
|
||||||
|
|
||||||
|
def test_oidc_discovery_endpoint(self):
|
||||||
|
response = APIClient().get(reverse("oidc-discovery"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn("issuer", response.data)
|
||||||
|
self.assertIn("authorization_endpoint", response.data)
|
||||||
|
self.assertIn("scopes_supported", response.data)
|
||||||
|
|
||||||
|
def test_oauth_endpoints_report_not_implemented(self):
|
||||||
|
client = APIClient()
|
||||||
|
response = client.get("/oauth/authorize?client_id=test&response_type=code")
|
||||||
|
self.assertIn(response.status_code, (400, 302))
|
||||||
|
token_response = client.post("/oauth/token", {"grant_type": "authorization_code"})
|
||||||
|
self.assertEqual(token_response.status_code, 400)
|
||||||
|
self.assertEqual(token_response.data["error"], "invalid_client")
|
||||||
8
apps/api/apps/common/urls.py
Normal file
8
apps/api/apps/common/urls.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.common.views import HealthView, NotificationListView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", HealthView.as_view(), name="health"),
|
||||||
|
path("notifications/", NotificationListView.as_view(), name="notification-list"),
|
||||||
|
]
|
||||||
38
apps/api/apps/common/utils.py
Normal file
38
apps/api/apps/common/utils.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
MOBILE_AGENTS = ("android", "iphone", "ipad", "mobile", "windows phone")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_token(length=48):
|
||||||
|
return secrets.token_urlsafe(length)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(raw_token):
|
||||||
|
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_client_id(prefix="app"):
|
||||||
|
return f"{prefix}_{secrets.token_urlsafe(24)}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_client_secret():
|
||||||
|
return secrets.token_urlsafe(48)
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip(request):
|
||||||
|
forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
return request.META.get("REMOTE_ADDR", "")
|
||||||
|
|
||||||
|
|
||||||
|
def device_name(user_agent):
|
||||||
|
if not user_agent:
|
||||||
|
return "Unknown"
|
||||||
|
ua = user_agent.lower()
|
||||||
|
if "mobile" in ua or "android" in ua or "iphone" in ua or "ipad" in ua:
|
||||||
|
return "Mobile"
|
||||||
|
if "bot" in ua or "spider" in ua or "crawler" in ua:
|
||||||
|
return "Bot"
|
||||||
|
return "Desktop"
|
||||||
85
apps/api/apps/common/views.py
Normal file
85
apps/api/apps/common/views.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.db import connection
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.permissions import BasePermission
|
||||||
|
|
||||||
|
from apps.token_blacklist.models import TokenBlacklist
|
||||||
|
from apps.common.models import Notification
|
||||||
|
|
||||||
|
|
||||||
|
class HealthView(APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
authentication_classes = []
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
database = "ok"
|
||||||
|
try:
|
||||||
|
connection.ensure_connection()
|
||||||
|
except Exception:
|
||||||
|
database = "error"
|
||||||
|
|
||||||
|
cache_backend = type(cache).__module__ + "." + type(cache).__name__
|
||||||
|
is_redis = "django_redis" in cache_backend
|
||||||
|
cache_status = "unknown"
|
||||||
|
try:
|
||||||
|
cache.set("health-check", "ok", 1)
|
||||||
|
cache_status = "ok" if cache.get("health-check") == "ok" else "error"
|
||||||
|
except Exception:
|
||||||
|
cache_status = "error"
|
||||||
|
is_redis = False
|
||||||
|
|
||||||
|
alive = TokenBlacklist.objects.count() >= 0
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"status": "ok" if database == "ok" and cache_status == "ok" else "degraded",
|
||||||
|
"service": "identity-platform-api",
|
||||||
|
"database": database,
|
||||||
|
"cache": "redis" if is_redis else "local",
|
||||||
|
"cache_status": cache_status,
|
||||||
|
"token_blacklist": "ready" if alive else "error",
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK if database == "ok" and cache_status == "ok" else status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationListView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
from apps.common.pagination import DefaultPagination
|
||||||
|
|
||||||
|
qs = Notification.objects.filter(user=request.user)
|
||||||
|
paginator = DefaultPagination()
|
||||||
|
page = paginator.paginate_queryset(qs, request)
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
"id": str(n.id),
|
||||||
|
"event_type": n.event_type,
|
||||||
|
"title": n.title,
|
||||||
|
"message": n.message,
|
||||||
|
"status": n.status,
|
||||||
|
"created_at": n.created_at.isoformat(),
|
||||||
|
"read_at": n.read_at.isoformat() if n.read_at else None,
|
||||||
|
}
|
||||||
|
for n in page
|
||||||
|
]
|
||||||
|
return paginator.get_paginated_response(data)
|
||||||
|
|
||||||
|
def patch(self, request):
|
||||||
|
notification_id = request.data.get("id")
|
||||||
|
if notification_id:
|
||||||
|
Notification.objects.filter(id=notification_id, user=request.user).update(
|
||||||
|
status=Notification.Status.READ,
|
||||||
|
read_at=timezone.now(),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
Notification.objects.filter(user=request.user, status=Notification.Status.UNREAD).update(
|
||||||
|
status=Notification.Status.READ,
|
||||||
|
read_at=timezone.now(),
|
||||||
|
)
|
||||||
|
return Response({"detail": "Notifications marked as read."}, status=status.HTTP_200_OK)
|
||||||
0
apps/api/apps/identity/__init__.py
Normal file
0
apps/api/apps/identity/__init__.py
Normal file
88
apps/api/apps/identity/admin.py
Normal file
88
apps/api/apps/identity/admin.py
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
|
||||||
|
|
||||||
|
from apps.identity.models import User, IdentityDocument
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(User)
|
||||||
|
class UserAdmin(DjangoUserAdmin):
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
list_display = (
|
||||||
|
"email",
|
||||||
|
"full_name",
|
||||||
|
"username",
|
||||||
|
"status",
|
||||||
|
"is_staff",
|
||||||
|
"mfa_enabled",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
"status",
|
||||||
|
"is_staff",
|
||||||
|
"is_active",
|
||||||
|
"is_superuser",
|
||||||
|
"mfa_enabled",
|
||||||
|
"email_verified",
|
||||||
|
)
|
||||||
|
search_fields = ("email", "full_name", "username", "phone")
|
||||||
|
readonly_fields = ("id", "created_at", "updated_at", "last_login_at")
|
||||||
|
fieldsets = (
|
||||||
|
("Credential", {"fields": ("email", "password")}),
|
||||||
|
(
|
||||||
|
"Profile",
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"full_name",
|
||||||
|
"given_name",
|
||||||
|
"family_name",
|
||||||
|
"username",
|
||||||
|
"phone",
|
||||||
|
"avatar_url",
|
||||||
|
"locale",
|
||||||
|
"timezone",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Identity",
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"status",
|
||||||
|
"id",
|
||||||
|
"email_verified",
|
||||||
|
"phone_verified",
|
||||||
|
"mfa_enabled",
|
||||||
|
"last_login_at",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Permissions",
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"is_active",
|
||||||
|
"is_staff",
|
||||||
|
"is_superuser",
|
||||||
|
"groups",
|
||||||
|
"user_permissions",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
("Timestamps", {"fields": ("created_at", "updated_at")}),
|
||||||
|
)
|
||||||
|
add_fieldsets = (
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"classes": ("wide",),
|
||||||
|
"fields": ("email", "full_name", "password1", "password2"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(IdentityDocument)
|
||||||
|
class IdentityDocumentAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "doc_type", "verification_status", "verified_at")
|
||||||
|
list_filter = ("doc_type", "verification_status")
|
||||||
|
search_fields = ("user__email", "user__full_name")
|
||||||
26
apps/api/apps/identity/managers.py
Normal file
26
apps/api/apps/identity/managers.py
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
from django.contrib.auth.models import BaseUserManager
|
||||||
|
|
||||||
|
|
||||||
|
class UserManager(BaseUserManager):
|
||||||
|
use_in_migrations = True
|
||||||
|
|
||||||
|
def _create_user(self, email, password, **extra_fields):
|
||||||
|
if not email:
|
||||||
|
raise ValueError("An email address is required.")
|
||||||
|
email = self.normalize_email(email)
|
||||||
|
user = self.model(email=email, **extra_fields)
|
||||||
|
user.set_password(password)
|
||||||
|
user.save(using=self._db)
|
||||||
|
return user
|
||||||
|
|
||||||
|
def create_user(self, email, password=None, **extra_fields):
|
||||||
|
extra_fields.setdefault("is_staff", False)
|
||||||
|
extra_fields.setdefault("is_superuser", False)
|
||||||
|
return self._create_user(email, password, **extra_fields)
|
||||||
|
|
||||||
|
def create_superuser(self, email, password=None, **extra_fields):
|
||||||
|
extra_fields.setdefault("is_staff", True)
|
||||||
|
extra_fields.setdefault("is_superuser", True)
|
||||||
|
extra_fields.setdefault("is_active", True)
|
||||||
|
extra_fields.setdefault("status", "active")
|
||||||
|
return self._create_user(email, password, **extra_fields)
|
||||||
54
apps/api/apps/identity/migrations/0001_initial.py
Normal file
54
apps/api/apps/identity/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
# Generated by Django 5.2.17 on 2026-08-13 13:32
|
||||||
|
|
||||||
|
import apps.identity.managers
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('auth', '0012_alter_user_first_name_max_length'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='User',
|
||||||
|
fields=[
|
||||||
|
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||||
|
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||||
|
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('email', models.EmailField(blank=True, max_length=254, null=True, unique=True)),
|
||||||
|
('phone', models.CharField(blank=True, max_length=32, null=True, unique=True)),
|
||||||
|
('username', models.SlugField(blank=True, max_length=64, null=True, unique=True)),
|
||||||
|
('full_name', models.CharField(blank=True, default='', max_length=200)),
|
||||||
|
('given_name', models.CharField(blank=True, default='', max_length=100)),
|
||||||
|
('family_name', models.CharField(blank=True, default='', max_length=100)),
|
||||||
|
('avatar_url', models.URLField(blank=True, default='', max_length=500)),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Pending'), ('active', 'Active'), ('suspended', 'Suspended'), ('banned', 'Banned')], default='pending', max_length=16)),
|
||||||
|
('email_verified', models.BooleanField(default=False)),
|
||||||
|
('phone_verified', models.BooleanField(default=False)),
|
||||||
|
('locale', models.CharField(default='fa', max_length=16)),
|
||||||
|
('timezone', models.CharField(default='UTC', max_length=64)),
|
||||||
|
('mfa_enabled', models.BooleanField(default=False)),
|
||||||
|
('totp_secret', models.CharField(blank=True, default='', max_length=64)),
|
||||||
|
('last_login_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('is_staff', models.BooleanField(default=False)),
|
||||||
|
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||||
|
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'indexes': [models.Index(fields=['email'], name='identity_us_email_bcdd3b_idx'), models.Index(fields=['status'], name='identity_us_status_9bd949_idx'), models.Index(fields=['username'], name='identity_us_usernam_c3cc86_idx')],
|
||||||
|
},
|
||||||
|
managers=[
|
||||||
|
('objects', apps.identity.managers.UserManager()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("identity", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="password_reset_token",
|
||||||
|
field=models.CharField(blank=True, default="", max_length=64),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="password_reset_expires",
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
# Generated by Django 5.2.17 on 2026-08-15 08:05
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('identity', '0002_password_reset_fields'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelManagers(
|
||||||
|
name='user',
|
||||||
|
managers=[
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='birth_date',
|
||||||
|
field=models.DateField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='display_name',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=200),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='gender',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=16),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='identity_verified',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='identity_verified_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='national_id',
|
||||||
|
field=models.CharField(blank=True, max_length=32, null=True, unique=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
35
apps/api/apps/identity/migrations/0004_identitydocument.py
Normal file
35
apps/api/apps/identity/migrations/0004_identitydocument.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
# Generated by Django 5.2.17 on 2026-08-15 10:20
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('identity', '0003_alter_user_managers_user_birth_date_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='IdentityDocument',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('doc_type', models.CharField(choices=[('national_id', 'National ID'), ('passport', 'Passport'), ('drivers_license', "Driver's License")], max_length=32)),
|
||||||
|
('file_url', models.URLField(blank=True, default='', max_length=500)),
|
||||||
|
('metadata', models.JSONField(blank=True, default=dict)),
|
||||||
|
('verification_status', models.CharField(choices=[('pending', 'Pending'), ('verified', 'Verified'), ('rejected', 'Rejected')], default='pending', max_length=16)),
|
||||||
|
('verified_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('expires_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='identity_documents', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'indexes': [models.Index(fields=['user', 'doc_type'], name='identity_id_user_id_69b228_idx'), models.Index(fields=['verification_status'], name='identity_id_verific_520c13_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("identity", "0004_identitydocument"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="province",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="Province/state of residence (استان)",
|
||||||
|
max_length=64,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="skills",
|
||||||
|
field=models.JSONField(
|
||||||
|
blank=True,
|
||||||
|
default=list,
|
||||||
|
help_text="List of skills (مهارت), e.g. ['python', 'design']",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 5.2.17 on 2026-08-19 08:34
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('identity', '0005_user_province_skills'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='city',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='City (شهر)', max_length=64),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='country',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='Country (کشور)', max_length=64),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
apps/api/apps/identity/migrations/0007_user_token_version.py
Normal file
18
apps/api/apps/identity/migrations/0007_user_token_version.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("identity", "0006_user_city_user_country"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="token_version",
|
||||||
|
field=models.BigIntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Incremented to invalidate all issued tokens (global logout / suspend).",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/api/apps/identity/migrations/__init__.py
Normal file
0
apps/api/apps/identity/migrations/__init__.py
Normal file
193
apps/api/apps/identity/models.py
Normal file
193
apps/api/apps/identity/models.py
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.contrib.auth.models import (
|
||||||
|
AbstractBaseUser,
|
||||||
|
BaseUserManager,
|
||||||
|
PermissionsMixin,
|
||||||
|
)
|
||||||
|
|
||||||
|
from apps.common.models import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class UserStatus(models.TextChoices):
|
||||||
|
PENDING = "pending", "Pending"
|
||||||
|
ACTIVE = "active", "Active"
|
||||||
|
SUSPENDED = "suspended", "Suspended"
|
||||||
|
BANNED = "banned", "Banned"
|
||||||
|
|
||||||
|
|
||||||
|
class DocType(models.TextChoices):
|
||||||
|
NATIONAL_ID = "national_id", "National ID"
|
||||||
|
PASSPORT = "passport", "Passport"
|
||||||
|
DRIVERS_LICENSE = "drivers_license", "Driver's License"
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityDocument(BaseModel):
|
||||||
|
"""Identity document verification evidence linked to a user."""
|
||||||
|
|
||||||
|
class VerificationStatus(models.TextChoices):
|
||||||
|
PENDING = "pending", "Pending"
|
||||||
|
VERIFIED = "verified", "Verified"
|
||||||
|
REJECTED = "rejected", "Rejected"
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
"identity.User",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="identity_documents",
|
||||||
|
)
|
||||||
|
doc_type = models.CharField(
|
||||||
|
max_length=32,
|
||||||
|
choices=DocType.choices,
|
||||||
|
)
|
||||||
|
file_url = models.URLField(max_length=500, blank=True, default="")
|
||||||
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
verification_status = models.CharField(
|
||||||
|
max_length=16,
|
||||||
|
choices=VerificationStatus.choices,
|
||||||
|
default=VerificationStatus.PENDING,
|
||||||
|
)
|
||||||
|
verified_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
expires_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "doc_type"]),
|
||||||
|
models.Index(fields=["verification_status"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user} — {self.get_doc_type_display()}"
|
||||||
|
|
||||||
|
|
||||||
|
class UserManager(BaseUserManager):
|
||||||
|
def create_user(self, email, password=None, **extra_fields):
|
||||||
|
if not email:
|
||||||
|
raise ValueError("The Email field must be set")
|
||||||
|
email = self.normalize_email(email)
|
||||||
|
user = self.model(email=email, **extra_fields)
|
||||||
|
user.set_password(password)
|
||||||
|
user.save(using=self._db)
|
||||||
|
return user
|
||||||
|
|
||||||
|
def create_superuser(self, email, password, **extra_fields):
|
||||||
|
extra_fields.setdefault("is_staff", True)
|
||||||
|
extra_fields.setdefault("is_superuser", True)
|
||||||
|
return self.create_user(email, password, **extra_fields)
|
||||||
|
|
||||||
|
|
||||||
|
class User(BaseModel, AbstractBaseUser, PermissionsMixin):
|
||||||
|
"""Extended user model with identity and profile fields."""
|
||||||
|
|
||||||
|
email = models.EmailField(unique=True, null=True, blank=True)
|
||||||
|
phone = models.CharField(max_length=32, unique=True, null=True, blank=True)
|
||||||
|
username = models.SlugField(max_length=64, unique=True, null=True, blank=True)
|
||||||
|
full_name = models.CharField(max_length=200, blank=True, default="")
|
||||||
|
given_name = models.CharField(max_length=100, blank=True, default="")
|
||||||
|
family_name = models.CharField(max_length=100, blank=True, default="")
|
||||||
|
display_name = models.CharField(max_length=200, blank=True, default="")
|
||||||
|
avatar_url = models.URLField(max_length=500, blank=True, default="")
|
||||||
|
gender = models.CharField(max_length=16, blank=True, default="")
|
||||||
|
birth_date = models.DateField(null=True, blank=True)
|
||||||
|
province = models.CharField(
|
||||||
|
max_length=64,
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="Province/state of residence (استان)",
|
||||||
|
)
|
||||||
|
country = models.CharField(
|
||||||
|
max_length=64,
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="Country (کشور)",
|
||||||
|
)
|
||||||
|
city = models.CharField(
|
||||||
|
max_length=64,
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="City (شهر)",
|
||||||
|
)
|
||||||
|
skills = models.JSONField(
|
||||||
|
default=list,
|
||||||
|
blank=True,
|
||||||
|
help_text="List of skills (مهارت), e.g. ['python', 'design']",
|
||||||
|
)
|
||||||
|
national_id = models.CharField(max_length=32, unique=True, null=True, blank=True)
|
||||||
|
identity_verified = models.BooleanField(default=False)
|
||||||
|
identity_verified_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=16,
|
||||||
|
choices=UserStatus.choices,
|
||||||
|
default=UserStatus.PENDING,
|
||||||
|
)
|
||||||
|
email_verified = models.BooleanField(default=False)
|
||||||
|
phone_verified = models.BooleanField(default=False)
|
||||||
|
locale = models.CharField(max_length=16, default="fa")
|
||||||
|
timezone = models.CharField(max_length=64, default="UTC")
|
||||||
|
mfa_enabled = models.BooleanField(default=False)
|
||||||
|
totp_secret = models.CharField(max_length=64, blank=True, default="")
|
||||||
|
last_login_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
token_version = models.BigIntegerField(
|
||||||
|
default=0,
|
||||||
|
help_text="Incremented to invalidate all issued tokens (global logout / suspend).",
|
||||||
|
)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
is_staff = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
password_reset_token = models.CharField(max_length=64, blank=True, default="")
|
||||||
|
password_reset_expires = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
USERNAME_FIELD = "email"
|
||||||
|
REQUIRED_FIELDS = ["full_name"]
|
||||||
|
|
||||||
|
objects = UserManager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["email"]),
|
||||||
|
models.Index(fields=["status"]),
|
||||||
|
models.Index(fields=["username"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.email or self.full_name or str(self.id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_id(self):
|
||||||
|
return self.id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identity_key(self):
|
||||||
|
return f"usr_{self.id.hex}"
|
||||||
|
|
||||||
|
|
||||||
|
from django.db.models.signals import pre_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(pre_save, sender=User)
|
||||||
|
def _revoke_sessions_on_suspend(sender, instance, **kwargs):
|
||||||
|
"""Decision 9: suspending (or banning) a user must revoke access to ALL
|
||||||
|
products. We compare the persisted status with the incoming one and, on a
|
||||||
|
transition into a locked state, terminate every session/token."""
|
||||||
|
if not instance.pk:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
old_status = (
|
||||||
|
User.objects.filter(pk=instance.pk).values_list("status", flat=True).first()
|
||||||
|
)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return
|
||||||
|
if old_status is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
locked = {UserStatus.SUSPENDED, UserStatus.BANNED}
|
||||||
|
if old_status not in locked and instance.status in locked:
|
||||||
|
from apps.authentication.services import revoke_all_user_sessions
|
||||||
|
|
||||||
|
revoke_all_user_sessions(instance, reason="user_suspended")
|
||||||
|
# The service bumps token_version in the DB via an F() update, but the
|
||||||
|
# in-memory instance still holds the old value and its save() would
|
||||||
|
# clobber it. Reflect the bump locally so the persisted row keeps it.
|
||||||
|
instance.token_version = (instance.token_version or 0) + 1
|
||||||
174
apps/api/apps/identity/serializers.py
Normal file
174
apps/api/apps/identity/serializers.py
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.identity.models import User, IdentityDocument
|
||||||
|
from apps.authentication.serializers import PasskeySerializer
|
||||||
|
from apps.verification.services import TrustEngine
|
||||||
|
|
||||||
|
|
||||||
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
user_id = serializers.UUIDField(source="id", read_only=True)
|
||||||
|
identity_key = serializers.CharField(read_only=True)
|
||||||
|
trust_state = serializers.SerializerMethodField()
|
||||||
|
trust_score = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = (
|
||||||
|
"user_id",
|
||||||
|
"identity_key",
|
||||||
|
"email",
|
||||||
|
"phone",
|
||||||
|
"username",
|
||||||
|
"full_name",
|
||||||
|
"given_name",
|
||||||
|
"family_name",
|
||||||
|
"avatar_url",
|
||||||
|
"gender",
|
||||||
|
"birth_date",
|
||||||
|
"province",
|
||||||
|
"country",
|
||||||
|
"city",
|
||||||
|
"skills",
|
||||||
|
"status",
|
||||||
|
"is_active",
|
||||||
|
"email_verified",
|
||||||
|
"phone_verified",
|
||||||
|
"mfa_enabled",
|
||||||
|
"trust_state",
|
||||||
|
"trust_score",
|
||||||
|
"locale",
|
||||||
|
"timezone",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"last_login_at",
|
||||||
|
"passkeys",
|
||||||
|
)
|
||||||
|
read_only_fields = (
|
||||||
|
"user_id",
|
||||||
|
"identity_key",
|
||||||
|
"email_verified",
|
||||||
|
"phone_verified",
|
||||||
|
"mfa_enabled",
|
||||||
|
"trust_state",
|
||||||
|
"trust_score",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"last_login_at",
|
||||||
|
"passkeys",
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_trust_state(self, obj):
|
||||||
|
return TrustEngine.compute_trust_state(obj)
|
||||||
|
|
||||||
|
def get_trust_score(self, obj):
|
||||||
|
return TrustEngine.get_trust_score(obj)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileUpdateSerializer(serializers.ModelSerializer):
|
||||||
|
"""Self-service profile editing. Exposes only non-sensitive, user-editable
|
||||||
|
fields. Identity/credential fields (email, phone, status, verification
|
||||||
|
flags, mfa) are intentionally excluded and must go through dedicated flows."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = (
|
||||||
|
"full_name",
|
||||||
|
"given_name",
|
||||||
|
"family_name",
|
||||||
|
"username",
|
||||||
|
"avatar_url",
|
||||||
|
"gender",
|
||||||
|
"birth_date",
|
||||||
|
"province",
|
||||||
|
"country",
|
||||||
|
"city",
|
||||||
|
"skills",
|
||||||
|
"locale",
|
||||||
|
"timezone",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserAdminSerializer(serializers.ModelSerializer):
|
||||||
|
user_id = serializers.UUIDField(source="id", read_only=True)
|
||||||
|
password = serializers.CharField(write_only=True, required=False)
|
||||||
|
trust_state = serializers.SerializerMethodField()
|
||||||
|
trust_score = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = (
|
||||||
|
"user_id",
|
||||||
|
"email",
|
||||||
|
"username",
|
||||||
|
"phone",
|
||||||
|
"full_name",
|
||||||
|
"given_name",
|
||||||
|
"family_name",
|
||||||
|
"avatar_url",
|
||||||
|
"gender",
|
||||||
|
"birth_date",
|
||||||
|
"province",
|
||||||
|
"country",
|
||||||
|
"city",
|
||||||
|
"skills",
|
||||||
|
"status",
|
||||||
|
"email_verified",
|
||||||
|
"mfa_enabled",
|
||||||
|
"trust_state",
|
||||||
|
"trust_score",
|
||||||
|
"locale",
|
||||||
|
"timezone",
|
||||||
|
"is_active",
|
||||||
|
"is_staff",
|
||||||
|
"password",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"last_login_at",
|
||||||
|
)
|
||||||
|
read_only_fields = (
|
||||||
|
"user_id",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"last_login_at",
|
||||||
|
"trust_state",
|
||||||
|
"trust_score",
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_trust_state(self, obj):
|
||||||
|
return TrustEngine.compute_trust_state(obj)
|
||||||
|
|
||||||
|
def get_trust_score(self, obj):
|
||||||
|
return TrustEngine.get_trust_score(obj)
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
password = validated_data.pop("password", None)
|
||||||
|
user = User(**validated_data)
|
||||||
|
if password:
|
||||||
|
user.set_password(password)
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
password = validated_data.pop("password", None)
|
||||||
|
if password:
|
||||||
|
instance.set_password(password)
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityDocumentSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = IdentityDocument
|
||||||
|
fields = (
|
||||||
|
"id",
|
||||||
|
"doc_type",
|
||||||
|
"file_url",
|
||||||
|
"metadata",
|
||||||
|
"verification_status",
|
||||||
|
"verified_at",
|
||||||
|
"expires_at",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
read_only_fields = (
|
||||||
|
"id",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
43
apps/api/apps/identity/summary.py
Normal file
43
apps/api/apps/identity/summary.py
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from apps.application.models import Application, ApplicationStatus
|
||||||
|
from apps.application.serializers import ApplicationSerializer
|
||||||
|
from apps.identity.serializers import UserSerializer
|
||||||
|
from apps.membership.models import Membership
|
||||||
|
from apps.membership.serializers import MembershipSerializer
|
||||||
|
from apps.organization.models import Organization
|
||||||
|
from apps.organization.serializers import OrganizationSerializer
|
||||||
|
from apps.product.models import Product
|
||||||
|
from apps.product.serializers import ProductSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class IdentitySummaryView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
user = request.user
|
||||||
|
memberships = Membership.objects.filter(user=user, status="active")
|
||||||
|
organizations = Organization.objects.filter(
|
||||||
|
memberships__user=user,
|
||||||
|
memberships__status="active",
|
||||||
|
).distinct()
|
||||||
|
applications = Application.objects.filter(status=ApplicationStatus.ACTIVE)
|
||||||
|
products = Product.objects.filter(is_active=True)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"user": UserSerializer(user).data,
|
||||||
|
"memberships": MembershipSerializer(memberships, many=True).data,
|
||||||
|
"organizations": OrganizationSerializer(
|
||||||
|
organizations,
|
||||||
|
many=True,
|
||||||
|
context={"request": request},
|
||||||
|
).data,
|
||||||
|
"applications": ApplicationSerializer(applications, many=True).data,
|
||||||
|
"products": ProductSerializer(products, many=True).data,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
94
apps/api/apps/identity/tests.py
Normal file
94
apps/api/apps/identity/tests.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from apps.identity.models import IdentityDocument
|
||||||
|
from apps.verification.models import (
|
||||||
|
EvidenceConfidence,
|
||||||
|
EvidenceType,
|
||||||
|
VerificationDimension,
|
||||||
|
)
|
||||||
|
from apps.verification.services import TrustEngine
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityDocumentApprovalCapabilityTests(TestCase):
|
||||||
|
"""FIX 2: approving an identity document is gated by a CapabilityPolicy."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
email="approver@example.com",
|
||||||
|
password="S3cure-Pass-123",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.doc = IdentityDocument.objects.create(
|
||||||
|
user=self.user, doc_type="passport", file_url="https://example.com/x"
|
||||||
|
)
|
||||||
|
self.url = f"/api/v1/identity/documents/{self.doc.id}/approve/"
|
||||||
|
|
||||||
|
def _grant_strong_trust(self):
|
||||||
|
TrustEngine.record_evidence(
|
||||||
|
person=self.user,
|
||||||
|
dimension=VerificationDimension.IDENTITY,
|
||||||
|
evidence_type=EvidenceType.PASSKEY,
|
||||||
|
method="webauthn",
|
||||||
|
provider="Identity Platform",
|
||||||
|
confidence=EvidenceConfidence.HIGH,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_approval_denied_without_sufficient_trust(self):
|
||||||
|
self.client.force_authenticate(self.user)
|
||||||
|
response = self.client.post(self.url)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_approval_allowed_with_strong_trust(self):
|
||||||
|
self._grant_strong_trust()
|
||||||
|
self.assertEqual(TrustEngine.compute_trust_state(self.user), "strong")
|
||||||
|
self.client.force_authenticate(self.user)
|
||||||
|
response = self.client.post(self.url)
|
||||||
|
self.assertEqual(response.status_code, 200, response.data)
|
||||||
|
self.doc.refresh_from_db()
|
||||||
|
self.assertEqual(self.doc.verification_status, "verified")
|
||||||
|
|
||||||
|
|
||||||
|
class UserModelTests(TestCase):
|
||||||
|
def test_user_id_is_uuid(self):
|
||||||
|
user = User.objects.create_user(email="uuid@example.com", password="S3cure-Pass-123")
|
||||||
|
self.assertIsInstance(user.id, uuid.UUID)
|
||||||
|
|
||||||
|
def test_identity_key_is_derived(self):
|
||||||
|
user = User.objects.create_user(email="key@example.com", password="S3cure-Pass-123")
|
||||||
|
self.assertTrue(user.identity_key.startswith("usr_"))
|
||||||
|
|
||||||
|
def test_user_id_is_immutable(self):
|
||||||
|
user = User.objects.create_user(email="immutable@example.com", password="S3cure-Pass-123")
|
||||||
|
original = user.id
|
||||||
|
user.save()
|
||||||
|
user.refresh_from_db()
|
||||||
|
self.assertEqual(user.id, original)
|
||||||
|
|
||||||
|
def test_email_unique(self):
|
||||||
|
User.objects.create_user(email="dup@example.com", password="S3cure-Pass-123")
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
User.objects.create_user(email="dup@example.com", password="S3cure-Pass-123")
|
||||||
|
|
||||||
|
def test_username_not_required(self):
|
||||||
|
user = User.objects.create_user(
|
||||||
|
email="nouser@example.com",
|
||||||
|
password="S3cure-Pass-123",
|
||||||
|
username=None,
|
||||||
|
)
|
||||||
|
self.assertIsNone(user.username)
|
||||||
|
|
||||||
|
def test_default_status_pending(self):
|
||||||
|
user = User.objects.create_user(email="pending@example.com", password="S3cure-Pass-123")
|
||||||
|
self.assertEqual(user.status, "pending")
|
||||||
|
|
||||||
|
def test_password_is_hashed(self):
|
||||||
|
user = User.objects.create_user(email="hash@example.com", password="S3cure-Pass-123")
|
||||||
|
self.assertNotEqual(user.password, "S3cure-Pass-123")
|
||||||
|
self.assertTrue(user.check_password("S3cure-Pass-123"))
|
||||||
15
apps/api/apps/identity/urls.py
Normal file
15
apps/api/apps/identity/urls.py
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.identity.summary import IdentitySummaryView
|
||||||
|
from apps.identity.views import (
|
||||||
|
IdentityDocumentApproveView,
|
||||||
|
IdentityDocumentListCreateView,
|
||||||
|
IdentityDocumentDetailView,
|
||||||
|
)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", IdentitySummaryView.as_view(), name="identity-summary"),
|
||||||
|
path("documents/", IdentityDocumentListCreateView.as_view(), name="identity-document-list"),
|
||||||
|
path("documents/<uuid:pk>/", IdentityDocumentDetailView.as_view(), name="identity-document-detail"),
|
||||||
|
path("documents/<uuid:pk>/approve/", IdentityDocumentApproveView.as_view(), name="identity-document-approve"),
|
||||||
|
]
|
||||||
16
apps/api/apps/identity/user_urls.py
Normal file
16
apps/api/apps/identity/user_urls.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
from rest_framework import viewsets
|
||||||
|
|
||||||
|
from apps.common.permissions import IsStaffOrReadOnly
|
||||||
|
from apps.identity.models import User
|
||||||
|
from apps.identity.serializers import UserAdminSerializer, UserSerializer
|
||||||
|
from apps.identity.views import UserViewSet
|
||||||
|
|
||||||
|
from django.urls import include, path
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"", UserViewSet, basename="users")
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", include(router.urls)),
|
||||||
|
]
|
||||||
81
apps/api/apps/identity/views.py
Normal file
81
apps/api/apps/identity/views.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
from rest_framework import viewsets, generics
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.common.pagination import DefaultPagination
|
||||||
|
from apps.common.permissions import IsStaffOrReadOnly
|
||||||
|
from apps.identity.models import User, IdentityDocument
|
||||||
|
from apps.identity.serializers import UserAdminSerializer, UserSerializer, IdentityDocumentSerializer
|
||||||
|
from apps.verification.permissions import CapabilityPermission
|
||||||
|
|
||||||
|
|
||||||
|
class ApproveIdentityDocumentPermission(CapabilityPermission):
|
||||||
|
capability_key = "approve_identity_document"
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityDocumentApproveView(generics.GenericAPIView):
|
||||||
|
"""FIX 2: approving a government identity document is a high-trust operation.
|
||||||
|
|
||||||
|
It is gated by the ``approve_identity_document`` CapabilityPolicy, so only
|
||||||
|
users whose verification evidence satisfies the policy may approve.
|
||||||
|
"""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated, ApproveIdentityDocumentPermission]
|
||||||
|
serializer_class = IdentityDocumentSerializer
|
||||||
|
|
||||||
|
def post(self, request, pk):
|
||||||
|
doc = get_object_or_404(IdentityDocument, pk=pk)
|
||||||
|
doc.verification_status = "verified"
|
||||||
|
doc.verified_at = timezone.now()
|
||||||
|
doc.save(update_fields=["verification_status", "verified_at", "updated_at"])
|
||||||
|
return Response(
|
||||||
|
IdentityDocumentSerializer(doc).data, status=status.HTTP_200_OK
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserViewSet(viewsets.ModelViewSet):
|
||||||
|
http_method_names = ["get", "post", "patch", "delete"]
|
||||||
|
permission_classes = [IsStaffOrReadOnly]
|
||||||
|
pagination_class = DefaultPagination
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
admin_serializer_class = UserAdminSerializer
|
||||||
|
search_fields = ("email", "full_name", "username", "phone")
|
||||||
|
filterset_fields = ("status", "is_active")
|
||||||
|
ordering_fields = ("created_at", "full_name", "email")
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
user = self.request.user
|
||||||
|
if user.is_staff:
|
||||||
|
return User.objects.all()
|
||||||
|
return User.objects.filter(pk=user.pk)
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.request.user.is_staff:
|
||||||
|
return UserAdminSerializer
|
||||||
|
return UserSerializer
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
def perform_update(self, serializer):
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityDocumentListCreateView(generics.ListCreateAPIView):
|
||||||
|
queryset = IdentityDocument.objects.all()
|
||||||
|
serializer_class = IdentityDocumentSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
pagination_class = DefaultPagination
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return IdentityDocument.objects.filter(user=self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityDocumentDetailView(generics.RetrieveUpdateDestroyAPIView):
|
||||||
|
queryset = IdentityDocument.objects.all()
|
||||||
|
serializer_class = IdentityDocumentSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
0
apps/api/apps/membership/__init__.py
Normal file
0
apps/api/apps/membership/__init__.py
Normal file
25
apps/api/apps/membership/admin.py
Normal file
25
apps/api/apps/membership/admin.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.membership.models import Membership, Ownership, Invitation
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Membership)
|
||||||
|
class MembershipAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "organization", "role", "status", "created_at")
|
||||||
|
list_filter = ("role", "status")
|
||||||
|
search_fields = ("user__email", "user__full_name", "organization__name")
|
||||||
|
readonly_fields = ("id", "created_at", "updated_at")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Ownership)
|
||||||
|
class OwnershipAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("organization", "owner", "ownership_type", "transferred_at")
|
||||||
|
list_filter = ("ownership_type",)
|
||||||
|
search_fields = ("organization__name", "owner__email", "owner__full_name")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Invitation)
|
||||||
|
class InvitationAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("email", "organization", "role", "status", "created_at")
|
||||||
|
list_filter = ("role", "status")
|
||||||
|
search_fields = ("email", "organization__name", "invited_by__email")
|
||||||
76
apps/api/apps/membership/migrations/0001_initial.py
Normal file
76
apps/api/apps/membership/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# Generated by Django 5.2.17 on 2026-08-15 09:02
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('organization', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Invitation',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('email', models.EmailField(max_length=254)),
|
||||||
|
('role', models.CharField(choices=[('owner', 'Owner'), ('admin', 'Admin'), ('member', 'Member')], default='member', max_length=16)),
|
||||||
|
('token', models.CharField(max_length=64, unique=True)),
|
||||||
|
('status', models.CharField(choices=[('pending', 'Pending'), ('accepted', 'Accepted'), ('rejected', 'Rejected'), ('expired', 'Expired')], default='pending', max_length=16)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('expires_at', models.DateTimeField()),
|
||||||
|
('accepted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('invited_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations_created', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='organization.organization')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'indexes': [models.Index(fields=['organization', 'status'], name='membership__organiz_dfca8c_idx'), models.Index(fields=['token'], name='membership__token_0b5448_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Membership',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('role', models.CharField(choices=[('owner', 'Owner'), ('admin', 'Admin'), ('member', 'Member')], default='member', max_length=16)),
|
||||||
|
('status', models.CharField(choices=[('active', 'Active'), ('invited', 'Invited'), ('pending', 'Pending'), ('deactivated', 'Deactivated')], default='active', max_length=16)),
|
||||||
|
('joined_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('invited_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invitations_sent', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='organization.organization')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'indexes': [models.Index(fields=['user'], name='membership__user_id_d1aef6_idx'), models.Index(fields=['organization'], name='membership__organiz_84a7e4_idx')],
|
||||||
|
'constraints': [models.UniqueConstraint(fields=('user', 'organization'), name='unique_membership')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Ownership',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('ownership_type', models.CharField(choices=[('primary', 'Primary Owner'), ('transferred', 'Transferred Owner')], default='primary', max_length=16)),
|
||||||
|
('transferred_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('reason', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ownership_history', to='organization.organization')),
|
||||||
|
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ownership_transactions', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('transferred_from', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ownership_transferred_from', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-transferred_at'],
|
||||||
|
'indexes': [models.Index(fields=['organization', 'ownership_type'], name='membership__organiz_46da30_idx'), models.Index(fields=['owner'], name='membership__owner_i_d44392_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 5.2.17 on 2026-08-19 08:34
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('membership', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='membership',
|
||||||
|
name='ended_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='Membership end date (تاریخ پایان)', null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/api/apps/membership/migrations/__init__.py
Normal file
0
apps/api/apps/membership/migrations/__init__.py
Normal file
164
apps/api/apps/membership/models.py
Normal file
164
apps/api/apps/membership/models.py
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from apps.common.models import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipRole(models.TextChoices):
|
||||||
|
OWNER = "owner", "Owner"
|
||||||
|
ADMIN = "admin", "Admin"
|
||||||
|
MEMBER = "member", "Member"
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipStatus(models.TextChoices):
|
||||||
|
ACTIVE = "active", "Active"
|
||||||
|
INVITED = "invited", "Invited"
|
||||||
|
PENDING = "pending", "Pending"
|
||||||
|
DEACTIVATED = "deactivated", "Deactivated"
|
||||||
|
|
||||||
|
|
||||||
|
class Membership(BaseModel):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
"identity.User",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="memberships",
|
||||||
|
)
|
||||||
|
organization = models.ForeignKey(
|
||||||
|
"organization.Organization",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="memberships",
|
||||||
|
)
|
||||||
|
role = models.CharField(
|
||||||
|
max_length=16,
|
||||||
|
choices=MembershipRole.choices,
|
||||||
|
default=MembershipRole.MEMBER,
|
||||||
|
)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=16,
|
||||||
|
choices=MembershipStatus.choices,
|
||||||
|
default=MembershipStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
joined_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
ended_at = models.DateTimeField(
|
||||||
|
null=True, blank=True, help_text="Membership end date (تاریخ پایان)"
|
||||||
|
)
|
||||||
|
invited_by = models.ForeignKey(
|
||||||
|
"identity.User",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="invitations_sent",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "organization"],
|
||||||
|
name="unique_membership",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user"]),
|
||||||
|
models.Index(fields=["organization"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user} @ {self.organization}"
|
||||||
|
|
||||||
|
|
||||||
|
class Ownership(BaseModel):
|
||||||
|
"""Organization ownership history and current primary owner."""
|
||||||
|
|
||||||
|
class OwnershipType(models.TextChoices):
|
||||||
|
PRIMARY = "primary", "Primary Owner"
|
||||||
|
TRANSFERRED = "transferred", "Transferred Owner"
|
||||||
|
|
||||||
|
organization = models.ForeignKey(
|
||||||
|
"organization.Organization",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="ownership_history",
|
||||||
|
)
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
"identity.User",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="ownership_transactions",
|
||||||
|
)
|
||||||
|
ownership_type = models.CharField(
|
||||||
|
max_length=16,
|
||||||
|
choices=OwnershipType.choices,
|
||||||
|
default=OwnershipType.PRIMARY,
|
||||||
|
)
|
||||||
|
transferred_from = models.ForeignKey(
|
||||||
|
"identity.User",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="ownership_transferred_from",
|
||||||
|
)
|
||||||
|
transferred_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
reason = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-transferred_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["organization", "ownership_type"]),
|
||||||
|
models.Index(fields=["owner"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.get_ownership_type_display()} ownership of {self.organization} by {self.owner}"
|
||||||
|
|
||||||
|
|
||||||
|
class Invitation(BaseModel):
|
||||||
|
"""Invitation to join an organization."""
|
||||||
|
|
||||||
|
class InvitationStatus(models.TextChoices):
|
||||||
|
PENDING = "pending", "Pending"
|
||||||
|
ACCEPTED = "accepted", "Accepted"
|
||||||
|
REJECTED = "rejected", "Rejected"
|
||||||
|
EXPIRED = "expired", "Expired"
|
||||||
|
|
||||||
|
organization = models.ForeignKey(
|
||||||
|
"organization.Organization",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="invitations",
|
||||||
|
)
|
||||||
|
email = models.EmailField()
|
||||||
|
invited_by = models.ForeignKey(
|
||||||
|
"identity.User",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="invitations_created",
|
||||||
|
)
|
||||||
|
role = models.CharField(
|
||||||
|
max_length=16,
|
||||||
|
choices=MembershipRole.choices,
|
||||||
|
default=MembershipRole.MEMBER,
|
||||||
|
)
|
||||||
|
token = models.CharField(max_length=64, unique=True)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=16,
|
||||||
|
choices=InvitationStatus.choices,
|
||||||
|
default=InvitationStatus.PENDING,
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
expires_at = models.DateTimeField()
|
||||||
|
accepted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["organization", "status"]),
|
||||||
|
models.Index(fields=["token"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Invitation for {self.email} to join {self.organization}"
|
||||||
|
|
||||||
|
def is_valid(self):
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
return (
|
||||||
|
self.status == InvitationStatus.PENDING
|
||||||
|
and not self.expires_at < timezone.now()
|
||||||
|
and self.accepted_at is None
|
||||||
|
)
|
||||||
83
apps/api/apps/membership/serializers.py
Normal file
83
apps/api/apps/membership/serializers.py
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.membership.models import Membership, Ownership, Invitation
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipSerializer(serializers.ModelSerializer):
|
||||||
|
user_id = serializers.UUIDField(source="user.id", read_only=True)
|
||||||
|
user_name = serializers.CharField(source="user.full_name", read_only=True)
|
||||||
|
user_email = serializers.EmailField(source="user.email", read_only=True)
|
||||||
|
organization_id = serializers.UUIDField(source="organization.id", read_only=True)
|
||||||
|
organization_name = serializers.CharField(
|
||||||
|
source="organization.name", read_only=True
|
||||||
|
)
|
||||||
|
organization_slug = serializers.SlugField(
|
||||||
|
source="organization.slug", read_only=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Membership
|
||||||
|
fields = (
|
||||||
|
"id",
|
||||||
|
"user_id",
|
||||||
|
"user_name",
|
||||||
|
"user_email",
|
||||||
|
"organization_id",
|
||||||
|
"organization_name",
|
||||||
|
"organization_slug",
|
||||||
|
"role",
|
||||||
|
"status",
|
||||||
|
"joined_at",
|
||||||
|
"ended_at",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipSerializer(serializers.ModelSerializer):
|
||||||
|
organization_id = serializers.UUIDField(source="organization.id", read_only=True)
|
||||||
|
organization_name = serializers.CharField(
|
||||||
|
source="organization.name", read_only=True
|
||||||
|
)
|
||||||
|
owner_id = serializers.UUIDField(source="owner.id", read_only=True)
|
||||||
|
owner_name = serializers.CharField(source="owner.full_name", read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Ownership
|
||||||
|
fields = (
|
||||||
|
"id",
|
||||||
|
"organization_id",
|
||||||
|
"organization_name",
|
||||||
|
"owner_id",
|
||||||
|
"owner_name",
|
||||||
|
"ownership_type",
|
||||||
|
"transferred_from",
|
||||||
|
"transferred_at",
|
||||||
|
"reason",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InvitationSerializer(serializers.ModelSerializer):
|
||||||
|
organization_id = serializers.UUIDField(source="organization.id", read_only=True)
|
||||||
|
organization_name = serializers.CharField(
|
||||||
|
source="organization.name", read_only=True
|
||||||
|
)
|
||||||
|
invited_by_name = serializers.CharField(
|
||||||
|
source="invited_by.full_name", read_only=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Invitation
|
||||||
|
fields = (
|
||||||
|
"id",
|
||||||
|
"organization_id",
|
||||||
|
"organization_name",
|
||||||
|
"email",
|
||||||
|
"invited_by_name",
|
||||||
|
"role",
|
||||||
|
"token",
|
||||||
|
"status",
|
||||||
|
"created_at",
|
||||||
|
"expires_at",
|
||||||
|
"accepted_at",
|
||||||
|
)
|
||||||
118
apps/api/apps/membership/tests.py
Normal file
118
apps/api/apps/membership/tests.py
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.identity.models import User
|
||||||
|
from apps.organization.models import Organization
|
||||||
|
from apps.membership.models import Membership, Ownership, Invitation
|
||||||
|
from apps.membership.serializers import InvitationSerializer
|
||||||
|
from apps.common.models import OutboxEvent
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class InvitationAcceptFlowTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.owner = User.objects.create_user(
|
||||||
|
email="owner@example.com", password="S3cure-Pass-123", status="active"
|
||||||
|
)
|
||||||
|
self.invited = User.objects.create_user(
|
||||||
|
email="invited@example.com", password="S3cure-Pass-123", status="active"
|
||||||
|
)
|
||||||
|
self.org = Organization.objects.create(name="OrgFlow")
|
||||||
|
|
||||||
|
def _auth(self, user):
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
# create a session/login token via password login
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/login/",
|
||||||
|
{"email": user.email, "password": "S3cure-Pass-123"},
|
||||||
|
)
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {resp.data['access']}")
|
||||||
|
return client
|
||||||
|
|
||||||
|
def test_invitation_accept_creates_membership(self):
|
||||||
|
invitation = Invitation.objects.create(
|
||||||
|
organization=self.org,
|
||||||
|
email=self.invited.email,
|
||||||
|
invited_by=self.owner,
|
||||||
|
role="member",
|
||||||
|
token="tok-123",
|
||||||
|
expires_at=timezone.now() + timezone.timedelta(days=1),
|
||||||
|
)
|
||||||
|
client = self._auth(self.invited)
|
||||||
|
resp = client.post(f"/api/v1/membership/invitations/{invitation.id}/accept/")
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
invitation.refresh_from_db()
|
||||||
|
self.assertEqual(invitation.status, "accepted")
|
||||||
|
self.assertIsNotNone(invitation.accepted_at)
|
||||||
|
membership = Membership.objects.filter(
|
||||||
|
user=self.invited, organization=self.org
|
||||||
|
).first()
|
||||||
|
self.assertIsNotNone(membership)
|
||||||
|
self.assertEqual(membership.role, "member")
|
||||||
|
self.assertEqual(membership.status, "active")
|
||||||
|
|
||||||
|
def test_invitation_accept_wrong_user_forbidden(self):
|
||||||
|
other = User.objects.create_user(
|
||||||
|
email="other@example.com", password="S3cure-Pass-123", status="active"
|
||||||
|
)
|
||||||
|
invitation = Invitation.objects.create(
|
||||||
|
organization=self.org,
|
||||||
|
email=self.invited.email,
|
||||||
|
invited_by=self.owner,
|
||||||
|
role="member",
|
||||||
|
token="tok-999",
|
||||||
|
expires_at=timezone.now() + timezone.timedelta(days=1),
|
||||||
|
)
|
||||||
|
client = self._auth(other)
|
||||||
|
resp = client.post(f"/api/v1/membership/invitations/{invitation.id}/accept/")
|
||||||
|
self.assertEqual(resp.status_code, 403)
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipTransferTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.owner = User.objects.create_user(
|
||||||
|
email="owner2@example.com", password="S3cure-Pass-123", status="active"
|
||||||
|
)
|
||||||
|
self.new_owner = User.objects.create_user(
|
||||||
|
email="newowner@example.com", password="S3cure-Pass-123", status="active"
|
||||||
|
)
|
||||||
|
self.org = Organization.objects.create(name="TransferOrg")
|
||||||
|
Membership.objects.create(
|
||||||
|
user=self.owner, organization=self.org, role="owner", status="active"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _auth(self, user):
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/login/",
|
||||||
|
{"email": user.email, "password": "S3cure-Pass-123"},
|
||||||
|
)
|
||||||
|
client.credentials(HTTP_AUTHORIZATION=f"Bearer {resp.data['access']}")
|
||||||
|
return client
|
||||||
|
|
||||||
|
def test_transfer_ownership(self):
|
||||||
|
client = self._auth(self.owner)
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/organizations/{self.org.slug}/transfer-ownership/",
|
||||||
|
{"new_owner_id": str(self.new_owner.id), "reason": "handover"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
# new owner now has owner membership
|
||||||
|
new_ms = Membership.objects.get(user=self.new_owner, organization=self.org)
|
||||||
|
self.assertEqual(new_ms.role, "owner")
|
||||||
|
# old owner demoted to member
|
||||||
|
old_ms = Membership.objects.get(user=self.owner, organization=self.org)
|
||||||
|
self.assertEqual(old_ms.role, "member")
|
||||||
|
# ownership history recorded
|
||||||
|
ownership = Ownership.objects.filter(
|
||||||
|
organization=self.org, owner=self.new_owner
|
||||||
|
).first()
|
||||||
|
self.assertIsNotNone(ownership)
|
||||||
|
self.assertEqual(ownership.ownership_type, "transferred")
|
||||||
|
self.assertEqual(ownership.transferred_from, self.owner)
|
||||||
13
apps/api/apps/membership/urls.py
Normal file
13
apps/api/apps/membership/urls.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
from django.urls import include, path
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from apps.membership.views import MembershipViewSet, OwnershipViewSet, InvitationViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"memberships", MembershipViewSet, basename="membership")
|
||||||
|
router.register(r"ownerships", OwnershipViewSet, basename="ownership")
|
||||||
|
router.register(r"invitations", InvitationViewSet, basename="invitation")
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", include(router.urls)),
|
||||||
|
]
|
||||||
124
apps/api/apps/membership/views.py
Normal file
124
apps/api/apps/membership/views.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework import viewsets
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import status as http_status
|
||||||
|
|
||||||
|
from apps.common.pagination import DefaultPagination
|
||||||
|
from apps.common.permissions import IsStaffOrReadOnly
|
||||||
|
from apps.common.models import OutboxEvent
|
||||||
|
from apps.membership.models import Membership, Ownership, Invitation
|
||||||
|
from apps.membership.serializers import (
|
||||||
|
MembershipSerializer,
|
||||||
|
OwnershipSerializer,
|
||||||
|
InvitationSerializer,
|
||||||
|
)
|
||||||
|
from apps.security.models import SecurityEventType
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Membership.objects.all()
|
||||||
|
serializer_class = MembershipSerializer
|
||||||
|
permission_classes = [IsStaffOrReadOnly]
|
||||||
|
pagination_class = DefaultPagination
|
||||||
|
filterset_fields = ("organization", "role", "status", "user")
|
||||||
|
ordering_fields = ("joined_at", "created_at")
|
||||||
|
ordering = ("-joined_at",)
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
membership = serializer.save()
|
||||||
|
OutboxEvent.objects.publish(
|
||||||
|
OutboxEvent.EventType.MEMBERSHIP,
|
||||||
|
user=membership.user,
|
||||||
|
title="Membership created",
|
||||||
|
message=f"You were added to a new organization membership ({membership.role}).",
|
||||||
|
metadata={
|
||||||
|
"organization": str(membership.organization_id),
|
||||||
|
"role": membership.role,
|
||||||
|
},
|
||||||
|
security_event_type=SecurityEventType.MEMBERSHIP_CHANGED,
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def my(self, request):
|
||||||
|
qs = Membership.objects.filter(user=request.user)
|
||||||
|
page = self.paginate_queryset(qs)
|
||||||
|
return self.get_paginated_response(MembershipSerializer(page, many=True).data)
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
queryset = Ownership.objects.all()
|
||||||
|
serializer_class = OwnershipSerializer
|
||||||
|
permission_classes = [IsStaffOrReadOnly]
|
||||||
|
pagination_class = DefaultPagination
|
||||||
|
filterset_fields = ("organization", "ownership_type", "owner")
|
||||||
|
ordering = ("-transferred_at",)
|
||||||
|
|
||||||
|
|
||||||
|
class InvitationViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Invitation.objects.all()
|
||||||
|
serializer_class = InvitationSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
pagination_class = DefaultPagination
|
||||||
|
filterset_fields = ("organization", "status", "role")
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
invitation = serializer.save()
|
||||||
|
invited_user = User.objects.filter(email=invitation.email).first()
|
||||||
|
OutboxEvent.objects.publish(
|
||||||
|
OutboxEvent.EventType.MEMBERSHIP,
|
||||||
|
user=invited_user,
|
||||||
|
title="Invitation received",
|
||||||
|
message=f"You have been invited to join {invitation.organization.name} as {invitation.role}.",
|
||||||
|
metadata={
|
||||||
|
"organization": str(invitation.organization_id),
|
||||||
|
"role": invitation.role,
|
||||||
|
},
|
||||||
|
security_event_type=SecurityEventType.INVITATION_CREATED,
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"])
|
||||||
|
def accept(self, request, pk=None):
|
||||||
|
invitation = self.get_object()
|
||||||
|
if invitation.status != "pending":
|
||||||
|
return Response(
|
||||||
|
{"detail": "Invitation is not pending."},
|
||||||
|
status=http_status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
if not request.user.is_staff and request.user.email != invitation.email:
|
||||||
|
return Response(
|
||||||
|
{"detail": "This invitation is not for you."},
|
||||||
|
status=http_status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
invitation.status = "accepted"
|
||||||
|
invitation.accepted_at = timezone.now()
|
||||||
|
invitation.save(update_fields=["status", "accepted_at", "updated_at"])
|
||||||
|
membership, _ = Membership.objects.get_or_create(
|
||||||
|
user=request.user,
|
||||||
|
organization=invitation.organization,
|
||||||
|
defaults={"role": invitation.role, "status": "active"},
|
||||||
|
)
|
||||||
|
if not _:
|
||||||
|
membership.role = invitation.role
|
||||||
|
membership.status = "active"
|
||||||
|
membership.save(update_fields=["role", "status", "updated_at"])
|
||||||
|
OutboxEvent.objects.publish(
|
||||||
|
OutboxEvent.EventType.MEMBERSHIP,
|
||||||
|
user=request.user,
|
||||||
|
title="Invitation accepted",
|
||||||
|
message=f"You joined {invitation.organization.name}.",
|
||||||
|
metadata={"organization": str(invitation.organization_id)},
|
||||||
|
security_event_type=SecurityEventType.INVITATION_ACCEPTED,
|
||||||
|
)
|
||||||
|
return Response(InvitationSerializer(invitation).data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["get"])
|
||||||
|
def my(self, request):
|
||||||
|
qs = Invitation.objects.filter(invited_by=request.user)
|
||||||
|
page = self.paginate_queryset(qs)
|
||||||
|
return self.get_paginated_response(InvitationSerializer(page, many=True).data)
|
||||||
0
apps/api/apps/oauth/__init__.py
Normal file
0
apps/api/apps/oauth/__init__.py
Normal file
36
apps/api/apps/oauth/admin.py
Normal file
36
apps/api/apps/oauth/admin.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.oauth.models import (
|
||||||
|
AccessToken,
|
||||||
|
AuthorizationCode,
|
||||||
|
OAuthScope,
|
||||||
|
RefreshToken,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(OAuthScope)
|
||||||
|
class OAuthScopeAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("code", "description", "is_default", "is_system")
|
||||||
|
search_fields = ("code",)
|
||||||
|
readonly_fields = ("id", "created_at", "updated_at")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(AuthorizationCode)
|
||||||
|
class AuthorizationCodeAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "application", "used", "expires_at", "created_at")
|
||||||
|
search_fields = ("user__email", "application__name")
|
||||||
|
readonly_fields = ("id", "code_hash", "created_at", "updated_at")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(AccessToken)
|
||||||
|
class AccessTokenAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "application", "session", "revoked", "expires_at", "created_at")
|
||||||
|
search_fields = ("user__email",)
|
||||||
|
readonly_fields = ("id", "token_hash", "created_at", "updated_at")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(RefreshToken)
|
||||||
|
class RefreshTokenAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "application", "session", "revoked", "expires_at", "created_at")
|
||||||
|
search_fields = ("user__email",)
|
||||||
|
readonly_fields = ("id", "token_hash", "created_at", "updated_at")
|
||||||
7
apps/api/apps/oauth/discovery_urls.py
Normal file
7
apps/api/apps/oauth/discovery_urls.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.oauth.views import DiscoveryView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", DiscoveryView.as_view(), name="oidc-discovery"),
|
||||||
|
]
|
||||||
93
apps/api/apps/oauth/migrations/0001_initial.py
Normal file
93
apps/api/apps/oauth/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Generated by Django 5.2.17 on 2026-08-13 13:33
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('application', '0001_initial'),
|
||||||
|
('session', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='OAuthScope',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('code', models.CharField(max_length=64, unique=True)),
|
||||||
|
('description', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('is_default', models.BooleanField(default=False)),
|
||||||
|
('is_system', models.BooleanField(default=False)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['code'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AuthorizationCode',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('code_hash', models.CharField(max_length=64, unique=True)),
|
||||||
|
('redirect_uri', models.URLField(blank=True, default='', max_length=500)),
|
||||||
|
('expires_at', models.DateTimeField()),
|
||||||
|
('used', models.BooleanField(default=False)),
|
||||||
|
('consumed_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('nonce', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('code_challenge', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('code_challenge_method', models.CharField(blank=True, default='', max_length=16)),
|
||||||
|
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='authorization_codes', to='application.application')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='authorization_codes', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('scopes', models.ManyToManyField(related_name='authorization_codes', to='oauth.oauthscope')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AccessToken',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('token_hash', models.CharField(max_length=64, unique=True)),
|
||||||
|
('expires_at', models.DateTimeField()),
|
||||||
|
('revoked', models.BooleanField(default=False)),
|
||||||
|
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_tokens', to='application.application')),
|
||||||
|
('session', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_tokens', to='session.session')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_tokens', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('scopes', models.ManyToManyField(related_name='access_tokens', to='oauth.oauthscope')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='RefreshToken',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('token_hash', models.CharField(max_length=64, unique=True)),
|
||||||
|
('expires_at', models.DateTimeField()),
|
||||||
|
('revoked', models.BooleanField(default=False)),
|
||||||
|
('replaced_by', models.CharField(blank=True, max_length=64, null=True)),
|
||||||
|
('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='refresh_tokens', to='application.application')),
|
||||||
|
('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='refresh_tokens', to='session.session')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='refresh_tokens', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("oauth", "0001_initial"),
|
||||||
|
("organization", "0003_organization_username"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="authorizationcode",
|
||||||
|
name="organization",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="authorization_codes",
|
||||||
|
to="organization.organization",
|
||||||
|
help_text="Active business (کسبوکار فعال) for this authorization",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
33
apps/api/apps/oauth/migrations/0003_consent.py
Normal file
33
apps/api/apps/oauth/migrations/0003_consent.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Generated by Django 5.2.17 on 2026-08-22 13:11
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('application', '0002_initial'),
|
||||||
|
('oauth', '0002_authorizationcode_organization'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Consent',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consents', to='application.application')),
|
||||||
|
('scopes', models.ManyToManyField(related_name='consents', to='oauth.oauthscope')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consents', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'constraints': [models.UniqueConstraint(fields=('user', 'application'), name='unique_user_application_consent')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
apps/api/apps/oauth/migrations/__init__.py
Normal file
0
apps/api/apps/oauth/migrations/__init__.py
Normal file
145
apps/api/apps/oauth/models.py
Normal file
145
apps/api/apps/oauth/models.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.common.models import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthScope(BaseModel):
|
||||||
|
code = models.CharField(max_length=64, unique=True)
|
||||||
|
description = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
is_default = models.BooleanField(default=False)
|
||||||
|
is_system = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["code"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.code
|
||||||
|
|
||||||
|
|
||||||
|
class Consent(BaseModel):
|
||||||
|
"""Recorded user consent for an OAuth application (Google-style).
|
||||||
|
|
||||||
|
Once a user approves a product, subsequent SSO authorizations skip the
|
||||||
|
consent prompt for that application.
|
||||||
|
"""
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
"identity.User",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="consents",
|
||||||
|
)
|
||||||
|
application = models.ForeignKey(
|
||||||
|
"application.Application",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="consents",
|
||||||
|
)
|
||||||
|
scopes = models.ManyToManyField(OAuthScope, related_name="consents")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["user", "application"],
|
||||||
|
name="unique_user_application_consent",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user} consented to {self.application}"
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorizationCode(BaseModel):
|
||||||
|
code_hash = models.CharField(max_length=64, unique=True)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
"identity.User",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="authorization_codes",
|
||||||
|
)
|
||||||
|
application = models.ForeignKey(
|
||||||
|
"application.Application",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="authorization_codes",
|
||||||
|
)
|
||||||
|
redirect_uri = models.URLField(max_length=500, blank=True, default="")
|
||||||
|
scopes = models.ManyToManyField(OAuthScope, related_name="authorization_codes")
|
||||||
|
expires_at = models.DateTimeField()
|
||||||
|
used = models.BooleanField(default=False)
|
||||||
|
consumed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
nonce = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
code_challenge = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
code_challenge_method = models.CharField(max_length=16, blank=True, default="")
|
||||||
|
organization = models.ForeignKey(
|
||||||
|
"organization.Organization",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="authorization_codes",
|
||||||
|
help_text="Active business (کسبوکار فعال) for this authorization",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
def is_valid(self):
|
||||||
|
return not self.used and self.expires_at > timezone.now()
|
||||||
|
|
||||||
|
|
||||||
|
class AccessToken(BaseModel):
|
||||||
|
token_hash = models.CharField(max_length=64, unique=True)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
"identity.User",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="access_tokens",
|
||||||
|
)
|
||||||
|
application = models.ForeignKey(
|
||||||
|
"application.Application",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="access_tokens",
|
||||||
|
)
|
||||||
|
session = models.ForeignKey(
|
||||||
|
"session.Session",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="access_tokens",
|
||||||
|
)
|
||||||
|
scopes = models.ManyToManyField(OAuthScope, related_name="access_tokens")
|
||||||
|
expires_at = models.DateTimeField()
|
||||||
|
revoked = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
def is_valid(self):
|
||||||
|
return not self.revoked and self.expires_at > timezone.now()
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshToken(BaseModel):
|
||||||
|
token_hash = models.CharField(max_length=64, unique=True)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
"identity.User",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="refresh_tokens",
|
||||||
|
)
|
||||||
|
application = models.ForeignKey(
|
||||||
|
"application.Application",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="refresh_tokens",
|
||||||
|
)
|
||||||
|
session = models.ForeignKey(
|
||||||
|
"session.Session",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="refresh_tokens",
|
||||||
|
)
|
||||||
|
expires_at = models.DateTimeField()
|
||||||
|
revoked = models.BooleanField(default=False)
|
||||||
|
replaced_by = models.CharField(max_length=64, null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
def is_valid(self):
|
||||||
|
return not self.revoked and self.expires_at > timezone.now()
|
||||||
9
apps/api/apps/oauth/serializers.py
Normal file
9
apps/api/apps/oauth/serializers.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.oauth.models import OAuthScope
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthScopeSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = OAuthScope
|
||||||
|
fields = ("id", "code", "description", "is_default", "is_system")
|
||||||
390
apps/api/apps/oauth/services.py
Normal file
390
apps/api/apps/oauth/services.py
Normal file
|
|
@ -0,0 +1,390 @@
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.application.models import Application, ApplicationStatus
|
||||||
|
from apps.common.utils import generate_token, hash_token
|
||||||
|
from apps.oauth.models import AuthorizationCode, OAuthScope, RefreshToken
|
||||||
|
from apps.product.models import Product
|
||||||
|
from apps.session.models import Session, SessionType
|
||||||
|
|
||||||
|
|
||||||
|
def validate_authorize_request(params):
|
||||||
|
"""Validate OAuth2 authorization request parameters.
|
||||||
|
|
||||||
|
Returns (application, redirect_uri, scopes, error_response).
|
||||||
|
If validation fails, application is None and error_response contains
|
||||||
|
the error details for the redirect.
|
||||||
|
"""
|
||||||
|
client_id = params.get("client_id", "")
|
||||||
|
response_type = params.get("response_type", "")
|
||||||
|
redirect_uri = params.get("redirect_uri", "")
|
||||||
|
scope = params.get("scope", "")
|
||||||
|
state = params.get("state", "")
|
||||||
|
code_challenge = params.get("code_challenge", "")
|
||||||
|
code_challenge_method = params.get("code_challenge_method", "")
|
||||||
|
|
||||||
|
try:
|
||||||
|
application = Application.objects.get(
|
||||||
|
client_id=client_id,
|
||||||
|
status=ApplicationStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
except Application.DoesNotExist:
|
||||||
|
return None, None, None, {"error": "unauthorized_client", "state": state}
|
||||||
|
|
||||||
|
if response_type != "code":
|
||||||
|
return (
|
||||||
|
application,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
{"error": "unsupported_response_type", "state": state},
|
||||||
|
)
|
||||||
|
|
||||||
|
if redirect_uri not in application.redirect_uris:
|
||||||
|
return (
|
||||||
|
application,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "Invalid redirect_uri",
|
||||||
|
"state": state,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
requested_scopes = scope.split() if scope else []
|
||||||
|
if requested_scopes:
|
||||||
|
allowed_scope_codes = set(application.scopes.values_list("code", flat=True))
|
||||||
|
for s in requested_scopes:
|
||||||
|
if (
|
||||||
|
s not in allowed_scope_codes
|
||||||
|
and not application.scopes.filter(code=s).exists()
|
||||||
|
):
|
||||||
|
return (
|
||||||
|
application,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
{"error": "invalid_scope", "state": state},
|
||||||
|
)
|
||||||
|
|
||||||
|
if code_challenge_method and code_challenge_method not in ("S256", "plain"):
|
||||||
|
return (
|
||||||
|
application,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "Unsupported code_challenge_method",
|
||||||
|
"state": state,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return application, redirect_uri, requested_scopes, None
|
||||||
|
|
||||||
|
|
||||||
|
def create_authorization_code(
|
||||||
|
user,
|
||||||
|
application,
|
||||||
|
redirect_uri,
|
||||||
|
scopes,
|
||||||
|
nonce="",
|
||||||
|
code_challenge="",
|
||||||
|
code_challenge_method="",
|
||||||
|
organization=None,
|
||||||
|
):
|
||||||
|
"""Create a new authorization code and return the raw code string."""
|
||||||
|
raw_code = secrets.token_urlsafe(32)
|
||||||
|
auth_code = AuthorizationCode.objects.create(
|
||||||
|
code_hash=hash_token(raw_code),
|
||||||
|
user=user,
|
||||||
|
application=application,
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
|
nonce=nonce,
|
||||||
|
code_challenge=code_challenge,
|
||||||
|
code_challenge_method=code_challenge_method,
|
||||||
|
organization=organization,
|
||||||
|
expires_at=timezone.now() + timedelta(minutes=10),
|
||||||
|
)
|
||||||
|
# Persist the requested scopes so the issued token is limited to exactly
|
||||||
|
# what the user consented to (least privilege), not every app scope.
|
||||||
|
if scopes:
|
||||||
|
auth_code.scopes.set(OAuthScope.objects.filter(code__in=scopes))
|
||||||
|
return raw_code
|
||||||
|
|
||||||
|
|
||||||
|
def _build_scope_list(application, requested_scopes):
|
||||||
|
"""Return the list of scope codes for the token."""
|
||||||
|
if requested_scopes:
|
||||||
|
return requested_scopes
|
||||||
|
if application.scopes.exists():
|
||||||
|
return list(application.scopes.values_list("code", flat=True))
|
||||||
|
return ["openid"]
|
||||||
|
|
||||||
|
|
||||||
|
def build_id_token(user, client_id, scopes, nonce="", auth_time=None):
|
||||||
|
"""Build a standard OIDC id_token (signed JWT) for the user.
|
||||||
|
|
||||||
|
Mirrors what Google returns: `iss`, `sub`, `aud`, `exp`, `iat`,
|
||||||
|
`auth_time`, `nonce` plus scope-driven claims (profile/email/phone) and
|
||||||
|
the platform-specific `identity_verified` / `trust_state` claims so
|
||||||
|
products can render a verified badge without extra calls.
|
||||||
|
"""
|
||||||
|
from datetime import timezone as dt_timezone
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from rest_framework_simplejwt.tokens import AccessToken
|
||||||
|
|
||||||
|
from apps.verification.services import TrustEngine
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
epoch = int(now.timestamp())
|
||||||
|
exp = int((now + settings.SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"]).timestamp())
|
||||||
|
|
||||||
|
if auth_time is None:
|
||||||
|
auth_time = user.last_login_at or user.created_at
|
||||||
|
auth_time_epoch = int(auth_time.timestamp()) if auth_time else epoch
|
||||||
|
|
||||||
|
scopes_set = set(scopes or ["openid"])
|
||||||
|
claims = {
|
||||||
|
"iss": settings.OIDC_ISSUER,
|
||||||
|
"sub": str(user.id),
|
||||||
|
"aud": client_id,
|
||||||
|
"exp": exp,
|
||||||
|
"iat": epoch,
|
||||||
|
"auth_time": auth_time_epoch,
|
||||||
|
"token_type": "id",
|
||||||
|
"identity_verified": bool(user.identity_verified),
|
||||||
|
"trust_state": TrustEngine.compute_trust_state(user),
|
||||||
|
}
|
||||||
|
if nonce:
|
||||||
|
claims["nonce"] = nonce
|
||||||
|
|
||||||
|
if "profile" in scopes_set:
|
||||||
|
claims.update(
|
||||||
|
{
|
||||||
|
"name": user.full_name or user.display_name,
|
||||||
|
"given_name": user.given_name,
|
||||||
|
"family_name": user.family_name,
|
||||||
|
"preferred_username": user.username or user.email,
|
||||||
|
"picture": user.avatar_url or "",
|
||||||
|
"locale": user.locale or "fa",
|
||||||
|
"updated_at": int(user.updated_at.timestamp())
|
||||||
|
if user.updated_at
|
||||||
|
else epoch,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if "email" in scopes_set:
|
||||||
|
claims["email"] = user.email or ""
|
||||||
|
claims["email_verified"] = bool(user.email_verified)
|
||||||
|
if "phone" in scopes_set:
|
||||||
|
claims["phone_number"] = user.phone or ""
|
||||||
|
claims["phone_number_verified"] = bool(user.phone_verified)
|
||||||
|
|
||||||
|
idt = AccessToken()
|
||||||
|
idt.payload.update(claims)
|
||||||
|
return str(idt)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_session(user, application):
|
||||||
|
"""Create an API session for the OAuth2 client, scoped to the product."""
|
||||||
|
refresh_lifetime = settings.SIMPLE_JWT["REFRESH_TOKEN_LIFETIME"]
|
||||||
|
session = Session.objects.create(
|
||||||
|
user=user,
|
||||||
|
application=application,
|
||||||
|
session_type=SessionType.API,
|
||||||
|
ip_address="",
|
||||||
|
user_agent="",
|
||||||
|
device_name="OAuth2 Client",
|
||||||
|
expires_at=timezone.now() + refresh_lifetime,
|
||||||
|
)
|
||||||
|
product = Product.objects.filter(key=application.product_key).first()
|
||||||
|
if product:
|
||||||
|
session.product = product
|
||||||
|
session.save(update_fields=["product", "updated_at"])
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def issue_tokens_for_code(
|
||||||
|
raw_code, client_id, client_secret, redirect_uri, code_verifier=None
|
||||||
|
):
|
||||||
|
"""Exchange an authorization code for access + refresh tokens.
|
||||||
|
|
||||||
|
Returns (tokens_dict, error_dict).
|
||||||
|
"""
|
||||||
|
from apps.application.models import Application
|
||||||
|
|
||||||
|
try:
|
||||||
|
application = Application.objects.get(
|
||||||
|
client_id=client_id,
|
||||||
|
status=ApplicationStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
except Application.DoesNotExist:
|
||||||
|
return None, {"error": "invalid_client"}
|
||||||
|
|
||||||
|
expected_hash = hash_token(client_secret)
|
||||||
|
if not hmac.compare_digest(application.client_secret_hash, expected_hash):
|
||||||
|
return None, {"error": "invalid_client"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
auth_code = AuthorizationCode.objects.select_related("user", "application").get(
|
||||||
|
code_hash=hash_token(raw_code),
|
||||||
|
)
|
||||||
|
except AuthorizationCode.DoesNotExist:
|
||||||
|
return None, {"error": "invalid_grant"}
|
||||||
|
|
||||||
|
if not auth_code.is_valid():
|
||||||
|
return None, {
|
||||||
|
"error": "invalid_grant",
|
||||||
|
"error_description": "Authorization code has expired or been used",
|
||||||
|
}
|
||||||
|
|
||||||
|
if redirect_uri != auth_code.redirect_uri:
|
||||||
|
return None, {
|
||||||
|
"error": "invalid_grant",
|
||||||
|
"error_description": "redirect_uri mismatch",
|
||||||
|
}
|
||||||
|
|
||||||
|
if auth_code.code_challenge and auth_code.code_challenge_method:
|
||||||
|
if auth_code.code_challenge_method == "S256":
|
||||||
|
verifier_hash = hashlib.sha256(code_verifier.encode("utf-8")).hexdigest()
|
||||||
|
if not hmac.compare_digest(verifier_hash, auth_code.code_challenge):
|
||||||
|
return None, {
|
||||||
|
"error": "invalid_grant",
|
||||||
|
"error_description": "PKCE verification failed",
|
||||||
|
}
|
||||||
|
elif auth_code.code_challenge_method == "plain":
|
||||||
|
if not hmac.compare_digest(code_verifier or "", auth_code.code_challenge):
|
||||||
|
return None, {
|
||||||
|
"error": "invalid_grant",
|
||||||
|
"error_description": "PKCE verification failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
auth_code.used = True
|
||||||
|
auth_code.consumed_at = timezone.now()
|
||||||
|
auth_code.save(update_fields=["used", "consumed_at", "updated_at"])
|
||||||
|
|
||||||
|
scope_codes = list(auth_code.scopes.values_list("code", flat=True))
|
||||||
|
scope_list = scope_codes or _build_scope_list(application, [])
|
||||||
|
user = auth_code.user
|
||||||
|
organization = auth_code.organization
|
||||||
|
|
||||||
|
session = _create_session(user, application)
|
||||||
|
if organization:
|
||||||
|
session.organization = organization
|
||||||
|
session.save(update_fields=["organization", "updated_at"])
|
||||||
|
raw_refresh = generate_token()
|
||||||
|
RefreshToken.objects.create(
|
||||||
|
user=user,
|
||||||
|
application=application,
|
||||||
|
session=session,
|
||||||
|
token_hash=hash_token(raw_refresh),
|
||||||
|
expires_at=session.expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
from apps.authentication.services import create_access_token
|
||||||
|
|
||||||
|
access = create_access_token(
|
||||||
|
user,
|
||||||
|
product_key=application.product_key,
|
||||||
|
organization_id=str(organization.id) if organization else None,
|
||||||
|
scopes=scope_list,
|
||||||
|
)
|
||||||
|
access["scope"] = " ".join(scope_list)
|
||||||
|
|
||||||
|
id_token = build_id_token(
|
||||||
|
user=user,
|
||||||
|
client_id=application.client_id,
|
||||||
|
scopes=scope_list,
|
||||||
|
nonce=auth_code.nonce,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": str(access),
|
||||||
|
"id_token": id_token,
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": int(settings.SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"].total_seconds()),
|
||||||
|
"refresh_token": raw_refresh,
|
||||||
|
"scope": " ".join(scope_list),
|
||||||
|
}, None
|
||||||
|
|
||||||
|
|
||||||
|
def issue_tokens_for_refresh(raw_refresh, client_id, client_secret):
|
||||||
|
"""Exchange a refresh token for new access + refresh tokens.
|
||||||
|
|
||||||
|
Returns (tokens_dict, error_dict).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
application = Application.objects.get(
|
||||||
|
client_id=client_id,
|
||||||
|
status=ApplicationStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
except Application.DoesNotExist:
|
||||||
|
return None, {"error": "invalid_client"}
|
||||||
|
|
||||||
|
expected_hash = hash_token(client_secret)
|
||||||
|
if not hmac.compare_digest(application.client_secret_hash, expected_hash):
|
||||||
|
return None, {"error": "invalid_client"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
refresh_token = RefreshToken.objects.select_related(
|
||||||
|
"user", "session", "application"
|
||||||
|
).get(
|
||||||
|
token_hash=hash_token(raw_refresh),
|
||||||
|
)
|
||||||
|
except RefreshToken.DoesNotExist:
|
||||||
|
return None, {"error": "invalid_grant"}
|
||||||
|
|
||||||
|
if not refresh_token.is_valid():
|
||||||
|
return None, {
|
||||||
|
"error": "invalid_grant",
|
||||||
|
"error_description": "Refresh token expired or revoked",
|
||||||
|
}
|
||||||
|
|
||||||
|
user = refresh_token.user
|
||||||
|
|
||||||
|
refresh_token.revoked = True
|
||||||
|
refresh_token.replaced_by = "rotated"
|
||||||
|
refresh_token.save(update_fields=["revoked", "replaced_by", "updated_at"])
|
||||||
|
|
||||||
|
session = refresh_token.session
|
||||||
|
session.last_activity_at = timezone.now()
|
||||||
|
session.save(update_fields=["last_activity_at", "updated_at"])
|
||||||
|
|
||||||
|
raw_new_refresh = generate_token()
|
||||||
|
new_token = RefreshToken.objects.create(
|
||||||
|
user=user,
|
||||||
|
application=application,
|
||||||
|
session=session,
|
||||||
|
token_hash=hash_token(raw_new_refresh),
|
||||||
|
expires_at=timezone.now() + settings.SIMPLE_JWT["REFRESH_TOKEN_LIFETIME"],
|
||||||
|
)
|
||||||
|
|
||||||
|
from apps.authentication.services import create_access_token
|
||||||
|
|
||||||
|
scope_list = ["openid", "profile", "email", "phone"]
|
||||||
|
access = create_access_token(
|
||||||
|
user,
|
||||||
|
product_key=application.product_key,
|
||||||
|
organization_id=session.organization_id,
|
||||||
|
scopes=scope_list,
|
||||||
|
)
|
||||||
|
access["scope"] = " ".join(scope_list)
|
||||||
|
|
||||||
|
id_token = build_id_token(
|
||||||
|
user=user,
|
||||||
|
client_id=application.client_id,
|
||||||
|
scopes=scope_list,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": str(access),
|
||||||
|
"id_token": id_token,
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": int(settings.SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"].total_seconds()),
|
||||||
|
"refresh_token": raw_new_refresh,
|
||||||
|
"scope": " ".join(scope_list),
|
||||||
|
}, None
|
||||||
58
apps/api/apps/oauth/templates/oauth/consent.html
Normal file
58
apps/api/apps/oauth/templates/oauth/consent.html
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fa" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>تأیید دسترسی | Hamsoo SSO</title>
|
||||||
|
<style>
|
||||||
|
:root { --brand:#2f6df6; --bg:#f4f6fb; --card:#fff; --text:#1f2733; --muted:#6b7280; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin:0; font-family: -apple-system, "Segoe UI", Tahoma, sans-serif; background:var(--bg); color:var(--text); display:flex; align-items:center; justify-content:center; min-height:100vh; }
|
||||||
|
.card { background:var(--card); padding:32px; border-radius:16px; box-shadow:0 10px 40px rgba(0,0,0,.08); width:400px; max-width:92vw; }
|
||||||
|
.logo { text-align:center; margin-bottom:8px; }
|
||||||
|
.logo b { color:var(--brand); font-size:22px; letter-spacing:.5px; }
|
||||||
|
h1 { font-size:18px; text-align:center; margin:0 0 4px; }
|
||||||
|
p.sub { text-align:center; color:var(--muted); font-size:13px; margin:0 0 20px; }
|
||||||
|
.app { text-align:center; font-weight:600; font-size:16px; margin-bottom:4px; }
|
||||||
|
.scopes { list-style:none; padding:0; margin:16px 0; border-top:1px solid #eef1f6; }
|
||||||
|
.scopes li { padding:10px 4px; border-bottom:1px solid #eef1f6; font-size:14px; }
|
||||||
|
.scopes li span { color:var(--muted); font-size:12px; display:block; margin-top:2px; }
|
||||||
|
.org { background:#eef3ff; color:var(--brand); padding:10px 12px; border-radius:10px; font-size:13px; margin-bottom:12px; text-align:center; }
|
||||||
|
.actions { display:flex; gap:10px; margin-top:20px; }
|
||||||
|
button { flex:1; padding:12px; border:none; border-radius:10px; font-size:15px; cursor:pointer; }
|
||||||
|
.allow { background:var(--brand); color:#fff; }
|
||||||
|
.deny { background:#fff; color:#b42318; border:1px solid #f0c2c2; }
|
||||||
|
button:hover { filter:brightness(1.05); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="logo"><b>hamsoo</b></div>
|
||||||
|
<h1>تأیید دسترسی</h1>
|
||||||
|
<p class="sub">این سرویس میخواهد به حساب یکپارچه شما دسترسی داشته باشد</p>
|
||||||
|
|
||||||
|
<div class="app">{{ application.name }}</div>
|
||||||
|
|
||||||
|
{% if organization_id %}<div class="org">کسبوکار فعال: {{ organization_id }}</div>{% endif %}
|
||||||
|
|
||||||
|
<ul class="scopes">
|
||||||
|
{% for scope in scopes %}
|
||||||
|
<li>{{ scope.code }}<span>{{ scope.description }}</span></li>
|
||||||
|
{% empty %}
|
||||||
|
<li>دسترسی پایه (openid)</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form method="post" action="">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% for key, value in query_params %}
|
||||||
|
<input type="hidden" name="{{ key }}" value="{{ value }}" />
|
||||||
|
{% endfor %}
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" name="decision" value="deny" class="deny">رد</button>
|
||||||
|
<button type="submit" name="decision" value="allow" class="allow">تأیید و ادامه</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
41
apps/api/apps/oauth/templates/oauth/web_message.html
Normal file
41
apps/api/apps/oauth/templates/oauth/web_message.html
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fa" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>در حال ارسال درخواست authorization</title>
|
||||||
|
<style>
|
||||||
|
:root { --brand:#2f6df6; --bg:#f4f6fb; --card:#fff; --text:#1f2733; --muted:#6b7280; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin:0; font-family: -apple-system, "Segoe UI", Tahoma, sans-serif; background:var(--bg); color:var(--text); display:flex; align-items:center; justify-content:center; min-height:100vh; }
|
||||||
|
.card { background:var(--card); padding:32px; border-radius:16px; box-shadow:0 10px 40px rgba(0,0,0,.08); width:360px; max-width:92vw; text-align:center; }
|
||||||
|
.logo { text-align:center; margin-bottom:8px; }
|
||||||
|
.logo b { color:var(--brand); font-size:22px; letter-spacing:.5px; }
|
||||||
|
h1 { font-size:18px; text-align:center; margin:0 0 4px; }
|
||||||
|
p { color:var(--muted); font-size:13px; }
|
||||||
|
.spinner { width:40px; height:40px; border:3px solid #eef1f6; border-top-color:var(--brand); border-radius:50%; animation: spin .8s linear infinite; margin:20px auto; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="logo"><b>hamsoo</b></div>
|
||||||
|
<h1>ارسال کدauthorization</h1>
|
||||||
|
<p>در حال ارسال کد 권한 به سمت صفحه اصلی...</p>
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Send authorization response via postMessage and close the popup
|
||||||
|
const params = {{ code|safe }}{% if state %}, "state": {{ state|safe }}{% endif %};
|
||||||
|
const message = { type: "authorization_response", code: params.code, state: params.state };
|
||||||
|
const targetOrigin = "{{ redirect_uri|safe }}";
|
||||||
|
|
||||||
|
// Verify target origin is from registered redirect URIs
|
||||||
|
if (window.opener && window.opener.location.href.includes("hamsoo.me")) {
|
||||||
|
window.opener.postMessage(message, targetOrigin);
|
||||||
|
}
|
||||||
|
setTimeout(() => { window.close(); }, 1500);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
550
apps/api/apps/oauth/tests.py
Normal file
550
apps/api/apps/oauth/tests.py
Normal file
|
|
@ -0,0 +1,550 @@
|
||||||
|
import uuid
|
||||||
|
from datetime import timedelta
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from apps.application.models import Application, ApplicationStatus
|
||||||
|
from apps.common.utils import generate_client_secret, hash_token
|
||||||
|
from apps.oauth.models import AuthorizationCode, OAuthScope, RefreshToken
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthAuthorizationCodeTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
email="oauth@example.com",
|
||||||
|
password="S3cure-Pass-123",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
login = self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{"email": "oauth@example.com", "password": "S3cure-Pass-123"},
|
||||||
|
)
|
||||||
|
self.access_token = login.data["access"]
|
||||||
|
|
||||||
|
self.scope_openid = OAuthScope.objects.create(code="openid", is_system=True)
|
||||||
|
self.scope_profile = OAuthScope.objects.create(code="profile", is_system=False)
|
||||||
|
|
||||||
|
raw_secret = generate_client_secret()
|
||||||
|
self.application = Application.objects.create(
|
||||||
|
product_key="bermooda",
|
||||||
|
name="Bermooda Web",
|
||||||
|
client_secret_hash=hash_token(raw_secret),
|
||||||
|
redirect_uris=["https://bermooda.com/callback"],
|
||||||
|
grant_types=["authorization_code", "refresh_token"],
|
||||||
|
response_types=["code"],
|
||||||
|
status=ApplicationStatus.ACTIVE,
|
||||||
|
created_by=self.user,
|
||||||
|
)
|
||||||
|
self.application.scopes.add(self.scope_openid, self.scope_profile)
|
||||||
|
self.client_secret = raw_secret
|
||||||
|
|
||||||
|
def _auth_headers(self):
|
||||||
|
return {"HTTP_AUTHORIZATION": f"Bearer {self.access_token}"}
|
||||||
|
|
||||||
|
def test_authorize_returns_redirect_with_code(self):
|
||||||
|
params = {
|
||||||
|
"client_id": self.application.client_id,
|
||||||
|
"response_type": "code",
|
||||||
|
"redirect_uri": "https://bermooda.com/callback",
|
||||||
|
"scope": "openid profile",
|
||||||
|
"state": "abc123",
|
||||||
|
}
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("oauth-authorize"),
|
||||||
|
data=params,
|
||||||
|
**self._auth_headers(),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
location = response.url
|
||||||
|
self.assertTrue(location.startswith("https://bermooda.com/callback"))
|
||||||
|
parsed = urlparse(location)
|
||||||
|
qs = parse_qs(parsed.query)
|
||||||
|
self.assertIn("code", qs)
|
||||||
|
self.assertEqual(qs["state"], ["abc123"])
|
||||||
|
self.assertTrue(
|
||||||
|
AuthorizationCode.objects.filter(
|
||||||
|
user=self.user, application=self.application
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_authorize_redirects_with_error_for_invalid_client(self):
|
||||||
|
params = {
|
||||||
|
"client_id": "invalid_client",
|
||||||
|
"response_type": "code",
|
||||||
|
"redirect_uri": "https://bermooda.com/callback",
|
||||||
|
"state": "abc",
|
||||||
|
}
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("oauth-authorize"),
|
||||||
|
data=params,
|
||||||
|
**self._auth_headers(),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertEqual(response.data["error"], "unauthorized_client")
|
||||||
|
|
||||||
|
def test_authorize_401_when_not_authenticated(self):
|
||||||
|
params = {
|
||||||
|
"client_id": self.application.client_id,
|
||||||
|
"response_type": "code",
|
||||||
|
"redirect_uri": "https://bermooda.com/callback",
|
||||||
|
}
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("oauth-authorize"),
|
||||||
|
data=params,
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
|
def test_authorize_rejects_invalid_response_type(self):
|
||||||
|
params = {
|
||||||
|
"client_id": self.application.client_id,
|
||||||
|
"response_type": "token",
|
||||||
|
"redirect_uri": "https://bermooda.com/callback",
|
||||||
|
}
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("oauth-authorize"),
|
||||||
|
data=params,
|
||||||
|
**self._auth_headers(),
|
||||||
|
)
|
||||||
|
self.assertIn(response.status_code, [302, 400])
|
||||||
|
|
||||||
|
def test_code_is_single_use(self):
|
||||||
|
params = {
|
||||||
|
"client_id": self.application.client_id,
|
||||||
|
"response_type": "code",
|
||||||
|
"redirect_uri": "https://bermooda.com/callback",
|
||||||
|
"scope": "openid",
|
||||||
|
}
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("oauth-authorize"),
|
||||||
|
data=params,
|
||||||
|
**self._auth_headers(),
|
||||||
|
)
|
||||||
|
location = response.url
|
||||||
|
qs = parse_qs(urlparse(location).query)
|
||||||
|
code = qs["code"][0]
|
||||||
|
|
||||||
|
# First exchange works
|
||||||
|
token_resp = self.client.post(
|
||||||
|
reverse("oauth-token"),
|
||||||
|
{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": "https://bermooda.com/callback",
|
||||||
|
"client_id": self.application.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(token_resp.status_code, 200)
|
||||||
|
self.assertIn("access_token", token_resp.data)
|
||||||
|
self.assertIn("refresh_token", token_resp.data)
|
||||||
|
|
||||||
|
# Second exchange fails (code used)
|
||||||
|
reuse_resp = self.client.post(
|
||||||
|
reverse("oauth-token"),
|
||||||
|
{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": "https://bermooda.com/callback",
|
||||||
|
"client_id": self.application.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(reuse_resp.status_code, 400)
|
||||||
|
self.assertIn("error", reuse_resp.data)
|
||||||
|
|
||||||
|
auth_code = AuthorizationCode.objects.get(user=self.user)
|
||||||
|
self.assertTrue(auth_code.used)
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthTokenEndpointTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
email="tokentest@example.com",
|
||||||
|
password="S3cure-Pass-123",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
login = self.client.post(
|
||||||
|
reverse("auth-login"),
|
||||||
|
{"email": "tokentest@example.com", "password": "S3cure-Pass-123"},
|
||||||
|
)
|
||||||
|
self.access_token = login.data["access"]
|
||||||
|
|
||||||
|
self.scope = OAuthScope.objects.create(code="openid", is_system=True)
|
||||||
|
|
||||||
|
raw_secret = generate_client_secret()
|
||||||
|
self.application = Application.objects.create(
|
||||||
|
product_key="hamsoo",
|
||||||
|
name="Hamsoo Mobile",
|
||||||
|
client_secret_hash=hash_token(raw_secret),
|
||||||
|
redirect_uris=["https://hamsoo.com/callback"],
|
||||||
|
grant_types=["authorization_code", "refresh_token"],
|
||||||
|
response_types=["code"],
|
||||||
|
status=ApplicationStatus.ACTIVE,
|
||||||
|
created_by=self.user,
|
||||||
|
)
|
||||||
|
self.application.scopes.add(self.scope)
|
||||||
|
self.client_secret = raw_secret
|
||||||
|
|
||||||
|
def _create_and_get_code(self):
|
||||||
|
from apps.oauth.services import create_authorization_code
|
||||||
|
|
||||||
|
return create_authorization_code(
|
||||||
|
user=self.user,
|
||||||
|
application=self.application,
|
||||||
|
redirect_uri="https://hamsoo.com/callback",
|
||||||
|
scopes=["openid"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_token_grant_authorization_code_success(self):
|
||||||
|
code = self._create_and_get_code()
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("oauth-token"),
|
||||||
|
{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": "https://hamsoo.com/callback",
|
||||||
|
"client_id": self.application.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn("access_token", response.data)
|
||||||
|
self.assertIn("refresh_token", response.data)
|
||||||
|
self.assertEqual(response.data["token_type"], "Bearer")
|
||||||
|
self.assertIn("expires_in", response.data)
|
||||||
|
self.assertIn("scope", response.data)
|
||||||
|
|
||||||
|
def test_token_grant_invalid_client_secret(self):
|
||||||
|
code = self._create_and_get_code()
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("oauth-token"),
|
||||||
|
{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": "https://hamsoo.com/callback",
|
||||||
|
"client_id": self.application.client_id,
|
||||||
|
"client_secret": "wrong-secret",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_token_grant_invalid_code(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("oauth-token"),
|
||||||
|
{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": "invalid-code",
|
||||||
|
"redirect_uri": "https://hamsoo.com/callback",
|
||||||
|
"client_id": self.application.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_token_grant_redirect_uri_mismatch(self):
|
||||||
|
code = self._create_and_get_code()
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("oauth-token"),
|
||||||
|
{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": "https://evil.com/callback",
|
||||||
|
"client_id": self.application.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_token_grant_refresh_token_success(self):
|
||||||
|
code = self._create_and_get_code()
|
||||||
|
login_response = self.client.post(
|
||||||
|
reverse("oauth-token"),
|
||||||
|
{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": "https://hamsoo.com/callback",
|
||||||
|
"client_id": self.application.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
refresh_token = login_response.data["refresh_token"]
|
||||||
|
|
||||||
|
refresh_response = self.client.post(
|
||||||
|
reverse("oauth-token"),
|
||||||
|
{
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"client_id": self.application.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(refresh_response.status_code, 200)
|
||||||
|
self.assertIn("access_token", refresh_response.data)
|
||||||
|
self.assertIn("refresh_token", refresh_response.data)
|
||||||
|
self.assertNotEqual(refresh_response.data["refresh_token"], refresh_token)
|
||||||
|
|
||||||
|
def test_token_refresh_invalid_refresh_token(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("oauth-token"),
|
||||||
|
{
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": "invalid-refresh",
|
||||||
|
"client_id": self.application.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_token_unsupported_grant_type(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("oauth-token"),
|
||||||
|
{
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"client_id": self.application.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_token_invalid_client_id(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("oauth-token"),
|
||||||
|
{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": "test",
|
||||||
|
"client_id": "invalid_client",
|
||||||
|
"client_secret": "secret",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthDiscoveryTests(TestCase):
|
||||||
|
def test_discovery_endpoint(self):
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.get(reverse("oidc-discovery"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn("issuer", response.data)
|
||||||
|
self.assertIn("authorization_endpoint", response.data)
|
||||||
|
self.assertIn("token_endpoint", response.data)
|
||||||
|
self.assertIn("jwks_uri", response.data)
|
||||||
|
|
||||||
|
def test_jwks_endpoint(self):
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.get(reverse("oauth-jwks"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn("keys", response.data)
|
||||||
|
|
||||||
|
def test_scopes_endpoint(self):
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
OAuthScope.objects.create(code="email", is_system=False)
|
||||||
|
OAuthScope.objects.create(code="offline_access", is_system=False)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.get(reverse("oauth-scopes"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
codes = [s["code"] for s in response.data]
|
||||||
|
self.assertIn("email", codes)
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCIdTokenTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from apps.common.utils import generate_client_secret, hash_token
|
||||||
|
|
||||||
|
self.settings = settings
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
email="idtoken@example.com",
|
||||||
|
password="S3cure-Pass-123",
|
||||||
|
status="active",
|
||||||
|
full_name="Id Token",
|
||||||
|
username="idtoken",
|
||||||
|
email_verified=True,
|
||||||
|
identity_verified=True,
|
||||||
|
locale="fa",
|
||||||
|
)
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
self.scope_openid = OAuthScope.objects.create(code="openid", is_system=True)
|
||||||
|
self.scope_profile = OAuthScope.objects.create(code="profile", is_system=False)
|
||||||
|
self.scope_email = OAuthScope.objects.create(code="email", is_system=False)
|
||||||
|
self.scope_phone = OAuthScope.objects.create(code="phone", is_system=False)
|
||||||
|
|
||||||
|
raw_secret = generate_client_secret()
|
||||||
|
self.application = Application.objects.create(
|
||||||
|
product_key="bermooda",
|
||||||
|
name="Bermooda Web",
|
||||||
|
client_secret_hash=hash_token(raw_secret),
|
||||||
|
redirect_uris=["https://bermooda.com/callback"],
|
||||||
|
grant_types=["authorization_code", "refresh_token"],
|
||||||
|
response_types=["code"],
|
||||||
|
status=ApplicationStatus.ACTIVE,
|
||||||
|
created_by=self.user,
|
||||||
|
)
|
||||||
|
self.application.scopes.add(
|
||||||
|
self.scope_openid,
|
||||||
|
self.scope_profile,
|
||||||
|
self.scope_email,
|
||||||
|
self.scope_phone,
|
||||||
|
)
|
||||||
|
self.client_secret = raw_secret
|
||||||
|
|
||||||
|
def _exchange_code(self, scopes):
|
||||||
|
from apps.oauth.services import create_authorization_code
|
||||||
|
|
||||||
|
code = create_authorization_code(
|
||||||
|
user=self.user,
|
||||||
|
application=self.application,
|
||||||
|
redirect_uri="https://bermooda.com/callback",
|
||||||
|
scopes=scopes,
|
||||||
|
)
|
||||||
|
return self.client.post(
|
||||||
|
reverse("oauth-token"),
|
||||||
|
{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": "https://bermooda.com/callback",
|
||||||
|
"client_id": self.application.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _decode_id_token(self, raw_id_token):
|
||||||
|
from rest_framework_simplejwt.tokens import AccessToken
|
||||||
|
|
||||||
|
return AccessToken(raw_id_token).payload
|
||||||
|
|
||||||
|
def test_id_token_returned_for_openid_scope(self):
|
||||||
|
response = self._exchange_code(["openid", "profile", "email", "phone"])
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn("id_token", response.data)
|
||||||
|
|
||||||
|
claims = self._decode_id_token(response.data["id_token"])
|
||||||
|
self.assertEqual(claims["sub"], str(self.user.id))
|
||||||
|
self.assertEqual(claims["aud"], self.application.client_id)
|
||||||
|
self.assertEqual(claims["iss"], self.settings.OIDC_ISSUER)
|
||||||
|
self.assertEqual(claims["preferred_username"], "idtoken")
|
||||||
|
self.assertEqual(claims["email"], "idtoken@example.com")
|
||||||
|
self.assertTrue(claims["email_verified"])
|
||||||
|
self.assertEqual(claims["phone_number"], "")
|
||||||
|
self.assertTrue(claims["identity_verified"])
|
||||||
|
self.assertIn("auth_time", claims)
|
||||||
|
|
||||||
|
def test_id_token_omits_email_without_email_scope(self):
|
||||||
|
response = self._exchange_code(["openid", "profile"])
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
claims = self._decode_id_token(response.data["id_token"])
|
||||||
|
self.assertNotIn("email", claims)
|
||||||
|
self.assertIn("name", claims)
|
||||||
|
|
||||||
|
def test_refresh_returns_id_token(self):
|
||||||
|
first = self._exchange_code(["openid", "profile", "email"])
|
||||||
|
refresh_response = self.client.post(
|
||||||
|
reverse("oauth-token"),
|
||||||
|
{
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": first.data["refresh_token"],
|
||||||
|
"client_id": self.application.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(refresh_response.status_code, 200)
|
||||||
|
self.assertIn("id_token", refresh_response.data)
|
||||||
|
claims = self._decode_id_token(refresh_response.data["id_token"])
|
||||||
|
self.assertEqual(claims["sub"], str(self.user.id))
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCUserInfoTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
from apps.common.utils import generate_client_secret, hash_token
|
||||||
|
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
email="userinfo@example.com",
|
||||||
|
password="S3cure-Pass-123",
|
||||||
|
status="active",
|
||||||
|
full_name="User Info",
|
||||||
|
username="userinfo",
|
||||||
|
email_verified=True,
|
||||||
|
identity_verified=True,
|
||||||
|
)
|
||||||
|
self.client = APIClient()
|
||||||
|
|
||||||
|
self.scope_openid = OAuthScope.objects.create(code="openid", is_system=True)
|
||||||
|
self.scope_profile = OAuthScope.objects.create(code="profile", is_system=False)
|
||||||
|
self.scope_email = OAuthScope.objects.create(code="email", is_system=False)
|
||||||
|
|
||||||
|
raw_secret = generate_client_secret()
|
||||||
|
self.application = Application.objects.create(
|
||||||
|
product_key="hamsoo",
|
||||||
|
name="Hamsoo Web",
|
||||||
|
client_secret_hash=hash_token(raw_secret),
|
||||||
|
redirect_uris=["https://hamsoo.com/callback"],
|
||||||
|
grant_types=["authorization_code", "refresh_token"],
|
||||||
|
response_types=["code"],
|
||||||
|
status=ApplicationStatus.ACTIVE,
|
||||||
|
created_by=self.user,
|
||||||
|
)
|
||||||
|
self.application.scopes.add(
|
||||||
|
self.scope_openid, self.scope_profile, self.scope_email
|
||||||
|
)
|
||||||
|
self.client_secret = raw_secret
|
||||||
|
|
||||||
|
def _get_access_token(self):
|
||||||
|
from apps.oauth.services import create_authorization_code
|
||||||
|
|
||||||
|
code = create_authorization_code(
|
||||||
|
user=self.user,
|
||||||
|
application=self.application,
|
||||||
|
redirect_uri="https://hamsoo.com/callback",
|
||||||
|
scopes=["openid", "profile", "email"],
|
||||||
|
)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("oauth-token"),
|
||||||
|
{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": "https://hamsoo.com/callback",
|
||||||
|
"client_id": self.application.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return response.data["access_token"]
|
||||||
|
|
||||||
|
def test_userinfo_requires_auth(self):
|
||||||
|
response = self.client.get(reverse("oauth-userinfo"))
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
|
def test_userinfo_returns_scope_claims(self):
|
||||||
|
access = self._get_access_token()
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("oauth-userinfo"),
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {access}",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["sub"], str(self.user.id))
|
||||||
|
self.assertEqual(response.data["email"], "userinfo@example.com")
|
||||||
|
self.assertTrue(response.data["email_verified"])
|
||||||
|
self.assertEqual(response.data["preferred_username"], "userinfo")
|
||||||
|
self.assertTrue(response.data["identity_verified"])
|
||||||
19
apps/api/apps/oauth/urls.py
Normal file
19
apps/api/apps/oauth/urls.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.oauth.views import (
|
||||||
|
AuthorizeView,
|
||||||
|
ConsentView,
|
||||||
|
JWKSView,
|
||||||
|
ScopeListView,
|
||||||
|
TokenView,
|
||||||
|
UserInfoView,
|
||||||
|
)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("authorize", AuthorizeView.as_view(), name="oauth-authorize"),
|
||||||
|
path("consent", ConsentView.as_view(), name="oauth-consent"),
|
||||||
|
path("token", TokenView.as_view(), name="oauth-token"),
|
||||||
|
path("userinfo", UserInfoView.as_view(), name="oauth-userinfo"),
|
||||||
|
path("jwks", JWKSView.as_view(), name="oauth-jwks"),
|
||||||
|
path("scopes/", ScopeListView.as_view(), name="oauth-scopes"),
|
||||||
|
]
|
||||||
467
apps/api/apps/oauth/views.py
Normal file
467
apps/api/apps/oauth/views.py
Normal file
|
|
@ -0,0 +1,467 @@
|
||||||
|
from urllib.parse import quote, urlencode
|
||||||
|
|
||||||
|
from django.http import HttpResponseRedirect
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.authentication import SessionAuthentication
|
||||||
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||||
|
|
||||||
|
from apps.access.services import user_has_business_access
|
||||||
|
from apps.application.models import Application
|
||||||
|
from apps.oauth.models import Consent, OAuthScope
|
||||||
|
from apps.oauth.serializers import OAuthScopeSerializer
|
||||||
|
from apps.oauth.services import (
|
||||||
|
create_authorization_code,
|
||||||
|
issue_tokens_for_code,
|
||||||
|
issue_tokens_for_refresh,
|
||||||
|
validate_authorize_request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_user(request):
|
||||||
|
"""Return the authenticated user for a browser session or Bearer token."""
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
return request.user
|
||||||
|
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
|
||||||
|
if auth_header.startswith("Bearer "):
|
||||||
|
try:
|
||||||
|
validated = JWTAuthentication().get_validated_token(auth_header[7:])
|
||||||
|
return JWTAuthentication().get_user(validated)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_organization(user, application, organization_id):
|
||||||
|
"""Validate and return the requested Active Business, or False if denied."""
|
||||||
|
from apps.organization.models import Organization
|
||||||
|
|
||||||
|
organization = Organization.objects.filter(id=organization_id).first()
|
||||||
|
if organization is None or not user_has_business_access(
|
||||||
|
user, str(organization.id), application.product_key
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return organization
|
||||||
|
|
||||||
|
|
||||||
|
def _is_browser(request):
|
||||||
|
return "text/html" in request.META.get("HTTP_ACCEPT", "")
|
||||||
|
|
||||||
|
|
||||||
|
class ScopeListView(APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
authentication_classes = []
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
scopes = OAuthScope.objects.filter(is_system=False).order_by("code")
|
||||||
|
return Response(OAuthScopeSerializer(scopes, many=True).data)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthorizeView(APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
# The IdP SSO session (Django session cookie) is the primary auth for the
|
||||||
|
# browser redirect flow; a Bearer token remains supported for API clients.
|
||||||
|
authentication_classes = [SessionAuthentication]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
application, redirect_uri, scopes, error = validate_authorize_request(
|
||||||
|
request.GET
|
||||||
|
)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
if application and redirect_uri:
|
||||||
|
location = f"{redirect_uri}?{urlencode(error)}"
|
||||||
|
return HttpResponseRedirect(location)
|
||||||
|
return Response(error, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
user = _resolve_user(request)
|
||||||
|
if user is None or getattr(user, "status", "") != "active":
|
||||||
|
# Browser SSO: redirect to the central IdP login page, returning
|
||||||
|
# here afterwards (single sign-on across hamsoo subdomains).
|
||||||
|
if _is_browser(request):
|
||||||
|
login_path = (
|
||||||
|
reverse("sso-login") + "?next=" + quote(request.get_full_path())
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(request.build_absolute_uri(login_path))
|
||||||
|
|
||||||
|
login_url = reverse("auth-login")
|
||||||
|
login_path = request.build_absolute_uri(login_url)
|
||||||
|
error_params = urlencode({"error": "login_required"})
|
||||||
|
location = f"{redirect_uri}?{error_params}" if redirect_uri else None
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"detail": "Authentication required.",
|
||||||
|
"login_url": login_path,
|
||||||
|
"redirect_uri": location,
|
||||||
|
"application": {
|
||||||
|
"name": application.name,
|
||||||
|
"client_id": application.client_id,
|
||||||
|
"redirect_uris": application.redirect_uris,
|
||||||
|
},
|
||||||
|
"scopes": scopes,
|
||||||
|
},
|
||||||
|
status=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
|
||||||
|
state = request.GET.get("state", "")
|
||||||
|
nonce = request.GET.get("nonce", "")
|
||||||
|
code_challenge = request.GET.get("code_challenge", "")
|
||||||
|
code_challenge_method = request.GET.get("code_challenge_method", "plain")
|
||||||
|
|
||||||
|
# Decision 7: the product may request a specific Active Business
|
||||||
|
# (کسبوکار فعال). The user must actually hold access to it.
|
||||||
|
organization_id = request.GET.get("organization_id")
|
||||||
|
organization = None
|
||||||
|
if organization_id:
|
||||||
|
organization = _resolve_organization(user, application, organization_id)
|
||||||
|
if organization is False:
|
||||||
|
error_params = urlencode({"error": "access_denied", "state": state})
|
||||||
|
return HttpResponseRedirect(f"{redirect_uri}?{error_params}")
|
||||||
|
|
||||||
|
# Google-style consent: first-time authorization for an application
|
||||||
|
# requires explicit user approval (browser flow only).
|
||||||
|
if (
|
||||||
|
_is_browser(request)
|
||||||
|
and not Consent.objects.filter(user=user, application=application).exists()
|
||||||
|
):
|
||||||
|
consent_path = reverse("oauth-consent") + "?" + request.GET.urlencode()
|
||||||
|
return HttpResponseRedirect(request.build_absolute_uri(consent_path))
|
||||||
|
|
||||||
|
raw_code = create_authorization_code(
|
||||||
|
user=user,
|
||||||
|
application=application,
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
|
scopes=scopes,
|
||||||
|
nonce=nonce,
|
||||||
|
code_challenge=code_challenge,
|
||||||
|
code_challenge_method=code_challenge_method,
|
||||||
|
organization=organization,
|
||||||
|
)
|
||||||
|
|
||||||
|
params = {"code": raw_code}
|
||||||
|
if state:
|
||||||
|
params["state"] = state
|
||||||
|
location = f"{redirect_uri}?{urlencode(params)}"
|
||||||
|
|
||||||
|
# Support response_mode=web_message for popup/modal flows
|
||||||
|
response_mode = request.GET.get("response_mode", "")
|
||||||
|
if response_mode == "web_message":
|
||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"oauth/web_message.html",
|
||||||
|
{
|
||||||
|
"code": raw_code,
|
||||||
|
"state": state,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"application": application,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return HttpResponseRedirect(location)
|
||||||
|
|
||||||
|
|
||||||
|
class ConsentView(APIView):
|
||||||
|
"""Google-style OAuth consent screen for the browser SSO flow."""
|
||||||
|
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
authentication_classes = [SessionAuthentication]
|
||||||
|
template_name = "oauth/consent.html"
|
||||||
|
|
||||||
|
def _redirect_with_error(self, redirect_uri, state):
|
||||||
|
error_params = urlencode({"error": "access_denied", "state": state})
|
||||||
|
return HttpResponseRedirect(f"{redirect_uri}?{error_params}")
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
user = _resolve_user(request)
|
||||||
|
if user is None or getattr(user, "status", "") != "active":
|
||||||
|
if _is_browser(request):
|
||||||
|
login_path = (
|
||||||
|
reverse("sso-login") + "?next=" + quote(request.get_full_path())
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(request.build_absolute_uri(login_path))
|
||||||
|
return Response(
|
||||||
|
{"detail": "Authentication required."},
|
||||||
|
status=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
|
||||||
|
application, redirect_uri, scopes, error = validate_authorize_request(
|
||||||
|
request.GET
|
||||||
|
)
|
||||||
|
if error:
|
||||||
|
if application and redirect_uri:
|
||||||
|
return HttpResponseRedirect(f"{redirect_uri}?{urlencode(error)}")
|
||||||
|
return Response(error, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
state = request.GET.get("state", "")
|
||||||
|
organization_id = request.GET.get("organization_id")
|
||||||
|
if organization_id:
|
||||||
|
organization = _resolve_organization(user, application, organization_id)
|
||||||
|
if organization is False:
|
||||||
|
return self._redirect_with_error(redirect_uri, state)
|
||||||
|
|
||||||
|
# Already consented: skip straight back to the authorization endpoint.
|
||||||
|
if Consent.objects.filter(user=user, application=application).exists():
|
||||||
|
authorize_url = request.build_absolute_uri(
|
||||||
|
reverse("oauth-authorize") + "?" + request.GET.urlencode()
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(authorize_url)
|
||||||
|
|
||||||
|
scope_objs = OAuthScope.objects.filter(code__in=scopes)
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
self.template_name,
|
||||||
|
{
|
||||||
|
"application": application,
|
||||||
|
"scopes": scope_objs,
|
||||||
|
"organization_id": organization_id,
|
||||||
|
"query_params": list(request.GET.items()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
user = _resolve_user(request)
|
||||||
|
if user is None or getattr(user, "status", "") != "active":
|
||||||
|
return Response(
|
||||||
|
{"detail": "Authentication required."},
|
||||||
|
status=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The original authorize parameters travel in the consent URL query
|
||||||
|
# string (the form posts back to the same URL), so read them from GET.
|
||||||
|
params = request.GET
|
||||||
|
application, redirect_uri, scopes, error = validate_authorize_request(params)
|
||||||
|
if error:
|
||||||
|
if application and redirect_uri:
|
||||||
|
return HttpResponseRedirect(f"{redirect_uri}?{urlencode(error)}")
|
||||||
|
return Response(error, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
state = params.get("state", "")
|
||||||
|
decision = request.POST.get("decision")
|
||||||
|
|
||||||
|
if decision != "allow":
|
||||||
|
return self._redirect_with_error(redirect_uri, state)
|
||||||
|
|
||||||
|
consent, _ = Consent.objects.get_or_create(user=user, application=application)
|
||||||
|
consent.scopes.set(OAuthScope.objects.filter(code__in=scopes))
|
||||||
|
|
||||||
|
authorize_url = request.build_absolute_uri(
|
||||||
|
reverse("oauth-authorize") + "?" + params.urlencode()
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(authorize_url)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenView(APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
authentication_classes = []
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
grant_type = request.data.get("grant_type", "")
|
||||||
|
client_id = request.data.get("client_id", "")
|
||||||
|
|
||||||
|
if not grant_type:
|
||||||
|
return Response(
|
||||||
|
{"error": "unsupported_grant_type"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not client_id:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"error": "invalid_client",
|
||||||
|
"error_description": "client_id is required",
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
application = Application.objects.get(client_id=client_id)
|
||||||
|
except Application.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "invalid_client"},
|
||||||
|
status=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
|
||||||
|
if grant_type == "authorization_code":
|
||||||
|
code = request.data.get("code", "")
|
||||||
|
redirect_uri = request.data.get("redirect_uri", "")
|
||||||
|
code_verifier = request.data.get("code_verifier", "")
|
||||||
|
|
||||||
|
if not code:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "code is required",
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
tokens, error = issue_tokens_for_code(
|
||||||
|
raw_code=code,
|
||||||
|
client_id=client_id,
|
||||||
|
client_secret=request.data.get("client_secret", ""),
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
|
code_verifier=code_verifier,
|
||||||
|
)
|
||||||
|
if error:
|
||||||
|
return Response(error, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
return Response(tokens, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
elif grant_type == "refresh_token":
|
||||||
|
refresh_token = request.data.get("refresh_token", "")
|
||||||
|
if not refresh_token:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"error": "invalid_request",
|
||||||
|
"error_description": "refresh_token is required",
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
tokens, error = issue_tokens_for_refresh(
|
||||||
|
raw_refresh=refresh_token,
|
||||||
|
client_id=client_id,
|
||||||
|
client_secret=request.data.get("client_secret", ""),
|
||||||
|
)
|
||||||
|
if error:
|
||||||
|
return Response(error, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
return Response(tokens, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
else:
|
||||||
|
return Response(
|
||||||
|
{"error": "unsupported_grant_type"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JWKSView(APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
authentication_classes = []
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from django.conf import settings
|
||||||
|
import base64
|
||||||
|
|
||||||
|
jwt_public_key = getattr(settings, "JWT_PUBLIC_KEY", "")
|
||||||
|
if jwt_public_key:
|
||||||
|
public_key = serialization.load_pem_public_key(jwt_public_key.encode())
|
||||||
|
if isinstance(public_key, rsa.RSAPublicKey):
|
||||||
|
numbers = public_key.public_numbers()
|
||||||
|
n = numbers.n
|
||||||
|
e = numbers.e
|
||||||
|
|
||||||
|
def int_to_base64(val):
|
||||||
|
byte_length = (val.bit_length() + 7) // 8
|
||||||
|
return (
|
||||||
|
base64.urlsafe_b64encode(val.to_bytes(byte_length, "big"))
|
||||||
|
.rstrip(b"=")
|
||||||
|
.decode()
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"kty": "RSA",
|
||||||
|
"use": "sig",
|
||||||
|
"kid": "default",
|
||||||
|
"alg": "RS256",
|
||||||
|
"n": int_to_base64(n),
|
||||||
|
"e": int_to_base64(e),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return Response({"keys": []})
|
||||||
|
|
||||||
|
|
||||||
|
class DiscoveryView(APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
authentication_classes = []
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
base = request.build_absolute_uri("/").rstrip("/")
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"issuer": base,
|
||||||
|
"authorization_endpoint": base + reverse("oauth-authorize"),
|
||||||
|
"token_endpoint": base + reverse("oauth-token"),
|
||||||
|
"userinfo_endpoint": base + reverse("oauth-userinfo"),
|
||||||
|
"jwks_uri": base + reverse("oauth-jwks"),
|
||||||
|
"login_url": base + reverse("sso-login"),
|
||||||
|
"end_session_endpoint": base + reverse("sso-logout"),
|
||||||
|
"scopes_supported": list(
|
||||||
|
OAuthScope.objects.values_list("code", flat=True)
|
||||||
|
),
|
||||||
|
"response_types_supported": ["code"],
|
||||||
|
"response_modes_supported": ["query"],
|
||||||
|
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||||
|
"subject_types_supported": ["public"],
|
||||||
|
"id_token_signing_alg_values_supported": ["RS256"],
|
||||||
|
"token_endpoint_auth_methods_supported": ["client_secret_post"],
|
||||||
|
"claims_supported": [
|
||||||
|
"sub",
|
||||||
|
"name",
|
||||||
|
"email",
|
||||||
|
"email_verified",
|
||||||
|
"preferred_username",
|
||||||
|
"locale",
|
||||||
|
"picture",
|
||||||
|
],
|
||||||
|
"code_challenge_methods_supported": ["S256", "plain"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserInfoView(APIView):
|
||||||
|
"""OIDC UserInfo endpoint. Returns the claims that correspond to the
|
||||||
|
scopes granted to the bearer access token (Google-style)."""
|
||||||
|
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
authentication_classes = [JWTAuthentication]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
from apps.verification.services import TrustEngine
|
||||||
|
|
||||||
|
user = request.user
|
||||||
|
token = request.auth
|
||||||
|
scopes = set((token.get("scope") or "openid").split())
|
||||||
|
if not scopes:
|
||||||
|
scopes = {"openid"}
|
||||||
|
|
||||||
|
claims = {
|
||||||
|
"sub": str(user.id),
|
||||||
|
"identity_verified": bool(user.identity_verified),
|
||||||
|
"trust_state": TrustEngine.compute_trust_state(user),
|
||||||
|
}
|
||||||
|
if "profile" in scopes:
|
||||||
|
claims.update(
|
||||||
|
{
|
||||||
|
"name": user.full_name or user.display_name,
|
||||||
|
"given_name": user.given_name,
|
||||||
|
"family_name": user.family_name,
|
||||||
|
"preferred_username": user.username or user.email,
|
||||||
|
"picture": user.avatar_url or "",
|
||||||
|
"locale": user.locale or "fa",
|
||||||
|
"updated_at": int(user.updated_at.timestamp())
|
||||||
|
if user.updated_at
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if "email" in scopes:
|
||||||
|
claims["email"] = user.email or ""
|
||||||
|
claims["email_verified"] = bool(user.email_verified)
|
||||||
|
if "phone" in scopes:
|
||||||
|
claims["phone_number"] = user.phone or ""
|
||||||
|
claims["phone_number_verified"] = bool(user.phone_verified)
|
||||||
|
|
||||||
|
# Drop None values so the JSON response stays clean.
|
||||||
|
claims = {k: v for k, v in claims.items() if v is not None}
|
||||||
|
return Response(claims)
|
||||||
0
apps/api/apps/organization/__init__.py
Normal file
0
apps/api/apps/organization/__init__.py
Normal file
11
apps/api/apps/organization/admin.py
Normal file
11
apps/api/apps/organization/admin.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.organization.models import Organization
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Organization)
|
||||||
|
class OrganizationAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "slug", "status", "created_by", "created_at")
|
||||||
|
list_filter = ("status",)
|
||||||
|
search_fields = ("name", "slug")
|
||||||
|
readonly_fields = ("id", "created_at", "updated_at")
|
||||||
41
apps/api/apps/organization/migrations/0001_initial.py
Normal file
41
apps/api/apps/organization/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Generated by Django 5.2.17 on 2026-08-15 09:02
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Organization',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('name', models.CharField(max_length=200)),
|
||||||
|
('legal_name', models.CharField(blank=True, default='', max_length=200)),
|
||||||
|
('slug', models.SlugField(max_length=100, unique=True)),
|
||||||
|
('organization_type', models.CharField(choices=[('company', 'Company'), ('startup', 'Startup'), ('team', 'Team'), ('agency', 'Agency'), ('community', 'Community'), ('school', 'School'), ('other', 'Other')], default='company', max_length=32)),
|
||||||
|
('description', models.TextField(blank=True, default='')),
|
||||||
|
('logo_url', models.URLField(blank=True, default='', max_length=500)),
|
||||||
|
('website_url', models.URLField(blank=True, default='', max_length=500)),
|
||||||
|
('domain', models.CharField(blank=True, default=None, max_length=128, null=True)),
|
||||||
|
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive')], default='active', max_length=16)),
|
||||||
|
('verification_status', models.CharField(choices=[('pending', 'Pending'), ('verified', 'Verified'), ('rejected', 'Rejected')], default='pending', max_length=16)),
|
||||||
|
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_organizations', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'indexes': [models.Index(fields=['slug'], name='organizatio_slug_0d3b5f_idx'), models.Index(fields=['status'], name='organizatio_status_6c2557_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
# Generated by Django 5.2.17 on 2026-08-19 08:34
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('organization', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='organization',
|
||||||
|
name='activity_field',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='Activity field (حوزه فعالیت)', max_length=128),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='organization',
|
||||||
|
name='address',
|
||||||
|
field=models.TextField(blank=True, default='', help_text='Address (آدرس)'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='organization',
|
||||||
|
name='brand_name',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='Brand name (نام تجاری)', max_length=200),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='organization',
|
||||||
|
name='business_nature',
|
||||||
|
field=models.CharField(blank=True, choices=[('b2b', 'B2B'), ('b2c', 'B2C'), ('b2g', 'B2G')], default='', help_text='Business nature (ماهیت کسب\u200cوکار): B2B/B2C/B2G', max_length=8),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='organization',
|
||||||
|
name='city',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='City (شهر)', max_length=64),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='organization',
|
||||||
|
name='country',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='Country (کشور)', max_length=64),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='organization',
|
||||||
|
name='cover_url',
|
||||||
|
field=models.URLField(blank=True, default='', help_text='Cover image (تصویر کاور)', max_length=500),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='organization',
|
||||||
|
name='economic_code',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='Economic code (کد اقتصادی)', max_length=64),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='organization',
|
||||||
|
name='email',
|
||||||
|
field=models.EmailField(blank=True, default='', help_text='Email (ایمیل)', max_length=254),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='organization',
|
||||||
|
name='employee_count',
|
||||||
|
field=models.PositiveIntegerField(blank=True, help_text='Number of employees (تعداد کارکنان)', null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='organization',
|
||||||
|
name='founded_year',
|
||||||
|
field=models.PositiveIntegerField(blank=True, help_text='Founded year (سال تأسیس)', null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='organization',
|
||||||
|
name='industry',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='Industry (صنعت)', max_length=128),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='organization',
|
||||||
|
name='legal_status',
|
||||||
|
field=models.CharField(choices=[('pending', 'Pending'), ('verified', 'Verified'), ('rejected', 'Rejected')], default='pending', help_text='Legal status (وضعیت حقوقی)', max_length=16),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='organization',
|
||||||
|
name='mobile',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='Mobile (موبایل)', max_length=32),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='organization',
|
||||||
|
name='national_id',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='National ID (شناسه ملی)', max_length=32),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='organization',
|
||||||
|
name='phone',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='Phone (تلفن)', max_length=32),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='organization',
|
||||||
|
name='postal_code',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='Postal code (کد پستی)', max_length=32),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='organization',
|
||||||
|
name='province',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='Province (استان)', max_length=64),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='organization',
|
||||||
|
name='registration_number',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='Registration number (شماره ثبت)', max_length=64),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='organization',
|
||||||
|
name='social_links',
|
||||||
|
field=models.JSONField(blank=True, default=list, help_text='Social networks (شبکه\u200cهای اجتماعی)'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='organization',
|
||||||
|
name='legal_name',
|
||||||
|
field=models.CharField(blank=True, default='', help_text='Legal name (نام حقوقی)', max_length=200),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='organization',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(help_text='Business name (نام کسب\u200cوکار)', max_length=200),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='organization',
|
||||||
|
name='organization_type',
|
||||||
|
field=models.CharField(choices=[('individual', 'Individual (شخص حقیقی)'), ('private', 'Private (شرکت خصوصی)'), ('startup', 'Startup (استارتاپ)'), ('government', 'Government (دولتی)'), ('multinational', 'Multinational (چندملیتی)')], default='private', help_text='Company type (نوع شرکت)', max_length=32),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='organization',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive'), ('suspended', 'Suspended'), ('verified', 'Verified')], default='active', max_length=16),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
# Generated by Django 5.2.17 on 2026-08-22 11:24
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_username(apps, schema_editor):
|
||||||
|
Organization = apps.get_model("organization", "Organization")
|
||||||
|
for org in Organization.objects.all():
|
||||||
|
if not org.username:
|
||||||
|
org.username = org.slug or f"org-{str(org.id)[:8]}"
|
||||||
|
org.save(update_fields=["username"])
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_backfill(apps, schema_editor):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
(
|
||||||
|
"organization",
|
||||||
|
"0002_organization_activity_field_organization_address_and_more",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="organization",
|
||||||
|
name="username",
|
||||||
|
field=models.SlugField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
default=None,
|
||||||
|
help_text="Business username / handle (نام کاربری کسبوکار)",
|
||||||
|
max_length=64,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(backfill_username, reverse_backfill),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="organization",
|
||||||
|
name="username",
|
||||||
|
field=models.SlugField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
default=None,
|
||||||
|
help_text="Business username / handle (نام کاربری کسبوکار)",
|
||||||
|
max_length=64,
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user