Skip to content

Improve our authentication #119

@jbriones1

Description

@jbriones1

TL;DR We should use JSON Web Tokens (JWTs)

Look at the implementation section for how we could implement it
https://fastapi.tiangolo.com/advanced/security/oauth2-scopes/
https://www.jwt.io/
https://www.rfc-editor.org/rfc/rfc7519#section-10.1
https://www.rfc-editor.org/rfc/rfc8725

Description

I think our backend should be frontend agnostic, which means we should probably improve how we handle sessions. We could also share sessions between different applications across the CSSS. This will require us to rewrite how our authentication flow works. Since we rely on SFU's CAS system, we would just need to adapt how our sessions are created, managed, and destroyed.

Right now we hit the database every time we need to check sessions. With JWTs we remove that requirement.

Changes

NOTE: We can remove needing to check the database every time we want to verify someone since everything we need should be in the access token. Right now, our DB reads are super low, so we can keep our current version. The instructions below assume we'll still store the session/access tokens and check the database every time we need to, but we can rework that table into the refresh table instead of making an entirely new refresh_token table.

Backend

  • Add secrets to create JWT tokens
  • Modify the POST /auth/login endpoint
    • Instead of creating a simple session ID, we'll create two JWTs: Access token and Refresh token (explained below)
  • Create a new endpoint, POST/auth/refresh, which takes the refresh token in the body and verifies it with the backend
    • Once verified, an access token is generated and stored on the cookie

Database

  • Modify the user_session table

    • rename session_id -> access_token: holds the access token's JWT value
      • we can just check this value for the privileges the person has, instead of checking the database for their permissions
  • Make a new table refresh_tokens, with columns:

    • hashed_token (TEXT, primary key): the unique identifier of the token, used to match the token's values
    • computing_id (VARCHAR): the user's computing ID
    • service (VARCHAR): the CAS service used when creating the refresh token, for logging out of CAS
    • jti (TEXT): the unique JWT ID, generated from uuidv4 or something
    • issued_at (INTEGER): date, as a UTC number, for when the token was generated
    • expires_at (INTEGER): date, as a UTC number, for when the token should expire
    • revoked (BOOLEAN): true if the token has been revoked, false otherwise
    • ip (TEXT): hashed IP address from the request which created this token
    • os_name (TEXT): hashed value from the user agent
    • os_version (TEXT): hashed value from the user agent
    • browser (TEXT): hashed value from the user agent

Access token

  • JWT with the claims
    • iss: string, "https://sfucsss.org"
    • sub: string, computing_id
    • iat: integer, represents the UTC time of when the token was generated
    • exp: integer, represents the UTC time of when the token should be expired (iat + 15 minutes)
    • roles: array of string, contains all the privileges a user has
      • could be admin, user, etc
      • needs every single role and lower (e.g. an admin should also have user in its array)
  • Created after a successful verification with SFU CAS
  • Stored in the cookie under the key session_id
  • Utilize httpOnly and secure (already done)
  • Used to verify any sensitive actions
  • Backend should verify that the JWT token is valid and the scopes can be read from the cookie
    • If the token is expired, send a HTTP 401 with WWW-Authenticate=Bearer

Refresh token

  • JWT which expires after 7 days, with claims
    • iss: string, "https://sfucsss.org"
    • sub: string, the user's computing_id
    • iat: integer, represents the UTC time of when the token was generated
    • exp: integer, represents the UTC time of when the token should be expired (iat + 7 days)
    • ip: hashed IP address the request came from
    • ua: hashed the information from the User-Agent header of the request when the refresh token was generated
    • jti: string, unique UUID of this token
  • Created in the backend, stored in the new DB table refresh_token
  • When creating a new refresh token, delete/invalidate the old one
  • Stored in local storage on the frontend
  • Our client applications can send this to the backend, which allows us the create a new session without having to authenticate with SFU's CAS system again
  • Backend needs to validate that everything in the refresh token matches what is stored in the database entry
    • That means the request's IP and User-Agent need to be checked for VERY sensitive data (like writing to the database)
    • Could make the restrictions looser by checking the if the IP address is in the same region (dunno how to check that)
    • Browser version changes should be okay
    • If the validation fails, immediately revoke that token and send back a 401

