Security key
This commit is contained in:
parent
6c6ee030cd
commit
56c555d699
5 changed files with 759 additions and 288 deletions
712
Cargo.lock
generated
712
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -27,5 +27,6 @@ tokio = { version = "1.50.0", features = [
|
||||||
"process",
|
"process",
|
||||||
] }
|
] }
|
||||||
zbus = "5.14.0"
|
zbus = "5.14.0"
|
||||||
totp-rs = { version = "5.7", features = ["gen_secret", "otpauth"] }
|
webauthn-rs = "0.5"
|
||||||
base32 = "0.5.1"
|
url = "2"
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
pname = "server-dash-api";
|
pname = "server-dash-api";
|
||||||
version = "0.1.0";
|
version = "0.1.0";
|
||||||
src = ./.;
|
src = ./.;
|
||||||
cargoHash = "sha256-bOxtdDv0qtB61x9K/ik14DXCV1QfOBJQu7yQBx3L0wc=";
|
cargoLock.lockFile = ./Cargo.lock;
|
||||||
inherit nativeBuildInputs buildInputs;
|
inherit nativeBuildInputs buildInputs;
|
||||||
OPENSSL_NO_VENDOR = 1;
|
OPENSSL_NO_VENDOR = 1;
|
||||||
PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig";
|
PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig";
|
||||||
|
|
@ -101,7 +101,7 @@
|
||||||
|
|
||||||
systemd.tmpfiles.rules = [
|
systemd.tmpfiles.rules = [
|
||||||
"d /var/lib/server-dash-api 0750 server-dash-api server-dash-api -"
|
"d /var/lib/server-dash-api 0750 server-dash-api server-dash-api -"
|
||||||
"d /var/lib/server-dash-api/google-auth 0755 server-dash-api server-dash-api -"
|
"d /var/lib/server-dash-api/webauthn-credentials 0750 server-dash-api server-dash-api -"
|
||||||
];
|
];
|
||||||
|
|
||||||
security.polkit.extraConfig = ''
|
security.polkit.extraConfig = ''
|
||||||
|
|
|
||||||
317
src/auth.rs
317
src/auth.rs
|
|
@ -1,21 +1,61 @@
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
|
use axum::extract::State;
|
||||||
use axum::{
|
use axum::{
|
||||||
http::HeaderMap, http::Request, http::StatusCode, middleware::Next, response::IntoResponse,
|
http::HeaderMap,
|
||||||
response::Json, response::Response,
|
http::Request,
|
||||||
|
http::StatusCode,
|
||||||
|
middleware::Next,
|
||||||
|
response::IntoResponse,
|
||||||
|
response::Json,
|
||||||
|
response::Response,
|
||||||
};
|
};
|
||||||
use base64::{Engine, engine::general_purpose};
|
use base64::{Engine, engine::general_purpose};
|
||||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::OnceLock;
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
use totp_rs::{Algorithm, Secret, TOTP};
|
use url::Url;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use webauthn_rs::prelude::*;
|
||||||
use yescrypt::{PasswordHash, PasswordVerifier, Yescrypt};
|
use yescrypt::{PasswordHash, PasswordVerifier, Yescrypt};
|
||||||
|
|
||||||
static JWT_SECRET: OnceLock<String> = OnceLock::new();
|
static JWT_SECRET: OnceLock<String> = OnceLock::new();
|
||||||
|
|
||||||
const ROTATION_DAYS: u64 = 7;
|
const ROTATION_DAYS: u64 = 7;
|
||||||
const TOTP_SECRET_PATH: &str = "/var/lib/server-dash-api/google-auth";
|
const CREDENTIAL_DIR: &str = "/var/lib/server-dash-api/webauthn-credentials";
|
||||||
|
const CHALLENGE_TTL: Duration = Duration::from_secs(300);
|
||||||
|
const RP_ID: &str = "jackmechem.dev";
|
||||||
|
const RP_ORIGIN: &str = "https://dashboard.jackmechem.dev";
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct StoredCredentials {
|
||||||
|
user_id: Uuid,
|
||||||
|
credentials: Vec<Passkey>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
pub webauthn: Webauthn,
|
||||||
|
pending_auth: Mutex<HashMap<String, (PasskeyAuthentication, Instant, String)>>,
|
||||||
|
pending_reg: Mutex<HashMap<String, (PasskeyRegistration, Instant, String, Uuid)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let rp_origin = Url::parse(RP_ORIGIN).expect("Invalid RP origin");
|
||||||
|
let webauthn = WebauthnBuilder::new(RP_ID, &rp_origin)
|
||||||
|
.expect("Invalid WebAuthn config")
|
||||||
|
.rp_name("Server Dashboard")
|
||||||
|
.build()
|
||||||
|
.expect("Failed to build WebAuthn");
|
||||||
|
Self {
|
||||||
|
webauthn,
|
||||||
|
pending_auth: Mutex::new(HashMap::new()),
|
||||||
|
pending_reg: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn secret_path() -> PathBuf {
|
fn secret_path() -> PathBuf {
|
||||||
PathBuf::from("/var/lib/server-dash-api/jwt_secret")
|
PathBuf::from("/var/lib/server-dash-api/jwt_secret")
|
||||||
|
|
@ -99,17 +139,13 @@ pub fn verify_token(headers: &HeaderMap) -> bool {
|
||||||
.is_ok()
|
.is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decode_basic_auth(headers: &HeaderMap) -> Option<(String, String, String)> {
|
pub fn decode_basic_auth(headers: &HeaderMap) -> Option<(String, String)> {
|
||||||
let val = headers.get("Authorization")?.to_str().ok()?;
|
let val = headers.get("Authorization")?.to_str().ok()?;
|
||||||
let encoded = val.strip_prefix("Basic ")?;
|
let encoded = val.strip_prefix("Basic ")?;
|
||||||
let decoded = general_purpose::STANDARD.decode(encoded).ok()?;
|
let decoded = general_purpose::STANDARD.decode(encoded).ok()?;
|
||||||
let s = String::from_utf8(decoded).ok()?;
|
let s = String::from_utf8(decoded).ok()?;
|
||||||
let (user, rest) = s.split_once(':')?;
|
let (user, password) = s.split_once(':')?;
|
||||||
if rest.len() < 6 {
|
Some((user.to_string(), password.to_string()))
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let (password, totp) = rest.split_at(rest.len() - 6);
|
|
||||||
Some((user.to_string(), password.to_string(), totp.to_string()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn verify_password(username: &str, password: &str) -> bool {
|
fn verify_password(username: &str, password: &str) -> bool {
|
||||||
|
|
@ -147,39 +183,32 @@ fn verify_shadow_hash(password: &str, hash: &str) -> bool {
|
||||||
.is_ok()
|
.is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn verify_totp(username: &str, totp_code: &str) -> bool {
|
fn load_credentials(username: &str) -> Option<StoredCredentials> {
|
||||||
let path = PathBuf::from(TOTP_SECRET_PATH).join(username);
|
let path = PathBuf::from(CREDENTIAL_DIR).join(format!("{}.json", username));
|
||||||
let secret_file = match std::fs::read_to_string(&path) {
|
let data = std::fs::read_to_string(path).ok()?;
|
||||||
Ok(f) => f,
|
serde_json::from_str(&data).ok()
|
||||||
Err(e) => {
|
|
||||||
println!("Failed to read TOTP secret: {}", e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let secret_b32 = secret_file.lines().next().unwrap_or("").trim().to_string();
|
|
||||||
|
|
||||||
let totp = match TOTP::new(
|
|
||||||
Algorithm::SHA1,
|
|
||||||
6,
|
|
||||||
1,
|
|
||||||
30,
|
|
||||||
Secret::Encoded(secret_b32).to_bytes().unwrap(),
|
|
||||||
None,
|
|
||||||
username.to_string(),
|
|
||||||
) {
|
|
||||||
Ok(t) => t,
|
|
||||||
Err(e) => {
|
|
||||||
println!("Failed to create TOTP: {:?}", e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
totp.check_current(totp_code).unwrap_or(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn verify_system_credentials(username: &str, password: &str, totp: &str) -> bool {
|
fn save_credentials(username: &str, creds: &StoredCredentials) -> Result<(), String> {
|
||||||
verify_password(username, password) && verify_totp(username, totp)
|
let dir = PathBuf::from(CREDENTIAL_DIR);
|
||||||
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
let path = dir.join(format!("{}.json", username));
|
||||||
|
let data = serde_json::to_string(creds).map_err(|e| e.to_string())?;
|
||||||
|
std::fs::write(&path, &data).map_err(|e| e.to_string())?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).ok();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_session_id() -> String {
|
||||||
|
format!(
|
||||||
|
"{:016x}{:016x}",
|
||||||
|
rand::random::<u64>(),
|
||||||
|
rand::random::<u64>()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn require_auth(headers: HeaderMap, request: Request<Body>, next: Next) -> Response {
|
pub async fn require_auth(headers: HeaderMap, request: Request<Body>, next: Next) -> Response {
|
||||||
|
|
@ -190,23 +219,205 @@ pub async fn require_auth(headers: HeaderMap, request: Request<Body>, next: Next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /auth/login
|
// POST /auth/login — verifies password, returns a WebAuthn challenge for the YubiKey
|
||||||
pub async fn post_login(headers: HeaderMap) -> impl IntoResponse {
|
pub async fn post_login(
|
||||||
let (username, password, totp) = match decode_basic_auth(&headers) {
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let (username, password) = match decode_basic_auth(&headers) {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => {
|
None => {
|
||||||
return (
|
return (StatusCode::UNAUTHORIZED, "Missing or invalid Authorization header")
|
||||||
StatusCode::UNAUTHORIZED,
|
.into_response()
|
||||||
"Missing or invalid Authorization header",
|
|
||||||
)
|
|
||||||
.into_response();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if !verify_system_credentials(&username, &password, &totp) {
|
if !verify_password(&username, &password) {
|
||||||
return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
|
return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let stored = match load_credentials(&username) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => {
|
||||||
|
return (StatusCode::UNAUTHORIZED, "No YubiKey registered for this user")
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (rcr, auth_state) = match state
|
||||||
|
.webauthn
|
||||||
|
.start_passkey_authentication(&stored.credentials)
|
||||||
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
println!("WebAuthn start auth error: {:?}", e);
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, "WebAuthn error").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let session_id = generate_session_id();
|
||||||
|
{
|
||||||
|
let mut pending = state.pending_auth.lock().unwrap();
|
||||||
|
pending.retain(|_, (_, created, _)| created.elapsed() < CHALLENGE_TTL);
|
||||||
|
pending.insert(session_id.clone(), (auth_state, Instant::now(), username));
|
||||||
|
}
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"session_id": session_id,
|
||||||
|
"challenge": rcr,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct VerifyRequest {
|
||||||
|
session_id: String,
|
||||||
|
credential: PublicKeyCredential,
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /auth/verify — verifies the YubiKey assertion and returns a JWT
|
||||||
|
pub async fn post_verify(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(body): Json<VerifyRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let (auth_state, username) = {
|
||||||
|
let mut pending = state.pending_auth.lock().unwrap();
|
||||||
|
match pending.remove(&body.session_id) {
|
||||||
|
Some((s, created, u)) if created.elapsed() < CHALLENGE_TTL => (s, u),
|
||||||
|
Some(_) => return (StatusCode::UNAUTHORIZED, "Challenge expired").into_response(),
|
||||||
|
None => return (StatusCode::UNAUTHORIZED, "Invalid session").into_response(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let auth_result = match state
|
||||||
|
.webauthn
|
||||||
|
.finish_passkey_authentication(&body.credential, &auth_state)
|
||||||
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
println!("WebAuthn finish auth error: {:?}", e);
|
||||||
|
return (StatusCode::UNAUTHORIZED, "WebAuthn verification failed").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Persist updated credential counter
|
||||||
|
if let Some(mut stored) = load_credentials(&username) {
|
||||||
|
for cred in &mut stored.credentials {
|
||||||
|
cred.update_credential(&auth_result);
|
||||||
|
}
|
||||||
|
save_credentials(&username, &stored).ok();
|
||||||
|
}
|
||||||
|
|
||||||
let token = create_token(&username);
|
let token = create_token(&username);
|
||||||
(StatusCode::OK, Json(serde_json::json!({ "token": token }))).into_response()
|
(StatusCode::OK, Json(serde_json::json!({ "token": token }))).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /auth/register/start — verifies password, returns a WebAuthn registration challenge
|
||||||
|
pub async fn post_register_start(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let (username, password) = match decode_basic_auth(&headers) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
return (StatusCode::UNAUTHORIZED, "Missing or invalid Authorization header")
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !verify_password(&username, &password) {
|
||||||
|
return (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let stored = load_credentials(&username);
|
||||||
|
let user_id = stored.as_ref().map(|s| s.user_id).unwrap_or_else(Uuid::new_v4);
|
||||||
|
|
||||||
|
let exclude: Option<Vec<CredentialID>> = stored.as_ref().map(|s| {
|
||||||
|
s.credentials
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.cred_id().clone())
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
let (ccr, reg_state) = match state
|
||||||
|
.webauthn
|
||||||
|
.start_passkey_registration(user_id, &username, &username, exclude)
|
||||||
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
println!("WebAuthn start reg error: {:?}", e);
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, "WebAuthn error").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let session_id = generate_session_id();
|
||||||
|
{
|
||||||
|
let mut pending = state.pending_reg.lock().unwrap();
|
||||||
|
pending.retain(|_, (_, created, _, _)| created.elapsed() < CHALLENGE_TTL);
|
||||||
|
pending.insert(
|
||||||
|
session_id.clone(),
|
||||||
|
(reg_state, Instant::now(), username, user_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"session_id": session_id,
|
||||||
|
"challenge": ccr,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RegisterFinishRequest {
|
||||||
|
session_id: String,
|
||||||
|
credential: RegisterPublicKeyCredential,
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /auth/register/finish — completes YubiKey enrollment and saves the credential
|
||||||
|
pub async fn post_register_finish(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Json(body): Json<RegisterFinishRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let (reg_state, username, user_id) = {
|
||||||
|
let mut pending = state.pending_reg.lock().unwrap();
|
||||||
|
match pending.remove(&body.session_id) {
|
||||||
|
Some((s, created, u, id)) if created.elapsed() < CHALLENGE_TTL => (s, u, id),
|
||||||
|
Some(_) => return (StatusCode::UNAUTHORIZED, "Challenge expired").into_response(),
|
||||||
|
None => return (StatusCode::UNAUTHORIZED, "Invalid session").into_response(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let passkey = match state
|
||||||
|
.webauthn
|
||||||
|
.finish_passkey_registration(&body.credential, ®_state)
|
||||||
|
{
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
println!("WebAuthn finish reg error: {:?}", e);
|
||||||
|
return (StatusCode::BAD_REQUEST, "WebAuthn registration failed").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut stored = load_credentials(&username).unwrap_or(StoredCredentials {
|
||||||
|
user_id,
|
||||||
|
credentials: vec![],
|
||||||
|
});
|
||||||
|
stored.credentials.push(passkey);
|
||||||
|
|
||||||
|
if let Err(e) = save_credentials(&username, &stored) {
|
||||||
|
println!("Failed to save credentials: {}", e);
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to save credential").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(serde_json::json!({ "message": "YubiKey registered successfully" })),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use axum::middleware;
|
use axum::middleware;
|
||||||
use axum::response::Redirect;
|
use axum::response::Redirect;
|
||||||
use axum::{Router, routing::get, routing::post};
|
use axum::{Router, routing::get, routing::post};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
mod config;
|
mod config;
|
||||||
|
|
@ -9,6 +10,8 @@ mod routes;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
let state = Arc::new(auth::AppState::new());
|
||||||
|
|
||||||
let protected = Router::new()
|
let protected = Router::new()
|
||||||
.route("/stats", get(routes::stats::get_stats))
|
.route("/stats", get(routes::stats::get_stats))
|
||||||
.route(
|
.route(
|
||||||
|
|
@ -34,7 +37,11 @@ async fn main() {
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(|| async { Redirect::permanent("/stats") }))
|
.route("/", get(|| async { Redirect::permanent("/stats") }))
|
||||||
.route("/auth/login", post(auth::post_login))
|
.route("/auth/login", post(auth::post_login))
|
||||||
.merge(protected);
|
.route("/auth/verify", post(auth::post_verify))
|
||||||
|
.route("/auth/register/start", post(auth::post_register_start))
|
||||||
|
.route("/auth/register/finish", post(auth::post_register_finish))
|
||||||
|
.merge(protected)
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:3001")
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:3001")
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue