Skip to content

User Authentication Endpoint #24

@aniebietafia

Description

@aniebietafia

Feature: Implement POST /api/v1/auth/login — User Authentication Endpoint

Problem
Registered FluentMeet users have no way to authenticate. Without a login endpoint, no access or refresh tokens can be issued, making every protected endpoint in the application unreachable. Additionally, storing refresh tokens in a way that is accessible to JavaScript exposes them to XSS attacks; they must be set as HttpOnly cookies by the server.

Proposed Solution
Implement POST /api/v1/auth/login which validates user credentials, issues a short-lived JWT access token and a long-lived JWT refresh token, and returns them in a structured response body. The refresh token is simultaneously set as an HttpOnly, Secure, SameSite=Strict cookie so the browser manages it safely without JavaScript access. The access token is returned in the response body for the client to store in memory (not localStorage).

User Stories

  • As a registered user, I want to log in with my email and password and receive an access token, so I can make authenticated API requests.
  • As a registered user, I want my refresh token to be stored in an HttpOnly cookie, so it cannot be stolen by a malicious script running on the page.
  • As a security engineer, I want login attempts for non-existent users and wrong passwords to return the same generic error, so attackers cannot enumerate valid email addresses via timing or message differences.
  • As a developer, I want login to be explicitly blocked for unverified or soft-deleted accounts, with a clear error code for each case, so the client can display a meaningful message.

Acceptance Criteria

  1. POST /api/v1/auth/login accepts the following JSON body:
    {
      "email": "user@example.com",
      "password": "MyStr0ngP@ss!"
    }
  2. Validation & Authentication:
    • If the email does not exist or the password is incorrect, return 401 Unauthorized with the same generic message to prevent user enumeration:
      { "status": "error", "code": "INVALID_CREDENTIALS", "message": "Invalid email or password.", "details": [] }
    • If the user exists but is_verified=False, return 403 Forbidden:
      { "status": "error", "code": "EMAIL_NOT_VERIFIED", "message": "Please verify your email before logging in.", "details": [] }
    • If the user exists but deleted_at is not null, return 403 Forbidden:
      { "status": "error", "code": "ACCOUNT_DELETED", "message": "This account has been deleted.", "details": [] }
  3. Token Issuance:
    • Access Token (AT): signed JWT with sub (user email), jti (UUID), exp (ACCESS_TOKEN_EXPIRE_MINUTES from settings). Returned in the response body.
    • Refresh Token (RT): signed JWT with sub (user email), jti (UUID), exp (REFRESH_TOKEN_EXPIRE_DAYS from settings). The jti is stored in Redis as refresh_token:{jti} with a TTL equal to the token expiry.
  4. On successful login, the response is 200 OK:
    {
      "access_token": "<jwt>",
      "refresh_token": "<jwt>",
      "token_type": "bearer",
      "expires_in": 900
    }
    The refresh token is also set in an HttpOnly cookie:
    Set-Cookie: refresh_token=<jwt>; HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth; Max-Age=604800
    
  5. The Path of the cookie is restricted to /api/v1/auth so it is only sent to token-rotation and logout endpoints, not every API request.
  6. The endpoint is rate-limited to 10 requests/minute per IP.
  7. Passwords are verified using passlib.context.CryptContext.verify() — never compared in plaintext.
  8. Unit and integration tests cover: successful login, wrong password, unverified account, deleted account, and correct cookie attributes.

Proposed Technical Details

  • Router: app/api/v1/endpoints/auth.py — new POST /login route alongside /signup.
  • Schema: New LoginRequest(email: EmailStr, password: str) and reuse Token schema (already in app/schemas/user.py with access_token, refresh_token, token_type, expires_in).
  • Security Logic: app/core/security.py — implement create_access_token(email, jti) and create_refresh_token(email, jti) using python-jose.
  • Redis: app/services/token_store.pysave_refresh_token(jti, ttl) and revoke_refresh_token(jti) helpers.
  • Cookie: Use FastAPI's Response object (response.set_cookie(...)) to set the HttpOnly cookie within the route handler.
  • New/Modified Files:
    • app/api/v1/endpoints/auth.py — add POST /login [MODIFY]
    • app/core/security.pycreate_access_token, create_refresh_token, verify_password [NEW]
    • app/services/token_store.py — Redis refresh token persistence [NEW]
    • app/schemas/auth.pyLoginRequest schema [MODIFY]

Tasks

  • Implement verify_password, create_access_token, and create_refresh_token in app/core/security.py.
  • Implement save_refresh_token and revoke_refresh_token Redis helpers in app/services/token_store.py.
  • Add LoginRequest Pydantic schema to app/schemas/auth.py.
  • Implement POST /api/v1/auth/login in app/api/v1/endpoints/auth.py.
  • Apply ForbiddenException with codes EMAIL_NOT_VERIFIED and ACCOUNT_DELETED for the respective guard conditions.
  • Set the HttpOnly refresh token cookie with correct attributes in the route response.
  • Apply @limiter.limit("10/minute") rate limit to the login route.
  • Write unit tests for verify_password, create_access_token, and create_refresh_token.
  • Write integration tests for: correct credentials (200), wrong password (401), unverified user (403), deleted user (403), and cookie presence/attributes.

Open Questions/Considerations

  • Should the access token be returned in the response body only (recommended, stored in memory by the client), or also as a separate cookie? Dual-cookie setups simplify some SPA architectures but complicate CSRF protection.
  • Should we enforce a maximum failed login attempt lockout (e.g., lock account after 10 consecutive failures), or rely solely on rate limiting per IP?
  • The ACCESS_TOKEN_EXPIRE_MINUTES default is 15 minutes — is this the agreed value, or does the team want a longer-lived AT (e.g., 60 minutes) to reduce /refresh-token call frequency?
  • Should login also return the user's profile (language preferences, avatar URL) in the same response, to save the client a subsequent GET /users/me call?

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions