Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 17 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,22 +76,9 @@ cd LinuxTutorialCMS

# Install frontend dependencies
npm install

# Start the backend (in a separate terminal)
cd backend
cargo run

# Start the frontend
npm run dev
```

### Access the Application

- 🌐 **Frontend:** http://localhost:5173
- 🔧 **Backend API:** http://localhost:8489
- 🔐 **Admin Panel:** http://localhost:5173/login

### Admin-Anmeldung
### Configuration

To get started, you need to create a `.env` file in the `backend` directory. This file will store the necessary environment variables for the application to run correctly.

Expand All @@ -109,7 +96,22 @@ ADMIN_USERNAME=admin
ADMIN_PASSWORD=your-secure-password
```

Once you have created the `.env` file, you can start the backend server.
### Running the Application

```bash
# Start the backend (in a separate terminal)
cd backend
cargo run

# Start the frontend
npm run dev
```

### Access the Application

- 🌐 **Frontend:** http://localhost:5173
- 🔧 **Backend API:** http://localhost:8489
- 🔐 **Admin Panel:** http://localhost:5173/login

---

Expand Down
117 changes: 116 additions & 1 deletion backend/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,20 @@ const SECRET_BLACKLIST: &[&str] = &[
const MIN_SECRET_LENGTH: usize = 43; // ~256 bits when base64 encoded
const MIN_UNIQUE_CHARS: usize = 10;
const MIN_CHAR_CLASSES: usize = 3;

/// The name of the authentication cookie.
pub const AUTH_COOKIE_NAME: &str = "ltcms_session";
const AUTH_COOKIE_TTL_SECONDS: i64 = 24 * 60 * 60; // 24 hours

/// Initializes the JWT secret from the `JWT_SECRET` environment variable.
///
/// This function performs critical security checks to ensure the secret is not a placeholder
/// and meets minimum entropy requirements. It must be called successfully at startup.
///
/// # Returns
///
/// * `Ok(())` if the secret is valid and initialized.
/// * `Err(String)` if the secret is missing, empty, a placeholder, or too weak.
pub fn init_jwt_secret() -> Result<(), String> {
let secret = env::var("JWT_SECRET")
.map_err(|_| "JWT_SECRET environment variable not set".to_string())?;
Expand Down Expand Up @@ -55,21 +66,36 @@ pub fn init_jwt_secret() -> Result<(), String> {
Ok(())
}

/// Retrieves the initialized JWT secret.
///
/// # Panics
///
/// Panics if `init_jwt_secret` has not been called.
fn get_jwt_secret() -> &'static str {
JWT_SECRET
.get()
.expect("JWT_SECRET not initialized. Call init_jwt_secret() first.")
.as_str()
}

/// Represents the claims contained within a JWT.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
pub sub: String, // username
/// The subject of the token (username).
pub sub: String,
/// The role of the user.
pub role: String,
/// The expiration timestamp.
pub exp: usize,
}

impl Claims {
/// Creates new `Claims` for a user with a 24-hour expiration.
///
/// # Arguments
///
/// * `username` - The username to encode in the token.
/// * `role` - The user's role.
pub fn new(username: String, role: String) -> Self {
// Use checked arithmetic to prevent overflow
let expiration = Utc::now()
Expand All @@ -85,6 +111,16 @@ impl Claims {
}
}

/// Creates a JWT for the given user.
///
/// # Arguments
///
/// * `username` - The username.
/// * `role` - The user's role.
///
/// # Returns
///
/// A `Result` containing the signed JWT string or a `jsonwebtoken::errors::Error`.
pub fn create_jwt(username: String, role: String) -> Result<String, jsonwebtoken::errors::Error> {
let claims = Claims::new(username, role);
let secret = get_jwt_secret();
Expand All @@ -96,6 +132,15 @@ pub fn create_jwt(username: String, role: String) -> Result<String, jsonwebtoken
)
}

