Security key
This commit is contained in:
parent
6c6ee030cd
commit
56c555d699
5 changed files with 759 additions and 288 deletions
317
src/auth.rs
317
src/auth.rs
|
|
@ -1,21 +1,61 @@
|
|||
use axum::body::Body;
|
||||
use axum::extract::State;
|
||||
use axum::{
|
||||
http::HeaderMap, http::Request, http::StatusCode, middleware::Next, response::IntoResponse,
|
||||
response::Json, response::Response,
|
||||
http::HeaderMap,
|
||||
http::Request,
|
||||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::IntoResponse,
|
||||
response::Json,
|
||||
response::Response,
|
||||
};
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use totp_rs::{Algorithm, Secret, TOTP};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
use webauthn_rs::prelude::*;
|
||||
use yescrypt::{PasswordHash, PasswordVerifier, Yescrypt};
|
||||
|
||||
static JWT_SECRET: OnceLock<String> = OnceLock::new();
|
||||
|
||||
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 {
|
||||
PathBuf::from("/var/lib/server-dash-api/jwt_secret")
|
||||
|
|
@ -99,17 +139,13 @@ pub fn verify_token(headers: &HeaderMap) -> bool {
|
|||
.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 encoded = val.strip_prefix("Basic ")?;
|
||||
let decoded = general_purpose::STANDARD.decode(encoded).ok()?;
|
||||
let s = String::from_utf8(decoded).ok()?;
|
||||
let (user, rest) = s.split_once(':')?;
|
||||
if rest.len() < 6 {
|
||||
return None;
|
||||
}
|
||||
let (password, totp) = rest.split_at(rest.len() - 6);
|
||||
Some((user.to_string(), password.to_string(), totp.to_string()))
|
||||
let (user, password) = s.split_once(':')?;
|
||||
Some((user.to_string(), password.to_string()))
|
||||
}
|
||||
|
||||
fn verify_password(username: &str, password: &str) -> bool {
|
||||
|
|
@ -147,39 +183,32 @@ fn verify_shadow_hash(password: &str, hash: &str) -> bool {
|
|||
.is_ok()
|
||||
}
|
||||
|
||||
fn verify_totp(username: &str, totp_code: &str) -> bool {
|
||||
let path = PathBuf::from(TOTP_SECRET_PATH).join(username);
|
||||
let secret_file = match std::fs::read_to_string(&path) {
|
||||
Ok(f) => f,
|
||||
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)
|
||||
fn load_credentials(username: &str) -> Option<StoredCredentials> {
|
||||
let path = PathBuf::from(CREDENTIAL_DIR).join(format!("{}.json", username));
|
||||
let data = std::fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str(&data).ok()
|
||||
}
|
||||
|
||||
pub fn verify_system_credentials(username: &str, password: &str, totp: &str) -> bool {
|
||||
verify_password(username, password) && verify_totp(username, totp)
|
||||
fn save_credentials(username: &str, creds: &StoredCredentials) -> Result<(), String> {
|
||||
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 {
|
||||
|
|
@ -190,23 +219,205 @@ pub async fn require_auth(headers: HeaderMap, request: Request<Body>, next: Next
|
|||
}
|
||||
}
|
||||
|
||||
// POST /auth/login
|
||||
pub async fn post_login(headers: HeaderMap) -> impl IntoResponse {
|
||||
let (username, password, totp) = match decode_basic_auth(&headers) {
|
||||
// POST /auth/login — verifies password, returns a WebAuthn challenge for the YubiKey
|
||||
pub async fn post_login(
|
||||
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();
|
||||
return (StatusCode::UNAUTHORIZED, "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();
|
||||
}
|
||||
|
||||
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);
|
||||
(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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue