feat: improve authentication flow

authentication now works either with sessionid or basic auth, with basic
auth not creating a session
episode-actions
Jef Roosens 2025-03-04 20:04:44 +01:00
parent 2f974fd1ff
commit 648921837b
No known key found for this signature in database
GPG Key ID: 21FD3D77D56BAF49
6 changed files with 69 additions and 39 deletions

1
Cargo.lock generated
View File

@ -896,6 +896,7 @@ dependencies = [
"axum-extra",
"chrono",
"clap",
"cookie",
"diesel",
"diesel_migrations",
"libsqlite3-sys",

View File

@ -9,6 +9,7 @@ axum = { version = "0.8.1", features = ["macros"] }
axum-extra = { version = "0.10", features = ["cookie", "typed-header"] }
chrono = { version = "0.4.39", features = ["serde"] }
clap = { version = "4.5.30", features = ["derive", "env"] }
cookie = "0.18.1"
diesel = { version = "2.2.7", features = ["r2d2", "sqlite", "returning_clauses_for_sqlite_3_35"] }
diesel_migrations = { version = "2.2.0", features = ["sqlite"] }
libsqlite3-sys = { version = "0.31.0", features = ["bundled"] }

View File

@ -20,6 +20,30 @@ impl From<diesel::result::Error> for gpodder::AuthErr {
}
impl gpodder::AuthRepository for SqliteRepository {
fn validate_credentials(
&self,
username: &str,
password: &str,
) -> Result<gpodder::models::User, AuthErr> {
if let Some(user) = users::table
.select(db::User::as_select())
.filter(users::username.eq(username))
.first(&mut self.pool.get()?)
.optional()?
{
if user.verify_password(password) {
Ok(gpodder::User {
id: user.id,
username: user.username,
})
} else {
Err(gpodder::AuthErr::InvalidPassword)
}
} else {
Err(gpodder::AuthErr::UnknownUser)
}
}
fn validate_session(&self, session_id: i64) -> Result<gpodder::User, gpodder::AuthErr> {
match sessions::dsl::sessions
.inner_join(users::table)

View File

@ -13,6 +13,10 @@ pub trait AuthRepository {
/// Validate the given session ID and return its user.
fn validate_session(&self, session_id: i64) -> Result<models::User, AuthErr>;
/// Validate the credentials, returning the user if the credentials are correct.
fn validate_credentials(&self, username: &str, password: &str)
-> Result<models::User, AuthErr>;
/// Create a new session for the given user.
fn create_session(
&self,

View File

@ -44,44 +44,57 @@ pub fn router(ctx: Context) -> Router<Context> {
/// This middleware accepts
pub async fn auth_middleware(State(ctx): State<Context>, mut req: Request, next: Next) -> Response {
// SAFETY: this extractor's error type is Infallible
let jar: CookieJar = req.extract_parts().await.unwrap();
let mut auth_user = None;
let mut new_session_id = None;
tracing::debug!("{:?}", req.headers());
// SAFETY: this extractor's error type is Infallible
let mut jar: CookieJar = req.extract_parts().await.unwrap();
let mut auth_user = None;
// First try to validate the session
if let Some(session_id) = jar
.get(SESSION_ID_COOKIE)
.and_then(|c| c.value().parse::<i64>().ok())
{
match tokio::task::spawn_blocking(move || ctx.repo.validate_session(session_id))
let ctx_clone = ctx.clone();
match tokio::task::spawn_blocking(move || ctx_clone.repo.validate_session(session_id))
.await
.unwrap()
.map_err(AppError::from)
{
Ok(user) => {
auth_user = Some(user);
}
Err(gpodder::AuthErr::UnknownSession) => {
jar = jar.add(
Cookie::build((SESSION_ID_COOKIE, String::new()))
.max_age(cookie::time::Duration::ZERO),
);
}
Err(err) => {
return err.into_response();
return AppError::from(err).into_response();
}
};
} else if let Ok(auth) = req
.extract_parts::<TypedHeader<Authorization<Basic>>>()
.await
{
match tokio::task::spawn_blocking(move || {
ctx.repo.create_session(auth.username(), auth.password())
})
.await
.unwrap()
.map_err(AppError::from)
}
// Only if the sessionid wasn't present or valid do we check the credentials.
if auth_user.is_none() {
if let Ok(auth) = req
.extract_parts::<TypedHeader<Authorization<Basic>>>()
.await
{
Ok((session_id, user)) => {
auth_user = Some(user);
new_session_id = Some(session_id);
}
Err(err) => {
return err.into_response();
match tokio::task::spawn_blocking(move || {
ctx.repo
.validate_credentials(auth.username(), auth.password())
})
.await
.unwrap()
.map_err(AppError::from)
{
Ok(user) => {
auth_user = Some(user);
}
Err(err) => {
return err.into_response();
}
}
}
}
@ -89,22 +102,9 @@ pub async fn auth_middleware(State(ctx): State<Context>, mut req: Request, next:
if let Some(user) = auth_user {
req.extensions_mut().insert(user);
let res = next.run(req).await;
if let Some(session_id) = new_session_id {
(
jar.add(
Cookie::build((SESSION_ID_COOKIE, session_id.to_string()))
.expires(Expiration::Session),
),
res,
)
.into_response()
} else {
res
}
(jar, next.run(req).await).into_response()
} else {
let mut res = StatusCode::UNAUTHORIZED.into_response();
let mut res = (jar, StatusCode::UNAUTHORIZED).into_response();
// This is what the gpodder.net service returns, and some clients seem to depend on it
res.headers_mut().insert(

View File

@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
#[derive(Deserialize, Debug)]
pub struct SubscriptionDelta {
pub add: Vec<String>,
pub remove: Vec<String>,