commit 54d5891edfbadd708a855412212049e627fb3082 Author: bermooda-company Date: Sun Aug 23 23:59:14 2026 +0330 user diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6b7f9e3 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5818238 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..54dbd4a --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2b55944 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..bca0d44 --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/apps/api/apps/__init__.py b/apps/api/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/access/__init__.py b/apps/api/apps/access/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/access/admin.py b/apps/api/apps/access/admin.py new file mode 100644 index 0000000..e22f912 --- /dev/null +++ b/apps/api/apps/access/admin.py @@ -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") diff --git a/apps/api/apps/access/apps.py b/apps/api/apps/access/apps.py new file mode 100644 index 0000000..04b97bf --- /dev/null +++ b/apps/api/apps/access/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccessConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.access" diff --git a/apps/api/apps/access/migrations/0001_initial.py b/apps/api/apps/access/migrations/0001_initial.py new file mode 100644 index 0000000..c8e22ae --- /dev/null +++ b/apps/api/apps/access/migrations/0001_initial.py @@ -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')], + }, + ), + ] diff --git a/apps/api/apps/access/migrations/__init__.py b/apps/api/apps/access/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/access/models.py b/apps/api/apps/access/models.py new file mode 100644 index 0000000..ebc4f16 --- /dev/null +++ b/apps/api/apps/access/models.py @@ -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"] + ) diff --git a/apps/api/apps/access/serializers.py b/apps/api/apps/access/serializers.py new file mode 100644 index 0000000..1426c5f --- /dev/null +++ b/apps/api/apps/access/serializers.py @@ -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", + ) diff --git a/apps/api/apps/access/services.py b/apps/api/apps/access/services.py new file mode 100644 index 0000000..3da5251 --- /dev/null +++ b/apps/api/apps/access/services.py @@ -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 diff --git a/apps/api/apps/access/urls.py b/apps/api/apps/access/urls.py new file mode 100644 index 0000000..07c8da3 --- /dev/null +++ b/apps/api/apps/access/urls.py @@ -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 diff --git a/apps/api/apps/access/views.py b/apps/api/apps/access/views.py new file mode 100644 index 0000000..b77830c --- /dev/null +++ b/apps/api/apps/access/views.py @@ -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) diff --git a/apps/api/apps/application/__init__.py b/apps/api/apps/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/application/admin.py b/apps/api/apps/application/admin.py new file mode 100644 index 0000000..08f31d6 --- /dev/null +++ b/apps/api/apps/application/admin.py @@ -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") \ No newline at end of file diff --git a/apps/api/apps/application/migrations/0001_initial.py b/apps/api/apps/application/migrations/0001_initial.py new file mode 100644 index 0000000..bd70940 --- /dev/null +++ b/apps/api/apps/application/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/apps/api/apps/application/migrations/0002_initial.py b/apps/api/apps/application/migrations/0002_initial.py new file mode 100644 index 0000000..7c6f26d --- /dev/null +++ b/apps/api/apps/application/migrations/0002_initial.py @@ -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'), + ), + ] diff --git a/apps/api/apps/application/migrations/__init__.py b/apps/api/apps/application/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/application/models.py b/apps/api/apps/application/models.py new file mode 100644 index 0000000..811fe85 --- /dev/null +++ b/apps/api/apps/application/models.py @@ -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 \ No newline at end of file diff --git a/apps/api/apps/application/serializers.py b/apps/api/apps/application/serializers.py new file mode 100644 index 0000000..81b8823 --- /dev/null +++ b/apps/api/apps/application/serializers.py @@ -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 \ No newline at end of file diff --git a/apps/api/apps/application/tests.py b/apps/api/apps/application/tests.py new file mode 100644 index 0000000..1450fe5 --- /dev/null +++ b/apps/api/apps/application/tests.py @@ -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"]) \ No newline at end of file diff --git a/apps/api/apps/application/urls.py b/apps/api/apps/application/urls.py new file mode 100644 index 0000000..d4902aa --- /dev/null +++ b/apps/api/apps/application/urls.py @@ -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)), +] diff --git a/apps/api/apps/application/views.py b/apps/api/apps/application/views.py new file mode 100644 index 0000000..604e157 --- /dev/null +++ b/apps/api/apps/application/views.py @@ -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) \ No newline at end of file diff --git a/apps/api/apps/authentication/__init__.py b/apps/api/apps/authentication/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/authentication/auth.py b/apps/api/apps/authentication/auth.py new file mode 100644 index 0000000..91f151d --- /dev/null +++ b/apps/api/apps/authentication/auth.py @@ -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 diff --git a/apps/api/apps/authentication/migrations/0001_initial.py b/apps/api/apps/authentication/migrations/0001_initial.py new file mode 100644 index 0000000..84b61de --- /dev/null +++ b/apps/api/apps/authentication/migrations/0001_initial.py @@ -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')], + }, + ), + ] diff --git a/apps/api/apps/authentication/migrations/0002_passkey.py b/apps/api/apps/authentication/migrations/0002_passkey.py new file mode 100644 index 0000000..d6795d9 --- /dev/null +++ b/apps/api/apps/authentication/migrations/0002_passkey.py @@ -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')], + }, + ), + ] diff --git a/apps/api/apps/authentication/migrations/0003_recoverycode_trusteddevice.py b/apps/api/apps/authentication/migrations/0003_recoverycode_trusteddevice.py new file mode 100644 index 0000000..9430424 --- /dev/null +++ b/apps/api/apps/authentication/migrations/0003_recoverycode_trusteddevice.py @@ -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')], + }, + ), + ] diff --git a/apps/api/apps/authentication/migrations/__init__.py b/apps/api/apps/authentication/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/authentication/models.py b/apps/api/apps/authentication/models.py new file mode 100644 index 0000000..8368016 --- /dev/null +++ b/apps/api/apps/authentication/models.py @@ -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() \ No newline at end of file diff --git a/apps/api/apps/authentication/serializers.py b/apps/api/apps/authentication/serializers.py new file mode 100644 index 0000000..26edb85 --- /dev/null +++ b/apps/api/apps/authentication/serializers.py @@ -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", + ) diff --git a/apps/api/apps/authentication/services.py b/apps/api/apps/authentication/services.py new file mode 100644 index 0000000..bcca47c --- /dev/null +++ b/apps/api/apps/authentication/services.py @@ -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 diff --git a/apps/api/apps/authentication/tests.py b/apps/api/apps/authentication/tests.py new file mode 100644 index 0000000..b1903c1 --- /dev/null +++ b/apps/api/apps/authentication/tests.py @@ -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") diff --git a/apps/api/apps/authentication/urls.py b/apps/api/apps/authentication/urls.py new file mode 100644 index 0000000..8839b35 --- /dev/null +++ b/apps/api/apps/authentication/urls.py @@ -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//", + CredentialUpdateView.as_view(), + name="credential-update", + ), + path( + "credentials//delete/", + CredentialDeleteView.as_view(), + name="credential-delete", + ), +] diff --git a/apps/api/apps/authentication/views.py b/apps/api/apps/authentication/views.py new file mode 100644 index 0000000..bd14e30 --- /dev/null +++ b/apps/api/apps/authentication/views.py @@ -0,0 +1,1022 @@ +from django.contrib.auth import authenticate +from django.urls import path +from django.utils import timezone +import io +import qrcode +import pyotp +import secrets +from django.core.files.base import ContentFile +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 apps.authentication.serializers import ( + ChangePasswordSerializer, + LoginSerializer, + LogoutSerializer, + PasswordResetConfirmSerializer, + PasswordResetRequestSerializer, + RefreshSerializer, + RegisterSerializer, + PasskeySerializer, + CredentialSerializer, +) +from apps.authentication.models import Credential, CredentialType, Passkey +from apps.authentication.services import ( + blacklist_token, + count_remaining_recovery_codes, + create_access_token, + create_auth_tokens, + generate_recovery_codes, + redeem_recovery_code, + revoke_all_user_sessions, + rotate_refresh_tokens, +) +from apps.common.ratelimit import RateLimiter +from apps.common.utils import client_ip, hash_token, device_name +from apps.identity.models import User, UserStatus +from apps.identity.serializers import UserSerializer, ProfileUpdateSerializer +from apps.oauth.models import RefreshToken +from apps.security.models import SecurityEventType, Severity, record_security_event +from apps.session.models import Session, SessionStatus +from apps.authentication.webauthn_utils import ( + decode_credential_id, + encode_credential_data, + encode_credential_id, + get_server, + options_to_client, + passkey_to_credential, + user_entity, +) +from fido2.webauthn import AuthenticationResponse +from apps.common.models import OutboxEvent + +LOGIN_LIMITER = RateLimiter("login", max_attempts=5, window_seconds=300) + + +class LoginView(APIView): + permission_classes = [AllowAny] + authentication_classes = [] + serializer_class = LoginSerializer + + def post(self, request): + serializer = LoginSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + identifier = serializer.validated_data["email"].strip() + password = serializer.validated_data["password"] + ip = client_ip(request) + + # Resolve the account by email OR phone before authenticating. + from django.contrib.auth import get_user_model + + lookup = ( + {"email__iexact": identifier} + if "@" in identifier + else {"phone": identifier} + ) + target_user = get_user_model().objects.filter(**lookup).first() + + if not LOGIN_LIMITER.is_allowed(f"{identifier.lower()}:{ip}"): + record_security_event( + SecurityEventType.LOGIN_LOCKED, + severity=Severity.HIGH, + ip_address=ip, + user_agent=request.META.get("HTTP_USER_AGENT", ""), + metadata={"identifier": identifier, "reason": "rate_limit"}, + ) + return Response( + {"detail": "Too many login attempts. Please try again later."}, + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) + + user = None + if target_user is not None: + user = authenticate( + request, + username=target_user.email, + password=password, + ) + if user is None: + record_security_event( + SecurityEventType.LOGIN_FAILED, + user=target_user, + severity=Severity.MEDIUM, + ip_address=ip, + user_agent=request.META.get("HTTP_USER_AGENT", ""), + metadata={"identifier": identifier}, + ) + return Response( + {"detail": "Invalid credentials."}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + if user.status not in ("active",) or not user.is_active: + return Response( + {"detail": "This account is not active."}, + status=status.HTTP_403_FORBIDDEN, + ) + + if user.mfa_enabled and user.totp_secret: + import pyotp + + mfa_code = ( + (serializer.validated_data.get("mfa_code", "") or "").strip().upper() + ) + if not mfa_code: + return Response( + {"detail": "MFA code required.", "mfa_required": True}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + totp = pyotp.TOTP(user.totp_secret) + mfa_ok = totp.verify(mfa_code, valid_window=1) + # Google-style fallback: a one-time recovery code also satisfies MFA. + if not mfa_ok: + mfa_ok = redeem_recovery_code(user, mfa_code) + + if not mfa_ok: + record_security_event( + SecurityEventType.LOGIN_FAILED, + user=user, + severity=Severity.HIGH, + ip_address=ip, + user_agent=request.META.get("HTTP_USER_AGENT", ""), + metadata={"email": identifier, "reason": "mfa_failed"}, + ) + return Response( + {"detail": "Invalid MFA code or recovery code."}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + tokens = create_auth_tokens( + user, + request, + product_key=serializer.validated_data.get("product_key"), + organization_id=serializer.validated_data.get("organization_id"), + ) + User.objects.filter(pk=user.pk).update(last_login_at=timezone.now()) + record_security_event( + SecurityEventType.LOGIN_SUCCESS, + user=user, + severity=Severity.INFO, + ip_address=ip, + user_agent=request.META.get("HTTP_USER_AGENT", ""), + metadata={"session_id": tokens["session_id"]}, + ) + OutboxEvent.objects.publish( + OutboxEvent.EventType.AUTH, + user=user, + title="New sign-in", + message="A new session was created for your account.", + metadata={"session_id": tokens["session_id"], "ip": ip}, + security_event_type=SecurityEventType.LOGIN_SUCCESS, + ) + ensure_credential(user, CredentialType.PASSWORD, provider="identity") + return Response(tokens, status=status.HTTP_200_OK) + + +class RefreshView(APIView): + permission_classes = [AllowAny] + authentication_classes = [] + serializer_class = RefreshSerializer + + def post(self, request): + serializer = RefreshSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + raw_refresh = serializer.validated_data["refresh"] + + try: + refresh_token = RefreshToken.objects.select_related( + "user", "session", "application" + ).get(token_hash=hash_token(raw_refresh)) + except RefreshToken.DoesNotExist: + return Response( + {"detail": "Invalid refresh token."}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + if not refresh_token.is_valid(): + return Response( + {"detail": "Refresh token is expired or revoked."}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + user = refresh_token.user + if user.status not in ("active",) or not user.is_active: + return Response( + {"detail": "This account is not active."}, + status=status.HTTP_403_FORBIDDEN, + ) + + session = refresh_token.session + session.last_activity_at = timezone.now() + session.save(update_fields=["last_activity_at", "updated_at"]) + + new_refresh, new_token = rotate_refresh_tokens( + user, + session, + application=refresh_token.application, + ) + refresh_token.revoked = True + refresh_token.replaced_by = new_token.token_hash + refresh_token.save(update_fields=["replaced_by", "revoked", "updated_at"]) + + return Response( + { + "access": str(create_access_token(user)), + "refresh": new_refresh, + "session_id": str(session.id), + } + ) + + +class LogoutView(APIView): + permission_classes = [IsAuthenticated] + serializer_class = LogoutSerializer + + def post(self, request): + serializer = LogoutSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + raw_refresh = serializer.validated_data["refresh"] + logout_type = serializer.validated_data.get("logout_type", "global") + + auth_header = request.META.get("HTTP_AUTHORIZATION", "") + access_token = None + if auth_header.startswith("Bearer "): + access_token = auth_header.split(" ")[1] + + try: + refresh_token = RefreshToken.objects.select_related("session").get( + token_hash=hash_token(raw_refresh), + user=request.user, + ) + except RefreshToken.DoesNotExist: + if access_token: + blacklist_token(access_token, "access") + return Response(status=status.HTTP_204_NO_CONTENT) + + if logout_type == "global": + # Decision 8: Global (SSO) logout is the default. Terminate every + # session/token across all products and bump the token version so + # any still-valid access tokens are rejected on next use. + revoke_all_user_sessions(request.user, reason="global_logout") + else: + # Local logout: revoke only this session + its refresh token. + session = refresh_token.session + session.revoke(reason="logout") + refresh_token.revoked = True + refresh_token.save(update_fields=["revoked", "updated_at"]) + + if access_token: + blacklist_token(access_token, "access") + + record_security_event( + SecurityEventType.LOGOUT, + user=request.user, + ip_address=client_ip(request), + user_agent=request.META.get("HTTP_USER_AGENT", ""), + metadata={"logout_type": logout_type}, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class ChangePasswordView(APIView): + permission_classes = [IsAuthenticated] + serializer_class = ChangePasswordSerializer + + def post(self, request): + serializer = ChangePasswordSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = request.user + + if not user.check_password(serializer.validated_data["current_password"]): + return Response( + {"current_password": "Current password is incorrect."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + auth_header = request.META.get("HTTP_AUTHORIZATION", "") + access_token = None + if auth_header.startswith("Bearer "): + access_token = auth_header.split(" ")[1] + + user.set_password(serializer.validated_data["new_password"]) + user.save(update_fields=["password", "updated_at"]) + Session.objects.filter(user=user, status=SessionStatus.ACTIVE).update( + status=SessionStatus.REVOKED, + revoked_reason="password_change", + ) + + if access_token: + blacklist_token(access_token, "access") + + record_security_event( + SecurityEventType.PASSWORD_CHANGE, + user=user, + severity=Severity.MEDIUM, + ip_address=client_ip(request), + user_agent=request.META.get("HTTP_USER_AGENT", ""), + ) + OutboxEvent.objects.publish( + OutboxEvent.EventType.CREDENTIAL, + user=user, + title="Password changed", + message="Your account password was changed.", + metadata={}, + security_event_type=SecurityEventType.PASSWORD_CHANGE, + ) + ensure_credential( + user, CredentialType.PASSWORD, provider="identity", mark_used=True + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class PasswordResetRequestView(APIView): + permission_classes = [AllowAny] + authentication_classes = [] + serializer_class = PasswordResetRequestSerializer + + def post(self, request): + serializer = PasswordResetRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + email = serializer.validated_data["email"] + ip = client_ip(request) + + user = User.objects.filter(email__iexact=email).first() + + if user: + from django.utils import timezone + import secrets + + token = secrets.token_urlsafe(32) + token_hash = hash_token(token) + + user.password_reset_token = token_hash + user.password_reset_expires = timezone.now() + timezone.timedelta(hours=1) + user.save( + update_fields=[ + "password_reset_token", + "password_reset_expires", + "updated_at", + ] + ) + + record_security_event( + SecurityEventType.PASSWORD_CHANGE, + user=user, + severity=Severity.INFO, + ip_address=ip, + user_agent=request.META.get("HTTP_USER_AGENT", ""), + metadata={"action": "reset_requested"}, + ) + + return Response( + {"detail": "If the email exists, a reset link has been sent."}, + status=status.HTTP_200_OK, + ) + + +class PasswordResetConfirmView(APIView): + permission_classes = [AllowAny] + authentication_classes = [] + serializer_class = PasswordResetConfirmSerializer + + def post(self, request): + serializer = PasswordResetConfirmSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + token = serializer.validated_data["token"] + new_password = serializer.validated_data["new_password"] + + token_hash = hash_token(token) + + try: + user = User.objects.get( + password_reset_token=token_hash, + password_reset_expires__gt=timezone.now(), + ) + except User.DoesNotExist: + return Response( + {"detail": "Invalid or expired reset token."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user.set_password(new_password) + user.password_reset_token = "" + user.password_reset_expires = None + user.save( + update_fields=[ + "password", + "password_reset_token", + "password_reset_expires", + "updated_at", + ] + ) + + Session.objects.filter(user=user, status=SessionStatus.ACTIVE).update( + status=SessionStatus.REVOKED, + revoked_reason="password_reset", + ) + + record_security_event( + SecurityEventType.PASSWORD_CHANGE, + user=user, + severity=Severity.MEDIUM, + ip_address=client_ip(request), + user_agent=request.META.get("HTTP_USER_AGENT", ""), + metadata={"action": "reset_confirmed"}, + ) + + return Response( + {"detail": "Password has been reset successfully."}, + status=status.HTTP_200_OK, + ) + + +class EnableMFAView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + user = request.user + if user.mfa_enabled: + return Response( + {"detail": "MFA is already enabled."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + secret = pyotp.random_base32() + user.totp_secret = secret + user.save(update_fields=["totp_secret", "updated_at"]) + + totp_uri = pyotp.totp.TOTP(secret).provisioning_uri( + name=user.email or user.full_name, + issuer_name="Identity Platform", + ) + + qr = qrcode.QRCode(version=1, box_size=10, border=4) + qr.add_data(totp_uri) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + img_io = io.BytesIO() + img.save(img_io, format="PNG") + img_io.seek(0) + + import base64 + + img_base64 = base64.b64encode(img_io.read()).decode() + + return Response( + { + "secret": secret, + "qr_code": f"data:image/png;base64,{img_base64}", + "totp_uri": totp_uri, + } + ) + + +class VerifyMFASetupView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + user = request.user + code = request.data.get("code", "") + + if not code: + return Response( + {"detail": "Verification code is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not user.totp_secret: + return Response( + {"detail": "MFA setup not initiated. Call GET first."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + totp = pyotp.TOTP(user.totp_secret) + if not totp.verify(code, valid_window=1): + return Response( + {"detail": "Invalid verification code."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user.mfa_enabled = True + user.save(update_fields=["mfa_enabled", "updated_at"]) + + from apps.verification.services import TrustEngine + from apps.verification.models import ( + EvidenceType, + EvidenceConfidence, + VerificationDimension, + ) + + TrustEngine.record_evidence( + person=user, + dimension=VerificationDimension.BIOMETRIC, + evidence_type=EvidenceType.TOTP, + method="totp", + provider="Identity Platform", + confidence=EvidenceConfidence.HIGH, + ) + + record_security_event( + SecurityEventType.MFA_ENABLED, + user=user, + severity=Severity.MEDIUM, + ip_address=client_ip(request), + user_agent=request.META.get("HTTP_USER_AGENT", ""), + ) + + return Response({"detail": "MFA enabled successfully."}) + + +class DisableMFAView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + user = request.user + + if not user.mfa_enabled: + return Response( + {"detail": "MFA is not enabled."}, status=status.HTTP_400_BAD_REQUEST + ) + + current_password = request.data.get("current_password", "") + if not user.check_password(current_password): + return Response( + {"detail": "Current password is incorrect."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user.mfa_enabled = False + user.totp_secret = "" + user.save(update_fields=["mfa_enabled", "totp_secret", "updated_at"]) + + record_security_event( + SecurityEventType.MFA_DISABLED, + user=user, + severity=Severity.MEDIUM, + ip_address=client_ip(request), + user_agent=request.META.get("HTTP_USER_AGENT", ""), + ) + + return Response({"detail": "MFA disabled successfully."}) + + +class RecoveryCodeGenerateView(APIView): + """Generate a fresh set of one-time MFA recovery codes (Google-style). + + Codes are shown to the user exactly once; only their hashes are stored. + Requires the current password and an enabled MFA, matching Google's flow. + """ + + permission_classes = [IsAuthenticated] + + def post(self, request): + user = request.user + if not user.check_password(request.data.get("current_password", "")): + return Response( + {"detail": "Current password is required to view recovery codes."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not user.mfa_enabled: + return Response( + {"detail": "Enable MFA before generating recovery codes."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + codes = generate_recovery_codes(user) + record_security_event( + SecurityEventType.CREDENTIAL_ADDED, + user=user, + severity=Severity.MEDIUM, + ip_address=client_ip(request), + user_agent=request.META.get("HTTP_USER_AGENT", ""), + metadata={"reason": "recovery_codes_generated", "count": len(codes)}, + ) + return Response( + {"recovery_codes": codes, "count": len(codes)}, + status=status.HTTP_201_CREATED, + ) + + +class RecoveryCodeStatusView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + return Response({"remaining": count_remaining_recovery_codes(request.user)}) + + +class RegisterView(APIView): + """Public self-registration. + + Creates an ``active`` account (so the user can immediately authenticate), + issues a session + tokens, and returns the user profile alongside them. + Mirrors the Hamsoo pattern of returning tokens + user in one call. + """ + + permission_classes = [AllowAny] + authentication_classes = [] + serializer_class = RegisterSerializer + + def post(self, request): + serializer = RegisterSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + username = data.get("username") or None + if username: + username = username.lower() + if User.objects.filter(username=username).exists(): + return Response( + {"username": ["This username is already taken."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = User.objects.create_user( + email=data["email"], + password=data["password"], + full_name=data.get("full_name") or "", + username=username, + status=UserStatus.ACTIVE, + ) + + tokens = create_auth_tokens(user, request) + User.objects.filter(pk=user.pk).update(last_login_at=timezone.now()) + + record_security_event( + SecurityEventType.SESSION_CREATED, + user=user, + severity=Severity.INFO, + ip_address=client_ip(request), + user_agent=request.META.get("HTTP_USER_AGENT", ""), + metadata={"reason": "registration", "session_id": tokens["session_id"]}, + ) + + return Response( + {"user": UserSerializer(user).data, **tokens}, + status=status.HTTP_201_CREATED, + ) + + +class MeView(APIView): + """Return (GET) or update (PATCH) the currently authenticated user profile.""" + + permission_classes = [IsAuthenticated] + + def get(self, request): + return Response(UserSerializer(request.user).data) + + def patch(self, request): + serializer = ProfileUpdateSerializer( + request.user, data=request.data, partial=True + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(UserSerializer(request.user).data) + + +def ensure_credential(user, credential_type, provider="", mark_used=False): + """FIX 4: Make sure a Credential record exists for the user/type/provider. + + Returns the (possibly created) Credential. If mark_used is set the + record is touched so the credentials list reflects real activity. + """ + cred, _created = Credential.objects.get_or_create( + user=user, + credential_type=credential_type, + provider=provider, + defaults={"is_default": not Credential.objects.filter(user=user).exists()}, + ) + if mark_used: + cred.mark_used() + return cred + + +class PasskeyRegisterBeginView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + user = request.user + server = get_server() + existing = [ + passkey_to_credential(pk) for pk in user.passkeys.filter(is_active=True) + ] + options, state = server.register_begin( + user_entity(user), + credentials=existing, + user_verification="preferred", + ) + request.session["passkey_register_state"] = state + return Response(options_to_client(options)) + + +class PasskeyRegisterFinishView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + state = request.session.get("passkey_register_state") + if not state: + return Response( + {"detail": "Passkey registration was not initiated."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = request.user + server = get_server() + try: + auth_data = server.register_complete(state, request.data) + except ValueError as exc: + request.session.pop("passkey_register_state", None) + return Response( + {"detail": f"Passkey registration failed: {exc}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + request.session.pop("passkey_register_state", None) + credential = auth_data.credential_data + credential_id = encode_credential_id(credential.credential_id) + public_key = encode_credential_data(credential) + + passkey = Passkey.objects.create( + user=user, + credential_id=credential_id, + public_key=public_key, + name=request.data.get("name", "New Passkey"), + sign_count=auth_data.counter, + touch_enabled=True, + user_verification="preferred", + ) + + from apps.verification.models import ( + EvidenceConfidence, + EvidenceType, + VerificationDimension, + ) + from apps.verification.services import TrustEngine + + TrustEngine.record_evidence( + person=user, + dimension=VerificationDimension.BIOMETRIC, + evidence_type=EvidenceType.PASSKEY, + method="webauthn", + provider="Identity Platform", + confidence=EvidenceConfidence.HIGH, + ) + + record_security_event( + SecurityEventType.PASSKEY_REGISTERED, + user=user, + severity=Severity.INFO, + ip_address=client_ip(request), + user_agent=request.META.get("HTTP_USER_AGENT", ""), + metadata={"credential_id": credential_id, "action": "register"}, + ) + OutboxEvent.objects.publish( + OutboxEvent.EventType.PASSKEY, + user=user, + title="Passkey registered", + message=f"A new passkey '{passkey.name}' was registered.", + metadata={"credential_id": credential_id, "action": "register"}, + security_event_type=SecurityEventType.PASSKEY_REGISTERED, + ) + ensure_credential(user, CredentialType.PASSKEY, provider="webauthn") + + return Response(PasskeySerializer(passkey).data, status=status.HTTP_201_CREATED) + + +class PasskeyAuthenticateBeginView(APIView): + permission_classes = [AllowAny] + + def post(self, request): + email = request.data.get("email", "") + if not email: + return Response( + {"detail": "email is required to begin passkey authentication."}, + status=status.HTTP_400_BAD_REQUEST, + ) + user = User.objects.filter(email__iexact=email).first() + if not user or not user.is_active: + return Response( + {"detail": "No passkey account found for this email."}, + status=status.HTTP_404_NOT_FOUND, + ) + + server = get_server() + credentials = [ + passkey_to_credential(pk) for pk in user.passkeys.filter(is_active=True) + ] + if not credentials: + return Response( + {"detail": "This account has no registered passkeys."}, + status=status.HTTP_404_NOT_FOUND, + ) + + options, state = server.authenticate_begin(credentials=credentials) + request.session["passkey_auth_state"] = state + request.session["passkey_auth_user_id"] = str(user.id) + return Response(options_to_client(options)) + + +class PasskeyAuthenticateFinishView(APIView): + permission_classes = [AllowAny] + + def post(self, request): + state = request.session.get("passkey_auth_state") + if not state: + return Response( + {"detail": "Passkey authentication was not initiated."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + raw_id = request.data.get("rawId") or request.data.get("id") or "" + try: + credential_id_bytes = decode_credential_id(raw_id) + except Exception: + request.session.pop("passkey_auth_state", None) + return Response( + {"detail": "Invalid credential id."}, + status=status.HTTP_400_BAD_REQUEST, + ) + credential_id = encode_credential_id(credential_id_bytes) + + try: + passkey = Passkey.objects.get(credential_id=credential_id, is_active=True) + except Passkey.DoesNotExist: + request.session.pop("passkey_auth_state", None) + return Response( + {"detail": "Passkey not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + user = passkey.user + server = get_server() + stored_credential = passkey_to_credential(passkey) + try: + auth_response = AuthenticationResponse.from_dict(request.data) + server.authenticate_complete(state, [stored_credential], auth_response) + except ValueError as exc: + request.session.pop("passkey_auth_state", None) + record_security_event( + SecurityEventType.PASSKEY_AUTH_FAILED, + user=user, + severity=Severity.HIGH, + ip_address=client_ip(request), + user_agent=request.META.get("HTTP_USER_AGENT", ""), + metadata={"credential_id": credential_id, "action": "authenticate"}, + ) + return Response( + {"detail": f"Passkey authentication failed: {exc}"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + request.session.pop("passkey_auth_state", None) + new_counter = auth_response.response.authenticator_data.counter + passkey.sign_count = new_counter + passkey.last_used_at = timezone.now() + passkey.save(update_fields=["sign_count", "last_used_at", "updated_at"]) + + record_security_event( + SecurityEventType.PASSKEY_AUTHENTICATED, + user=user, + severity=Severity.INFO, + ip_address=client_ip(request), + user_agent=request.META.get("HTTP_USER_AGENT", ""), + metadata={"credential_id": credential_id, "action": "authenticate"}, + ) + OutboxEvent.objects.publish( + OutboxEvent.EventType.PASSKEY, + user=user, + title="Passkey sign-in", + message=f"You signed in with passkey '{passkey.name}'.", + metadata={"credential_id": credential_id, "action": "authenticate"}, + security_event_type=SecurityEventType.PASSKEY_AUTHENTICATED, + ) + ensure_credential( + user, CredentialType.PASSKEY, provider="webauthn", mark_used=True + ) + + tokens = create_auth_tokens(user, request) + return Response( + {**tokens, "passkey_id": passkey.credential_id}, + status=status.HTTP_200_OK, + ) + + +class PasskeyListView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + user = request.user + passkeys = user.passkeys.filter(is_active=True) + return Response(PasskeySerializer(passkeys, many=True).data) + + +class CredentialListCreateView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + user = request.user + credentials = user.credentials.all() + return Response(CredentialSerializer(credentials, many=True).data) + + def post(self, request): + serializer = CredentialSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(user=request.user) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class CredentialUpdateView(APIView): + permission_classes = [IsAuthenticated] + + def patch(self, request, credential_id): + user = request.user + try: + credential = Credential.objects.get(id=credential_id, user=user) + except Credential.DoesNotExist: + return Response( + {"detail": "Credential not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + serializer = CredentialSerializer(credential, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class CredentialDeleteView(APIView): + permission_classes = [IsAuthenticated] + + def delete(self, request, credential_id): + user = request.user + try: + credential = Credential.objects.get(id=credential_id, user=user) + credential.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + except Credential.DoesNotExist: + return Response( + {"detail": "Credential not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + +urlpatterns = [ + path("login/", LoginView.as_view(), name="auth-login"), + 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"), + # 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//", + CredentialUpdateView.as_view(), + name="credential-update", + ), + path( + "credentials//delete/", + CredentialDeleteView.as_view(), + name="credential-delete", + ), +] diff --git a/apps/api/apps/authentication/webauthn_utils.py b/apps/api/apps/authentication/webauthn_utils.py new file mode 100644 index 0000000..545477e --- /dev/null +++ b/apps/api/apps/authentication/webauthn_utils.py @@ -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) diff --git a/apps/api/apps/common/__init__.py b/apps/api/apps/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/common/exceptions.py b/apps/api/apps/common/exceptions.py new file mode 100644 index 0000000..c4af31b --- /dev/null +++ b/apps/api/apps/common/exceptions.py @@ -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, + ) diff --git a/apps/api/apps/common/migrations/0001_initial.py b/apps/api/apps/common/migrations/0001_initial.py new file mode 100644 index 0000000..e1a764a --- /dev/null +++ b/apps/api/apps/common/migrations/0001_initial.py @@ -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')], + }, + ), + ] diff --git a/apps/api/apps/common/migrations/0002_outboxevent_message_outboxevent_title.py b/apps/api/apps/common/migrations/0002_outboxevent_message_outboxevent_title.py new file mode 100644 index 0000000..3358219 --- /dev/null +++ b/apps/api/apps/common/migrations/0002_outboxevent_message_outboxevent_title.py @@ -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), + ), + ] diff --git a/apps/api/apps/common/migrations/__init__.py b/apps/api/apps/common/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/common/models.py b/apps/api/apps/common/models.py new file mode 100644 index 0000000..13d39c2 --- /dev/null +++ b/apps/api/apps/common/models.py @@ -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"]) diff --git a/apps/api/apps/common/pagination.py b/apps/api/apps/common/pagination.py new file mode 100644 index 0000000..3ce817f --- /dev/null +++ b/apps/api/apps/common/pagination.py @@ -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 diff --git a/apps/api/apps/common/permissions.py b/apps/api/apps/common/permissions.py new file mode 100644 index 0000000..5e7f5bc --- /dev/null +++ b/apps/api/apps/common/permissions.py @@ -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) diff --git a/apps/api/apps/common/ratelimit.py b/apps/api/apps/common/ratelimit.py new file mode 100644 index 0000000..79cc4a4 --- /dev/null +++ b/apps/api/apps/common/ratelimit.py @@ -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) diff --git a/apps/api/apps/common/tests.py b/apps/api/apps/common/tests.py new file mode 100644 index 0000000..a5d4438 --- /dev/null +++ b/apps/api/apps/common/tests.py @@ -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") \ No newline at end of file diff --git a/apps/api/apps/common/urls.py b/apps/api/apps/common/urls.py new file mode 100644 index 0000000..9cb2070 --- /dev/null +++ b/apps/api/apps/common/urls.py @@ -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"), +] diff --git a/apps/api/apps/common/utils.py b/apps/api/apps/common/utils.py new file mode 100644 index 0000000..9301164 --- /dev/null +++ b/apps/api/apps/common/utils.py @@ -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" diff --git a/apps/api/apps/common/views.py b/apps/api/apps/common/views.py new file mode 100644 index 0000000..ecb6bc4 --- /dev/null +++ b/apps/api/apps/common/views.py @@ -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) diff --git a/apps/api/apps/identity/__init__.py b/apps/api/apps/identity/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/identity/admin.py b/apps/api/apps/identity/admin.py new file mode 100644 index 0000000..121d454 --- /dev/null +++ b/apps/api/apps/identity/admin.py @@ -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") diff --git a/apps/api/apps/identity/managers.py b/apps/api/apps/identity/managers.py new file mode 100644 index 0000000..6ee9c2c --- /dev/null +++ b/apps/api/apps/identity/managers.py @@ -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) \ No newline at end of file diff --git a/apps/api/apps/identity/migrations/0001_initial.py b/apps/api/apps/identity/migrations/0001_initial.py new file mode 100644 index 0000000..931d200 --- /dev/null +++ b/apps/api/apps/identity/migrations/0001_initial.py @@ -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()), + ], + ), + ] diff --git a/apps/api/apps/identity/migrations/0002_password_reset_fields.py b/apps/api/apps/identity/migrations/0002_password_reset_fields.py new file mode 100644 index 0000000..dde4277 --- /dev/null +++ b/apps/api/apps/identity/migrations/0002_password_reset_fields.py @@ -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), + ), + ] diff --git a/apps/api/apps/identity/migrations/0003_alter_user_managers_user_birth_date_and_more.py b/apps/api/apps/identity/migrations/0003_alter_user_managers_user_birth_date_and_more.py new file mode 100644 index 0000000..8f47cb3 --- /dev/null +++ b/apps/api/apps/identity/migrations/0003_alter_user_managers_user_birth_date_and_more.py @@ -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), + ), + ] diff --git a/apps/api/apps/identity/migrations/0004_identitydocument.py b/apps/api/apps/identity/migrations/0004_identitydocument.py new file mode 100644 index 0000000..0638531 --- /dev/null +++ b/apps/api/apps/identity/migrations/0004_identitydocument.py @@ -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')], + }, + ), + ] diff --git a/apps/api/apps/identity/migrations/0005_user_province_skills.py b/apps/api/apps/identity/migrations/0005_user_province_skills.py new file mode 100644 index 0000000..3e05153 --- /dev/null +++ b/apps/api/apps/identity/migrations/0005_user_province_skills.py @@ -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']", + ), + ), + ] diff --git a/apps/api/apps/identity/migrations/0006_user_city_user_country.py b/apps/api/apps/identity/migrations/0006_user_city_user_country.py new file mode 100644 index 0000000..271662b --- /dev/null +++ b/apps/api/apps/identity/migrations/0006_user_city_user_country.py @@ -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), + ), + ] diff --git a/apps/api/apps/identity/migrations/0007_user_token_version.py b/apps/api/apps/identity/migrations/0007_user_token_version.py new file mode 100644 index 0000000..f308999 --- /dev/null +++ b/apps/api/apps/identity/migrations/0007_user_token_version.py @@ -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).", + ), + ), + ] diff --git a/apps/api/apps/identity/migrations/__init__.py b/apps/api/apps/identity/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/identity/models.py b/apps/api/apps/identity/models.py new file mode 100644 index 0000000..5d9ff26 --- /dev/null +++ b/apps/api/apps/identity/models.py @@ -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 diff --git a/apps/api/apps/identity/serializers.py b/apps/api/apps/identity/serializers.py new file mode 100644 index 0000000..f4b3eaa --- /dev/null +++ b/apps/api/apps/identity/serializers.py @@ -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", + ) diff --git a/apps/api/apps/identity/summary.py b/apps/api/apps/identity/summary.py new file mode 100644 index 0000000..4ecd4f0 --- /dev/null +++ b/apps/api/apps/identity/summary.py @@ -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, + ) \ No newline at end of file diff --git a/apps/api/apps/identity/tests.py b/apps/api/apps/identity/tests.py new file mode 100644 index 0000000..e8b1e5a --- /dev/null +++ b/apps/api/apps/identity/tests.py @@ -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")) \ No newline at end of file diff --git a/apps/api/apps/identity/urls.py b/apps/api/apps/identity/urls.py new file mode 100644 index 0000000..e91fa36 --- /dev/null +++ b/apps/api/apps/identity/urls.py @@ -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//", IdentityDocumentDetailView.as_view(), name="identity-document-detail"), + path("documents//approve/", IdentityDocumentApproveView.as_view(), name="identity-document-approve"), +] diff --git a/apps/api/apps/identity/user_urls.py b/apps/api/apps/identity/user_urls.py new file mode 100644 index 0000000..68bbb38 --- /dev/null +++ b/apps/api/apps/identity/user_urls.py @@ -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)), +] diff --git a/apps/api/apps/identity/views.py b/apps/api/apps/identity/views.py new file mode 100644 index 0000000..1fc64d9 --- /dev/null +++ b/apps/api/apps/identity/views.py @@ -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] \ No newline at end of file diff --git a/apps/api/apps/membership/__init__.py b/apps/api/apps/membership/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/membership/admin.py b/apps/api/apps/membership/admin.py new file mode 100644 index 0000000..29cf038 --- /dev/null +++ b/apps/api/apps/membership/admin.py @@ -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") diff --git a/apps/api/apps/membership/migrations/0001_initial.py b/apps/api/apps/membership/migrations/0001_initial.py new file mode 100644 index 0000000..883c620 --- /dev/null +++ b/apps/api/apps/membership/migrations/0001_initial.py @@ -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')], + }, + ), + ] diff --git a/apps/api/apps/membership/migrations/0002_membership_ended_at.py b/apps/api/apps/membership/migrations/0002_membership_ended_at.py new file mode 100644 index 0000000..fc8ba5e --- /dev/null +++ b/apps/api/apps/membership/migrations/0002_membership_ended_at.py @@ -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), + ), + ] diff --git a/apps/api/apps/membership/migrations/__init__.py b/apps/api/apps/membership/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/membership/models.py b/apps/api/apps/membership/models.py new file mode 100644 index 0000000..a4b6695 --- /dev/null +++ b/apps/api/apps/membership/models.py @@ -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 + ) diff --git a/apps/api/apps/membership/serializers.py b/apps/api/apps/membership/serializers.py new file mode 100644 index 0000000..4bc8e3c --- /dev/null +++ b/apps/api/apps/membership/serializers.py @@ -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", + ) diff --git a/apps/api/apps/membership/tests.py b/apps/api/apps/membership/tests.py new file mode 100644 index 0000000..17cf195 --- /dev/null +++ b/apps/api/apps/membership/tests.py @@ -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) diff --git a/apps/api/apps/membership/urls.py b/apps/api/apps/membership/urls.py new file mode 100644 index 0000000..1cf3e74 --- /dev/null +++ b/apps/api/apps/membership/urls.py @@ -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)), +] diff --git a/apps/api/apps/membership/views.py b/apps/api/apps/membership/views.py new file mode 100644 index 0000000..9d5e754 --- /dev/null +++ b/apps/api/apps/membership/views.py @@ -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) diff --git a/apps/api/apps/oauth/__init__.py b/apps/api/apps/oauth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/oauth/admin.py b/apps/api/apps/oauth/admin.py new file mode 100644 index 0000000..270fc06 --- /dev/null +++ b/apps/api/apps/oauth/admin.py @@ -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") \ No newline at end of file diff --git a/apps/api/apps/oauth/discovery_urls.py b/apps/api/apps/oauth/discovery_urls.py new file mode 100644 index 0000000..81bb637 --- /dev/null +++ b/apps/api/apps/oauth/discovery_urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from apps.oauth.views import DiscoveryView + +urlpatterns = [ + path("", DiscoveryView.as_view(), name="oidc-discovery"), +] diff --git a/apps/api/apps/oauth/migrations/0001_initial.py b/apps/api/apps/oauth/migrations/0001_initial.py new file mode 100644 index 0000000..bbca0ab --- /dev/null +++ b/apps/api/apps/oauth/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/apps/api/apps/oauth/migrations/0002_authorizationcode_organization.py b/apps/api/apps/oauth/migrations/0002_authorizationcode_organization.py new file mode 100644 index 0000000..ef99d60 --- /dev/null +++ b/apps/api/apps/oauth/migrations/0002_authorizationcode_organization.py @@ -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", + ), + ), + ] diff --git a/apps/api/apps/oauth/migrations/0003_consent.py b/apps/api/apps/oauth/migrations/0003_consent.py new file mode 100644 index 0000000..a1c524d --- /dev/null +++ b/apps/api/apps/oauth/migrations/0003_consent.py @@ -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')], + }, + ), + ] diff --git a/apps/api/apps/oauth/migrations/__init__.py b/apps/api/apps/oauth/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/oauth/models.py b/apps/api/apps/oauth/models.py new file mode 100644 index 0000000..bf36622 --- /dev/null +++ b/apps/api/apps/oauth/models.py @@ -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() diff --git a/apps/api/apps/oauth/serializers.py b/apps/api/apps/oauth/serializers.py new file mode 100644 index 0000000..471b39d --- /dev/null +++ b/apps/api/apps/oauth/serializers.py @@ -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") \ No newline at end of file diff --git a/apps/api/apps/oauth/services.py b/apps/api/apps/oauth/services.py new file mode 100644 index 0000000..34f421d --- /dev/null +++ b/apps/api/apps/oauth/services.py @@ -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 diff --git a/apps/api/apps/oauth/templates/oauth/consent.html b/apps/api/apps/oauth/templates/oauth/consent.html new file mode 100644 index 0000000..ea35779 --- /dev/null +++ b/apps/api/apps/oauth/templates/oauth/consent.html @@ -0,0 +1,58 @@ + + + + + + تأیید دسترسی | Hamsoo SSO + + + +
+ +

