diff --git a/Cargo.lock b/Cargo.lock index 31ad508..26a3f58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -139,6 +139,15 @@ dependencies = [ "libc", ] +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + [[package]] name = "apply" version = "0.3.0" @@ -383,6 +392,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.1.0" @@ -410,6 +430,29 @@ version = "0.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ba43ea6f343b788c8764558649e08df62f86c6ef251fdaeb1ffd010a9ae50a2" +[[package]] +name = "bindgen" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bd2a9a458e8f4304c52c43ebb0cfbd520289f8379a52e329a38afda99bf8eb8" +dependencies = [ + "bitflags 1.3.2", + "cexpr", + "clang-sys", + "clap", + "env_logger 0.9.3", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "which", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -557,6 +600,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.0" @@ -569,6 +621,32 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "clang-sys" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c688fc74432808e3eb684cae8830a86be1d66a2bd58e1f248ed0960a590baf6f" +dependencies = [ + "glob", + "libc", + "libloading 0.7.4", +] + +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +dependencies = [ + "ansi_term", + "atty", + "bitflags 1.3.2", + "strsim 0.8.0", + "textwrap", + "unicode-width", + "vec_map", +] + [[package]] name = "clipboard-win" version = "4.5.0" @@ -759,11 +837,12 @@ dependencies = [ name = "cosmic-greeter" version = "0.1.0" dependencies = [ - "env_logger", + "env_logger 0.10.0", "freedesktop_entry_parser", "greetd_ipc", "libcosmic", "log", + "pam-client", "pwd", "shlex", "tokio", @@ -960,7 +1039,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.10.0", "syn 1.0.109", ] @@ -974,7 +1053,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.10.0", "syn 2.0.37", ] @@ -1127,6 +1206,17 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" +[[package]] +name = "enum-repr" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad30c9c0fa1aaf1ae5010dab11f1117b15d35faf62cda4bbbc53b9987950f18" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "enumflags2" version = "0.7.8" @@ -1148,6 +1238,19 @@ dependencies = [ "syn 2.0.37", ] +[[package]] +name = "env_logger" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + [[package]] name = "env_logger" version = "0.10.0" @@ -1635,6 +1738,12 @@ version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5418c17512bdf42730f9032c74e1ae39afc408745ebb2acf72fbc4691c17945" +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + [[package]] name = "glow" version = "0.12.3" @@ -1782,6 +1891,15 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hermit-abi" version = "0.3.3" @@ -1800,6 +1918,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "home" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb" +dependencies = [ + "windows-sys 0.48.0", +] + [[package]] name = "humantime" version = "2.1.0" @@ -2075,7 +2202,7 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" dependencies = [ - "hermit-abi", + "hermit-abi 0.3.3", "libc", "windows-sys 0.48.0", ] @@ -2086,7 +2213,7 @@ version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" dependencies = [ - "hermit-abi", + "hermit-abi 0.3.3", "rustix 0.38.14", "windows-sys 0.48.0", ] @@ -2176,6 +2303,12 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "lebe" version = "0.5.2" @@ -2639,7 +2772,7 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" dependencies = [ - "hermit-abi", + "hermit-abi 0.3.3", "libc", ] @@ -2818,6 +2951,30 @@ dependencies = [ "syn 2.0.37", ] +[[package]] +name = "pam-client" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bd776116a7ada5ebbe31f54cdc5b1030ed7265686cf7c8a21c057a2f8dab9a" +dependencies = [ + "bitflags 1.3.2", + "enum-repr", + "libc", + "pam-sys", + "rpassword", + "rustversion", +] + +[[package]] +name = "pam-sys" +version = "1.0.0-alpha4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e9dfd42858f6a6bb1081079fd9dc259ca3e2aaece6cb689fd36b1058046c969" +dependencies = [ + "bindgen", + "libc", +] + [[package]] name = "parking" version = "2.1.1" @@ -2878,6 +3035,12 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + [[package]] name = "percent-encoding" version = "2.3.0" @@ -3296,6 +3459,18 @@ dependencies = [ "xmlparser", ] +[[package]] +name = "rpassword" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf099a1888612545b683d2661a1940089f6c2e5a8e38979b2159da876bfd956" +dependencies = [ + "libc", + "serde", + "serde_json", + "winapi", +] + [[package]] name = "rust-ini" version = "0.18.0" @@ -3345,6 +3520,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "rustversion" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" + [[package]] name = "rustybuzz" version = "0.7.0" @@ -3676,6 +3857,12 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + [[package]] name = "strsim" version = "0.10.0" @@ -3761,6 +3948,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.49" @@ -4479,6 +4675,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.14", +] + [[package]] name = "widestring" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index e5e27bc..f0172bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" env_logger = "0.10" freedesktop_entry_parser = "1" log = "0.4" +pam-client = "0.5" pwd = "1" shlex = "1" diff --git a/examples/pam.rs b/examples/pam.rs new file mode 100644 index 0000000..ce61eb4 --- /dev/null +++ b/examples/pam.rs @@ -0,0 +1,23 @@ +fn main() { + use pam_client::conv_cli::Conversation; + use pam_client::{Context, Flag}; // CLI implementation + + let passwd = pwd::Passwd::current_user().expect("Failed to get current user"); + + let mut context = Context::new( + "cosmic-lock", // Service name, decides which policy is used (see `/etc/pam.d`) + Some(&passwd.name), // Optional preset user name + Conversation::new(), // Handler for user interaction + ) + .expect("Failed to initialize PAM context"); + + // Authenticate the user (ask for password, 2nd-factor token, fingerprint, etc.) + context + .authenticate(Flag::NONE) + .expect("Authentication failed"); + + // Validate the account (is not locked, expired, etc.) + context + .acct_mgmt(Flag::NONE) + .expect("Account validation failed"); +} diff --git a/src/greeter.rs b/src/greeter.rs new file mode 100644 index 0000000..c692b77 --- /dev/null +++ b/src/greeter.rs @@ -0,0 +1,516 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: GPL-3.0-only + +use cosmic::app::{message, Command, Core, Settings}; +use cosmic::{executor, iced, widget, Element}; +use greetd_ipc::{codec::SyncCodec, AuthMessageType, Request, Response}; +use std::{collections::HashMap, env, fs, io, path::Path, sync::Arc}; +use tokio::net::UnixStream; + +pub fn main() -> Result<(), Box> { + // The pwd::Passwd method is unsafe (but not labelled as such) due to using global state (libc pwent functions). + let users: Vec<_> = /* unsafe */ { + pwd::Passwd::iter() + .filter(|user| { + if user.uid < 1000 { + // Skip system accounts + return false; + } + + match Path::new(&user.shell).file_name().and_then(|x| x.to_str()) { + // Skip shell ending in false + Some("false") => false, + // Skip shell ending in nologin + Some("nologin") => false, + _ => true, + } + }) + .map(|user| { + let icon_path = Path::new("/var/lib/AccountsService/icons").join(&user.name); + let icon_opt = if icon_path.is_file() { + match fs::read(&icon_path) { + Ok(icon_data) => Some(widget::image::Handle::from_memory(icon_data)), + Err(err) => { + log::error!("failed to read {:?}: {:?}", icon_path, err); + None + } + } + } else { + None + }; + (user, icon_opt) + }) + .collect() + }; + + //TODO: allow custom directories? + let session_dirs = &[ + Path::new("/usr/share/wayland-sessions"), + Path::new("/usr/share/xsessions"), + ]; + + let sessions = { + let mut sessions = HashMap::new(); + for session_dir in session_dirs { + let read_dir = match fs::read_dir(&session_dir) { + Ok(ok) => ok, + Err(err) => { + log::warn!( + "failed to read session directory {:?}: {:?}", + session_dir, + err + ); + continue; + } + }; + + for dir_entry_res in read_dir { + let dir_entry = match dir_entry_res { + Ok(ok) => ok, + Err(err) => { + log::warn!( + "failed to read session directory {:?} entry: {:?}", + session_dir, + err + ); + continue; + } + }; + + let entry = match freedesktop_entry_parser::parse_entry(dir_entry.path()) { + Ok(ok) => ok, + Err(err) => { + log::warn!( + "failed to read session file {:?}: {:?}", + dir_entry.path(), + err + ); + continue; + } + }; + + let name = match entry.section("Desktop Entry").attr("Name") { + Some(some) => some, + None => { + log::warn!( + "failed to read session file {:?}: no Desktop Entry/Name attribute", + dir_entry.path() + ); + continue; + } + }; + + let exec = match entry.section("Desktop Entry").attr("Exec") { + Some(some) => some, + None => { + log::warn!( + "failed to read session file {:?}: no Desktop Entry/Exec attribute", + dir_entry.path() + ); + continue; + } + }; + + let split = match shlex::split(exec) { + Some(some) => some, + None => { + log::warn!( + "failed to parse session file {:?} Exec field {:?}", + dir_entry.path(), + exec + ); + continue; + } + }; + + match sessions.insert(name.to_string(), split) { + Some(some) => { + log::warn!("session overwritten with command {:?}", some); + } + None => {} + } + } + } + sessions + }; + + let flags = Flags { users, sessions }; + + let settings = Settings::default() + .antialiasing(true) + .client_decorations(true) + .debug(false) + .default_icon_theme("Cosmic") + .default_text_size(16.0) + .scale_factor(1.0) + .theme(cosmic::Theme::dark()); + + cosmic::app::run::(settings, flags)?; + + Ok(()) +} + +async fn request_message(socket: Arc, request: Request) -> Message { + //TODO: handle errors + socket.writable().await.unwrap(); + { + let mut bytes = Vec::::new(); + request.write_to(&mut bytes).unwrap(); + socket.try_write(&bytes).unwrap(); + } + + //TODO: handle responses at any time? + loop { + socket.readable().await.unwrap(); + + let mut bytes = Vec::::with_capacity(4096); + match socket.try_read_buf(&mut bytes) { + Ok(0) => break, + Ok(_count) => { + let mut cursor = io::Cursor::new(bytes); + let response = Response::read_from(&mut cursor).unwrap(); + log::info!("{:?}", response); + match response { + Response::AuthMessage { + auth_message_type, + auth_message, + } => match auth_message_type { + AuthMessageType::Secret => { + return Message::Input(InputState::Auth { + prompt: auth_message, + value_opt: Some(String::new()), + secret: true, + }) + } + AuthMessageType::Visible => { + return Message::Input(InputState::Auth { + prompt: auth_message, + value_opt: Some(String::new()), + secret: false, + }) + } + //TODO: treat error type differently? + AuthMessageType::Info | AuthMessageType::Error => { + return Message::Input(InputState::Auth { + prompt: auth_message, + value_opt: None, + secret: false, + }) + } + }, + Response::Error { + error_type: _, + description, + } => { + //TODO: use error_type? + return Message::Error(description); + } + Response::Success => match request { + Request::CreateSession { .. } => { + // User has no auth required, proceed to login + return Message::Login(socket); + } + Request::PostAuthMessageResponse { .. } => { + // All auth is completed, proceed to login + return Message::Login(socket); + } + Request::StartSession { .. } => { + // Session has been started, exit greeter + return Message::Exit; + } + Request::CancelSession => { + //TODO: restart whole process + return Message::None; + } + }, + } + } + Err(err) => match err.kind() { + io::ErrorKind::WouldBlock => continue, + _ => { + log::error!("failed to read socket: {:?}", err); + break; + } + }, + } + } + + Message::None +} + +fn request_command(socket: Arc, request: Request) -> Command { + Command::perform( + async move { message::app(request_message(socket, request).await) }, + |x| x, + ) +} + +#[derive(Clone)] +pub struct Flags { + users: Vec<(pwd::Passwd, Option)>, + sessions: HashMap>, +} + +#[derive(Clone, Debug)] +pub enum SocketState { + /// Opening GREETD_SOCK + Pending, + /// GREETD_SOCK is open + Open(Arc), + /// No GREETD_SOCK variable set + NotSet, + /// Failed to open GREETD_SOCK + Error(Arc), +} + +#[derive(Clone, Debug)] +pub enum InputState { + Username, + Auth { + prompt: String, + value_opt: Option, + secret: bool, + }, +} + +/// Messages that are used specifically by our [`App`]. +#[derive(Clone, Debug)] +pub enum Message { + None, + Socket(SocketState), + Input(InputState), + Session(String), + Error(String), + Username(Arc, String), + Auth(Arc, Option), + Login(Arc), + Exit, +} + +/// The [`App`] stores application-specific state. +pub struct App { + core: Core, + flags: Flags, + socket_state: SocketState, + input_state: InputState, + session_names: Vec, + selected_session: String, + error_opt: Option, + text_input_id: widget::Id, +} + +/// Implement [`cosmic::Application`] to integrate with COSMIC. +impl cosmic::Application for App { + /// Default async executor to use with the app. + type Executor = executor::Default; + + /// Argument received [`cosmic::Application::new`]. + type Flags = Flags; + + /// Message type specific to our [`App`]. + type Message = Message; + + /// The unique application ID to supply to the window manager. + const APP_ID: &'static str = "com.system76.CosmicGreeter"; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + /// Creates the application, and optionally emits command on initialize. + fn init(mut core: Core, flags: Self::Flags) -> (Self, Command) { + core.window.show_window_menu = false; + core.window.show_headerbar = false; + core.window.sharp_corners = true; + core.window.show_maximize = false; + core.window.show_minimize = false; + core.window.use_template = false; + + let mut session_names: Vec<_> = flags.sessions.keys().map(|x| x.to_string()).collect(); + session_names.sort(); + + //TODO: determine default session? + let selected_session = session_names.first().cloned().unwrap_or(String::new()); + + ( + App { + core, + flags, + socket_state: SocketState::Pending, + //TODO: set to pending until socket is open? + input_state: InputState::Username, + session_names, + selected_session, + error_opt: None, + text_input_id: widget::Id::unique(), + }, + Command::perform( + async { + message::app(Message::Socket(match env::var_os("GREETD_SOCK") { + Some(socket_path) => match UnixStream::connect(&socket_path).await { + Ok(socket) => SocketState::Open(Arc::new(socket)), + Err(err) => SocketState::Error(Arc::new(err)), + }, + None => SocketState::NotSet, + })) + }, + |x| x, + ), + ) + } + + /// Handle application events here. + fn update(&mut self, message: Self::Message) -> Command { + match message { + Message::None => {} + Message::Socket(socket_state) => { + self.socket_state = socket_state; + } + Message::Input(input_state) => { + self.input_state = input_state; + //TODO: only focus text input on changes to the page + return widget::text_input::focus(self.text_input_id.clone()); + } + Message::Session(selected_session) => { + self.selected_session = selected_session; + } + Message::Error(error) => { + self.error_opt = Some(error); + } + Message::Username(socket, username) => { + return request_command(socket, Request::CreateSession { username }); + } + Message::Auth(socket, response) => { + return request_command(socket, Request::PostAuthMessageResponse { response }); + } + Message::Login(socket) => { + match self.flags.sessions.get(&self.selected_session).cloned() { + Some(cmd) => { + return request_command( + socket, + Request::StartSession { + cmd, + env: Vec::new(), + }, + ); + } + None => todo!("session {:?} not found", self.selected_session), + } + } + Message::Exit => { + return iced::window::close(); + } + } + Command::none() + } + + /// Creates a view after each update. + fn view(&self) -> Element { + let content: Element<_> = match &self.socket_state { + SocketState::Pending => widget::text("Opening GREETD_SOCK").into(), + SocketState::Open(socket) => match &self.input_state { + InputState::Username => { + let mut row = widget::row::with_capacity(self.flags.users.len()).spacing(12.0); + for (user, icon_opt) in &self.flags.users { + let mut column = widget::column::with_capacity(2).spacing(12.0); + match icon_opt { + Some(icon) => { + column = column.push( + widget::Image::new(icon.clone()) + .width(iced::Length::Fixed(256.0)) + .height(iced::Length::Fixed(256.0)), + ) + } + None => {} + } + match &user.gecos { + Some(gecos) => { + column = column.push(widget::text(gecos)); + } + None => {} + } + row = row.push( + widget::MouseArea::new( + widget::cosmic_container::container(column) + .layer(cosmic::cosmic_theme::Layer::Primary) + .padding(16) + .style(cosmic::theme::Container::Primary), + ) + .on_press(Message::Username(socket.clone(), user.name.clone())), + ); + } + row.into() + } + InputState::Auth { + prompt, + value_opt, + secret, + } => { + let mut column = widget::column::with_capacity(2) + .spacing(12.0) + .width(iced::Length::Fixed(400.0)); + column = column.push(widget::text(prompt)); + + match value_opt { + Some(value) => { + let text_input = widget::text_input("", &value) + .id(self.text_input_id.clone()) + .on_input(|value| { + Message::Input(InputState::Auth { + prompt: prompt.clone(), + value_opt: Some(value), + secret: *secret, + }) + }) + .on_submit(Message::Auth(socket.clone(), Some(value.clone()))); + if *secret { + column = column.push(text_input.password()); + } else { + column = column.push(text_input); + } + } + None => { + column = column.push( + widget::button("Confirm") + .on_press(Message::Auth(socket.clone(), None)), + ); + } + } + + column.into() + } + }, + SocketState::NotSet => widget::text("GREETD_SOCK variable not set").into(), + SocketState::Error(err) => { + widget::text(format!("Failed to open GREETD_SOCK: {:?}", err)).into() + } + }; + + let session_picker = widget::pick_list( + &self.session_names, + Some(self.selected_session.clone()), + Message::Session, + ); + + let mut column = widget::column::with_capacity(3) + .push(content) + .push(session_picker) + .spacing(12.0); + + if let Some(error) = &self.error_opt { + column = column.push(widget::text(error.clone())); + } + + let centered = widget::container(column) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .align_x(iced::alignment::Horizontal::Center) + .align_y(iced::alignment::Vertical::Center); + + Element::from(centered) + } +} diff --git a/src/locker.rs b/src/locker.rs new file mode 100644 index 0000000..ad6b43c --- /dev/null +++ b/src/locker.rs @@ -0,0 +1,296 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: GPL-3.0-only + +use cosmic::app::{message, Command, Core, Settings}; +use cosmic::{ + executor, + iced::{ + self, + futures::{self, SinkExt}, + subscription, Subscription, + }, + widget, Element, +}; +use std::ffi::{CStr, CString}; +use tokio::{sync::mpsc, task, time}; + +pub fn main(current_user: pwd::Passwd) -> Result<(), Box> { + let flags = Flags { current_user }; + + let settings = Settings::default() + .antialiasing(true) + .client_decorations(true) + .debug(false) + .default_icon_theme("Cosmic") + .default_text_size(16.0) + .scale_factor(1.0) + .theme(cosmic::Theme::dark()); + + cosmic::app::run::(settings, flags)?; + + Ok(()) +} + +pub fn pam_thread(username: String, conversation: Conversation) -> Result<(), pam_client::Error> { + //TODO: send errors to GUI, restart process + + // Create PAM context + let mut context = pam_client::Context::new("cosmic-locker", Some(&username), conversation)?; + + // Authenticate the user (ask for password, 2nd-factor token, fingerprint, etc.) + log::info!("authenticate"); + context.authenticate(pam_client::Flag::NONE)?; + + // Validate the account (is not locked, expired, etc.) + log::info!("acct_mgmt"); + context.acct_mgmt(pam_client::Flag::NONE)?; + + Ok(()) +} + +pub struct Conversation { + msg_tx: futures::channel::mpsc::Sender, + value_rx: mpsc::Receiver, +} + +impl Conversation { + fn prompt_value( + &mut self, + prompt_c: &CStr, + secret: bool, + ) -> Result { + let prompt = prompt_c.to_str().map_err(|err| { + log::error!("failed to convert prompt to UTF-8: {:?}", err); + pam_client::ErrorCode::CONV_ERR + })?; + + futures::executor::block_on(async { + self.msg_tx + .send(Message::Prompt(prompt.to_string(), secret, String::new())) + .await + }) + .map_err(|err| { + log::error!("failed to send prompt: {:?}", err); + pam_client::ErrorCode::CONV_ERR + })?; + + let value = self.value_rx.blocking_recv().ok_or_else(|| { + log::error!("failed to receive value: channel closed"); + pam_client::ErrorCode::CONV_ERR + })?; + + CString::new(value).map_err(|err| { + log::error!("failed to convert value to C string: {:?}", err); + pam_client::ErrorCode::CONV_ERR + }) + } +} + +impl pam_client::ConversationHandler for Conversation { + fn prompt_echo_on(&mut self, prompt_c: &CStr) -> Result { + log::info!("prompt_echo_on {:?}", prompt_c); + self.prompt_value(prompt_c, false) + } + fn prompt_echo_off(&mut self, prompt_c: &CStr) -> Result { + log::info!("prompt_echo_off {:?}", prompt_c); + self.prompt_value(prompt_c, true) + } + fn text_info(&mut self, msg: &CStr) { + log::warn!("TODO text_info: {:?}", msg); + } + fn error_msg(&mut self, msg: &CStr) { + log::info!("TODO error_msg: {:?}", msg); + } +} + +#[derive(Clone)] +pub struct Flags { + current_user: pwd::Passwd, +} + +/// Messages that are used specifically by our [`App`]. +#[derive(Clone, Debug)] +pub enum Message { + Channel(mpsc::Sender), + Prompt(String, bool, String), + Submit, + Error(String), + Exit, +} + +/// The [`App`] stores application-specific state. +pub struct App { + core: Core, + flags: Flags, + value_tx_opt: Option>, + prompt_opt: Option<(String, bool, String)>, + error_opt: Option, + text_input_id: widget::Id, + exited: bool, +} + +/// Implement [`cosmic::Application`] to integrate with COSMIC. +impl cosmic::Application for App { + /// Default async executor to use with the app. + type Executor = executor::Default; + + /// Argument received [`cosmic::Application::new`]. + type Flags = Flags; + + /// Message type specific to our [`App`]. + type Message = Message; + + /// The unique application ID to supply to the window manager. + const APP_ID: &'static str = "com.system76.CosmicGreeter"; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + /// Creates the application, and optionally emits command on initialize. + fn init(mut core: Core, flags: Self::Flags) -> (Self, Command) { + core.window.show_window_menu = false; + core.window.show_headerbar = false; + core.window.sharp_corners = true; + core.window.show_maximize = false; + core.window.show_minimize = false; + core.window.use_template = false; + + ( + App { + core, + flags, + value_tx_opt: None, + prompt_opt: None, + error_opt: None, + text_input_id: widget::Id::unique(), + exited: false, + }, + Command::none(), + ) + } + + /// Handle application events here. + fn update(&mut self, message: Self::Message) -> Command { + match message { + Message::Channel(value_tx) => { + self.value_tx_opt = Some(value_tx); + } + Message::Prompt(prompt, secret, value) => { + self.prompt_opt = Some((prompt, secret, value)); + //TODO: only focus text input on changes to the page + return widget::text_input::focus(self.text_input_id.clone()); + } + Message::Submit => match self.prompt_opt.take() { + Some((_prompt, _secret, value)) => match self.value_tx_opt.take() { + Some(value_tx) => { + return Command::perform( + async move { + value_tx.send(value).await.unwrap(); + message::app(Message::Channel(value_tx)) + }, + |x| x, + ); + } + None => log::warn!("tried to submit when value_tx_opt not set"), + }, + None => log::warn!("tried to submit without prompt"), + }, + Message::Error(error) => { + self.error_opt = Some(error); + } + Message::Exit => { + self.exited = true; + return iced::window::close(); + } + } + Command::none() + } + + /// Creates a view after each update. + fn view(&self) -> Element { + let mut column = widget::column::with_capacity(3).spacing(12.0); + + match &self.prompt_opt { + Some((prompt, secret, value)) => { + column = column.push(widget::text(prompt.clone())); + + let mut text_input = widget::text_input("", &value) + .id(self.text_input_id.clone()) + .on_input(|value| Message::Prompt(prompt.clone(), *secret, value)) + .on_submit(Message::Submit); + + if *secret { + text_input = text_input.password() + } + + column = column.push(text_input); + } + None => {} + } + + if let Some(error) = &self.error_opt { + column = column.push(widget::text(error)); + } + + let centered = widget::container(column) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .align_x(iced::alignment::Horizontal::Center) + .align_y(iced::alignment::Vertical::Center); + + Element::from(centered) + } + + fn subscription(&self) -> Subscription { + if self.exited { + return Subscription::none(); + } + + struct SomeWorker; + + //TODO: how to avoid cloning this on every time subscription is called? + let username = self.flags.current_user.name.clone(); + subscription::channel( + std::any::TypeId::of::(), + 16, + |mut msg_tx| async move { + loop { + let (value_tx, value_rx) = mpsc::channel(16); + msg_tx.send(Message::Channel(value_tx)).await.unwrap(); + + let pam_res = { + let username = username.clone(); + let msg_tx = msg_tx.clone(); + task::spawn_blocking(move || { + pam_thread(username, Conversation { msg_tx, value_rx }) + }) + .await + .unwrap() + }; + + match pam_res { + Ok(()) => { + log::info!("successfully authenticated"); + msg_tx.send(Message::Exit).await.unwrap(); + break; + } + Err(err) => { + log::info!("authentication error: {:?}", err); + msg_tx.send(Message::Error(err.to_string())).await.unwrap(); + } + } + } + + //TODO: how to properly kill this task? + loop { + time::sleep(time::Duration::new(1, 0)).await; + } + }, + ) + } +} diff --git a/src/main.rs b/src/main.rs index a8c26d3..e0e69a4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,525 +1,17 @@ // Copyright 2023 System76 -// SPDX-License-Identifier: MPL-2.0 +// SPDX-License-Identifier: GPL-3.0-only -//! Application API example - -use cosmic::app::{message, Command, Core, Settings}; -use cosmic::{executor, iced, widget, Element}; -use greetd_ipc::{codec::SyncCodec, AuthMessageType, Request, Response}; -use std::{collections::HashMap, env, fs, io, path::Path, sync::Arc}; -use tokio::net::UnixStream; +mod greeter; +mod locker; fn main() -> Result<(), Box> { env_logger::init(); - // The pwd::Passwd method is unsafe (but not labelled as such) due to using global state (libc pwent functions). - let users: Vec<_> = /* unsafe */ { - pwd::Passwd::iter() - .filter(|user| { - if user.uid < 1000 { - // Skip system accounts - return false; - } - - match Path::new(&user.shell).file_name().and_then(|x| x.to_str()) { - // Skip shell ending in false - Some("false") => false, - // Skip shell ending in nologin - Some("nologin") => false, - _ => true, - } - }) - .map(|user| { - let icon_path = Path::new("/var/lib/AccountsService/icons").join(&user.name); - let icon_opt = if icon_path.is_file() { - match fs::read(&icon_path) { - Ok(icon_data) => Some(widget::image::Handle::from_memory(icon_data)), - Err(err) => { - log::error!("failed to read {:?}: {:?}", icon_path, err); - None - } - } - } else { - None - }; - (user, icon_opt) - }) - .collect() - }; - - //TODO: allow custom directories? - let session_dirs = &[ - Path::new("/usr/share/wayland-sessions"), - Path::new("/usr/share/xsessions"), - ]; - - let sessions = { - let mut sessions = HashMap::new(); - for session_dir in session_dirs { - let read_dir = match fs::read_dir(&session_dir) { - Ok(ok) => ok, - Err(err) => { - log::warn!( - "failed to read session directory {:?}: {:?}", - session_dir, - err - ); - continue; - } - }; - - for dir_entry_res in read_dir { - let dir_entry = match dir_entry_res { - Ok(ok) => ok, - Err(err) => { - log::warn!( - "failed to read session directory {:?} entry: {:?}", - session_dir, - err - ); - continue; - } - }; - - let entry = match freedesktop_entry_parser::parse_entry(dir_entry.path()) { - Ok(ok) => ok, - Err(err) => { - log::warn!( - "failed to read session file {:?}: {:?}", - dir_entry.path(), - err - ); - continue; - } - }; - - let name = match entry.section("Desktop Entry").attr("Name") { - Some(some) => some, - None => { - log::warn!( - "failed to read session file {:?}: no Desktop Entry/Name attribute", - dir_entry.path() - ); - continue; - } - }; - - let exec = match entry.section("Desktop Entry").attr("Exec") { - Some(some) => some, - None => { - log::warn!( - "failed to read session file {:?}: no Desktop Entry/Exec attribute", - dir_entry.path() - ); - continue; - } - }; - - let split = match shlex::split(exec) { - Some(some) => some, - None => { - log::warn!( - "failed to parse session file {:?} Exec field {:?}", - dir_entry.path(), - exec - ); - continue; - } - }; - - match sessions.insert(name.to_string(), split) { - Some(some) => { - log::warn!("session overwritten with command {:?}", some); - } - None => {} - } - } - } - sessions - }; - - let flags = Flags { users, sessions }; - - let settings = Settings::default() - .antialiasing(true) - .client_decorations(true) - .debug(false) - .default_icon_theme("Cosmic") - .default_text_size(16.0) - .scale_factor(1.0) - .theme(cosmic::Theme::dark()); - - cosmic::app::run::(settings, flags)?; - - Ok(()) -} - -async fn request_message(socket: Arc, request: Request) -> Message { - //TODO: handle errors - socket.writable().await.unwrap(); - { - let mut bytes = Vec::::new(); - request.write_to(&mut bytes).unwrap(); - socket.try_write(&bytes).unwrap(); - } - - //TODO: handle responses at any time? - loop { - socket.readable().await.unwrap(); - - let mut bytes = Vec::::with_capacity(4096); - match socket.try_read_buf(&mut bytes) { - Ok(0) => break, - Ok(_count) => { - let mut cursor = io::Cursor::new(bytes); - let response = Response::read_from(&mut cursor).unwrap(); - log::info!("{:?}", response); - match response { - Response::AuthMessage { - auth_message_type, - auth_message, - } => match auth_message_type { - AuthMessageType::Secret => { - return Message::Input(InputState::Auth { - prompt: auth_message, - value_opt: Some(String::new()), - secret: true, - }) - } - AuthMessageType::Visible => { - return Message::Input(InputState::Auth { - prompt: auth_message, - value_opt: Some(String::new()), - secret: false, - }) - } - //TODO: treat error type differently? - AuthMessageType::Info | AuthMessageType::Error => { - return Message::Input(InputState::Auth { - prompt: auth_message, - value_opt: None, - secret: false, - }) - } - }, - Response::Error { - error_type: _, - description, - } => { - //TODO: use error_type? - return Message::Error(description); - } - Response::Success => match request { - Request::CreateSession { .. } => { - // User has no auth required, proceed to login - return Message::Login(socket); - } - Request::PostAuthMessageResponse { .. } => { - // All auth is completed, proceed to login - return Message::Login(socket); - } - Request::StartSession { .. } => { - // Session has been started, exit greeter - return Message::Exit; - } - Request::CancelSession => { - //TODO: restart whole process - return Message::None; - } - }, - } - } - Err(err) => match err.kind() { - io::ErrorKind::WouldBlock => continue, - _ => { - log::error!("failed to read socket: {:?}", err); - break; - } - }, - } - } - - Message::None -} - -fn request_command(socket: Arc, request: Request) -> Command { - Command::perform( - async move { message::app(request_message(socket, request).await) }, - |x| x, - ) -} - -#[derive(Clone)] -pub struct Flags { - users: Vec<(pwd::Passwd, Option)>, - sessions: HashMap>, -} - -#[derive(Clone, Debug)] -pub enum SocketState { - /// Opening GREETD_SOCK - Pending, - /// GREETD_SOCK is open - Open(Arc), - /// No GREETD_SOCK variable set - NotSet, - /// Failed to open GREETD_SOCK - Error(Arc), -} - -#[derive(Clone, Debug)] -pub enum InputState { - None, - Username, - Auth { - prompt: String, - value_opt: Option, - secret: bool, - }, -} - -/// Messages that are used specifically by our [`App`]. -#[derive(Clone, Debug)] -pub enum Message { - None, - Socket(SocketState), - Input(InputState), - Session(String), - Error(String), - Username(Arc, String), - Auth(Arc, Option), - Login(Arc), - Exit, -} - -/// The [`App`] stores application-specific state. -pub struct App { - core: Core, - flags: Flags, - socket_state: SocketState, - input_state: InputState, - session_names: Vec, - selected_session: String, - error_opt: Option, - text_input_id: widget::Id, -} - -/// Implement [`cosmic::Application`] to integrate with COSMIC. -impl cosmic::Application for App { - /// Default async executor to use with the app. - type Executor = executor::Default; - - /// Argument received [`cosmic::Application::new`]. - type Flags = Flags; - - /// Message type specific to our [`App`]. - type Message = Message; - - /// The unique application ID to supply to the window manager. - const APP_ID: &'static str = "com.system76.CosmicGreeter"; - - fn core(&self) -> &Core { - &self.core - } - - fn core_mut(&mut self) -> &mut Core { - &mut self.core - } - - /// Creates the application, and optionally emits command on initialize. - fn init(mut core: Core, flags: Self::Flags) -> (Self, Command) { - core.window.show_window_menu = false; - core.window.show_headerbar = false; - core.window.sharp_corners = true; - core.window.show_maximize = false; - core.window.show_minimize = false; - core.window.use_template = false; - - let mut session_names: Vec<_> = flags.sessions.keys().map(|x| x.to_string()).collect(); - session_names.sort(); - - //TODO: determine default session? - let selected_session = session_names.first().cloned().unwrap_or(String::new()); - - ( - App { - core, - flags, - socket_state: SocketState::Pending, - //TODO: set to pending until socket is open? - input_state: InputState::Username, - session_names, - selected_session, - error_opt: None, - text_input_id: widget::Id::unique(), - }, - Command::perform( - async { - message::app(Message::Socket(match env::var_os("GREETD_SOCK") { - Some(socket_path) => match UnixStream::connect(&socket_path).await { - Ok(socket) => SocketState::Open(Arc::new(socket)), - Err(err) => SocketState::Error(Arc::new(err)), - }, - None => SocketState::NotSet, - })) - }, - |x| x, - ), - ) - } - - /// Handle application events here. - fn update(&mut self, message: Self::Message) -> Command { - match message { - Message::None => {} - Message::Socket(socket_state) => { - self.socket_state = socket_state; - } - Message::Input(input_state) => { - self.input_state = input_state; - //TODO: only focus text input on changes to the page - return widget::text_input::focus(self.text_input_id.clone()); - } - Message::Session(selected_session) => { - self.selected_session = selected_session; - } - Message::Error(error) => { - self.error_opt = Some(error); - } - Message::Username(socket, username) => { - return request_command(socket, Request::CreateSession { username }); - } - Message::Auth(socket, response) => { - return request_command(socket, Request::PostAuthMessageResponse { response }); - } - Message::Login(socket) => { - match self.flags.sessions.get(&self.selected_session).cloned() { - Some(cmd) => { - return request_command( - socket, - Request::StartSession { - cmd, - env: Vec::new(), - }, - ); - } - None => todo!("session {:?} not found", self.selected_session), - } - } - Message::Exit => { - return iced::window::close(); - } - } - Command::none() - } - - /// Creates a view after each update. - fn view(&self) -> Element { - let content: Element<_> = match &self.socket_state { - SocketState::Pending => widget::text("Opening GREETD_SOCK").into(), - SocketState::Open(socket) => match &self.input_state { - InputState::None => { - //TODO - widget::text("").into() - } - InputState::Username => { - let mut row = widget::row::with_capacity(self.flags.users.len()).spacing(12.0); - for (user, icon_opt) in &self.flags.users { - let mut column = widget::column::with_capacity(2).spacing(12.0); - match icon_opt { - Some(icon) => { - column = column.push( - widget::Image::new(icon.clone()) - .width(iced::Length::Fixed(256.0)) - .height(iced::Length::Fixed(256.0)), - ) - } - None => {} - } - match &user.gecos { - Some(gecos) => { - column = column.push(widget::text(gecos)); - } - None => {} - } - row = row.push( - widget::MouseArea::new( - widget::cosmic_container::container(column) - .layer(cosmic::cosmic_theme::Layer::Primary) - .padding(16) - .style(cosmic::theme::Container::Primary), - ) - .on_press(Message::Username(socket.clone(), user.name.clone())), - ); - } - row.into() - } - InputState::Auth { - prompt, - value_opt, - secret, - } => { - let mut column = widget::column::with_capacity(2) - .spacing(12.0) - .width(iced::Length::Fixed(400.0)); - column = column.push(widget::text(prompt)); - - match value_opt { - Some(value) => { - let text_input = widget::text_input("", &value) - .id(self.text_input_id.clone()) - .on_input(|value| { - Message::Input(InputState::Auth { - prompt: prompt.clone(), - value_opt: Some(value), - secret: *secret, - }) - }) - .on_submit(Message::Auth(socket.clone(), Some(value.clone()))); - if *secret { - column = column.push(text_input.password()); - } else { - column = column.push(text_input); - } - } - None => { - column = column.push( - widget::button("Confirm") - .on_press(Message::Auth(socket.clone(), None)), - ); - } - } - - column.into() - } - }, - SocketState::NotSet => widget::text("GREETD_SOCK variable not set").into(), - SocketState::Error(err) => { - widget::text(format!("Failed to open GREETD_SOCK: {:?}", err)).into() - } - }; - - let session_picker = widget::pick_list( - &self.session_names, - Some(self.selected_session.clone()), - Message::Session, - ); - - let mut column = widget::column::with_capacity(3) - .push(content) - .push(session_picker) - .spacing(12.0); - - if let Some(error) = &self.error_opt { - column = column.push(widget::text(error.clone())); - } - - let centered = widget::container(column) - .width(iced::Length::Fill) - .height(iced::Length::Fill) - .align_x(iced::alignment::Horizontal::Center) - .align_y(iced::alignment::Vertical::Center); - - Element::from(centered) + match pwd::Passwd::current_user() { + Some(current_user) => match current_user.name.as_str() { + "greeter" => greeter::main(), + _ => locker::main(current_user), + }, + _ => Err("failed to determine current user".into()), } }