-
Notifications
You must be signed in to change notification settings - Fork 2
The Authentification 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.
- user_data.rs — Data Structures
- firebase.rs — Firestore Connection
- auth_utils.rs — Password Hashing
- user_auth.rs — Business Logic
- api_rest.rs — HTTP Server & Route Handlers
- users-service.js — API Gateway
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 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.
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 {}| 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 provides generic CRUD helpers and concrete shorthands for the User collection.
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.
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: DBDataInternally 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()),
}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 succeededOverwrites 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: DBDataRemoves 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>>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.
// 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 contains the two cryptographic helpers used by the registration and login flows. Both are thin wrappers around the argon2 crate.
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.
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 contains the four core functions that implement the user lifecycle. All of them sit between the HTTP handlers and the database layer.
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.
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.
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.
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 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.
| 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.
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(),
}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(),
}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 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 | 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 |
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.
The gateway implements the Double Submit Cookie pattern:
-
GET /api/csrf-tokengenerates a 32-byte hex token, sets it as anhttpOnlycookie (csrf_token), and returns the same value in the JSON response body. - The frontend stores the body value and attaches it to every mutating request as the
X-CSRF-Tokenheader. - The
verifyCsrfmiddleware (applied to all mutating local routes and the/apiproxy) 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.
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).
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 |
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.
| 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 |