تأیید دسترسی

+

این سرویس می‌خواهد به حساب یکپارچه شما دسترسی داشته باشد

+ +
{{ application.name }}
+ + {% if organization_id %}
کسب‌وکار فعال: {{ organization_id }}
{% endif %} + +
    + {% for scope in scopes %} +
  • {{ scope.code }}{{ scope.description }}
  • + {% empty %} +
  • دسترسی پایه (openid)
  • + {% endfor %} +
+ +
+ {% csrf_token %} + {% for key, value in query_params %} + + {% endfor %} +
+ + +
+
+
+ + diff --git a/apps/api/apps/oauth/templates/oauth/web_message.html b/apps/api/apps/oauth/templates/oauth/web_message.html new file mode 100644 index 0000000..ab5e4b2 --- /dev/null +++ b/apps/api/apps/oauth/templates/oauth/web_message.html @@ -0,0 +1,41 @@ + + + + + + در حال ارسال درخواست authorization + + + +
+ +

ارسال کدauthorization

+

در حال ارسال کد 권한 به سمت صفحه اصلی...

+
+
+ + + + \ No newline at end of file diff --git a/apps/api/apps/oauth/tests.py b/apps/api/apps/oauth/tests.py new file mode 100644 index 0000000..f9202b9 --- /dev/null +++ b/apps/api/apps/oauth/tests.py @@ -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"]) diff --git a/apps/api/apps/oauth/urls.py b/apps/api/apps/oauth/urls.py new file mode 100644 index 0000000..b19dd48 --- /dev/null +++ b/apps/api/apps/oauth/urls.py @@ -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"), +] diff --git a/apps/api/apps/oauth/views.py b/apps/api/apps/oauth/views.py new file mode 100644 index 0000000..de052a5 --- /dev/null +++ b/apps/api/apps/oauth/views.py @@ -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) diff --git a/apps/api/apps/organization/__init__.py b/apps/api/apps/organization/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/organization/admin.py b/apps/api/apps/organization/admin.py new file mode 100644 index 0000000..2bf57ef --- /dev/null +++ b/apps/api/apps/organization/admin.py @@ -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") \ No newline at end of file diff --git a/apps/api/apps/organization/migrations/0001_initial.py b/apps/api/apps/organization/migrations/0001_initial.py new file mode 100644 index 0000000..4fc7d8a --- /dev/null +++ b/apps/api/apps/organization/migrations/0001_initial.py @@ -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')], + }, + ), + ] diff --git a/apps/api/apps/organization/migrations/0002_organization_activity_field_organization_address_and_more.py b/apps/api/apps/organization/migrations/0002_organization_activity_field_organization_address_and_more.py new file mode 100644 index 0000000..cc380c3 --- /dev/null +++ b/apps/api/apps/organization/migrations/0002_organization_activity_field_organization_address_and_more.py @@ -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), + ), + ] diff --git a/apps/api/apps/organization/migrations/0003_organization_username.py b/apps/api/apps/organization/migrations/0003_organization_username.py new file mode 100644 index 0000000..4c08e6d --- /dev/null +++ b/apps/api/apps/organization/migrations/0003_organization_username.py @@ -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, + ), + ), + ] diff --git a/apps/api/apps/organization/migrations/__init__.py b/apps/api/apps/organization/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/organization/models.py b/apps/api/apps/organization/models.py new file mode 100644 index 0000000..095ed30 --- /dev/null +++ b/apps/api/apps/organization/models.py @@ -0,0 +1,167 @@ +from django.db import models +from django.utils.text import slugify + +from apps.common.models import BaseModel + + +class OrganizationStatus(models.TextChoices): + ACTIVE = "active", "Active" + INACTIVE = "inactive", "Inactive" + SUSPENDED = "suspended", "Suspended" + VERIFIED = "verified", "Verified" + + +class CompanyType(models.TextChoices): + INDIVIDUAL = "individual", "Individual (شخص حقیقی)" + PRIVATE = "private", "Private (شرکت خصوصی)" + STARTUP = "startup", "Startup (استارتاپ)" + GOVERNMENT = "government", "Government (دولتی)" + MULTINATIONAL = "multinational", "Multinational (چندملیتی)" + + +class BusinessNature(models.TextChoices): + B2B = "b2b", "B2B" + B2C = "b2c", "B2C" + B2G = "b2g", "B2G" + + +class LegalStatus(models.TextChoices): + PENDING = "pending", "Pending" + VERIFIED = "verified", "Verified" + REJECTED = "rejected", "Rejected" + + +class Organization(BaseModel): + name = models.CharField(max_length=200, help_text="Business name (نام کسب‌وکار)") + username = models.SlugField( + max_length=64, + unique=True, + blank=True, + null=True, + default=None, + help_text="Business username / handle (نام کاربری کسب‌وکار)", + ) + brand_name = models.CharField( + max_length=200, blank=True, default="", help_text="Brand name (نام تجاری)" + ) + legal_name = models.CharField( + max_length=200, blank=True, default="", help_text="Legal name (نام حقوقی)" + ) + slug = models.SlugField(max_length=100, unique=True) + description = models.TextField(blank=True, default="") + + logo_url = models.URLField(max_length=500, blank=True, default="") + cover_url = models.URLField( + max_length=500, blank=True, default="", help_text="Cover image (تصویر کاور)" + ) + + organization_type = models.CharField( + max_length=32, + choices=CompanyType.choices, + default=CompanyType.PRIVATE, + help_text="Company type (نوع شرکت)", + ) + business_nature = models.CharField( + max_length=8, + choices=BusinessNature.choices, + blank=True, + default="", + help_text="Business nature (ماهیت کسب‌وکار): B2B/B2C/B2G", + ) + industry = models.CharField( + max_length=128, blank=True, default="", help_text="Industry (صنعت)" + ) + activity_field = models.CharField( + max_length=128, blank=True, default="", help_text="Activity field (حوزه فعالیت)" + ) + employee_count = models.PositiveIntegerField( + null=True, blank=True, help_text="Number of employees (تعداد کارکنان)" + ) + founded_year = models.PositiveIntegerField( + null=True, blank=True, help_text="Founded year (سال تأسیس)" + ) + + phone = models.CharField( + max_length=32, blank=True, default="", help_text="Phone (تلفن)" + ) + mobile = models.CharField( + max_length=32, blank=True, default="", help_text="Mobile (موبایل)" + ) + email = models.EmailField(blank=True, default="", help_text="Email (ایمیل)") + website_url = models.URLField(max_length=500, blank=True, default="") + social_links = models.JSONField( + default=list, blank=True, help_text="Social networks (شبکه‌های اجتماعی)" + ) + + country = models.CharField( + max_length=64, blank=True, default="", help_text="Country (کشور)" + ) + province = models.CharField( + max_length=64, blank=True, default="", help_text="Province (استان)" + ) + city = models.CharField( + max_length=64, blank=True, default="", help_text="City (شهر)" + ) + address = models.TextField(blank=True, default="", help_text="Address (آدرس)") + postal_code = models.CharField( + max_length=32, blank=True, default="", help_text="Postal code (کد پستی)" + ) + + national_id = models.CharField( + max_length=32, blank=True, default="", help_text="National ID (شناسه ملی)" + ) + registration_number = models.CharField( + max_length=64, + blank=True, + default="", + help_text="Registration number (شماره ثبت)", + ) + economic_code = models.CharField( + max_length=64, blank=True, default="", help_text="Economic code (کد اقتصادی)" + ) + legal_status = models.CharField( + max_length=16, + choices=LegalStatus.choices, + default=LegalStatus.PENDING, + help_text="Legal status (وضعیت حقوقی)", + ) + + domain = models.CharField( + max_length=128, unique=False, blank=True, null=True, default=None + ) + status = models.CharField( + max_length=16, + choices=OrganizationStatus.choices, + default=OrganizationStatus.ACTIVE, + ) + verification_status = models.CharField( + max_length=16, + choices=[ + ("pending", "Pending"), + ("verified", "Verified"), + ("rejected", "Rejected"), + ], + default="pending", + ) + created_by = models.ForeignKey( + "identity.User", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="created_organizations", + ) + + class Meta: + ordering = ["-created_at"] + indexes = [models.Index(fields=["slug"]), models.Index(fields=["status"])] + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.name) + if not self.username: + base = self.slug or slugify(self.name) + self.username = base + super().save(*args, **kwargs) + + def __str__(self): + return self.name diff --git a/apps/api/apps/organization/serializers.py b/apps/api/apps/organization/serializers.py new file mode 100644 index 0000000..232eb0f --- /dev/null +++ b/apps/api/apps/organization/serializers.py @@ -0,0 +1,150 @@ +from rest_framework import serializers + +from apps.membership.models import Membership, MembershipRole +from apps.organization.models import Organization + + +class OrganizationSerializer(serializers.ModelSerializer): + member_count = serializers.IntegerField(read_only=True) + role = serializers.SerializerMethodField() + joined_at = serializers.SerializerMethodField() + + class Meta: + model = Organization + fields = ( + "id", + "name", + "username", + "brand_name", + "legal_name", + "slug", + "description", + "website_url", + "logo_url", + "cover_url", + "organization_type", + "business_nature", + "industry", + "activity_field", + "employee_count", + "founded_year", + "phone", + "mobile", + "email", + "social_links", + "country", + "province", + "city", + "address", + "postal_code", + "national_id", + "registration_number", + "economic_code", + "legal_status", + "status", + "member_count", + "role", + "joined_at", + "created_at", + "updated_at", + ) + read_only_fields = ("id", "slug", "status", "created_at", "updated_at") + + def get_role(self, obj): + user = self.context.get("request").user + membership = getattr(obj, "current_membership", None) + if membership: + return membership.role + if user.is_staff: + return "admin" + return None + + def get_joined_at(self, obj): + membership = getattr(obj, "current_membership", None) + return membership.joined_at if membership else None + + +class OrganizationCreateSerializer(serializers.ModelSerializer): + class Meta: + model = Organization + fields = ( + "name", + "username", + "brand_name", + "legal_name", + "slug", + "description", + "website_url", + "logo_url", + "cover_url", + "organization_type", + "business_nature", + "industry", + "activity_field", + "employee_count", + "founded_year", + "phone", + "mobile", + "email", + "social_links", + "country", + "province", + "city", + "address", + "postal_code", + "national_id", + "registration_number", + "economic_code", + "legal_status", + ) + extra_kwargs = {"slug": {"required": False}} + + def validate_slug(self, value): + from django.utils.text import slugify + + return value or None + + def create(self, validated_data): + from django.utils.text import slugify + + name = validated_data.get("name") + slug = validated_data.pop("slug", None) or slugify(name) + organization = Organization.objects.create( + **validated_data, + slug=slug, + created_by=self.context["request"].user, + ) + Membership.objects.create( + user=self.context["request"].user, + organization=organization, + role=MembershipRole.OWNER, + ) + return organization + + +class MemberSerializer(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) + + class Meta: + model = Membership + fields = ( + "id", + "user_id", + "user_name", + "user_email", + "role", + "status", + "created_at", + ) + read_only_fields = ("id", "user_id", "user_name", "user_email", "created_at") + + def validate(self, attrs): + org_slug = self.context.get("org_slug") + user = self.context.get("request").user + if self.instance is None and not user.is_staff: + raise serializers.ValidationError( + "Only platform administrators can add members." + ) + return attrs diff --git a/apps/api/apps/organization/tests.py b/apps/api/apps/organization/tests.py new file mode 100644 index 0000000..c5f08ca --- /dev/null +++ b/apps/api/apps/organization/tests.py @@ -0,0 +1,82 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from apps.membership.models import Membership +from apps.organization.models import Organization + +User = get_user_model() + + +def login(client, email, password): + response = client.post( + reverse("auth-login"), + {"email": email, "password": password}, + ) + client.credentials(HTTP_AUTHORIZATION=f"Bearer {response.data['access']}") + return response + + +class OrganizationTests(TestCase): + def setUp(self): + self.user = User.objects.create_user( + email="owner@example.com", + password="S3cure-Pass-123", + status="active", + ) + self.client = APIClient() + login(self.client, "owner@example.com", "S3cure-Pass-123") + + def test_create_organization_creates_owner_membership(self): + response = self.client.post( + "/api/v1/organizations/", + {"name": "Acme Holding", "description": "Test"}, + format="json", + ) + self.assertEqual(response.status_code, 201) + org = Organization.objects.get(slug="acme-holding") + self.assertTrue( + Membership.objects.filter( + user=self.user, organization=org, role="owner" + ).exists() + ) + + def test_list_returns_only_my_organizations(self): + other = User.objects.create_user( + email="other@example.com", + password="S3cure-Pass-123", + status="active", + ) + org = Organization.objects.create(name="Mine", created_by=self.user) + Membership.objects.create( + user=self.user, organization=org, role="owner" + ) + other_org = Organization.objects.create(name="Theirs", created_by=other) + Membership.objects.create( + user=other, organization=other_org, role="owner" + ) + response = self.client.get("/api/v1/organizations/") + self.assertEqual(response.status_code, 200) + slugs = {item["slug"] for item in response.data["results"]} + self.assertIn("mine", slugs) + self.assertNotIn("theirs", slugs) + + def test_member_can_join_and_update_role(self): + org = Organization.objects.create(name="Team", created_by=self.user) + Membership.objects.create( + user=self.user, organization=org, role="owner" + ) + member = User.objects.create_user( + email="member@example.com", + password="S3cure-Pass-123", + status="active", + ) + response = self.client.post( + f"/api/v1/organizations/team/members/", + {"user_id": str(member.id), "role": "admin"}, + format="json", + ) + self.assertEqual(response.status_code, 201) + membership = Membership.objects.get(user=member, organization=org) + self.assertEqual(membership.role, "admin") \ No newline at end of file diff --git a/apps/api/apps/organization/urls.py b/apps/api/apps/organization/urls.py new file mode 100644 index 0000000..453bc46 --- /dev/null +++ b/apps/api/apps/organization/urls.py @@ -0,0 +1,25 @@ +from django.urls import path + +from apps.organization.views import MemberViewSet, OrganizationViewSet + +urlpatterns = [ + path("", OrganizationViewSet.as_view({"get": "list", "post": "create"})), + path( + "/transfer-ownership/", + OrganizationViewSet.as_view({"post": "transfer_ownership"}), + ), + path( + "/", + OrganizationViewSet.as_view( + {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} + ), + ), + path( + "/members/", + MemberViewSet.as_view({"get": "list", "post": "create"}), + ), + path( + "/members//", + MemberViewSet.as_view({"patch": "partial_update", "delete": "destroy"}), + ), +] diff --git a/apps/api/apps/organization/views.py b/apps/api/apps/organization/views.py new file mode 100644 index 0000000..d9823b8 --- /dev/null +++ b/apps/api/apps/organization/views.py @@ -0,0 +1,254 @@ +from django.contrib.auth import get_user_model +from django.db.models import Count +from django.utils import timezone +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import NotFound, PermissionDenied +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from apps.common.models import OutboxEvent +from apps.common.pagination import DefaultPagination +from apps.membership.models import Membership, Ownership +from apps.organization.models import Organization +from apps.organization.serializers import ( + MemberSerializer, + OrganizationCreateSerializer, + OrganizationSerializer, +) +from apps.security.models import SecurityEventType + +User = get_user_model() + + +class OrganizationViewSet(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + pagination_class = DefaultPagination + lookup_field = "slug" + + def get_base_queryset(self): + user = self.request.user + queryset = Organization.objects.annotate(member_count=Count("memberships")) + if user.is_staff: + return queryset.order_by("name") + return ( + queryset.filter(memberships__user=user, memberships__status="active") + .distinct() + .order_by("name") + ) + + def _attach_current_membership(self, orgs, user): + ids = [o.id for o in orgs] + memberships = Membership.objects.filter(user=user, organization_id__in=ids) + by_org = {m.organization_id: m for m in memberships} + for o in orgs: + o.current_membership = by_org.get(o.id) + + def list(self, request): + queryset = self.get_base_queryset() + paginator = DefaultPagination() + page = paginator.paginate_queryset(queryset, request) + self._attach_current_membership(page, request.user) + serializer = OrganizationSerializer( + page, many=True, context={"request": request} + ) + return paginator.get_paginated_response(serializer.data) + + def create(self, request): + serializer = OrganizationCreateSerializer( + data=request.data, context={"request": request} + ) + serializer.is_valid(raise_exception=True) + organization = serializer.save() + return Response( + OrganizationSerializer(organization, context={"request": request}).data, + status=status.HTTP_201_CREATED, + ) + + def retrieve(self, request, slug=None): + try: + organization = self.get_base_queryset().get(slug=slug) + except Organization.DoesNotExist: + raise NotFound("Organization not found.") + self._attach_current_membership([organization], request.user) + return Response( + OrganizationSerializer(organization, context={"request": request}).data + ) + + def _get_manageable(self, slug): + organization = Organization.objects.filter(slug=slug).first() + if not organization: + raise NotFound("Organization not found.") + user = self.request.user + membership = Membership.objects.filter( + user=user, organization=organization, status="active" + ).first() + can_manage = user.is_staff or ( + membership and membership.role in ("owner", "admin") + ) + if not can_manage: + raise PermissionDenied( + "You do not have permission to manage this organization." + ) + return organization + + def partial_update(self, request, slug=None): + organization = self._get_manageable(slug) + serializer = OrganizationCreateSerializer( + organization, data=request.data, partial=True + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response( + OrganizationSerializer(organization, context={"request": request}).data + ) + + def destroy(self, request, slug=None): + organization = self._get_manageable(slug) + organization.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + @action(detail=True, methods=["post"], url_path="transfer-ownership") + def transfer_ownership(self, request, slug=None): + organization = self._get_manageable(slug) + new_owner_id = request.data.get("new_owner_id") or request.data.get("user_id") + reason = request.data.get("reason", "") + if not new_owner_id: + return Response( + {"detail": "new_owner_id is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + new_owner = User.objects.filter(pk=new_owner_id).first() + if not new_owner: + return Response( + {"detail": "New owner not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + current_owner = Membership.objects.filter( + organization=organization, role="owner", status="active" + ).first() + if current_owner and current_owner.user_id == new_owner.id: + return Response( + {"detail": "User is already the owner."}, + status=status.HTTP_400_BAD_REQUEST, + ) + membership, created = Membership.objects.get_or_create( + user=new_owner, + organization=organization, + defaults={"role": "owner", "status": "active"}, + ) + if not created: + membership.role = "owner" + membership.status = "active" + membership.save(update_fields=["role", "status", "updated_at"]) + if current_owner: + current_owner.role = "member" + current_owner.save(update_fields=["role", "updated_at"]) + Ownership.objects.create( + organization=organization, + owner=new_owner, + ownership_type=Ownership.OwnershipType.TRANSFERRED, + transferred_from=current_owner.user, + transferred_at=timezone.now(), + reason=reason, + ) + else: + Ownership.objects.create( + organization=organization, + owner=new_owner, + ownership_type=Ownership.OwnershipType.TRANSFERRED, + transferred_at=timezone.now(), + reason=reason, + ) + OutboxEvent.objects.publish( + OutboxEvent.EventType.MEMBERSHIP, + user=new_owner, + title="Ownership transferred", + message=f"Ownership of {organization.name} was transferred to you.", + metadata={"organization": str(organization.id)}, + security_event_type=SecurityEventType.MEMBERSHIP_CHANGED, + ) + return Response( + OrganizationSerializer(organization, context={"request": request}).data + ) + + +class MemberViewSet(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + lookup_field = "pk" + + def _get_organization(self, slug): + organization = Organization.objects.filter(slug=slug).first() + if not organization: + raise NotFound("Organization not found.") + return organization + + def _require_manage(self, organization): + user = self.request.user + membership = Membership.objects.filter( + user=user, organization=organization, status="active" + ).first() + if not ( + user.is_staff or (membership and membership.role in ("owner", "admin")) + ): + raise PermissionDenied("You do not have permission to manage members.") + + def list(self, request, slug=None): + organization = self._get_organization(slug) + queryset = Membership.objects.filter(organization=organization) + paginator = DefaultPagination() + page = paginator.paginate_queryset(queryset, request) + serializer = MemberSerializer( + page, many=True, context={"request": request, "org_slug": slug} + ) + return paginator.get_paginated_response(serializer.data) + + def create(self, request, slug=None): + organization = self._get_organization(slug) + self._require_manage(organization) + user_id = request.data.get("user_id") + role = request.data.get("role", "member") + from django.contrib.auth import get_user_model + + user = get_user_model().objects.filter(pk=user_id).first() + if not user: + raise NotFound("User not found.") + membership, created = Membership.objects.get_or_create( + user=user, + organization=organization, + defaults={"role": role, "status": "active"}, + ) + if not created: + membership.role = role + membership.status = "active" + membership.save(update_fields=["role", "status", "updated_at"]) + return Response( + MemberSerializer(membership, context={"request": request}).data, + status=status.HTTP_201_CREATED, + ) + + def partial_update(self, request, slug=None, pk=None): + organization = self._get_organization(slug) + self._require_manage(organization) + membership = Membership.objects.filter(pk=pk, organization=organization).first() + if not membership: + raise NotFound("Membership not found.") + role = request.data.get("role") + status_value = request.data.get("status") + if role: + membership.role = role + if status_value: + membership.status = status_value + membership.save(update_fields=["role", "status", "updated_at"]) + return Response(MemberSerializer(membership, context={"request": request}).data) + + def destroy(self, request, slug=None, pk=None): + organization = self._get_organization(slug) + self._require_manage(organization) + membership = Membership.objects.filter(pk=pk, organization=organization).first() + if not membership: + raise NotFound("Membership not found.") + if membership.role == "owner": + raise PermissionDenied("The owner membership cannot be removed.") + membership.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/apps/product/__init__.py b/apps/api/apps/product/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/product/admin.py b/apps/api/apps/product/admin.py new file mode 100644 index 0000000..c935907 --- /dev/null +++ b/apps/api/apps/product/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from apps.product.models import Product + + +@admin.register(Product) +class ProductAdmin(admin.ModelAdmin): + list_display = ("key", "name", "is_active", "created_at") + list_filter = ("is_active",) + search_fields = ("key", "name") + readonly_fields = ("created_at", "updated_at") diff --git a/apps/api/apps/product/apps.py b/apps/api/apps/product/apps.py new file mode 100644 index 0000000..a9f0bbc --- /dev/null +++ b/apps/api/apps/product/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ProductConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.product" + verbose_name = "Products" diff --git a/apps/api/apps/product/migrations/0001_initial.py b/apps/api/apps/product/migrations/0001_initial.py new file mode 100644 index 0000000..86e9562 --- /dev/null +++ b/apps/api/apps/product/migrations/0001_initial.py @@ -0,0 +1,52 @@ +from django.conf import settings +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Product", + 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="")), + ("logo_url", models.URLField(blank=True, default="", max_length=500)), + ( + "website_url", + models.URLField(blank=True, default="", max_length=500), + ), + ("is_active", models.BooleanField(default=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=models.SET_NULL, + related_name="created_products", + to="identity.user", + ), + ), + ], + options={ + "ordering": ["name"], + }, + ), + ] diff --git a/apps/api/apps/product/migrations/0002_alter_product_key_alter_product_name_and_more.py b/apps/api/apps/product/migrations/0002_alter_product_key_alter_product_name_and_more.py new file mode 100644 index 0000000..8d9f628 --- /dev/null +++ b/apps/api/apps/product/migrations/0002_alter_product_key_alter_product_name_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.17 on 2026-08-14 12:30 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='product', + name='key', + field=models.SlugField(help_text="Unique product identifier, e.g. 'bermooda', 'hamsoo'", max_length=64, unique=True), + ), + migrations.AlterField( + model_name='product', + name='name', + field=models.CharField(help_text='Human-readable product name', max_length=200), + ), + migrations.AddIndex( + model_name='product', + index=models.Index(fields=['key'], name='product_pro_key_cb4b1b_idx'), + ), + migrations.AddIndex( + model_name='product', + index=models.Index(fields=['is_active'], name='product_pro_is_acti_9d034c_idx'), + ), + ] diff --git a/apps/api/apps/product/migrations/__init__.py b/apps/api/apps/product/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/api/apps/product/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/api/apps/product/models.py b/apps/api/apps/product/models.py new file mode 100644 index 0000000..45401bc --- /dev/null +++ b/apps/api/apps/product/models.py @@ -0,0 +1,29 @@ +from django.db import models + +from apps.common.models import BaseModel + + +class Product(BaseModel): + key = models.SlugField(max_length=64, unique=True, help_text="Unique product identifier, e.g. 'bermooda', 'hamsoo'") + name = models.CharField(max_length=200, help_text="Human-readable product name") + description = models.TextField(blank=True, default="") + logo_url = models.URLField(max_length=500, blank=True, default="") + website_url = models.URLField(max_length=500, blank=True, default="") + is_active = models.BooleanField(default=True) + created_by = models.ForeignKey( + "identity.User", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="created_products", + ) + + class Meta: + ordering = ["name"] + indexes = [ + models.Index(fields=["key"]), + models.Index(fields=["is_active"]), + ] + + def __str__(self): + return self.name diff --git a/apps/api/apps/product/serializers.py b/apps/api/apps/product/serializers.py new file mode 100644 index 0000000..ba50588 --- /dev/null +++ b/apps/api/apps/product/serializers.py @@ -0,0 +1,10 @@ +from rest_framework import serializers + +from apps.product.models import Product + + +class ProductSerializer(serializers.ModelSerializer): + class Meta: + model = Product + fields = ["id", "key", "name", "description", "logo_url", "website_url", "is_active", "created_at"] + read_only_fields = ["id", "created_at"] diff --git a/apps/api/apps/product/tests.py b/apps/api/apps/product/tests.py new file mode 100644 index 0000000..46e5793 --- /dev/null +++ b/apps/api/apps/product/tests.py @@ -0,0 +1,80 @@ +from django.contrib.auth import get_user_model +from django.core.cache import cache +from django.test import TestCase +from rest_framework.test import APIClient + +from apps.product.models import Product + +User = get_user_model() + + +class ProductModelTests(TestCase): + def setUp(self): + cache.clear() + self.user = User.objects.create_user( + email="prod@example.com", + password="S3cure-Pass-123", + status="active", + ) + self.client = APIClient() + login = self.client.post( + "/api/v1/auth/login/", + {"email": "prod@example.com", "password": "S3cure-Pass-123"}, + ) + self.client.credentials( + HTTP_AUTHORIZATION=f"Bearer {login.data['access']}" + ) + + def test_create_product(self): + product = Product.objects.create( + key="bermooda", + name="Bermooda", + description="Job platform", + is_active=True, + ) + self.assertEqual(product.key, "bermooda") + self.assertEqual(product.name, "Bermooda") + self.assertTrue(product.is_active) + self.assertEqual(product.description, "Job platform") + + def test_product_key_is_unique(self): + Product.objects.create(key="hamsoo", name="Hamsoo") + with self.assertRaises(Exception): + Product.objects.create(key="hamsoo", name="Duplicate") + + def test_product_str_returns_name(self): + product = Product.objects.create(key="test-prod", name="Test Product") + self.assertEqual(str(product), "Test Product") + + def test_default_is_active(self): + product = Product.objects.create(key="auto-active", name="Auto Active") + self.assertTrue(product.is_active) + + def test_is_inactive_excluded_from_active_queryset(self): + Product.objects.create(key="active-prod", name="Active", is_active=True) + Product.objects.create(key="inactive-prod", name="Inactive", is_active=False) + active = Product.objects.filter(is_active=True) + self.assertEqual(active.count(), 1) + self.assertEqual(active.first().key, "active-prod") + + def test_optional_fields_default_empty(self): + product = Product.objects.create(key="minimal", name="Minimal") + self.assertEqual(product.description, "") + self.assertEqual(product.logo_url, "") + self.assertEqual(product.website_url, "") + + def test_keys_action_endpoint(self): + Product.objects.create(key="bermooda", name="Bermooda", is_active=True) + Product.objects.create(key="hamsoo", name="Hamsoo", is_active=True) + Product.objects.create(key="archived", name="Archived", is_active=False) + + response = self.client.get("/api/v1/products/keys/") + self.assertEqual(response.status_code, 200) + keys = response.data["products"] + self.assertIn("bermooda", keys) + self.assertIn("hamsoo", keys) + self.assertNotIn("archived", keys) + + def test_v1_namespace_supports_product_endpoints(self): + response = self.client.get("/v1/products/") + self.assertEqual(response.status_code, 200) diff --git a/apps/api/apps/product/urls.py b/apps/api/apps/product/urls.py new file mode 100644 index 0000000..bb5cb2a --- /dev/null +++ b/apps/api/apps/product/urls.py @@ -0,0 +1,8 @@ +from rest_framework.routers import DefaultRouter + +from apps.product.views import ProductViewSet + +router = DefaultRouter() +router.register(r"", ProductViewSet, basename="product") + +urlpatterns = router.urls diff --git a/apps/api/apps/product/views.py b/apps/api/apps/product/views.py new file mode 100644 index 0000000..b86a1c5 --- /dev/null +++ b/apps/api/apps/product/views.py @@ -0,0 +1,19 @@ +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +from apps.product.models import Product +from apps.product.serializers import ProductSerializer + + +class ProductViewSet(viewsets.ModelViewSet): + queryset = Product.objects.all() + serializer_class = ProductSerializer + lookup_field = "key" + lookup_value_regex = "[a-z0-9-]+" + + @action(detail=False, methods=["get"], permission_classes=[AllowAny]) + def keys(self, request): + keys = list(Product.objects.filter(is_active=True).values_list("key", flat=True)) + return Response({"products": keys}) diff --git a/apps/api/apps/profile/__init__.py b/apps/api/apps/profile/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/profile/admin.py b/apps/api/apps/profile/admin.py new file mode 100644 index 0000000..d6675c3 --- /dev/null +++ b/apps/api/apps/profile/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from apps.profile.models import Profile + + +@admin.register(Profile) +class ProfileAdmin(admin.ModelAdmin): + list_display = ("user", "profile_type", "is_primary", "is_verified", "created_at") + list_filter = ("profile_type", "is_verified", "is_primary") + search_fields = ("user__email", "user__full_name", "company_name", "headline") diff --git a/apps/api/apps/profile/apps.py b/apps/api/apps/profile/apps.py new file mode 100644 index 0000000..9f477b3 --- /dev/null +++ b/apps/api/apps/profile/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ProfileConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.profile" diff --git a/apps/api/apps/profile/migrations/0001_initial.py b/apps/api/apps/profile/migrations/0001_initial.py new file mode 100644 index 0000000..84ddb0f --- /dev/null +++ b/apps/api/apps/profile/migrations/0001_initial.py @@ -0,0 +1,45 @@ +# Generated by Django 5.2.17 on 2026-08-19 08:15 + +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='Profile', + 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)), + ('profile_type', models.CharField(choices=[('expert', 'Expert'), ('business', 'Business')], default='expert', max_length=16)), + ('is_primary', models.BooleanField(default=False)), + ('is_verified', models.BooleanField(default=False)), + ('verified_at', models.DateTimeField(blank=True, null=True)), + ('headline', models.CharField(blank=True, default='', max_length=200)), + ('bio', models.TextField(blank=True, default='')), + ('location', models.CharField(blank=True, default='', max_length=128)), + ('skills', models.JSONField(blank=True, default=list, help_text='Skills for an expert profile (مهارت)')), + ('experience_years', models.PositiveIntegerField(blank=True, null=True)), + ('company_name', models.CharField(blank=True, default='', max_length=200)), + ('registration_number', models.CharField(blank=True, default='', max_length=64)), + ('website_url', models.URLField(blank=True, default='', max_length=500)), + ('data', models.JSONField(blank=True, default=dict)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='profiles', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['user', 'profile_type'], name='profile_pro_user_id_91b7ff_idx'), models.Index(fields=['profile_type'], name='profile_pro_profile_7b82bc_idx')], + 'constraints': [models.UniqueConstraint(fields=('user', 'profile_type'), name='unique_profile_per_user_type')], + }, + ), + ] diff --git a/apps/api/apps/profile/migrations/0002_profile_organization.py b/apps/api/apps/profile/migrations/0002_profile_organization.py new file mode 100644 index 0000000..08bc73c --- /dev/null +++ b/apps/api/apps/profile/migrations/0002_profile_organization.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.17 on 2026-08-19 08:34 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organization', '0002_organization_activity_field_organization_address_and_more'), + ('profile', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='organization', + field=models.ForeignKey(blank=True, help_text='Linked Business (کسب\u200cوکار) for a business profile', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='profiles', to='organization.organization'), + ), + ] diff --git a/apps/api/apps/profile/migrations/__init__.py b/apps/api/apps/profile/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/profile/models.py b/apps/api/apps/profile/models.py new file mode 100644 index 0000000..77f8e59 --- /dev/null +++ b/apps/api/apps/profile/models.py @@ -0,0 +1,74 @@ +from django.db import models +from django.utils import timezone + +from apps.common.models import BaseModel + + +class ProfileType(models.TextChoices): + EXPERT = "expert", "Expert" + BUSINESS = "business", "Business" + + +class Profile(BaseModel): + """User profile. A user may have an expert profile, a business profile, or both.""" + + user = models.ForeignKey( + "identity.User", + on_delete=models.CASCADE, + related_name="profiles", + ) + profile_type = models.CharField( + max_length=16, + choices=ProfileType.choices, + default=ProfileType.EXPERT, + ) + is_primary = models.BooleanField(default=False) + is_verified = models.BooleanField(default=False) + verified_at = models.DateTimeField(null=True, blank=True) + + headline = models.CharField(max_length=200, blank=True, default="") + bio = models.TextField(blank=True, default="") + location = models.CharField(max_length=128, blank=True, default="") + + skills = models.JSONField( + default=list, + blank=True, + help_text="Skills for an expert profile (مهارت)", + ) + experience_years = models.PositiveIntegerField(null=True, blank=True) + + company_name = models.CharField(max_length=200, blank=True, default="") + registration_number = models.CharField(max_length=64, blank=True, default="") + website_url = models.URLField(max_length=500, blank=True, default="") + + organization = models.ForeignKey( + "organization.Organization", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="profiles", + help_text="Linked Business (کسب‌وکار) for a business profile", + ) + + data = models.JSONField(default=dict, blank=True) + + class Meta: + ordering = ["-created_at"] + constraints = [ + models.UniqueConstraint( + fields=["user", "profile_type"], + name="unique_profile_per_user_type", + ) + ] + indexes = [ + models.Index(fields=["user", "profile_type"]), + models.Index(fields=["profile_type"]), + ] + + def __str__(self): + return f"{self.user} — {self.get_profile_type_display()}" + + def verify(self): + self.is_verified = True + self.verified_at = timezone.now() + self.save(update_fields=["is_verified", "verified_at", "updated_at"]) diff --git a/apps/api/apps/profile/serializers.py b/apps/api/apps/profile/serializers.py new file mode 100644 index 0000000..fb16656 --- /dev/null +++ b/apps/api/apps/profile/serializers.py @@ -0,0 +1,49 @@ +from rest_framework import serializers + +from apps.profile.models import Profile + + +class ProfileSerializer(serializers.ModelSerializer): + class Meta: + model = Profile + fields = ( + "id", + "user", + "profile_type", + "is_primary", + "is_verified", + "verified_at", + "headline", + "bio", + "location", + "skills", + "experience_years", + "company_name", + "registration_number", + "website_url", + "organization", + "data", + "created_at", + "updated_at", + ) + read_only_fields = ( + "id", + "is_verified", + "verified_at", + "created_at", + "updated_at", + ) + + def validate(self, attrs): + if self.instance is None: + user = attrs.get("user") + profile_type = attrs.get("profile_type") + else: + user = self.instance.user + profile_type = attrs.get("profile_type", self.instance.profile_type) + if Profile.objects.filter(user=user, profile_type=profile_type).exists(): + if self.instance is None or self.instance.profile_type != profile_type: + raise serializers.ValidationError( + "A profile of this type already exists for this user." + ) + return attrs diff --git a/apps/api/apps/profile/tests.py b/apps/api/apps/profile/tests.py new file mode 100644 index 0000000..00cfabe --- /dev/null +++ b/apps/api/apps/profile/tests.py @@ -0,0 +1,159 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase + +from apps.profile.models import Profile, ProfileType +from apps.organization.models import Organization, CompanyType, BusinessNature +from apps.membership.models import Membership +from apps.access.models import Role, Permission, ProductAccess, AccessStatus +from apps.product.models import Product + +User = get_user_model() + + +class IdentityLocationFieldsTests(TestCase): + def test_user_location_fields_default_empty(self): + user = User.objects.create_user( + email="loc@example.com", password="S3cure-Pass-123", status="active" + ) + self.assertEqual(user.province, "") + self.assertEqual(user.country, "") + self.assertEqual(user.city, "") + self.assertEqual(user.skills, []) + + def test_user_location_fields_set(self): + user = User.objects.create_user( + email="loc2@example.com", + password="S3cure-Pass-123", + status="active", + province="Tehran", + country="Iran", + city="Tehran", + skills=["python", "go"], + ) + self.assertEqual(user.province, "Tehran") + self.assertEqual(user.country, "Iran") + self.assertEqual(user.city, "Tehran") + self.assertEqual(user.skills, ["python", "go"]) + + +class ProfileBusinessLinkTests(TestCase): + def setUp(self): + self.user = User.objects.create_user( + email="p@example.com", password="S3cure-Pass-123", status="active" + ) + self.org = Organization.objects.create(name="Acme") + + def test_expert_profile(self): + profile = Profile.objects.create( + user=self.user, + profile_type=ProfileType.EXPERT, + headline="Senior Engineer", + bio="Short intro", + ) + self.assertEqual(profile.get_profile_type_display(), "Expert") + self.assertFalse(profile.is_verified) + + def test_business_profile_links_to_organization(self): + profile = Profile.objects.create( + user=self.user, + profile_type=ProfileType.BUSINESS, + company_name="Acme Co", + organization=self.org, + ) + self.assertEqual(profile.organization, self.org) + + def test_profile_type_unique_per_user(self): + Profile.objects.create(user=self.user, profile_type=ProfileType.EXPERT) + with self.assertRaises(Exception): + Profile.objects.create(user=self.user, profile_type=ProfileType.EXPERT) + + +class OrganizationExpansionTests(TestCase): + def test_full_organization_fields(self): + org = Organization.objects.create( + name="Bermooda Inc", + brand_name="Bermooda", + legal_name="Bermooda Legal", + organization_type=CompanyType.PRIVATE, + business_nature=BusinessNature.B2B, + industry="Software", + activity_field="HR", + employee_count=50, + founded_year=2020, + phone="+98211234", + mobile="+989121234567", + email="info@bermooda.com", + social_links=[{"network": "linkedin", "url": "x"}], + country="Iran", + province="Tehran", + city="Tehran", + address="St 1", + postal_code="12345", + national_id="12345678901", + registration_number="7654321", + economic_code="99887", + legal_status="verified", + status="verified", + ) + self.assertEqual(org.brand_name, "Bermooda") + self.assertEqual(org.business_nature, "b2b") + self.assertEqual(org.employee_count, 50) + self.assertEqual(org.legal_status, "verified") + self.assertEqual(org.status, "verified") + + +class MembershipEndedAtTests(TestCase): + def setUp(self): + self.user = User.objects.create_user( + email="m@example.com", password="S3cure-Pass-123", status="active" + ) + self.org = Organization.objects.create(name="OrgX") + + def test_membership_ended_at_nullable(self): + membership = Membership.objects.create(user=self.user, organization=self.org) + self.assertIsNone(membership.ended_at) + self.assertIsNotNone(membership.joined_at) + + +class ProductAccessTests(TestCase): + def setUp(self): + self.user = User.objects.create_user( + email="a@example.com", password="S3cure-Pass-123", status="active" + ) + self.org = Organization.objects.create(name="OrgA") + self.product = Product.objects.create(key="hamsoo", name="Hamsoo") + self.role = Role.objects.create(key="manager", name="Manager") + self.perm = Permission.objects.create( + key="hamsoo:order:create", name="Create Order", category="orders" + ) + self.role.permissions.add(self.perm) + + def test_product_access_lifecycle(self): + access = ProductAccess.objects.create( + user=self.user, + organization=self.org, + product=self.product, + role=self.role, + status=AccessStatus.PENDING, + ) + self.assertFalse(access.is_active) + access.activate() + self.assertTrue(access.is_active) + self.assertEqual(access.role.permissions.count(), 1) + access.revoke(reason="policy") + self.assertEqual(access.status, AccessStatus.REVOKED) + + def test_unique_product_access(self): + ProductAccess.objects.create( + user=self.user, + organization=self.org, + product=self.product, + status=AccessStatus.ACTIVE, + ) + with self.assertRaises(Exception): + ProductAccess.objects.create( + user=self.user, + organization=self.org, + product=self.product, + status=AccessStatus.ACTIVE, + ) diff --git a/apps/api/apps/profile/urls.py b/apps/api/apps/profile/urls.py new file mode 100644 index 0000000..c63b855 --- /dev/null +++ b/apps/api/apps/profile/urls.py @@ -0,0 +1,8 @@ +from rest_framework.routers import DefaultRouter + +from apps.profile.views import ProfileViewSet + +router = DefaultRouter() +router.register(r"", ProfileViewSet, basename="profile") + +urlpatterns = router.urls diff --git a/apps/api/apps/profile/views.py b/apps/api/apps/profile/views.py new file mode 100644 index 0000000..dbf968a --- /dev/null +++ b/apps/api/apps/profile/views.py @@ -0,0 +1,32 @@ +from rest_framework import viewsets, permissions +from rest_framework.decorators import action +from rest_framework.response import Response + +from apps.profile.models import Profile +from apps.profile.serializers import ProfileSerializer +from apps.common.models import OutboxEvent + + +class ProfileViewSet(viewsets.ModelViewSet): + queryset = Profile.objects.all() + serializer_class = ProfileSerializer + 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 verify(self, request, pk=None): + profile = self.get_object() + profile.verify() + OutboxEvent.objects.publish( + OutboxEvent.EventType.VERIFICATION, + user=request.user, + title="Profile verified", + message=f"{profile.get_profile_type_display()} profile verified.", + metadata={"profile_id": str(profile.id)}, + ) + return Response(self.get_serializer(profile).data) diff --git a/apps/api/apps/security/__init__.py b/apps/api/apps/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/security/admin.py b/apps/api/apps/security/admin.py new file mode 100644 index 0000000..b46fff1 --- /dev/null +++ b/apps/api/apps/security/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from apps.security.models import SecurityEvent + + +@admin.register(SecurityEvent) +class SecurityEventAdmin(admin.ModelAdmin): + list_display = ("event_type", "user", "application", "severity", "status", "ip_address", "occurred_at") + list_filter = ("event_type", "severity", "status") + search_fields = ("user__email", "ip_address") + readonly_fields = ("id", "created_at", "updated_at", "occurred_at") \ No newline at end of file diff --git a/apps/api/apps/security/migrations/0001_initial.py b/apps/api/apps/security/migrations/0001_initial.py new file mode 100644 index 0000000..5086739 --- /dev/null +++ b/apps/api/apps/security/migrations/0001_initial.py @@ -0,0 +1,40 @@ +# 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'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='SecurityEvent', + 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=[('login_success', 'Login Success'), ('login_failed', 'Login Failed'), ('login_locked', 'Login Locked'), ('logout', 'Logout'), ('password_change', 'Password Changed'), ('password_reset', 'Password Reset'), ('email_verified', 'Email Verified'), ('phone_verified', 'Phone Verified'), ('mfa_enabled', 'MFA Enabled'), ('mfa_disabled', 'MFA Disabled'), ('session_revoked', 'Session Revoked'), ('session_expired', 'Session Expired'), ('session_created', 'Session Created'), ('device_added', 'Device Added'), ('application_created', 'Application Created'), ('application_revoked', 'Application Revoked')], max_length=50)), + ('severity', models.CharField(choices=[('info', 'Info'), ('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], default='info', max_length=16)), + ('status', models.CharField(choices=[('open', 'Open'), ('resolved', 'Resolved'), ('ignored', 'Ignored')], default='open', max_length=16)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.CharField(blank=True, default='', max_length=500)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('occurred_at', models.DateTimeField(auto_now_add=True)), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='security_events', to='application.application')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='security_events', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-occurred_at'], + 'indexes': [models.Index(fields=['user', 'occurred_at'], name='security_se_user_id_25ee6b_idx'), models.Index(fields=['event_type'], name='security_se_event_t_e8f9c2_idx'), models.Index(fields=['status'], name='security_se_status_dedaae_idx')], + }, + ), + ] diff --git a/apps/api/apps/security/migrations/0002_alter_securityevent_event_type.py b/apps/api/apps/security/migrations/0002_alter_securityevent_event_type.py new file mode 100644 index 0000000..2a77f3f --- /dev/null +++ b/apps/api/apps/security/migrations/0002_alter_securityevent_event_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.17 on 2026-08-22 14:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('security', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='securityevent', + name='event_type', + field=models.CharField(choices=[('login_success', 'Login Success'), ('login_failed', 'Login Failed'), ('login_locked', 'Login Locked'), ('logout', 'Logout'), ('password_change', 'Password Changed'), ('password_reset', 'Password Reset'), ('email_verified', 'Email Verified'), ('phone_verified', 'Phone Verified'), ('mfa_enabled', 'MFA Enabled'), ('mfa_disabled', 'MFA Disabled'), ('session_revoked', 'Session Revoked'), ('session_expired', 'Session Expired'), ('session_created', 'Session Created'), ('device_added', 'Device Added'), ('application_created', 'Application Created'), ('application_revoked', 'Application Revoked'), ('passkey_registered', 'Passkey Registered'), ('passkey_authenticated', 'Passkey Authenticated'), ('passkey_auth_failed', 'Passkey Auth Failed'), ('membership_changed', 'Membership Changed'), ('invitation_created', 'Invitation Created'), ('invitation_accepted', 'Invitation Accepted'), ('credential_added', 'Credential Added')], max_length=50), + ), + ] diff --git a/apps/api/apps/security/migrations/0003_powchallenge.py b/apps/api/apps/security/migrations/0003_powchallenge.py new file mode 100644 index 0000000..fbe3ffe --- /dev/null +++ b/apps/api/apps/security/migrations/0003_powchallenge.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.17 on 2026-08-22 21:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('security', '0002_alter_securityevent_event_type'), + ] + + operations = [ + migrations.CreateModel( + name='PowChallenge', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('challenge', models.CharField(max_length=128, unique=True)), + ('salt', models.CharField(max_length=128)), + ('solution', models.CharField(max_length=64)), + ('expires_at', models.DateTimeField()), + ('signed_by', models.CharField(default='', max_length=128)), + ], + options={ + 'indexes': [models.Index(fields=['expires_at'], name='pow_challenge_expires_idx')], + }, + ), + ] diff --git a/apps/api/apps/security/migrations/__init__.py b/apps/api/apps/security/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/security/models.py b/apps/api/apps/security/models.py new file mode 100644 index 0000000..3c2e6a8 --- /dev/null +++ b/apps/api/apps/security/models.py @@ -0,0 +1,135 @@ +import uuid + +from django.conf import settings +from django.utils import timezone + +from django.db import models + +from apps.common.models import BaseModel + + +class SecurityEventType(models.TextChoices): + LOGIN_SUCCESS = "login_success", "Login Success" + LOGIN_FAILED = "login_failed", "Login Failed" + LOGIN_LOCKED = "login_locked", "Login Locked" + LOGOUT = "logout", "Logout" + PASSWORD_CHANGE = "password_change", "Password Changed" + PASSWORD_RESET = "password_reset", "Password Reset" + EMAIL_VERIFIED = "email_verified", "Email Verified" + PHONE_VERIFIED = "phone_verified", "Phone Verified" + MFA_ENABLED = "mfa_enabled", "MFA Enabled" + MFA_DISABLED = "mfa_disabled", "MFA Disabled" + SESSION_REVOKED = "session_revoked", "Session Revoked" + SESSION_EXPIRED = "session_expired", "Session Expired" + SESSION_CREATED = "session_created", "Session Created" + DEVICE_ADDED = "device_added", "Device Added" + APPLICATION_CREATED = "application_created", "Application Created" + APPLICATION_REVOKED = "application_revoked", "Application Revoked" + PASSKEY_REGISTERED = "passkey_registered", "Passkey Registered" + PASSKEY_AUTHENTICATED = "passkey_authenticated", "Passkey Authenticated" + PASSKEY_AUTH_FAILED = "passkey_auth_failed", "Passkey Auth Failed" + MEMBERSHIP_CHANGED = "membership_changed", "Membership Changed" + INVITATION_CREATED = "invitation_created", "Invitation Created" + INVITATION_ACCEPTED = "invitation_accepted", "Invitation Accepted" + CREDENTIAL_ADDED = "credential_added", "Credential Added" + + +class Severity(models.TextChoices): + INFO = "info", "Info" + LOW = "low", "Low" + MEDIUM = "medium", "Medium" + HIGH = "high", "High" + CRITICAL = "critical", "Critical" + + +class SecurityEventStatus(models.TextChoices): + OPEN = "open", "Open" + RESOLVED = "resolved", "Resolved" + IGNORED = "ignored", "Ignored" + + +class SecurityEvent(BaseModel): + user = models.ForeignKey( + "identity.User", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="security_events", + ) + application = models.ForeignKey( + "application.Application", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="security_events", + ) + event_type = models.CharField(max_length=50, choices=SecurityEventType.choices) + severity = models.CharField( + max_length=16, + choices=Severity.choices, + default=Severity.INFO, + ) + status = models.CharField( + max_length=16, + choices=SecurityEventStatus.choices, + default=SecurityEventStatus.OPEN, + ) + ip_address = models.GenericIPAddressField(null=True, blank=True) + user_agent = models.CharField(max_length=500, blank=True, default="") + metadata = models.JSONField(default=dict, blank=True) + occurred_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["-occurred_at"] + indexes = [ + models.Index(fields=["user", "occurred_at"]), + models.Index(fields=["event_type"]), + models.Index(fields=["status"]), + ] + + def __str__(self): + return f"{self.event_type} — {self.user or 'anonymous'}" + + +def record_security_event( + event_type, + *, + user=None, + application=None, + severity=Severity.INFO, + ip_address=None, + user_agent="", + metadata=None, +): + return SecurityEvent.objects.create( + user=user, + application=application, + event_type=event_type, + severity=severity, + ip_address=ip_address, + user_agent=(user_agent or "")[:500], + metadata=metadata or {}, + ) + +class PowChallenge(models.Model): + """Replay-protection PoW challenge: one-use only, TTL-gated.""" + challenge = models.CharField(max_length=128, unique=True) + salt = models.CharField(max_length=128) + solution = models.CharField(max_length=64) + expires_at = models.DateTimeField() + signed_by = models.CharField(max_length=128, default="") + + class Meta: + indexes = [ + models.Index(fields=["expires_at"], name="pow_challenge_expires_idx"), + ] + + def is_expired(self): + from django.utils import timezone + return timezone.now() > self.expires_at + + +def purge_expired_pow_challenges(): + """Management-command helper: delete all expired challenges.""" + from django.utils import timezone + PowChallenge.objects.filter(expires_at__lt=timezone.now()).delete() diff --git a/apps/api/apps/security/pow.py b/apps/api/apps/security/pow.py new file mode 100644 index 0000000..174cce3 --- /dev/null +++ b/apps/api/apps/security/pow.py @@ -0,0 +1,116 @@ +import hashlib +import hmac +import secrets +from django.conf import settings +from django.core.exceptions import ValidationError +from django.utils import timezone + + +POW_DIFFICULTY = getattr(settings, "POW_DIFFICULTY", 18) # leading-zero bits in hex +POW_TIMEOUT_SECONDS = getattr(settings, "POW_TIMEOUT_SECONDS", 300) + + +def compute_pow_solution(challenge: str, salt: str, difficulty: int) -> str: + """Find a solution such that SHA-256(challenge + salt + solution) has + `difficulty` leading zero bits in the hex digest. + + Returns the hex string used as the solution (shortest possible). + """ + prefix = f"{challenge}{salt}" + # Estimate: each attempt has 1/2^difficulty chance; we bound attempts. + max_attempts = 2**32 + for i in range(max_attempts): + solution = str(i) + digest = hashlib.sha256((prefix + solution).encode()).hexdigest() + # Count leading zero *bits*: convert hex to int and check top `difficulty` bits + int_val = int(digest, 16) + bit_length = digest_bit_length(digest) + # Equivalent: the hex must start with at least difficulty/4 '0' chars, + # but we compute exact bit-leading-zeros for safety. + leading_zeros = 0 + for ch in digest: + if ch == "0": + leading_zeros += 8 + else: + # first non-zero nibble contributes its bit count + leading_zeros += ch.bit_length() if False else 0 # placeholder + break + # Simpler: just check that int_val has at least `difficulty` leading zero bits + mask = 1 << (256 - difficulty) + if int_val & mask == 0: + return solution + raise ValidationError("PoW solution not found within attempt limit") + + +def digest_bit_length(hex_str: str) -> int: + """Return the number of significant bits in a hex string.""" + d = int(hex_str, 16) + if d == 0: + return 1 + return d.bit_length() + + +def verify_pow_solution( + challenge: str, salt: str, solution: str, difficulty: int +) -> bool: + """Recompute and verify the PoW solution against the expected difficulty.""" + try: + computed = compute_pow_solution(challenge, salt, difficulty) + return computed == solution + except (ValidationError, ValueError): + return False + + +def generate_pow_challenge( + challenge: str = None, + salt: str = None, + expires_at=None, + difficulty: int = None, +): + """Create a new PoW challenge and return (challenge, salt, solution, expires_at).""" + if challenge is None: + challenge = secrets.token_urlsafe(16) + if salt is None: + salt = secrets.token_urlsafe(16) + if difficulty is None: + difficulty = POW_DIFFICULTY + + solution = compute_pow_solution(challenge, salt, difficulty) + if expires_at is None: + expires_at = timezone.now() + timezone.timedelta(seconds=POW_TIMEOUT_SECONDS) + + # Sign the challenge+solution with the server's RSA private key (PEM) so the + # client cannot forge a valid challenge without the private key, but the server + # can verify the signature on receipt. + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import padding + from cryptography.hazmat.primitives import hashes + + private_key_pem = getattr(settings, "JWT_PRIVATE_KEY_PATH", None) + signature = None + if private_key_pem and private_key_pem.exists(): + try: + private_key_pem = private_key_pem.read_text() + private_key = serialization.load_pem_private_key( + private_key_pem.encode(), password=None + ) + signed = private_key.sign( + (challenge + salt + solution).encode(), + padding.PKCS1v15(), + hashes.SHA256(), + ) + signature = signed.hex() + except Exception: + signature = None + + return { + "challenge": challenge, + "salt": salt, + "solution": solution, + "difficulty": difficulty, + "expires_at": expires_at, + "signature": signature, + } + + +POW_CHALLENGE_TTL_SECONDS = 300 diff --git a/apps/api/apps/security/serializers.py b/apps/api/apps/security/serializers.py new file mode 100644 index 0000000..b8d7602 --- /dev/null +++ b/apps/api/apps/security/serializers.py @@ -0,0 +1,32 @@ +from rest_framework import serializers + +from apps.security.models import SecurityEvent + + +class SecurityEventSerializer(serializers.ModelSerializer): + user_email = serializers.CharField(source="user.email", read_only=True) + + class Meta: + model = SecurityEvent + fields = ( + "id", + "event_type", + "severity", + "status", + "ip_address", + "user_agent", + "user_email", + "metadata", + "occurred_at", + "created_at", + ) + read_only_fields = ( + "id", + "event_type", + "severity", + "ip_address", + "user_agent", + "metadata", + "occurred_at", + "created_at", + ) diff --git a/apps/api/apps/security/urls.py b/apps/api/apps/security/urls.py new file mode 100644 index 0000000..a582741 --- /dev/null +++ b/apps/api/apps/security/urls.py @@ -0,0 +1,13 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from apps.security.views import SecurityEventViewSet, PowChallengeView + +router = DefaultRouter() +router.register(r"events", SecurityEventViewSet, basename="security-events") + +urlpatterns = [ + path("", include(router.urls)), + path("pow/challenge/", PowChallengeView.as_view()), + path("pow/verify/", PowChallengeView.as_view()), +] diff --git a/apps/api/apps/security/views.py b/apps/api/apps/security/views.py new file mode 100644 index 0000000..bfc5b1e --- /dev/null +++ b/apps/api/apps/security/views.py @@ -0,0 +1,158 @@ +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import NotFound +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.common.pagination import DefaultPagination +from apps.security.models import SecurityEvent, PowChallenge +from apps.security.serializers import SecurityEventSerializer +from apps.security.pow import ( + generate_pow_challenge, + verify_pow_solution, + POW_DIFFICULTY, +) +from apps.common.utils import generate_client_secret, hash_token + + +class SecurityEventViewSet(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + pagination_class = DefaultPagination + filterset_fields = ("event_type", "severity", "status") + + def get_queryset(self): + user = self.request.user + queryset = ( + SecurityEvent.objects.all() + if user.is_staff + else SecurityEvent.objects.filter(user=user) + ) + params = self.request.query_params + if params.get("event_type"): + queryset = queryset.filter(event_type=params["event_type"]) + if params.get("severity"): + queryset = queryset.filter(severity=params["severity"]) + if params.get("status"): + queryset = queryset.filter(status=params["status"]) + return queryset + + def list(self, request): + queryset = self.get_queryset() + paginator = DefaultPagination() + page = paginator.paginate_queryset(queryset, request) + serializer = SecurityEventSerializer(page, many=True) + return paginator.get_paginated_response(serializer.data) + + def retrieve(self, request, pk=None): + event = self.get_queryset().filter(pk=pk).first() + if not event: + raise NotFound("Security event not found.") + return Response(SecurityEventSerializer(event).data) + + @action(detail=True, methods=["post"]) + def resolve(self, request, pk=None): + event = self.get_queryset().filter(pk=pk).first() + if not event: + raise NotFound("Security event not found.") + event.status = "resolved" + event.save(update_fields=["status", "updated_at"]) + return Response(SecurityEventSerializer(event).data) + + @action(detail=True, methods=["post"]) + def ignore(self, request, pk=None): + event = self.get_queryset().filter(pk=pk).first() + if not event: + raise NotFound("Security event not found.") + event.status = "ignored" + event.save(update_fields=["status", "updated_at"]) + return Response(SecurityEventSerializer(event).data) + + +class PowChallengeView(APIView): + """Issue a new PoW challenge or verify a submitted solution.""" + + permission_classes = [] # AllowAny - public endpoint + + def get(self, request): + """Issue a fresh PoW challenge.""" + from apps.security.pow import generate_pow_challenge + + data = generate_pow_challenge() + # Store the challenge server-authoritatively; the /verify endpoint + # will check reuse/replay and mark it consumed. + PowChallenge.objects.create( + challenge=data["challenge"], + salt=data["salt"], + solution=data["solution"], + expires_at=data["expires_at"], + signed_by="pow_challenge_view", + ) + return Response(data) + + def post(self, request): + """Verify a submitted PoW solution.""" + from apps.security.pow import verify_pow_solution + + challenge = request.data.get("challenge", "") + salt = request.data.get("salt", "") + solution = request.data.get("solution", "") + signature = request.data.get("signature", "") + + if not challenge or not salt or not solution: + return Response( + { + "error": "missing_fields", + "detail": "challenge, salt, solution required", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Replay protection: challenge already consumed (and not expired)? + try: + old = PowChallenge.objects.get(challenge=challenge) + if not old.is_expired(): + return Response( + { + "error": "challenge_already_used", + "detail": "This challenge was already solved.", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + # Expired: remove and allow a fresh challenge + old.delete() + except Exception: + pass # fresh challenge + + # Verify solution + valid = verify_pow_solution(challenge, salt, solution, POW_DIFFICULTY) + if not valid: + return Response( + { + "error": "invalid_solution", + "detail": "PoW solution does not meet difficulty target.", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Mark as used by deleting the challenge row + try: + PowChallenge.objects.get(challenge=challenge).delete() + except Exception: + pass + + return Response({"valid": True}) + + +class PowChallengeIssueView(APIView): + """Alias: issue a new PoW challenge via GET (for discoverability).""" + + def get(self, request): + return PowChallengeView.get(self, request) + + +class PowChallengeVerifyView(APIView): + """Alias: verify a PoW solution via POST (for discoverability).""" + + def post(self, request): + return PowChallengeView.post(self, request) diff --git a/apps/api/apps/session/__init__.py b/apps/api/apps/session/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/session/admin.py b/apps/api/apps/session/admin.py new file mode 100644 index 0000000..e9b34a1 --- /dev/null +++ b/apps/api/apps/session/admin.py @@ -0,0 +1,20 @@ +from django.contrib import admin + +from apps.session.models import Session + + +@admin.register(Session) +class SessionAdmin(admin.ModelAdmin): + list_display = ( + "user", + "application", + "session_type", + "status", + "ip_address", + "device_name", + "started_at", + "expires_at", + ) + list_filter = ("status", "session_type") + search_fields = ("user__email", "ip_address") + readonly_fields = ("id", "created_at", "updated_at", "started_at", "revoked_at") \ No newline at end of file diff --git a/apps/api/apps/session/migrations/0001_initial.py b/apps/api/apps/session/migrations/0001_initial.py new file mode 100644 index 0000000..46e24f1 --- /dev/null +++ b/apps/api/apps/session/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 5.2.17 on 2026-08-13 13:33 + +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 = [ + ('application', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Session', + 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)), + ('session_type', models.CharField(choices=[('browser', 'Browser'), ('api', 'API'), ('device', 'Device')], default='browser', max_length=16)), + ('status', models.CharField(choices=[('active', 'Active'), ('expired', 'Expired'), ('revoked', 'Revoked')], default='active', max_length=16)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.CharField(blank=True, default='', max_length=500)), + ('device_name', models.CharField(blank=True, default='', max_length=100)), + ('started_at', models.DateTimeField(default=django.utils.timezone.now)), + ('last_activity_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=50)), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='application.application')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-started_at'], + 'indexes': [models.Index(fields=['user', 'status'], name='session_ses_user_id_1baefb_idx'), models.Index(fields=['status'], name='session_ses_status_18cbaa_idx')], + }, + ), + ] diff --git a/apps/api/apps/session/migrations/0002_session_organization_session_product.py b/apps/api/apps/session/migrations/0002_session_organization_session_product.py new file mode 100644 index 0000000..1e4d698 --- /dev/null +++ b/apps/api/apps/session/migrations/0002_session_organization_session_product.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.17 on 2026-08-19 08:15 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organization', '0001_initial'), + ('product', '0002_alter_product_key_alter_product_name_and_more'), + ('session', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='session', + name='organization', + field=models.ForeignKey(blank=True, help_text='Active business (کسب\u200cوکار فعال) for this session', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='organization.organization'), + ), + migrations.AddField( + model_name='session', + name='product', + field=models.ForeignKey(blank=True, help_text='Product context (محصول) for this session', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='product.product'), + ), + ] diff --git a/apps/api/apps/session/migrations/__init__.py b/apps/api/apps/session/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/session/models.py b/apps/api/apps/session/models.py new file mode 100644 index 0000000..56db815 --- /dev/null +++ b/apps/api/apps/session/models.py @@ -0,0 +1,83 @@ +from django.db import models +from django.utils import timezone + +from apps.common.models import BaseModel + + +class SessionType(models.TextChoices): + BROWSER = "browser", "Browser" + API = "api", "API" + DEVICE = "device", "Device" + + +class SessionStatus(models.TextChoices): + ACTIVE = "active", "Active" + EXPIRED = "expired", "Expired" + REVOKED = "revoked", "Revoked" + + +class Session(BaseModel): + user = models.ForeignKey( + "identity.User", + on_delete=models.CASCADE, + related_name="sessions", + ) + application = models.ForeignKey( + "application.Application", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="sessions", + ) + organization = models.ForeignKey( + "organization.Organization", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="sessions", + help_text="Active business (کسب‌وکار فعال) for this session", + ) + product = models.ForeignKey( + "product.Product", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="sessions", + help_text="Product context (محصول) for this session", + ) + session_type = models.CharField( + max_length=16, + choices=SessionType.choices, + default=SessionType.BROWSER, + ) + status = models.CharField( + max_length=16, + choices=SessionStatus.choices, + default=SessionStatus.ACTIVE, + ) + ip_address = models.GenericIPAddressField(null=True, blank=True) + user_agent = models.CharField(max_length=500, blank=True, default="") + device_name = models.CharField(max_length=100, blank=True, default="") + started_at = models.DateTimeField(default=timezone.now) + last_activity_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=50, blank=True, default="") + + class Meta: + ordering = ["-started_at"] + indexes = [ + models.Index(fields=["user", "status"]), + models.Index(fields=["status"]), + ] + + def __str__(self): + return f"{self.user} — {self.session_type}" + + def revoke(self, reason="manual"): + self.status = SessionStatus.REVOKED + self.revoked_at = timezone.now() + self.revoked_reason = reason + self.save( + update_fields=["status", "revoked_at", "revoked_reason", "updated_at"] + ) diff --git a/apps/api/apps/session/serializers.py b/apps/api/apps/session/serializers.py new file mode 100644 index 0000000..27678c4 --- /dev/null +++ b/apps/api/apps/session/serializers.py @@ -0,0 +1,38 @@ +from rest_framework import serializers + +from apps.session.models import Session + + +class SessionSerializer(serializers.ModelSerializer): + user_email = serializers.CharField(source="user.email", read_only=True) + application_name = serializers.CharField(source="application.name", read_only=True) + + class Meta: + model = Session + fields = ( + "id", + "organization", + "product", + "session_type", + "status", + "ip_address", + "user_agent", + "device_name", + "user_email", + "application_name", + "started_at", + "last_activity_at", + "expires_at", + "revoked_at", + "revoked_reason", + "created_at", + ) + read_only_fields = ( + "id", + "started_at", + "last_activity_at", + "expires_at", + "revoked_at", + "revoked_reason", + "created_at", + ) diff --git a/apps/api/apps/session/urls.py b/apps/api/apps/session/urls.py new file mode 100644 index 0000000..95aebb4 --- /dev/null +++ b/apps/api/apps/session/urls.py @@ -0,0 +1,11 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from apps.session.views import SessionViewSet + +router = DefaultRouter() +router.register(r"", SessionViewSet, basename="sessions") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/apps/api/apps/session/views.py b/apps/api/apps/session/views.py new file mode 100644 index 0000000..840d098 --- /dev/null +++ b/apps/api/apps/session/views.py @@ -0,0 +1,45 @@ +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import NotFound +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from apps.common.pagination import DefaultPagination +from apps.security.models import SecurityEventType, Severity, record_security_event +from apps.session.models import Session, SessionStatus +from apps.session.serializers import SessionSerializer + + +class SessionViewSet(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + pagination_class = DefaultPagination + + def get_queryset(self): + return Session.objects.filter(user=self.request.user) + + def list(self, request): + queryset = self.get_queryset() + paginator = DefaultPagination() + page = paginator.paginate_queryset(queryset, request) + serializer = SessionSerializer(page, many=True) + return paginator.get_paginated_response(serializer.data) + + def retrieve(self, request, pk=None): + session = self.get_queryset().filter(pk=pk).first() + if not session: + raise NotFound("Session not found.") + return Response(SessionSerializer(session).data) + + @action(detail=True, methods=["post"]) + def revoke(self, request, pk=None): + session = self.get_queryset().filter(pk=pk, status=SessionStatus.ACTIVE).first() + if not session: + raise NotFound("Active session not found.") + session.revoke(reason="user_revoked") + record_security_event( + SecurityEventType.SESSION_REVOKED, + user=request.user, + severity=Severity.MEDIUM, + metadata={"session_id": str(session.id), "device": session.device_name}, + ) + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/apps/api/apps/sso/__init__.py b/apps/api/apps/sso/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/sso/management/__init__.py b/apps/api/apps/sso/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/sso/management/commands/__init__.py b/apps/api/apps/sso/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/sso/management/commands/seed_hamsoo_sso.py b/apps/api/apps/sso/management/commands/seed_hamsoo_sso.py new file mode 100644 index 0000000..2279db1 --- /dev/null +++ b/apps/api/apps/sso/management/commands/seed_hamsoo_sso.py @@ -0,0 +1,125 @@ +from django.core.management.base import BaseCommand + +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 +from apps.product.models import Product + +HAMSOO_PRODUCTS = [ + { + "product_key": "store", + "name": "Hamsoo Store", + "subdomain": "store.hamsoo.me", + }, + { + "product_key": "hr", + "name": "Hamsoo HR", + "subdomain": "hr.hamsoo.me", + }, + { + "product_key": "project", + "name": "Hamsoo Project", + "subdomain": "project.hamsoo.me", + }, + { + "product_key": "calener", + "name": "Hamsoo Calener", + "subdomain": "calener.hamsoo.me", + }, +] + + +class Command(BaseCommand): + help = "Register the hamsoo subdomain products as OAuth Applications for SSO." + + def add_arguments(self, parser): + parser.add_argument( + "--dry-run", + action="store_true", + help="Report what would be created without writing to the database.", + ) + + def handle(self, *args, **options): + dry_run = options.get("dry_run") + default_scopes = list( + OAuthScope.objects.filter(is_system=False).values_list("code", flat=True) + ) or ["openid"] + + for spec in HAMSOO_PRODUCTS: + product_key = spec["product_key"] + subdomain = spec["subdomain"] + redirect_uri = f"https://{subdomain}/callback" + + if dry_run: + product_exists = Product.objects.filter(key=product_key).exists() + app_exists = Application.objects.filter( + product_key=product_key + ).exists() + self.stdout.write( + f"[product] {'exists ' if product_exists else 'create '} {product_key} ({subdomain})" + ) + self.stdout.write( + f"[app] {'would exist' if app_exists else 'would create'} {product_key} -> {redirect_uri}" + ) + continue + + product, product_created = Product.objects.get_or_create( + key=product_key, + defaults={ + "name": spec["name"], + "website_url": f"https://{subdomain}", + }, + ) + if product_created: + self.stdout.write(f"[product] created {product_key} ({subdomain})") + else: + self.stdout.write(f"[product] exists {product_key} ({subdomain})") + + application, app_created = Application.objects.get_or_create( + product_key=product_key, + defaults={ + "name": spec["name"], + "description": f"Hamsoo SSO client for {subdomain}", + "website_url": f"https://{subdomain}", + "redirect_uris": [redirect_uri], + "allowed_origins": [f"https://{subdomain}"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "status": ApplicationStatus.ACTIVE, + "client_id": generate_client_id(), + }, + ) + + if app_created: + raw_secret = generate_client_secret() + application.client_secret_hash = hash_token(raw_secret) + application.save(update_fields=["client_secret_hash"]) + application.scopes.set( + OAuthScope.objects.filter(code__in=default_scopes) + ) + self.stdout.write( + self.style.SUCCESS( + f"[app] created {product_key}\n" + f" client_id: {application.client_id}\n" + f" client_secret: {raw_secret}\n" + f" redirect_uri: {redirect_uri}" + ) + ) + else: + if not application.client_secret_hash: + raw_secret = generate_client_secret() + application.client_secret_hash = hash_token(raw_secret) + application.save(update_fields=["client_secret_hash"]) + self.stdout.write( + self.style.SUCCESS( + f"[app] set missing secret for {product_key}\n" + f" client_secret: {raw_secret}" + ) + ) + if not application.scopes.exists(): + application.scopes.set( + OAuthScope.objects.filter(code__in=default_scopes) + ) + self.stdout.write( + f"[app] exists {product_key} (client_id={application.client_id})" + ) diff --git a/apps/api/apps/sso/templates/sso/login.html b/apps/api/apps/sso/templates/sso/login.html new file mode 100644 index 0000000..5852295 --- /dev/null +++ b/apps/api/apps/sso/templates/sso/login.html @@ -0,0 +1,161 @@ + + + + + + ورود به هم‌سو | Hamsoo SSO + + + +
+ +

