Authentication
How Ananas GDS authenticates users and API requests — session model, token management, and security.
Overview
Ananas GDS has two distinct authentication contexts that use different mechanisms and serve different audiences:
| Context | Who Uses It | Mechanism | Expiry |
|---|---|---|---|
| Web app (frontend) | Accommodation and TO users logging in via the browser | DRF token stored in accessToken cookie, with custom session layer |
Inactivity-based (30 min or 7 days) |
| External API (partners) | Tour operator integration systems calling the public API | Static API token passed in the URL path | None — active until manually revoked |
The underlying token type for web app sessions is a standard DRF authtoken.Token — an opaque string stored in the database. Despite the frontend file being named jwtInterceptor.js, no JWT tokens are used anywhere in the system. The JWT library is installed but unused.
Login Flow
All web app logins go through a single endpoint. The flow involves several sequential checks before a session is created.
-
POST to
api/auth/token/login/Submit credentials as JSON:
{"email": "...", "password": "..."} -
IP ban check
The server checks the file-based cache for an active ban on the requesting IP. If a ban is in effect, the request is rejected immediately with a 429 response and an
X-IP-Bannedheader indicating the remaining ban duration. -
Account approval check
The server looks up the user by email and verifies that
approval_status == "approved". Pending, rejected, and paused accounts are rejected with a 403 response. -
Password verification
Standard Django password check. A failure increments the IP attempt counter. At 13 failed attempts the IP is banned for 120 seconds.
-
Concurrent session check
The server counts active
UserSessionrecords for the account. If 2 sessions already exist, the login is rejected with 429too_many_sessionsand a security alert is sent to the account holder. -
Session creation
A DRF
Tokenis created (or retrieved if one exists) and aUserSessionrecord is written with the client IP, parsed user agent, and whether "Remember Me" was selected. -
Response returned
The server responds with
{"auth_token": "abc123..."}. -
Frontend stores the token
The Vue app writes the token to the
accessTokencookie. The JWT interceptor picks it up on all subsequent requests.
Response Codes
| Code | Meaning |
|---|---|
200 |
Success — token returned in response body |
400 |
Invalid credentials (wrong email or password) |
403 |
Account not approved, inactive, rejected, or paused |
429 |
IP currently banned due to failed attempts, or concurrent session limit reached |
Session Model
Each active login is represented by a UserSession record. This model extends DRF's basic token auth with metadata and expiry controls.
class UserSession(models.Model):
token # OneToOneField -> authtoken.Token (CASCADE on delete)
user # ForeignKey -> User
ip # CharField — client IP address at login time
device_info # CharField — parsed user agent string
is_extended # BooleanField — True if "Remember Me" was selected
created_at # DateTimeField — session creation timestamp
last_active # DateTimeField — updated on each request (throttled)
When a Token is deleted (e.g., on logout), its associated UserSession is automatically removed via the CASCADE constraint. There is no orphaned session data.
Session Expiry
Sessions expire based on inactivity, not on absolute time. The inactivity window depends on whether the user selected "Remember Me" at login:
| Session Type | Inactivity Timeout | Condition |
|---|---|---|
| Standard | 30 minutes | is_extended = False |
| Extended ("Remember Me") | 7 days | is_extended = True |
Inactivity is measured from last_active. Every authenticated request touches this field (see throttling note below). If last_active is older than the allowed window when a request arrives, the session is considered expired: the UserSession and its Token are deleted, and a 401 with session_expired is returned.
Per-Request Authentication
Every API request that requires authentication passes through the custom SessionTokenAuthentication class. This class replaces the standard DRF TokenAuthentication and adds session validation on every call.
-
Extract the token
The
Authorization: Token <key>header is read from the request. If absent or malformed, the request proceeds as anonymous (and will fail anyIsAuthenticatedpermission check). -
Look up the Token record
The token string is looked up in the
authtoken_tokentable. If not found, a 401 is returned. -
Check for a UserSession
The corresponding
UserSessionis looked up. If none exists (e.g., the token was created outside the normal login flow), a 401session_invalidis returned. -
Check inactivity window
The current time is compared against
last_active + inactivity_window. If the session has expired, it is deleted and a 401session_expiredis returned. -
Touch
last_activeTo avoid a database write on every single request,
last_activeis updated at most once every 5 minutes per session. If the last touch was less than 5 minutes ago, the update is skipped.
Session Heartbeat
The frontend actively keeps sessions alive for users who are working in the app. The heartbeat mechanism prevents the 30-minute inactivity timer from firing while a user has the app open and is actively using it.
| Parameter | Value |
|---|---|
| Endpoint | GET /api/auth/check-token/ |
| Interval | Every 10 minutes |
| Condition | Only fires if the user has been active in the last 15 minutes |
| Effect | Passes through SessionTokenAuthentication, touching last_active |
If the user closes the browser or leaves the tab idle for more than 15 minutes, the heartbeat stops firing. The session will then expire naturally after the inactivity window elapses.
Concurrent Session Limits
Each account is limited to 2 active sessions at any one time. This limit is enforced at login time, not during active sessions.
| Scenario | Outcome |
|---|---|
| First login (0 existing sessions) | Session created normally |
| Second login (1 existing session) | Session created; account owner notified by email and in-app notification |
| Third login attempt (2 existing sessions) | Login rejected with 429 too_many_sessions; security alert sent to account holder |
To log in on a new device when at the limit, you must first log out from one of the existing sessions, or wait for an inactive session to expire naturally.
Logout
Logging out destroys the session token server-side. The process is:
-
POST to
api/auth/token/destroy/The request must include the
Authorization: Token <key>header. -
Token deleted
The DRF
Tokenrecord is deleted from the database. -
Session cascade-deleted
The associated
UserSessionis automatically removed via theCASCADEconstraint on thetokenfield. -
Frontend cleanup
The Vue app clears the
accessTokencookie, resets the auth and account Pinia stores, and redirects to/login.
After logout, the token is invalid immediately. Any in-flight requests that were made with the old token after the DELETE completes will receive a 401.
API Token Authentication (Partners)
External integrations — tour operators and travel agents calling the public API — use a separate token system that is entirely distinct from the web app session tokens.
These static API tokens are:
- Created by accommodation providers in the Dev Tools > API Tokens panel
- Scoped to a specific partner contract
- Passed in the URL path, not in an Authorization header
- Active indefinitely until manually revoked by the accommodation provider
- Tracked by the
APIStatsmodel for per-token usage analytics
# Fetch fact sheet data for a property
GET /api/v1/facts/your-api-token-here/
# Fetch property photos
GET /api/v1/photos/your-api-token-here/
# Fetch stop sale calendar
GET /api/v1/stop-sale/your-api-token-here/
No Authorization header is needed for these endpoints. The token in the URL path is the credential. No session is created or required.
No Session Required for Public API
Public API calls do not go through SessionTokenAuthentication. They are validated by a separate token lookup that checks the API token against the partner contract and property ownership. Session expiry rules and concurrent session limits do not apply.
IP Ban System
To limit brute-force login attempts, the platform implements an IP-level ban using the file-based cache.
| Parameter | Value |
|---|---|
| Failed attempts before ban | 13 |
| Ban duration | 120 seconds |
| Attempt counter cache key | login_attempts_{ip} |
| Ban cache key | ip_ban_{ip} |
| Ban TTL | 120 seconds |
| Response when banned | HTTP 429 with X-IP-Banned header containing remaining seconds |
On each failed login attempt the counter increments. When it reaches 13, both the counter and a ban flag are written to the cache. All subsequent login requests from that IP within the 120-second window are rejected immediately without reaching the password check.
File-Based Cache Limitations
The file-based cache used in production means IP bans do not survive a server restart. Additionally, on multi-worker setups each worker process has its own cache file — the effective ban threshold is multiplied by the number of workers. In a 2-worker setup, up to 26 failed attempts may be possible before a ban takes effect.
Security Notes
The following table documents known security characteristics and trade-offs in the current implementation. These are intentional or accepted constraints, not untracked bugs.
| Concern | Status | Notes |
|---|---|---|
| Token storage | Cookie (JS-readable) | The accessToken cookie is not HttpOnly — JavaScript can read it. XSS vulnerabilities could expose the session token. Mitigated by CSP and Vuetify's safe rendering practices, but not eliminated. |
| CSRF | Not required for token auth | The Authorization header cannot be set by cross-origin forms (CORS blocks it), so CSRF tokens are not needed for API endpoints that use token auth. |
| Approval status re-check | Login-time only | If an account is suspended (paused) after the user has already logged in, their existing session remains valid until it expires naturally. There is no real-time revocation of active sessions on status change. |
| Concurrent session race | Low risk | Two simultaneous login requests from different devices could theoretically both pass the concurrent session check before either creates a session record, resulting in 3 active sessions briefly. The window is millisecond-scale and the alert system would fire for the second login regardless. |
| Token expiry | Inactivity-based only | There is no absolute maximum session lifetime. A user who sends a request at least once every 30 minutes (or 7 days on extended) can maintain a session indefinitely. There is no forced re-authentication after N days. |
| IP ban bypass | Possible via IP rotation | The ban is keyed on IP address. Attackers with access to many IPs (e.g., proxies) can bypass it by rotating. The 13-attempt threshold per IP is the effective rate limit. |
DRF Authentication Class Ordering
A known configuration requirement: in DRF settings, TokenAuthentication (or SessionTokenAuthentication) must appear before SessionAuthentication in the DEFAULT_AUTHENTICATION_CLASSES list. Reversing this order causes 401 errors on token-authenticated endpoints because DRF tries the session authenticator first, fails, and does not fall through correctly.
# settings.py — CORRECT ORDER
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'authapp.authentication.SessionTokenAuthentication', # must be first
'rest_framework.authentication.SessionAuthentication',
],
...
}