Merge branch 'feature/prep-shell-and-pending'
This commit is contained in:
@@ -36,7 +36,7 @@ pub struct Credential {
|
||||
pub created_unix: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
|
||||
pub struct PendingFlag {
|
||||
pub required_methods: Vec<Method>,
|
||||
pub created_unix: u64,
|
||||
@@ -46,6 +46,13 @@ pub struct PendingFlag {
|
||||
pub re_enroll: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
|
||||
pub struct PendingStatus {
|
||||
pub present: bool,
|
||||
/// Contents are the default when `!present`; consumer must check `present` first.
|
||||
pub flag: PendingFlag,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, zvariant::Type)]
|
||||
pub struct Violation {
|
||||
pub user: String,
|
||||
@@ -119,4 +126,30 @@ mod tests {
|
||||
let back: Credential = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(c, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_status_serde() {
|
||||
let s = PendingStatus {
|
||||
present: true,
|
||||
flag: PendingFlag {
|
||||
required_methods: vec![Method::Fido2],
|
||||
created_unix: 1_700_000_000,
|
||||
deadline_unix: 0,
|
||||
re_enroll: false,
|
||||
},
|
||||
};
|
||||
let bytes = serde_json::to_vec(&s).unwrap();
|
||||
let back: PendingStatus = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(s, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_status_default_when_absent() {
|
||||
let s = PendingStatus {
|
||||
present: false,
|
||||
flag: PendingFlag::default(),
|
||||
};
|
||||
assert!(!s.present);
|
||||
assert!(s.flag.required_methods.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use crate::polkit::Polkit;
|
||||
use crate::state::AppState;
|
||||
use authforge_common::policy::Policy;
|
||||
use authforge_common::types::{Credential, PendingFlag, PolicyApplyResult, RecoveryCodeSummary};
|
||||
use authforge_common::types::{
|
||||
Credential, PendingFlag, PendingStatus, PolicyApplyResult, RecoveryCodeSummary,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct AuthForge {
|
||||
@@ -129,6 +131,14 @@ impl AuthForge {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_pending_status(&self, user: String) -> zbus::fdo::Result<PendingStatus> {
|
||||
// No polkit gate — pure read; daemon's pattern is gates on writes only.
|
||||
self.state
|
||||
.get_pending_status(&user)
|
||||
.await
|
||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
|
||||
}
|
||||
|
||||
async fn generate_recovery_code(&self, user: String) -> zbus::fdo::Result<String> {
|
||||
self.authz("io.dangerousthings.AuthForge.generate-recovery")
|
||||
.await?;
|
||||
@@ -387,6 +397,34 @@ central_path = "{}"
|
||||
assert!(!state.has_pending("alice").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_status_is_absent_for_fresh_user() {
|
||||
let (_srv, client, _state, _tmp) = p2p_pair().await;
|
||||
let p = proxy(&client).await;
|
||||
let s: PendingStatus = p.call("GetPendingStatus", &("alice",)).await.unwrap();
|
||||
assert!(!s.present);
|
||||
assert!(s.flag.required_methods.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_status_round_trips_set_flag() {
|
||||
let (_srv, client, _state, _tmp) = p2p_pair().await;
|
||||
let p = proxy(&client).await;
|
||||
let flag = PendingFlag {
|
||||
required_methods: vec![Method::Fido2],
|
||||
created_unix: 100,
|
||||
deadline_unix: 200,
|
||||
re_enroll: true,
|
||||
};
|
||||
let _: () = p
|
||||
.call("SetPendingFlag", &("alice", flag.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
let s: PendingStatus = p.call("GetPendingStatus", &("alice",)).await.unwrap();
|
||||
assert!(s.present);
|
||||
assert_eq!(s.flag, flag);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_recovery_code_returns_8_digits() {
|
||||
let (_srv, client, _state, _tmp) = p2p_pair().await;
|
||||
|
||||
@@ -7,7 +7,9 @@ use crate::storage::policy::PolicyStore;
|
||||
use crate::storage::recovery::{RecoveryEntry, RecoveryStore, RecoveryStoreError};
|
||||
use crate::storage::userdb::{UserDb, UserDbError};
|
||||
use authforge_common::policy::{Policy, PolicyError};
|
||||
use authforge_common::types::{Credential, Method, PendingFlag, PolicyApplyResult, Transport};
|
||||
use authforge_common::types::{
|
||||
Credential, Method, PendingFlag, PendingStatus, PolicyApplyResult, Transport,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -249,12 +251,26 @@ impl AppState {
|
||||
}
|
||||
|
||||
/// Tests assert pending flag round-trips through SetPendingFlag /
|
||||
/// ClearPendingFlag via this read accessor. Production readers (PAM
|
||||
/// module, GUI status) read the on-disk file directly in later phases.
|
||||
/// ClearPendingFlag via this read accessor. Production readers go
|
||||
/// through `get_pending_status` (GUI) or read the on-disk file
|
||||
/// directly (PAM module).
|
||||
#[cfg(test)]
|
||||
pub async fn has_pending(&self, user: &str) -> Result<bool, StateError> {
|
||||
Ok(self.pending.get(user)?.is_some())
|
||||
}
|
||||
|
||||
pub async fn get_pending_status(&self, user: &str) -> Result<PendingStatus, StateError> {
|
||||
match self.pending.get(user)? {
|
||||
Some(flag) => Ok(PendingStatus {
|
||||
present: true,
|
||||
flag,
|
||||
}),
|
||||
None => Ok(PendingStatus {
|
||||
present: false,
|
||||
flag: PendingFlag::default(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
27
gui/src/app_context.rs
Normal file
27
gui/src/app_context.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
//! Shared per-window handles passed to every page constructor.
|
||||
//!
|
||||
//! `daemon` is a `RefCell<Option<...>>` because the connect-on-startup attempt
|
||||
//! may fail (pages render their disconnected banner); a successful Retry
|
||||
//! repopulates this same cell, and every page sees the new value via the Rc
|
||||
//! clone chain.
|
||||
|
||||
use crate::bus::Daemon;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct AppContext {
|
||||
pub parent_window: gtk::Window,
|
||||
pub toast_overlay: adw::ToastOverlay,
|
||||
pub daemon: Rc<RefCell<Option<Daemon>>>,
|
||||
}
|
||||
|
||||
impl AppContext {
|
||||
pub fn new(parent_window: gtk::Window, toast_overlay: adw::ToastOverlay) -> Self {
|
||||
Self {
|
||||
parent_window,
|
||||
toast_overlay,
|
||||
daemon: Rc::new(RefCell::new(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
//! `glib::MainContext::spawn_local`, which polls the future on the GTK main
|
||||
//! thread; tokio's reactor wakes the future when zbus I/O is ready.
|
||||
|
||||
use authforge_common::types::Credential;
|
||||
use authforge_common::types::{Credential, PendingStatus};
|
||||
|
||||
const BUS_NAME: &str = "io.dangerousthings.AuthForge";
|
||||
const OBJECT_PATH: &str = "/io/dangerousthings/AuthForge";
|
||||
@@ -48,6 +48,14 @@ impl Daemon {
|
||||
) -> zbus::Result<zbus::proxy::SignalStream<'static>> {
|
||||
self.proxy.receive_signal("EnrollmentFailed").await
|
||||
}
|
||||
|
||||
pub async fn get_pending_status(&self, user: &str) -> zbus::Result<PendingStatus> {
|
||||
self.proxy.call("GetPendingStatus", &(user,)).await
|
||||
}
|
||||
|
||||
pub async fn clear_pending_flag(&self, user: &str) -> zbus::Result<()> {
|
||||
self.proxy.call("ClearPendingFlag", &(user,)).await
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // wired through keys_page.rs in Task 4.
|
||||
|
||||
14
gui/src/firstrun.rs
Normal file
14
gui/src/firstrun.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
//! First-login modal. Phase 10 implements the body of `run`.
|
||||
|
||||
#![allow(dead_code)] // body lands in Phase 10.
|
||||
|
||||
use crate::app_context::AppContext;
|
||||
|
||||
/// Entry point for `authforge --first-run`. Phase 10 fills this with the
|
||||
/// fullscreen modal + watchdog + enroll-then-clear-pending logic.
|
||||
/// Until then it exits cleanly so a developer who passes `--first-run`
|
||||
/// doesn't get a stuck process.
|
||||
pub(crate) fn run(_ctx: AppContext) {
|
||||
eprintln!("authforge: --first-run mode is a Phase 10 stub; exiting.");
|
||||
std::process::exit(0);
|
||||
}
|
||||
@@ -1,27 +1,23 @@
|
||||
//! "Security keys" preferences page. v1 surface: the current user's enrolled
|
||||
//! FIDO2 credentials with per-row remove + a single "Enroll a new key" button.
|
||||
|
||||
use crate::app_context::AppContext;
|
||||
use crate::bus::{current_user, Daemon};
|
||||
use crate::error::user_message;
|
||||
use adw::prelude::*;
|
||||
use gtk::glib;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
pub(crate) struct KeysPage {
|
||||
pub root: adw::Bin,
|
||||
daemon: Rc<RefCell<Option<Daemon>>>,
|
||||
parent_window: gtk::Window,
|
||||
toast_overlay: adw::ToastOverlay,
|
||||
ctx: AppContext,
|
||||
}
|
||||
|
||||
impl KeysPage {
|
||||
pub fn new(parent_window: gtk::Window, toast_overlay: adw::ToastOverlay) -> Rc<Self> {
|
||||
pub fn new(ctx: AppContext) -> Rc<Self> {
|
||||
let page = Rc::new(Self {
|
||||
root: adw::Bin::new(),
|
||||
daemon: Rc::new(RefCell::new(None)),
|
||||
parent_window,
|
||||
toast_overlay,
|
||||
ctx,
|
||||
});
|
||||
let p = page.clone();
|
||||
glib::MainContext::default().spawn_local(async move {
|
||||
@@ -33,7 +29,7 @@ impl KeysPage {
|
||||
async fn try_connect_and_render(self: Rc<Self>) {
|
||||
match Daemon::connect().await {
|
||||
Ok(d) => {
|
||||
*self.daemon.borrow_mut() = Some(d);
|
||||
*self.ctx.daemon.borrow_mut() = Some(d);
|
||||
self.render_connected().await;
|
||||
}
|
||||
Err(e) => self.render_disconnected(&user_message(&e)),
|
||||
@@ -63,7 +59,7 @@ impl KeysPage {
|
||||
}
|
||||
|
||||
async fn render_connected(self: &Rc<Self>) {
|
||||
let daemon = match self.daemon.borrow().clone() {
|
||||
let daemon = match self.ctx.daemon.borrow().clone() {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
self.render_disconnected("internal: daemon handle missing");
|
||||
@@ -140,7 +136,7 @@ impl KeysPage {
|
||||
}
|
||||
|
||||
async fn remove_credential(self: &Rc<Self>, cred_id: &str) {
|
||||
let daemon = match self.daemon.borrow().clone() {
|
||||
let daemon = match self.ctx.daemon.borrow().clone() {
|
||||
Some(d) => d,
|
||||
None => return,
|
||||
};
|
||||
@@ -153,12 +149,12 @@ impl KeysPage {
|
||||
}
|
||||
|
||||
async fn start_enroll(self: &Rc<Self>) {
|
||||
let daemon = match self.daemon.borrow().clone() {
|
||||
let daemon = match self.ctx.daemon.borrow().clone() {
|
||||
Some(d) => d,
|
||||
None => return,
|
||||
};
|
||||
let me = self.clone();
|
||||
crate::enroll_dialog::present(&self.parent_window, daemon, move || {
|
||||
crate::enroll_dialog::present(&self.ctx.parent_window, daemon, move || {
|
||||
let me = me.clone();
|
||||
glib::MainContext::default().spawn_local(async move {
|
||||
me.render_connected().await;
|
||||
@@ -168,7 +164,7 @@ impl KeysPage {
|
||||
|
||||
fn show_toast(self: &Rc<Self>, msg: &str) {
|
||||
let toast = adw::Toast::builder().title(msg).timeout(5).build();
|
||||
self.toast_overlay.add_toast(toast);
|
||||
self.ctx.toast_overlay.add_toast(toast);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use adw::prelude::*;
|
||||
use gtk::glib;
|
||||
|
||||
mod app_context;
|
||||
mod bus;
|
||||
mod enroll_dialog;
|
||||
mod error;
|
||||
mod firstrun;
|
||||
mod keys_page;
|
||||
|
||||
const APP_ID: &str = "io.dangerousthings.AuthForge";
|
||||
@@ -19,14 +21,33 @@ fn main() -> glib::ExitCode {
|
||||
.expect("build tokio runtime");
|
||||
let _guard = runtime.enter();
|
||||
|
||||
// Strip `--first-run` from argv before GTK's own option parser sees it;
|
||||
// GTK rejects unknown long options. Phase 10 fills the body of
|
||||
// firstrun::run; the prep lane only wires the entry point.
|
||||
let raw_args: Vec<String> = std::env::args().collect();
|
||||
let first_run = raw_args.iter().any(|a| a == "--first-run");
|
||||
let gtk_args: Vec<String> = raw_args
|
||||
.iter()
|
||||
.filter(|a| a.as_str() != "--first-run")
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let app = adw::Application::builder().application_id(APP_ID).build();
|
||||
app.connect_activate(|app| {
|
||||
app.connect_activate(move |app| {
|
||||
let win = adw::ApplicationWindow::builder()
|
||||
.application(app)
|
||||
.default_width(720)
|
||||
.default_height(540)
|
||||
.title("Authentication")
|
||||
.build();
|
||||
let toast_overlay = adw::ToastOverlay::new();
|
||||
let ctx = app_context::AppContext::new(win.clone().upcast(), toast_overlay.clone());
|
||||
|
||||
if first_run {
|
||||
firstrun::run(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
let stack = adw::ViewStack::new();
|
||||
let switcher = adw::ViewSwitcher::builder()
|
||||
@@ -35,8 +56,7 @@ fn main() -> glib::ExitCode {
|
||||
.build();
|
||||
header.set_title_widget(Some(&switcher));
|
||||
|
||||
let toast_overlay = adw::ToastOverlay::new();
|
||||
let keys = keys_page::KeysPage::new(win.clone().upcast(), toast_overlay.clone());
|
||||
let keys = keys_page::KeysPage::new(ctx.clone());
|
||||
stack.add_titled(&keys.root, Some("keys"), "Keys");
|
||||
|
||||
// ToolbarView is libadwaita v1_4-gated; the project sticks with the
|
||||
@@ -50,5 +70,5 @@ fn main() -> glib::ExitCode {
|
||||
win.set_content(Some(&toast_overlay));
|
||||
win.present();
|
||||
});
|
||||
app.run()
|
||||
app.run_with_args(>k_args)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user