Skip to content

Commit 97a7b55

Browse files
committed
feat: enforce secure-by-default authentication configuration
- Changed AUTH_COOKIE_SECURE to default to true (secure cookies) unless explicitly disabled with warning - Made LOGIN_ATTEMPT_SALT required environment variable to ensure brute-force protection is properly configured - Removed client-side JWT token storage in favor of HttpOnly cookies for improved security - Added admin-only permission check for comment creation endpoint
1 parent 288cd31 commit 97a7b55

File tree

5 files changed

+33
-37
lines changed

5 files changed

+33
-37
lines changed

.env.example

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,16 @@ JWT_SECRET=your-super-secret-jwt-key-min-32-chars-change-me-in-production
1717
PORT=8489
1818
FRONTEND_ORIGINS=http://localhost:5173,http://localhost:3000
1919

20-
# Auth Cookie Configuration
21-
# Set to "true" in production behind HTTPS so cookies are marked as Secure.
22-
AUTH_COOKIE_SECURE=false
23-
2420
# Admin Credentials (used to bootstrap default admin user)
2521
# IMPORTANT: Password must be at least 12 characters long (NIST recommendation)!
2622
# Change these values for production use!
2723
ADMIN_USERNAME=admin
2824
ADMIN_PASSWORD=change-me-min-12-chars
2925

26+
# Login Security Configuration
27+
# Required: high-entropy salt used to hash login attempt identifiers (protects rate limiting)
28+
LOGIN_ATTEMPT_SALT=generate-a-random-64-byte-string
29+
3030
# Logging Configuration
3131
# Rust log level (trace, debug, info, warn, error)
3232
RUST_LOG=info

backend/src/auth.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -199,9 +199,15 @@ fn secret_has_min_entropy(secret: &str) -> bool {
199199
}
200200

201201
fn cookies_should_be_secure() -> bool {
202-
env::var("AUTH_COOKIE_SECURE")
203-
.map(|value| value.trim().eq_ignore_ascii_case("true"))
204-
.unwrap_or(false)
202+
match env::var("AUTH_COOKIE_SECURE") {
203+
Ok(value) if value.trim().eq_ignore_ascii_case("false") => {
204+
tracing::warn!(
205+
"AUTH_COOKIE_SECURE explicitly set to false. Cookies will be sent over HTTP; only use this in trusted development environments."
206+
);
207+
false
208+
}
209+
_ => true,
210+
}
205211
}
206212

207213
fn extract_token(headers: &HeaderMap) -> Option<String> {

backend/src/handlers/auth.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,12 @@ fn hash_login_identifier(username: &str) -> String {
2020

2121
let salt = SALT.get_or_init(|| {
2222
env::var("LOGIN_ATTEMPT_SALT").unwrap_or_else(|_| {
23-
tracing::warn!("LOGIN_ATTEMPT_SALT not set; using default dev salt. Set a random value in production.");
24-
"dev-login-attempt-salt".to_string()
23+
tracing::error!(
24+
"LOGIN_ATTEMPT_SALT environment variable is missing. Set a high-entropy value to enable secure brute-force protection."
25+
);
26+
panic!(
27+
"LOGIN_ATTEMPT_SALT must be set to a random, high-entropy string. See .env.example for guidance."
28+
);
2529
})
2630
});
2731

backend/src/handlers/comments.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,15 @@ pub async fn create_comment(
100100
Path(tutorial_id): Path<String>,
101101
Json(payload): Json<CreateCommentRequest>,
102102
) -> Result<Json<Comment>, (StatusCode, Json<ErrorResponse>)> {
103+
if claims.role != "admin" {
104+
return Err((
105+
StatusCode::FORBIDDEN,
106+
Json(ErrorResponse {
107+
error: "Insufficient permissions".to_string(),
108+
}),
109+
));
110+
}
111+
103112
if let Err(e) = validate_tutorial_id(&tutorial_id) {
104113
return Err((
105114
StatusCode::BAD_REQUEST,

src/api/client.js

Lines changed: 5 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -56,45 +56,22 @@ const isBinaryBody = (body) => {
5656
return false
5757
}
5858

59-
const isLikelyJwt = (token) => {
60-
if (typeof token !== 'string') return false
61-
const parts = token.split('.')
62-
return parts.length === 3 && parts.every((part) => part.length > 0)
63-
}
64-
6559
class ApiClient {
6660
constructor() {
6761
this.token = null
68-
if (typeof window !== 'undefined') {
69-
const stored = window.localStorage.getItem('ltcms_token')
70-
if (isLikelyJwt(stored)) {
71-
this.token = stored
72-
}
73-
}
7462
}
7563

7664
setToken(token) {
77-
if (token && !isLikelyJwt(token)) {
65+
// Tokens are now managed exclusively via HttpOnly cookies.
66+
// Retain method signature for backwards compatibility without storing client-side state.
67+
if (token && typeof token !== 'string') {
7868
console.warn('Attempted to set invalid JWT token; ignoring')
79-
this.token = null
80-
return
81-
}
82-
this.token = token || null
83-
if (typeof window !== 'undefined') {
84-
if (this.token) {
85-
window.localStorage.setItem('ltcms_token', this.token)
86-
} else {
87-
window.localStorage.removeItem('ltcms_token')
88-
}
8969
}
70+
this.token = null
9071
}
9172

9273
getHeaders() {
93-
const headers = {}
94-
if (this.token) {
95-
headers['Authorization'] = `Bearer ${this.token}`
96-
}
97-
return headers
74+
return {}
9875
}
9976

10077
async request(endpoint, options = {}) {

0 commit comments

Comments
 (0)