add authentication header support
This commit is contained in:
parent
2f7eab29da
commit
a5ffb69ccd
11 changed files with 196 additions and 24 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -987,6 +987,7 @@ dependencies = [
|
||||||
"chir-rs-http-api",
|
"chir-rs-http-api",
|
||||||
"chir-rs-misc",
|
"chir-rs-misc",
|
||||||
"eyre",
|
"eyre",
|
||||||
|
"futures",
|
||||||
"mime",
|
"mime",
|
||||||
"rand",
|
"rand",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
@ -32,7 +32,7 @@ args@{
|
||||||
ignoreLockHash,
|
ignoreLockHash,
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
nixifiedLockHash = "11a64cfe7de901d3281b189f031b32ae0457044c4134a845d433d25e040d5018";
|
nixifiedLockHash = "7dc55944ae2b0d1b65a004e3be3f5314339c3d2e023e794d54b030b197c5bf87";
|
||||||
workspaceSrc = if args.workspaceSrc == null then ./. else args.workspaceSrc;
|
workspaceSrc = if args.workspaceSrc == null then ./. else args.workspaceSrc;
|
||||||
currentLockHash = builtins.hashFile "sha256" (workspaceSrc + /Cargo.lock);
|
currentLockHash = builtins.hashFile "sha256" (workspaceSrc + /Cargo.lock);
|
||||||
lockHashIgnored =
|
lockHashIgnored =
|
||||||
|
@ -3074,6 +3074,10 @@ else
|
||||||
(rustPackages."registry+https://github.com/rust-lang/crates.io-index".eyre."0.6.12" {
|
(rustPackages."registry+https://github.com/rust-lang/crates.io-index".eyre."0.6.12" {
|
||||||
inherit profileName;
|
inherit profileName;
|
||||||
}).out;
|
}).out;
|
||||||
|
futures =
|
||||||
|
(rustPackages."registry+https://github.com/rust-lang/crates.io-index".futures."0.3.31" {
|
||||||
|
inherit profileName;
|
||||||
|
}).out;
|
||||||
mime =
|
mime =
|
||||||
(rustPackages."registry+https://github.com/rust-lang/crates.io-index".mime."0.3.17" {
|
(rustPackages."registry+https://github.com/rust-lang/crates.io-index".mime."0.3.17" {
|
||||||
inherit profileName;
|
inherit profileName;
|
||||||
|
|
|
@ -15,6 +15,7 @@ mime = "0.3.17"
|
||||||
chir-rs-http-api = { version = "0.1.0", path = "../chir-rs-http-api" }
|
chir-rs-http-api = { version = "0.1.0", path = "../chir-rs-http-api" }
|
||||||
chir-rs-misc = { version = "0.1.0", path = "../chir-rs-misc", features = ["id-generator"] }
|
chir-rs-misc = { version = "0.1.0", path = "../chir-rs-misc", features = ["id-generator"] }
|
||||||
rand = "0.8.5"
|
rand = "0.8.5"
|
||||||
|
futures = "0.3.31"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
deprecated-safe = "forbid"
|
deprecated-safe = "forbid"
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
//! Session-related functionality
|
//! Session-related functionality
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::{collections::HashSet, time::Duration};
|
||||||
|
|
||||||
use crate::Database;
|
use crate::Database;
|
||||||
|
use chir_rs_http_api::auth::Scope;
|
||||||
use chir_rs_misc::id_generator;
|
use chir_rs_misc::id_generator;
|
||||||
use eyre::Result;
|
use eyre::Result;
|
||||||
|
use futures::StreamExt as _;
|
||||||
use rand::{thread_rng, Rng as _};
|
use rand::{thread_rng, Rng as _};
|
||||||
use sqlx::query;
|
use sqlx::query;
|
||||||
use tracing::{error, info, instrument};
|
use tracing::{error, info, instrument};
|
||||||
|
@ -19,6 +21,7 @@ pub async fn expire(db: &Database) -> Result<()> {
|
||||||
let id = id_generator::generate();
|
let id = id_generator::generate();
|
||||||
let oldest_acceptable_id = id - ((24 * 60 * 60) << 48);
|
let oldest_acceptable_id = id - ((24 * 60 * 60) << 48);
|
||||||
let oldest_acceptable_id = oldest_acceptable_id.to_be_bytes();
|
let oldest_acceptable_id = oldest_acceptable_id.to_be_bytes();
|
||||||
|
#[expect(clippy::panic, reason = "sqlx moment")]
|
||||||
query!(
|
query!(
|
||||||
r#"DELETE FROM "session_scopes" WHERE session_id < $1"#,
|
r#"DELETE FROM "session_scopes" WHERE session_id < $1"#,
|
||||||
&oldest_acceptable_id
|
&oldest_acceptable_id
|
||||||
|
@ -28,6 +31,47 @@ pub async fn expire(db: &Database) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns username and scopes for a session ID
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// This function returns an error if accessing the database fails
|
||||||
|
#[instrument]
|
||||||
|
#[expect(clippy::panic, reason = "sqlx moment")]
|
||||||
|
pub async fn fetch_session_info(
|
||||||
|
db: &Database,
|
||||||
|
session_id: u128,
|
||||||
|
) -> Result<Option<(String, HashSet<Scope>)>> {
|
||||||
|
let session_id = session_id.to_be_bytes();
|
||||||
|
let Some(username_record) = query!(
|
||||||
|
r#"
|
||||||
|
SELECT "user".username FROM "user"
|
||||||
|
INNER JOIN "sessions"
|
||||||
|
ON "sessions".user_id = "user".id
|
||||||
|
WHERE "sessions".id = $1
|
||||||
|
"#,
|
||||||
|
&session_id
|
||||||
|
)
|
||||||
|
.fetch_optional(&*db.0)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut scopes = HashSet::with_capacity(4);
|
||||||
|
|
||||||
|
let mut scopes_records = query!(
|
||||||
|
"SELECT scope FROM session_scopes WHERE session_id = $1",
|
||||||
|
&session_id
|
||||||
|
)
|
||||||
|
.fetch(&*db.0);
|
||||||
|
|
||||||
|
while let Some(scope_record) = scopes_records.next().await {
|
||||||
|
scopes.insert(Scope::from_i64(scope_record?.scope)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some((username_record.username, scopes)))
|
||||||
|
}
|
||||||
|
|
||||||
/// Automatically expires outdated sessions
|
/// Automatically expires outdated sessions
|
||||||
///
|
///
|
||||||
/// This is intended to be called on a dedicated job.
|
/// This is intended to be called on a dedicated job.
|
||||||
|
|
|
@ -21,6 +21,17 @@ impl Scope {
|
||||||
Self::Full => 0,
|
Self::Full => 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Converts a scope ID to the scope
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// This function returns an error if the scope ID is invalid.
|
||||||
|
pub fn from_i64(id: i64) -> Result<Self> {
|
||||||
|
match id {
|
||||||
|
0 => Ok(Self::Full),
|
||||||
|
_ => bail!("Invalid scope ID {id}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Login request for the user
|
/// Login request for the user
|
||||||
|
|
|
@ -42,6 +42,21 @@ pub enum APIError {
|
||||||
/// Invalid password
|
/// Invalid password
|
||||||
#[error("Invalid password for user {0}")]
|
#[error("Invalid password for user {0}")]
|
||||||
InvalidPassword(String),
|
InvalidPassword(String),
|
||||||
|
/// Missing authorization header
|
||||||
|
#[error("Missing authorization header")]
|
||||||
|
MissingAuthorizationHeader,
|
||||||
|
/// Invalid Authorization header value
|
||||||
|
#[error("Invalid authorization header: {0}")]
|
||||||
|
InvalidAuthorizationHeader(String),
|
||||||
|
/// Invalid authorization method
|
||||||
|
#[error("Invalid authorization method: {0}, expected {1}")]
|
||||||
|
InvalidAuthorizationMethod(String, String),
|
||||||
|
/// Unauthorized
|
||||||
|
#[error("Unauthorized")]
|
||||||
|
Unauthorized,
|
||||||
|
/// Invalid session
|
||||||
|
#[error("Invalid session")]
|
||||||
|
InvalidSession,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl APIError {
|
impl APIError {
|
||||||
|
@ -54,7 +69,13 @@ impl APIError {
|
||||||
}
|
}
|
||||||
Self::PayloadTooBig => StatusCode::PAYLOAD_TOO_LARGE,
|
Self::PayloadTooBig => StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
Self::PayloadLoadError | Self::PayloadInvalid => StatusCode::BAD_REQUEST,
|
Self::PayloadLoadError | Self::PayloadInvalid => StatusCode::BAD_REQUEST,
|
||||||
Self::UserNotFound(_) | Self::InvalidPassword(_) => StatusCode::UNAUTHORIZED,
|
Self::UserNotFound(_)
|
||||||
|
| Self::InvalidPassword(_)
|
||||||
|
| Self::MissingAuthorizationHeader
|
||||||
|
| Self::InvalidAuthorizationHeader(_)
|
||||||
|
| Self::InvalidAuthorizationMethod(_, _)
|
||||||
|
| Self::Unauthorized
|
||||||
|
| Self::InvalidSession => StatusCode::UNAUTHORIZED,
|
||||||
Self::Unknown(_) | Self::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
Self::Unknown(_) | Self::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,10 +21,7 @@ chir-rs-misc = { version = "0.1.0", path = "../chir-rs-misc", features = [
|
||||||
chrono = "0.4.38"
|
chrono = "0.4.38"
|
||||||
eyre = "0.6.12"
|
eyre = "0.6.12"
|
||||||
mime = "0.3.17"
|
mime = "0.3.17"
|
||||||
rusty_paseto = { version = "0.7.1", default-features = false, features = [
|
rusty_paseto = { version = "0.7.1", default-features = false, features = ["batteries_included", "v4_local"] }
|
||||||
"batteries_included",
|
|
||||||
"v4_local",
|
|
||||||
] }
|
|
||||||
sentry-tower = { version = "0.34.0", features = ["axum", "axum-matched-path"] }
|
sentry-tower = { version = "0.34.0", features = ["axum", "axum-matched-path"] }
|
||||||
tokio = { version = "1.41.1", features = ["fs", "net"] }
|
tokio = { version = "1.41.1", features = ["fs", "net"] }
|
||||||
tower-http = { version = "0.6.2", features = ["trace"] }
|
tower-http = { version = "0.6.2", features = ["trace"] }
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
//! Authentication related functionality
|
//! Authentication related functionality
|
||||||
|
|
||||||
pub mod password_login;
|
pub mod password_login;
|
||||||
|
pub mod req_auth;
|
||||||
|
|
83
chir-rs-http/src/auth/req_auth/auth_header.rs
Normal file
83
chir-rs-http/src/auth/req_auth/auth_header.rs
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
//! Authentication header handler
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
async_trait,
|
||||||
|
extract::FromRequestParts,
|
||||||
|
http::{header::AUTHORIZATION, request::Parts},
|
||||||
|
};
|
||||||
|
use chir_rs_db::session::fetch_session_info;
|
||||||
|
use chir_rs_http_api::{auth::Scope, errors::APIError};
|
||||||
|
use eyre::{Context as _, OptionExt as _};
|
||||||
|
use rusty_paseto::core::{Local, V4};
|
||||||
|
use rusty_paseto::prelude::PasetoParser;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Read Authorization from the bearer token.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct AuthHeader(pub String, pub HashSet<Scope>);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FromRequestParts<AppState> for AuthHeader {
|
||||||
|
type Rejection = APIError;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut Parts,
|
||||||
|
state: &AppState,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
let Some(authorization_header) = parts.headers.get(AUTHORIZATION) else {
|
||||||
|
return Err(APIError::MissingAuthorizationHeader);
|
||||||
|
};
|
||||||
|
let authorization_header = authorization_header
|
||||||
|
.to_str()
|
||||||
|
.context("Parsing the authorization header")
|
||||||
|
.map_err(|e| APIError::InvalidAuthorizationHeader(format!("{e:?}")))?;
|
||||||
|
|
||||||
|
let Some((method, key)) = authorization_header.split_once(' ') else {
|
||||||
|
return Err(APIError::InvalidAuthorizationHeader(
|
||||||
|
authorization_header.to_string(),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
if !method.trim().eq_ignore_ascii_case("Bearer") {
|
||||||
|
return Err(APIError::InvalidAuthorizationMethod(
|
||||||
|
method.trim().to_string(),
|
||||||
|
"Bearer".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = PasetoParser::<V4, Local>::default()
|
||||||
|
.parse(key.trim(), &state.paseto_key)
|
||||||
|
.context("Verifying paseto token")
|
||||||
|
.map_err(|e| {
|
||||||
|
info!("Failed authentication with: {e:?}");
|
||||||
|
APIError::Unauthorized
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let session_id: u128 = json["jti"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_eyre("Reading the token ID as a string")
|
||||||
|
.and_then(|v| v.parse().context("Parsing session ID"))
|
||||||
|
.map_err(|e| {
|
||||||
|
error!("Invalid issued token: {e:?}");
|
||||||
|
APIError::Unknown(format!("Invalid issued token: {e:?}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let session_info = fetch_session_info(&state.db, session_id)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("Verifying session {session_id}"))
|
||||||
|
.map_err(|e| {
|
||||||
|
error!("Failed to verify session: {e:?}");
|
||||||
|
APIError::Unknown(format!("Failed to verify session: {e:?}"))
|
||||||
|
})?
|
||||||
|
.ok_or_eyre("Found session info")
|
||||||
|
.map_err(|e| {
|
||||||
|
info!("User Error validating session {e:?}");
|
||||||
|
APIError::InvalidSession
|
||||||
|
})?;
|
||||||
|
Ok(Self(session_info.0, session_info.1))
|
||||||
|
}
|
||||||
|
}
|
3
chir-rs-http/src/auth/req_auth/mod.rs
Normal file
3
chir-rs-http/src/auth/req_auth/mod.rs
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
//! Request authentication
|
||||||
|
|
||||||
|
pub mod auth_header;
|
40
src/main.rs
40
src/main.rs
|
@ -14,23 +14,8 @@ use tracing_subscriber::{
|
||||||
fmt::format::JsonFields, layer::SubscriberExt as _, util::SubscriberInitExt as _, Layer,
|
fmt::format::JsonFields, layer::SubscriberExt as _, util::SubscriberInitExt as _, Layer,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
/// Initializes logging for the application
|
||||||
color_eyre::install().ok();
|
fn init_logging(cfg: &ChirRs) -> Result<()> {
|
||||||
dotenvy::dotenv().ok();
|
|
||||||
|
|
||||||
// NO THREADS BEFORE THIS POINT
|
|
||||||
|
|
||||||
let cfg = ChirRs::read_from_env().context("Reading chir.rs configuration")?;
|
|
||||||
|
|
||||||
let _guard = sentry::init(sentry::ClientOptions {
|
|
||||||
dsn: cfg.logging.sentry_dsn.clone(),
|
|
||||||
release: sentry::release_name!(),
|
|
||||||
traces_sample_rate: 0.1,
|
|
||||||
attach_stacktrace: true,
|
|
||||||
debug: cfg!(debug_assertions),
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
|
|
||||||
let log_filter = tracing_subscriber::EnvFilter::from_str(&cfg.logging.log_level)
|
let log_filter = tracing_subscriber::EnvFilter::from_str(&cfg.logging.log_level)
|
||||||
.with_context(|| format!("Setting log filter to {}", cfg.logging.log_level))?;
|
.with_context(|| format!("Setting log filter to {}", cfg.logging.log_level))?;
|
||||||
|
|
||||||
|
@ -85,6 +70,27 @@ fn main() -> Result<()> {
|
||||||
.init();
|
.init();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
color_eyre::install().ok();
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
|
// NO THREADS BEFORE THIS POINT
|
||||||
|
|
||||||
|
let cfg = ChirRs::read_from_env().context("Reading chir.rs configuration")?;
|
||||||
|
|
||||||
|
let _guard = sentry::init(sentry::ClientOptions {
|
||||||
|
dsn: cfg.logging.sentry_dsn.clone(),
|
||||||
|
release: sentry::release_name!(),
|
||||||
|
traces_sample_rate: 0.1,
|
||||||
|
attach_stacktrace: true,
|
||||||
|
debug: cfg!(debug_assertions),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
init_logging(&cfg)?;
|
||||||
|
|
||||||
tokio::runtime::Builder::new_multi_thread()
|
tokio::runtime::Builder::new_multi_thread()
|
||||||
.enable_all()
|
.enable_all()
|
||||||
|
|
Loading…
Reference in a new issue