Skip to content

The Authentification Module

Matías Valle Trapiella edited this page Apr 13, 2026 · 2 revisions

The Authentication Module

yovi_en2a/userAuthentification

The purpose of this microservice is to handle all user identity operations: registration, login, username update, and account deletion. It exposes a small HTTP API (port 4001) that the API Gateway calls directly. It connects to Firestore for persistent user storage and uses Argon2 for password hashing.


Table of Contents


user_data.rs — Data Structures

user_data.rs is the data layer of the module. It defines the structures that travel between the API, the business logic, and Firestore, as well as the shared DBData trait.

DBData trait

DBData is a marker trait that bundles the constraints required to work with Firestore's Fluent API:

pub trait DBData: Serialize + for<'de> Deserialize<'de> + Debug + Send + Sync {}

Any struct that implements DBData can be passed to the generic read_db, insert_db, update_db, and delete_db functions in firebase.rs. This keeps the database layer fully generic — it never needs to know the concrete type it is reading or writing.

User

The persisted representation of a user in Firestore. The email is used as the document ID, so it doubles as the unique key.

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
    pub email: String,
    pub username: String,
    pub password_hash: String,  // Argon2 hash — the raw password is never stored
}
impl DBData for User {}

API Request / Response Models

Struct Direction Fields
RegisterRequest Inbound (POST /register) email, username, password
LoginRequest Inbound (POST /login) email, password
LoginResponse Outbound username, email, message
UpdateUsernameRequest Inbound (POST /update-username) email, new_username

firebase.rs — Firestore Connection

firebase.rs provides generic CRUD helpers and concrete shorthands for the User collection.

Crypto provider setup

The firestore crate uses rustls for TLS. Since Rust 1.x, rustls no longer ships with a default crypto provider, so one must be installed at startup. A Once guard ensures this happens exactly once, no matter how many times get_connection() is called concurrently:

static INIT_CRYPTO: Once = Once::new();

INIT_CRYPTO.call_once(|| {
    let provider = rustls::crypto::ring::default_provider();
    let _ = provider.install_default();
});

Why Ring? It is the most mature Rust crypto library, is optimized at the assembly level for common CPU architectures, supports only secure algorithms, and compiles reliably across Linux (Docker) and Windows.

The FIREBASE_PROJECT_ID environment variable is read at connection time — if it is not set, get_connection() returns an error immediately.

read_db<T>

Fetches a single document from a Firestore collection and deserializes it into any type that implements DBData.

pub async fn read_db<T>(table_name: &str, id: &str) -> Result<T, Box<dyn Error>>
where
    for<'de> T: DBData

Internally it opens a connection, queries using the Fluent API, and maps the Option<T> result into a Result, returning an error if the document does not exist:

let object: Option<T> = db.fluent()
    .select()
    .by_id_in(table_name)
    .obj()
    .one(id)
    .await?;

match object {
    Some(data) => Ok(data),
    None => Err(format!("Document with ID {} not found into {}", id, table_name).into()),
}

insert_db<T>

Inserts a document and then immediately reads it back to verify the write was durable. If the post-insertion read fails, the error is propagated to the caller.

db.fluent()
    .insert()
    .into(table_name)
    .document_id(id)
    .object(data)
    .execute::<()>()
    .await?;

read_db::<T>(table_name, id).await?;  // Verify the write succeeded

update_db<T>

Overwrites an existing document in place. Unlike insert_db, it does not perform a post-write read — it trusts Firestore's update semantics. Used by the username update flow.

pub async fn update_db<T>(table_name: &str, id: &str, data: &T) -> Result<(), Box<dyn Error>>
where
    for<'de> T: DBData

delete_db

Removes a document from a collection by ID. Firestore returns success even if the document did not exist, so callers should not rely on this call as an existence check.

pub async fn delete_db(table_name: &str, id: &str) -> Result<(), Box<dyn Error>>

check_username_exists

Queries the Users collection for any document whose username field matches the given string. Unlike the other helpers, this performs a field-value query rather than a document-ID lookup, because usernames are stored as a field, not as the document key.

pub async fn check_username_exists(username: &str) -> Result<bool, Box<dyn Error>>

Returns true if at least one matching document is found, false otherwise.

Concrete shorthands

// Read a user by email (used as document ID)
pub async fn get_user_by_id(id: &str) -> Result<User, Box<dyn Error>>

// Insert a user into the "Users" collection
pub async fn insert_user_by_id(id: &str, user_data: &User) -> Result<(), Box<dyn Error>>

// Delete a user from the "Users" collection
pub async fn delete_user_by_id(id: &str) -> Result<(), Box<dyn Error>>

auth_utils.rs — Password Hashing

auth_utils.rs contains the two cryptographic helpers used by the registration and login flows. Both are thin wrappers around the argon2 crate.

hash_password

Generates a random salt via OsRng (the OS cryptographic random source) and produces an Argon2 hash string that encodes the algorithm parameters, salt, and digest in a single portable string:

pub fn hash_password(password: &str) -> Result<String, Box<dyn Error>> {
    let salt = SaltString::generate(&mut OsRng);
    let hash = Argon2::default().hash_password(password.as_bytes(), &salt)?;
    Ok(hash.to_string())
}

The resulting string is what gets stored in Firestore — the raw password is never persisted anywhere.

verify_password

Parses the stored hash string back into a PasswordHash object and uses Argon2's built-in constant-time comparison to check the candidate password. Using constant-time comparison is important because a naive string comparison would leak timing information that could help an attacker brute-force credentials.

pub fn verify_password(password: &str, hash: &str) -> Result<bool, Box<dyn Error>> {
    let parsed_hash = PasswordHash::new(hash)?;
    Ok(Argon2::default()
        .verify_password(password.as_bytes(), &parsed_hash)
        .is_ok())
}

user_auth.rs — Business Logic

user_auth.rs contains the four core functions that implement the user lifecycle. All of them sit between the HTTP handlers and the database layer.

register_user

email, username, password
│
▼

read_db("Users", email)  →  already exists?  →  Err("Email already exists")
│
▼
check_username_exists(username)  →  already taken?  →  Err("Username already in use")
│
▼
hash_password(password)
│
▼
insert_db("Users", email, User { email, username, password_hash })
│
▼
Ok(())

The email is used as both the login identifier and the Firestore document ID, so it must be globally unique. The username is stored as a plain field and is enforced to be unique via check_username_exists before insertion.

login_user

email, password
│
▼

read_db("Users", email)  →  not found?  →  Err("Invalid email or password")
│
▼
verify_password(password, user.password_hash)
│
┌──┴──┐
true   false
│      │
Ok(user) Err("Invalid email or password")

Both the "email not found" and "wrong password" branches return the same generic error string. This is deliberate — returning different messages for each case would allow an attacker to enumerate registered email addresses.

delete_user

Calls delete_db("Users", email) to remove the document. The function logs the result but does not perform an existence check beforehand — if the document is absent, Firestore returns success silently.

update_username

email, new_username
│
▼

read_db("Users", email)  →  not found?  →  Err("User not found")
│
▼
check_username_exists(new_username)  →  already taken?  →  Err("Username already in use")
│
▼
user.username = new_username
│
▼
update_db("Users", email, &user)
│
▼
Ok(())

The full User struct is fetched first so that the update_db call preserves the email and password_hash fields — only username is mutated before the write.


api_rest.rs — HTTP Server & Route Handlers

api_rest.rs defines the Axum HTTP server and the three route handlers. It is the entry point to the microservice and delegates all business logic to user_auth.rs.

Routes

Method Path Handler Description
POST /register register_handler Creates a new user account
POST /login login_handler Authenticates an existing user
POST /update-username update_username_handler Changes the username for an existing account

The server binds to 0.0.0.0:4001. Using 0.0.0.0 instead of 127.0.0.1 is required for Docker so the port is reachable from outside the container.

register_handler

Deserializes the JSON body into a RegisterRequest and calls register_user. On success it returns HTTP 200 with a LoginResponse containing the new account details; on failure it returns HTTP 400 with the error string.

match register_user(&payload.email, &payload.username, &payload.password).await {
    Ok(_)  => (StatusCode::OK,         Json(LoginResponse { username, email, message: "User registered successfully".to_string() })).into_response(),
    Err(e) => (StatusCode::BAD_REQUEST, Json(json!({ "error": e.to_string() }))).into_response(),
}

login_handler

Deserializes the body into a LoginRequest and calls login_user. On success it returns HTTP 200 with a LoginResponse containing username, email, and a personalised welcome message; on failure it returns HTTP 401.

match login_user(&payload.email, &payload.password).await {
    Ok(user) => (StatusCode::OK,          Json(LoginResponse { username, email, message: format!("Welcome again {}", user.username) })).into_response(),
    Err(e)   => (StatusCode::UNAUTHORIZED, Json(json!({ "error": e.to_string() }))).into_response(),
}

update_username_handler

Deserializes the body into an UpdateUsernameRequest and calls update_username. On success it returns HTTP 200 with a confirmation message; on failure it returns HTTP 400.

match update_username(&payload.email, &payload.new_username).await {
    Ok(_)  => (StatusCode::OK,         Json(json!({ "message": "Username updated successfully" }))).into_response(),
    Err(e) => (StatusCode::BAD_REQUEST, Json(json!({ "error": e.to_string() }))).into_response(),
}

users-service.js — API Gateway

users-service.js is a Node.js/Express service running on port 3000. It is the single entry point the frontend talks to. It handles CORS, CSRF protection, Redis-backed session management, metrics, and Swagger docs. Authentication endpoints are handled locally — not proxied — so the gateway can create and destroy sessions around each call.

Middleware stack

# Middleware Purpose
1 cookie-parser Parses incoming cookies into req.cookies
2 express-prom-bundle Exposes a /metrics endpoint for Prometheus scraping
3 Custom CORS handler Validates the request origin against an explicit allowlist; handles OPTIONS preflight responses
4 swagger-ui-express Serves the OpenAPI spec at /api-docs
5 express.json() Parses JSON request bodies — must come before local POST routes

CORS protection

Rather than echoing back whatever origin the browser sends, the gateway validates it against a static allowlist built at startup:

const allowedOriginsList = [
  ...(deployHost ? [`https://${deployHost}`] : []),
  'http://localhost',
  'http://localhost:80',
  'http://localhost:5173',
];

Only origins present in this list receive Access-Control-Allow-Origin set to their own value (required for credentialed requests). In development, requests with no Origin header (e.g. Postman, server-to-server) receive a wildcard origin — without credentials, since the CORS spec forbids combining * with credentials: true.

CSRF protection

The gateway implements the Double Submit Cookie pattern:

  1. GET /api/csrf-token generates a 32-byte hex token, sets it as an httpOnly cookie (csrf_token), and returns the same value in the JSON response body.
  2. The frontend stores the body value and attaches it to every mutating request as the X-CSRF-Token header.
  3. The verifyCsrf middleware (applied to all mutating local routes and the /api proxy) compares the cookie value against the header value. A mismatch returns HTTP 403.
const cookieToken = req.cookies.csrf_token;
const headerToken = req.headers['x-csrf-token'];

if (!cookieToken || !headerToken || cookieToken !== headerToken) {
    return res.status(403).json({ error: 'Invalid or missing CSRF token' });
}

GET, OPTIONS, and HEAD requests are exempt from the check since they carry no side effects.

Session management (Redis)

Sessions are stored server-side in Redis. The browser only holds an opaque sessionId cookie — no user data is ever written to the client. The session TTL is 30 minutes, and it is refreshed (sliding window) on every call to GET /api/me.

const SESSION_TTL_SECONDS = 1800;

// Create — stores { username, email } under a random 64-char hex key
async function createSession(userData) { ... }

// Read — returns the parsed object or null if expired/missing
async function getSession(sessionId) { ... }

// Destroy — removes the key from Redis
async function destroySession(sessionId) { ... }

The sessionId cookie is set with httpOnly: true, sameSite: 'lax', and secure: true in production (HTTPS only).

Local auth endpoints

Login and registration are handled directly in the gateway — not proxied — so a Redis session can be created immediately after the Auth service responds with HTTP 200. The shared handleAuthRequest helper encapsulates this pattern: it POSTs to the Auth service, creates a session on success, and maps error status codes to user-friendly messages on failure.

Method Path CSRF Description
GET /api/csrf-token Issues a new CSRF token
GET /api/me Returns the current session's user data from Redis
POST /api/login Calls Auth service, creates session on success
POST /api/register Calls Auth service, creates session on success
POST /api/logout Destroys the Redis session and clears the cookie
POST /api/update-username Calls Auth service to persist the change, then updates the Redis session

Proxy routes

Any /api/* path not matched by a local handler falls through to the Auth service proxy. /game/* traffic is forwarded to the Game Manager without CSRF enforcement.

Prefix Target CSRF required Path rewrite
/api Auth service (AUTH_URL, default :4001) /api/foo/foo
/game Game Manager (GAMEMANAGER_URL, default :5000) /game/foo/foo

fixRequestBody is applied to both proxies to rebuild the request body stream that express.json() already consumed, otherwise the downstream service would receive an empty body.

Environment variables

Variable Default Description
GAMEMANAGER_URL http://localhost:5000 URL of the GameManager microservice
AUTH_URL http://localhost:4001 URL of the Auth microservice
DEPLOY_HOST Hostname of the production deployment; added to the CORS allowlist as https://<DEPLOY_HOST>
NODE_ENV Set to production to enable secure cookies

Clone this wiki locally