271 lines
7.9 KiB
Rust
271 lines
7.9 KiB
Rust
// Copyright (c) [2024] SUSE LLC
|
|
//
|
|
// All Rights Reserved.
|
|
//
|
|
// This program is free software; you can redistribute it and/or modify it
|
|
// under the terms of the GNU General Public License as published by the Free
|
|
// Software Foundation; either version 2 of the License, or (at your option)
|
|
// any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful, but WITHOUT
|
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
// more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License along
|
|
// with this program; if not, contact SUSE LLC.
|
|
//
|
|
// To contact SUSE LLC about this file by physical or electronic mail, you may
|
|
// find current contact information at www.suse.com.
|
|
|
|
//! 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,
|
|
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;
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Error, Debug)]
|
|
#[error("Invalid authentication token: {0}")]
|
|
pub struct AuthTokenError(#[from] jsonwebtoken::errors::Error);
|
|
|
|
/// Represents an authentication token (JWT).
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct AuthToken(pub 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<Self, AuthTokenError> {
|
|
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<Self> {
|
|
if let Ok(path) = Self::user_token_path() {
|
|
if let Ok(token) = Self::read(path) {
|
|
return Some(token);
|
|
}
|
|
}
|
|
|
|
Self::read(AGAMA_TOKEN_FILE).ok()
|
|
}
|
|
|
|
pub fn master() -> Option<Self> {
|
|
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<P: AsRef<Path>>(path: P) -> io::Result<Self> {
|
|
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<P: AsRef<Path>>(&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<TokenClaims, AuthTokenError> {
|
|
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<PathBuf> {
|
|
let Some(path) = home::home_dir() else {
|
|
return Err(io::Error::other("Cannot find the user's home directory"));
|
|
};
|
|
|
|
Ok(path.join(USER_TOKEN_PATH))
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for AuthToken {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> 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,
|
|
pub client_id: ClientId,
|
|
}
|
|
|
|
// FIXME: replace with TokenClaims::new, as it does not exist a "default" token.
|
|
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(),
|
|
client_id: ClientId::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Identifies a client.
|
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
|
pub struct ClientId(Uuid);
|
|
|
|
impl Default for ClientId {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl ClientId {
|
|
pub fn new() -> Self {
|
|
ClientId(Uuid::new_v4())
|
|
}
|
|
|
|
pub fn new_from_uuid(uuid: Uuid) -> Self {
|
|
ClientId(uuid)
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for ClientId {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(f, "{}", self.0)
|
|
}
|
|
}
|
|
|
|
#[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<P: AsRef<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());
|
|
}
|
|
}
|