use axum::{
extract::State,
http::{header, HeaderMap, StatusCode},
middleware,
response::IntoResponse,
routing::post,
Extension, Json, Router,
};
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use chrono::Duration;
use serde_json::json;
use dragoon_proto::models::{AuthChallenge, AuthLoginRequest, AuthLoginResponse};
use dragoon_proto::verify::verify_ssh_wire_signature;
use crate::{
app::{signed_request, AppState, SignedSession},
audit, auth as authmod, users_repo,
};
pub fn router(state: AppState) -> Router {
let public = Router::new()
.route("/v1/auth/challenge", post(challenge))
.route("/v1/auth/login", post(login))
.with_state(state.clone());
let signed = Router::new()
.route("/v1/auth/logout", post(logout))
.layer(middleware::from_fn_with_state(state.clone(), signed_request))
.with_state(state);
public.merge(signed)
}
async fn challenge(
State(state): State<AppState>,
) -> Result<Json<AuthChallenge>, StatusCode> {
let conn = state.conn.lock().unwrap();
let issued = authmod::issue_challenge(&conn, None)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(AuthChallenge {
challenge: issued.challenge,
expires_at: issued.expires_at,
}))
}
async fn login(
State(state): State<AppState>,
Json(req): Json<AuthLoginRequest>,
) -> Result<Json<AuthLoginResponse>, StatusCode> {
let conn = state.conn.lock().unwrap();
let fail = |reason: &str| -> StatusCode {
let _ = audit::log(
&conn,
Some(&format!("user:{}", req.username)),
"login",
Some("/v1/auth/login"),
None,
&json!({"ok": false, "reason": reason}),
);
StatusCode::UNAUTHORIZED
};
let user = users_repo::get_user(&conn, &req.username)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or_else(|| fail("unknown_user"))?;
if user.revoked_at.is_some() {
return Err(fail("unknown_user"));
}
if !authmod::verify_password(&req.password, &user.password_hash) {
return Err(fail("bad_password"));
}
if !authmod::verify_totp(&user.totp_secret, &req.totp_code) {
return Err(fail("bad_totp"));
}
let consumed = authmod::consume_challenge(&conn, &req.challenge)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if !consumed {
return Err(fail("bad_challenge"));
}
let row: Option<(String, Vec<u8>, Option<String>)> = conn
.query_row(
"SELECT alg, pubkey_blob, revoked_at FROM user_pubkeys
WHERE user_id=? AND fingerprint=?",
rusqlite::params![user.id, req.key_fingerprint],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.ok();
let Some((_alg, pub_blob, revoked_at)) = row else {
return Err(fail("unknown_fp"));
};
if revoked_at.is_some() {
return Err(fail("revoked_fp"));
}
let sig_wire = B64
.decode(req.challenge_signature_b64.as_bytes())
.map_err(|_| fail("bad_sig"))?;
verify_ssh_wire_signature(&pub_blob, &sig_wire, req.challenge.as_bytes())
.map_err(|_| fail("bad_sig"))?;
let (token, expires) = authmod::issue_session(
&conn,
user.id,
&req.key_fingerprint,
Duration::hours(state.settings.session_ttl_hours),
)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let _ = audit::log(
&conn,
Some(&format!("user:{}", req.username)),
"login",
Some("/v1/auth/login"),
Some(&req.key_fingerprint),
&json!({"ok": true}),
);
Ok(Json(AuthLoginResponse {
session_token: token,
expires_at: expires,
}))
}
async fn logout(
State(state): State<AppState>,
headers: HeaderMap,
Extension(sess): Extension<SignedSession>,
) -> impl IntoResponse {
let conn = state.conn.lock().unwrap();
let auth_hdr = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.unwrap_or("");
let _ = authmod::revoke_session(&conn, auth_hdr);
let username = crate::app::username_for(&conn, sess.0.user_id);
let _ = audit::log(
&conn,
Some(&username),
"logout",
Some("/v1/auth/logout"),
Some(&sess.0.fingerprint),
&json!({}),
);
Json(json!({"ok": true}))
}