Authentication & permissions
GreekManage supports four 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
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