diff --git a/rust/Cargo.lock b/rust/Cargo.lock index a9ac17634e..a16bc51ca7 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -30,7 +30,6 @@ dependencies = [ "convert_case", "curl", "fs_extra", - "home", "indicatif", "log", "nix 0.27.1", @@ -62,10 +61,13 @@ dependencies = [ "agama-settings", "anyhow", "async-trait", + "chrono", "cidr", "curl", "futures-util", + "home", "jsonschema", + "jsonwebtoken", "log", "reqwest 0.12.4", "serde", @@ -713,9 +715,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.35" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf5903dcbc0a39312feb77df2ff4c76387d591b9fc7b04a238dcf8bb62639a" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ "android-tzdata", "iana-time-zone", diff --git a/rust/agama-cli/Cargo.toml b/rust/agama-cli/Cargo.toml index 772ba7e6e1..2e5007ded4 100644 --- a/rust/agama-cli/Cargo.toml +++ b/rust/agama-cli/Cargo.toml @@ -27,7 +27,6 @@ zbus = { version = "3", default-features = false, features = ["tokio"] } tokio = { version = "1.33.0", features = ["macros", "rt-multi-thread"] } async-trait = "0.1.77" reqwest = { version = "0.11", features = ["json"] } -home = "0.5.9" rpassword = "7.3.1" url = "2.5.0" diff --git a/rust/agama-cli/src/auth.rs b/rust/agama-cli/src/auth.rs index b922b8a68c..7ce7d35f13 100644 --- a/rust/agama-cli/src/auth.rs +++ b/rust/agama-cli/src/auth.rs @@ -1,19 +1,11 @@ +use agama_lib::auth::AuthToken; use clap::Subcommand; +use crate::error::CliError; use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE}; -use std::fs; -use std::fs::File; use std::io::{self, IsTerminal}; -use std::io::{BufRead, BufReader}; -use std::os::unix::fs::PermissionsExt; -use std::path::{Path, PathBuf}; - -use crate::error::CliError; -const DEFAULT_JWT_FILE: &str = ".agama/agama-jwt"; -const DEFAULT_AGAMA_TOKEN_FILE: &str = "/run/agama/token"; const DEFAULT_AUTH_URL: &str = "http://localhost/api/auth"; -const DEFAULT_FILE_MODE: u32 = 0o600; #[derive(Subcommand, Debug)] pub enum AuthCommands { @@ -38,28 +30,6 @@ pub async fn run(subcommand: AuthCommands) -> anyhow::Result<()> { } } -/// Returns the stored Agama token. -pub fn agama_token() -> anyhow::Result { - if let Some(file) = agama_token_file() { - if let Ok(token) = read_line_from_file(file.as_path()) { - return Ok(token); - } - } - - Err(anyhow::anyhow!("Authentication token not available")) -} - -/// Reads stored token and returns it -pub fn jwt() -> anyhow::Result { - if let Some(file) = jwt_file() { - if let Ok(token) = read_line_from_file(file.as_path()) { - return Ok(token); - } - } - - Err(anyhow::anyhow!("Authentication token not available")) -} - /// Reads the password /// /// It reads the password from stdin if available; otherwise, it asks the @@ -76,53 +46,6 @@ fn read_password() -> Result { Ok(password) } -/// Path to file where JWT is stored -fn jwt_file() -> Option { - Some(home::home_dir()?.join(DEFAULT_JWT_FILE)) -} -/// Path to agama-live token file. -fn agama_token_file() -> Option { - home::home_dir().map(|p| p.join(DEFAULT_AGAMA_TOKEN_FILE)) -} - -/// Reads first line from given file -fn read_line_from_file(path: &Path) -> io::Result { - if !path.exists() { - return Err(io::Error::new( - io::ErrorKind::Other, - "Cannot find the file containing the credentials.", - )); - } - - if let Ok(file) = File::open(path) { - // cares only of first line, take everything. No comments - // or something like that supported - let raw = BufReader::new(file).lines().next(); - - if let Some(line) = raw { - return line; - } - } - - Err(io::Error::new( - io::ErrorKind::Other, - "Failed to open the file", - )) -} - -/// Sets the archive owner to root:root. Also sets the file permissions to read/write for the -/// owner only. -fn set_file_permissions(file: &Path) -> io::Result<()> { - let attr = fs::metadata(file)?; - let mut permissions = attr.permissions(); - - // set the file file permissions to -rw------- - permissions.set_mode(DEFAULT_FILE_MODE); - fs::set_permissions(file, permissions)?; - - Ok(()) -} - /// Necessary http request header for authenticate fn authenticate_headers() -> HeaderMap { let mut headers = HeaderMap::new(); @@ -157,44 +80,21 @@ async fn get_jwt(url: String, password: String) -> anyhow::Result { async fn login(password: String) -> anyhow::Result<()> { // 1) ask web server for JWT let res = get_jwt(DEFAULT_AUTH_URL.to_string(), password).await?; - - // 2) if successful store the JWT for later use - if let Some(path) = jwt_file() { - if let Some(dir) = path.parent() { - fs::create_dir_all(dir)?; - } else { - return Err(anyhow::anyhow!("Cannot store the authentication token")); - } - - fs::write(path.as_path(), res)?; - set_file_permissions(path.as_path())?; - } - - Ok(()) + let token = AuthToken::new(&res); + Ok(token.write_user_token()?) } /// Releases JWT fn logout() -> anyhow::Result<()> { - let path = jwt_file(); - - if !&path.clone().is_some_and(|p| p.exists()) { - // mask if the file with the JWT doesn't exist (most probably no login before logout) - return Ok(()); - } - - // panicking is right thing to do if expect fails, becase it was already checked twice that - // the path exists - let file = path.expect("Cannot locate stored JWT"); - - Ok(fs::remove_file(file)?) + Ok(AuthToken::remove_user_token()?) } /// Shows stored JWT on stdout fn show() -> anyhow::Result<()> { // we do not care if jwt() fails or not. If there is something to print, show it otherwise // stay silent - if let Ok(token) = jwt() { - println!("{}", token); + if let Some(token) = AuthToken::find() { + println!("{}", token.as_str()); } Ok(()) diff --git a/rust/agama-cli/src/config.rs b/rust/agama-cli/src/config.rs index a2a65cb3c7..eae517a449 100644 --- a/rust/agama-cli/src/config.rs +++ b/rust/agama-cli/src/config.rs @@ -1,9 +1,9 @@ use crate::{ - auth, error::CliError, printers::{print, Format}, }; use agama_lib::{ + auth::AuthToken, connection, install_settings::{InstallSettings, Scope}, Store as SettingsStore, @@ -53,17 +53,13 @@ pub enum ConfigAction { Load(String), } -fn token() -> Option { - auth::jwt().or_else(|_| auth::agama_token()).ok() -} - pub async fn run(subcommand: ConfigCommands, format: Format) -> anyhow::Result<()> { - let Some(token) = token() else { + let Some(token) = AuthToken::find() else { println!("You need to login for generating a valid token"); return Ok(()); }; - let client = agama_lib::http_client(token)?; + let client = agama_lib::http_client(token.as_str())?; let store = SettingsStore::new(connection().await?, client).await?; let command = parse_config_command(subcommand)?; diff --git a/rust/agama-lib/Cargo.toml b/rust/agama-lib/Cargo.toml index dc0020c419..1d67eb918a 100644 --- a/rust/agama-lib/Cargo.toml +++ b/rust/agama-lib/Cargo.toml @@ -26,3 +26,6 @@ utoipa = "4.2.0" zbus = { version = "3", default-features = false, features = ["tokio"] } # Needed to define curl error in profile errors curl = { version = "0.4.44", features = ["protocol-ftp"] } +jsonwebtoken = "9.3.0" +chrono = { version = "0.4.38", default-features = false, features = ["now", "std", "alloc", "clock"] } +home = "0.5.9" diff --git a/rust/agama-lib/src/auth.rs b/rust/agama-lib/src/auth.rs new file mode 100644 index 0000000000..ab48f8a399 --- /dev/null +++ b/rust/agama-lib/src/auth.rs @@ -0,0 +1,219 @@ +//! This module implements an API to deal with authentication tokens. +//! +//! Agama web server relies on JSON Web Tokens (JWT) for authentication purposes. +//! This module implements a simple API to perform the common operations. +//! +//! ## The master token +//! +//! When Agama web server starts, it writes a master token (which is just a regular +//! JWT) in `/run/agama/token`. That file is only readable by the root user and +//! can be used by any Agama component. +//! +//! ## The user token +//! +//! When a user does not have access to the master token it needs to authenticate +//! with the server. In that process, it obtains a new token that should be stored +//! in user's home directory (`$HOME/.local/agama/token`). +//! +//! ## A simplistic API +//! +//! The current API is rather limited and it does not support, for instance, +//! keeping tokens for different servers. We might extend this API if needed +//! in the future. + +const USER_TOKEN_PATH: &str = ".local/agama/token"; +const AGAMA_TOKEN_FILE: &str = "/run/agama/token"; + +use std::{ + fmt::Display, + fs::{self, File}, + io::{self, BufRead, BufReader, Write}, + os::unix::fs::OpenOptionsExt, + path::{Path, PathBuf}, +}; + +use chrono::{Duration, Utc}; +use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Error, Debug)] +#[error("Invalid authentication token: {0}")] +pub struct AuthTokenError(#[from] jsonwebtoken::errors::Error); + +/// Represents an authentication token (JWT). +pub struct AuthToken(String); + +impl AuthToken { + /// Creates a new token with the given content. + /// + /// * `content`: token raw content. + pub fn new(content: &str) -> Self { + Self(content.to_string()) + } + + /// Generates a new token using the given secret. + /// + /// * `secret`: secret to encode the token. + pub fn generate(secret: &str) -> Result { + let claims = TokenClaims::default(); + let token = jsonwebtoken::encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_ref()), + )?; + Ok(AuthToken(token)) + } + + /// Finds an usable token for the current user. + /// + /// It searches for a token in user's home directory and, if it does not exists, + /// it tries to read the master token. + pub fn find() -> Option { + if let Ok(path) = Self::user_token_path() { + if let Ok(token) = Self::read(path) { + return Some(token); + } + } + + Self::read(AGAMA_TOKEN_FILE).ok() + } + + /// Reads the token from the given path. + /// + /// * `path`: file's path to read the token from. + pub fn read>(path: P) -> io::Result { + let file = File::open(path)?; + let mut reader = BufReader::new(file); + let mut buf = String::new(); + reader.read_line(&mut buf)?; + Ok(AuthToken(buf)) + } + + /// Writes the token to the given path. + /// + /// It takes care of setting the right permissions (0400). + /// + /// * `path`: file's path to write the token to. + pub fn write>(&self, path: P) -> io::Result<()> { + if let Some(parent) = path.as_ref().parent() { + std::fs::create_dir_all(parent)?; + } + let mut file = fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o400) + .open(path)?; + file.write_all(self.0.as_bytes())?; + Ok(()) + } + + /// Removes the user token. + pub fn remove_user_token() -> io::Result<()> { + let path = Self::user_token_path()?; + if path.exists() { + fs::remove_file(path)?; + } + Ok(()) + } + + /// Returns the claims from the token. + /// + /// * `secret`: secret to decode the token. + pub fn claims(&self, secret: &str) -> Result { + let decoding = DecodingKey::from_secret(secret.as_ref()); + let token_data = jsonwebtoken::decode(&self.0, &decoding, &Validation::default())?; + Ok(token_data.claims) + } + + /// Returns a reference to the token's content. + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + /// Writes the token to the user's home directory. + pub fn write_user_token(&self) -> io::Result<()> { + let path = Self::user_token_path()?; + self.write(path) + } + + /// Writes the token to Agama's run directory. + /// + /// For this function to succeed the run directory should exist and the user needs write + /// permissions. + pub fn write_master_token(&self) -> io::Result<()> { + self.write(AGAMA_TOKEN_FILE) + } + + fn user_token_path() -> io::Result { + let Some(path) = home::home_dir() else { + return Err(io::Error::new( + io::ErrorKind::Other, + "Cannot find the user's home directory", + )); + }; + + Ok(path.join(USER_TOKEN_PATH)) + } +} + +impl Display for AuthToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Claims that are included in the token. +/// +/// See https://datatracker.ietf.org/doc/html/rfc7519 for reference. +#[derive(Debug, Serialize, Deserialize)] +pub struct TokenClaims { + pub exp: i64, +} + +impl Default for TokenClaims { + fn default() -> Self { + let mut exp = Utc::now(); + + if let Some(days) = Duration::try_days(1) { + exp += days; + } + + Self { + exp: exp.timestamp(), + } + } +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::AuthToken; + + #[test] + fn test_generate_token() { + let token = AuthToken::generate("nots3cr3t").unwrap(); + let decoded = token.claims("nots3cr3t"); + assert!(decoded.is_ok()); + + let wrong = token.claims("wrong"); + assert!(wrong.is_err()) + } + + #[test] + fn test_write_and_read_token() { + // let token = AuthToken::from_path>(path: P) + // let token = AuthToken::from_path() + let token = AuthToken::generate("nots3cr3t").unwrap(); + + let tmp_dir = tempdir().unwrap(); + let path = tmp_dir.path().join("token"); + token.write(&path).unwrap(); + + let read_token = AuthToken::read(&path).unwrap(); + let decoded = read_token.claims("nots3cr3t"); + assert!(decoded.is_ok()); + } +} diff --git a/rust/agama-lib/src/lib.rs b/rust/agama-lib/src/lib.rs index 05869aa81b..6435517803 100644 --- a/rust/agama-lib/src/lib.rs +++ b/rust/agama-lib/src/lib.rs @@ -23,6 +23,7 @@ //! //! As said, those modules might implement additional stuff, like specific types, clients, etc. +pub mod auth; pub mod error; pub mod install_settings; pub mod localization; @@ -57,7 +58,7 @@ pub async fn connection_to(address: &str) -> Result Result { +pub fn http_client(token: &str) -> Result { let mut headers = header::HeaderMap::new(); let value = header::HeaderValue::from_str(format!("Bearer {}", token).as_str()) .map_err(|e| ServiceError::NetworkClientError(e.to_string()))?; diff --git a/rust/agama-server/src/agama-web-server.rs b/rust/agama-server/src/agama-web-server.rs index fcc025df93..1cf0d150b4 100644 --- a/rust/agama-server/src/agama-web-server.rs +++ b/rust/agama-server/src/agama-web-server.rs @@ -1,17 +1,14 @@ use std::{ - fs, - io::{self, Write}, - os::unix::fs::OpenOptionsExt, path::{Path, PathBuf}, pin::Pin, process::{ExitCode, Termination}, }; -use agama_lib::connection_to; +use agama_lib::{auth::AuthToken, connection_to}; use agama_server::{ l10n::helpers, logs::init_logging, - web::{self, generate_token, run_monitor}, + web::{self, run_monitor}, }; use anyhow::Context; use axum::{ @@ -349,16 +346,9 @@ async fn run_command(cli: Cli) -> anyhow::Result<()> { } } -fn write_token(path: &str, secret: &str) -> io::Result<()> { - let token = generate_token(secret); - let mut file = fs::OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .mode(0o400) - .open(path)?; - file.write_all(token.as_bytes())?; - Ok(()) +fn write_token(path: &str, secret: &str) -> anyhow::Result<()> { + let token = AuthToken::generate(secret)?; + Ok(token.write(path)?) } /// Represents the result of execution. diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index b321228b34..1ea0026552 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -28,7 +28,6 @@ mod state; mod ws; use agama_lib::{connection, error::ServiceError}; -pub use auth::generate_token; pub use config::ServiceConfig; pub use docs::ApiDoc; pub use event::{Event, EventsReceiver, EventsSender}; diff --git a/rust/agama-server/src/web/auth.rs b/rust/agama-server/src/web/auth.rs index 06f807e883..0e68145fb1 100644 --- a/rust/agama-server/src/web/auth.rs +++ b/rust/agama-server/src/web/auth.rs @@ -1,6 +1,7 @@ //! Contains the code to handle access authorization. use super::state::ServiceState; +use agama_lib::auth::{AuthToken, AuthTokenError, TokenClaims}; use async_trait::async_trait; use axum::{ extract::FromRequestParts, @@ -12,10 +13,7 @@ use axum_extra::{ headers::{self, authorization::Bearer}, TypedHeader, }; -use chrono::{Duration, Utc}; -use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; use pam::PamError; -use serde::{Deserialize, Serialize}; use serde_json::json; use thiserror::Error; @@ -27,7 +25,7 @@ pub enum AuthError { MissingToken, /// The authentication error is invalid. #[error("Invalid authentication token: {0}")] - InvalidToken(#[from] jsonwebtoken::errors::Error), + InvalidToken(#[from] AuthTokenError), /// The authentication failed (most probably the password is wrong) #[error("Authentication via PAM failed: {0}")] Failed(#[from] PamError), @@ -42,40 +40,6 @@ impl IntoResponse for AuthError { } } -/// Claims that are included in the token. -/// -/// See https://datatracker.ietf.org/doc/html/rfc7519 for reference. -#[derive(Debug, Serialize, Deserialize)] -pub struct TokenClaims { - exp: i64, -} - -impl TokenClaims { - /// Builds claims for a given token. - /// - /// * `token`: token to extract the claims from. - /// * `secret`: secret to decode the token. - pub fn from_token(token: &str, secret: &str) -> Result { - let decoding = DecodingKey::from_secret(secret.as_ref()); - let token_data = jsonwebtoken::decode(token, &decoding, &Validation::default())?; - Ok(token_data.claims) - } -} - -impl Default for TokenClaims { - fn default() -> Self { - let mut exp = Utc::now(); - - if let Some(days) = Duration::try_days(1) { - exp += days; - } - - Self { - exp: exp.timestamp(), - } - } -} - #[async_trait] impl FromRequestParts for TokenClaims { type Rejection = AuthError; @@ -101,19 +65,7 @@ impl FromRequestParts for TokenClaims { } }; - TokenClaims::from_token(&token, &state.config.jwt_secret) + let token = AuthToken::new(&token); + Ok(token.claims(&state.config.jwt_secret)?) } } - -/// Generates a JWT. -/// -/// - `secret`: secret to encrypt/sign the token. -pub fn generate_token(secret: &str) -> String { - let claims = TokenClaims::default(); - jsonwebtoken::encode( - &Header::default(), - &claims, - &EncodingKey::from_secret(secret.as_ref()), - ) - .unwrap() -} diff --git a/rust/agama-server/src/web/http.rs b/rust/agama-server/src/web/http.rs index 92a89956ed..d306a290b5 100644 --- a/rust/agama-server/src/web/http.rs +++ b/rust/agama-server/src/web/http.rs @@ -1,9 +1,7 @@ //! Implements the basic handlers for the HTTP-based API (login, logout, ping, etc.). -use super::{ - auth::{generate_token, AuthError, TokenClaims}, - state::ServiceState, -}; +use super::{auth::AuthError, state::ServiceState}; +use agama_lib::auth::{AuthToken, TokenClaims}; use axum::{ body::Body, extract::{Query, State}, @@ -56,9 +54,9 @@ pub async fn login( .set_credentials("root", login.password); pam_client.authenticate()?; - let token = generate_token(&state.config.jwt_secret); + let token = AuthToken::generate(&state.config.jwt_secret)?; let content = Json(AuthResponse { - token: token.to_owned(), + token: token.to_string(), }); let mut headers = HeaderMap::new(); @@ -86,8 +84,9 @@ pub async fn login_from_query( ) -> impl IntoResponse { let mut headers = HeaderMap::new(); - if TokenClaims::from_token(¶ms.token, &state.config.jwt_secret).is_ok() { - let cookie = auth_cookie_from_token(¶ms.token); + let token = AuthToken::new(¶ms.token); + if token.claims(&state.config.jwt_secret).is_ok() { + let cookie = auth_cookie_from_token(&token); headers.insert( header::SET_COOKIE, cookie.parse().expect("could not build a valid cookie"), @@ -129,8 +128,8 @@ pub async fn session(_claims: TokenClaims) -> Result<(), AuthError> { /// for further information. /// /// * `token`: authentication token. -fn auth_cookie_from_token(token: &str) -> String { - format!("agamaToken={}; HttpOnly", &token) +fn auth_cookie_from_token(token: &AuthToken) -> String { + format!("agamaToken={}; HttpOnly", &token.to_string()) } // builds a response tuple for translation redirection diff --git a/rust/agama-server/src/web/service.rs b/rust/agama-server/src/web/service.rs index 31af807388..91346ce737 100644 --- a/rust/agama-server/src/web/service.rs +++ b/rust/agama-server/src/web/service.rs @@ -1,5 +1,6 @@ use super::http::{login, login_from_query, logout, session}; -use super::{auth::TokenClaims, config::ServiceConfig, state::ServiceState, EventsSender}; +use super::{config::ServiceConfig, state::ServiceState, EventsSender}; +use agama_lib::auth::TokenClaims; use axum::{ body::Body, extract::Request, diff --git a/rust/agama-server/tests/service.rs b/rust/agama-server/tests/service.rs index 53e06393b2..5fc4f3e5b5 100644 --- a/rust/agama-server/tests/service.rs +++ b/rust/agama-server/tests/service.rs @@ -1,6 +1,7 @@ pub mod common; -use agama_server::web::{generate_token, MainServiceBuilder, ServiceConfig}; +use agama_lib::auth::AuthToken; +use agama_server::web::{MainServiceBuilder, ServiceConfig}; use axum::{ body::Body, http::{Method, Request, StatusCode}, @@ -65,8 +66,8 @@ async fn access_protected_route(token: &str, jwt_secret: &str) -> Response { // TODO: The following test should belong to `auth.rs` #[test] async fn test_access_protected_route() -> Result<(), Box> { - let token = generate_token("nots3cr3t"); - let response = access_protected_route(&token, "nots3cr3t").await; + let token = AuthToken::generate("nots3cr3t")?; + let response = access_protected_route(token.as_str(), "nots3cr3t").await; assert_eq!(response.status(), StatusCode::OK); let body = body_to_string(response.into_body()).await; @@ -77,8 +78,8 @@ async fn test_access_protected_route() -> Result<(), Box> { // TODO: The following test should belong to `auth.rs`. #[test] async fn test_access_protected_route_failed() -> Result<(), Box> { - let token = generate_token("nots3cr3t"); - let response = access_protected_route(&token, "wrong").await; + let token = AuthToken::generate("nots3cr3t")?; + let response = access_protected_route(token.as_str(), "wrong").await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); Ok(()) }