Frontend

  • Refresh tokens should be stored in local storage
  • Access token needs to be sent for each request, but only check it when the user needs to access sensitive data
  • If a 401 is received, make an API request to POST /api/auth/refresh with the header Authorization: Bearer <refresh_token>
  • It is the frontend's responsibility to manage the refresh token once it is received

Login flow

  1. Frontend -> CAS
  2. CAS: ticket -> Frontend
  3. Frontend: /api/auth/login w/ ticket & service -> Backend
  4. Backend: /serviceValidate?... -> CAS
  5. CAS : user info -> Backend
  6. Backend:
    6.1. creates tokens, stores the refresh token in the database and
    6.2. sends refresh token in the response, sets access token in the cookie
  7. Frontend:
    7.1. uses the access token whenever you need something sensitive or to access the CSSS's APIs

Refresh token flow

Frontend

POST /api/auth/refresh, with header Authorization: Bearer <refresh token> -> Backend

Backend

  1. Hash the token to fetch it from the database, don't decode it yet
  2. Fetch the token from the refresh_token table
    2.1. If token is missing from the request, send back a 400, with detail "token is missing from Authorization header"
    2.2. If token is not found in the database, send back a 401, with detail "invalid"
    2.3. If token is found, but revoked, send back a 401, with detail "invalid". Revoke every token the of that computing_id.
    2.4. Decode the token at this point. Using the JWT decode function can also check if the token is expired or is malformed. Return 401 on these cases, with detail "invalid"
    2.5. If token is found, but there is field mismatch, send back a 401, with detail "invalid". Revoke every token of that computing_id.
    2.6. If the token is found, but it is expired, send back a 401, with detail "expired"
    2.7. If the token is found and valid, return 200. Revoke this token and generate an access and refresh token. Set the access token onto the cookie and return the refresh token in body { token: <new refresh token> }

Implementation Phases

To implement this, I suggest we break it up into phases. All timestamps should be in UTC time in milliseconds (UNIX timestamp) to prevent issues with timezones

Phase 1: Replacing Session ID with a JWT access token

This should be enough for a while, until we decide to create other applications that will share authentications. Once a user's session is up, we ask them to reverify themselves.

  • One token, access token, which is set on the /auth/login response
  • Modify our cookie with the following:
    • Change session_id -> access: the access JWT
    • Add Max-Age: 86400 seconds (24 hours)
  • Convert our session IDs into JWT tokens with the following claims
    • iss: string, "https://sfucsss.org"
    • sub: string, computing_id
    • iat: integer, represents the UTC time of when the token was generated
    • exp: integer, represents the UTC time of when the token should be expired (iat + 1 day)
    • roles: array of string, contains all the privileges a user has
      • could be admin, user, etc
      • needs every single role and lower (e.g. an admin should also have user in its array)
  • Validate requests using the JWT's information, rather than asking the database
  • Continue storing session information to the user_session table
  • Remove the /permissions endpoint, since everything about the user will be stored in the JWT

Phase 2: Add authentication middleware

This should be a relatively smaller change to implement

  • Add middleware that checks the cookie's JWT for every request that requires it
  • Remove all the logged in and admin checks for request functions that use them

Phase 3: Add refresh tokens

This will be a huge implementation, that I hope we decide not to do

  • Add refresh token and its support
  • Add the refresh token table to the database
  • Cookie's Max-Age 86400 seconds (1 day) -> 900 seconds (15 minutes)
  • Add a refresh scheme to automatically request the user to send their refresh token
  • Add the /auth/refresh endpoint
  • Update all frontend applications to manage sending refresh tokens

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