-
Notifications
You must be signed in to change notification settings - Fork 2
The Authentification Module
| yovi_en2a/authService
The purpose of this microservice is to handle all user identity operations: registration and login. It exposes a small HTTP API (port 4001) that the API Gateway proxies under the /api prefix. It connects to Firestore for persistent user storage and uses Argon2 for password hashing.
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 and insert_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
|
firebase.rs provides two generic read/write helpers and a pair of concrete shorthands for the User collection. Its design mirrors the GameManager's Firebase module.
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? See the GameManager docs for the full rationale. In short: 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.
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 in {}", 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 succeeded// 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>>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 two core functions that implement the registration and login workflows. Both sit between the HTTP handlers and the database layer.
email, username, password
│
▼
1. read_db("Users", email) → already exists? → Err("User already exists")
│
▼
2. hash_password(password)
│
▼
3. insert_db("Users", email, User { email, username, password_hash })
│
▼
4. Ok(())
The email is used as both the login identifier and the Firestore document ID. Attempting to look up the document before inserting is the uniqueness check — if read_db returns Ok, the email is already taken.
if let Ok(_) = read_db::<User>("Users", email).await {
return Err("User already exists".into());
}email, password
│
▼
1. read_db("Users", email) → not found? → Err (from read_db)
│
▼
2. verify_password(password, user.password_hash)
│
┌──┴──┐
true false
│ │
Ok(user) Err("Invalid password")
api_rest.rs defines the Axum HTTP server and the two 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 |
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 confirmation message; on failure it returns HTTP 400 with the error string.
match register_user(&payload.email, &payload.username, &payload.password).await {
Ok(_) => (StatusCode::OK, Json(json!({ "message": "User registered successfully" }))).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 welcome message; on failure it returns HTTP 401.
match login_user(&payload.email, &payload.password).await {
Ok(user) => (StatusCode::OK, Json(LoginResponse { ... })).into_response(),
Err(e) => (StatusCode::UNAUTHORIZED, 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, metrics, and Swagger docs, then proxies requests to the appropriate backend microservice.
| # | 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 | Echoes back the origin header for allowed origins; 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 |
The gateway implements the Double Submit Cookie pattern:
-
GET /api/csrf-tokengenerates a UUID 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 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.
| Prefix | Target | CSRF required | Path rewrite |
|---|---|---|---|
/api |
Auth service (AUTH_URL, default :4001) |
✅ |
/api/login → /login
|
/game |
Game Manager (GAMEMANAGER_URL, default :5000) |
❌ |
/game/bestTimes → /bestTimes
|
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 |
NODE_ENV |
— | Set to production to enable secure cookies and strict CORS |