ورود به حساب یکپارچه

+

یک بار ورود، دسترسی به همه سرویس‌های هم‌سو

+ + {% if error %}
{{ error }}
{% endif %} + + + + +
+ {% csrf_token %} + + + + {% if mfa_required %} + + +
حساب شما با احراز هویت دو مرحله‌ای محافظت می‌شود.
+ {% endif %} +
+ +
+ +
+
+ + + + \ No newline at end of file diff --git a/apps/api/apps/sso/tests.py b/apps/api/apps/sso/tests.py new file mode 100644 index 0000000..44f1745 --- /dev/null +++ b/apps/api/apps/sso/tests.py @@ -0,0 +1,456 @@ +import uuid +from urllib.parse import urlparse, parse_qs + +from django.contrib.auth import get_user_model +from django.core.cache import cache +from django.core.management import call_command +from django.test import TestCase +from django.urls import reverse +from rest_framework_simplejwt.tokens import AccessToken + +from apps.access.models import AccessStatus, ProductAccess +from apps.application.models import Application, ApplicationStatus +from apps.common.utils import generate_client_secret, hash_token +from apps.oauth.models import AuthorizationCode, Consent, OAuthScope +from apps.organization.models import Organization +from apps.product.models import Product + +User = get_user_model() + + +class SsoLoginPageTests(TestCase): + def setUp(self): + cache.clear() + self.user = User.objects.create_user( + email="sso@example.com", + password="S3cure-Pass-123", + status="active", + ) + self.client = self.client + + def test_login_page_renders(self): + response = self.client.get(reverse("sso-login")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "hamsoo") + + def test_login_invalid_credentials(self): + response = self.client.post( + reverse("sso-login"), + {"email": "sso@example.com", "password": "wrong-password"}, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Invalid credentials") + + def test_login_rejects_suspended_user(self): + self.user.status = "suspended" + self.user.save() + response = self.client.post( + reverse("sso-login"), + {"email": "sso@example.com", "password": "S3cure-Pass-123"}, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "not active") + + def test_login_sets_sso_session_and_redirects(self): + response = self.client.post( + reverse("sso-login"), + {"email": "sso@example.com", "password": "S3cure-Pass-123"}, + ) + self.assertEqual(response.status_code, 302) + self.assertIn("sessionid", response.cookies) + + +class SsoAuthorizeBrowserFlowTests(TestCase): + def setUp(self): + cache.clear() + self.user = User.objects.create_user( + email="browser@example.com", + password="S3cure-Pass-123", + status="active", + ) + self.scope = OAuthScope.objects.create(code="openid", is_system=True) + raw_secret = generate_client_secret() + self.application = Application.objects.create( + product_key="store", + name="Hamsoo Store", + client_secret_hash=hash_token(raw_secret), + redirect_uris=["https://store.hamsoo.me/callback"], + grant_types=["authorization_code"], + response_types=["code"], + status=ApplicationStatus.ACTIVE, + created_by=self.user, + ) + self.application.scopes.add(self.scope) + self.client_secret = raw_secret + + def _authorize_params(self): + return { + "client_id": self.application.client_id, + "response_type": "code", + "redirect_uri": "https://store.hamsoo.me/callback", + "scope": "openid", + "state": "xyz", + } + + def test_unauthenticated_browser_redirects_to_login(self): + response = self.client.get( + reverse("oauth-authorize"), + data=self._authorize_params(), + HTTP_ACCEPT="text/html", + ) + self.assertEqual(response.status_code, 302) + self.assertIn(reverse("sso-login"), response.url) + + def test_unauthenticated_api_returns_401(self): + response = self.client.get( + reverse("oauth-authorize"), + data=self._authorize_params(), + ) + self.assertEqual(response.status_code, 401) + + def test_sso_login_then_authorize_issues_code(self): + resp = self.client.get( + reverse("oauth-authorize"), + data=self._authorize_params(), + HTTP_ACCEPT="text/html", + ) + self.assertEqual(resp.status_code, 302) + # The authorize redirect carries the original authorize URL as ?next= + next_url = parse_qs(urlparse(resp.url).query).get("next", [""])[0] + self.assertTrue(next_url.startswith("/oauth/authorize")) + + login_resp = self.client.post( + reverse("sso-login"), + { + "email": "browser@example.com", + "password": "S3cure-Pass-123", + "next": next_url, + }, + ) + self.assertEqual(login_resp.status_code, 302) + # After SSO login the browser is sent back to the authorize endpoint + # (now carrying the established SSO session). + self.assertTrue(login_resp["Location"].startswith("/oauth/authorize")) + + resp2 = self.client.get( + login_resp["Location"], + HTTP_ACCEPT="text/html", + ) + # First-time authorization is intercepted by the consent screen. + self.assertEqual(resp2.status_code, 302) + self.assertIn(reverse("oauth-consent"), resp2.url) + + consent_page = self.client.get(resp2.url, HTTP_ACCEPT="text/html") + self.assertEqual(consent_page.status_code, 200) + self.assertContains(consent_page, "Hamsoo Store") + + allow_resp = self.client.post(resp2.url, {"decision": "allow"}) + self.assertEqual(allow_resp.status_code, 302) + self.assertIn(reverse("oauth-authorize"), allow_resp.url) + + resp3 = self.client.get(allow_resp.url, HTTP_ACCEPT="text/html") + self.assertEqual(resp3.status_code, 302) + location = resp3.url + self.assertTrue(location.startswith("https://store.hamsoo.me/callback")) + qs = parse_qs(urlparse(location).query) + self.assertIn("code", qs) + self.assertEqual(qs["state"], ["xyz"]) + self.assertTrue( + AuthorizationCode.objects.filter( + user=self.user, application=self.application + ).exists() + ) + + def test_sso_logout_clears_session(self): + self.client.post( + reverse("sso-login"), + {"email": "browser@example.com", "password": "S3cure-Pass-123"}, + ) + response = self.client.get(reverse("sso-logout")) + self.assertEqual(response.status_code, 200) + self.assertTrue( + response.cookies.get("sessionid") is None + or response.cookies["sessionid"].value == "" + or "sessionid" in response.cookies + ) + + +class SsoOrganizationContextTests(TestCase): + def setUp(self): + cache.clear() + self.user = User.objects.create_user( + email="orgflow@example.com", + password="S3cure-Pass-123", + status="active", + ) + self.product = Product.objects.create(key="store", name="Hamsoo Store") + self.scope = OAuthScope.objects.create(code="openid", is_system=True) + self.raw_secret = generate_client_secret() + self.application = Application.objects.create( + product_key="store", + name="Hamsoo Store", + client_secret_hash=hash_token(self.raw_secret), + redirect_uris=["https://store.hamsoo.me/callback"], + grant_types=["authorization_code"], + response_types=["code"], + status=ApplicationStatus.ACTIVE, + created_by=self.user, + ) + self.application.scopes.add(self.scope) + + self.allowed_org = Organization.objects.create( + name="Allowed Biz", slug="allowed-biz" + ) + ProductAccess.objects.create( + user=self.user, + organization=self.allowed_org, + product=self.product, + status=AccessStatus.ACTIVE, + ) + self.denied_org = Organization.objects.create( + name="Denied Biz", slug="denied-biz" + ) + + def _login(self): + self.client.post( + reverse("sso-login"), + {"email": "orgflow@example.com", "password": "S3cure-Pass-123"}, + ) + + def _authorize_params(self, organization_id=None): + params = { + "client_id": self.application.client_id, + "response_type": "code", + "redirect_uri": "https://store.hamsoo.me/callback", + "scope": "openid", + "state": "xyz", + } + if organization_id: + params["organization_id"] = str(organization_id) + return params + + def test_valid_organization_embeds_claims_in_token(self): + self._login() + resp = self.client.get( + reverse("oauth-authorize"), + data=self._authorize_params(self.allowed_org.id), + HTTP_ACCEPT="text/html", + ) + # Intercepted by consent screen on first authorization. + self.assertEqual(resp.status_code, 302) + self.assertIn(reverse("oauth-consent"), resp.url) + + allow_resp = self.client.post(resp.url, {"decision": "allow"}) + self.assertEqual(allow_resp.status_code, 302) + self.assertIn(reverse("oauth-authorize"), allow_resp.url) + + resp2 = self.client.get(allow_resp.url, HTTP_ACCEPT="text/html") + self.assertEqual(resp2.status_code, 302) + code = parse_qs(urlparse(resp2.url).query)["code"][0] + + token_resp = self.client.post( + reverse("oauth-token"), + { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "https://store.hamsoo.me/callback", + "client_id": self.application.client_id, + "client_secret": self.raw_secret, + }, + ) + self.assertEqual(token_resp.status_code, 200) + decoded = AccessToken(token_resp.data["access_token"]) + self.assertEqual(decoded["organization_id"], str(self.allowed_org.id)) + self.assertEqual(decoded["product_key"], "store") + + def test_invalid_organization_is_denied(self): + self._login() + resp = self.client.get( + reverse("oauth-authorize"), + data=self._authorize_params(self.denied_org.id), + HTTP_ACCEPT="text/html", + ) + self.assertEqual(resp.status_code, 302) + self.assertIn("error=access_denied", resp.url) + self.assertIn("state=xyz", resp.url) + + +class SsoConsentTests(TestCase): + def setUp(self): + cache.clear() + self.user = User.objects.create_user( + email="consent@example.com", + password="S3cure-Pass-123", + status="active", + ) + self.scope = OAuthScope.objects.create(code="openid", is_system=True) + self.read_scope = OAuthScope.objects.create( + code="read", description="Read access", is_default=False + ) + self.raw_secret = generate_client_secret() + self.application = Application.objects.create( + product_key="store", + name="Hamsoo Store", + client_secret_hash=hash_token(self.raw_secret), + redirect_uris=["https://store.hamsoo.me/callback"], + grant_types=["authorization_code"], + response_types=["code"], + status=ApplicationStatus.ACTIVE, + created_by=self.user, + ) + self.application.scopes.add(self.scope, self.read_scope) + self._login() + + def _login(self): + self.client.post( + reverse("sso-login"), + {"email": "consent@example.com", "password": "S3cure-Pass-123"}, + ) + + def _authorize_params(self, scope="openid"): + return { + "client_id": self.application.client_id, + "response_type": "code", + "redirect_uri": "https://store.hamsoo.me/callback", + "scope": scope, + "state": "abc", + } + + def _consent_url(self): + resp = self.client.get( + reverse("oauth-authorize"), + data=self._authorize_params(), + HTTP_ACCEPT="text/html", + ) + self.assertEqual(resp.status_code, 302) + self.assertIn(reverse("oauth-consent"), resp.url) + return resp.url + + def test_consent_screen_renders_app_and_scopes(self): + consent_page = self.client.get(self._consent_url(), HTTP_ACCEPT="text/html") + self.assertEqual(consent_page.status_code, 200) + self.assertContains(consent_page, "Hamsoo Store") + self.assertContains(consent_page, "openid") + + def test_consent_allow_creates_consent_record(self): + consent_url = self._consent_url() + resp = self.client.post(consent_url, {"decision": "allow"}) + self.assertEqual(resp.status_code, 302) + self.assertIn(reverse("oauth-authorize"), resp.url) + self.assertTrue( + Consent.objects.filter( + user=self.user, application=self.application + ).exists() + ) + + def test_consent_deny_redirects_access_denied(self): + consent_url = self._consent_url() + resp = self.client.post(consent_url, {"decision": "deny"}) + self.assertEqual(resp.status_code, 302) + self.assertIn("error=access_denied", resp.url) + self.assertIn("state=abc", resp.url) + self.assertFalse( + Consent.objects.filter( + user=self.user, application=self.application + ).exists() + ) + + def test_consent_skipped_when_already_granted(self): + Consent.objects.create(user=self.user, application=self.application) + resp = self.client.get( + reverse("oauth-authorize"), + data=self._authorize_params(), + HTTP_ACCEPT="text/html", + ) + # No consent redirect: authorization proceeds straight to the code. + self.assertEqual(resp.status_code, 302) + self.assertNotIn(reverse("oauth-consent"), resp.url) + self.assertTrue(resp.url.startswith("https://store.hamsoo.me/callback")) + self.assertIn("code", parse_qs(urlparse(resp.url).query)) + + +class SsoSessionFlushOnSuspendTests(TestCase): + def setUp(self): + cache.clear() + self.user = User.objects.create_user( + email="suspend@example.com", + password="S3cure-Pass-123", + status="active", + ) + self.scope = OAuthScope.objects.create(code="openid", is_system=True) + self.raw_secret = generate_client_secret() + self.application = Application.objects.create( + product_key="store", + name="Hamsoo Store", + client_secret_hash=hash_token(self.raw_secret), + redirect_uris=["https://store.hamsoo.me/callback"], + grant_types=["authorization_code"], + response_types=["code"], + status=ApplicationStatus.ACTIVE, + created_by=self.user, + ) + self.application.scopes.add(self.scope) + + def _login(self): + self.client.post( + reverse("sso-login"), + {"email": "suspend@example.com", "password": "S3cure-Pass-123"}, + ) + + def _authorize_params(self): + return { + "client_id": self.application.client_id, + "response_type": "code", + "redirect_uri": "https://store.hamsoo.me/callback", + "scope": "openid", + "state": "xyz", + } + + def test_suspended_user_cannot_use_sso_session(self): + self._login() + # The shared SSO session cookie (Django sessionid) is established. + self.assertIn("sessionid", self.client.cookies) + + # Suspending the user must revoke and flush the browser SSO session + # everywhere (decision 9). + self.user.status = "suspended" + self.user.save() + + resp = self.client.get( + reverse("oauth-authorize"), + data=self._authorize_params(), + HTTP_ACCEPT="text/html", + ) + self.assertEqual(resp.status_code, 302) + self.assertIn(reverse("sso-login"), resp.url) + + +class SeedHamsooSsoTests(TestCase): + def test_seed_creates_four_hamsoo_applications(self): + cache.clear() + self.assertEqual( + Application.objects.filter( + product_key__in=["store", "hr", "project", "calener"] + ).count(), + 0, + ) + + call_command("seed_hamsoo_sso") + + apps = Application.objects.filter( + product_key__in=["store", "hr", "project", "calener"] + ) + self.assertEqual(apps.count(), 4) + for app in apps: + self.assertTrue(app.client_secret_hash) + self.assertTrue(app.redirect_uris) + self.assertEqual(app.status, ApplicationStatus.ACTIVE) + + # Idempotent: a second run does not create duplicates. + call_command("seed_hamsoo_sso") + self.assertEqual( + Application.objects.filter( + product_key__in=["store", "hr", "project", "calener"] + ).count(), + 4, + ) diff --git a/apps/api/apps/sso/urls.py b/apps/api/apps/sso/urls.py new file mode 100644 index 0000000..4e649cc --- /dev/null +++ b/apps/api/apps/sso/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from apps.sso.views import SsoLoginView, SsoLogoutView + +urlpatterns = [ + path("login/", SsoLoginView.as_view(), name="sso-login"), + path("logout/", SsoLogoutView.as_view(), name="sso-logout"), +] diff --git a/apps/api/apps/sso/views.py b/apps/api/apps/sso/views.py new file mode 100644 index 0000000..d094627 --- /dev/null +++ b/apps/api/apps/sso/views.py @@ -0,0 +1,173 @@ +import logging + +import pyotp +import json +import base64 + +from django.conf import settings +from django.contrib.auth import authenticate, login, logout +from django.http import HttpResponse, HttpResponseRedirect +from django.shortcuts import render +from django.urls import reverse +from django.utils import timezone +from django.utils.http import ( + urlsafe_base64_encode, + urlsafe_base64_decode, + url_has_allowed_host_and_scheme, +) +from django.views import View + +from apps.authentication.services import revoke_all_user_sessions +from apps.common.utils import client_ip +from apps.security.models import SecurityEventType, Severity, record_security_event + +logger = logging.getLogger(__name__) + + +def _b64encode_account(payload): + """Base64-encode the account payload for the hamsoo_accounts cookie.""" + raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode() + return base64.urlsafe_b64encode(raw).decode() + + +def _b64decode_account(value): + """Base64-decode the account cookie payload. Returns dict or None.""" + try: + return json.loads(urlsafe_base64_decode(value.encode())) + except Exception: + return None + + +def _safe_next(next_url, fallback="/sso/login/"): + if next_url and url_has_allowed_host_and_scheme( + next_url, + allowed_hosts=settings.ALLOWED_HOSTS, + require_https=getattr(settings, "SESSION_COOKIE_SECURE", False), + ): + return next_url + return fallback + + +class SsoLoginView(View): + """Central IdP login page (Google-account style). + + Establishes the shared SSO session (Django session cookie) that is readable + across every ``*.hamsoo.me`` subdomain, enabling single sign-on: once a + user authenticates here, every other hamsoo product recognizes the session + and skips re-login. + """ + + template_name = "sso/login.html" + + def get(self, request): + if request.user.is_authenticated: + return HttpResponseRedirect(_safe_next(request.GET.get("next"))) + # Pass decoded account cookie payload so the template can show the chooser + cookie_val = request.COOKIES.get("hamsoo_accounts") + accounts = _b64decode_account(cookie_val) if cookie_val else None + return render( + request, + self.template_name, + {"next": request.GET.get("next", ""), "error": None, "accounts": accounts}, + ) + + def post(self, request): + email = request.POST.get("email", "") + password = request.POST.get("password", "") + mfa_code = request.POST.get("mfa_code", "") + next_url = request.POST.get("next", "") + + user = authenticate(request, username=email, password=password) + if user is None: + return render( + request, + self.template_name, + {"next": next_url, "email": email, "error": "Invalid credentials."}, + ) + + if getattr(user, "status", "active") not in ("active",): + return render( + request, + self.template_name, + { + "next": next_url, + "email": email, + "error": "This account is not active.", + }, + ) + + if user.mfa_enabled: + if not mfa_code: + return render( + request, + self.template_name, + { + "next": next_url, + "email": email, + "error": "MFA code required.", + "mfa_required": True, + }, + ) + totp = pyotp.TOTP(user.totp_secret) + if not totp.verify(mfa_code, valid_window=1): + return render( + request, + self.template_name, + { + "next": next_url, + "email": email, + "error": "Invalid MFA code.", + "mfa_required": True, + }, + ) + + login(request, user) + # Set a base64-encoded cookie with the user's account info for the account + # chooser on the SSO login page. This cookie is readable across *.hamsoo.me + # subdomains. + account_payload = { + "email": user.email, + "name": getattr(user, "full_name", "") + or getattr(user, "display_name", "") + or "", + "avatar": getattr(user, "avatar_url", "") or "", + } + response = HttpResponseRedirect(_safe_next(next_url)) + response.set_cookie( + "hamsoo_accounts", + _b64encode_account(account_payload), + max_age=60 * 60 * 24 * 30, # 30 days + httponly=False, # JS needs to read it for account chooser + samesite="Lax", + path="/", + ) + record_security_event( + SecurityEventType.LOGIN_SUCCESS, + user=user, + severity=Severity.INFO, + ip_address=client_ip(request), + user_agent=request.META.get("HTTP_USER_AGENT", ""), + metadata={"method": "sso_login_page"}, + ) + return response + + +class SsoLogoutView(View): + """Global (SSO) logout: end the IdP session and revoke every token/session + across all products (decision 8 default).""" + + def get(self, request): + user = request.user if request.user.is_authenticated else None + if user is not None: + try: + revoke_all_user_sessions(user, reason="global_logout") + except Exception: + logger.exception("Failed to revoke sessions on SSO logout") + logout(request) + next_url = request.GET.get("next") + if next_url and url_has_allowed_host_and_scheme( + next_url, + allowed_hosts=settings.ALLOWED_HOSTS, + ): + return HttpResponseRedirect(next_url) + return HttpResponse("Logged out.") diff --git a/apps/api/apps/token_blacklist/__init__.py b/apps/api/apps/token_blacklist/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/token_blacklist/admin.py b/apps/api/apps/token_blacklist/admin.py new file mode 100644 index 0000000..537f8be --- /dev/null +++ b/apps/api/apps/token_blacklist/admin.py @@ -0,0 +1,15 @@ +from django.contrib import admin + +from apps.token_blacklist.models import TokenBlacklist + + +@admin.register(TokenBlacklist) +class TokenBlacklistAdmin(admin.ModelAdmin): + list_display = ("token_hash_short", "token_type", "expires_at", "created_at") + list_filter = ("token_type",) + search_fields = ("token_hash",) + readonly_fields = ("created_at", "updated_at") + + def token_hash_short(self, obj): + return f"{obj.token_hash[:16]}..." + token_hash_short.short_description = "Token Hash" diff --git a/apps/api/apps/token_blacklist/apps.py b/apps/api/apps/token_blacklist/apps.py new file mode 100644 index 0000000..e751547 --- /dev/null +++ b/apps/api/apps/token_blacklist/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class TokenBlacklistConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.token_blacklist" + verbose_name = "Token Blacklist" diff --git a/apps/api/apps/token_blacklist/management/__init__.py b/apps/api/apps/token_blacklist/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/token_blacklist/management/commands/__init__.py b/apps/api/apps/token_blacklist/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/token_blacklist/management/commands/cleanup_expired_tokens.py b/apps/api/apps/token_blacklist/management/commands/cleanup_expired_tokens.py new file mode 100644 index 0000000..1276128 --- /dev/null +++ b/apps/api/apps/token_blacklist/management/commands/cleanup_expired_tokens.py @@ -0,0 +1,25 @@ +from django.core.management.base import BaseCommand +from django.utils import timezone + +from apps.token_blacklist.models import TokenBlacklist + + +class Command(BaseCommand): + help = "Remove expired entries from the token blacklist" + + def add_arguments(self, parser): + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be deleted without actually deleting", + ) + + def handle(self, *args, **options): + now = timezone.now() + expired = TokenBlacklist.objects.filter(expires_at__lte=now) + count = expired.count() + if options.get("dry_run"): + self.stdout.write(f"{count} expired token(s) would be deleted") + else: + expired.delete() + self.stdout.write(self.style.SUCCESS(f"Deleted {count} expired token(s)")) diff --git a/apps/api/apps/token_blacklist/migrations/0001_initial.py b/apps/api/apps/token_blacklist/migrations/0001_initial.py new file mode 100644 index 0000000..a8f2dca --- /dev/null +++ b/apps/api/apps/token_blacklist/migrations/0001_initial.py @@ -0,0 +1,23 @@ +from django.db import migrations, models +import uuid + + +class Migration(migrations.Migration): + initial = True + + operations = [ + migrations.CreateModel( + name="TokenBlacklist", + 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(db_index=True, max_length=64, unique=True)), + ("token_type", models.CharField(choices=[("access", "Access"), ("refresh", "Refresh")], max_length=16)), + ("expires_at", models.DateTimeField()), + ], + options={ + "ordering": ["-created_at"], + }, + ), + ] diff --git a/apps/api/apps/token_blacklist/migrations/0002_tokenblacklist_token_black_token_h_30fb2d_idx_and_more.py b/apps/api/apps/token_blacklist/migrations/0002_tokenblacklist_token_black_token_h_30fb2d_idx_and_more.py new file mode 100644 index 0000000..05db326 --- /dev/null +++ b/apps/api/apps/token_blacklist/migrations/0002_tokenblacklist_token_black_token_h_30fb2d_idx_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.17 on 2026-08-14 12:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('token_blacklist', '0001_initial'), + ] + + operations = [ + migrations.AddIndex( + model_name='tokenblacklist', + index=models.Index(fields=['token_hash'], name='token_black_token_h_30fb2d_idx'), + ), + migrations.AddIndex( + model_name='tokenblacklist', + index=models.Index(fields=['expires_at'], name='token_black_expires_d182d3_idx'), + ), + ] diff --git a/apps/api/apps/token_blacklist/migrations/__init__.py b/apps/api/apps/token_blacklist/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/token_blacklist/models.py b/apps/api/apps/token_blacklist/models.py new file mode 100644 index 0000000..645e56e --- /dev/null +++ b/apps/api/apps/token_blacklist/models.py @@ -0,0 +1,24 @@ +from django.db import models + +from apps.common.models import BaseModel + + +class TokenBlacklist(BaseModel): + token_hash = models.CharField(max_length=64, unique=True, db_index=True) + token_type = models.CharField(max_length=16, choices=[("access", "Access"), ("refresh", "Refresh")]) + expires_at = models.DateTimeField() + + class Meta: + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["token_hash"]), + models.Index(fields=["expires_at"]), + ] + + def __str__(self): + return f"{self.token_type} — {self.token_hash[:16]}..." + + @property + def is_expired(self): + from django.utils import timezone + return self.expires_at <= timezone.now() diff --git a/apps/api/apps/token_blacklist/tests.py b/apps/api/apps/token_blacklist/tests.py new file mode 100644 index 0000000..21b56dd --- /dev/null +++ b/apps/api/apps/token_blacklist/tests.py @@ -0,0 +1,124 @@ +import uuid +from datetime import timedelta +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from apps.authentication.services import ( + blacklist_token, + create_access_token, + is_token_blacklisted, +) +from apps.common.utils import generate_token, hash_token +from apps.token_blacklist.models import TokenBlacklist + +User = get_user_model() + + +class TokenBlacklistServiceTests(TestCase): + def setUp(self): + self.user = User.objects.create_user( + email="svc@example.com", + password="S3cure-Pass-123", + status="active", + ) + + def test_blacklist_token_adds_entry(self): + token = str(create_access_token(self.user)) + self.assertFalse(is_token_blacklisted(token)) + blacklist_token(token, "access") + self.assertTrue(is_token_blacklisted(token)) + entry = TokenBlacklist.objects.get(token_hash=hash_token(token)) + self.assertEqual(entry.token_type, "access") + + def test_blacklist_token_does_not_duplicate(self): + token = str(create_access_token(self.user)) + blacklist_token(token, "access") + blacklist_token(token, "access") + self.assertEqual(TokenBlacklist.objects.count(), 1) + + def test_is_blacklisted_returns_false_for_unknown_token(self): + token = str(create_access_token(self.user)) + self.assertFalse(is_token_blacklisted(token)) + + def test_expired_blacklist_entry_does_not_block(self): + token = str(create_access_token(self.user)) + blacklist_token(token, "access") + TokenBlacklist.objects.update(expires_at=timezone.now()) + self.assertFalse(is_token_blacklisted(token)) + + def test_blacklist_view(self): + client = APIClient() + client.post( + reverse("auth-login"), + {"email": "svc@example.com", "password": "S3cure-Pass-123"}, + ) + + response = client.post( + reverse("blacklist-access-token"), + {"token": generate_token()}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["detail"], "Token blacklisted.") + + def test_check_blacklist_view_unknown_token(self): + client = APIClient() + response = client.post( + reverse("check-token-blacklist"), + {"token": generate_token()}, + ) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.data["blacklisted"]) + + def test_check_blacklist_view_blacklisted_token(self): + client = APIClient() + token = generate_token() + TokenBlacklist.objects.create( + token_hash=hash_token(token), + token_type="access", + expires_at=timezone.now() + timedelta(hours=1), + ) + response = client.post( + reverse("check-token-blacklist"), + {"token": token}, + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data["blacklisted"]) + + +class ExpiredTokenCleanupTests(TestCase): + def test_dry_run_reports_count(self): + from io import StringIO + from django.core.management import call_command + + TokenBlacklist.objects.create( + token_hash=hash_token("expired_token"), + token_type="access", + expires_at=timezone.now() - timedelta(hours=1), + ) + out = StringIO() + call_command("cleanup_expired_tokens", "--dry-run", stdout=out) + self.assertIn("1 expired", out.getvalue()) + + def test_cleanup_deletes_expired(self): + from django.core.management import call_command + + TokenBlacklist.objects.create( + token_hash=hash_token("expired_token"), + token_type="access", + expires_at=timezone.now() - timedelta(hours=1), + ) + TokenBlacklist.objects.create( + token_hash=hash_token("valid_token"), + token_type="access", + expires_at=timezone.now() + timedelta(hours=1), + ) + call_command("cleanup_expired_tokens") + self.assertEqual(TokenBlacklist.objects.count(), 1) + self.assertFalse( + TokenBlacklist.objects.filter(token_hash=hash_token("expired_token")).exists() + ) diff --git a/apps/api/apps/token_blacklist/urls.py b/apps/api/apps/token_blacklist/urls.py new file mode 100644 index 0000000..378a7f6 --- /dev/null +++ b/apps/api/apps/token_blacklist/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from apps.token_blacklist.views import BlacklistAccessTokenView, CheckTokenBlacklistView + +urlpatterns = [ + path("blacklist/", BlacklistAccessTokenView.as_view(), name="blacklist-access-token"), + path("check/", CheckTokenBlacklistView.as_view(), name="check-token-blacklist"), +] diff --git a/apps/api/apps/token_blacklist/views.py b/apps/api/apps/token_blacklist/views.py new file mode 100644 index 0000000..54d46e8 --- /dev/null +++ b/apps/api/apps/token_blacklist/views.py @@ -0,0 +1,62 @@ +from django.utils import timezone +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.common.utils import hash_token +from apps.oauth.models import AccessToken, RefreshToken +from apps.token_blacklist.models import TokenBlacklist + + +class TokenBlacklistMixin: + @staticmethod + def add_to_blacklist(token_hash, token_type, expires_at): + TokenBlacklist.objects.update_or_create( + token_hash=token_hash, + defaults={ + "token_type": token_type, + "expires_at": expires_at, + }, + ) + + @staticmethod + def is_blacklisted(token_hash): + try: + entry = TokenBlacklist.objects.get(token_hash=token_hash) + return not entry.is_expired + except TokenBlacklist.DoesNotExist: + return False + + +class BlacklistAccessTokenView(APIView, TokenBlacklistMixin): + permission_classes = [AllowAny] + + def post(self, request): + token = request.data.get("token") + if not token: + return Response( + {"detail": "Token is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + token_hash = hash_token(token) + self.add_to_blacklist(token_hash, "access", timezone.now()) + return Response({"detail": "Token blacklisted."}, status=status.HTTP_200_OK) + + +class CheckTokenBlacklistView(APIView, TokenBlacklistMixin): + permission_classes = [AllowAny] + + def post(self, request): + token = request.data.get("token") + if not token: + return Response( + {"detail": "Token is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + token_hash = hash_token(token) + is_blacklisted = self.is_blacklisted(token_hash) + return Response( + {"blacklisted": is_blacklisted}, + status=status.HTTP_200_OK, + ) diff --git a/apps/api/apps/verification/__init__.py b/apps/api/apps/verification/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/verification/admin.py b/apps/api/apps/verification/admin.py new file mode 100644 index 0000000..1b35f36 --- /dev/null +++ b/apps/api/apps/verification/admin.py @@ -0,0 +1,20 @@ +from django.contrib import admin + +from .models import ( + VerificationEvidence, + TrustState, + CapabilityPolicy, +) + + +@admin.register(VerificationEvidence) +class VerificationEvidenceAdmin(admin.ModelAdmin): + list_display = ["person", "dimension", "evidence_type", "status", "confidence", "verified_at", "is_valid"] + list_filter = ["dimension", "evidence_type", "status", "confidence"] + search_fields = ["person__email", "person__full_name"] + + +@admin.register(CapabilityPolicy) +class CapabilityPolicyAdmin(admin.ModelAdmin): + list_display = ["operation", "name", "min_trust_state", "mfa_required"] + list_editable = ["min_trust_state", "mfa_required"] diff --git a/apps/api/apps/verification/apps.py b/apps/api/apps/verification/apps.py new file mode 100644 index 0000000..04e6560 --- /dev/null +++ b/apps/api/apps/verification/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class VerificationConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.verification" diff --git a/apps/api/apps/verification/migrations/0001_initial.py b/apps/api/apps/verification/migrations/0001_initial.py new file mode 100644 index 0000000..7908755 --- /dev/null +++ b/apps/api/apps/verification/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.2.17 on 2026-08-14 20:38 + +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='CapabilityPolicy', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('operation', models.CharField(max_length=128, unique=True)), + ('name', models.CharField(max_length=200)), + ('description', models.TextField(blank=True, default='')), + ('required_dimensions', models.JSONField(blank=True, default=list)), + ('required_evidence_types', models.JSONField(blank=True, default=list)), + ('min_trust_state', models.CharField(choices=[('basic', 'Basic'), ('verified', 'Verified'), ('strong', 'Strong')], default='basic', max_length=16)), + ('mfa_required', models.BooleanField(default=False)), + ('stepup_required_if_below', models.CharField(blank=True, choices=[('basic', 'Basic'), ('verified', 'Verified'), ('strong', 'Strong')], default='strong', max_length=16)), + ('key', models.CharField(default='', max_length=128, unique=True)), + ], + options={ + 'ordering': ['operation'], + }, + ), + migrations.CreateModel( + name='VerificationEvidence', + 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)), + ('dimension', models.CharField(choices=[('account', 'Account'), ('contact', 'Contact Verification'), ('identity', 'Identity Verification'), ('biometric', 'Biometric Verification'), ('organization', 'Organization Verification')], max_length=32)), + ('evidence_type', models.CharField(choices=[('email_otp', 'Email OTP'), ('email_link', 'Email Link'), ('sms_otp', 'SMS OTP'), ('phone_call', 'Phone Call'), ('national_id', 'National Identity'), ('passport', 'Passport'), ('drivers_license', "Driver's License"), ('face_match', 'Face Match'), ('face_liveness', 'Face Liveness'), ('fingerprint', 'Fingerprint'), ('org_membership', 'Organization Membership'), ('user_role', 'User Role'), ('passkey', 'Passkey'), ('totp', 'TOTP'), ('webauthn', 'WebAuthn'), ('manual_review', 'Manual Review')], max_length=32)), + ('method', models.CharField(blank=True, default='', max_length=64)), + ('provider', models.CharField(blank=True, default='', max_length=64)), + ('value_hash', models.CharField(blank=True, default='', max_length=128)), + ('confidence', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High')], default='high', max_length=16)), + ('status', models.CharField(choices=[('active', 'Active'), ('expired', 'Expired'), ('revoked', 'Revoked'), ('pending', 'Pending')], default='active', max_length=16)), + ('verified_at', models.DateTimeField()), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ('revoked_at', models.DateTimeField(blank=True, null=True)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='verification_evidence', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-verified_at'], + 'indexes': [models.Index(fields=['person', 'dimension'], name='verificatio_person__35ad93_idx'), models.Index(fields=['person', 'evidence_type'], name='verificatio_person__118f84_idx'), models.Index(fields=['status'], name='verificatio_status_f4f397_idx'), models.Index(fields=['verified_at'], name='verificatio_verifie_2c01a8_idx')], + }, + ), + ] diff --git a/apps/api/apps/verification/migrations/0002_seed_capabilities.py b/apps/api/apps/verification/migrations/0002_seed_capabilities.py new file mode 100644 index 0000000..d628624 --- /dev/null +++ b/apps/api/apps/verification/migrations/0002_seed_capabilities.py @@ -0,0 +1,85 @@ +from django.db import migrations + + +def seed_capabilities(apps, schema_editor): + CapabilityPolicy = apps.get_model("verification", "CapabilityPolicy") + + policies = [ + { + "key": "login", + "operation": "login", + "name": "User Login", + "description": "Basic login requires at least a password (basic trust)", + "min_trust_state": "basic", + "mfa_required": False, + }, + { + "key": "view_sensitive_data", + "operation": "view_sensitive_data", + "name": "View Sensitive Data", + "description": "Requires verified trust state with identity proofing", + "min_trust_state": "verified", + "mfa_required": False, + }, + { + "key": "modify_security_settings", + "operation": "modify_security_settings", + "name": "Modify Security Settings", + "description": "Requires strong trust state with MFA", + "min_trust_state": "verified", + "mfa_required": True, + }, + { + "key": "financial_operation", + "operation": "financial_operation", + "name": "Financial Operation", + "description": "Requires strong trust state with biometric MFA", + "min_trust_state": "strong", + "mfa_required": True, + }, + { + "key": "approve_identity_document", + "operation": "approve_identity_document", + "name": "Approve Identity Document", + "description": "Approving a government identity document requires a strong, " + "biometrically-verified trust state.", + "min_trust_state": "strong", + "mfa_required": True, + }, + ] + + for p in policies: + CapabilityPolicy.objects.get_or_create( + key=p["key"], + defaults={ + "operation": p["operation"], + "name": p["name"], + "description": p["description"], + "min_trust_state": p["min_trust_state"], + "mfa_required": p["mfa_required"], + }, + ) + + +def reverse_seed(apps, schema_editor): + CapabilityPolicy = apps.get_model("verification", "CapabilityPolicy") + CapabilityPolicy.objects.filter( + key__in=[ + "login", + "view_sensitive_data", + "modify_security_settings", + "financial_operation", + "approve_identity_document", + ] + ).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("verification", "0001_initial"), + ] + + operations = [ + migrations.RunPython(seed_capabilities, reverse_seed), + ] diff --git a/apps/api/apps/verification/migrations/__init__.py b/apps/api/apps/verification/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/apps/verification/models.py b/apps/api/apps/verification/models.py new file mode 100644 index 0000000..eae8c8e --- /dev/null +++ b/apps/api/apps/verification/models.py @@ -0,0 +1,178 @@ +from datetime import timedelta + +from django.db import models +from django.utils import timezone + +from apps.common.models import BaseModel + + +class VerificationDimension(models.TextChoices): + ACCOUNT = "account", "Account" + CONTACT = "contact", "Contact Verification" + IDENTITY = "identity", "Identity Verification" + BIOMETRIC = "biometric", "Biometric Verification" + ORGANIZATION = "organization", "Organization Verification" + + +class EvidenceType(models.TextChoices): + EMAIL_OTP = "email_otp", "Email OTP" + EMAIL_LINK = "email_link", "Email Link" + SMS_OTP = "sms_otp", "SMS OTP" + PHONE_CALL = "phone_call", "Phone Call" + NATIONAL_ID = "national_id", "National Identity" + PASSPORT = "passport", "Passport" + DRIVERS_LICENSE = "drivers_license", "Driver's License" + FACE_MATCH = "face_match", "Face Match" + FACE_LIVENESS = "face_liveness", "Face Liveness" + FINGERPRINT = "fingerprint", "Fingerprint" + ORG_MEMBERSHIP = "org_membership", "Organization Membership" + USER_ROLE = "user_role", "User Role" + PASSKEY = "passkey", "Passkey" + TOTP = "totp", "TOTP" + WEBAUTHN = "webauthn", "WebAuthn" + MANUAL_REVIEW = "manual_review", "Manual Review" + + +class EvidenceConfidence(models.TextChoices): + LOW = "low", "Low" + MEDIUM = "medium", "Medium" + HIGH = "high", "High" + + +class EvidenceStatus(models.TextChoices): + ACTIVE = "active", "Active" + EXPIRED = "expired", "Expired" + REVOKED = "revoked", "Revoked" + PENDING = "pending", "Pending" + + +class VerificationEvidence(BaseModel): + person = models.ForeignKey( + "identity.User", + on_delete=models.CASCADE, + related_name="verification_evidence", + ) + dimension = models.CharField( + max_length=32, + choices=VerificationDimension.choices, + ) + evidence_type = models.CharField( + max_length=32, + choices=EvidenceType.choices, + ) + method = models.CharField(max_length=64, blank=True, default="") + provider = models.CharField(max_length=64, blank=True, default="") + value_hash = models.CharField(max_length=128, blank=True, default="") + confidence = models.CharField( + max_length=16, + choices=EvidenceConfidence.choices, + default=EvidenceConfidence.HIGH, + ) + status = models.CharField( + max_length=16, + choices=EvidenceStatus.choices, + default=EvidenceStatus.ACTIVE, + ) + verified_at = models.DateTimeField() + expires_at = models.DateTimeField(null=True, blank=True) + revoked_at = models.DateTimeField(null=True, blank=True) + metadata = models.JSONField(default=dict, blank=True) + + class Meta: + ordering = ["-verified_at"] + indexes = [ + models.Index(fields=["person", "dimension"]), + models.Index(fields=["person", "evidence_type"]), + models.Index(fields=["status"]), + models.Index(fields=["verified_at"]), + ] + + def __str__(self): + return f"{self.person} — {self.dimension}:{self.evidence_type}" + + @property + def is_valid(self): + if self.status != EvidenceStatus.ACTIVE: + return False + if self.expires_at and self.expires_at <= timezone.now(): + return False + return True + + def revoke(self, reason="manual"): + self.status = EvidenceStatus.REVOKED + self.revoked_at = timezone.now() + self.save(update_fields=["status", "revoked_at", "updated_at"]) + + def decay_confidence(self): + """Reduce confidence over time based on age.""" + age = timezone.now() - self.verified_at + if age > timezone.timedelta(days=180): + self.confidence = EvidenceConfidence.MEDIUM + self.save(update_fields=["confidence", "updated_at"]) + elif age > timezone.timedelta(days=365): + self.confidence = EvidenceConfidence.LOW + self.save(update_fields=["confidence", "updated_at"]) + + +class TrustState(models.TextChoices): + BASIC = "basic", "Basic" + VERIFIED = "verified", "Verified" + STRONG = "strong", "Strong" + + +class CapabilityPolicy(models.Model): + """Defines evidence requirements for a capability/operation.""" + + operation = models.CharField(max_length=128, unique=True) + name = models.CharField(max_length=200) + description = models.TextField(blank=True, default="") + required_dimensions = models.JSONField(default=list, blank=True) + required_evidence_types = models.JSONField(default=list, blank=True) + min_trust_state = models.CharField( + max_length=16, + choices=TrustState.choices, + default=TrustState.BASIC, + ) + mfa_required = models.BooleanField(default=False) + stepup_required_if_below = models.CharField( + max_length=16, + choices=TrustState.choices, + default=TrustState.STRONG, + blank=True, + ) + key = models.CharField(max_length=128, unique=True, default="") + + class Meta: + ordering = ["operation"] + + def __str__(self): + return self.operation + + def is_satisfied_by(self, person): + """Check if a person satisfies this policy based on their evidence.""" + from apps.verification.services import TrustEngine + + evidence = TrustEngine.get_person_evidence(person) + trust_state = TrustEngine.compute_trust_state(person) + + valid_evidence = [e for e in evidence if e.is_valid] + + state_order = {ts[0]: i for i, ts in enumerate(TrustState.choices)} + if state_order.get(trust_state, 0) < state_order.get(self.min_trust_state, 0): + missing = [f"trust_state >= {self.min_trust_state}"] + return False, missing + + missing = [] + if self.required_dimensions: + person_dimensions = {e.dimension for e in valid_evidence} + for req_dim in self.required_dimensions: + if req_dim not in person_dimensions: + missing.append(req_dim) + + if self.required_evidence_types: + person_types = {e.evidence_type for e in valid_evidence} + for req_type in self.required_evidence_types: + if req_type not in person_types: + missing.append(req_type) + + return len(missing) == 0, missing diff --git a/apps/api/apps/verification/permissions.py b/apps/api/apps/verification/permissions.py new file mode 100644 index 0000000..2a67326 --- /dev/null +++ b/apps/api/apps/verification/permissions.py @@ -0,0 +1,37 @@ +from rest_framework.permissions import BasePermission + +from apps.verification.services import TrustEngine + + +class CapabilityPermission(BasePermission): + """Enforce a named CapabilityPolicy against the requesting user. + + Set ``capability_key`` on the view. The request is allowed only when the + authenticated user satisfies the configured policy (sufficient trust state + and required evidence). If the policy is missing or unsatisfied the request + is denied with HTTP 403 and a descriptive message. + + This turns the previously-cosmetic Trust/Verification engine into a real + authorization gate for sensitive operations. + """ + + capability_key = None + message = "You do not satisfy the required capability policy for this operation." + + def has_permission(self, request, view): + user = request.user + if not user or not getattr(user, "is_authenticated", False): + return False + if not self.capability_key: + return True + + satisfied, _policy, missing = TrustEngine.check_capability( + user, self.capability_key + ) + if not satisfied: + self.message = ( + f"Operation requires capability '{self.capability_key}'. " + f"Missing: {', '.join(missing) if missing else 'insufficient trust level'}" + ) + return False + return True diff --git a/apps/api/apps/verification/serializers.py b/apps/api/apps/verification/serializers.py new file mode 100644 index 0000000..2d577bb --- /dev/null +++ b/apps/api/apps/verification/serializers.py @@ -0,0 +1,89 @@ +from rest_framework import serializers + +from .models import ( + VerificationEvidence, + EvidenceStatus, + EvidenceConfidence, + EvidenceType, + VerificationDimension, + TrustState, + CapabilityPolicy, +) + + +class VerificationEvidenceSerializer(serializers.ModelSerializer): + person = serializers.SerializerMethodField(read_only=True) + + def get_person(self, obj): + return str(obj.person.id) if obj.person else None + + class Meta: + model = VerificationEvidence + fields = [ + "id", + "person", + "dimension", + "evidence_type", + "method", + "provider", + "value_hash", + "confidence", + "status", + "verified_at", + "expires_at", + "revoked_at", + "metadata", + "is_valid", + ] + read_only_fields = [ + "id", + "person", + "verified_at", + "revoked_at", + "is_valid", + ] + + +class RecordEvidenceSerializer(serializers.Serializer): + dimension = serializers.ChoiceField(choices=VerificationDimension.choices) + evidence_type = serializers.ChoiceField(choices=EvidenceType.choices) + method = serializers.CharField(max_length=64, required=False, default="") + provider = serializers.CharField(max_length=64, required=False, default="") + confidence = serializers.ChoiceField( + choices=EvidenceConfidence.choices, + default=EvidenceConfidence.HIGH, + ) + ttl_days = serializers.IntegerField(default=365) + evidence_data = serializers.JSONField(required=False, default=dict) + + +class TrustStateSerializer(serializers.Serializer): + trust_state = serializers.ChoiceField(choices=TrustState.choices) + trust_score = serializers.IntegerField() + evidence_count = serializers.IntegerField() + evidence = VerificationEvidenceSerializer(many=True) + + +class CapabilityPolicySerializer(serializers.ModelSerializer): + class Meta: + model = CapabilityPolicy + fields = [ + "id", + "key", + "operation", + "name", + "description", + "required_dimensions", + "required_evidence_types", + "min_trust_state", + "mfa_required", + "stepup_required_if_below", + ] + + +class CapabilityCheckSerializer(serializers.Serializer): + capability_key = serializers.CharField() + satisfied = serializers.BooleanField() + trust_state = serializers.ChoiceField(choices=TrustState.choices) + trust_score = serializers.IntegerField() + missing = serializers.ListField(child=serializers.CharField()) diff --git a/apps/api/apps/verification/services.py b/apps/api/apps/verification/services.py new file mode 100644 index 0000000..aa69611 --- /dev/null +++ b/apps/api/apps/verification/services.py @@ -0,0 +1,125 @@ +from django.utils import timezone +from datetime import timedelta +from .models import ( + VerificationEvidence, + EvidenceStatus, + EvidenceConfidence, + EvidenceType, + TrustState, + CapabilityPolicy, + VerificationDimension, +) + + +class TrustEngine: + """ + Computes trust state from verification evidence. + """ + + EMAIL_DIMENSION = VerificationDimension.CONTACT + PHONE_DIMENSION = VerificationDimension.CONTACT + MFA_DIMENSION = VerificationDimension.IDENTITY + + @staticmethod + def get_person_evidence(person, dimension=None): + """Return valid evidence for a person, optionally filtered by dimension.""" + qs = VerificationEvidence.objects.filter( + person=person, + status=EvidenceStatus.ACTIVE, + expires_at__gt=timezone.now(), + ) + if dimension: + qs = qs.filter(dimension=dimension) + return qs.order_by('-verified_at') + + @staticmethod + def get_valid_evidence(person, dimension=None, evidence_type=None): + """Return the most recent valid evidence matching criteria.""" + qs = TrustEngine.get_person_evidence(person, dimension=dimension) + if evidence_type: + qs = qs.filter(evidence_type=evidence_type) + return qs.first() + + @staticmethod + def compute_trust_state(person): + """ + Compute trust state based on evidence: + - strong: has identity verification + MFA (face_match OR fingerprint OR passkey) + phone or email OTP + - verified: has identity verification + one contact method + - basic: otherwise + """ + identity_evidence = TrustEngine.get_person_evidence( + person, dimension=VerificationDimension.IDENTITY + ) + contact_evidence = TrustEngine.get_person_evidence( + person, dimension=VerificationDimension.CONTACT + ) + biometric_evidence = TrustEngine.get_person_evidence( + person, dimension=VerificationDimension.BIOMETRIC + ) + + has_identity = identity_evidence.exists() + has_contact = contact_evidence.exists() + has_strong_auth = biometric_evidence.exists() or identity_evidence.filter( + evidence_type__in=[ + EvidenceType.PASSKEY, + EvidenceType.WEBAUTHN, + EvidenceType.TOTP, + ] + ).exists() + + if has_identity and has_strong_auth: + return TrustState.STRONG + elif has_identity and has_contact: + return TrustState.VERIFIED + return TrustState.BASIC + + @staticmethod + def get_trust_score(person): + """Return numeric trust score 0-100.""" + state = TrustEngine.compute_trust_state(person) + scores = { + TrustState.BASIC: 25, + TrustState.VERIFIED: 75, + TrustState.STRONG: 100, + } + return scores.get(state, 0) + + @staticmethod + def record_evidence( + person, + dimension, + evidence_type, + method, + provider, + confidence=EvidenceConfidence.MEDIUM, + ttl_days=365, + evidence_data=None, + ): + """Record a new piece of verification evidence.""" + now = timezone.now() + evidence = VerificationEvidence.objects.create( + person=person, + dimension=dimension, + evidence_type=evidence_type, + method=method, + provider=provider, + confidence=confidence, + status=EvidenceStatus.ACTIVE, + metadata=evidence_data or {}, + verified_at=now, + expires_at=now + timedelta(days=ttl_days), + ) + return evidence + + @staticmethod + def check_capability(person, capability_key): + """Check if person satisfies a named capability policy.""" + try: + policy = CapabilityPolicy.objects.get(key=capability_key) + except CapabilityPolicy.DoesNotExist: + return False, None, "Policy not found" + + evidence = TrustEngine.get_person_evidence(person) + satisfied, missing = policy.is_satisfied_by(person) + return satisfied, policy, missing diff --git a/apps/api/apps/verification/tests.py b/apps/api/apps/verification/tests.py new file mode 100644 index 0000000..8a077c6 --- /dev/null +++ b/apps/api/apps/verification/tests.py @@ -0,0 +1,281 @@ +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase +from django.contrib.auth import get_user_model + +from apps.verification.models import ( + VerificationEvidence, + EvidenceStatus, + EvidenceConfidence, + TrustState, + CapabilityPolicy, + VerificationDimension, + EvidenceType, +) +from apps.verification.services import TrustEngine + + +User = get_user_model() + + +class TrustEngineTests(APITestCase): + def setUp(self): + self.user = User.objects.create_user( + email="test@example.com", + password="TestPass123!", + full_name="Test User", + ) + + def test_basic_trust_state_no_evidence(self): + """A user with no evidence should have basic trust.""" + state = TrustEngine.compute_trust_state(self.user) + self.assertEqual(state, TrustState.BASIC) + + def test_trust_score_matches_state(self): + """Trust score should match the trust state.""" + state = TrustEngine.compute_trust_state(self.user) + score = TrustEngine.get_trust_score(self.user) + self.assertEqual(state, TrustState.BASIC) + self.assertEqual(score, 25) + + def test_verified_state_with_identity_and_contact(self): + """Identity verification + contact method gives verified trust.""" + TrustEngine.record_evidence( + self.user, + VerificationDimension.IDENTITY, + EvidenceType.NATIONAL_ID, + "manual_upload", + "gov_db", + EvidenceConfidence.HIGH, + ) + TrustEngine.record_evidence( + self.user, + VerificationDimension.CONTACT, + EvidenceType.EMAIL_OTP, + "otp", + "system", + EvidenceConfidence.HIGH, + ) + state = TrustEngine.compute_trust_state(self.user) + self.assertEqual(state, TrustState.VERIFIED) + score = TrustEngine.get_trust_score(self.user) + self.assertEqual(score, 75) + + def test_strong_state_with_biometric_mfa(self): + """Identity + MFA gives strong trust.""" + TrustEngine.record_evidence( + self.user, + VerificationDimension.IDENTITY, + EvidenceType.NATIONAL_ID, + "manual_upload", + "gov_db", + EvidenceConfidence.HIGH, + ) + TrustEngine.record_evidence( + self.user, + VerificationDimension.BIOMETRIC, + EvidenceType.FACE_MATCH, + "camera", + "system", + EvidenceConfidence.HIGH, + ) + state = TrustEngine.compute_trust_state(self.user) + self.assertEqual(state, TrustState.STRONG) + score = TrustEngine.get_trust_score(self.user) + self.assertEqual(score, 100) + + def test_evidence_revocation(self): + """Revoked evidence should not count as valid.""" + evidence = TrustEngine.record_evidence( + self.user, + VerificationDimension.IDENTITY, + EvidenceType.NATIONAL_ID, + "manual_upload", + "gov_db", + EvidenceConfidence.HIGH, + ) + evidence.revoke() + self.assertFalse(evidence.is_valid) + state = TrustEngine.compute_trust_state(self.user) + self.assertEqual(state, TrustState.BASIC) + + def test_expired_evidence_invalid(self): + """Expired evidence should not be valid.""" + from datetime import timedelta + from django.utils import timezone + + evidence = TrustEngine.record_evidence( + self.user, + VerificationDimension.IDENTITY, + EvidenceType.NATIONAL_ID, + "manual_upload", + "gov_db", + EvidenceConfidence.HIGH, + ttl_days=365, + ) + evidence.expires_at = timezone.now() - timedelta(days=1) + evidence.save() + self.assertFalse(evidence.is_valid) + + def test_get_person_evidence_filters_by_dimension(self): + """get_person_evidence should filter by dimension.""" + TrustEngine.record_evidence( + self.user, + VerificationDimension.IDENTITY, + EvidenceType.NATIONAL_ID, + "id_check", + "gov", + EvidenceConfidence.HIGH, + ) + TrustEngine.record_evidence( + self.user, + VerificationDimension.CONTACT, + EvidenceType.EMAIL_OTP, + "email", + "system", + EvidenceConfidence.MEDIUM, + ) + identity_ev = TrustEngine.get_person_evidence( + self.user, dimension=VerificationDimension.IDENTITY + ) + self.assertEqual(identity_ev.count(), 1) + contact_ev = TrustEngine.get_person_evidence( + self.user, dimension=VerificationDimension.CONTACT + ) + self.assertEqual(contact_ev.count(), 1) + + +class CapabilityPolicyTests(APITestCase): + def setUp(self): + self.user = User.objects.create_user( + email="test@example.com", + password="TestPass123!", + full_name="Test User", + ) + self.policy = CapabilityPolicy.objects.create( + key="test_capability", + operation="test_op", + name="Test", + min_trust_state=TrustState.VERIFIED, + ) + + def test_policy_not_satisfied_without_evidence(self): + """Policy requiring verified state should not be satisfied without evidence.""" + satisfied, missing = self.policy.is_satisfied_by(self.user) + self.assertFalse(satisfied) + + def test_policy_satisfied_with_evidence(self): + """Policy should be satisfied when evidence meets minimum trust state.""" + TrustEngine.record_evidence( + self.user, + VerificationDimension.IDENTITY, + EvidenceType.NATIONAL_ID, + "manual", + "gov", + EvidenceConfidence.HIGH, + ) + TrustEngine.record_evidence( + self.user, + VerificationDimension.CONTACT, + EvidenceType.EMAIL_OTP, + "email", + "system", + EvidenceConfidence.HIGH, + ) + satisfied, missing = self.policy.is_satisfied_by(self.user) + self.assertTrue(satisfied) + self.assertEqual(len(missing), 0) + + +class VerificationAPITests(APITestCase): + def setUp(self): + self.user = User.objects.create_user( + email="test@example.com", + password="TestPass123!", + full_name="Test User", + ) + self.client.force_authenticate(user=self.user) + + def test_trust_state_endpoint(self): + """Trust state endpoint should return state, score, and evidence count.""" + TrustEngine.record_evidence( + self.user, + VerificationDimension.IDENTITY, + EvidenceType.NATIONAL_ID, + "manual_upload", + "gov_db", + EvidenceConfidence.HIGH, + ) + TrustEngine.record_evidence( + self.user, + VerificationDimension.CONTACT, + EvidenceType.EMAIL_OTP, + "email", + "system", + EvidenceConfidence.HIGH, + ) + url = reverse("trust-me") + res = self.client.get(url) + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertIn("trust_state", data) + self.assertIn("trust_score", data) + self.assertIn("evidence", data) + self.assertEqual(data["trust_state"], TrustState.VERIFIED) + + def test_record_evidence_endpoint(self): + """Record evidence endpoint should create evidence.""" + url = "/api/v1/verification/evidence/record/" + res = self.client.post( + url, + { + "dimension": VerificationDimension.IDENTITY, + "evidence_type": EvidenceType.NATIONAL_ID, + "method": "manual_upload", + "provider": "gov_db", + "confidence": EvidenceConfidence.HIGH, + }, + ) + self.assertEqual(res.status_code, 201) + self.assertTrue( + VerificationEvidence.objects.filter( + person=self.user, + evidence_type=EvidenceType.NATIONAL_ID, + ).exists() + ) + + def test_capability_check_endpoint(self): + """Capability check endpoint should return satisfied status.""" + url = "/api/v1/verification/trust/check/" + res = self.client.post( + url, + {"capability_key": "login"}, + ) + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertIn("satisfied", data) + self.assertTrue(data["satisfied"]) + + def test_evidence_list_endpoint(self): + """Evidence list endpoint should return user's evidence.""" + TrustEngine.record_evidence( + self.user, + VerificationDimension.IDENTITY, + EvidenceType.NATIONAL_ID, + "manual", + "gov", + EvidenceConfidence.HIGH, + ) + url = "/api/v1/verification/evidence/" + res = self.client.get(url) + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertEqual(data["count"], 1) + + def test_policies_endpoint(self): + """Policies list endpoint should return policies.""" + url = "/api/v1/verification/trust/policies/" + res = self.client.get(url) + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertGreaterEqual(data["count"], 1) diff --git a/apps/api/apps/verification/urls.py b/apps/api/apps/verification/urls.py new file mode 100644 index 0000000..b39edf3 --- /dev/null +++ b/apps/api/apps/verification/urls.py @@ -0,0 +1,12 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from .views import VerificationEvidenceViewSet, TrustStateViewSet + +router = DefaultRouter() +router.register(r"evidence", VerificationEvidenceViewSet, basename="evidence") +router.register(r"trust", TrustStateViewSet, basename="trust") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/apps/api/apps/verification/views.py b/apps/api/apps/verification/views.py new file mode 100644 index 0000000..b668734 --- /dev/null +++ b/apps/api/apps/verification/views.py @@ -0,0 +1,94 @@ +from rest_framework import viewsets, status +from rest_framework.decorators import action, api_view, permission_classes +from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.response import Response + +from .models import ( + VerificationEvidence, + EvidenceStatus, + TrustState, + CapabilityPolicy, +) +from .serializers import ( + VerificationEvidenceSerializer, + RecordEvidenceSerializer, + TrustStateSerializer, + CapabilityPolicySerializer, + CapabilityCheckSerializer, +) +from .services import TrustEngine + + +class VerificationEvidenceViewSet(viewsets.ReadOnlyModelViewSet): + serializer_class = VerificationEvidenceSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return VerificationEvidence.objects.filter(person=self.request.user) + + @action(detail=False, methods=["post"], url_path="record") + def record(self, request): + serializer = RecordEvidenceSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + evidence = TrustEngine.record_evidence( + person=request.user, + dimension=serializer.validated_data["dimension"], + evidence_type=serializer.validated_data["evidence_type"], + method=serializer.validated_data.get("method", ""), + provider=serializer.validated_data.get("provider", ""), + confidence=serializer.validated_data.get("confidence", "high"), + ttl_days=serializer.validated_data.get("ttl_days", 365), + evidence_data=serializer.validated_data.get("evidence_data", {}), + ) + return Response(VerificationEvidenceSerializer(evidence).data, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=["post"], url_path="revoke") + def revoke(self, request, pk=None): + evidence = self.get_object() + evidence.revoke() + return Response({"status": "revoked"}) + + +class TrustStateViewSet(viewsets.ViewSet): + permission_classes = [IsAuthenticated] + + @action(detail=False, methods=["get"], url_path="me") + def me(self, request): + person = request.user + evidence = TrustEngine.get_person_evidence(person) + state = TrustEngine.compute_trust_state(person) + score = TrustEngine.get_trust_score(person) + return Response( + { + "trust_state": state, + "trust_score": score, + "evidence_count": evidence.count(), + "evidence": VerificationEvidenceSerializer(evidence, many=True).data, + } + ) + + @action(detail=False, methods=["get"], url_path="policies") + def policies(self, request): + policies = CapabilityPolicy.objects.all() + data = CapabilityPolicySerializer(policies, many=True).data + return Response({"count": len(data), "results": data}) + + @action(detail=False, methods=["post"], url_path="check") + def check(self, request): + capability_key = request.data.get("capability_key") + person = request.user + + satisfied, policy, missing = TrustEngine.check_capability(person, capability_key) + state = TrustEngine.compute_trust_state(person) + score = TrustEngine.get_trust_score(person) + + serializer = CapabilityCheckSerializer( + { + "capability_key": capability_key, + "satisfied": satisfied, + "trust_state": state, + "trust_score": score, + "missing": missing if not satisfied else [], + } + ) + return Response(serializer.data) diff --git a/apps/api/config/__init__.py b/apps/api/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/config/asgi.py b/apps/api/config/asgi.py new file mode 100644 index 0000000..856079b --- /dev/null +++ b/apps/api/config/asgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_asgi_application() diff --git a/apps/api/config/settings.py b/apps/api/config/settings.py new file mode 100644 index 0000000..c3bfff0 --- /dev/null +++ b/apps/api/config/settings.py @@ -0,0 +1,247 @@ +import os +from datetime import timedelta +from pathlib import Path + +import dj_database_url +from django.core.exceptions import ImproperlyConfigured +from dotenv import load_dotenv + +BASE_DIR = Path(__file__).resolve().parent.parent + +load_dotenv(BASE_DIR / ".env") + +JWT_PRIVATE_KEY_PATH = BASE_DIR / "keys" / "rsa_private.pem" +JWT_PUBLIC_KEY_PATH = BASE_DIR / "keys" / "rsa_public.pem" +JWT_PRIVATE_KEY = "" +JWT_PUBLIC_KEY = "" +if JWT_PRIVATE_KEY_PATH.exists(): + JWT_PRIVATE_KEY = JWT_PRIVATE_KEY_PATH.read_text() +if JWT_PUBLIC_KEY_PATH.exists(): + JWT_PUBLIC_KEY = JWT_PUBLIC_KEY_PATH.read_text() + + +def env(name, default=None): + return os.getenv(name, default) + + +def env_bool(name, default=False): + return os.getenv(name, str(default)).lower() in ("1", "true", "yes", "on") + + +def env_list(name, default=""): + return [item.strip() for item in env(name, default).split(",") if item.strip()] + + +DJANGO_ENV = env("DJANGO_ENV", "development") +DEBUG = env_bool("DJANGO_DEBUG", DJANGO_ENV == "development") + +SECRET_KEY = env("DJANGO_SECRET_KEY", "dev-only-insecure-secret-key") +if DJANGO_ENV == "production" and SECRET_KEY == "dev-only-insecure-secret-key": + raise ImproperlyConfigured("DJANGO_SECRET_KEY must be set in production.") + +JWT_SIGNING_KEY = env("JWT_SIGNING_KEY", "dev-only-insecure-secret-key-32b-min") +if ( + DJANGO_ENV == "production" + and JWT_SIGNING_KEY == "dev-only-insecure-secret-key-32b-min" +): + raise ImproperlyConfigured("JWT_SIGNING_KEY must be set in production.") + +ALLOWED_HOSTS = env_list( + "DJANGO_ALLOWED_HOSTS", + "localhost,127.0.0.1,0.0.0.0,backend,frontend", +) +INTERNAL_IPS = ["127.0.0.1", "0.0.0.0"] + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "drf_spectacular", + "django_filters", + "corsheaders", + "apps.common", + "apps.identity", + "apps.oauth", + "apps.organization", + "apps.membership", + "apps.session", + "apps.security", + "apps.application", + "apps.authentication", + "apps.product", + "apps.profile", + "apps.access", + "apps.token_blacklist", + "apps.verification", + "apps.sso", +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" +ASGI_APPLICATION = "config.asgi.application" + +DATABASES = { + "default": dj_database_url.config( + default=env( + "DATABASE_URL", + "postgres://identity:identity@localhost:5432/identity", + ), + conn_max_age=600, + ) +} + +AUTH_USER_MODEL = "identity.User" + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + "OPTIONS": {"min_length": 10}, + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +LANGUAGE_CODE = "en" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True +LOCALE_PATHS = [BASE_DIR / "locale"] + +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" + +STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage" + }, +} + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +REDIS_URL = env("REDIS_URL") +if REDIS_URL: + CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, + } + } +else: + CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "identity-platform", + } + } + +CORS_ALLOWED_ORIGINS = env_list( + "CORS_ALLOWED_ORIGINS", + "http://localhost:3000,http://127.0.0.1:3000", +) +CORS_ALLOW_CREDENTIALS = True +CSRF_TRUSTED_ORIGINS = env_list("CSRF_TRUSTED_ORIGINS", "http://localhost:3000") + +# Central SSO session cookie domain. For Google-style cross-subdomain SSO the +# IdP session cookie must be readable by every hamsoo subdomain, so in +# production this is set to ".hamsoo.me". In development (localhost) it must be +# left empty so the cookie is host-only. +SSO_COOKIE_DOMAIN = env("SSO_COOKIE_DOMAIN", "") +if SSO_COOKIE_DOMAIN: + SESSION_COOKIE_DOMAIN = SSO_COOKIE_DOMAIN + CSRF_COOKIE_DOMAIN = SSO_COOKIE_DOMAIN + SESSION_COOKIE_NAME = env("SESSION_COOKIE_NAME", "hamsoo_sso_session") + CSRF_COOKIE_NAME = env("CSRF_COOKIE_NAME", "hamsoo_sso_csrftoken") + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + SESSION_COOKIE_SAMESITE = "Lax" + CSRF_COOKIE_SAMESITE = "Lax" + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "apps.authentication.auth.JwtBlacklistAuthentication", + "rest_framework.authentication.SessionAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], + "DEFAULT_FILTER_BACKENDS": [ + "django_filters.rest_framework.DjangoFilterBackend", + "rest_framework.filters.SearchFilter", + "rest_framework.filters.OrderingFilter", + ], + "DEFAULT_PAGINATION_CLASS": "apps.common.pagination.DefaultPagination", + "PAGE_SIZE": 20, + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "EXCEPTION_HANDLER": "apps.common.exceptions.api_exception_handler", +} + +# OIDC issuer used in the `iss` claim of id_tokens. In production this must +# match the public base URL products use to reach the platform (and the value +# advertised in /.well-known/openid-configuration). +OIDC_ISSUER = env("OIDC_ISSUER", "http://localhost:8000") + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=15), + "REFRESH_TOKEN_LIFETIME": timedelta(days=30), + "AUTH_HEADER_TYPES": ("Bearer",), + "USER_ID_CLAIM": "user_id", + "UPDATE_LAST_LOGIN": False, + "ALGORITHM": "RS256", + "SIGNING_KEY": JWT_PRIVATE_KEY or JWT_SIGNING_KEY, + "VERIFYING_KEY": JWT_PUBLIC_KEY, + "TOKEN_TYPE_CLAIM": "typ", +} + +SPECTACULAR_SETTINGS = { + "TITLE": "Identity Platform API", + "DESCRIPTION": ( + "Central identity, authentication and authorization infrastructure " + "for the ecosystem. One Identity. Every Product." + ), + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "SERVE_PERMISSIONS": ["rest_framework.permissions.AllowAny"], + "COMPONENT_SPLIT_REQUEST": True, +} diff --git a/apps/api/config/urls.py b/apps/api/config/urls.py new file mode 100644 index 0000000..0b7363b --- /dev/null +++ b/apps/api/config/urls.py @@ -0,0 +1,40 @@ +from django.contrib import admin +from django.urls import include, path + +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView + +api_v1_patterns = [ + path("identity/", include("apps.identity.urls")), + path("auth/", include("apps.authentication.urls")), + path("users/", include("apps.identity.user_urls")), + path("organizations/", include("apps.organization.urls")), + path("products/", include("apps.product.urls")), + path("profiles/", include("apps.profile.urls")), + path("access/", include("apps.access.urls")), + path("applications/", include("apps.application.urls")), + path("sessions/", include("apps.session.urls")), + path("security/", include("apps.security.urls")), + path("verification/", include("apps.verification.urls")), + path("membership/", include("apps.membership.urls")), + path("health/", include("apps.common.urls")), +] + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api/schema/", SpectacularAPIView.as_view(), name="schema"), + path( + "api/docs/", + SpectacularSwaggerView.as_view(url_name="schema"), + name="api-docs", + ), + path("oauth/", include("apps.oauth.urls")), + path("sso/", include("apps.sso.urls")), + path(".well-known/openid-configuration", include("apps.oauth.discovery_urls")), + path("api/v1/tokens/", include("apps.token_blacklist.urls")), + path("api/v1/", include(api_v1_patterns)), + path("v1/", include(api_v1_patterns)), +] + +admin.site.site_header = "Identity Platform Admin" +admin.site.site_title = "Identity Platform" +admin.site.index_title = "Identity Platform Administration" diff --git a/apps/api/config/wsgi.py b/apps/api/config/wsgi.py new file mode 100644 index 0000000..8509335 --- /dev/null +++ b/apps/api/config/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_wsgi_application() diff --git a/apps/api/dev.db b/apps/api/dev.db new file mode 100644 index 0000000..20732b4 Binary files /dev/null and b/apps/api/dev.db differ diff --git a/apps/api/e2e_check.py b/apps/api/e2e_check.py new file mode 100644 index 0000000..cedb528 --- /dev/null +++ b/apps/api/e2e_check.py @@ -0,0 +1,129 @@ +import os, django + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") +django.setup() + +import urllib.request, urllib.parse, urllib.error, json, base64, time +from apps.identity.models import User +from apps.application.models import Application, ApplicationStatus +from apps.oauth.models import OAuthScope +from apps.common.utils import generate_client_secret, hash_token + +user, _ = User.objects.get_or_create( + email="e2e%d@example.com" % int(time.time()), defaults={"status": "active"} +) +user.set_password("S3cure-Pass-123") +user.status = "active" +user.email_verified = True +user.save() + +sc, _ = OAuthScope.objects.get_or_create(code="openid", defaults={"is_system": True}) +sp, _ = OAuthScope.objects.get_or_create(code="profile") +se, _ = OAuthScope.objects.get_or_create(code="email") +sph, _ = OAuthScope.objects.get_or_create(code="phone") + +secret = generate_client_secret() +app, _ = Application.objects.get_or_create( + product_key="e2e", + defaults={ + "name": "E2E", + "client_secret_hash": hash_token(secret), + "redirect_uris": ["https://e2e.com/cb"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "status": ApplicationStatus.ACTIVE, + }, +) +app.client_secret_hash = hash_token(secret) +app.scopes.set([sc, sp, se, sph]) +app.save() + + +def post(path, data, headers=None): + req = urllib.request.Request( + "http://localhost:8000" + path, + data=urllib.parse.urlencode(data).encode(), + headers=headers or {}, + ) + try: + r = urllib.request.urlopen(req, timeout=10) + return r.status, r.read().decode() + except urllib.error.HTTPError as e: + return e.code, e.read().decode() + + +st, body = post( + "/api/v1/auth/login/", {"email": user.email, "password": "S3cure-Pass-123"} +) +print("login ->", st, body[:300]) +tok = json.loads(body).get("access") +if not tok: + print("LOGIN BODY NO ACCESS:", body) + raise SystemExit(1) +auth_hdr = {"Authorization": f"Bearer {tok}"} + +q = urllib.parse.urlencode( + { + "client_id": app.client_id, + "response_type": "code", + "redirect_uri": "https://e2e.com/cb", + "scope": "openid profile email phone", + "state": "x", + } +) + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +opener = urllib.request.build_opener(NoRedirect) +req = urllib.request.Request( + "http://localhost:8000/oauth/authorize?" + q, headers=auth_hdr +) +try: + r = opener.open(req, timeout=10) + loc = r.headers.get("Location") +except urllib.error.HTTPError as e: + print("authorize HTTPError", e.code, e.read().decode()[:300]) + loc = e.headers.get("Location") +print("authorize ->", loc) +code = urllib.parse.parse_qs(urllib.parse.urlparse(loc).query).get("code") +if not code: + print("NO CODE IN LOCATION; body of error if any above") + raise SystemExit(1) +code = code[0] + +st2, body2 = post( + "/oauth/token", + { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "https://e2e.com/cb", + "client_id": app.client_id, + "client_secret": secret, + }, +) +print("token ->", st2, body2[:300]) +data = json.loads(body2) if body2 else {} +print("has id_token:", "id_token" in data) +_, p, _ = data["id_token"].split(".") +p += "=" * (-len(p) % 4) +claims = json.loads(base64.urlsafe_b64decode(p)) +print( + "id_token claims: sub=%s email=%s iss=%s aud=%s" + % (claims.get("sub"), claims.get("email"), claims.get("iss"), claims.get("aud")) +) + +# userinfo with the OAuth access token (carries openid profile email phone) +oauth_hdr = {"Authorization": f"Bearer {data['access_token']}"} +req2 = urllib.request.Request("http://localhost:8000/oauth/userinfo", headers=oauth_hdr) +r2 = urllib.request.urlopen(req2, timeout=10) +claims2 = json.loads(r2.read()) +print( + "userinfo ->", + r2.status, + "email=%s" % claims2.get("email"), + "name=%s" % claims2.get("name"), +) diff --git a/apps/api/keys/rsa_private.pem b/apps/api/keys/rsa_private.pem new file mode 100644 index 0000000..19c5ea4 --- /dev/null +++ b/apps/api/keys/rsa_private.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCN1KFqe0ubiV/2 +ShQRY5+lAnfy5C1xJkWncKebFfkFJM/H9We570jlwV5g9BNt5eYpM+2v4PVZOjdQ +zJHECUHkO2vUSMTHs1OpRWN3PL2KZggee1Kzw0WMSjfcG3xx6Sf0FRNfJxX6SdB5 +xxBUvYb3dbpRvj2UGC3ATYBpw05ynSvFLKX9O7Y9gFdNYyVW9Rf4GF4DTYn9F1Am +QmbUSkfz6l3MCPpY7QopzCr+k46DryMEHIdX3Ulcx1jOoaWm+bER7y9BbQ8PFb7w +a2x60Xwa4E2TLP7BgVkgvXLDCVMosRCPb00i0s3GlOdndkmYJUeE1lm+vQO/bLAW +RsiK1QmRAgMBAAECggEAIpcinPUgDfl1mXwco9cPtu9AsNDcklV6tGkBv42e05XU +RRjBaPQGa956tZuhZ3Kj7RWYmQX84HuVxRN3U3/MfazOUhJDR88hDs35AbojIe9b +eI+sLmJoAlyRfhGICsIJ9/nx5QmDzyyUdzbI8Vnd4llojQogO4+gDN/5+xFifwof +9tz1OkAdtLprrgRjmelgE/j3dIKYn5tZiMp2+ZOZEFszIU5lNYrhR1VIpQ2/v4Ej +XKqULf41HweQgV0euHD/n1Fw8WuEoKkU4Lp1T0FewF5bHnzMManYhxIVT7STN8j/ +Xlj+Q74es6Nuhl6YpLTKAXA9yHyN0wFUHivpjURjyQKBgQDH6IOa8nqT3wFbHE4m +F0gbEaRYFlmi1EBoomE/FDMXzoFbIgZ3H7x6KlArQnhtTvFiy1OsBkYaTEHXQ9nX +KGr1QcVilwfoGeNxrLuIK1e3pzrPyExUAvcRORxk4bVa4W2zEor2NhxBo3wTmM/7 +RejalmVU5S/MYv/+AGBGHQ5n3QKBgQC1oGAaOvd2hbwGihWvEr/xkB2PI7RSwVdW +quND5AXAeMGElPzbx5ttdHfPnaLhgRgBjmr4aVxiqG9pIlOnSv8zWvgrCYKEiAxw +Kt01wELWsNPsZ7JYI8WcatOeyPXwJvhr46HSN03IxYCYhGtNBu1tvJHyAr+r2zFg +YqKUccwHRQKBgGUggVrj6RBe0q/FfN8WDfrrjMim3cdaOg70fd9MF6CmbZetebnP +Syg9uXp40LTzJ3dDxlsSfWoWQ4RjJZMLNjhFglWic3R9jCpYKDH1QxV7umucNsiV +C2kiC/QYngaQXU8mRTfSHa8yxbSgLC4/qlDRngc5PVnWhwt2Iz20uzHdAoGBAIFQ +dzwVwb00SIQLapbk7Z6K8lDIpgnJuGpvbzIWNnYsQ/Qms8WzX5lVtDwwyxhtdm8d +PFIzieCAdhpPo2nX/s1MtqbFtZSw3NI74pXzlmMPMUP/LL6OcZMFiDhkcp6S0IrY +Xo2ybIJHBGES3ubPyNo5yVua02cDwCsU7xZr001VAoGAVxqLT9vwuA40LgzJTQ0o +4y7pPfIgrHgLmtSlemK8t7hDocs/K6Vvax01VGYEUaCQCQhSNH0s9VmidRxbuquV +eyTbrVTmq4q8DTl0MtXRazl3Sa9bS2kMu6eI5pCR0W1eWNVGbJwK9eohDdKmXHCc +rUBF+eANIVmL/IQzPiIA4Is= +-----END PRIVATE KEY----- diff --git a/apps/api/keys/rsa_public.pem b/apps/api/keys/rsa_public.pem new file mode 100644 index 0000000..396a434 --- /dev/null +++ b/apps/api/keys/rsa_public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjdShantLm4lf9koUEWOf +pQJ38uQtcSZFp3CnmxX5BSTPx/Vnue9I5cFeYPQTbeXmKTPtr+D1WTo3UMyRxAlB +5Dtr1EjEx7NTqUVjdzy9imYIHntSs8NFjEo33Bt8cekn9BUTXycV+knQeccQVL2G +93W6Ub49lBgtwE2AacNOcp0rxSyl/Tu2PYBXTWMlVvUX+BheA02J/RdQJkJm1EpH +8+pdzAj6WO0KKcwq/pOOg68jBByHV91JXMdYzqGlpvmxEe8vQW0PDxW+8GtsetF8 +GuBNkyz+wYFZIL1ywwlTKLEQj29NItLNxpTnZ3ZJmCVHhNZZvr0Dv2ywFkbIitUJ +kQIDAQAB +-----END PUBLIC KEY----- diff --git a/apps/api/manage.py b/apps/api/manage.py new file mode 100644 index 0000000..729cb24 --- /dev/null +++ b/apps/api/manage.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python +import os +import sys + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/apps/api/requirements.txt b/apps/api/requirements.txt new file mode 100644 index 0000000..9c9c31f --- /dev/null +++ b/apps/api/requirements.txt @@ -0,0 +1,17 @@ +Django>=5.2,<5.3 +djangorestframework>=3.16,<3.17 +drf-spectacular>=0.28,<0.29 +djangorestframework-simplejwt>=5.4,<6.0 +django-filter>=25.1,<26.0 +django-cors-headers>=4.7,<5.0 +django-redis>=5.4,<6.0 +dj-database-url>=2.3,<3.0 +psycopg[binary]>=3.2,<4.0 +python-dotenv>=1.0,<2.0 +whitenoise>=6.9,<7.0 +gunicorn>=23.0,<24.0 +pyotp>=2.10,<3.0 +fido2>=2.0,<3.0 +cbor2>=5.6,<6.0 +qrcode[pil]>=7.4.2,<8.0 +cryptography>=44.0,<45.0 diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..0db19d1 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,4 @@ +# Public site configuration (client-side, NEXT_PUBLIC_ prefix required) +NEXT_PUBLIC_SITE_NAME=Identity Platform +NEXT_PUBLIC_SITE_URL=http://localhost:3000 +NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1 diff --git a/apps/web/.eslintrc.json b/apps/web/.eslintrc.json new file mode 100644 index 0000000..5c3dc16 --- /dev/null +++ b/apps/web/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": ["next/core-web-vitals"], + "rules": { + "react/react-in-jsx-scope": "off" + } +} \ No newline at end of file diff --git a/apps/web/app/account/page.tsx b/apps/web/app/account/page.tsx new file mode 100644 index 0000000..4ccef63 --- /dev/null +++ b/apps/web/app/account/page.tsx @@ -0,0 +1,703 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Users, Shield, Building2, Settings, LogOut, User, Key, KeyRound, Smartphone, Calendar, MapPin, Image as ImageIcon, Briefcase, Mail, ChevronLeft, ChevronRight } from "lucide-react"; +import { useLanguage } from "@/components/language-provider"; +import { useAuth } from "@/components/auth/auth-provider"; +import { RouteGuard } from "@/components/auth/route-guard"; +import { getAccessToken, apiFetch } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { registerPasskey } from "@/lib/webauthn"; + +interface UserAccount { + user_id: string; + email: string; + username?: string | null; + full_name: string | null; + given_name?: string | null; + family_name?: string | null; + trust_state?: string | null; + trust_score?: number | null; + skills?: string[]; + birth_date?: string | null; + province?: string | null; + city?: string | null; + gender?: string | null; + avatar_url?: string | null; + status: string; + is_active: boolean; + mfa_enabled: boolean; + email_verified: boolean; + phone_verified: boolean; + phone: string | null; + created_at: string; + last_login_at: string | null; + passkeys?: any[]; + sessions?: any[]; +} + +const SECTIONS = [ + { + key: "identity", + labelFa: "هویت", + labelEn: "Identity", + icon: Users, + descFa: "اطلاعات هویتی و پروفایل عمومی حساب", + descEn: "Identity info and public profile", + }, + { + key: "auth", + labelFa: "احراز هویت", + labelEn: "Authentication", + icon: Shield, + descFa: "اطلاعات ورود، دستگاه‌ها و امنیت حسابتان را مدیریت کنید", + descEn: "Manage sign-in methods, devices and security", + }, + { + key: "businesses", + labelFa: "کسب‌وکارها", + labelEn: "Businesses", + icon: Building2, + descFa: "سازمان‌ها و کسب‌وکارهای متصل به حساب شما", + descEn: "Organizations connected to your account", + }, + { + key: "bizManage", + labelFa: "مدیریت کسب‌وکار", + labelEn: "Business Management", + icon: Settings, + descFa: "ساخت، ویرایش و انتقال مالکیت کسب‌وکارها", + descEn: "Create, edit and transfer businesses", + }, +]; + +export default function AccountCenterPage() { + const { lang } = useLanguage(); + const t = (fa: string, en: string) => (lang === "fa" ? fa : en); + const Chev = lang === "fa" ? ChevronLeft : ChevronRight; + + const { user: ctxUser, logout } = useAuth(); + const [mounted, setMounted] = useState(false); + const token = getAccessToken(); + const [activeTab, setActiveTab] = useState("identity"); + const [loading, setLoading] = useState(false); + const [user, setUser] = useState(null); + const [orgs, setOrgs] = useState([]); + const [sessions, setSessions] = useState([]); + const [products, setProducts] = useState([]); + const [pkBusy, setPkBusy] = useState(false); + const [pkError, setPkError] = useState(""); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + const [form, setForm] = useState>({}); + + const startEdit = () => { + setForm({ + full_name: user?.full_name || "", + given_name: user?.given_name || "", + family_name: user?.family_name || "", + username: user?.username || "", + province: user?.province || "", + city: user?.city || "", + gender: user?.gender || "", + birth_date: user?.birth_date || "", + skills: (user?.skills || []).join(", "), + }); + setEditing(true); + }; + + const cancelEdit = () => setEditing(false); + + const saveEdit = async () => { + setSaving(true); + try { + const payload: any = { ...form }; + payload.skills = form.skills + ? form.skills + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean) + : []; + const res = await apiFetch("/auth/me/", { + method: "PATCH", + body: JSON.stringify(payload), + }); + if (res.ok) { + setUser(await res.json()); + setEditing(false); + } + } finally { + setSaving(false); + } + }; + + useEffect(() => { + setMounted(true); + }, []); + + // Keep the active tab in sync with the ?tab= query param so the URL reflects + // the selected sidebar section and supports back/forward navigation. + useEffect(() => { + const valid = new Set(SECTIONS.map((s) => s.key)); + const syncFromUrl = () => { + const tab = new URLSearchParams(window.location.search).get("tab"); + if (tab && valid.has(tab)) setActiveTab(tab); + }; + syncFromUrl(); + window.addEventListener("popstate", syncFromUrl); + return () => window.removeEventListener("popstate", syncFromUrl); + }, []); + + useEffect(() => { + if (ctxUser && !user) setUser(ctxUser as unknown as UserAccount); + }, [ctxUser, user]); + + useEffect(() => { + const fetchData = async () => { + if (!token) return; + setLoading(true); + try { + const userRes = await apiFetch("/auth/me/"); + if (userRes.ok) setUser(await userRes.json()); + const orgRes = await apiFetch("/organizations/"); + if (orgRes.ok) { + const d = await orgRes.json(); + setOrgs((d as any).results || d || []); + } + const sessRes = await apiFetch("/sessions/"); + if (sessRes.ok) { + const d = await sessRes.json(); + setSessions(Array.isArray(d) ? d : d.results || []); + } + const prodRes = await apiFetch("/products/"); + if (prodRes.ok) { + const d = await prodRes.json(); + setProducts(Array.isArray(d) ? d : d.results || []); + } + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + fetchData(); + }, [token]); + + const handleLogout = async () => { + await logout(); + window.location.href = `/login?next=${encodeURIComponent(window.location.pathname)}`; + }; + + const handleAddPasskey = async () => { + if (!token) return; + setPkBusy(true); + setPkError(""); + try { + await registerPasskey(token, "My Passkey"); + const userRes = await apiFetch("/auth/me/"); + if (userRes.ok) setUser(await userRes.json()); + } catch (e: any) { + setPkError(e?.message || t("ثبت پاسکلی شکست خورد", "Passkey registration failed")); + } finally { + setPkBusy(false); + } + }; + + if (!mounted) return
; + + const current = SECTIONS.find((s) => s.key === activeTab); + + return ( + +
+ + +
+
+

{current ? t(current.labelFa, current.labelEn) : ""}

+

{current ? t(current.descFa, current.descEn) : ""}

+
+ + {activeTab === "identity" && ( +
+ {/* Profile summary card */} + + {user?.avatar_url ? ( + + ) : ( + + + + )} +
{user?.full_name || "-"}
+
{user?.email || ""}
+ {user?.status === "active" && ( + + + {t("فعال", "Active")} + + )} +
+ {typeof user?.mfa_enabled === "boolean" && ( + + + {user.mfa_enabled + ? t("احراز دومرحله‌ای فعال", "MFA enabled") + : t("احراز دومرحله‌ای غیرفعال", "MFA disabled")} + + )} + {user?.trust_state && ( + + {t("سطح اعتماد", "Trust")}: {user.trust_state} + {typeof user.trust_score === "number" ? ` (${user.trust_score})` : ""} + + )} +
+ +
+ + {/* Personal info card */} + +
+

{t("اطلاعات شخصی", "Personal information")}

+ {editing ? ( +
+ + +
+ ) : ( + + )} +
+ {loading ? ( + + ) : editing ? ( +
+ setForm({ ...form, full_name: v })} /> + setForm({ ...form, given_name: v })} /> + setForm({ ...form, family_name: v })} /> + setForm({ ...form, username: v })} /> + setForm({ ...form, province: v })} /> + setForm({ ...form, city: v })} /> + setForm({ ...form, gender: v })} + select + options={[ + { value: "", label: t("انتخاب نشده", "Not set") }, + { value: "male", label: t("مرد", "Male") }, + { value: "female", label: t("زن", "Female") }, + ]} + /> + setForm({ ...form, birth_date: v })} /> + setForm({ ...form, skills: v })} + full + hint={t("با کاما جدا کنید", "Comma separated")} + /> +
+ ) : ( +
+ + + + + + + + + +
+ )} +
+
+ )} + + {activeTab === "auth" && ( +
+ + +
+ + +
+ +
+ + + +
+ {pkError &&

{pkError}

} +
+ + + +
+ {(sessions || []).slice(0, 5).map((s: any, i: number) => ( +
+ + + +
+
{s.device_name || t("دستگاه ناشناس", "Unknown device")}
+
+ {s.ip_address || "—"} ·{" "} + {s.expires_at ? new Date(s.expires_at).toLocaleDateString(lang === "fa" ? "fa-IR" : "en-US") : "—"} +
+
+ + + {s.status === "active" ? t("فعال", "Active") : (s.status || t("غیرفعال", "Inactive"))} + + +
+ ))} + {(!sessions || sessions.length === 0) && !loading && ( +

{t("نشست فعالی یافت نشد", "No active sessions found")}

+ )} +
+ +
+
+ )} + + {activeTab === "businesses" && ( + + + {loading ? ( + + ) : orgs.length === 0 ? ( + + ) : ( +
+ {orgs.map((o: any, i: number) => ( +
+ + {(o.name || "?").charAt(0)} + +
+
{o.name || o.slug}
+
+ + {roleLabel(o.role, t)} + + {t("عضو", "Member")} +
+
+
+ {o.joined_at ? new Date(o.joined_at).toLocaleDateString(lang === "fa" ? "fa-IR" : "en-US") : ""} +
+ +
+ ))} +
+ )} +
+ )} + + {activeTab === "bizManage" && ( +
+ + +
+ + +
+
+ + + +
+ {products.length === 0 ? ( +

{t("هنوز به محصولی دسترسی ندارید", "You don't have access to any product yet")}

+ ) : ( + products.map((p: any) => ( +
+ + {(p.name || p.key || "?").charAt(0)} + +
+
{p.name || p.key}
+
{p.description || p.key}
+
+ +
+ )) + )} +
+
+ {t( + "دسترسی به محصولات تابع سیاست دسترسی کلی است.", + "Product access follows the global access policy." + )} +
+
+
+ )} + +
+ © {new Date().getFullYear()} UserManager · MyAccount Hamsoo +
+
+
+
+ ); +} + +function Card({ children, className }: { children: React.ReactNode; className?: string }) { + return ( +
+ {children} +
+ ); +} + +function CardHead({ title, desc, action, classTop }: { title: string; desc?: string; action?: string; classTop?: string }) { + return ( +
+
+

{title}

+ {desc &&

{desc}

} +
+ {action && ( + + )} +
+ ); +} + +function Field({ label, value, icon: Icon, full }: any) { + return ( +
+
{label}
+
+ {Icon && } + {value} +
+
+ ); +} + +function EditField({ + label, + value, + onChange, + type, + full, + hint, + select, + options, +}: any) { + const base = + "w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-800 outline-none transition focus:border-[#6d5ef0]/50 focus:ring-2 focus:ring-[#6d5ef0]/15"; + return ( +
+ + {select ? ( + + ) : ( + onChange(e.target.value)} + /> + )} + {hint &&

{hint}

} +
+ ); +} + +function Row({ icon: Icon, title, sub, verified, toggleOn, chev, button, onClick, busy }: any) { + return ( +
+ + + +
+
{title}
+ {sub &&
{sub}
} +
+ {verified !== undefined && ( + + {!verified && "! "} + {verified ? "تأیید شده ✓" : "تأیید نشده"} + + )} + {toggleOn !== undefined && ( + + + + )} + {chev && } + {button && ( + + )} +
+ ); +} + +function ActionCard({ icon: Icon, title, desc, cta, primary }: any) { + return ( +
+ + + +
{title}
+

{desc}

+ +
+ ); +} + +function Empty({ text, icon: Icon }: any) { + return ( +
+ + + +

{text}

+
+ ); +} + +function Loading() { + return ( +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+ ); +} + +function genderLabel(g: string | null | undefined, t: (fa: string, en: string) => string) { + if (!g) return "-"; + if (g === "male") return t("مرد", "Male"); + if (g === "female") return t("زن", "Female"); + return g; +} + +function roleLabel(r: string | undefined | null, t: (fa: string, en: string) => string) { + const v = (r || "").toLowerCase(); + if (v === "owner") return t("مالک", "Owner"); + if (v === "admin") return t("مدیر", "Admin"); + return t("عضو", "Member"); +} diff --git a/apps/web/app/admin/applications/page.tsx b/apps/web/app/admin/applications/page.tsx new file mode 100644 index 0000000..7ec22d7 --- /dev/null +++ b/apps/web/app/admin/applications/page.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Search, Shield, RefreshCw, Copy, Eye, EyeOff } from "lucide-react"; +import { AdminLayout } from "@/components/admin/admin-layout"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { useLanguage } from "@/components/language-provider"; + +interface App { + id: string; + name: string; + client_id: string; + client_type: string; + redirect_uris: string[]; + grant_types: string[]; + scopes: string[]; + is_active: boolean; + created_at: string; +} + +export default function AdminApplicationsPage() { + const { lang, dir } = useLanguage(); + const [apps, setApps] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [showSecret, setShowSecret] = useState(null); + + useEffect(() => { + const token = localStorage.getItem("access_token"); + if (!token) { + window.location.href = "/login?redirect=/admin/applications"; + return; + } + + const fetchData = async () => { + const baseURL = process.env.NEXT_PUBLIC_API_URL; + const qs = new URLSearchParams(); + if (search) qs.set("search", search); + + try { + const res = await fetch(`${baseURL}/applications/?${qs.toString()}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json(); + setApps(data.results || []); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + const timer = setTimeout(fetchData, search ? 500 : 0); + return () => clearTimeout(timer); + }, [search]); + + const t = (fa: string, en: string) => (lang === "fa" ? fa : en); + const copyToClipboard = (text: string) => navigator.clipboard.writeText(text); + + if (loading) { + return ( + +
+ +
+
+ ); + } + + return ( + +
+
+

{t("اپلیکیشن\u200cها", "Applications")}

+ +
+ +
+ + setSearch(e.target.value)} + className="w-full pl-12 pr-4 py-2.5 rounded-lg border border-white/10 bg-white/5 text-white focus:outline-none focus:ring-2 focus:ring-brand-500/50" + dir={dir} + /> +
+ +
+ + + + + + + + + + + + {apps.map((app) => ( + + + + + + + + ))} + +
{t("نام", "Name")}{t("Client ID", "Client ID")}{t("نوع", "Type")}{t("اسکوپ\u200cها", "Scopes")}{t("وضعیت", "Status")}
+
+ + + + {app.name} +
+
+
+ {app.client_id.slice(0, 16)}... + +
+
{app.client_type} +
+ {app.scopes?.slice(0, 4).map((s) => ( + + {s} + + ))} +
+
+ + {app.is_active ? t("فعال", "Active") : t("غیرفعال", "Inactive")} + +
+
+
+
+ ); +} diff --git a/apps/web/app/admin/events/page.tsx b/apps/web/app/admin/events/page.tsx new file mode 100644 index 0000000..8920fb7 --- /dev/null +++ b/apps/web/app/admin/events/page.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { RefreshCw, AlertTriangle, CheckCircle, XCircle, ShieldAlert } from "lucide-react"; +import { AdminLayout } from "@/components/admin/admin-layout"; +import { Badge } from "@/components/ui/badge"; +import { useLanguage } from "@/components/language-provider"; + +interface Event { + id: string; + event_type: string; + severity: string; + ip_address: string; + user_agent: string; + user?: { email: string }; + metadata: Record; + status: string; + created_at: string; +} + +const SEVERITY_COLORS: Record = { + low: "bg-slate-500/15 text-slate-300", + medium: "bg-amber-500/15 text-amber-300", + high: "bg-rose-500/15 text-rose-300", + critical: "bg-rose-600/20 text-rose-200", +}; + +const SEVERITY_ICONS: Record> = { + low: ShieldAlert, + medium: AlertTriangle, + high: AlertTriangle, + critical: XCircle, +}; + +export default function AdminEventsPage() { + const { lang } = useLanguage(); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const token = localStorage.getItem("access_token"); + if (!token) { + window.location.href = "/login?redirect=/admin/events"; + return; + } + + const fetchData = async () => { + const baseURL = process.env.NEXT_PUBLIC_API_URL; + try { + const res = await fetch(`${baseURL}/security/events/`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json(); + setEvents(data.results || []); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + const t = (fa: string, en: string) => (lang === "fa" ? fa : en); + + if (loading) { + return ( + +
+ +
+
+ ); + } + + return ( + +
+

{t("رویدادهای امنیتی", "Security Events")}

+ +
+ + + + + + + + + + + + {events.map((e) => { + const Icon = SEVERITY_ICONS[e.severity] || ShieldAlert; + return ( + + + + + + + + ); + })} + +
{t("زمان", "Time")}{t("رویداد", "Event")}{t("کاربر", "User")}{t("آی\u200cپی", "IP")}{t("شدت", "Severity")}
+ {new Date(e.created_at).toLocaleString(lang === "fa" ? "fa-IR" : "en-US")} + + {e.event_type} + {e.user?.email || "—"}{e.ip_address} + + + {e.severity} + +
+ {events.length === 0 && !loading && ( +
+ {t("رویدادی یافت نشد", "No events found")} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/admin/invitations/page.tsx b/apps/web/app/admin/invitations/page.tsx new file mode 100644 index 0000000..d8690fb --- /dev/null +++ b/apps/web/app/admin/invitations/page.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { RefreshCw, Mail } from "lucide-react"; +import { AdminLayout } from "@/components/admin/admin-layout"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { useLanguage } from "@/components/language-provider"; + +interface Invitation { + id: string; + organization_name: string; + email: string; + role: string; + status: string; + created_at: string; +} + +export default function AdminInvitationsPage() { + const { lang } = useLanguage(); + const [invitations, setInvitations] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const token = localStorage.getItem("access_token"); + if (!token) { + window.location.href = "/login?redirect=/admin/invitations"; + return; + } + + const fetchData = async () => { + const baseURL = process.env.NEXT_PUBLIC_API_URL; + try { + const res = await fetch(`${baseURL}/membership/invitations/`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json(); + setInvitations(data.results || []); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + const t = (fa: string, en: string) => (lang === "fa" ? fa : en); + + if (loading) { + return ( + +
+ +
+
+ ); + } + + return ( + +
+

{t("دعوت\u200cها", "Invitations")}

+ +
+ + + + + + + + + + + {invitations.map((inv) => ( + + + + + + + ))} + +
{t("ایمیل", "Email")}{t("سازمان", "Organization")}{t("نقش", "Role")}{t("وضعیت", "Status")}
{inv.email}{inv.organization_name} + {inv.role} + + + {inv.status} + +
+ {invitations.length === 0 && !loading && ( +
+ {t("دعوتی یافت نشد", "No invitations found")} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/admin/members/page.tsx b/apps/web/app/admin/members/page.tsx new file mode 100644 index 0000000..74b03db --- /dev/null +++ b/apps/web/app/admin/members/page.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { RefreshCw, Users } from "lucide-react"; +import { AdminLayout } from "@/components/admin/admin-layout"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { useLanguage } from "@/components/language-provider"; + +interface Membership { + id: string; + user_name: string; + user_email: string; + organization_name: string; + role: string; + status: string; + created_at: string; +} + +export default function AdminMembersPage() { + const { lang } = useLanguage(); + const [memberships, setMemberships] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const token = localStorage.getItem("access_token"); + if (!token) { + window.location.href = "/login?redirect=/admin/members"; + return; + } + + const fetchData = async () => { + const baseURL = process.env.NEXT_PUBLIC_API_URL; + try { + const res = await fetch(`${baseURL}/membership/memberships/`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json(); + setMemberships(data.results || []); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + const t = (fa: string, en: string) => (lang === "fa" ? fa : en); + + if (loading) { + return ( + +
+ +
+
+ ); + } + + return ( + +
+

{t("اعضا", "Members")}

+ +
+ + + + + + + + + + + {memberships.map((m) => ( + + + + + + + ))} + +
{t("کاربر", "User")}{t("سازمان", "Organization")}{t("نقش", "Role")}{t("وضعیت", "Status")}
+
+ + {m.user_name?.charAt(0) || m.user_email?.charAt(0) || "?"} + + {m.user_name || m.user_email} +
+
{m.organization_name} + {m.role} + + + {m.status} + +
+ {memberships.length === 0 && !loading && ( +
+ {t("عضوی یافت نشد", "No members found")} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/admin/notifications/page.tsx b/apps/web/app/admin/notifications/page.tsx new file mode 100644 index 0000000..4bd506b --- /dev/null +++ b/apps/web/app/admin/notifications/page.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { RefreshCw, Bell } from "lucide-react"; +import { AdminLayout } from "@/components/admin/admin-layout"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { useLanguage } from "@/components/language-provider"; + +interface NotificationItem { + id: string; + event_type: string; + title: string; + message: string; + status: string; + created_at: string; +} + +export default function AdminNotificationsPage() { + const { lang } = useLanguage(); + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const token = localStorage.getItem("access_token"); + if (!token) { + window.location.href = "/login?redirect=/admin/notifications"; + return; + } + + const fetchData = async () => { + const baseURL = process.env.NEXT_PUBLIC_API_URL; + try { + const res = await fetch(`${baseURL}/health/notifications/`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json(); + setNotifications(data.results || []); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + const t = (fa: string, en: string) => (lang === "fa" ? fa : en); + + if (loading) { + return ( + +
+ +
+
+ ); + } + + return ( + +
+
+

{t("اعلانات", "Notifications")}

+ +
+ +
+ {notifications.map((n) => ( +
+ + + +
+
+

{n.title}

+ + {n.status} + +
+

{n.message}

+

+ {new Date(n.created_at).toLocaleString(lang === "fa" ? "fa-IR" : "en-US")} +

+
+
+ ))} + {notifications.length === 0 && !loading && ( +
+ {t("اعلانی یافت نشد", "No notifications found")} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/admin/organizations/page.tsx b/apps/web/app/admin/organizations/page.tsx new file mode 100644 index 0000000..efb8f62 --- /dev/null +++ b/apps/web/app/admin/organizations/page.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Search, Building2, RefreshCw, Users } from "lucide-react"; +import { AdminLayout } from "@/components/admin/admin-layout"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { useLanguage } from "@/components/language-provider"; + +interface Org { + id: string; + name: string; + slug: string; + description: string; + is_active: boolean; + created_at: string; + member_count: number; + user_role: string; +} + +export default function AdminOrganizationsPage() { + const { lang, dir } = useLanguage(); + const [orgs, setOrgs] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + + useEffect(() => { + const token = localStorage.getItem("access_token"); + if (!token) { + window.location.href = "/login?redirect=/admin/organizations"; + return; + } + + const fetchData = async () => { + const baseURL = process.env.NEXT_PUBLIC_API_URL; + const qs = new URLSearchParams(); + if (search) qs.set("search", search); + + try { + const res = await fetch(`${baseURL}/organizations/?${qs.toString()}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json(); + setOrgs(data.results || []); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + const timer = setTimeout(fetchData, search ? 500 : 0); + return () => clearTimeout(timer); + }, [search]); + + const t = (fa: string, en: string) => (lang === "fa" ? fa : en); + + if (loading) { + return ( + +
+ +
+
+ ); + } + + return ( + +
+
+

{t("سازمان\u200cها", "Organizations")}

+ +
+ +
+ + setSearch(e.target.value)} + className="w-full pl-12 pr-4 py-2.5 rounded-lg border border-white/10 bg-white/5 text-white focus:outline-none focus:ring-2 focus:ring-brand-500/50" + dir={dir} + /> +
+ +
+ + + + + + + + + + + + {orgs.map((org) => ( + + + + + + + + ))} + +
{t("سازمان", "Organization")}{t("وضعیت", "Status")}{t("اعضا", "Members")}{t("نقش شما", "Your Role")}{t("ساخته شده", "Created")}
+
+ + + +
+
{org.name}
+
{org.slug}
+
+
+
+ + {org.is_active ? t("فعال", "Active") : t("غیرفعال", "Inactive")} + + {org.member_count} + + {t(org.user_role, org.user_role)} + + + {new Date(org.created_at).toLocaleDateString(lang === "fa" ? "fa-IR" : "en-US")} +
+ {orgs.length === 0 && !loading && ( +
+ {t("سازمانی یافت نشد", "No organizations found")} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx new file mode 100644 index 0000000..e4a0382 --- /dev/null +++ b/apps/web/app/admin/page.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Users, + Building2, + Shield, + Activity, + RefreshCw, + Lock, + Calendar, + Mail, + Phone, +} from "lucide-react"; +import { AdminLayout } from "@/components/admin/admin-layout"; +import { useLanguage } from "@/components/language-provider"; +import { apiFetch } from "@/lib/api"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; + +interface ApiResult { + count: number; + results: any[]; +} + +interface NavItem { + id: string; + label: string; + icon: React.ComponentType<{ className?: string }>; + href: string; +} + +export default function AdminDashboardPage() { + const { t, lang } = useLanguage(); + const [activeSessions, setActiveSessions] = useState(0); + const [stats, setStats] = useState(null); + const [sessions, setSessions] = useState([]); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const token = localStorage.getItem("access_token"); + if (!token) { + window.location.href = "/login?redirect=/admin"; + return; + } + + const fetchData = async () => { + try { + const [usersRes, orgsRes, appsRes, sessionsRes, eventsRes] = + await Promise.all([ + apiFetch("/users/"), + apiFetch("/organizations/"), + apiFetch("/applications/"), + apiFetch("/sessions/"), + apiFetch("/security/events/"), + ]); + + const usersData = await usersRes.json(); + const orgsData = await orgsRes.json(); + const appsData = await appsRes.json(); + const sessionsData = await sessionsRes.json(); + const eventsData = await eventsRes.json(); + + setStats({ + count: 4, + results: [ + { name: "users", count: usersData.count || 0 }, + { name: "organizations", count: orgsData.count || 0 }, + { name: "applications", count: appsData.count || 0 }, + { name: "sessions", count: sessionsData.count || 0 }, + ], + }); + + const sessionsList = sessionsData.results || []; + setSessions(sessionsList); + const active = sessionsList.filter( + (s: any) => s.status === "active" + ).length; + setActiveSessions(active); + setEvents(eventsData.results || []); + } catch (e) { + console.error("Admin fetch error:", e); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + const navItems: NavItem[] = [ + { id: "home", label: t.admin.dashboard, icon: Users, href: "/admin" }, + { id: "users", label: t.admin.users, icon: Users, href: "/admin/users" }, + { id: "organizations", label: t.admin.orgs as string, icon: Building2, href: "/admin/orgs" }, + { id: "applications", label: t.admin.apps as string, icon: Shield, href: "/admin/apps" }, + { id: "security", label: t.admin.security as string, icon: Lock, href: "/admin/security" }, + { id: "audit", label: t.admin.auditLog, icon: Calendar, href: "/admin/audit" }, + ]; + + const cards = [ + { name: "users", labelFa: "کاربران", labelEn: "Users", icon: Users, color: "text-blue-400", bg: "bg-blue-500/10" }, + { name: "organizations", labelFa: "سازمان\u200cها", labelEn: "Organizations", icon: Building2, color: "text-emerald-400", bg: "bg-emerald-500/10" }, + { name: "applications", labelFa: "اپلیکیشن\u200cها", labelEn: "Applications", icon: Shield, color: "text-purple-400", bg: "bg-purple-500/10" }, + { name: "sessions", labelFa: "نشست\u200cها", labelEn: "Sessions", icon: Activity, color: "text-amber-400", bg: "bg-amber-500/10" }, + ]; + + if (loading) { + return ( + +
+ +
+
+ ); + } + + return ( + + {/* Left Sidebar */} + + + {/* Main Content */} +
+ {/* Header / Quick Stats */} +
+
+ {cards.map((card) => { + const value = stats?.results.find((r) => r.name === card.name)?.count || 0; + return ( +
+
{value}
+
{card.labelEn}
+
+ ); + })} +
+
+ + {/* Quick Action Cards */} +
+
+
+ +
+

Manage Users

+

{t.admin.manageUsers}

+ +
+ +
+
+ +
+

Organizations

+

{t.admin.manageOrgs}

+ +
+ +
+
+ +
+

Security

+

{t.admin.securitySettings}

+ +
+
+ + {/* Active Sessions Section */} +
+
+
+

+ {t.dashboard.activeSessions!} +

+

{activeSessions} {t.dashboard.of as string} {stats?.results.find((r) => r.name === "sessions")?.count || 0} {t.dashboard.totalSessions as string}

+
+ +
+ +
+ + + + + + + + + + + {sessions.slice(0, 5).length === 0 ? ( + + + + ) : ( + sessions.slice(0, 5).map((r: any, i: number) => ( + + + + + + + )) + )} + +
UserAppLocationStatus
+ {lang === "fa" ? "نشست فعالی یافت نشد" : "No active sessions found"} +
+
+
+ {(r.user_email || "?").charAt(0).toUpperCase()} +
+
+ {r.user_email || (lang === "fa" ? "کاربر ناشناس" : "Unknown user")} +
+
+
{r.application_name || "—"}{r.ip_address || "—"} + + {r.status === "active" + ? lang === "fa" + ? "فعال" + : "Active" + : r.status === "revoked" + ? lang === "fa" + ? "لغو شده" + : "Revoked" + : lang === "fa" + ? "منقضی" + : "Expired"} + +
+
+
+ + {/* Recent Security Events */} +
+
+

+ {t.dashboard.recentEvents} +

+ +
+ +
+ {events.length === 0 ? ( +

+ {lang === "fa" ? "رخداد امنیتی یافت نشد" : "No security events"} +

+ ) : ( + events.slice(0, 6).map((r: any, i: number) => ( +
+
+ + {(r.user_email || "?").charAt(0).toUpperCase()} + +
+
{r.event_type}
+
+ {r.occurred_at + ? new Date(r.occurred_at).toLocaleString(lang === "fa" ? "fa-IR" : "en-US") + : ""} +
+
+
+
+ {r.user_email || (lang === "fa" ? "ناشناس" : "anonymous")} + {r.severity || r.status || ""} +
+
+ )) + )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/app/admin/products/page.tsx b/apps/web/app/admin/products/page.tsx new file mode 100644 index 0000000..d37e363 --- /dev/null +++ b/apps/web/app/admin/products/page.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Search, Shield, RefreshCw, Plus } from "lucide-react"; +import { AdminLayout } from "@/components/admin/admin-layout"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { useLanguage } from "@/components/language-provider"; + +interface Product { + id: string; + key: string; + name: string; + description: string; + is_active: boolean; + created_at: string; +} + +export default function AdminProductsPage() { + const { lang } = useLanguage(); + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const token = localStorage.getItem("access_token"); + if (!token) { + window.location.href = "/login"; + return; + } + + const fetchData = async () => { + const baseURL = process.env.NEXT_PUBLIC_API_URL; + try { + const res = await fetch(`${baseURL}/products/`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json(); + setProducts(data.results || []); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + const t = (fa: string, en: string) => (lang === "fa" ? fa : en); + + if (loading) { + return ( + +
+ +
+
+ ); + } + + return ( + +
+
+

{t("محصول\u200cها", "Products")}

+ +
+ +
+ + + + + + + + + + + + {products.map((p) => ( + + + + + + + + ))} + +
{t("کلید", "Key")}{t("نام", "Name")}{t("توضیحات", "Description")}{t("وضعیت", "Status")}{t("ساخته شده", "Created")}
+ {p.key} + {p.name}{p.description || "—"} + + {p.is_active ? t("فعال", "Active") : t("غیرفعال", "Inactive")} + + + {new Date(p.created_at).toLocaleDateString(lang === "fa" ? "fa-IR" : "en-US")} +
+ {products.length === 0 && !loading && ( +
+ {t("محصولی یافت نشد", "No products found")} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/admin/security/page.tsx b/apps/web/app/admin/security/page.tsx new file mode 100644 index 0000000..21aae8a --- /dev/null +++ b/apps/web/app/admin/security/page.tsx @@ -0,0 +1,299 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useLanguage } from "@/components/language-provider"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +const TRUST_STATE_COLORS: Record = { + basic: "bg-slate-500/20 text-slate-400", + verified: "bg-amber-500/20 text-amber-400", + strong: "bg-emerald-500/20 text-emerald-400", +}; + +const TRUST_STATE_LABELS: Record = { + basic: { fa: "پایه", en: "Basic" }, + verified: { fa: "تأیید شده", en: "Verified" }, + strong: { fa: "قوی", en: "Strong" }, +}; + +export default function SecurityPage() { + const { lang, dir } = useLanguage(); + const t = (fa: string, en: string) => (lang === "fa" ? fa : en); + + const [mfaEnabled, setMfaEnabled] = useState(false); + const [setupData, setSetupData] = useState<{ secret: string; qr_code: string; totp_uri: string } | null>(null); + const [verifyCode, setVerifyCode] = useState(""); + const [currentPassword, setCurrentPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const [trustState, setTrustState] = useState(null); + const [trustScore, setTrustScore] = useState(null); + const [evidenceList, setEvidenceList] = useState([]); + + const getToken = () => localStorage.getItem("access_token"); + + useEffect(() => { + fetchMfaStatus(); + fetchTrustState(); + }, []); + + const fetchTrustState = async () => { + const token = getToken(); + if (!token) return; + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/verification/trust/me/`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const data = await res.json(); + setTrustState(data.trust_state); + setTrustScore(data.trust_score); + setEvidenceList(data.evidence || []); + } + } catch (e) { + console.error(e); + } + }; + + const fetchMfaStatus = async () => { + const token = getToken(); + if (!token) return; + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/mfa/setup/`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.status === 200) { + const data = await res.json(); + setMfaEnabled(false); + setSetupData(null); + } else if (res.status === 400) { + const data = await res.json(); + if (data.detail && data.detail.includes("already enabled")) { + setMfaEnabled(true); + } + } + } catch (e) { + console.error(e); + } + }; + + const startSetup = async () => { + setLoading(true); + setError(null); + setSuccess(null); + try { + const token = getToken(); + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/mfa/setup/`, { + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const data = await res.json(); + setSetupData(data); + } else { + const data = await res.json(); + setError(data.detail || t("خطا در راه‌اندازی MFA", "Error setting up MFA")); + } + } catch (e) { + setError(t("خطا در ارتباط با سرور", "Server connection error")); + } finally { + setLoading(false); + } + }; + + const verifyMfa = async () => { + setLoading(true); + setError(null); + setSuccess(null); + try { + const token = getToken(); + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/mfa/verify/`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ code: verifyCode }), + }); + if (res.ok) { + setSuccess(t("MFA با موفقیت فعال شد", "MFA enabled successfully")); + setMfaEnabled(true); + setSetupData(null); + setVerifyCode(""); + } else { + const data = await res.json(); + setError(data.detail || t("کد نامعتبر است", "Invalid code")); + } + } catch (e) { + setError(t("خطا در ارتباط با سرور", "Server connection error")); + } finally { + setLoading(false); + } + }; + + const disableMfa = async () => { + setLoading(true); + setError(null); + setSuccess(null); + try { + const token = getToken(); + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/mfa/disable/`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ current_password: currentPassword }), + }); + if (res.ok) { + setSuccess(t("MFA با موفقیت غیرفعال شد", "MFA disabled successfully")); + setMfaEnabled(false); + setCurrentPassword(""); + } else { + const data = await res.json(); + setError(data.detail || t("خطا در غیرفعال‌سازی MFA", "Error disabling MFA")); + } + } catch (e) { + setError(t("خطا در ارتباط با سرور", "Server connection error")); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

{t("امنیت", "Security")}

+

{t("تنظیمات احراز هویت دو مرحله‌ای (MFA)", "Multi-factor authentication settings")}

+
+ + {error && ( +
{error}
+ )} + {success && ( +
{success}
+ )} + + + + {t("حالت اعتماد", "Trust State")} + + +
+
+ {trustState + ? TRUST_STATE_LABELS[trustState]?.[lang] || trustState + : t("در حال بارگذاری...", "Loading...")} +
+ {trustScore !== null && ( +
{trustScore}/100
+ )} +
+ +
+ {t("تعداد شواهد ثبت‌شده:", "Evidence records:")} {evidenceList.length} +
+ + {evidenceList.length > 0 && ( +
+ {evidenceList.map((e) => ( +
+ + {e.evidence_type} — {e.dimension} + + + {e.provider} + +
+ ))} +
+ )} +
+
+ + + + + {t("احراز هویت دو مرحله‌ای (MFA)", "Multi-Factor Authentication (MFA)")} + + + +
+
+

+ {t("وضعیت MFA", "MFA Status")} +

+

+ {mfaEnabled + ? t("فعال شده", "Enabled") + : t("غیرفعال", "Disabled")} +

+
+ {!mfaEnabled && !setupData && ( + + )} +
+ + {setupData && ( +
+

+ {t("کد QR را با برنامه Authenticator خود اسکن کنید:", "Scan this QR code with your authenticator app:")} +

+ {/* eslint-disable-next-line @next/next/no-img-element */} + MFA QR Code +
+

{t("کد دستی:", "Manual code:")}

+ + {setupData.secret} + +
+
+ setVerifyCode(e.target.value)} + dir="ltr" + /> +
+ +
+ )} + + {mfaEnabled && ( +
+

+ {t("برای غیرفعال‌سازی MFA، رمز عبور فعلی خود را وارد کنید:", "To disable MFA, enter your current password:")} +

+
+ setCurrentPassword(e.target.value)} + dir="ltr" + /> +
+ +
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/admin/sessions/page.tsx b/apps/web/app/admin/sessions/page.tsx new file mode 100644 index 0000000..3f4d76c --- /dev/null +++ b/apps/web/app/admin/sessions/page.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { RefreshCw, Monitor, Globe, Shield } from "lucide-react"; +import { AdminLayout } from "@/components/admin/admin-layout"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { useLanguage } from "@/components/language-provider"; + +interface Session { + id: string; + device_name: string; + ip_address: string; + user_agent: string; + session_type: string; + status: string; + started_at: string; + last_activity_at: string; + expires_at: string; + revoked_reason: string; + user?: { email: string; full_name: string }; +} + +export default function AdminSessionsPage() { + const { lang } = useLanguage(); + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const token = localStorage.getItem("access_token"); + if (!token) { + window.location.href = "/login?redirect=/admin/sessions"; + return; + } + + const fetchData = async () => { + const baseURL = process.env.NEXT_PUBLIC_API_URL; + try { + const res = await fetch(`${baseURL}/sessions/`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json(); + setSessions(data.results || []); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + const t = (fa: string, en: string) => (lang === "fa" ? fa : en); + + if (loading) { + return ( + +
+ +
+
+ ); + } + + return ( + +
+

{t("نشست\u200cها", "Sessions")}

+ +
+ + + + + + + + + + + + + {sessions.map((s) => ( + + + + + + + + + ))} + +
{t("کاربر", "User")}{t("دستگاه", "Device")}{t("آی\u200cپی", "IP")}{t("نوع", "Type")}{t("وضعیت", "Status")}{t("اقدامات", "Actions")}
+
+ + {s.user?.full_name?.charAt(0) || s.user?.email?.charAt(0) || "?"} + + {s.user?.full_name || s.user?.email || "—"} +
+
{s.device_name || "—"}{s.ip_address} + + {s.session_type} + + + + {s.status === "active" ? t("فعال", "Active") : t("غیرفعال", "Inactive")} + + + {s.status === "active" && ( + + )} +
+ {sessions.length === 0 && !loading && ( +
+ {t("نشستی یافت نشد", "No sessions found")} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/admin/users/page.tsx b/apps/web/app/admin/users/page.tsx new file mode 100644 index 0000000..05b9313 --- /dev/null +++ b/apps/web/app/admin/users/page.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Search, Users, RefreshCw, Trash2 } from "lucide-react"; +import { AdminLayout } from "@/components/admin/admin-layout"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { useLanguage } from "@/components/language-provider"; + +interface User { + user_id: string; + email: string; + full_name: string; + status: string; + is_active: boolean; + is_staff: boolean; + mfa_enabled: boolean; + trust_state?: string; + trust_score?: number; + last_login_at: string | null; + created_at: string; +} + +export default function AdminUsersPage() { + const { lang, dir } = useLanguage(); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + + useEffect(() => { + const token = localStorage.getItem("access_token"); + if (!token) { + window.location.href = "/login?redirect=/admin/users"; + return; + } + + const fetchData = async () => { + const baseURL = process.env.NEXT_PUBLIC_API_URL; + const qs = new URLSearchParams(); + if (search) qs.set("search", search); + + try { + const res = await fetch(`${baseURL}/users/?${qs.toString()}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await res.json(); + setUsers(data.results || []); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + const timer = setTimeout(fetchData, search ? 500 : 0); + return () => clearTimeout(timer); + }, [search]); + + const t = (fa: string, en: string) => (lang === "fa" ? fa : en); + + if (loading) { + return ( + +
+ +
+
+ ); + } + + return ( + +
+
+

{t("کاربران", "Users")}

+
+ +
+
+ + setSearch(e.target.value)} + className="w-full pl-12 pr-4 py-2.5 rounded-lg border border-white/10 bg-white/5 text-white focus:outline-none focus:ring-2 focus:ring-brand-500/50" + dir={dir} + /> +
+
+ +
+ + + + + + + + + + + + + {users.map((u) => ( + + + + + + + + + ))} + +
{t("کاربر", "User")}{t("وضعیت", "Status")}{t("اعتماد", "Trust")}{t("امنیت", "Security")}{t("آخرین ورود", "Last Login")}{t("اقدامات", "Actions")}
+
+ + {u.full_name?.charAt(0) || u.email?.charAt(0) || "?"} + +
+
{u.full_name || u.email}
+
{u.email}
+
+
+
+ + {u.status} + + +
+ {u.trust_score !== undefined && ( + + {u.trust_score}/100 + + )} + {u.trust_state && ( + + {lang === "fa" + ? (u.trust_state === "basic" ? "پایه" : u.trust_state === "verified" ? "تأیید شده" : "قوی") + : (u.trust_state.charAt(0).toUpperCase() + u.trust_state.slice(1)) + } + + )} +
+
+
+ MFA: + + {u.mfa_enabled ? t("فعال", "On") : t("خاموش", "Off")} + + {u.is_staff && ( + Admin + )} +
+
+ {u.last_login_at ? new Date(u.last_login_at).toLocaleString(lang === "fa" ? "fa-IR" : "en-US") : t("هرگز", "Never")} + + +
+
+
+
+ ); +} diff --git a/apps/web/app/api/auth/login/route.ts b/apps/web/app/api/auth/login/route.ts new file mode 100644 index 0000000..25ab3de --- /dev/null +++ b/apps/web/app/api/auth/login/route.ts @@ -0,0 +1,15 @@ +import { NextRequest, NextResponse } from "next/server"; +import { djangoUrl, setAuthCookies } from "../proxy"; + +export async function POST(req: NextRequest) { + const body = await req.text(); + const upstream = await fetch(djangoUrl("auth/login/"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + const data = await upstream.json().catch(() => ({})); + const res = NextResponse.json(data, { status: upstream.status }); + if (upstream.ok) setAuthCookies(res, data); + return res; +} diff --git a/apps/web/app/api/auth/logout/route.ts b/apps/web/app/api/auth/logout/route.ts new file mode 100644 index 0000000..ec1114d --- /dev/null +++ b/apps/web/app/api/auth/logout/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { djangoUrl, clearAuthCookies, setAuthCookies } from "../proxy"; + +export async function POST(req: NextRequest) { + const cookieRefresh = req.cookies.get("refresh_token")?.value; + const cookieSession = req.cookies.get("session_id")?.value; + const body = await req.text().catch(() => ""); + let parsed: any = {}; + try { + parsed = body ? JSON.parse(body) : {}; + } catch { + parsed = {}; + } + const upstream = await fetch(djangoUrl("auth/logout/"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + refresh: parsed.refresh || cookieRefresh || "", + session_id: parsed.session_id || cookieSession || "", + logout_type: parsed.logout_type || "global", + }), + }); + const data = await upstream.json().catch(() => ({})); + const res = NextResponse.json(data, { status: upstream.status }); + // Always clear local cookies; the upstream may also rotate the session. + clearAuthCookies(res); + if (upstream.ok && data) { + // Preserve any refreshed tokens returned by the server, if applicable. + setAuthCookies(res, data); + } + return res; +} diff --git a/apps/web/app/api/auth/me/route.ts b/apps/web/app/api/auth/me/route.ts new file mode 100644 index 0000000..08d6106 --- /dev/null +++ b/apps/web/app/api/auth/me/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from "next/server"; +import { djangoUrl } from "../proxy"; + +export async function GET(req: NextRequest) { + const access = req.cookies.get("access_token")?.value; + const upstream = await fetch(djangoUrl("auth/me/"), { + method: "GET", + headers: access + ? { Authorization: `Bearer ${access}` } + : { "Content-Type": "application/json" }, + }); + const data = await upstream.json().catch(() => ({})); + return NextResponse.json(data, { status: upstream.status }); +} diff --git a/apps/web/app/api/auth/proxy.ts b/apps/web/app/api/auth/proxy.ts new file mode 100644 index 0000000..c3622c5 --- /dev/null +++ b/apps/web/app/api/auth/proxy.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; + +const API_BASE = + process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1"; + +export function djangoUrl(path: string): string { + return `${API_BASE}/${path}`; +} + +function cookieBase() { + const secure = process.env.NODE_ENV === "production"; + return { + httpOnly: true, + secure, + sameSite: "lax" as const, + path: "/", + }; +} + +export function setAuthCookies(res: NextResponse, data: any) { + if (data?.access) { + res.cookies.set("access_token", data.access, { + ...cookieBase(), + maxAge: 60 * 30, + }); + } + if (data?.refresh) { + res.cookies.set("refresh_token", data.refresh, { + ...cookieBase(), + maxAge: 60 * 60 * 24 * 30, + }); + } + if (data?.session_id) { + res.cookies.set("session_id", data.session_id, { + ...cookieBase(), + maxAge: 60 * 60 * 24 * 30, + }); + } +} + +export function clearAuthCookies(res: NextResponse) { + for (const name of ["access_token", "refresh_token", "session_id"]) { + res.cookies.set(name, "", { ...cookieBase(), maxAge: 0 }); + } +} diff --git a/apps/web/app/api/auth/refresh/route.ts b/apps/web/app/api/auth/refresh/route.ts new file mode 100644 index 0000000..1b0dabe --- /dev/null +++ b/apps/web/app/api/auth/refresh/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { djangoUrl, setAuthCookies } from "../proxy"; + +export async function POST(req: NextRequest) { + const cookieRefresh = req.cookies.get("refresh_token")?.value; + const body = await req.text().catch(() => ""); + let parsed: any = {}; + try { + parsed = body ? JSON.parse(body) : {}; + } catch { + parsed = {}; + } + const refresh = parsed.refresh || cookieRefresh || ""; + const upstream = await fetch(djangoUrl("auth/refresh/"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh }), + }); + const data = await upstream.json().catch(() => ({})); + const res = NextResponse.json(data, { status: upstream.status }); + if (upstream.ok) setAuthCookies(res, data); + return res; +} diff --git a/apps/web/app/api/auth/register/route.ts b/apps/web/app/api/auth/register/route.ts new file mode 100644 index 0000000..244c649 --- /dev/null +++ b/apps/web/app/api/auth/register/route.ts @@ -0,0 +1,15 @@ +import { NextRequest, NextResponse } from "next/server"; +import { djangoUrl, setAuthCookies } from "../proxy"; + +export async function POST(req: NextRequest) { + const body = await req.text(); + const upstream = await fetch(djangoUrl("auth/register/"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + const data = await upstream.json().catch(() => ({})); + const res = NextResponse.json(data, { status: upstream.status }); + if (upstream.ok) setAuthCookies(res, data); + return res; +} diff --git a/apps/web/app/developers/page.tsx b/apps/web/app/developers/page.tsx new file mode 100644 index 0000000..37501d9 --- /dev/null +++ b/apps/web/app/developers/page.tsx @@ -0,0 +1,187 @@ +/* eslint-disable */ +import React, { useState, useEffect } from "react" + +const SDK_SCRIPT_SNIPPET = `` +const SIGNIN_ROOT_SNIPPET = `
` +const INIT_SNIPPET = `HamsooID.init({ + client_id: "your-client-id", + redirect_uri: "https://your-site.com/callback", + scope: "openid profile email", + response_mode: "web_message", // برای پاپ‌آپ موبایلی + onSuccess: ({ code, state }) => { + // Exchange code for tokens at: POST /oauth/token + console.log("Authorization code:", code, "State:", state) + }, + onError: (err) => { + console.error("Sign in error:", err) + }, +})` +const TOKEN_EXCHANGE_SNIPPET = `import requests + +resp = requests.post( + "https://account.hamsoo.me/oauth/token", + data={ + "grant_type": "authorization_code", + "code": "", + "redirect_uri": "https://your-site.com/callback", + "client_id": "your-client-id", + "client_secret": "", + }, +) +tokens = resp.json() +print(tokens["access_token"])` + +export default function DevelopersPage() { + const [sdkLoaded, setSdkLoaded] = useState(false) + + useEffect(() => { + const script = document.createElement("script") + script.src = "/sdk/identity-widget.js" + script.async = true + script.onload = () => setSdkLoaded(true) + script.onerror = () => console.error("Failed to load Hamsoo SDK") + document.head.appendChild(script) + }, []) + + return ( +
+
+

+ مستندات توسعه‌دهندگان / Developer Documentation +

+ +
+

۵ دقیقه شروع کنید / Quickstart

+
    +
  1. + 1. ثبت اپلیکیشن: در داشبورد هوامله (https://account.hamsoo.me) یک application ثبت کنید. client_id و redirect_uris را ثبت کنید. گام scopes: openid profile email phone. +
  2. +
  3. + 2. SDK را اضافه کنید: کد زیر را در هدر یا انتهای body سایت خود قرار دهید: +
    {SDK_SCRIPT_SNIPPET}
    +
  4. +
  5. + 3. دکمه را جایگذاری کنید: +
    {SIGNIN_ROOT_SNIPPET}
    +
    {INIT_SNIPPET}
    +
  6. +
  7. + 4. backend: تبادل code برای توکن: +
    {TOKEN_EXCHANGE_SNIPPET}
    +
  8. +
+
+ +
+

مرجع SDK / SDK Reference

+ + + + + + + + + + + + + + + + + + + + + +
روش / Methodتوضیحات / Description
HamsooID.init({"{ options }"})ابزار SDK را مقداردهی و دکمه را render می‌کند
onSuccess({"{ code, state }"})وقتی کاربر با موفقیت احراز هویت می‌شود، این تابع صدا زده می‌شود
onError({"{ error }"})وقتی احراز هویت شکست خورد، این تابع صدا زده می‌شود
+
+ +
+

ارائه‌دهنده API / API Endpoints

+

+ تمام endpoints روی دامنه account.hamsoo.me قابل دسترسی هستند. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Endpointروش / Methodتوضیحات / Description
GET /.well-known/openid-configurationGETOpenID Discovery document
GET /oauth/jwksGETJSON Web Key Set for JWT verification
GET /oauth/authorizeGET/POSTAuthorization endpoint (PKCE + consent)
POST /oauth/tokenPOSTExchange code for tokens (PKCE)
GET /oauth/userinfoGETUser claims (scope-dependent)
GET /sso/loginGETCentral SSO login page (account chooser + MFA + Passkey)
POST /api/v1/security/pow/challengePOSTSelf-hosted proof-of-work challenge (anti-bot)
+
+ +
+

مباحث امنیتی / Security Best Practices

+
    +
  • + حتماً PKCE استفاده کنید: هر JWT Authorization Request باید شامل code verifier و challenge باشد. +
  • +
  • + پارامتر state را ولید کنید: همیشه پاسخ state را با درخواست اصلی مقایسه کنید. +
  • +
  • + redirect_uri را تأیید کنید: redirect_uri ثبت‌شده است و با redirect_uris اپلیکیشن تطابق دارد. +
  • +
  • + response_mode=web_message برای پاپ‌آپ‌ها استفاده کنید: این روش redirect صفحه کامل را حذف می‌کند و تجربه modal ایجاد می‌کند. +
  • +
  • + Self-hosted PoW برای لاگین: هر درخواست لاگین باید challenge را حل کند (سبک ALTCHA) که فعالیت bot را کاهش می‌دهد. +
  • +
+
+ +
+

مدیریت اپلیکیشن / App Management

+

+ اپلیکیشن‌های خود را در پورتال مدیریت کنید:{" "} + + account.hamsoo.me/admin/applications + +

+
+
+
+ ) +} diff --git a/apps/web/app/forgot-password/page.tsx b/apps/web/app/forgot-password/page.tsx new file mode 100644 index 0000000..fb43324 --- /dev/null +++ b/apps/web/app/forgot-password/page.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useState } from "react"; +import { Mail, Fingerprint, ArrowLeft, ArrowRight, CheckCircle } from "lucide-react"; +import { useLanguage } from "@/components/language-provider"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; + +export default function ForgotPasswordPage() { + const { t, lang, dir } = useLanguage(); + const Arrow = lang === "fa" ? ArrowRight : ArrowLeft; + + const [email, setEmail] = useState(""); + const [step, setStep] = useState<"request" | "sent">("request"); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/password/reset/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.detail || data.error || "Request failed"); + } + + setStep("sent"); + } catch (err) { + setError(err instanceof Error ? err.message : "Request failed"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ + + + + {process.env.NEXT_PUBLIC_SITE_NAME || "MyAccount Hamsoo"} + + {step === "request" ? ( + <> +

{lang === "fa" ? "بازیابی رمز عبور" : "Reset Password"}

+

+ {lang === "fa" ? "ایمیل خود را وارد کنید تا لینک بازیابی ارسال شود" : "Enter your email to receive a reset link"} +

+ + ) : ( + <> +
+ +
+

{lang === "fa" ? "ایمیل ارسال شد" : "Email Sent"}

+

+ {lang === "fa" + ? "اگر ایمیل در سیستم ثبت باشد، لینک بازیابی ارسال می‌شود" + : "If the email exists in our system, a reset link has been sent"} +

+ + )} +
+ +
+ {error && ( +
+ {error} +
+ )} + + {step === "request" && ( +
+
+ +
+ + + setEmail(e.target.value)} + required + className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-white/10 bg-white/5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand-500/50 focus:border-transparent" + placeholder={lang === "fa" ? "you@example.com" : "you@example.com"} + dir="ltr" + /> +
+
+ + +
+ )} + + {step === "sent" && ( +
+ +

+ {lang === "fa" ? "ایمیل را پیدا نکردید؟" : "Didn't receive the email?"}{" "} + + {lang === "fa" ? "ارسال مجدد" : "Resend"} + +

+
+ )} +
+ +
+ + + {lang === "fa" ? "مرکز حساب همسو" : "MyAccount Hamsoo"} + +
+
+
+ ); +} + +function Link({ href, children, className }: { href: string; children: React.ReactNode; className?: string }) { + return {children}; +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 0000000..280284a --- /dev/null +++ b/apps/web/app/globals.css @@ -0,0 +1,55 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color-scheme: light; +} + +html.dark { + color-scheme: dark; +} + +html { + scroll-behavior: smooth; +} + +body { + @apply bg-white text-slate-800 antialiased dark:bg-ink dark:text-slate-200; + font-feature-settings: "ss01"; +} + +::selection { + @apply bg-brand-500/30 text-white; +} + +@layer components { + .container-x { + @apply container mx-auto px-6; + } + + .eyebrow { + @apply inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-medium uppercase tracking-wider text-brand-600 dark:border-white/10 dark:bg-white/5 dark:text-brand-300; + } + + .card { + @apply rounded-2xl border border-slate-200 bg-white p-6 shadow-sm transition dark:border-white/10 dark:bg-white/[0.03] dark:backdrop-blur; + } + + .card-hover { + @apply hover:-translate-y-1 hover:border-brand-500/30 hover:shadow-md dark:hover:border-brand-500/40 dark:hover:bg-white/[0.06]; + } + + .gradient-text { + @apply bg-brand-gradient bg-clip-text text-transparent; + } + + .link-underline { + @apply relative after:absolute after:inset-x-0 after:bottom-0 after:h-px after:origin-right after:scale-x-0 after:bg-brand-400 after:transition-transform hover:after:origin-left hover:after:scale-x-100; + } + + @keyframes float { + 0%, 100% { transform: translateY(0px); } + 50% { transform: translateY(-8px); } + } +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 0000000..86a0c1b --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,49 @@ +import type { Metadata } from "next"; +import { Vazirmatn } from "next/font/google"; +import "./globals.css"; +import { LanguageProvider } from "@/components/language-provider"; +import { ThemeProvider } from "@/components/theme-provider"; +import { LoginModalProvider } from "@/components/login-modal-provider"; +import { AuthProvider } from "@/components/auth/auth-provider"; +import { Navbar } from "@/components/navbar"; +import { Footer } from "@/components/footer"; + +const vazir = Vazirmatn({ + subsets: ["arabic", "latin"], + variable: "--font-fa", + weight: ["400", "500", "600", "700", "800"], + display: "swap", +}); + +export const metadata: Metadata = { + title: { + default: "MyAccount Hamsoo — One Identity. Every Product.", + template: "%s · MyAccount Hamsoo", + }, + description: + "Central identity, authentication and authorization infrastructure for the ecosystem.", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + + + +
{children}
+
+ + + + + + + ); +} diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx new file mode 100644 index 0000000..ab0feb9 --- /dev/null +++ b/apps/web/app/login/page.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useState, useEffect, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Eye, EyeOff, Mail, Lock, Fingerprint, ArrowLeft, ArrowRight } from "lucide-react"; +import { useLanguage } from "@/components/language-provider"; +import { useAuth } from "@/components/auth/auth-provider"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; + +export default function LoginPage() { + return ( + + + + ); +} + +function LoginPageInner() { + const { t, lang, dir } = useLanguage(); + const { login } = useAuth(); + const router = useRouter(); + const searchParams = useSearchParams(); + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + const Arrow = lang === "fa" ? ArrowRight : ArrowLeft; + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [mfaCode, setMfaCode] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [rememberDevice, setRememberDevice] = useState(false); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const [mfaRequired, setMfaRequired] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + const result = await login(email, password, mfaRequired ? mfaCode : ""); + if (result.mfaRequired) { + setMfaRequired(true); + setError(""); + return; + } + if (rememberDevice) { + localStorage.setItem("remember_device", "true"); + } + const next = searchParams.get("next"); + router.replace(next ? decodeURIComponent(next) : "/account"); + } catch (err) { + setError(err instanceof Error ? err.message : "Login failed"); + } finally { + setLoading(false); + } + }; + + if (!mounted) return null; + + return ( +
+
+
+ + + + + {process.env.NEXT_PUBLIC_SITE_NAME || "MyAccount Hamsoo"} + +

{t.nav.login}

+

{lang === "fa" ? "به اکوسیستم هویت خود خوش آمدید" : "Sign in to your identity ecosystem"}

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+
+ +
+ + + setEmail(e.target.value)} + required + className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-white/10 bg-white/5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand-500/50 focus:border-transparent" + placeholder={lang === "fa" ? "you@example.com" : "you@example.com"} + dir="ltr" + /> +
+
+ +
+ +
+ + + setPassword(e.target.value)} + required + className="w-full pl-10 pr-12 py-2.5 rounded-lg border border-white/10 bg-white/5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand-500/50 focus:border-transparent" + placeholder={lang === "fa" ? "••••••••" : "••••••••"} + dir="ltr" + /> + +
+
+ + {mfaRequired && ( +
+ + setMfaCode(e.target.value)} + required + className="w-full px-4 py-2.5 rounded-lg border border-white/10 bg-white/5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand-500/50 focus:border-transparent" + placeholder={lang === "fa" ? "123456" : "123456"} + dir="ltr" + maxLength={6} + /> +
+ )} + +
+ + {!mfaRequired && ( + + {lang === "fa" ? "فراموشی رمز عبور؟" : "Forgot password?"} + + )} +
+ + +
+ +
+

+ {lang === "fa" ? "حساب ندارید؟" : "Don't have an account?"}{" "} + + {lang === "fa" ? "ثبت‌نام" : "Sign up"} + +

+
+
+ +
+ + + {lang === "fa" ? "مرکز حساب همسو" : "MyAccount Hamsoo"} + +

{lang === "fa" ? "بنیاد امن برای همه محصولات" : "Secure foundation for all products"}

+
+
+
+ ); +} + +function Link({ href, children, className }: { href: string; children: React.ReactNode; className?: string }) { + return {children}; +} diff --git a/apps/web/app/oauth/consent/page.tsx b/apps/web/app/oauth/consent/page.tsx new file mode 100644 index 0000000..0dd0abe --- /dev/null +++ b/apps/web/app/oauth/consent/page.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useSearchParams, useRouter } from "next/navigation"; +import { Suspense, useEffect, useState } from "react"; +import { LoginForm } from "@/components/auth/login-form"; +import { useLanguage } from "@/components/language-provider"; + +interface Application { + client_id: string; + name: string; + redirect_uris: string[]; +} + +interface ConsentData { + application: Application; + scopes: string[]; + login_url: string; +} + +function ConsentContent() { + const searchParams = useSearchParams(); + const router = useRouter(); + const { lang, dir } = useLanguage(); + + const client_id = searchParams.get("client_id") || ""; + const redirect_uri = searchParams.get("redirect_uri") || ""; + const scope = searchParams.get("scope") || "openid"; + const state = searchParams.get("state") || ""; + const nonce = searchParams.get("nonce") || ""; + const code_challenge = searchParams.get("code_challenge") || ""; + const code_challenge_method = searchParams.get("code_challenge_method") || "plain"; + + const [consentData, setConsentData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [token, setToken] = useState(null); + + const t = (fa: string, en: string) => (lang === "fa" ? fa : en); + + const fetchConsent = async () => { + try { + const storedToken = localStorage.getItem("access_token"); + if (storedToken) { + setToken(storedToken); + } + + const params = new URLSearchParams({ + client_id, + redirect_uri, + response_type: "code", + scope, + state, + nonce, + code_challenge, + code_challenge_method, + }); + + const headers: Record = {}; + if (storedToken) { + headers["Authorization"] = `Bearer ${storedToken}`; + } + + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/oauth/authorize?${params.toString()}`, + { headers } + ); + + if (res.status === 401) { + const data = await res.json(); + setConsentData({ + application: data.application || { client_id, name: "", redirect_uris: [redirect_uri] }, + scopes: data.scopes || scope.split(" "), + login_url: data.login_url, + }); + setToken(null); + } else if (res.ok) { + const data = await res.json(); + setConsentData({ + application: data.application || { client_id, name: "", redirect_uris: [redirect_uri] }, + scopes: data.scopes || scope.split(" "), + login_url: "", + }); + } + } catch (e) { + setError("خطا در بارگذاری اطلاعات برنامه"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchConsent(); + }, []); + + const handleConsent = async () => { + if (!token) return; + + const params = new URLSearchParams({ + client_id, + redirect_uri, + response_type: "code", + scope, + state, + nonce, + code_challenge, + code_challenge_method, + }); + + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/oauth/authorize?${params.toString()}`, + { + headers: { Authorization: `Bearer ${token}` }, + } + ); + + if (res.ok) { + const data = await res.json(); + const code = data.code; + const redirectParams = new URLSearchParams(); + redirectParams.set("code", code); + if (state) redirectParams.set("state", state); + + window.location.href = `${redirect_uri}?${redirectParams.toString()}`; + } + }; + + const handleLogin = (access: string) => { + setToken(access); + handleConsent(); + }; + + if (loading) { + return ( +
+
+
+

{t("در حال بارگذاری...", "Loading...")}

+
+
+ ); + } + + return ( +
+
+
+

+ {t("تایید دسترسی", "Authorize Application")} +

+ + {error && ( +
{error}
+ )} + + {consentData && ( +
+
+

+ {t("برنامه درخواست دسترسی به:", "This application requests access to:")} +

+
+ + {consentData.application?.name?.charAt(0) || "App"} + +
+
+ {consentData.application?.name || client_id} +
+
{client_id}
+
+
+
+ +
+

+ {t("دسترسی‌های درخواستی:", "Requested permissions:")} +

+
    + {consentData.scopes.map((s: string) => ( +
  • + {s} +
  • + ))} +
+
+ + {!token ? ( + + ) : ( +
+ + +
+ )} +
+ )} +
+
+
+ ); +} + +export default function ConsentPage() { + return ( +
Loading...
}> + + + ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx new file mode 100644 index 0000000..90f6dda --- /dev/null +++ b/apps/web/app/page.tsx @@ -0,0 +1,253 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { ArrowLeft, ArrowRight, ShieldCheck, SlidersHorizontal, UserCircle2, Lock, Smartphone, KeyRound, MailCheck, LogIn, Layers } from "lucide-react"; +import { useLanguage } from "@/components/language-provider"; +import { useLoginModal } from "@/components/login-modal-provider"; +import { buttonVariants } from "@/components/ui/button"; +import { Reveal } from "@/components/ui/reveal"; +import { ScrollProgress } from "@/components/ui/scroll-progress"; +import { cn } from "@/lib/utils"; + +export default function Home() { + const { t, lang } = useLanguage(); + const { open: openLogin } = useLoginModal(); + const Arrow = lang === "fa" ? ArrowLeft : ArrowRight; + const [heroIn, setHeroIn] = useState(false); + + useEffect(() => { + const t = setTimeout(() => setHeroIn(true), 80); + return () => clearTimeout(t); + }, []); + + const CHIPS = [ + { icon: ShieldCheck, fa: "احراز هویت امن", en: "Secure auth" }, + { icon: MailCheck, fa: "تأیید ایمیل", en: "Email verified" }, + { icon: LogIn, fa: "ورود بی‌دردسر", en: "Frictionless login" }, + { icon: Layers, fa: "احراز چند مرحله‌ای", en: "Multi-step auth" }, + ]; + + return ( +
+ + +
+
+ {t.hero.badge} +
+

+ {t.hero.title} +

+

+ {t.hero.subtitle} +

+
+ + + {t.hero.ctaSecondary} + +
+ +
+ {CHIPS.map((c) => ( + + + + + {lang === "fa" ? c.fa : c.en} + + ))} +
+
+ +
+
+ +
+
{t.benefits.eyebrow}
+

{t.benefits.title}

+

{t.benefits.subtitle}

+
+
+
+ {t.benefits.points.map((p, i) => ( + + + + ))} +
+
+
+ +
+ +
+
{t.ecosystem.eyebrow}
+

{t.ecosystem.title}

+

{t.ecosystem.subtitle}

+
+
+
+ + + + + + + + + +
+
+ +
+
+ +
+
{t.capabilities.eyebrow}
+

{t.capabilities.title}

+

{t.capabilities.subtitle}

+
+
+
+ {t.capabilities.items.map((it, idx) => ( + + + + ))} +
+
+
+ +
+
+ +
+
{t.safety.eyebrow}
+

{t.safety.title}

+

{t.safety.subtitle}

+
+ {t.safety.points.map((p, i) => ( + +
+
+ +
+
+
{p.title}
+
{p.body}
+
+
+
+ ))} +
+
+
+ +
+
{t.preview.title}
+
{t.preview.subtitle}
+
+ {t.preview.cards.map((c, i) => ( + +
+
{c.value}
+
{c.label}
+
+
+ ))} +
+ + {t.preview.cta} + + +
+
+
+
+ + +
+
+

{t.cta.title}

+

{t.cta.subtitle}

+ +
+
+
+
+ ); +} + +function GoogleCard({ title, body, icon: Icon, color }: { title: string; body: string; icon: any; color: string }) { + return ( +
+
+ +
+

{title}

+

{body}

+
+ ); +} + +function EcosysCard({ name, desc, color, letter, dashed }: { name: string; desc: string; color: string; letter: string; dashed?: boolean }) { + return ( +
+
{letter}
+
{name}
+
{desc}
+
+ ); +} diff --git a/apps/web/app/register/page.tsx b/apps/web/app/register/page.tsx new file mode 100644 index 0000000..5e4d741 --- /dev/null +++ b/apps/web/app/register/page.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { useState, useEffect, Suspense } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { + Eye, + EyeOff, + Mail, + Lock, + User as UserIcon, + Fingerprint, + ArrowLeft, + ArrowRight, +} from "lucide-react"; +import { useLanguage } from "@/components/language-provider"; +import { useAuth } from "@/components/auth/auth-provider"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; + +export default function RegisterPage() { + return ( + + + + ); +} + +function RegisterPageInner() { + const { t, lang, dir } = useLanguage(); + const { register, isAuthenticated } = useAuth(); + const router = useRouter(); + const searchParams = useSearchParams(); + const Arrow = lang === "fa" ? ArrowRight : ArrowLeft; + + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + const [fullName, setFullName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [passwordConfirm, setPasswordConfirm] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (password !== passwordConfirm) { + setError( + lang === "fa" ? "رمز عبور و تکرار آن یکسان نیستند" : "Passwords do not match", + ); + return; + } + if (password.length < 10) { + setError( + lang === "fa" + ? "رمز عبور باید حداقل ۱۰ کاراکتر باشد" + : "Password must be at least 10 characters", + ); + return; + } + + setLoading(true); + try { + await register({ + email, + password, + password_confirm: passwordConfirm, + full_name: fullName, + }); + const next = searchParams.get("next"); + router.replace(next ? decodeURIComponent(next) : "/account"); + } catch (err: any) { + const data = err?.errors; + let message = err?.message || "Registration failed"; + if (data && typeof data === "object") { + const first = Object.values(data)[0]; + if (Array.isArray(first) && first.length) message = String(first[0]); + } + setError(message); + } finally { + setLoading(false); + } + }; + + if (!mounted) return null; + + // If already signed in, bounce to account. + useEffect(() => { + if (isAuthenticated) router.replace("/account"); + }, [isAuthenticated, router]); + + return ( +
+
+
+ + + + + + {process.env.NEXT_PUBLIC_SITE_NAME || "MyAccount Hamsoo"} + + +

+ {lang === "fa" ? "ایجاد حساب کاربری" : "Create your account"} +

+

+ {lang === "fa" + ? "به اکوسیستم هویت خود بپیوندید" + : "Join your identity ecosystem"} +

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+
+ +
+ + + setFullName(e.target.value)} + className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-white/10 bg-white/5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand-500/50 focus:border-transparent" + placeholder={lang === "fa" ? "نام و نام خانوادگی" : "John Doe"} + dir="ltr" + /> +
+
+ +
+ +
+ + + setEmail(e.target.value)} + required + className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-white/10 bg-white/5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand-500/50 focus:border-transparent" + placeholder="you@example.com" + dir="ltr" + /> +
+
+ +
+ +
+ + + setPassword(e.target.value)} + required + className="w-full pl-10 pr-12 py-2.5 rounded-lg border border-white/10 bg-white/5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand-500/50 focus:border-transparent" + placeholder="••••••••••" + dir="ltr" + /> + +
+
+ +
+ +
+ + + setPasswordConfirm(e.target.value)} + required + className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-white/10 bg-white/5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand-500/50 focus:border-transparent" + placeholder="••••••••••" + dir="ltr" + /> +
+
+ + +
+ +
+

+ {lang === "fa" ? "حساب دارید؟" : "Already have an account?"}{" "} + + {lang === "fa" ? "ورود" : "Sign in"} + +

+
+
+ +
+ + + {lang === "fa" ? "مرکز حساب همسو" : "MyAccount Hamsoo"} + +

+ {lang === "fa" + ? "بنیاد امن برای همه محصولات" + : "Secure foundation for all products"} +

+
+
+
+ ); +} diff --git a/apps/web/app/reset-password/page.tsx b/apps/web/app/reset-password/page.tsx new file mode 100644 index 0000000..cfc2c43 --- /dev/null +++ b/apps/web/app/reset-password/page.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Eye, EyeOff, Lock, Fingerprint, ArrowLeft, ArrowRight, CheckCircle } from "lucide-react"; +import { useLanguage } from "@/components/language-provider"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; + +export default function ResetPasswordPage() { + const { lang, dir } = useLanguage(); + const [mounted, setMounted] = useState(false); + const [token, setToken] = useState(""); + useEffect(() => { + setMounted(true); + const params = new URLSearchParams(window.location.search); + setToken(params.get("token") || ""); + }, []); + const Arrow = lang === "fa" ? ArrowRight : ArrowLeft; + + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [step, setStep] = useState<"set" | "success">("set"); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (password !== confirmPassword) { + setError(lang === "fa" ? "رمزها مطابقت ندارند" : "Passwords do not match"); + return; + } + + if (password.length < 10) { + setError(lang === "fa" ? "رمز باید حداقل ۱۰ کاراکتر باشد" : "Password must be at least 10 characters"); + return; + } + + if (!token) { + setError(lang === "fa" ? "توکن نامعتبر است" : "Invalid token"); + return; + } + + setLoading(true); + + try { + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/password/reset/confirm/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, new_password: password }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.detail || data.error || "Reset failed"); + } + + setStep("success"); + } catch (err) { + setError(err instanceof Error ? err.message : "Reset failed"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ + + + + {process.env.NEXT_PUBLIC_SITE_NAME || "MyAccount Hamsoo"} + + {step === "set" ? ( + <> +

{lang === "fa" ? "تنظیم رمز عبور جدید" : "Set New Password"}

+

+ {lang === "fa" ? "رمز عبور جدید خود را وارد کنید" : "Enter your new password"} +

+ + ) : ( + <> +
+ +
+

{lang === "fa" ? "رمز با موفقیت تغییر کرد" : "Password Changed"}

+

+ {lang === "fa" ? "اکنون می‌توانید با رمز جدید وارد شوید" : "You can now sign in with your new password"} +

+ + )} +
+ +
+ {error && ( +
+ {error} +
+ )} + + {step === "set" && ( +
+
+ +
+ + + setPassword(e.target.value)} + required + minLength={10} + className="w-full pl-10 pr-12 py-2.5 rounded-lg border border-white/10 bg-white/5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand-500/50 focus:border-transparent" + placeholder={lang === "fa" ? "حداقل ۱۰ کاراکتر" : "At least 10 characters"} + dir="ltr" + /> + +
+
+ +
+ +
+ + + setConfirmPassword(e.target.value)} + required + minLength={10} + className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-white/10 bg-white/5 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand-500/50 focus:border-transparent" + placeholder={lang === "fa" ? "رمز را مجدداً وارد کنید" : "Re-enter password"} + dir="ltr" + /> +
+
+ + +
+ )} + + {step === "success" && ( +
+ +
+ )} +
+ +
+ + + {lang === "fa" ? "مرکز حساب همسو" : "MyAccount Hamsoo"} + +
+
+
+ ); +} diff --git a/apps/web/components/admin/admin-layout.tsx b/apps/web/components/admin/admin-layout.tsx new file mode 100644 index 0000000..5c48e35 --- /dev/null +++ b/apps/web/components/admin/admin-layout.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useState, ReactNode } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + LayoutDashboard, + Users, + Building2, + Shield, + LogOut, + Menu, + X, + Fingerprint, + Activity, + Bell, + Mail, +} from "lucide-react"; +import { useLanguage } from "@/components/language-provider"; +import { useAuth } from "@/components/auth/auth-provider"; +import { RouteGuard } from "@/components/auth/route-guard"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const navItems = [ + { key: "overview", labelFa: "نمایش کلی", labelEn: "Overview", href: "/admin", icon: LayoutDashboard }, + { key: "users", labelFa: "کاربران", labelEn: "Users", href: "/admin/users", icon: Users }, + { key: "members", labelFa: "اعضا", labelEn: "Members", href: "/admin/members", icon: Users }, + { key: "invitations", labelFa: "دعوت‌ها", labelEn: "Invitations", href: "/admin/invitations", icon: Mail }, + { key: "orgs", labelFa: "سازمان\u200cها", labelEn: "Organizations", href: "/admin/organizations", icon: Building2 }, + { key: "products", labelFa: "محصول‌ها", labelEn: "Products", href: "/admin/products", icon: Shield }, + { key: "apps", labelFa: "اپلیکیشن‌ها", labelEn: "Applications", href: "/admin/applications", icon: Shield }, + { key: "notifications", labelFa: "اعلانات", labelEn: "Notifications", href: "/admin/notifications", icon: Bell }, + { key: "sessions", labelFa: "نشست\u200cها", labelEn: "Sessions", href: "/admin/sessions", icon: Activity }, + { key: "events", labelFa: "رویدادهای امنیتی", labelEn: "Security Events", href: "/admin/events", icon: Activity }, + { key: "security", labelFa: "امنیت", labelEn: "Security", href: "/admin/security", icon: Fingerprint }, +]; + +export function AdminLayout({ children, className }: { children: ReactNode; className?: string }) { + const { lang, toggle, dir } = useLanguage(); + const { logout } = useAuth(); + const pathname = usePathname(); + const [sidebarOpen, setSidebarOpen] = useState(false); + + const t = (fa: string, en: string) => (lang === "fa" ? fa : en); + + return ( + +
+ {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + + + +
+
+
+ +

+ {navItems.find((i) => pathname === i.href) + ? t( + navItems.find((i) => pathname === i.href)?.labelFa || "", + navItems.find((i) => pathname === i.href)?.labelEn || "", + ) + : t("نمایش کلی", "Overview")} +

+
+
+
+ {children} +
+
+
+ + ); +} diff --git a/apps/web/components/admin/auth-context.tsx b/apps/web/components/admin/auth-context.tsx new file mode 100644 index 0000000..8c8f45e --- /dev/null +++ b/apps/web/components/admin/auth-context.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useEffect, useState, ReactNode } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; + +const TOKEN_EXPIRY = 15 * 60 * 1000; + +export interface AuthState { + accessToken: string | null; + refreshToken: string | null; + user: any | null; + isAuthenticated: boolean; + isLoading: boolean; +} + +export function useAuthApi() { + const [auth, setAuth] = useState({ + accessToken: null, + refreshToken: null, + user: null, + isAuthenticated: false, + isLoading: true, + }); + + useEffect(() => { + const access = localStorage.getItem("access_token"); + const refresh = localStorage.getItem("refresh_token"); + if (access) { + setAuth((prev) => ({ + ...prev, + accessToken: access, + refreshToken: refresh, + isAuthenticated: true, + isLoading: false, + })); + } else { + setAuth((prev) => ({ ...prev, isLoading: false })); + } + }, []); + + const logout = () => { + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + setAuth({ + accessToken: null, + refreshToken: null, + user: null, + isAuthenticated: false, + isLoading: false, + }); + }; + + const apiFetch = async (url: string, options: RequestInit = {}) => { + const baseURL = process.env.NEXT_PUBLIC_API_URL; + const fullUrl = url.startsWith("http") ? url : `${baseURL}${url}`; + + const headers = new Headers(options.headers); + headers.set("Content-Type", "application/json"); + + if (auth.accessToken) { + headers.set("Authorization", `Bearer ${auth.accessToken}`); + } + + let res = await fetch(fullUrl, { ...options, headers }); + return res; + }; + + return { auth, setAuth, apiFetch, logout }; +} diff --git a/apps/web/components/auth/auth-provider.tsx b/apps/web/components/auth/auth-provider.tsx new file mode 100644 index 0000000..34df7b2 --- /dev/null +++ b/apps/web/components/auth/auth-provider.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useEffect, + useState, + ReactNode, +} from "react"; +import { + apiFetch, + authLogin, + authLogout, + authRegister, + clearTokens, + fetchMe, + getAccessToken, + getRefreshToken, + setTokens, + ACCESS_TOKEN_KEY, + REFRESH_TOKEN_KEY, + SESSION_ID_KEY, +} from "@/lib/api"; + +export interface AuthUser { + user_id: string; + email: string; + username?: string | null; + full_name: string | null; + [key: string]: any; +} + +interface AuthState { + user: AuthUser | null; + isAuthenticated: boolean; + isLoading: boolean; +} + +interface AuthContextValue extends AuthState { + login: ( + email: string, + password: string, + mfaCode?: string, + ) => Promise<{ mfaRequired?: boolean }>; + register: (payload: { + email: string; + password: string; + password_confirm: string; + full_name?: string; + username?: string; + }) => Promise; + logout: () => Promise; + refreshUser: () => Promise; + apiFetch: typeof apiFetch; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState({ + user: null, + isAuthenticated: false, + isLoading: true, + }); + + const refreshUser = useCallback(async () => { + const user = await fetchMe(); + if (user) { + setState((prev) => ({ + ...prev, + user, + isAuthenticated: true, + isLoading: false, + })); + } else { + clearTokens(); + setState({ user: null, isAuthenticated: false, isLoading: false }); + } + }, []); + + // Bootstrap: if a token exists in storage, load the current user. + useEffect(() => { + let active = true; + if (typeof window === "undefined") { + setState((prev) => ({ ...prev, isLoading: false })); + return; + } + if (getAccessToken()) { + fetchMe().then((user) => { + if (!active) return; + if (user) { + setState({ user, isAuthenticated: true, isLoading: false }); + } else { + clearTokens(); + setState({ user: null, isAuthenticated: false, isLoading: false }); + } + }); + } else { + setState((prev) => ({ ...prev, isLoading: false })); + } + return () => { + active = false; + }; + }, []); + + const login = useCallback( + async (email: string, password: string, mfaCode?: string) => { + const { ok, data } = await authLogin(email, password, mfaCode); + if (!ok) { + if (data?.mfa_required) { + return { mfaRequired: true }; + } + const err = new Error(data?.detail || "Login failed") as any; + err.status = data?.status; + throw err; + } + setTokens(data.access, data.refresh, data.session_id); + const user = await fetchMe(); + setState({ + user: user ?? null, + isAuthenticated: true, + isLoading: false, + }); + return {}; + }, + [], + ); + + const register = useCallback( + async (payload: { + email: string; + password: string; + password_confirm: string; + full_name?: string; + username?: string; + }) => { + const { ok, data } = await authRegister(payload); + if (!ok) { + const err = new Error(data?.detail || "Registration failed") as any; + err.errors = data; + err.status = data?.status; + throw err; + } + setTokens(data.access, data.refresh, data.session_id); + setState({ + user: data.user ?? null, + isAuthenticated: true, + isLoading: false, + }); + }, + [], + ); + + const logout = useCallback(async () => { + await authLogout(getRefreshToken()); + clearTokens(); + setState({ user: null, isAuthenticated: false, isLoading: false }); + }, []); + + const value: AuthContextValue = { + ...state, + login, + register, + logout, + refreshUser, + apiFetch, + }; + + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return ctx; +} + +export { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, SESSION_ID_KEY }; diff --git a/apps/web/components/auth/login-form.tsx b/apps/web/components/auth/login-form.tsx new file mode 100644 index 0000000..f3d1878 --- /dev/null +++ b/apps/web/components/auth/login-form.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useState } from "react"; +import { useLanguage } from "@/components/language-provider"; +import { useAuth } from "@/components/auth/auth-provider"; +import { getAccessToken } from "@/lib/api"; + +interface LoginFormProps { + onSuccess: (token: string) => void; + redirectUri?: string; + state?: string; +} + +export function LoginForm({ onSuccess, redirectUri, state }: LoginFormProps) { + const { lang, dir } = useLanguage(); + const { login } = useAuth(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const t = (fa: string, en: string) => (lang === "fa" ? fa : en); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(""); + + try { + const result = await login(email, password); + if (result.mfaRequired) { + setError(t("احراز هویت دومرحله‌ای مورد نیاز است", "MFA is required")); + return; + } + const token = getAccessToken() || ""; + onSuccess(token); + } catch (err: any) { + setError(err?.message || t("خطا در ورود", "Login failed")); + } finally { + setLoading(false); + } + }; + + return ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + setEmail(e.target.value)} + className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-brand-500/50" + dir="ltr" + required + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-brand-500/50" + required + /> +
+ + + + {redirectUri && state && ( + + )} +
+ ); +} diff --git a/apps/web/components/auth/route-guard.tsx b/apps/web/components/auth/route-guard.tsx new file mode 100644 index 0000000..1db8c79 --- /dev/null +++ b/apps/web/components/auth/route-guard.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useEffect, ReactNode } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/components/auth/auth-provider"; + +function FullScreenLoader() { + return ( +
+
+
+ ); +} + +export function RouteGuard({ children }: { children: ReactNode }) { + const { isAuthenticated, isLoading } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + const next = encodeURIComponent(window.location.pathname); + router.replace(`/login?next=${next}`); + } + }, [isLoading, isAuthenticated, router]); + + if (isLoading) return ; + if (!isAuthenticated) return ; + + return <>{children}; +} diff --git a/apps/web/components/footer.tsx b/apps/web/components/footer.tsx new file mode 100644 index 0000000..ded695e --- /dev/null +++ b/apps/web/components/footer.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Fingerprint } from "lucide-react"; +import { usePathname } from "next/navigation"; +import { useLanguage } from "@/components/language-provider"; + +export function Footer() { + const { t } = useLanguage(); + const pathname = usePathname(); + if (pathname?.startsWith("/account") || pathname?.startsWith("/admin")) { + return null; + } + return ( + + ); +} diff --git a/apps/web/components/language-provider.tsx b/apps/web/components/language-provider.tsx new file mode 100644 index 0000000..ec8267a --- /dev/null +++ b/apps/web/components/language-provider.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; +import type { Lang } from "@/lib/content"; +import { dict, type Dict } from "@/lib/i18n"; + +interface LanguageContextValue { + lang: Lang; + dir: "rtl" | "ltr"; + t: Dict; + toggle: () => void; + setLang: (lang: Lang) => void; +} + +const LanguageContext = createContext(null); + +const STORAGE_KEY = "ip-lang"; + +function getInitialLang(): Lang { + if (typeof window === "undefined") return "fa"; + const stored = window.localStorage.getItem(STORAGE_KEY); + return stored === "en" || stored === "fa" ? stored : "fa"; +} + +export function LanguageProvider({ children }: { children: ReactNode }) { + const [lang, setLangState] = useState("fa"); + + useEffect(() => { + setLangState(getInitialLang()); + }, []); + + useEffect(() => { + document.documentElement.lang = lang; + document.documentElement.dir = lang === "fa" ? "rtl" : "ltr"; + window.localStorage.setItem(STORAGE_KEY, lang); + }, [lang]); + + const value: LanguageContextValue = { + lang, + dir: lang === "fa" ? "rtl" : "ltr", + t: dict[lang], + toggle: () => setLangState((p) => (p === "fa" ? "en" : "fa")), + setLang: setLangState, + }; + + return ( + + {children} + + ); +} + +export function useLanguage() { + const ctx = useContext(LanguageContext); + if (!ctx) throw new Error("useLanguage must be used within LanguageProvider"); + return ctx; +} diff --git a/apps/web/components/login-modal-provider.tsx b/apps/web/components/login-modal-provider.tsx new file mode 100644 index 0000000..53db579 --- /dev/null +++ b/apps/web/components/login-modal-provider.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { createContext, useContext, useState, type ReactNode } from "react"; +import { LoginModal } from "@/components/login-modal"; + +const Ctx = createContext<{ open: () => void; close: () => void } | null>(null); + +export function LoginModalProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + return ( + setOpen(true), close: () => setOpen(false) }}> + {children} + setOpen(false)} /> + + ); +} + +export function useLoginModal() { + const c = useContext(Ctx); + if (!c) throw new Error("useLoginModal must be within LoginModalProvider"); + return c; +} diff --git a/apps/web/components/login-modal.tsx b/apps/web/components/login-modal.tsx new file mode 100644 index 0000000..01c95bb --- /dev/null +++ b/apps/web/components/login-modal.tsx @@ -0,0 +1,284 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { X, Eye, EyeOff, Mail, Lock, Fingerprint, ArrowRight, ArrowLeft, Chrome, Github, Send } from "lucide-react"; +import { useLanguage } from "@/components/language-provider"; +import { useAuth } from "@/components/auth/auth-provider"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export function LoginModal({ open, onClose }: { open: boolean; onClose: () => void }) { + const { lang } = useLanguage(); + const { login } = useAuth(); + const router = useRouter(); + const Back = lang === "fa" ? ArrowRight : ArrowLeft; + + const [step, setStep] = useState<"credentials" | "otp">("credentials"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const [otp, setOtp] = useState(["", "", "", "", "", ""]); + const [countdown, setCountdown] = useState(60); + const otpRefs = useRef<(HTMLInputElement | null)[]>([]); + + useEffect(() => { + if (!open) { + setStep("credentials"); + setError(""); + setOtp(["", "", "", "", "", ""]); + } + }, [open]); + + useEffect(() => { + if (step !== "otp") return; + setCountdown(60); + const id = setInterval(() => { + setCountdown((c) => (c > 0 ? c - 1 : 0)); + }, 1000); + return () => clearInterval(id); + }, [step]); + + if (!open) return null; + + const isLocal = (process.env.NEXT_PUBLIC_API_URL || "").includes("localhost"); + + const apiBase = + process.env.NEXT_PUBLIC_API_URL?.replace(/\/api\/v1$/, "") || + "http://localhost:8000"; + const ssoLoginUrl = `${apiBase}/sso/login/`; + + const doLogin = async (mfaCode: string) => { + setError(""); + setLoading(true); + try { + const result = await login(email, password, mfaCode); + if (result.mfaRequired) { + setStep("otp"); + setTimeout(() => otpRefs.current[0]?.focus(), 100); + return; + } + onClose(); + router.replace("/account"); + } catch (err) { + setError(err instanceof Error ? err.message : "Login failed"); + } finally { + setLoading(false); + } + }; + + const handleOtpChange = (i: number, v: string) => { + const digit = v.replace(/\D/g, "").slice(-1); + const next = [...otp]; + next[i] = digit; + setOtp(next); + if (digit && i < 5) otpRefs.current[i + 1]?.focus(); + if (next.join("").length === 6 && !next.includes("")) doLogin(next.join("")); + }; + + const handleOtpKeyDown = (i: number, e: React.KeyboardEvent) => { + if (e.key === "Backspace" && !otp[i] && i > 0) { + otpRefs.current[i - 1]?.focus(); + const next = [...otp]; + next[i - 1] = ""; + setOtp(next); + } + }; + + const fillTest = () => { + setEmail("alighafouri.v@gmail.com"); + setPassword("Hamsoo@1234"); + }; + + return ( +
+
+
+ + + {step === "credentials" ? ( + <> +
+ + + +

{lang === "fa" ? "خوش آمدید" : "Welcome back"}

+

+ {lang === "fa" ? "برای ادامه وارد حساب خود شوید" : "Sign in to continue"} +

+
+ + {isLocal && ( +
+
{lang === "fa" ? "حالت تست لوکال" : "Local test mode"}
+
+ {lang === "fa" ? "اگر حساب ندارید بسازید:" : "No account? Create one:"} + + python manage.py createsuperuser + +
+ +
+ )} + + {error && ( +
{error}
+ )} + +
{ + e.preventDefault(); + doLogin(""); + }} + className="mt-5 space-y-4" + > +
+ +
+ + setEmail(e.target.value)} + required + className="w-full rounded-full border border-slate-200 bg-white py-2.5 pl-10 pr-4 text-sm text-slate-800 placeholder:text-slate-300 focus:border-[#6d5ef0]/50 focus:outline-none focus:ring-2 focus:ring-[#6d5ef0]/20" + placeholder="you@example.com" + dir="ltr" + /> +
+
+
+ +
+ + setPassword(e.target.value)} + required + className="w-full rounded-full border border-slate-200 bg-white py-2.5 pl-10 pr-11 text-sm text-slate-800 placeholder:text-slate-300 focus:border-[#6d5ef0]/50 focus:outline-none focus:ring-2 focus:ring-[#6d5ef0]/20" + placeholder="••••••••" + dir="ltr" + /> + +
+
+ + + + + {lang === "fa" ? "بازیابی رمز عبور" : "Forgot password?"} + +
+ +
+ + {lang === "fa" ? "یا با روش‌های دیگر وارد شوید" : "Or sign in with"} + +
+ +
+ {[Chrome, Github, Send].map((Icon, i) => ( + + + + ))} +
+ + ) : ( + <> + + +
+ + + +

{lang === "fa" ? "تأیید هویت دومرحله‌ای" : "Two-step verification"}

+

+ {lang === "fa" + ? "کد تأیید ۶ رقمی ارسال‌شده به تلفن همراه شما را وارد کنید." + : "Enter the 6-digit code sent to your phone."} +

+
+ + {error && ( +
{error}
+ )} + +
+ {otp.map((digit, i) => ( + { + otpRefs.current[i] = el; + }} + type="text" + inputMode="numeric" + maxLength={1} + value={digit} + onChange={(e) => handleOtpChange(i, e.target.value)} + onKeyDown={(e) => handleOtpKeyDown(i, e)} + className={cn( + "rounded-xl border-2 bg-white text-center text-xl font-bold text-[#1c1d3a] outline-none transition", + digit ? "border-[#6d5ef0]" : "border-slate-200", + "focus:border-[#6d5ef0] focus:ring-2 focus:ring-[#6d5ef0]/15" + )} + style={{ height: 52, width: 46 }} + /> + ))} +
+ +
+ {countdown > 0 ? ( + + {lang === "fa" ? "ارسال مجدد کد تا" : "Resend code in"}{" "} + + {String(Math.floor(countdown / 60)).padStart(2, "0")}:{String(countdown % 60).padStart(2, "0")} + + + ) : ( + + )} +
+ + + + )} +
+
+ ); +} diff --git a/apps/web/components/navbar.tsx b/apps/web/components/navbar.tsx new file mode 100644 index 0000000..8dfc11e --- /dev/null +++ b/apps/web/components/navbar.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { Fingerprint, Languages, ArrowLeft, ArrowRight, Moon, Sun } from "lucide-react"; +import { usePathname } from "next/navigation"; +import { useLanguage } from "@/components/language-provider"; +import { useTheme } from "@/components/theme-provider"; +import { useLoginModal } from "@/components/login-modal-provider"; +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const LINKS = [ + { id: "benefits", label: { fa: "مزایا", en: "Benefits" } }, + { id: "ecosystem", label: { fa: "محصولات", en: "Products" } }, + { id: "capabilities", label: { fa: "قابلیت‌ها", en: "Capabilities" } }, + { id: "safety", label: { fa: "امنیت", en: "Safety" } }, +]; + +export function Navbar() { + const { t, lang, toggle } = useLanguage(); + const { theme, toggle: toggleTheme } = useTheme(); + const { open } = useLoginModal(); + const pathname = usePathname(); + const Arrow = lang === "fa" ? ArrowLeft : ArrowRight; + + if (pathname?.startsWith("/account") || pathname?.startsWith("/admin")) return null; + + return ( +
+
+ + + + + {process.env.NEXT_PUBLIC_SITE_NAME || "MyAccount Hamsoo"} + + + + +
+ + + +
+
+
+ ); +} diff --git a/apps/web/components/sections/account-preview.tsx b/apps/web/components/sections/account-preview.tsx new file mode 100644 index 0000000..2211ba2 --- /dev/null +++ b/apps/web/components/sections/account-preview.tsx @@ -0,0 +1,57 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeft, ArrowRight, UserCircle2 } from "lucide-react"; +import { useLanguage } from "@/components/language-provider"; +import { Section, SectionHeading } from "@/components/ui/section"; +import { Button } from "@/components/ui/button"; + +export function AccountPreview() { + const { t, lang } = useLanguage(); + const Arrow = lang === "fa" ? ArrowLeft : ArrowRight; + + return ( +
+
+ + +
+
+
+ +
+
+
MyAccount Hamsoo
+
user@hamsoo.com
+
+
+ +
+ {t.preview.cards.map((c) => ( +
+
{c.value}
+
{c.label}
+
+ ))} +
+ +
+ + + +
+
+
+
+ ); +} diff --git a/apps/web/components/sections/architecture.tsx b/apps/web/components/sections/architecture.tsx new file mode 100644 index 0000000..c24942e --- /dev/null +++ b/apps/web/components/sections/architecture.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useLanguage } from "@/components/language-provider"; +import { Section, SectionHeading } from "@/components/ui/section"; + +export function Architecture() { + const { t } = useLanguage(); + return ( +
+
+ + +
+
+
+ + IP + +

+ {t.architecture.identityTitle} +

+
+

+ MyAccount Hamsoo +

+
+ +
+
+ + ◆ + +

+ {t.architecture.productTitle} +

+
+

+ Bermooda · Hamsoo · Future Apps +

+
+
+ +
+ + + + + + + + + {t.architecture.rows.map((r, i) => ( + + + + + ))} + +
+ {t.architecture.tableHead.data} + + {t.architecture.tableHead.owner} +
{r.data} + + {r.owner} + +
+
+
+
+ ); +} diff --git a/apps/web/components/sections/benefits.tsx b/apps/web/components/sections/benefits.tsx new file mode 100644 index 0000000..13219e4 --- /dev/null +++ b/apps/web/components/sections/benefits.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { Link2, UserCircle2, SlidersHorizontal } from "lucide-react"; +import { useLanguage } from "@/components/language-provider"; +import { Section, SectionHeading } from "@/components/ui/section"; + +const ICONS = [Link2, UserCircle2, SlidersHorizontal]; + +export function Benefits() { + const { t } = useLanguage(); + return ( +
+
+ +
+ {t.benefits.points.map((p, i) => { + const Icon = ICONS[i]; + return ( +
+
+ +
+

{p.title}

+

{p.body}

+
+ ); + })} +
+
+
+ ); +} diff --git a/apps/web/components/sections/capabilities.tsx b/apps/web/components/sections/capabilities.tsx new file mode 100644 index 0000000..e582d83 --- /dev/null +++ b/apps/web/components/sections/capabilities.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { + LogIn, + ShieldCheck, + Smartphone, + Lock, + KeyRound, + Sparkles, + type LucideIcon, +} from "lucide-react"; +import { useLanguage } from "@/components/language-provider"; +import { Section, SectionHeading } from "@/components/ui/section"; + +const ICONS: Record = { + signin: LogIn, + shield: ShieldCheck, + devices: Smartphone, + privacy: Lock, + recovery: KeyRound, + personalize: Sparkles, +}; + +export function Capabilities() { + const { t } = useLanguage(); + return ( +
+
+ +
+ {t.capabilities.items.map((it) => { + const Icon = ICONS[it.icon] ?? Sparkles; + return ( +
+
+ +
+

{it.title}

+

{it.body}

+
+ ); + })} +
+
+
+ ); +} diff --git a/apps/web/components/sections/cta.tsx b/apps/web/components/sections/cta.tsx new file mode 100644 index 0000000..9a12c32 --- /dev/null +++ b/apps/web/components/sections/cta.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { ArrowRight, ArrowLeft } from "lucide-react"; +import Link from "next/link"; +import { useLanguage } from "@/components/language-provider"; +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export function Cta() { + const { t, lang } = useLanguage(); + const Arrow = lang === "fa" ? ArrowLeft : ArrowRight; + return ( +
+
+
+ +
+

+ {t.cta.title} +

+

+ {t.cta.subtitle} +

+
+ + {t.cta.button} + + +
+
+
+ ); +} diff --git a/apps/web/components/sections/dashboard.tsx b/apps/web/components/sections/dashboard.tsx new file mode 100644 index 0000000..42bfc47 --- /dev/null +++ b/apps/web/components/sections/dashboard.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { Users, MonitorSmartphone, ShieldAlert, Activity } from "lucide-react"; +import { useLanguage } from "@/components/language-provider"; +import { Section, SectionHeading } from "@/components/ui/section"; + +const STATS = [ + { key: "users", value: "128,492", icon: "users" }, + { key: "sessions", value: "3,914", icon: "sessions" }, + { key: "events", value: "742", icon: "events" }, + { key: "activeNow", value: "1,208", icon: "activeNow" }, +] as const; + +const ICONS = { + users: Users, + sessions: MonitorSmartphone, + events: ShieldAlert, + activeNow: Activity, +}; + +const SESSION_ROWS = [ + { user: "leila@bermooda.io", app: "Bermooda", loc: "Tehran, IR", status: "active" }, + { user: "sam@hamsoo.com", app: "Hamsoo", loc: "Istanbul, TR", status: "active" }, + { user: "ops@acme.com", app: "Bermooda", loc: "Berlin, DE", status: "idle" }, + { user: "dev@acme.com", app: "Hamsoo", loc: "Dubai, AE", status: "active" }, +]; + +const EVENT_ROWS = [ + { kind: "login_success", who: "leila@bermooda.io", when: "2m" }, + { kind: "token_refreshed", who: "sam@hamsoo.com", when: "6m" }, + { kind: "login_failed", who: "unknown@acme.com", when: "14m" }, + { kind: "session_revoked", who: "ops@acme.com", when: "22m" }, +]; + +export function Dashboard() { + const { t, lang } = useLanguage(); + return ( +
+
+ + +
+
+ {STATS.map((s) => { + const Icon = ICONS[s.icon]; + return ( +
+ + + +
{s.value}
+
+ {t.dashboard[s.key]} +
+
+ ); + })} +
+
+ +
+
+

+ {t.dashboard.sessions} +

+
+ {SESSION_ROWS.map((r, i) => ( +
+
+ + {r.user.charAt(0).toUpperCase()} + +
+
{r.user}
+
+ {r.app} · {r.loc} +
+
+
+
+ + {r.status === "active" + ? lang === "fa" + ? "فعال" + : "Active" + : lang === "fa" + ? "غیرفعال" + : "Idle"} + + +
+
+ ))} +
+
+ +
+

+ {t.dashboard.events} +

+
+ {EVENT_ROWS.map((r, i) => ( +
+ {r.kind} + {r.who} + {r.when} +
+ ))} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/components/sections/developers.tsx b/apps/web/components/sections/developers.tsx new file mode 100644 index 0000000..a47a962 --- /dev/null +++ b/apps/web/components/sections/developers.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useLanguage } from "@/components/language-provider"; +import { Section, SectionHeading } from "@/components/ui/section"; +import { PROTOCOLS } from "@/lib/content"; + +const ENDPOINTS = [ + "POST /api/v1/auth/login", + "POST /api/v1/auth/refresh", + "GET /api/v1/users/me", + "GET /api/v1/organizations", + "GET /api/v1/sessions", + "GET /.well-known/openid-configuration", +]; + +const CODE = `curl -X POST https://id.example.com/api/v1/auth/login \\ + -H "Content-Type: application/json" \\ + -d '{"email":"user@acme.com","password":"••••••••"}' + +# => { "access": "eyJ...", "refresh": "eyJ...", "token_type": "bearer" }`; + +export function Developers() { + const { t, lang } = useLanguage(); + return ( +
+
+ + +
+
+

+ {t.developers.endpointsTitle} +

+
    + {ENDPOINTS.map((e) => ( +
  • + {e} +
  • + ))} +
+
+ {PROTOCOLS.map((p) => ( + + {p} + + ))} +
+
+ +
+

+ {t.developers.codeTitle} +

+
+              {CODE}
+            
+
+
+
+
+ ); +} diff --git a/apps/web/components/sections/ecosystem.tsx b/apps/web/components/sections/ecosystem.tsx new file mode 100644 index 0000000..e305eac --- /dev/null +++ b/apps/web/components/sections/ecosystem.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useLanguage } from "@/components/language-provider"; +import { Section, SectionHeading } from "@/components/ui/section"; +import { PRODUCTS } from "@/lib/content"; + +export function Ecosystem() { + const { t, lang } = useLanguage(); + return ( +
+
+ + +
+
+ {PRODUCTS.map((p) => ( +
+
+ {p.name.charAt(0)} +
+
{p.name}
+
{p.category[lang]}
+

+ {p.tagline[lang]} +

+ + {p.status === "connected" + ? lang === "fa" + ? "متصل شده" + : "Connected" + : lang === "fa" + ? "آینده" + : "Future"} + +
+ ))} +
+ +
+ + + + + {t.ecosystem.futureNote} +
+ +
+ + {t.ecosystem.hubLabel} + +
+
+
+
+ ); +} diff --git a/apps/web/components/sections/features.tsx b/apps/web/components/sections/features.tsx new file mode 100644 index 0000000..cd57f79 --- /dev/null +++ b/apps/web/components/sections/features.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { KeyRound, Shield, Users, Building2, Activity, Lock } from "lucide-react"; +import { useLanguage } from "@/components/language-provider"; +import { Section, SectionHeading } from "@/components/ui/section"; +import { FEATURES } from "@/lib/content"; + +const ICONS = { + key: KeyRound, + shield: Shield, + users: Users, + building: Building2, + activity: Activity, + lock: Lock, +} as const; + +export function Features() { + const { t, lang } = useLanguage(); + return ( +
+
+ +
+ {FEATURES.map((f) => { + const Icon = ICONS[f.icon]; + return ( +
+
+ +
+

{f.title[lang]}

+

+ {f.description[lang]} +

+
+ ); + })} +
+
+
+ ); +} diff --git a/apps/web/components/sections/hero.tsx b/apps/web/components/sections/hero.tsx new file mode 100644 index 0000000..d89353f --- /dev/null +++ b/apps/web/components/sections/hero.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { ArrowRight, ArrowLeft, ShieldCheck, UserCircle2 } from "lucide-react"; +import Link from "next/link"; +import { useLanguage } from "@/components/language-provider"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { PRODUCTS } from "@/lib/content"; +import { cn } from "@/lib/utils"; + +export function Hero() { + const { t, lang, dir } = useLanguage(); + const Arrow = lang === "fa" ? ArrowLeft : ArrowRight; + + return ( +
+
+
+ +
+
+ + + {t.hero.badge} + +

+ {t.hero.title} +

+

+ {t.hero.subtitle} +

+
+ + {t.hero.ctaPrimary} + + + + + {t.hero.ctaSecondary} + +
+

{t.hero.note}

+
+ +
+ +
+
+
+ ); +} + +function HubVisual() { + const { lang } = useLanguage(); + return ( +
+
+
+ + + {lang === "fa" ? "هاب مرکزی" : "Central Hub"} + +
+
+ + {PRODUCTS.map((p, i) => { + const angle = (Math.PI * 2 * i) / PRODUCTS.length - Math.PI / 2; + const radius = 130; + const x = 50 + (Math.cos(angle) * radius) / 3.2; + const y = 50 + (Math.sin(angle) * radius) / 3.2; + return ( +
+
+ {p.name} + + {p.status === "connected" + ? lang === "fa" + ? "متصل" + : "Live" + : lang === "fa" + ? "آینده" + : "Soon"} + +
+
+ ); + })} + + + {PRODUCTS.map((_, i) => { + const angle = (Math.PI * 2 * i) / PRODUCTS.length - Math.PI / 2; + const radius = 130; + const x = 50 + (Math.cos(angle) * radius) / 3.2; + const y = 50 + (Math.sin(angle) * radius) / 3.2; + return ( + + ); + })} + +
+ ); +} diff --git a/apps/web/components/sections/how.tsx b/apps/web/components/sections/how.tsx new file mode 100644 index 0000000..56680f5 --- /dev/null +++ b/apps/web/components/sections/how.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { Compass, Fingerprint, Ticket, BadgeCheck } from "lucide-react"; +import { useLanguage } from "@/components/language-provider"; +import { Section, SectionHeading } from "@/components/ui/section"; +import { STEPS } from "@/lib/content"; + +const ICONS = { compass: Compass, fingerprint: Fingerprint, ticket: Ticket, badgeCheck: BadgeCheck } as const; + +export function How() { + const { t, lang } = useLanguage(); + return ( +
+
+ +
+ {STEPS.map((s, i) => { + const Icon = ICONS[s.icon as keyof typeof ICONS]; + return ( +
+
+
+ +
+ 0{i + 1} +
+

+ {s.title[lang]} +

+

+ {s.description[lang]} +

+
+ ); + })} +
+
+
+ ); +} diff --git a/apps/web/components/sections/problem.tsx b/apps/web/components/sections/problem.tsx new file mode 100644 index 0000000..47c0409 --- /dev/null +++ b/apps/web/components/sections/problem.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { Copy, Layers, ShieldAlert, Coins } from "lucide-react"; +import { useLanguage } from "@/components/language-provider"; +import { Section, SectionHeading } from "@/components/ui/section"; + +const ICONS = [Copy, ShieldAlert, Coins]; + +export function Problem() { + const { t } = useLanguage(); + return ( +
+
+ + +
+ {t.problem.points.map((p, i) => { + const Icon = ICONS[i]; + return ( +
+
+ +
+

{p.title}

+

{p.body}

+
+ ); + })} +
+ +
+ {[t.problem.stat1, t.problem.stat2, t.problem.stat3].map((s, i) => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+
+
+ ); +} diff --git a/apps/web/components/sections/safety.tsx b/apps/web/components/sections/safety.tsx new file mode 100644 index 0000000..3360ced --- /dev/null +++ b/apps/web/components/sections/safety.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { Bell, ShieldCheck, Database } from "lucide-react"; +import { useLanguage } from "@/components/language-provider"; +import { Section, SectionHeading } from "@/components/ui/section"; + +const ICONS = [Bell, ShieldCheck, Database]; + +export function Safety() { + const { t } = useLanguage(); + return ( +
+
+ +
+ {t.safety.points.map((p, i) => { + const Icon = ICONS[i]; + return ( +
+
+ +
+

{p.title}

+

{p.body}

+
+ ); + })} +
+
+
+ ); +} diff --git a/apps/web/components/sections/security.tsx b/apps/web/components/sections/security.tsx new file mode 100644 index 0000000..0223e7c --- /dev/null +++ b/apps/web/components/sections/security.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { KeyRound, ShieldCheck, RefreshCw, ScrollText } from "lucide-react"; +import { useLanguage } from "@/components/language-provider"; +import { Section, SectionHeading } from "@/components/ui/section"; + +const ICONS: Record> = { + "RS256 signing": KeyRound, + "امضای RS256": KeyRound, + "Verify with public key": ShieldCheck, + "تأیید با کلید عمومی": ShieldCheck, + "Session & token revocation": RefreshCw, + "لغو نشست و توکن": RefreshCw, + "Audit log & service credentials": ScrollText, + "لاگ حسابرسی و اعتبار سرویس": ScrollText, +}; + +export function Security() { + const { t } = useLanguage(); + return ( +
+
+ + +
+ {t.security.points.map((p) => { + const Icon = ICONS[p.title] ?? KeyRound; + return ( +
+ + + +

+ {p.title} +

+

+ {p.body} +

+
+ ); + })} +
+
+
+ ); +} diff --git a/apps/web/components/theme-provider.tsx b/apps/web/components/theme-provider.tsx new file mode 100644 index 0000000..f946790 --- /dev/null +++ b/apps/web/components/theme-provider.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; + +type Theme = "light" | "dark"; + +interface ThemeCtx { + theme: Theme; + toggle: () => void; + setTheme: (t: Theme) => void; +} + +const Ctx = createContext(null); +const KEY = "ip-theme"; + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setTheme] = useState("light"); + + useEffect(() => { + const saved = localStorage.getItem(KEY) as Theme | null; + const initial = saved === "dark" || saved === "light" ? saved : "light"; + setTheme(initial); + document.documentElement.classList.toggle("dark", initial === "dark"); + }, []); + + useEffect(() => { + document.documentElement.classList.toggle("dark", theme === "dark"); + localStorage.setItem(KEY, theme); + }, [theme]); + + return ( + setTheme((p) => (p === "light" ? "dark" : "light")), setTheme }}> + {children} + + ); +} + +export function useTheme() { + const c = useContext(Ctx); + if (!c) throw new Error("useTheme must be within ThemeProvider"); + return c; +} diff --git a/apps/web/components/ui/badge.tsx b/apps/web/components/ui/badge.tsx new file mode 100644 index 0000000..4508efd --- /dev/null +++ b/apps/web/components/ui/badge.tsx @@ -0,0 +1,33 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium", + { + variants: { + variant: { + default: "border-brand-200 bg-brand-500/10 text-brand-700 dark:border-white/10 dark:bg-brand-500/15 dark:text-brand-200", + secondary: "border-slate-200 bg-slate-50 text-slate-600 dark:border-white/10 dark:bg-white/5 dark:text-slate-300", + destructive: "border-rose-500/30 bg-rose-500/15 text-rose-300", + success: "border-emerald-500/30 bg-emerald-500/15 text-emerald-300", + warning: "border-amber-500/30 bg-amber-500/15 text-amber-300", + }, + }, + defaultVariants: { variant: "default" }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +export function Badge({ className, variant, children, ...props }: BadgeProps) { + return ( + + {children} + + ); +} diff --git a/apps/web/components/ui/button.tsx b/apps/web/components/ui/button.tsx new file mode 100644 index 0000000..f3ede08 --- /dev/null +++ b/apps/web/components/ui/button.tsx @@ -0,0 +1,51 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import React from "react"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 rounded-xl text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-400 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + primary: + "bg-brand-gradient text-white shadow-lg shadow-brand-900/20 hover:brightness-110 dark:shadow-brand-900/40", + secondary: + "border border-slate-200 bg-white text-slate-800 hover:bg-slate-50 dark:border-white/15 dark:bg-white/5 dark:text-white dark:hover:bg-white/10", + ghost: "text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-300 dark:hover:text-white dark:hover:bg-white/5", + }, + size: { + md: "h-11 px-5", + lg: "h-12 px-7 text-base", + sm: "h-9 px-4", + }, + }, + defaultVariants: { variant: "primary", size: "md" }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +export function Button({ + className, + variant, + size, + asChild, + ...props +}: ButtonProps) { + const Component = asChild ? React.forwardRef((props, ref) => ( +