Merge branch 'feature/phase-10-firstrun'
This commit is contained in:
5
debian/rules
vendored
5
debian/rules
vendored
@@ -23,6 +23,11 @@ override_dh_auto_install:
|
|||||||
debian/authforge-gui/usr/share/applications/io.dangerousthings.AuthForge.desktop
|
debian/authforge-gui/usr/share/applications/io.dangerousthings.AuthForge.desktop
|
||||||
install -D -m 0644 gui/data/io.dangerousthings.AuthForge.svg \
|
install -D -m 0644 gui/data/io.dangerousthings.AuthForge.svg \
|
||||||
debian/authforge-gui/usr/share/icons/hicolor/scalable/apps/io.dangerousthings.AuthForge.svg
|
debian/authforge-gui/usr/share/icons/hicolor/scalable/apps/io.dangerousthings.AuthForge.svg
|
||||||
|
# First-run autostart entry (Phase 10): runs `authforge --first-run`
|
||||||
|
# at GNOME session start; binary exits ~immediately when no pending
|
||||||
|
# flag is set, so the per-session cost is one D-Bus round-trip.
|
||||||
|
install -D -m 0644 gui/data/authforge-firstrun.desktop \
|
||||||
|
debian/authforge-gui/etc/xdg/autostart/authforge-firstrun.desktop
|
||||||
# PAM
|
# PAM
|
||||||
$(MAKE) -C pam install DESTDIR=$(CURDIR)/debian/authforge-pam
|
$(MAKE) -C pam install DESTDIR=$(CURDIR)/debian/authforge-pam
|
||||||
# D-Bus activation file
|
# D-Bus activation file
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
| 7 | CLI: `authforgectl` | 4 days | ✅ **Done** (2026-04-27) — 14 subcommands |
|
| 7 | CLI: `authforgectl` | 4 days | ✅ **Done** (2026-04-27) — 14 subcommands |
|
||||||
| 8 | GUI: app shell + Security Keys tab | 6–8 days | ✅ **Code complete** (2026-04-27) — 4 unit tests; manual smoke recipe in `gui/TESTING.md` |
|
| 8 | GUI: app shell + Security Keys tab | 6–8 days | ✅ **Code complete** (2026-04-27) — 4 unit tests; manual smoke recipe in `gui/TESTING.md` |
|
||||||
| 9 | GUI: Policy tab + lockout-warning UX | 4 days | ✅ **Code complete** (2026-04-27) — bundles Phase 12 GUI tab; 3 unit tests in `policy_form` |
|
| 9 | GUI: Policy tab + lockout-warning UX | 4 days | ✅ **Code complete** (2026-04-27) — bundles Phase 12 GUI tab; 3 unit tests in `policy_form` |
|
||||||
| 10 | First-login flow: autostart entry + fullscreen modal | 4 days | **Spec'd** — open now (Phase 6 ✓ + Phase 8 ✓) |
|
| 10 | First-login flow: autostart entry + fullscreen modal | 4 days | ✅ **Code complete** (2026-04-27) — VM smoke deferred |
|
||||||
| 11 | TOTP support (PAM module + GUI tab) — feature flag | 5 days | **Spec'd** — open now (Phase 2 ✓ + Phase 4 ✓ + Phase 8 ✓ for the GUI tab) |
|
| 11 | TOTP support (PAM module + GUI tab) — feature flag | 5 days | **Spec'd** — open now (Phase 2 ✓ + Phase 4 ✓ + Phase 8 ✓ for the GUI tab) |
|
||||||
| 12 | Recovery flow (codes + emergency unlock) | 4 days | ✅ **Code complete** (2026-04-27) — backend + PAM + GUI tab all landed |
|
| 12 | Recovery flow (codes + emergency unlock) | 4 days | ✅ **Code complete** (2026-04-27) — backend + PAM + GUI tab all landed |
|
||||||
| 13 | Debian packaging finalization (postinst, debconf, purge) | 4 days | **Spec'd** — sequential tail; needs all binaries built |
|
| 13 | Debian packaging finalization (postinst, debconf, purge) | 4 days | **Spec'd** — sequential tail; needs all binaries built |
|
||||||
@@ -300,6 +300,19 @@ Landed via the plan at [2026-04-27-phase-9-and-12-gui.md](2026-04-27-phase-9-and
|
|||||||
**Deferred until Phase 14 VM smoke:**
|
**Deferred until Phase 14 VM smoke:**
|
||||||
- Manual Policy + Recovery walkthroughs in `gui/TESTING.md` against a real GNOME session with a Yubikey.
|
- Manual Policy + Recovery walkthroughs in `gui/TESTING.md` against a real GNOME session with a Yubikey.
|
||||||
|
|
||||||
|
### Phase 10 closeout notes (2026-04-27)
|
||||||
|
|
||||||
|
Landed via [2026-04-27-phase-10-firstrun.md](2026-04-27-phase-10-firstrun.md). Built atop the prep lane's `--first-run` flag scaffold and the `GetPendingStatus` D-Bus method.
|
||||||
|
|
||||||
|
**Done:**
|
||||||
|
- `gui/src/firstrun/{mod,watchdog}.rs` — fullscreen `adw::ApplicationWindow` modal, 60s idle watchdog (3 unit tests on `WatchdogState`), enrollment via the Phase 8 `enroll_dialog`, `ClearPendingFlag` on success, `gnome-session-quit --logout` on idle. Daemon-connect / `GetPendingStatus` failures render a Skip button via a shared `render_unavailable` helper instead of stranding the user for 60s.
|
||||||
|
- `gui/data/authforge-firstrun.desktop` — autostart entry installed to `/etc/xdg/autostart/`. `OnlyShowIn=GNOME`, `X-GNOME-Autostart-Phase=Initialization`. Cost on every session start: one D-Bus call to `GetPendingStatus`; no pending flag → immediate exit 0.
|
||||||
|
- `debian/authforge-gui.install` — installs the autostart entry alongside the binary.
|
||||||
|
|
||||||
|
**Plan deviations:** Task 4's Skip-button code was factored into a single `render_unavailable(win, status, message)` helper called from both the daemon-connect and `GetPendingStatus` error arms, rather than inlining the snippet twice as the plan literal suggested. Same UX, no duplication.
|
||||||
|
|
||||||
|
**Deferred until Phase 14 VM smoke:** the full Flow C walkthrough (`useradd -m testuser; chage -d 0 testuser; sudo authforgectl pending set testuser --methods fido2; logout; login; see modal; enroll; see desktop`). Recipe lives in `gui/FIRSTRUN-TESTING.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Phase 0: Repository Scaffolding (FULLY DETAILED)
|
# Phase 0: Repository Scaffolding (FULLY DETAILED)
|
||||||
|
|||||||
53
gui/FIRSTRUN-TESTING.md
Normal file
53
gui/FIRSTRUN-TESTING.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Testing the First-Run Modal (Phase 10)
|
||||||
|
|
||||||
|
Phase 10's user-visible flow can only be exercised against a real GTK
|
||||||
|
session. CI verifies that the binary builds and `--first-run` parses;
|
||||||
|
this recipe is the manual gate.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Ubuntu 24.04 VM with GNOME on Wayland (matches the project's target).
|
||||||
|
- `authforge-daemon` + `authforge-gui` debs installed (Phase 14 PPA build).
|
||||||
|
- A USB FIDO2 device (Yubikey, Solo, etc.) attached to the VM.
|
||||||
|
|
||||||
|
## Walk-through
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# As root in the VM:
|
||||||
|
sudo useradd -m testuser
|
||||||
|
sudo passwd testuser # set a temporary password
|
||||||
|
sudo chage -d 0 testuser # force password change at next login
|
||||||
|
sudo authforgectl pending set testuser --methods fido2
|
||||||
|
|
||||||
|
# Log out of the current session, log in as testuser.
|
||||||
|
# Expected sequence:
|
||||||
|
# 1. GDM forces a password change → set the real password.
|
||||||
|
# 2. Session starts.
|
||||||
|
# 3. authforge --first-run launches via the autostart entry.
|
||||||
|
# 4. Fullscreen modal appears: "Welcome — Enroll a security key…"
|
||||||
|
# 5. Click "Get started" → enrollment dialog appears.
|
||||||
|
# 6. Touch the Yubikey → modal closes → desktop usable.
|
||||||
|
# 7. Verify: authforgectl pending list (run as root in another tty) shows
|
||||||
|
# no pending flag for testuser.
|
||||||
|
|
||||||
|
# Re-test the auto-logout watchdog:
|
||||||
|
sudo authforgectl pending set testuser --methods fido2
|
||||||
|
# Log out, log in as testuser, do nothing for 60+ seconds.
|
||||||
|
# Expected: the user is logged out via gnome-session-quit and returned
|
||||||
|
# to GDM.
|
||||||
|
|
||||||
|
# Re-test the daemon-down case:
|
||||||
|
sudo systemctl stop authforge-daemon.service
|
||||||
|
# Log out, log in as testuser. The modal shows "Setup unavailable" with
|
||||||
|
# a Skip button; clicking it returns to the desktop.
|
||||||
|
sudo systemctl start authforge-daemon.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pass criteria
|
||||||
|
|
||||||
|
- [ ] The modal appears within 2s of session start.
|
||||||
|
- [ ] Successful enrollment clears the pending flag and exits the modal.
|
||||||
|
- [ ] 60s idle triggers a clean session logout.
|
||||||
|
- [ ] Daemon-down shows a graceful Skip button rather than hanging.
|
||||||
|
- [ ] No pending flag → no modal → session start cost is < 100ms (verify
|
||||||
|
with `journalctl --since=now -f` for the autostart unit).
|
||||||
9
gui/data/authforge-firstrun.desktop
Normal file
9
gui/data/authforge-firstrun.desktop
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=AuthForge First-Run Setup
|
||||||
|
Comment=Prompts new users to enroll a security key.
|
||||||
|
Exec=authforge --first-run
|
||||||
|
NoDisplay=true
|
||||||
|
OnlyShowIn=GNOME;
|
||||||
|
X-GNOME-Autostart-Phase=Initialization
|
||||||
|
X-GNOME-Autostart-enabled=true
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
//! 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);
|
|
||||||
}
|
|
||||||
181
gui/src/firstrun/mod.rs
Normal file
181
gui/src/firstrun/mod.rs
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
//! First-login modal. Fullscreen `adw::ApplicationWindow` with a 60s idle
|
||||||
|
//! watchdog: if the user goes silent we fall back to `gnome-session-quit
|
||||||
|
//! --logout`. On a present pending flag, we run the Phase 8 enrollment
|
||||||
|
//! dialog, clear the flag on success, and exit.
|
||||||
|
|
||||||
|
mod watchdog;
|
||||||
|
pub(crate) use watchdog::WatchdogState;
|
||||||
|
|
||||||
|
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;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const WATCHDOG_TIMEOUT: Duration = Duration::from_secs(60);
|
||||||
|
const WATCHDOG_TICK: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
pub(crate) fn run(ctx: AppContext) {
|
||||||
|
// Reuse the application from the parent window the activate handler
|
||||||
|
// already created — avoids re-fetching the default Application.
|
||||||
|
let app = ctx
|
||||||
|
.parent_window
|
||||||
|
.application()
|
||||||
|
.expect("parent window has its application set in main.rs");
|
||||||
|
let app = app
|
||||||
|
.downcast::<adw::Application>()
|
||||||
|
.expect("authforge uses adw::Application");
|
||||||
|
|
||||||
|
let win = adw::ApplicationWindow::builder()
|
||||||
|
.application(&app)
|
||||||
|
.title("Authentication setup")
|
||||||
|
.decorated(false)
|
||||||
|
.fullscreened(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let status = adw::StatusPage::builder()
|
||||||
|
.icon_name("dialog-password-symbolic")
|
||||||
|
.title("Setting up authentication")
|
||||||
|
.description("Connecting to the authentication daemon…")
|
||||||
|
.build();
|
||||||
|
win.set_content(Some(&status));
|
||||||
|
|
||||||
|
// Watchdog state shared across the activity-poke handlers and the timer.
|
||||||
|
let watchdog = Rc::new(RefCell::new(WatchdogState::new(WATCHDOG_TIMEOUT)));
|
||||||
|
|
||||||
|
install_activity_pokes(&win, watchdog.clone());
|
||||||
|
install_watchdog_timer(&win, watchdog);
|
||||||
|
|
||||||
|
// Async startup: GetPendingStatus → render flow.
|
||||||
|
let win_for_async = win.clone();
|
||||||
|
let status_for_async = status.clone();
|
||||||
|
glib::MainContext::default().spawn_local(async move {
|
||||||
|
let daemon = match Daemon::connect().await {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
render_unavailable(&win_for_async, &status_for_async, &user_message(&e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let user = current_user();
|
||||||
|
let s = match daemon.get_pending_status(&user).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
render_unavailable(&win_for_async, &status_for_async, &user_message(&e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !s.present {
|
||||||
|
// Nothing for us to do — exit cleanly. Most session starts
|
||||||
|
// hit this path; cost is one D-Bus call.
|
||||||
|
win_for_async.close();
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
render_get_started(&win_for_async, &status_for_async, daemon, user);
|
||||||
|
});
|
||||||
|
|
||||||
|
win.present();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_get_started(
|
||||||
|
win: &adw::ApplicationWindow,
|
||||||
|
status: &adw::StatusPage,
|
||||||
|
daemon: Daemon,
|
||||||
|
user: String,
|
||||||
|
) {
|
||||||
|
status.set_title("Welcome");
|
||||||
|
status.set_description(Some(
|
||||||
|
"Enroll a security key to finish setting up your account.",
|
||||||
|
));
|
||||||
|
let go = gtk::Button::builder()
|
||||||
|
.label("Get started")
|
||||||
|
.css_classes(["pill", "suggested-action"])
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
let win_for_click = win.clone();
|
||||||
|
let daemon_for_click = daemon.clone();
|
||||||
|
let user_for_click = user.clone();
|
||||||
|
go.connect_clicked(move |btn| {
|
||||||
|
// Disable the button so a second click doesn't double-prompt.
|
||||||
|
btn.set_sensitive(false);
|
||||||
|
let win_inner = win_for_click.clone();
|
||||||
|
let daemon_inner = daemon_for_click.clone();
|
||||||
|
let user_inner = user_for_click.clone();
|
||||||
|
crate::enroll_dialog::present(
|
||||||
|
&win_inner.clone().upcast::<gtk::Window>(),
|
||||||
|
daemon_inner.clone(),
|
||||||
|
move || {
|
||||||
|
// On success: clear the pending flag, then exit.
|
||||||
|
let win_done = win_inner.clone();
|
||||||
|
let daemon_done = daemon_inner.clone();
|
||||||
|
let user_done = user_inner.clone();
|
||||||
|
glib::MainContext::default().spawn_local(async move {
|
||||||
|
if let Err(e) = daemon_done.clear_pending_flag(&user_done).await {
|
||||||
|
// Even if clearing fails, exit; the user enrolled
|
||||||
|
// a key and the next login will succeed via pam_u2f
|
||||||
|
// even if pending is still set.
|
||||||
|
eprintln!("authforge: ClearPendingFlag failed: {}", user_message(&e));
|
||||||
|
}
|
||||||
|
win_done.close();
|
||||||
|
std::process::exit(0);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
status.set_child(Some(&go));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_unavailable(win: &adw::ApplicationWindow, status: &adw::StatusPage, message: &str) {
|
||||||
|
// Daemon-connect or GetPendingStatus failure: surface the error and give
|
||||||
|
// the user a Skip button so they aren't stuck waiting 60s for the
|
||||||
|
// watchdog to log them out.
|
||||||
|
status.set_title("Setup unavailable");
|
||||||
|
status.set_description(Some(message));
|
||||||
|
let skip = gtk::Button::builder()
|
||||||
|
.label("Skip")
|
||||||
|
.css_classes(["pill"])
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
let win_skip = win.clone();
|
||||||
|
skip.connect_clicked(move |_| {
|
||||||
|
win_skip.close();
|
||||||
|
std::process::exit(0);
|
||||||
|
});
|
||||||
|
status.set_child(Some(&skip));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_activity_pokes(win: &adw::ApplicationWindow, watchdog: Rc<RefCell<WatchdogState>>) {
|
||||||
|
// Any keypress or click counts as activity.
|
||||||
|
let key = gtk::EventControllerKey::new();
|
||||||
|
let w = watchdog.clone();
|
||||||
|
key.connect_key_pressed(move |_, _, _, _| {
|
||||||
|
w.borrow_mut().poke();
|
||||||
|
glib::Propagation::Proceed
|
||||||
|
});
|
||||||
|
win.add_controller(key);
|
||||||
|
|
||||||
|
let click = gtk::GestureClick::new();
|
||||||
|
let w = watchdog;
|
||||||
|
click.connect_pressed(move |_, _, _, _| w.borrow_mut().poke());
|
||||||
|
win.add_controller(click);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_watchdog_timer(win: &adw::ApplicationWindow, watchdog: Rc<RefCell<WatchdogState>>) {
|
||||||
|
let win_for_timer = win.clone();
|
||||||
|
glib::source::timeout_add_local(WATCHDOG_TICK, move || {
|
||||||
|
if watchdog.borrow().expired() {
|
||||||
|
// 60s idle — kick the user back out. gnome-session-quit
|
||||||
|
// is the documented path for "log this user out cleanly."
|
||||||
|
let _ = std::process::Command::new("gnome-session-quit")
|
||||||
|
.arg("--logout")
|
||||||
|
.arg("--no-prompt")
|
||||||
|
.spawn();
|
||||||
|
win_for_timer.close();
|
||||||
|
return glib::ControlFlow::Break;
|
||||||
|
}
|
||||||
|
glib::ControlFlow::Continue
|
||||||
|
});
|
||||||
|
}
|
||||||
55
gui/src/firstrun/watchdog.rs
Normal file
55
gui/src/firstrun/watchdog.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
//! Tracks the last-activity timestamp; the GTK timer queries this each tick
|
||||||
|
//! to decide whether 60s of idle has elapsed.
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
pub(crate) struct WatchdogState {
|
||||||
|
last_activity: Instant,
|
||||||
|
timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WatchdogState {
|
||||||
|
pub fn new(timeout: Duration) -> Self {
|
||||||
|
Self {
|
||||||
|
last_activity: Instant::now(),
|
||||||
|
timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn poke(&mut self) {
|
||||||
|
self.last_activity = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True iff `timeout` has passed since the last poke. Once expired,
|
||||||
|
/// stays expired until the caller drops + recreates.
|
||||||
|
pub fn expired(&self) -> bool {
|
||||||
|
self.last_activity.elapsed() >= self.timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fresh_state_is_not_expired() {
|
||||||
|
let w = WatchdogState::new(Duration::from_secs(60));
|
||||||
|
assert!(!w.expired());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_timeout_is_immediately_expired() {
|
||||||
|
let w = WatchdogState::new(Duration::from_millis(0));
|
||||||
|
std::thread::sleep(Duration::from_millis(2));
|
||||||
|
assert!(w.expired());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn poke_resets_the_clock() {
|
||||||
|
let mut w = WatchdogState::new(Duration::from_millis(100));
|
||||||
|
std::thread::sleep(Duration::from_millis(150));
|
||||||
|
assert!(w.expired());
|
||||||
|
w.poke();
|
||||||
|
assert!(!w.expired());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user