mirror of
https://codeberg.org/likwid/likwid.git
synced 2026-03-26 19:03:08 +00:00
Compare commits
7 commits
aa2e7894b4
...
33311c51c8
| Author | SHA1 | Date | |
|---|---|---|---|
| 33311c51c8 | |||
| 070257597e | |||
| 8c794432a3 | |||
| 30c6073126 | |||
| 51a78b1eb4 | |||
| d381478b29 | |||
| 4b4e2458e4 |
18 changed files with 588 additions and 62 deletions
|
|
@ -8,6 +8,15 @@ DATABASE_URL=postgres://likwid:likwid@localhost:5432/likwid
|
|||
SERVER_HOST=127.0.0.1
|
||||
SERVER_PORT=3000
|
||||
|
||||
# =============================================================================
|
||||
# CORS
|
||||
# =============================================================================
|
||||
# In development, CORS defaults to allowing any origin.
|
||||
# In production, set an allowlist (comma-separated) to restrict browser access.
|
||||
# Example:
|
||||
# CORS_ALLOWED_ORIGINS=https://openlikwid.org,https://staging.openlikwid.org
|
||||
CORS_ALLOWED_ORIGINS=
|
||||
|
||||
# JWT Secret for authentication tokens
|
||||
# IMPORTANT: Change this in production!
|
||||
JWT_SECRET=change-me-in-production
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
|
|
@ -34,10 +35,14 @@ pub fn create_token(
|
|||
}
|
||||
|
||||
pub fn verify_token(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
|
||||
let mut validation = Validation::new(Algorithm::HS256);
|
||||
validation.leeway = 30;
|
||||
validation.required_spec_claims = HashSet::from(["exp".to_string(), "sub".to_string()]);
|
||||
|
||||
let token_data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
&validation,
|
||||
)?;
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::header,
|
||||
http::{request::Parts, StatusCode},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
|
@ -8,6 +9,7 @@ use uuid::Uuid;
|
|||
use super::jwt::{verify_token, Claims};
|
||||
use crate::config::Config;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AuthUser {
|
||||
pub user_id: Uuid,
|
||||
pub username: String,
|
||||
|
|
@ -22,23 +24,39 @@ where
|
|||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let auth_header = parts
|
||||
.headers
|
||||
.get("Authorization")
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Missing authorization header"))?;
|
||||
|
||||
let token = auth_header.strip_prefix("Bearer ").ok_or((
|
||||
let mut pieces = auth_header.split_whitespace();
|
||||
let scheme = pieces.next().ok_or((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Invalid authorization header format",
|
||||
))?;
|
||||
let token = pieces.next().ok_or((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Invalid authorization header format",
|
||||
))?;
|
||||
if pieces.next().is_some() || !scheme.eq_ignore_ascii_case("bearer") {
|
||||
return Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Invalid authorization header format",
|
||||
));
|
||||
}
|
||||
|
||||
let secret = parts
|
||||
let config = parts
|
||||
.extensions
|
||||
.get::<Arc<Config>>()
|
||||
.map(|c| c.jwt_secret.clone())
|
||||
.or_else(|| std::env::var("JWT_SECRET").ok())
|
||||
.unwrap_or_else(|| "dev-secret-change-in-production".to_string());
|
||||
.ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Auth config missing"))?;
|
||||
|
||||
let claims: Claims = verify_token(token, &secret)
|
||||
if config.jwt_secret.trim().is_empty() {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"JWT secret not configured",
|
||||
));
|
||||
}
|
||||
|
||||
let claims: Claims = verify_token(token, &config.jwt_secret)
|
||||
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid token"))?;
|
||||
|
||||
Ok(AuthUser {
|
||||
|
|
@ -47,3 +65,83 @@ where
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::AuthUser;
|
||||
use crate::auth::create_token;
|
||||
use crate::config::Config;
|
||||
use axum::body::Body;
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::Request;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn parts_with_auth(
|
||||
auth: Option<&str>,
|
||||
config: Option<Arc<Config>>,
|
||||
) -> axum::http::request::Parts {
|
||||
let mut req = Request::builder().uri("/").body(Body::empty()).unwrap();
|
||||
if let Some(auth) = auth {
|
||||
req.headers_mut()
|
||||
.insert(axum::http::header::AUTHORIZATION, auth.parse().unwrap());
|
||||
}
|
||||
if let Some(config) = config {
|
||||
req.extensions_mut().insert(config);
|
||||
}
|
||||
let (parts, _) = req.into_parts();
|
||||
parts
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_missing_auth_header() {
|
||||
let config = Arc::new(Config {
|
||||
jwt_secret: "secret".to_string(),
|
||||
..Config::default()
|
||||
});
|
||||
let mut parts = parts_with_auth(None, Some(config));
|
||||
let err = AuthUser::from_request_parts(&mut parts, &())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.0, axum::http::StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepts_bearer_case_insensitive_and_whitespace() {
|
||||
let config = Arc::new(Config {
|
||||
jwt_secret: "secret".to_string(),
|
||||
..Config::default()
|
||||
});
|
||||
let token = create_token(Uuid::new_v4(), "alice", &config.jwt_secret).unwrap();
|
||||
let auth = format!(" bEaReR {token} ");
|
||||
let mut parts = parts_with_auth(Some(&auth), Some(config));
|
||||
let user = AuthUser::from_request_parts(&mut parts, &()).await.unwrap();
|
||||
assert_eq!(user.username, "alice");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_non_bearer_scheme() {
|
||||
let config = Arc::new(Config {
|
||||
jwt_secret: "secret".to_string(),
|
||||
..Config::default()
|
||||
});
|
||||
let token = create_token(Uuid::new_v4(), "alice", &config.jwt_secret).unwrap();
|
||||
let auth = format!("Token {token}");
|
||||
let mut parts = parts_with_auth(Some(&auth), Some(config));
|
||||
let err = AuthUser::from_request_parts(&mut parts, &())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.0, axum::http::StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn errors_when_config_missing() {
|
||||
let token = create_token(Uuid::new_v4(), "alice", "secret").unwrap();
|
||||
let auth = format!("Bearer {token}");
|
||||
let mut parts = parts_with_auth(Some(&auth), None);
|
||||
let err = AuthUser::from_request_parts(&mut parts, &())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.0, axum::http::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ pub struct Config {
|
|||
pub server_host: String,
|
||||
#[serde(default = "default_server_port")]
|
||||
pub server_port: u16,
|
||||
#[serde(default)]
|
||||
pub cors_allowed_origins: Option<String>,
|
||||
/// Enable demo mode - restricts destructive actions and enables demo accounts
|
||||
#[serde(default)]
|
||||
pub demo_mode: bool,
|
||||
|
|
@ -74,6 +76,7 @@ impl Default for Config {
|
|||
database_url: "postgres://likwid:likwid@localhost:5432/likwid".to_string(),
|
||||
server_host: "127.0.0.1".to_string(),
|
||||
server_port: 3000,
|
||||
cors_allowed_origins: None,
|
||||
demo_mode: false,
|
||||
jwt_secret: default_jwt_secret(),
|
||||
rate_limit_enabled: default_rate_limit_enabled(),
|
||||
|
|
|
|||
|
|
@ -55,6 +55,101 @@ async fn main() {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod security_headers_tests {
|
||||
use super::add_security_headers;
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, Request, StatusCode};
|
||||
use axum::middleware;
|
||||
use axum::response::Response;
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn security_headers_are_added_by_default() {
|
||||
let app = Router::new()
|
||||
.route("/api/ping", get(|| async { "ok" }))
|
||||
.layer(middleware::map_response(add_security_headers));
|
||||
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/ping")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let res = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
|
||||
assert_eq!(
|
||||
res.headers()
|
||||
.get("x-content-type-options")
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some("nosniff")
|
||||
);
|
||||
assert_eq!(
|
||||
res.headers()
|
||||
.get("x-frame-options")
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some("DENY")
|
||||
);
|
||||
assert_eq!(
|
||||
res.headers()
|
||||
.get("referrer-policy")
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some("no-referrer")
|
||||
);
|
||||
assert_eq!(
|
||||
res.headers()
|
||||
.get("permissions-policy")
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some("camera=(), microphone=(), geolocation=()")
|
||||
);
|
||||
assert_eq!(
|
||||
res.headers()
|
||||
.get("x-permitted-cross-domain-policies")
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some("none")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn security_headers_do_not_override_explicit_headers() {
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/ping",
|
||||
get(|| async {
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("x-frame-options", "SAMEORIGIN")
|
||||
.header(header::REFERRER_POLICY, "strict-origin")
|
||||
.body(Body::from("ok"))
|
||||
.unwrap()
|
||||
}),
|
||||
)
|
||||
.layer(middleware::map_response(add_security_headers));
|
||||
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/ping")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let res = app.oneshot(req).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
res.headers()
|
||||
.get("x-frame-options")
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some("SAMEORIGIN")
|
||||
);
|
||||
assert_eq!(
|
||||
res.headers()
|
||||
.get(header::REFERRER_POLICY)
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some("strict-origin")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> Result<(), StartupError> {
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
|
|
@ -152,10 +247,7 @@ async fn run() -> Result<(), StartupError> {
|
|||
}
|
||||
}
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
let cors = build_cors_layer(config.as_ref());
|
||||
|
||||
let plugins = plugins::PluginManager::new(pool.clone())
|
||||
.register_builtin_plugins()
|
||||
|
|
@ -352,5 +444,155 @@ async fn add_security_headers(mut res: Response) -> Response {
|
|||
);
|
||||
}
|
||||
|
||||
if !headers.contains_key("permissions-policy") {
|
||||
headers.insert(
|
||||
HeaderName::from_static("permissions-policy"),
|
||||
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
|
||||
);
|
||||
}
|
||||
|
||||
if !headers.contains_key("x-permitted-cross-domain-policies") {
|
||||
headers.insert(
|
||||
HeaderName::from_static("x-permitted-cross-domain-policies"),
|
||||
HeaderValue::from_static("none"),
|
||||
);
|
||||
}
|
||||
|
||||
res
|
||||
}
|
||||
|
||||
fn build_cors_layer(config: &Config) -> CorsLayer {
|
||||
let layer = CorsLayer::new().allow_methods(Any).allow_headers(Any);
|
||||
|
||||
let allowed = config
|
||||
.cors_allowed_origins
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty());
|
||||
|
||||
let Some(allowed) = allowed else {
|
||||
return layer.allow_origin(Any);
|
||||
};
|
||||
|
||||
let origins: Vec<HeaderValue> = allowed
|
||||
.split(',')
|
||||
.map(|v| v.trim())
|
||||
.filter(|v| !v.is_empty())
|
||||
.filter_map(|v| match HeaderValue::from_str(v) {
|
||||
Ok(hv) => Some(hv),
|
||||
Err(e) => {
|
||||
tracing::warn!(origin = v, error = %e, "Invalid CORS origin; ignoring");
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if origins.is_empty() {
|
||||
return layer.allow_origin(Any);
|
||||
}
|
||||
|
||||
layer.allow_origin(origins)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cors_tests {
|
||||
use super::build_cors_layer;
|
||||
use axum::body::Body;
|
||||
use axum::http::{header, Request, StatusCode};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
#[tokio::test]
|
||||
async fn cors_default_allows_any_origin() {
|
||||
let cfg = Config::default();
|
||||
let app = Router::new()
|
||||
.route("/api/ping", get(|| async { "ok" }))
|
||||
.layer(build_cors_layer(&cfg));
|
||||
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/ping")
|
||||
.header(header::ORIGIN, "https://example.com")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let res = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
res.headers()
|
||||
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some("*")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cors_allowlist_only_allows_listed_origins() {
|
||||
let cfg = Config {
|
||||
cors_allowed_origins: Some("https://a.example, https://b.example".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let app = Router::new()
|
||||
.route("/api/ping", get(|| async { "ok" }))
|
||||
.layer(build_cors_layer(&cfg));
|
||||
|
||||
let allowed_req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/ping")
|
||||
.header(header::ORIGIN, "https://a.example")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let allowed_res = app.clone().oneshot(allowed_req).await.unwrap();
|
||||
assert_eq!(allowed_res.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
allowed_res
|
||||
.headers()
|
||||
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some("https://a.example")
|
||||
);
|
||||
|
||||
let denied_req = Request::builder()
|
||||
.method("GET")
|
||||
.uri("/api/ping")
|
||||
.header(header::ORIGIN, "https://c.example")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let denied_res = app.clone().oneshot(denied_req).await.unwrap();
|
||||
assert_eq!(denied_res.status(), StatusCode::OK);
|
||||
assert!(denied_res
|
||||
.headers()
|
||||
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cors_preflight_responds_for_allowed_origin() {
|
||||
let cfg = Config {
|
||||
cors_allowed_origins: Some("https://a.example".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let app = Router::new()
|
||||
.route("/api/ping", get(|| async { "ok" }))
|
||||
.layer(build_cors_layer(&cfg));
|
||||
|
||||
let req = Request::builder()
|
||||
.method("OPTIONS")
|
||||
.uri("/api/ping")
|
||||
.header(header::ORIGIN, "https://a.example")
|
||||
.header(header::ACCESS_CONTROL_REQUEST_METHOD, "GET")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let res = app.oneshot(req).await.unwrap();
|
||||
assert!(res.status().is_success());
|
||||
assert_eq!(
|
||||
res.headers()
|
||||
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
.and_then(|v| v.to_str().ok()),
|
||||
Some("https://a.example")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,7 +123,15 @@ fn parse_ip_from_connect_info<B>(request: &Request<B>) -> Option<IpAddr> {
|
|||
|
||||
fn parse_user_from_auth(headers: &HeaderMap, secret: &str) -> Option<Uuid> {
|
||||
let auth = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
|
||||
let token = auth.strip_prefix("Bearer ")?;
|
||||
let mut pieces = auth.split_whitespace();
|
||||
let scheme = pieces.next()?;
|
||||
let token = pieces.next()?;
|
||||
if pieces.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
if !scheme.eq_ignore_ascii_case("bearer") {
|
||||
return None;
|
||||
}
|
||||
verify_token(token, secret).ok().map(|c| c.sub)
|
||||
}
|
||||
|
||||
|
|
@ -242,12 +250,13 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn middleware_bypasses_health() {
|
||||
let mut cfg = Config::default();
|
||||
cfg.rate_limit_enabled = true;
|
||||
cfg.rate_limit_ip_rpm = 1;
|
||||
cfg.rate_limit_user_rpm = 0;
|
||||
cfg.rate_limit_auth_rpm = 0;
|
||||
let cfg = Arc::new(cfg);
|
||||
let cfg = Arc::new(Config {
|
||||
rate_limit_enabled: true,
|
||||
rate_limit_ip_rpm: 1,
|
||||
rate_limit_user_rpm: 0,
|
||||
rate_limit_auth_rpm: 0,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
|
|
@ -270,12 +279,13 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn middleware_enforces_ip_rate_limit() {
|
||||
let mut cfg = Config::default();
|
||||
cfg.rate_limit_enabled = true;
|
||||
cfg.rate_limit_ip_rpm = 2;
|
||||
cfg.rate_limit_user_rpm = 0;
|
||||
cfg.rate_limit_auth_rpm = 0;
|
||||
let cfg = Arc::new(cfg);
|
||||
let cfg = Arc::new(Config {
|
||||
rate_limit_enabled: true,
|
||||
rate_limit_ip_rpm: 2,
|
||||
rate_limit_user_rpm: 0,
|
||||
rate_limit_auth_rpm: 0,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/ping", get(|| async { "ok" }))
|
||||
|
|
@ -308,12 +318,13 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn middleware_enforces_auth_rate_limit_on_login() {
|
||||
let mut cfg = Config::default();
|
||||
cfg.rate_limit_enabled = true;
|
||||
cfg.rate_limit_ip_rpm = 0;
|
||||
cfg.rate_limit_user_rpm = 0;
|
||||
cfg.rate_limit_auth_rpm = 1;
|
||||
let cfg = Arc::new(cfg);
|
||||
let cfg = Arc::new(Config {
|
||||
rate_limit_enabled: true,
|
||||
rate_limit_ip_rpm: 0,
|
||||
rate_limit_user_rpm: 0,
|
||||
rate_limit_auth_rpm: 1,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/auth/login", get(|| async { "ok" }))
|
||||
|
|
@ -344,13 +355,14 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn middleware_enforces_user_rate_limit_when_token_is_valid() {
|
||||
let mut cfg = Config::default();
|
||||
cfg.rate_limit_enabled = true;
|
||||
cfg.rate_limit_ip_rpm = 0;
|
||||
cfg.rate_limit_user_rpm = 1;
|
||||
cfg.rate_limit_auth_rpm = 0;
|
||||
cfg.jwt_secret = "testsecret".to_string();
|
||||
let cfg = Arc::new(cfg);
|
||||
let cfg = Arc::new(Config {
|
||||
rate_limit_enabled: true,
|
||||
rate_limit_ip_rpm: 0,
|
||||
rate_limit_user_rpm: 1,
|
||||
rate_limit_auth_rpm: 0,
|
||||
jwt_secret: "testsecret".to_string(),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let user_id = Uuid::new_v4();
|
||||
let token = create_token(user_id, "u", &cfg.jwt_secret).unwrap();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ Likwid is configured through environment variables and database settings.
|
|||
| `SERVER_HOST` | No | `127.0.0.1` | Bind address |
|
||||
| `SERVER_PORT` | No | `3000` | HTTP port |
|
||||
| `DEMO_MODE` | No | `false` | Enable demo features |
|
||||
| `CORS_ALLOWED_ORIGINS` | No | - | Comma-separated allowlist of CORS origins for browsers (recommended in production) |
|
||||
| `RATE_LIMIT_ENABLED` | No | `true` | Enable API rate limiting |
|
||||
| `RATE_LIMIT_IP_RPM` | No | `300` | Requests per minute per IP |
|
||||
| `RATE_LIMIT_USER_RPM` | No | `1200` | Requests per minute per authenticated user |
|
||||
|
|
@ -118,12 +119,18 @@ Disabling:
|
|||
|
||||
### CORS
|
||||
|
||||
By default, CORS allows all origins in development. For production:
|
||||
By default, CORS allows any origin (development-friendly). In production, set `CORS_ALLOWED_ORIGINS` to a comma-separated allowlist.
|
||||
|
||||
```bash
|
||||
CORS_ALLOWED_ORIGINS=https://likwid.example.org
|
||||
```
|
||||
|
||||
Multiple origins:
|
||||
|
||||
```bash
|
||||
CORS_ALLOWED_ORIGINS=https://openlikwid.org,https://staging.openlikwid.org
|
||||
```
|
||||
|
||||
## Logging
|
||||
|
||||
### Log Levels
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ cp compose/.env.production.example compose/.env.production
|
|||
```
|
||||
|
||||
Required settings:
|
||||
|
||||
- `POSTGRES_PASSWORD` - Strong database password
|
||||
- `JWT_SECRET` - Random 64+ character string
|
||||
|
||||
|
|
@ -38,8 +39,8 @@ podman-compose --env-file .env.production -f production.yml up -d
|
|||
|
||||
### 4. Access
|
||||
|
||||
- Frontend: http://localhost:4321
|
||||
- Backend API: http://localhost:3000
|
||||
- Frontend: <http://localhost:4321>
|
||||
- Backend API: <http://localhost:3000>
|
||||
|
||||
## Manual Installation
|
||||
|
||||
|
|
@ -76,19 +77,21 @@ node ./dist/server/entry.mjs
|
|||
|
||||
## Configuration Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `compose/production.yml` | Production container deployment |
|
||||
| `compose/demo.yml` | Demo instance deployment |
|
||||
| `compose/.env.production.example` | Environment template |
|
||||
| `backend/.env` | Backend configuration |
|
||||
|File|Purpose|
|
||||
|---|---|
|
||||
|`compose/production.yml`|Production container deployment|
|
||||
|`compose/demo.yml`|Demo instance deployment|
|
||||
|`compose/.env.production.example`|Environment template|
|
||||
|`backend/.env`|Backend configuration|
|
||||
|
||||
## Reverse Proxy
|
||||
|
||||
For production, use a reverse proxy (nginx, Caddy) with:
|
||||
|
||||
- HTTPS termination
|
||||
- WebSocket support (for real-time features)
|
||||
- Proper headers
|
||||
- HSTS (set on the reverse proxy)
|
||||
|
||||
Example nginx config:
|
||||
|
||||
|
|
@ -107,12 +110,16 @@ server {
|
|||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -5,36 +5,64 @@ Securing your Likwid instance.
|
|||
## Authentication
|
||||
|
||||
### JWT Tokens
|
||||
|
||||
- Use a strong, random `JWT_SECRET` (64+ characters)
|
||||
- Tokens expire after 24 hours by default
|
||||
- Refresh tokens are not stored server-side
|
||||
|
||||
### Password Policy
|
||||
|
||||
- Minimum 8 characters (configurable)
|
||||
- Bcrypt hashing with cost factor 12
|
||||
- No password in logs or error messages
|
||||
|
||||
### Two-Factor Authentication
|
||||
|
||||
Enable 2FA support for users:
|
||||
|
||||
- TOTP (Time-based One-Time Password)
|
||||
- Backup codes for recovery
|
||||
|
||||
## Network Security
|
||||
|
||||
### HTTPS
|
||||
|
||||
Always use HTTPS in production:
|
||||
|
||||
- Obtain certificates (Let's Encrypt recommended)
|
||||
- Configure reverse proxy for TLS termination
|
||||
- Enable HSTS headers
|
||||
|
||||
### Security Headers
|
||||
|
||||
The backend sets a small set of security headers on responses by default:
|
||||
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
- `X-Frame-Options: DENY`
|
||||
- `Referrer-Policy: no-referrer`
|
||||
- `Permissions-Policy: camera=(), microphone=(), geolocation=()`
|
||||
- `X-Permitted-Cross-Domain-Policies: none`
|
||||
|
||||
Set `Strict-Transport-Security` (HSTS) on your reverse proxy, because the backend does not know whether requests arrived via HTTPS.
|
||||
|
||||
### CORS
|
||||
|
||||
Restrict CORS in production:
|
||||
```
|
||||
|
||||
```bash
|
||||
CORS_ALLOWED_ORIGINS=https://likwid.example.org
|
||||
```
|
||||
|
||||
For multiple origins, use a comma-separated list:
|
||||
|
||||
```bash
|
||||
CORS_ALLOWED_ORIGINS=https://openlikwid.org,https://staging.openlikwid.org
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Protect against abuse:
|
||||
|
||||
- 300 requests/minute per IP (default)
|
||||
- 1200 requests/minute per authenticated user
|
||||
- 30 requests/minute per IP for auth endpoints
|
||||
|
|
@ -42,11 +70,13 @@ Protect against abuse:
|
|||
## Database Security
|
||||
|
||||
### Connection
|
||||
|
||||
- Use SSL for database connections
|
||||
- Dedicated database user with minimal privileges
|
||||
- Strong, unique password
|
||||
|
||||
### Backups
|
||||
|
||||
- Regular automated backups
|
||||
- Encrypted backup storage
|
||||
- Test restore procedures
|
||||
|
|
@ -54,16 +84,20 @@ Protect against abuse:
|
|||
## API Security
|
||||
|
||||
### Input Validation
|
||||
|
||||
All inputs are validated:
|
||||
|
||||
- Type checking
|
||||
- Length limits
|
||||
- Sanitization
|
||||
|
||||
### SQL Injection
|
||||
|
||||
- Parameterized queries only (SQLx)
|
||||
- No raw SQL string concatenation
|
||||
|
||||
### XSS Prevention
|
||||
|
||||
- HTML escaping in templates
|
||||
- Content Security Policy headers
|
||||
- No inline scripts in production
|
||||
|
|
@ -71,6 +105,7 @@ All inputs are validated:
|
|||
## Moderation Audit Trail
|
||||
|
||||
All moderation actions are logged:
|
||||
|
||||
- Who performed the action
|
||||
- What action was taken
|
||||
- Why (reason required)
|
||||
|
|
@ -81,6 +116,7 @@ Logs are immutable and tamper-evident.
|
|||
## Updates
|
||||
|
||||
Keep Likwid updated:
|
||||
|
||||
- Watch the repository for security announcements
|
||||
- Apply patches promptly
|
||||
- Test updates in staging first
|
||||
|
|
@ -88,6 +124,7 @@ Keep Likwid updated:
|
|||
## Incident Response
|
||||
|
||||
If you discover a security issue:
|
||||
|
||||
1. Document the incident
|
||||
2. Assess impact
|
||||
3. Contain the breach
|
||||
|
|
@ -96,6 +133,6 @@ If you discover a security issue:
|
|||
|
||||
## Reporting Vulnerabilities
|
||||
|
||||
Report security issues to: security@likwid.org
|
||||
Report security issues to: [security@likwid.org](mailto:security@likwid.org)
|
||||
|
||||
We follow responsible disclosure practices.
|
||||
|
|
|
|||
|
|
@ -53,6 +53,15 @@
|
|||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
min-width: 200px;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.theme-select {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-select:hover {
|
||||
|
|
|
|||
|
|
@ -118,6 +118,10 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
#main-content {
|
||||
scroll-margin-top: 6rem;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
top: 0.75rem;
|
||||
|
|
@ -138,6 +142,18 @@
|
|||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-primary-muted);
|
||||
}
|
||||
|
|
@ -159,11 +175,11 @@
|
|||
color: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
:where(a, button, input, textarea, select):focus {
|
||||
:where(a, button, input, textarea, select, summary):focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:where(a, button, input, textarea, select):focus-visible {
|
||||
:where(a, button, input, textarea, select, summary):focus-visible {
|
||||
box-shadow: var(--focus-ring);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
|
@ -1016,4 +1032,20 @@
|
|||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.ui-btn {
|
||||
white-space: normal;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ui-btn-lg {
|
||||
padding: 0.8rem 1.25rem;
|
||||
}
|
||||
|
||||
.ui-btn-xl {
|
||||
padding: 0.9rem 1.25rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -128,6 +128,12 @@ const publicDemoSite = isEnabled((globalThis as any).process?.env?.PUBLIC_DEMO_S
|
|||
navToggle.setAttribute('aria-expanded', 'true');
|
||||
if (window.matchMedia('(max-width: 640px)').matches) {
|
||||
document.body.classList.add('is-scroll-locked');
|
||||
const firstFocusable = nav.querySelector(
|
||||
'#nav-menu a, #nav-menu button, #nav-menu input, #nav-menu select, #nav-menu textarea, #nav-menu summary'
|
||||
);
|
||||
if (firstFocusable instanceof HTMLElement) {
|
||||
firstFocusable.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -136,6 +142,7 @@ const publicDemoSite = isEnabled((globalThis as any).process?.env?.PUBLIC_DEMO_S
|
|||
nav.classList.remove('is-open');
|
||||
navToggle.setAttribute('aria-expanded', 'false');
|
||||
document.body.classList.remove('is-scroll-locked');
|
||||
navToggle.focus();
|
||||
}
|
||||
|
||||
if (nav && navToggle) {
|
||||
|
|
@ -187,8 +194,9 @@ const publicDemoSite = isEnabled((globalThis as any).process?.env?.PUBLIC_DEMO_S
|
|||
const userData = JSON.parse(user);
|
||||
navAuth.innerHTML = `
|
||||
<a href="/dashboard">Dashboard</a>
|
||||
<a href="/notifications" class="nav-notifications" id="nav-notifications">
|
||||
<a href="/notifications" class="nav-notifications" id="nav-notifications" aria-label="Notifications">
|
||||
<span class="notif-icon">🔔</span>
|
||||
<span class="sr-only">Notifications</span>
|
||||
<span class="notif-badge" id="notif-badge" style="display:none;">0</span>
|
||||
</a>
|
||||
<a href="/settings">Settings</a>
|
||||
|
|
|
|||
|
|
@ -165,6 +165,12 @@ const defaultTheme = DEFAULT_THEME;
|
|||
toggle.setAttribute('aria-expanded', 'true');
|
||||
if (window.matchMedia('(max-width: 768px)').matches) {
|
||||
document.body.classList.add('is-scroll-locked');
|
||||
const firstFocusable = nav.querySelector(
|
||||
'#public-nav-menu a, #public-nav-menu button, #public-nav-menu input, #public-nav-menu select, #public-nav-menu textarea, #public-nav-menu summary'
|
||||
);
|
||||
if (firstFocusable instanceof HTMLElement) {
|
||||
firstFocusable.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -173,6 +179,7 @@ const defaultTheme = DEFAULT_THEME;
|
|||
nav.classList.remove('is-open');
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
document.body.classList.remove('is-scroll-locked');
|
||||
toggle.focus();
|
||||
}
|
||||
|
||||
function setActiveNav() {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import { API_BASE as apiBase } from '../lib/api';
|
|||
var allCommunities = [];
|
||||
var currentQuery = '';
|
||||
var currentSort = 'name_asc';
|
||||
var token = localStorage.getItem('token');
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '').replace(/[&<>\"']/g, function(ch) {
|
||||
|
|
@ -122,8 +123,18 @@ import { API_BASE as apiBase } from '../lib/api';
|
|||
var container = document.getElementById('communities-list');
|
||||
if (!container) return;
|
||||
|
||||
var label = isFiltered ? 'No communities match your search.' : 'No communities found.';
|
||||
var hint = isFiltered ? 'Try a different keyword or clear the search.' : 'Please try again later.';
|
||||
var isLoggedIn = !!token;
|
||||
var label;
|
||||
var hint;
|
||||
if (isFiltered) {
|
||||
label = 'No communities match your search.';
|
||||
hint = 'Try a different keyword or clear the search.';
|
||||
} else {
|
||||
label = isLoggedIn ? 'No communities yet.' : 'No communities found.';
|
||||
hint = isLoggedIn
|
||||
? 'Create the first community to start organizing proposals.'
|
||||
: 'Sign in to create a community, or refresh to try again.';
|
||||
}
|
||||
|
||||
container.innerHTML =
|
||||
'<div class="state-card ui-card">' +
|
||||
|
|
@ -131,6 +142,8 @@ import { API_BASE as apiBase } from '../lib/api';
|
|||
'<p class="hint">' + hint + '</p>' +
|
||||
'<div class="state-actions">' +
|
||||
(isFiltered ? '<button type="button" class="ui-btn ui-btn-secondary" id="reset-community-search">Reset search</button>' : '') +
|
||||
(!isFiltered && isLoggedIn ? '<a class="ui-btn ui-btn-primary" href="/communities/new">Create Community</a>' : '') +
|
||||
(!isFiltered && !isLoggedIn ? '<a class="ui-btn ui-btn-secondary" href="/login">Sign In</a>' : '') +
|
||||
'<a class="ui-btn ui-btn-primary" href="/communities">Refresh</a>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
|
@ -265,7 +278,6 @@ import { API_BASE as apiBase } from '../lib/api';
|
|||
}
|
||||
|
||||
// Show create button if logged in
|
||||
var token = localStorage.getItem('token');
|
||||
var createBtn = document.getElementById('create-btn');
|
||||
if (token && createBtn) {
|
||||
createBtn.style.display = 'block';
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ const { slug } = Astro.params;
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div id="proposals-list" class="list">
|
||||
<div id="proposals-list" class="list" aria-live="polite">
|
||||
<div class="list-skeleton">
|
||||
<div class="skeleton-card"></div>
|
||||
<div class="skeleton-card"></div>
|
||||
|
|
@ -154,7 +154,18 @@ const { slug } = Astro.params;
|
|||
if (!container) return;
|
||||
|
||||
if (proposals.length === 0) {
|
||||
container.innerHTML = '<div class="ui-card"><p class="empty">No proposals found.</p></div>';
|
||||
const token = localStorage.getItem('token');
|
||||
const isLoggedIn = !!token;
|
||||
container.innerHTML = `
|
||||
<div class="state-card ui-card">
|
||||
<p class="empty">No proposals yet.</p>
|
||||
<p class="hint">Create the first proposal for this community to start collecting feedback and votes.</p>
|
||||
<div class="state-actions">
|
||||
${isLoggedIn ? `<a class="ui-btn ui-btn-primary" href="/communities/${encodeURIComponent(String(slug))}/proposals/new">+ New Proposal</a>` : '<a class="ui-btn ui-btn-secondary" href="/login">Sign In</a>'}
|
||||
<a class="ui-btn ui-btn-secondary" href="/communities/${encodeURIComponent(String(slug))}">Back to community</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -531,6 +531,7 @@ const nextQuery = nextParam ? `&next=${encodeURIComponent(nextParam)}` : '';
|
|||
.card-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: 0.8125rem;
|
||||
|
|
@ -708,6 +709,14 @@ const nextQuery = nextParam ? `&next=${encodeURIComponent(nextParam)}` : '';
|
|||
align-items: flex-start;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.journey-step {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
|
|
|||
|
|
@ -100,13 +100,18 @@ import { API_BASE as apiBase } from '../lib/api';
|
|||
const container = document.getElementById('proposals-list');
|
||||
if (!container) return;
|
||||
|
||||
const label = isFiltered ? 'No proposals match your filters.' : 'No proposals yet.';
|
||||
const hint = isFiltered
|
||||
? 'Try adjusting search, status, or sort.'
|
||||
: 'To create one, open a community and click New Proposal.';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="state-card ui-card">
|
||||
<p class="empty">${isFiltered ? 'No proposals match your filters.' : 'No proposals found.'}</p>
|
||||
<p class="hint">Try adjusting search, status, or sort.</p>
|
||||
<p class="empty">${label}</p>
|
||||
<p class="hint">${hint}</p>
|
||||
<div class="state-actions">
|
||||
<button type="button" class="ui-btn ui-btn-secondary" id="reset-filters">Reset filters</button>
|
||||
<a class="ui-btn ui-btn-primary" href="/communities">Go to communities</a>
|
||||
${isFiltered ? '<button type="button" class="ui-btn ui-btn-secondary" id="reset-filters">Reset filters</button>' : ''}
|
||||
<a class="ui-btn ui-btn-primary" href="/communities">Browse communities</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -181,6 +181,8 @@ import { API_BASE as apiBase } from '../lib/api';
|
|||
.settings-page {
|
||||
padding: 2rem 0;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
--ui-form-group-mb: 1.25rem;
|
||||
}
|
||||
|
||||
|
|
@ -189,6 +191,17 @@ import { API_BASE as apiBase } from '../lib/api';
|
|||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.settings-page {
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
Loading…
Reference in a new issue