/// Verifies a JWT and returns its claims.
///
/// # Arguments
///
/// * `token` - The JWT string to verify.
///
/// # Returns
///
/// A `Result` containing the decoded `Claims` or a `jsonwebtoken::errors::Error`.
pub fn verify_jwt(token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
let secret = get_jwt_secret();

Expand All @@ -112,6 +157,17 @@ pub fn verify_jwt(token: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
Ok(token_data.claims)
}

/// Builds an authentication cookie containing the JWT.
///
/// The cookie is configured with `HttpOnly`, `SameSite=Lax`, and a secure flag if not in a development environment.
///
/// # Arguments
///
/// * `token` - The JWT string to embed in the cookie.
///
/// # Returns
///
/// A `Cookie` struct ready to be added to a response.
pub fn build_auth_cookie(token: &str) -> Cookie<'static> {
let mut builder = Cookie::build((AUTH_COOKIE_NAME, token.to_owned()))
.path("/")
Expand All @@ -126,6 +182,13 @@ pub fn build_auth_cookie(token: &str) -> Cookie<'static> {
builder.build()
}

/// Builds a cookie that instructs the client to remove the authentication cookie.
///
/// This is achieved by setting an immediate expiration date.
///
/// # Returns
///
/// A `Cookie` struct for removal.
pub fn build_cookie_removal() -> Cookie<'static> {
let mut builder = Cookie::build((AUTH_COOKIE_NAME, ""))
.path("/")
Expand All @@ -141,6 +204,10 @@ pub fn build_cookie_removal() -> Cookie<'static> {
builder.build()
}

/// Axum extractor for `Claims`.
///
/// This allows handlers to easily require authentication by including `Claims` in their arguments.
/// It extracts the token from the `Authorization` header or the auth cookie.
impl<S> FromRequestParts<S> for Claims
where
S: Send + Sync,
Expand All @@ -163,6 +230,12 @@ where
}
}

