Compare commits
No commits in common. "05915aae3077f3f613df255e590c9a1d1257534f" and "4f54d8d612cf4d515a00affc95da6035c884852b" have entirely different histories.
05915aae30
...
4f54d8d612
9 changed files with 184 additions and 1537 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1 @@
|
||||||
/target
|
/target
|
||||||
/result
|
|
||||||
|
|
|
||||||
1275
Cargo.lock
generated
1275
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -8,18 +8,12 @@ axum = "0.8.8"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
bcrypt = "0.19.0"
|
bcrypt = "0.19.0"
|
||||||
chrono = "0.4.44"
|
chrono = "0.4.44"
|
||||||
crypt = "0.1.0"
|
|
||||||
yescrypt = { version = "0.1.0-pre.4", features = ["password-hash"] }
|
|
||||||
password-hash = { version = "0.6", features = ["phc"] }
|
|
||||||
jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] }
|
jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] }
|
||||||
libc = "0.2.183"
|
|
||||||
pam = { version = "0.8.0", features = ["client"] }
|
pam = { version = "0.8.0", features = ["client"] }
|
||||||
pwhash = "1.0.0"
|
|
||||||
rand = "0.10.0"
|
rand = "0.10.0"
|
||||||
rustls = { version = "0.23.37", features = ["ring"] }
|
rustls = { version = "0.23.37", features = ["ring"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
shadow-rs = "1.7.1"
|
|
||||||
sysinfo = "0.38.4"
|
sysinfo = "0.38.4"
|
||||||
tokio = { version = "1.50.0", features = [
|
tokio = { version = "1.50.0", features = [
|
||||||
"macros",
|
"macros",
|
||||||
|
|
@ -27,6 +21,3 @@ tokio = { version = "1.50.0", features = [
|
||||||
"process",
|
"process",
|
||||||
] }
|
] }
|
||||||
zbus = "5.14.0"
|
zbus = "5.14.0"
|
||||||
webauthn-rs = "0.5"
|
|
||||||
url = "2"
|
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
|
||||||
|
|
|
||||||
26
Makefile
26
Makefile
|
|
@ -1,26 +0,0 @@
|
||||||
PKG := server-dash-api
|
|
||||||
USER := server-dash-api
|
|
||||||
BIN := ./result/bin/$(PKG)
|
|
||||||
ARGS ?=
|
|
||||||
|
|
||||||
.PHONY: build run logs clean
|
|
||||||
|
|
||||||
build:
|
|
||||||
nix build
|
|
||||||
|
|
||||||
run: build
|
|
||||||
sudo -u $(USER) $(BIN) $(ARGS)
|
|
||||||
|
|
||||||
logs:
|
|
||||||
journalctl -u $(PKG) -f
|
|
||||||
|
|
||||||
clean:
|
|
||||||
rm -f result
|
|
||||||
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
cargo build --release
|
|
||||||
sudo cp target/release/server-dash-api /var/lib/server-dash-api/server-dash-api
|
|
||||||
sudo chown server-dash-api:server-dash-api /var/lib/server-dash-api/server-dash-api
|
|
||||||
sudo chmod 755 /var/lib/server-dash-api/server-dash-api
|
|
||||||
sudo systemctl restart server-dash-api
|
|
||||||
45
flake.nix
45
flake.nix
|
|
@ -38,22 +38,20 @@
|
||||||
linux-pam
|
linux-pam
|
||||||
libclang
|
libclang
|
||||||
glibc.dev
|
glibc.dev
|
||||||
gnumake
|
|
||||||
];
|
];
|
||||||
package = pkgs.rustPlatform.buildRustPackage {
|
in
|
||||||
|
{
|
||||||
|
packages.default = pkgs.rustPlatform.buildRustPackage {
|
||||||
pname = "server-dash-api";
|
pname = "server-dash-api";
|
||||||
version = "0.1.0";
|
version = "0.1.0";
|
||||||
src = ./.;
|
src = ./.;
|
||||||
cargoLock.lockFile = ./Cargo.lock;
|
cargoHash = "sha256-z2sdfkRN25CAiXepQRzftoWGwbl8lI4KGuezGg4rD/A=";
|
||||||
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";
|
||||||
LIBCLANG_PATH = "${pkgs.libclang.lib}/lib";
|
LIBCLANG_PATH = "${pkgs.libclang.lib}/lib";
|
||||||
BINDGEN_EXTRA_CLANG_ARGS = "-I${pkgs.linux-pam}/include -I${pkgs.glibc.dev}/include";
|
BINDGEN_EXTRA_CLANG_ARGS = "-I${pkgs.linux-pam}/include -I${pkgs.glibc.dev}/include";
|
||||||
};
|
};
|
||||||
in
|
|
||||||
{
|
|
||||||
packages.default = package;
|
|
||||||
devShells.default = pkgs.mkShell {
|
devShells.default = pkgs.mkShell {
|
||||||
inherit nativeBuildInputs buildInputs;
|
inherit nativeBuildInputs buildInputs;
|
||||||
PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig";
|
PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig";
|
||||||
|
|
@ -82,11 +80,6 @@
|
||||||
{
|
{
|
||||||
options.services.server-dash-api = {
|
options.services.server-dash-api = {
|
||||||
enable = lib.mkEnableOption "server-dash-api system stats API";
|
enable = lib.mkEnableOption "server-dash-api system stats API";
|
||||||
useNixBuild = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = false;
|
|
||||||
description = "Build the binary via Nix instead of using a manually deployed binary";
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf config.services.server-dash-api.enable {
|
config = lib.mkIf config.services.server-dash-api.enable {
|
||||||
|
|
@ -101,21 +94,22 @@
|
||||||
|
|
||||||
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/webauthn-credentials 0750 server-dash-api server-dash-api -"
|
"d /var/lib/server-dash-api/google-auth 0750 server-dash-api server-dash-api -"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
security.pam.services.server-dash-api = {
|
||||||
|
text = ''
|
||||||
|
auth required ${pkgs.google-authenticator}/lib/security/pam_google_authenticator.so secret=/var/lib/server-dash-api/google-auth/%u user=server-dash-api no_increment_hotp
|
||||||
|
auth sufficient ${pkgs.linux-pam}/lib/security/pam_unix.so likeauth nullok
|
||||||
|
auth required ${pkgs.linux-pam}/lib/security/pam_unix.so
|
||||||
|
account required ${pkgs.linux-pam}/lib/security/pam_unix.so
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
security.polkit.extraConfig = ''
|
security.polkit.extraConfig = ''
|
||||||
polkit.addRule(function(action, subject) {
|
polkit.addRule(function(action, subject) {
|
||||||
if ((action.id == "org.freedesktop.systemd1.manage-units" ||
|
if ((action.id == "org.freedesktop.systemd1.manage-units" ||
|
||||||
action.id == "org.freedesktop.login1.reboot" ||
|
action.id == "org.freedesktop.login1.reboot") &&
|
||||||
action.id == "org.freedesktop.login1.reboot-multiple-sessions" ||
|
|
||||||
action.id == "org.freedesktop.login1.reboot-ignore-inhibit" ||
|
|
||||||
action.id == "org.freedesktop.login1.power-off" ||
|
|
||||||
action.id == "org.freedesktop.login1.power-off-multiple-sessions" ||
|
|
||||||
action.id == "org.freedesktop.login1.power-off-ignore-inhibit" ||
|
|
||||||
action.id == "org.freedesktop.login1.halt" ||
|
|
||||||
action.id == "org.freedesktop.login1.halt-multiple-sessions" ||
|
|
||||||
action.id == "org.freedesktop.login1.halt-ignore-inhibit") &&
|
|
||||||
subject.user == "server-dash-api") {
|
subject.user == "server-dash-api") {
|
||||||
return polkit.Result.YES;
|
return polkit.Result.YES;
|
||||||
}
|
}
|
||||||
|
|
@ -131,13 +125,8 @@
|
||||||
User = "server-dash-api";
|
User = "server-dash-api";
|
||||||
Group = "server-dash-api";
|
Group = "server-dash-api";
|
||||||
SupplementaryGroups = [ "shadow" ];
|
SupplementaryGroups = [ "shadow" ];
|
||||||
ExecStart =
|
ExecStart = "${self.packages.${pkgs.system}.default}/bin/server-dash-api";
|
||||||
if config.services.server-dash-api.useNixBuild then
|
Restart = "always";
|
||||||
"${self.packages.${pkgs.system}.default}/bin/server-dash-api"
|
|
||||||
else
|
|
||||||
"/var/lib/server-dash-api/server-dash-api";
|
|
||||||
Restart = "on-failure";
|
|
||||||
RestartSec = "10s";
|
|
||||||
StateDirectory = "server-dash-api";
|
StateDirectory = "server-dash-api";
|
||||||
Environment = [
|
Environment = [
|
||||||
"RUST_LOG=info"
|
"RUST_LOG=info"
|
||||||
|
|
|
||||||
324
src/auth.rs
324
src/auth.rs
|
|
@ -1,61 +1,19 @@
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::extract::State;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
http::HeaderMap,
|
http::HeaderMap, http::Request, http::StatusCode, middleware::Next, response::IntoResponse,
|
||||||
http::Request,
|
response::Json, response::Response,
|
||||||
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 pam::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::OnceLock;
|
||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{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();
|
static JWT_SECRET: OnceLock<String> = OnceLock::new();
|
||||||
|
|
||||||
const ROTATION_DAYS: u64 = 7;
|
const ROTATION_DAYS: u64 = 7;
|
||||||
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<SecurityKey>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AppState {
|
|
||||||
pub webauthn: Webauthn,
|
|
||||||
pending_auth: Mutex<HashMap<String, (SecurityKeyAuthentication, Instant, String)>>,
|
|
||||||
pending_reg: Mutex<HashMap<String, (SecurityKeyRegistration, 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")
|
||||||
|
|
@ -81,6 +39,7 @@ pub fn jwt_secret() -> &'static str {
|
||||||
let path = secret_path();
|
let path = secret_path();
|
||||||
std::fs::create_dir_all(path.parent().unwrap()).ok();
|
std::fs::create_dir_all(path.parent().unwrap()).ok();
|
||||||
|
|
||||||
|
// file format: "timestamp:secret"
|
||||||
if let Ok(contents) = std::fs::read_to_string(&path) {
|
if let Ok(contents) = std::fs::read_to_string(&path) {
|
||||||
if let Some((ts_str, secret)) = contents.trim().split_once(':') {
|
if let Some((ts_str, secret)) = contents.trim().split_once(':') {
|
||||||
if let Ok(ts) = ts_str.parse::<u64>() {
|
if let Ok(ts) = ts_str.parse::<u64>() {
|
||||||
|
|
@ -144,71 +103,19 @@ pub fn decode_basic_auth(headers: &HeaderMap) -> Option<(String, String)> {
|
||||||
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, password) = s.split_once(':')?;
|
let (user, pass) = s.split_once(':')?;
|
||||||
Some((user.to_string(), password.to_string()))
|
Some((user.to_string(), pass.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn verify_password(username: &str, password: &str) -> bool {
|
pub fn verify_system_credentials(username: &str, password: &str) -> bool {
|
||||||
let shadow_content = match std::fs::read_to_string("/etc/shadow") {
|
let mut client = match Client::with_password("server-dash-api") {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(_) => return false,
|
||||||
println!("Failed to read /etc/shadow: {}", e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
for line in shadow_content.lines() {
|
client
|
||||||
let fields: Vec<&str> = line.split(':').collect();
|
.conversation_mut()
|
||||||
if fields.len() < 2 {
|
.set_credentials(username, password);
|
||||||
continue;
|
client.authenticate().is_ok()
|
||||||
}
|
|
||||||
if fields[0] != username {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return verify_shadow_hash(password, fields[1]);
|
|
||||||
}
|
|
||||||
println!("User not found in shadow");
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_shadow_hash(password: &str, hash: &str) -> bool {
|
|
||||||
let parsed_hash = match PasswordHash::new(hash) {
|
|
||||||
Ok(h) => h,
|
|
||||||
Err(e) => {
|
|
||||||
println!("Failed to parse hash: {:?}", e);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Yescrypt::default()
|
|
||||||
.verify_password(password.as_bytes(), &parsed_hash)
|
|
||||||
.is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
pub async fn require_auth(headers: HeaderMap, request: Request<Body>, next: Next) -> Response {
|
||||||
|
|
@ -219,206 +126,23 @@ pub async fn require_auth(headers: HeaderMap, request: Request<Body>, next: Next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /auth/login — verifies password, returns a WebAuthn challenge for the YubiKey
|
// POST /auth/login
|
||||||
pub async fn post_login(
|
pub async fn post_login(headers: HeaderMap) -> impl IntoResponse {
|
||||||
State(state): State<Arc<AppState>>,
|
let (username, password_and_totp) = match decode_basic_auth(&headers) {
|
||||||
headers: HeaderMap,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
let (username, password) = match decode_basic_auth(&headers) {
|
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => {
|
None => {
|
||||||
return (StatusCode::UNAUTHORIZED, "Missing or invalid Authorization header")
|
return (
|
||||||
.into_response()
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"Missing or invalid Authorization header",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if !verify_password(&username, &password) {
|
if !verify_system_credentials(&username, &password_and_totp) {
|
||||||
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_securitykey_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(|_, (_, t, _)| t.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_securitykey_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_securitykey_registration(user_id, &username, &username, exclude, None, None)
|
|
||||||
{
|
|
||||||
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(|_, (_, t, _, _)| t.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_securitykey_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()
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ pub const ALLOWED_SERVICES: &[&str] = &[
|
||||||
"syncthing",
|
"syncthing",
|
||||||
"caddy",
|
"caddy",
|
||||||
"sshd",
|
"sshd",
|
||||||
"server-dash",
|
"dashboard",
|
||||||
"server-dash-api",
|
"sysapi",
|
||||||
"cloudflare-dyndns.timer",
|
"cloudflare-dyndns.timer",
|
||||||
"cloudflare-dyndns",
|
"cloudflare-dyndns",
|
||||||
"docker",
|
"docker",
|
||||||
|
|
|
||||||
10
src/main.rs
10
src/main.rs
|
|
@ -1,7 +1,6 @@
|
||||||
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;
|
||||||
|
|
@ -10,8 +9,6 @@ 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(
|
||||||
|
|
@ -31,17 +28,12 @@ async fn main() {
|
||||||
get(routes::services::service_logs),
|
get(routes::services::service_logs),
|
||||||
)
|
)
|
||||||
.route("/system/reboot", post(routes::system::system_reboot))
|
.route("/system/reboot", post(routes::system::system_reboot))
|
||||||
.route("/system/shutdown", post(routes::system::system_shutdown))
|
|
||||||
.route_layer(middleware::from_fn(auth::require_auth));
|
.route_layer(middleware::from_fn(auth::require_auth));
|
||||||
|
|
||||||
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))
|
||||||
.route("/auth/verify", post(auth::post_verify))
|
.merge(protected);
|
||||||
.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
|
||||||
|
|
|
||||||
|
|
@ -4,33 +4,6 @@ use zbus::Connection;
|
||||||
use crate::auth;
|
use crate::auth;
|
||||||
use crate::models;
|
use crate::models;
|
||||||
|
|
||||||
// POST /system/shutdown
|
|
||||||
pub async fn system_shutdown(headers: HeaderMap) -> impl IntoResponse {
|
|
||||||
let conn = match Connection::system().await {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
return models::ActionResponse::err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = conn
|
|
||||||
.call_method(
|
|
||||||
Some("org.freedesktop.login1"),
|
|
||||||
"/org/freedesktop/login1",
|
|
||||||
Some("org.freedesktop.login1.Manager"),
|
|
||||||
"PowerOff",
|
|
||||||
&(false,),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(_) => models::ActionResponse::ok("Shutting down...".to_string()).into_response(),
|
|
||||||
Err(e) => models::ActionResponse::err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
|
||||||
.into_response(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /system/reboot
|
// POST /system/reboot
|
||||||
pub async fn system_reboot(headers: HeaderMap) -> impl IntoResponse {
|
pub async fn system_reboot(headers: HeaderMap) -> impl IntoResponse {
|
||||||
let conn = match Connection::system().await {
|
let conn = match Connection::system().await {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue