From 54d5891edfbadd708a855412212049e627fb3082 Mon Sep 17 00:00:00 2001 From: bermooda-company Date: Sun, 23 Aug 2026 23:59:14 +0330 Subject: [PATCH] user --- .editorconfig | 15 + .env.example | 27 + .github/workflows/ci.yml | 49 + .gitignore | 52 + README.md | 181 + apps/api/apps/__init__.py | 0 apps/api/apps/access/__init__.py | 0 apps/api/apps/access/admin.py | 25 + apps/api/apps/access/apps.py | 6 + .../apps/access/migrations/0001_initial.py | 76 + apps/api/apps/access/migrations/__init__.py | 0 apps/api/apps/access/models.py | 136 + apps/api/apps/access/serializers.py | 52 + apps/api/apps/access/services.py | 30 + apps/api/apps/access/urls.py | 10 + apps/api/apps/access/views.py | 65 + apps/api/apps/application/__init__.py | 0 apps/api/apps/application/admin.py | 11 + .../application/migrations/0001_initial.py | 44 + .../application/migrations/0002_initial.py | 33 + .../apps/application/migrations/__init__.py | 0 apps/api/apps/application/models.py | 66 + apps/api/apps/application/serializers.py | 89 + apps/api/apps/application/tests.py | 41 + apps/api/apps/application/urls.py | 11 + apps/api/apps/application/views.py | 69 + apps/api/apps/authentication/__init__.py | 0 apps/api/apps/authentication/auth.py | 19 + .../authentication/migrations/0001_initial.py | 38 + .../authentication/migrations/0002_passkey.py | 38 + .../0003_recoverycode_trusteddevice.py | 52 + .../authentication/migrations/__init__.py | 0 apps/api/apps/authentication/models.py | 157 + apps/api/apps/authentication/serializers.py | 122 + apps/api/apps/authentication/services.py | 252 + apps/api/apps/authentication/tests.py | 873 +++ apps/api/apps/authentication/urls.py | 95 + apps/api/apps/authentication/views.py | 1022 +++ .../api/apps/authentication/webauthn_utils.py | 82 + apps/api/apps/common/__init__.py | 0 apps/api/apps/common/exceptions.py | 33 + .../apps/common/migrations/0001_initial.py | 54 + ...2_outboxevent_message_outboxevent_title.py | 23 + apps/api/apps/common/migrations/__init__.py | 0 apps/api/apps/common/models.py | 152 + apps/api/apps/common/pagination.py | 7 + apps/api/apps/common/permissions.py | 13 + apps/api/apps/common/ratelimit.py | 35 + apps/api/apps/common/tests.py | 26 + apps/api/apps/common/urls.py | 8 + apps/api/apps/common/utils.py | 38 + apps/api/apps/common/views.py | 85 + apps/api/apps/identity/__init__.py | 0 apps/api/apps/identity/admin.py | 88 + apps/api/apps/identity/managers.py | 26 + .../apps/identity/migrations/0001_initial.py | 54 + .../migrations/0002_password_reset_fields.py | 20 + ..._user_managers_user_birth_date_and_more.py | 48 + .../migrations/0004_identitydocument.py | 35 + .../migrations/0005_user_province_skills.py | 29 + .../migrations/0006_user_city_user_country.py | 23 + .../migrations/0007_user_token_version.py | 18 + apps/api/apps/identity/migrations/__init__.py | 0 apps/api/apps/identity/models.py | 193 + apps/api/apps/identity/serializers.py | 174 + apps/api/apps/identity/summary.py | 43 + apps/api/apps/identity/tests.py | 94 + apps/api/apps/identity/urls.py | 15 + apps/api/apps/identity/user_urls.py | 16 + apps/api/apps/identity/views.py | 81 + apps/api/apps/membership/__init__.py | 0 apps/api/apps/membership/admin.py | 25 + .../membership/migrations/0001_initial.py | 76 + .../migrations/0002_membership_ended_at.py | 18 + .../apps/membership/migrations/__init__.py | 0 apps/api/apps/membership/models.py | 164 + apps/api/apps/membership/serializers.py | 83 + apps/api/apps/membership/tests.py | 118 + apps/api/apps/membership/urls.py | 13 + apps/api/apps/membership/views.py | 124 + apps/api/apps/oauth/__init__.py | 0 apps/api/apps/oauth/admin.py | 36 + apps/api/apps/oauth/discovery_urls.py | 7 + .../api/apps/oauth/migrations/0001_initial.py | 93 + .../0002_authorizationcode_organization.py | 24 + .../api/apps/oauth/migrations/0003_consent.py | 33 + apps/api/apps/oauth/migrations/__init__.py | 0 apps/api/apps/oauth/models.py | 145 + apps/api/apps/oauth/serializers.py | 9 + apps/api/apps/oauth/services.py | 390 + .../apps/oauth/templates/oauth/consent.html | 58 + .../oauth/templates/oauth/web_message.html | 41 + apps/api/apps/oauth/tests.py | 550 ++ apps/api/apps/oauth/urls.py | 19 + apps/api/apps/oauth/views.py | 467 ++ apps/api/apps/organization/__init__.py | 0 apps/api/apps/organization/admin.py | 11 + .../organization/migrations/0001_initial.py | 41 + ...ity_field_organization_address_and_more.py | 133 + .../migrations/0003_organization_username.py | 51 + .../apps/organization/migrations/__init__.py | 0 apps/api/apps/organization/models.py | 167 + apps/api/apps/organization/serializers.py | 150 + apps/api/apps/organization/tests.py | 82 + apps/api/apps/organization/urls.py | 25 + apps/api/apps/organization/views.py | 254 + apps/api/apps/product/__init__.py | 0 apps/api/apps/product/admin.py | 11 + apps/api/apps/product/apps.py | 7 + .../apps/product/migrations/0001_initial.py | 52 + ...product_key_alter_product_name_and_more.py | 33 + apps/api/apps/product/migrations/__init__.py | 1 + apps/api/apps/product/models.py | 29 + apps/api/apps/product/serializers.py | 10 + apps/api/apps/product/tests.py | 80 + apps/api/apps/product/urls.py | 8 + apps/api/apps/product/views.py | 19 + apps/api/apps/profile/__init__.py | 0 apps/api/apps/profile/admin.py | 10 + apps/api/apps/profile/apps.py | 6 + .../apps/profile/migrations/0001_initial.py | 45 + .../migrations/0002_profile_organization.py | 20 + apps/api/apps/profile/migrations/__init__.py | 0 apps/api/apps/profile/models.py | 74 + apps/api/apps/profile/serializers.py | 49 + apps/api/apps/profile/tests.py | 159 + apps/api/apps/profile/urls.py | 8 + apps/api/apps/profile/views.py | 32 + apps/api/apps/security/__init__.py | 0 apps/api/apps/security/admin.py | 11 + .../apps/security/migrations/0001_initial.py | 40 + .../0002_alter_securityevent_event_type.py | 18 + .../security/migrations/0003_powchallenge.py | 27 + apps/api/apps/security/migrations/__init__.py | 0 apps/api/apps/security/models.py | 135 + apps/api/apps/security/pow.py | 116 + apps/api/apps/security/serializers.py | 32 + apps/api/apps/security/urls.py | 13 + apps/api/apps/security/views.py | 158 + apps/api/apps/session/__init__.py | 0 apps/api/apps/session/admin.py | 20 + .../apps/session/migrations/0001_initial.py | 44 + ...02_session_organization_session_product.py | 26 + apps/api/apps/session/migrations/__init__.py | 0 apps/api/apps/session/models.py | 83 + apps/api/apps/session/serializers.py | 38 + apps/api/apps/session/urls.py | 11 + apps/api/apps/session/views.py | 45 + apps/api/apps/sso/__init__.py | 0 apps/api/apps/sso/management/__init__.py | 0 .../apps/sso/management/commands/__init__.py | 0 .../management/commands/seed_hamsoo_sso.py | 125 + apps/api/apps/sso/templates/sso/login.html | 161 + apps/api/apps/sso/tests.py | 456 ++ apps/api/apps/sso/urls.py | 8 + apps/api/apps/sso/views.py | 173 + apps/api/apps/token_blacklist/__init__.py | 0 apps/api/apps/token_blacklist/admin.py | 15 + apps/api/apps/token_blacklist/apps.py | 7 + .../token_blacklist/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../commands/cleanup_expired_tokens.py | 25 + .../migrations/0001_initial.py | 23 + ...token_black_token_h_30fb2d_idx_and_more.py | 21 + .../token_blacklist/migrations/__init__.py | 0 apps/api/apps/token_blacklist/models.py | 24 + apps/api/apps/token_blacklist/tests.py | 124 + apps/api/apps/token_blacklist/urls.py | 8 + apps/api/apps/token_blacklist/views.py | 62 + apps/api/apps/verification/__init__.py | 0 apps/api/apps/verification/admin.py | 20 + apps/api/apps/verification/apps.py | 6 + .../verification/migrations/0001_initial.py | 60 + .../migrations/0002_seed_capabilities.py | 85 + .../apps/verification/migrations/__init__.py | 0 apps/api/apps/verification/models.py | 178 + apps/api/apps/verification/permissions.py | 37 + apps/api/apps/verification/serializers.py | 89 + apps/api/apps/verification/services.py | 125 + apps/api/apps/verification/tests.py | 281 + apps/api/apps/verification/urls.py | 12 + apps/api/apps/verification/views.py | 94 + apps/api/config/__init__.py | 0 apps/api/config/asgi.py | 7 + apps/api/config/settings.py | 247 + apps/api/config/urls.py | 40 + apps/api/config/wsgi.py | 7 + apps/api/dev.db | Bin 0 -> 1073152 bytes apps/api/e2e_check.py | 129 + apps/api/keys/rsa_private.pem | 28 + apps/api/keys/rsa_public.pem | 9 + apps/api/manage.py | 19 + apps/api/requirements.txt | 17 + apps/web/.env.example | 4 + apps/web/.eslintrc.json | 6 + apps/web/app/account/page.tsx | 703 ++ apps/web/app/admin/applications/page.tsx | 148 + apps/web/app/admin/events/page.tsx | 126 + apps/web/app/admin/invitations/page.tsx | 102 + apps/web/app/admin/members/page.tsx | 110 + apps/web/app/admin/notifications/page.tsx | 105 + apps/web/app/admin/organizations/page.tsx | 142 + apps/web/app/admin/page.tsx | 334 + apps/web/app/admin/products/page.tsx | 112 + apps/web/app/admin/security/page.tsx | 299 + apps/web/app/admin/sessions/page.tsx | 126 + apps/web/app/admin/users/page.tsx | 171 + apps/web/app/api/auth/login/route.ts | 15 + apps/web/app/api/auth/logout/route.ts | 32 + apps/web/app/api/auth/me/route.ts | 14 + apps/web/app/api/auth/proxy.ts | 45 + apps/web/app/api/auth/refresh/route.ts | 23 + apps/web/app/api/auth/register/route.ts | 15 + apps/web/app/developers/page.tsx | 187 + apps/web/app/forgot-password/page.tsx | 148 + apps/web/app/globals.css | 55 + apps/web/app/layout.tsx | 49 + apps/web/app/login/page.tsx | 201 + apps/web/app/oauth/consent/page.tsx | 223 + apps/web/app/page.tsx | 253 + apps/web/app/register/page.tsx | 278 + apps/web/app/reset-password/page.tsx | 192 + apps/web/components/admin/admin-layout.tsx | 158 + apps/web/components/admin/auth-context.tsx | 69 + apps/web/components/auth/auth-provider.tsx | 181 + apps/web/components/auth/login-form.tsx | 92 + apps/web/components/auth/route-guard.tsx | 30 + apps/web/components/footer.tsx | 56 + apps/web/components/language-provider.tsx | 63 + apps/web/components/login-modal-provider.tsx | 22 + apps/web/components/login-modal.tsx | 284 + apps/web/components/navbar.tsx | 76 + .../components/sections/account-preview.tsx | 57 + apps/web/components/sections/architecture.tsx | 86 + apps/web/components/sections/benefits.tsx | 36 + apps/web/components/sections/capabilities.tsx | 51 + apps/web/components/sections/cta.tsx | 33 + apps/web/components/sections/dashboard.tsx | 139 + apps/web/components/sections/developers.tsx | 69 + apps/web/components/sections/ecosystem.tsx | 69 + apps/web/components/sections/features.tsx | 46 + apps/web/components/sections/hero.tsx | 122 + apps/web/components/sections/how.tsx | 44 + apps/web/components/sections/problem.tsx | 46 + apps/web/components/sections/safety.tsx | 36 + apps/web/components/sections/security.tsx | 50 + apps/web/components/theme-provider.tsx | 42 + apps/web/components/ui/badge.tsx | 33 + apps/web/components/ui/button.tsx | 51 + apps/web/components/ui/card.tsx | 71 + apps/web/components/ui/input.tsx | 16 + apps/web/components/ui/reveal.tsx | 63 + apps/web/components/ui/scroll-progress.tsx | 22 + apps/web/components/ui/section.tsx | 48 + apps/web/e2e/auth.spec.ts | 48 + apps/web/lib/api.ts | 168 + apps/web/lib/content.ts | 146 + apps/web/lib/i18n.ts | 679 ++ apps/web/lib/utils.ts | 6 + apps/web/lib/webauthn.ts | 129 + apps/web/middleware.ts | 86 + apps/web/next.config.mjs | 9 + apps/web/package-lock.json | 1733 +++++ apps/web/package.json | 36 + apps/web/playwright.config.ts | 23 + apps/web/postcss.config.mjs | 6 + apps/web/sdk/identity-widget.js | 132 + apps/web/tailwind.config.ts | 69 + apps/web/tsconfig.json | 23 + dev.db | Bin 0 -> 475136 bytes docker-compose.yml | 76 + docker/Dockerfile.api | 18 + docker/Dockerfile.web | 28 + docs/EVENTS.md | 155 + docs/IDENTITY_MODEL.md | 346 + docs/MIGRATION.md | 233 + docs/SECURITY.md | 210 + docs/api.md | 518 ++ docs/architecture.md | 172 + docs/authentication.md | 283 + docs/roadmap.md | 164 + package-lock.json | 6307 +++++++++++++++++ package.json | 20 + packages/shared/package.json | 10 + packages/shared/src/constants.ts | 60 + packages/shared/src/index.ts | 2 + packages/shared/src/types.ts | 90 + packages/shared/tsconfig.json | 16 + 288 files changed, 31348 insertions(+) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/api/apps/__init__.py create mode 100644 apps/api/apps/access/__init__.py create mode 100644 apps/api/apps/access/admin.py create mode 100644 apps/api/apps/access/apps.py create mode 100644 apps/api/apps/access/migrations/0001_initial.py create mode 100644 apps/api/apps/access/migrations/__init__.py create mode 100644 apps/api/apps/access/models.py create mode 100644 apps/api/apps/access/serializers.py create mode 100644 apps/api/apps/access/services.py create mode 100644 apps/api/apps/access/urls.py create mode 100644 apps/api/apps/access/views.py create mode 100644 apps/api/apps/application/__init__.py create mode 100644 apps/api/apps/application/admin.py create mode 100644 apps/api/apps/application/migrations/0001_initial.py create mode 100644 apps/api/apps/application/migrations/0002_initial.py create mode 100644 apps/api/apps/application/migrations/__init__.py create mode 100644 apps/api/apps/application/models.py create mode 100644 apps/api/apps/application/serializers.py create mode 100644 apps/api/apps/application/tests.py create mode 100644 apps/api/apps/application/urls.py create mode 100644 apps/api/apps/application/views.py create mode 100644 apps/api/apps/authentication/__init__.py create mode 100644 apps/api/apps/authentication/auth.py create mode 100644 apps/api/apps/authentication/migrations/0001_initial.py create mode 100644 apps/api/apps/authentication/migrations/0002_passkey.py create mode 100644 apps/api/apps/authentication/migrations/0003_recoverycode_trusteddevice.py create mode 100644 apps/api/apps/authentication/migrations/__init__.py create mode 100644 apps/api/apps/authentication/models.py create mode 100644 apps/api/apps/authentication/serializers.py create mode 100644 apps/api/apps/authentication/services.py create mode 100644 apps/api/apps/authentication/tests.py create mode 100644 apps/api/apps/authentication/urls.py create mode 100644 apps/api/apps/authentication/views.py create mode 100644 apps/api/apps/authentication/webauthn_utils.py create mode 100644 apps/api/apps/common/__init__.py create mode 100644 apps/api/apps/common/exceptions.py create mode 100644 apps/api/apps/common/migrations/0001_initial.py create mode 100644 apps/api/apps/common/migrations/0002_outboxevent_message_outboxevent_title.py create mode 100644 apps/api/apps/common/migrations/__init__.py create mode 100644 apps/api/apps/common/models.py create mode 100644 apps/api/apps/common/pagination.py create mode 100644 apps/api/apps/common/permissions.py create mode 100644 apps/api/apps/common/ratelimit.py create mode 100644 apps/api/apps/common/tests.py create mode 100644 apps/api/apps/common/urls.py create mode 100644 apps/api/apps/common/utils.py create mode 100644 apps/api/apps/common/views.py create mode 100644 apps/api/apps/identity/__init__.py create mode 100644 apps/api/apps/identity/admin.py create mode 100644 apps/api/apps/identity/managers.py create mode 100644 apps/api/apps/identity/migrations/0001_initial.py create mode 100644 apps/api/apps/identity/migrations/0002_password_reset_fields.py create mode 100644 apps/api/apps/identity/migrations/0003_alter_user_managers_user_birth_date_and_more.py create mode 100644 apps/api/apps/identity/migrations/0004_identitydocument.py create mode 100644 apps/api/apps/identity/migrations/0005_user_province_skills.py create mode 100644 apps/api/apps/identity/migrations/0006_user_city_user_country.py create mode 100644 apps/api/apps/identity/migrations/0007_user_token_version.py create mode 100644 apps/api/apps/identity/migrations/__init__.py create mode 100644 apps/api/apps/identity/models.py create mode 100644 apps/api/apps/identity/serializers.py create mode 100644 apps/api/apps/identity/summary.py create mode 100644 apps/api/apps/identity/tests.py create mode 100644 apps/api/apps/identity/urls.py create mode 100644 apps/api/apps/identity/user_urls.py create mode 100644 apps/api/apps/identity/views.py create mode 100644 apps/api/apps/membership/__init__.py create mode 100644 apps/api/apps/membership/admin.py create mode 100644 apps/api/apps/membership/migrations/0001_initial.py create mode 100644 apps/api/apps/membership/migrations/0002_membership_ended_at.py create mode 100644 apps/api/apps/membership/migrations/__init__.py create mode 100644 apps/api/apps/membership/models.py create mode 100644 apps/api/apps/membership/serializers.py create mode 100644 apps/api/apps/membership/tests.py create mode 100644 apps/api/apps/membership/urls.py create mode 100644 apps/api/apps/membership/views.py create mode 100644 apps/api/apps/oauth/__init__.py create mode 100644 apps/api/apps/oauth/admin.py create mode 100644 apps/api/apps/oauth/discovery_urls.py create mode 100644 apps/api/apps/oauth/migrations/0001_initial.py create mode 100644 apps/api/apps/oauth/migrations/0002_authorizationcode_organization.py create mode 100644 apps/api/apps/oauth/migrations/0003_consent.py create mode 100644 apps/api/apps/oauth/migrations/__init__.py create mode 100644 apps/api/apps/oauth/models.py create mode 100644 apps/api/apps/oauth/serializers.py create mode 100644 apps/api/apps/oauth/services.py create mode 100644 apps/api/apps/oauth/templates/oauth/consent.html create mode 100644 apps/api/apps/oauth/templates/oauth/web_message.html create mode 100644 apps/api/apps/oauth/tests.py create mode 100644 apps/api/apps/oauth/urls.py create mode 100644 apps/api/apps/oauth/views.py create mode 100644 apps/api/apps/organization/__init__.py create mode 100644 apps/api/apps/organization/admin.py create mode 100644 apps/api/apps/organization/migrations/0001_initial.py create mode 100644 apps/api/apps/organization/migrations/0002_organization_activity_field_organization_address_and_more.py create mode 100644 apps/api/apps/organization/migrations/0003_organization_username.py create mode 100644 apps/api/apps/organization/migrations/__init__.py create mode 100644 apps/api/apps/organization/models.py create mode 100644 apps/api/apps/organization/serializers.py create mode 100644 apps/api/apps/organization/tests.py create mode 100644 apps/api/apps/organization/urls.py create mode 100644 apps/api/apps/organization/views.py create mode 100644 apps/api/apps/product/__init__.py create mode 100644 apps/api/apps/product/admin.py create mode 100644 apps/api/apps/product/apps.py create mode 100644 apps/api/apps/product/migrations/0001_initial.py create mode 100644 apps/api/apps/product/migrations/0002_alter_product_key_alter_product_name_and_more.py create mode 100644 apps/api/apps/product/migrations/__init__.py create mode 100644 apps/api/apps/product/models.py create mode 100644 apps/api/apps/product/serializers.py create mode 100644 apps/api/apps/product/tests.py create mode 100644 apps/api/apps/product/urls.py create mode 100644 apps/api/apps/product/views.py create mode 100644 apps/api/apps/profile/__init__.py create mode 100644 apps/api/apps/profile/admin.py create mode 100644 apps/api/apps/profile/apps.py create mode 100644 apps/api/apps/profile/migrations/0001_initial.py create mode 100644 apps/api/apps/profile/migrations/0002_profile_organization.py create mode 100644 apps/api/apps/profile/migrations/__init__.py create mode 100644 apps/api/apps/profile/models.py create mode 100644 apps/api/apps/profile/serializers.py create mode 100644 apps/api/apps/profile/tests.py create mode 100644 apps/api/apps/profile/urls.py create mode 100644 apps/api/apps/profile/views.py create mode 100644 apps/api/apps/security/__init__.py create mode 100644 apps/api/apps/security/admin.py create mode 100644 apps/api/apps/security/migrations/0001_initial.py create mode 100644 apps/api/apps/security/migrations/0002_alter_securityevent_event_type.py create mode 100644 apps/api/apps/security/migrations/0003_powchallenge.py create mode 100644 apps/api/apps/security/migrations/__init__.py create mode 100644 apps/api/apps/security/models.py create mode 100644 apps/api/apps/security/pow.py create mode 100644 apps/api/apps/security/serializers.py create mode 100644 apps/api/apps/security/urls.py create mode 100644 apps/api/apps/security/views.py create mode 100644 apps/api/apps/session/__init__.py create mode 100644 apps/api/apps/session/admin.py create mode 100644 apps/api/apps/session/migrations/0001_initial.py create mode 100644 apps/api/apps/session/migrations/0002_session_organization_session_product.py create mode 100644 apps/api/apps/session/migrations/__init__.py create mode 100644 apps/api/apps/session/models.py create mode 100644 apps/api/apps/session/serializers.py create mode 100644 apps/api/apps/session/urls.py create mode 100644 apps/api/apps/session/views.py create mode 100644 apps/api/apps/sso/__init__.py create mode 100644 apps/api/apps/sso/management/__init__.py create mode 100644 apps/api/apps/sso/management/commands/__init__.py create mode 100644 apps/api/apps/sso/management/commands/seed_hamsoo_sso.py create mode 100644 apps/api/apps/sso/templates/sso/login.html create mode 100644 apps/api/apps/sso/tests.py create mode 100644 apps/api/apps/sso/urls.py create mode 100644 apps/api/apps/sso/views.py create mode 100644 apps/api/apps/token_blacklist/__init__.py create mode 100644 apps/api/apps/token_blacklist/admin.py create mode 100644 apps/api/apps/token_blacklist/apps.py create mode 100644 apps/api/apps/token_blacklist/management/__init__.py create mode 100644 apps/api/apps/token_blacklist/management/commands/__init__.py create mode 100644 apps/api/apps/token_blacklist/management/commands/cleanup_expired_tokens.py create mode 100644 apps/api/apps/token_blacklist/migrations/0001_initial.py create mode 100644 apps/api/apps/token_blacklist/migrations/0002_tokenblacklist_token_black_token_h_30fb2d_idx_and_more.py create mode 100644 apps/api/apps/token_blacklist/migrations/__init__.py create mode 100644 apps/api/apps/token_blacklist/models.py create mode 100644 apps/api/apps/token_blacklist/tests.py create mode 100644 apps/api/apps/token_blacklist/urls.py create mode 100644 apps/api/apps/token_blacklist/views.py create mode 100644 apps/api/apps/verification/__init__.py create mode 100644 apps/api/apps/verification/admin.py create mode 100644 apps/api/apps/verification/apps.py create mode 100644 apps/api/apps/verification/migrations/0001_initial.py create mode 100644 apps/api/apps/verification/migrations/0002_seed_capabilities.py create mode 100644 apps/api/apps/verification/migrations/__init__.py create mode 100644 apps/api/apps/verification/models.py create mode 100644 apps/api/apps/verification/permissions.py create mode 100644 apps/api/apps/verification/serializers.py create mode 100644 apps/api/apps/verification/services.py create mode 100644 apps/api/apps/verification/tests.py create mode 100644 apps/api/apps/verification/urls.py create mode 100644 apps/api/apps/verification/views.py create mode 100644 apps/api/config/__init__.py create mode 100644 apps/api/config/asgi.py create mode 100644 apps/api/config/settings.py create mode 100644 apps/api/config/urls.py create mode 100644 apps/api/config/wsgi.py create mode 100644 apps/api/dev.db create mode 100644 apps/api/e2e_check.py create mode 100644 apps/api/keys/rsa_private.pem create mode 100644 apps/api/keys/rsa_public.pem create mode 100644 apps/api/manage.py create mode 100644 apps/api/requirements.txt create mode 100644 apps/web/.env.example create mode 100644 apps/web/.eslintrc.json create mode 100644 apps/web/app/account/page.tsx create mode 100644 apps/web/app/admin/applications/page.tsx create mode 100644 apps/web/app/admin/events/page.tsx create mode 100644 apps/web/app/admin/invitations/page.tsx create mode 100644 apps/web/app/admin/members/page.tsx create mode 100644 apps/web/app/admin/notifications/page.tsx create mode 100644 apps/web/app/admin/organizations/page.tsx create mode 100644 apps/web/app/admin/page.tsx create mode 100644 apps/web/app/admin/products/page.tsx create mode 100644 apps/web/app/admin/security/page.tsx create mode 100644 apps/web/app/admin/sessions/page.tsx create mode 100644 apps/web/app/admin/users/page.tsx create mode 100644 apps/web/app/api/auth/login/route.ts create mode 100644 apps/web/app/api/auth/logout/route.ts create mode 100644 apps/web/app/api/auth/me/route.ts create mode 100644 apps/web/app/api/auth/proxy.ts create mode 100644 apps/web/app/api/auth/refresh/route.ts create mode 100644 apps/web/app/api/auth/register/route.ts create mode 100644 apps/web/app/developers/page.tsx create mode 100644 apps/web/app/forgot-password/page.tsx create mode 100644 apps/web/app/globals.css create mode 100644 apps/web/app/layout.tsx create mode 100644 apps/web/app/login/page.tsx create mode 100644 apps/web/app/oauth/consent/page.tsx create mode 100644 apps/web/app/page.tsx create mode 100644 apps/web/app/register/page.tsx create mode 100644 apps/web/app/reset-password/page.tsx create mode 100644 apps/web/components/admin/admin-layout.tsx create mode 100644 apps/web/components/admin/auth-context.tsx create mode 100644 apps/web/components/auth/auth-provider.tsx create mode 100644 apps/web/components/auth/login-form.tsx create mode 100644 apps/web/components/auth/route-guard.tsx create mode 100644 apps/web/components/footer.tsx create mode 100644 apps/web/components/language-provider.tsx create mode 100644 apps/web/components/login-modal-provider.tsx create mode 100644 apps/web/components/login-modal.tsx create mode 100644 apps/web/components/navbar.tsx create mode 100644 apps/web/components/sections/account-preview.tsx create mode 100644 apps/web/components/sections/architecture.tsx create mode 100644 apps/web/components/sections/benefits.tsx create mode 100644 apps/web/components/sections/capabilities.tsx create mode 100644 apps/web/components/sections/cta.tsx create mode 100644 apps/web/components/sections/dashboard.tsx create mode 100644 apps/web/components/sections/developers.tsx create mode 100644 apps/web/components/sections/ecosystem.tsx create mode 100644 apps/web/components/sections/features.tsx create mode 100644 apps/web/components/sections/hero.tsx create mode 100644 apps/web/components/sections/how.tsx create mode 100644 apps/web/components/sections/problem.tsx create mode 100644 apps/web/components/sections/safety.tsx create mode 100644 apps/web/components/sections/security.tsx create mode 100644 apps/web/components/theme-provider.tsx create mode 100644 apps/web/components/ui/badge.tsx create mode 100644 apps/web/components/ui/button.tsx create mode 100644 apps/web/components/ui/card.tsx create mode 100644 apps/web/components/ui/input.tsx create mode 100644 apps/web/components/ui/reveal.tsx create mode 100644 apps/web/components/ui/scroll-progress.tsx create mode 100644 apps/web/components/ui/section.tsx create mode 100644 apps/web/e2e/auth.spec.ts create mode 100644 apps/web/lib/api.ts create mode 100644 apps/web/lib/content.ts create mode 100644 apps/web/lib/i18n.ts create mode 100644 apps/web/lib/utils.ts create mode 100644 apps/web/lib/webauthn.ts create mode 100644 apps/web/middleware.ts create mode 100644 apps/web/next.config.mjs create mode 100644 apps/web/package-lock.json create mode 100644 apps/web/package.json create mode 100644 apps/web/playwright.config.ts create mode 100644 apps/web/postcss.config.mjs create mode 100644 apps/web/sdk/identity-widget.js create mode 100644 apps/web/tailwind.config.ts create mode 100644 apps/web/tsconfig.json create mode 100644 dev.db create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile.api create mode 100644 docker/Dockerfile.web create mode 100644 docs/EVENTS.md create mode 100644 docs/IDENTITY_MODEL.md create mode 100644 docs/MIGRATION.md create mode 100644 docs/SECURITY.md create mode 100644 docs/api.md create mode 100644 docs/architecture.md create mode 100644 docs/authentication.md create mode 100644 docs/roadmap.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/constants.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/types.ts create mode 100644 packages/shared/tsconfig.json 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 0000000000000000000000000000000000000000..20732b4e579838ac24ddfc3aaac869d72612ab21 GIT binary patch literal 1073152 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 E107-jt^fc4 literal 0 HcmV?d00001 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) => ( +