This commit is contained in:
bermooda-company 2026-08-23 23:59:14 +03:30
commit 54d5891edf
288 changed files with 31348 additions and 0 deletions

15
.editorconfig Normal file
View 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
View 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
View 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
View 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
View 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.

View File

View File

View 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")

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccessConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.access"

View 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')],
},
),
]

View 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"]
)

View 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",
)

View 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

View 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

View 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)

View File

View 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")

View 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'],
},
),
]

View 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'),
),
]

View 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

View 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

View 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"])

View 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)),
]

View 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)

View File

View 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

View 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')],
},
),
]

View 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')],
},
),
]

View File

@ -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')],
},
),
]

View 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()

View 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",
)

View 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

View 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")

View 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",
),
]

File diff suppressed because it is too large Load Diff

View 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)

View File

View 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,
)

View 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')],
},
),
]

View File

@ -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),
),
]

View 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"])

View 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

View 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)

View 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)

View 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")

View 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"),
]

View 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"

View 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)

View File

View 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")

View 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)

View 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()),
],
),
]

View File

@ -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),
),
]

View File

@ -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),
),
]

View 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')],
},
),
]

View File

@ -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']",
),
),
]

View File

@ -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),
),
]

View 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).",
),
),
]

View 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

View 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",
)

View 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,
)

View 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"))

View 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"),
]

View 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)),
]

View 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]

View File

View 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")

View 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')],
},
),
]

View File

@ -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),
),
]

View 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
)

View 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",
)

View 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)

View 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)),
]

View 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)

View File

View 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")

View File

@ -0,0 +1,7 @@
from django.urls import path
from apps.oauth.views import DiscoveryView
urlpatterns = [
path("", DiscoveryView.as_view(), name="oidc-discovery"),
]

View 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'],
},
),
]

View File

@ -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",
),
),
]

View 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')],
},
),
]

View 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()

View 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")

View 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

View 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>

View 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>

View 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"])

View 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"),
]

View 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)

View File

View 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")

View 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')],
},
),
]

View File

@ -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),
),
]

View File

@ -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