/// Appends a `Set-Cookie` header to a `HeaderMap`.
///
/// # Arguments
///
/// * `headers` - The `HeaderMap` to modify.
/// * `cookie` - The `Cookie` to append.
pub fn append_auth_cookie(headers: &mut HeaderMap, cookie: Cookie<'static>) {
if let Ok(value) = HeaderValue::from_str(&cookie.to_string()) {
headers.append(SET_COOKIE, value);
Expand All @@ -171,6 +244,20 @@ pub fn append_auth_cookie(headers: &mut HeaderMap, cookie: Cookie<'static>) {
}
}

/// Checks if a secret meets minimum entropy requirements.
///
/// A secret is considered high-entropy if it:
/// - Is at least `MIN_SECRET_LENGTH` characters long.
/// - Contains at least `MIN_CHAR_CLASSES` character classes (lower, upper, digit, symbol).
/// - Has at least `MIN_UNIQUE_CHARS` unique characters.
///
/// # Arguments
///
/// * `secret` - The secret string to check.
///
/// # Returns
///
/// `true` if the secret meets the criteria, `false` otherwise.
fn secret_has_min_entropy(secret: &str) -> bool {
if secret.len() < MIN_SECRET_LENGTH {
return false;
Expand Down Expand Up @@ -198,6 +285,13 @@ fn secret_has_min_entropy(secret: &str) -> bool {
unique_chars.len() >= MIN_UNIQUE_CHARS
}

/// Determines if the `Secure` flag should be set on cookies.
///
/// The flag is set unless the `AUTH_COOKIE_SECURE` environment variable is explicitly "false".
///
/// # Returns
///
/// `true` if cookies should be secure, `false` otherwise.
fn cookies_should_be_secure() -> bool {
match env::var("AUTH_COOKIE_SECURE") {
Ok(value) if value.trim().eq_ignore_ascii_case("false") => {
Expand All @@ -210,6 +304,18 @@ fn cookies_should_be_secure() -> bool {
}
}

/// Extracts a JWT from request headers.
///
/// It first checks for an `Authorization: Bearer <token>` header, falling back
/// to the authentication cookie if not found.
///
/// # Arguments
///
/// * `headers` - The `HeaderMap` from the incoming request.
///
/// # Returns
///
/// An `Option<String>` containing the token if found.
fn extract_token(headers: &HeaderMap) -> Option<String> {
if let Some(header_value) = headers.get(AUTHORIZATION) {
if let Ok(value_str) = header_value.to_str() {
Expand All @@ -224,6 +330,15 @@ fn extract_token(headers: &HeaderMap) -> Option<String> {
.map(|cookie| cookie.value().to_string())
}

/// Parses a token from an `Authorization: Bearer <token>` header value.
///
/// # Arguments
///
/// * `value` - The raw string from the `Authorization` header.
///
/// # Returns
///
/// An `Option<String>` containing the token if parsing is successful.
fn parse_bearer_token(value: &str) -> Option<String> {
let trimmed = value.trim();
let (scheme, token) = trimmed.split_once(' ')?;
Expand Down
22 changes: 22 additions & 0 deletions backend/src/csrf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ const CSRF_VERSION: &str = "v1";

static CSRF_SECRET: OnceLock<Vec<u8>> = OnceLock::new();

/// Initializes the CSRF secret from the `CSRF_SECRET` environment variable.
///
/// This must be called at application startup. It validates that the secret
/// meets minimum length and complexity requirements.
pub fn init_csrf_secret() -> Result<(), String> {
let secret = env::var(CSRF_SECRET_ENV)
.map_err(|_| format!("{CSRF_SECRET_ENV} environment variable not set"))?;
Expand All @@ -59,13 +63,17 @@ pub fn init_csrf_secret() -> Result<(), String> {
Ok(())
}

/// Retrieves the initialized CSRF secret. Panics if not initialized.
fn get_secret() -> &'static [u8] {
CSRF_SECRET
.get()
.expect("CSRF secret not initialized. Call init_csrf_secret() first.")
.as_slice()
}

/// Issues a new CSRF token for a given username.
///
/// The token embeds the username, expiry, and a nonce, signed with HMAC-SHA256.
pub fn issue_csrf_token(username: &str) -> Result<String, String> {
if username.is_empty() {
return Err("Username required for CSRF token".to_string());
Expand All @@ -89,6 +97,9 @@ pub fn issue_csrf_token(username: &str) -> Result<String, String> {
Ok(format!("{versioned_payload}|{signature}"))
}

/// Validates a CSRF token against an expected username.
///
/// Checks the token's signature, expiry, and that it belongs to the authenticated user.
fn validate_csrf_token(token: &str, expected_username: &str) -> Result<(), String> {
let mut parts = token.split('|');

Expand Down Expand Up @@ -155,11 +166,13 @@ fn validate_csrf_token(token: &str, expected_username: &str) -> Result<(), Strin
Ok(())
}

/// Performs a constant-time comparison of two byte slices.
fn subtle_equals(a: &[u8], b: &[u8]) -> bool {
use subtle::ConstantTimeEq;
a.ct_eq(b).into()
}

/// Appends a `Set-Cookie` header for the CSRF token to a `HeaderMap`.
pub fn append_csrf_cookie(headers: &mut HeaderMap, token: &str) {
let cookie = build_csrf_cookie(token);
if let Ok(value) = HeaderValue::from_str(&cookie.to_string()) {
Expand All @@ -169,6 +182,7 @@ pub fn append_csrf_cookie(headers: &mut HeaderMap, token: &str) {
}
}

/// Appends a `Set-Cookie` header to clear the CSRF cookie.
pub fn append_csrf_removal(headers: &mut HeaderMap) {
let cookie = build_csrf_removal();
if let Ok(value) = HeaderValue::from_str(&cookie.to_string()) {
Expand All @@ -178,6 +192,7 @@ pub fn append_csrf_removal(headers: &mut HeaderMap) {
}
}

/// Builds the CSRF cookie with appropriate security flags.
fn build_csrf_cookie(token: &str) -> Cookie<'static> {
let mut builder = Cookie::build((CSRF_COOKIE_NAME, token.to_owned()))
.path("/")
Expand All @@ -192,6 +207,7 @@ fn build_csrf_cookie(token: &str) -> Cookie<'static> {
builder.build()
}

/// Builds a cookie that instructs the client to remove the CSRF cookie.
fn build_csrf_removal() -> Cookie<'static> {
let mut builder = Cookie::build((CSRF_COOKIE_NAME, ""))
.path("/")
Expand All @@ -207,6 +223,10 @@ fn build_csrf_removal() -> Cookie<'static> {
builder.build()
}

/// An Axum extractor that enforces CSRF protection for state-changing requests.
///
/// This guard checks for a valid CSRF token in both the cookie and header,
/// ensuring they match and are valid for the authenticated user.
pub struct CsrfGuard;

#[async_trait]
Expand Down Expand Up @@ -281,10 +301,12 @@ where
}
}

/// Returns the name of the CSRF cookie.
pub fn csrf_cookie_name() -> &'static str {
CSRF_COOKIE_NAME
}

/// Returns the name of the CSRF header.
pub fn csrf_header_name() -> &'static str {
CSRF_HEADER_NAME
}
Loading
Loading