Authentication & permissions
GreekManage supports five sign-in paths and a layered permission model.
Sign-in methods
JWT
Library: djangorestframework-simplejwt 5.5
Custom auth class: apps.common.authentication.CookieJWTAuthentication
backend/apps/common/authentication.py:7-21
backend/greekmanage/settings.py:248-254
Token lifetimes
| Token | Lifetime | Storage |
|---|---|---|
| Access | 30 minutes | httpOnly cookie (web) / Authorization: Bearer (mobile) |
| Refresh | 24 hours | httpOnly cookie (web) / keychain (mobile) |
Rotation + blacklist
Refresh tokens rotate on use — every refresh issues a new refresh + blacklists the old. So a stolen refresh token has a hard ceiling on its useful life:
- Used legitimately → rotated → previous token blacklisted
- Used by attacker → previous token blacklisted → real user's next refresh fails → forced re-login
Custom CookieJWTAuthentication
Reads the JWT from the httpOnly cookie access_token first; falls back to Authorization: Bearer <token> for mobile clients that don't speak cookies.
# backend/apps/common/authentication.py
class CookieJWTAuthentication(JWTAuthentication):
def authenticate(self, request):
token = request.COOKIES.get("access_token") \
or self.get_header(request)
if not token:
return None
validated = self.get_validated_token(token)
return self.get_user(validated), validated
Cookie attributes
HttpOnly: true
Secure: true (in production)
SameSite: Lax
Path: /
Set by set_auth_cookies() in apps/common/authentication.py:27-48.
SAML 2.0
Library: python3-saml 1.16
For enterprise customers with an existing IdP (Okta, Azure AD, OneLogin, Google Workspace SAML, etc.).
backend/apps/authentication/saml.py
backend/apps/authentication/models.py:36-55 (SAMLConfiguration)
Per-tenant config
Each org configures its own SAMLConfiguration with:
- IdP entity ID + SSO URL + SLO URL
- IdP x509 cert (encrypted via
EncryptedTextField) - Attribute mapping (which SAML attributes map to user fields)
- Auto-provision new users? (yes/no)
Endpoints (per IdP slug)
| URL | Purpose |
|---|---|
/api/auth/sso/saml/<slug>/metadata/ | SP metadata XML for IdP-side configuration |
/api/auth/sso/saml/<slug>/login/ | Initiates SSO (redirect to IdP) |
/api/auth/sso/saml/<slug>/acs/ | Assertion Consumer Service (POST from IdP) |
/api/auth/sso/saml/<slug>/sls/ | Single Logout Service |
Bindings
- HTTP-POST for assertions (signed XML payload)
- HTTP-Redirect for logout
Attribute mapping
Default mapping looks up:
urn:oid:0.9.2342.19200300.100.1.3→emailurn:oid:2.5.4.42→first_nameurn:oid:2.5.4.4→last_name
Customers can override per-config.
OAuth 2.0 / OIDC
Library: requests-oauthlib 2.0
Providers: Google Workspace, Microsoft 365, Okta, LinkedIn (LinkedIn used for profile sync, not auth)
backend/apps/authentication/oauth_providers.py
backend/apps/authentication/models.py:57-77 (OAuthConfiguration)
Per-tenant config
Each org configures OAuthConfiguration with:
- Provider (Google / Microsoft / Okta)
- Client ID + client secret (secret encrypted)
- Scopes (defaults:
openid email profile) - Allowed email domains (e.g.,
chapter-name.org)
Defaults for Google + Microsoft are pre-baked (authorization / token / userinfo URLs); Okta requires custom URLs.
Discovery
When a user enters their email on the login page, the frontend hits /api/auth/sso/discover?email=.... The backend looks up which orgs have an OAuthConfiguration matching that email's domain and returns the appropriate provider button.
Flow
Passkeys (WebAuthn)
Library: webauthn 2.x (Python) + @simplewebauthn/browser (frontend)
Models: accounts.WebAuthnCredential, accounts.PasskeyEnrollmentDismissal
GreekManage uses discoverable WebAuthn credentials (resident keys). The user is identified by the credential itself, so the sign-in flow doesn't need an email up front — the browser/OS picks the right passkey from the user's vault and sends its userHandle.
backend/apps/accounts/webauthn_views.py (registration + auth flows, credential CRUD)
backend/apps/accounts/models.py:157+ (WebAuthnCredential, PasskeyEnrollmentDismissal)
frontend/src/lib/passkey-auth.ts (SimpleWebAuthn browser wrapper)
frontend/src/components/settings/passkey-card.tsx (manage UI)
frontend/src/components/auth/passkey-enrollment-prompt.tsx (post-login enrollment)
Relying party config
| Setting | Value | Source |
|---|---|---|
RP_ID | app.greekmanage.com (prod) / localhost (dev) | WEBAUTHN_RP_ID env |
RP_NAME | GreekManage | WEBAUTHN_RP_NAME env |
EXPECTED_ORIGIN | https://app.greekmanage.com (prod) | WEBAUTHN_ORIGIN env |
| Attestation | none | We don't enforce specific authenticator brands |
| User verification | preferred | Asks for biometric / PIN where available |
Native apps (associated domains)
iOS and Android share passkeys with the web origin via:
- iOS:
apple-app-site-associationJSON served from/.well-known/apple-app-site-associationdeclaring the app's bundle ID underwebcredentials. Configured infrontend/ios/App/App/App.entitlements. - Android:
assetlinks.jsonserved from/.well-known/assetlinks.jsondeclaring the app's package + SHA-256 cert fingerprint. Configured in the manifest'sintent-filterforandroid:autoVerify="true".
A passkey enrolled on the web is therefore usable in the native apps and vice versa — the OS treats app.greekmanage.com as the same identity surface across all three platforms.
Registration flow
Authentication flow (discoverable)
Counter regression check
Each authenticator returns a monotonically increasing signature counter. If the next authentication's counter is less than the stored value, GreekManage rejects the assertion — the credential may have been cloned. The user must re-enroll.
Throttles
passkey_begin: 10/min per IP+user (anon and authenticated)passkey_complete: 5/min per IP+user
Tighter than auth (login) because each WebAuthn round-trip is more expensive on the verifier side.
Enrollment prompts
PasskeyEnrollmentDismissal records when a user dismissed the post-login "Set up a passkey" card. Subsequent sign-ins re-show the card after a 30-day cooldown (REMIND_AFTER_DAYS) unless the user has at least one credential.
Email verification
Users can have multiple emails (UserEmail table). Each carries:
email(the address)is_verified(bool)verification_token(random token, generated on add)verification_sent_at
SSO flows match incoming verified emails against UserEmail first, then fall back to User.email. This lets users keep their account when they change primary email.
backend/apps/accounts/models.py:76-112 (UserEmail)
Permission classes
All in backend/apps/common/permissions.py (291 lines).
Hierarchy
Class reference
| Class | What it checks | Example endpoint |
|---|---|---|
IsNationalAdmin | User is platform admin OR active org admin | POST /api/organizations/<id>/admins/ |
IsRegionalAdmin | Platform admin OR active regional admin (region-scoped) | GET /api/regions/<id>/chapters/ |
IsNationalOrRegionalAdmin | Either of the above | GET /api/compliance/region-overview/ |
IsChapterMember | Platform admin OR has active membership in any chapter | GET /api/chapters/<id>/feed/ |
IsChapterOfficer | Platform admin OR officer/president in active standing | POST /api/chapters/<id>/bulletins/ |
IsNationalAdminOrChapterOfficer | Either national admin or chapter officer (mixed scope) | PATCH /api/memberships/<id>/ |
App-specific permissions
Some apps have their own:
apps/alumni/permissions.py→IsAlumniMember,IsAuthenticatedMemberapps/foundation/permissions.py→IsFoundationAdmin,IsFoundationEditorapps/forums/permissions.py→IsForumMemberOrAdmin
Object-level checks
TierPermission provides _get_object_org_id(), _get_object_chapter_id(), _get_object_region_id() helpers. Subclasses use them in has_object_permission() to check that an object belongs to the user's scope.
Example:
class IsChapterOfficer(TierPermission):
def has_object_permission(self, request, view, obj):
if self._is_platform_admin(request.user):
return True
chapter_id = self._get_object_chapter_id(obj)
return chapter_id in self._user_officer_chapter_ids(request.user)
Default permission
REST_FRAMEWORK settings:
"DEFAULT_PERMISSION_CLASSES": (
"rest_framework.permissions.IsAuthenticated",
)
So every endpoint defaults to "must be signed in." Public endpoints (login, register, public donate, PNM apply) explicitly use permission_classes = [AllowAny].
Throttling
| Throttle scope | Anon rate | User rate |
|---|---|---|
| Default | 200/day prod, 10000/day dev | 2000/day prod, 100000/day dev |
auth | 10/minute prod, 10000/minute dev | – |
sso | 20/minute prod | – |
account_request | 3/hour prod | – |
password_reset | 5/hour prod | – |
pnm_submit_ip | 5/hour | – |
pnm_submit_email | 1/minute | – |
Production rates are deliberately tight to prevent enumeration / brute-force.
Mobile auth
Mobile clients (Capacitor) don't use cookies. They send Authorization: Bearer <access_token> and store both tokens in the device keychain (iOS: kSecAttrAccessibleAfterFirstUnlock; Android: EncryptedSharedPreferences).
Biometric unlock uses @aparajita/capacitor-biometric-auth to gate access to the stored tokens.
Related
- Permission tiers — full role hierarchy
- Multi-tenancy & RLS — DB-level isolation
- Audit & encryption — what's logged