phonograph/interim-server/src/user.rs

134 lines
4.3 KiB
Rust
Raw Normal View History

//! Provides an Axum extractor to fetch the authenticated user for a request.
2025-05-02 23:48:54 -07:00
use async_session::{Session, SessionStore as _};
use axum::{
2025-08-04 13:59:42 -07:00
RequestPartsExt,
2025-05-02 23:48:54 -07:00
extract::{FromRequestParts, OriginalUri},
2025-08-04 13:59:42 -07:00
http::{Method, request::Parts},
2025-05-02 23:48:54 -07:00
response::{IntoResponse, Redirect, Response},
};
use axum_extra::extract::{
CookieJar,
2025-08-04 13:59:42 -07:00
cookie::{Cookie, SameSite},
2025-05-02 23:48:54 -07:00
};
2025-08-04 13:59:42 -07:00
use interim_models::user::User;
use sqlx::query_as;
2025-05-02 23:48:54 -07:00
use uuid::Uuid;
use crate::{
app::App,
2025-05-02 23:48:54 -07:00
auth::{AuthInfo, SESSION_KEY_AUTH_INFO, SESSION_KEY_AUTH_REDIRECT},
errors::AppError,
2025-05-02 23:48:54 -07:00
sessions::AppSession,
};
/// Extractor for the authenticated user associated with an HTTP request. If
/// the request is not authenticated, the extractor will abort request handling
/// and redirect the client to an OAuth2 login page.
2025-05-02 23:48:54 -07:00
#[derive(Clone, Debug)]
pub(crate) struct CurrentUser(pub(crate) User);
2025-05-02 23:48:54 -07:00
impl FromRequestParts<App> for CurrentUser {
2025-05-02 23:48:54 -07:00
type Rejection = CurrentUserRejection;
async fn from_request_parts(parts: &mut Parts, state: &App) -> Result<Self, Self::Rejection> {
let mut session = if let AppSession(Some(value)) = parts.extract_with_state(state).await? {
value
} else {
Session::new()
};
2025-05-02 23:48:54 -07:00
let auth_info = if let Some(value) = session.get::<AuthInfo>(SESSION_KEY_AUTH_INFO) {
value
} else {
let jar: CookieJar = parts.extract().await?;
let method: Method = parts.extract().await?;
let jar = if method == Method::GET {
let OriginalUri(uri) = parts.extract().await?;
session.insert(
SESSION_KEY_AUTH_REDIRECT,
uri.path_and_query()
.map(|value| value.to_string())
.unwrap_or(format!("{}/", state.settings.root_path)),
2025-05-02 23:48:54 -07:00
)?;
if let Some(cookie_value) = state.session_store.store_session(session).await? {
2025-05-02 23:48:54 -07:00
tracing::debug!("adding session cookie to jar");
jar.add(
Cookie::build((state.settings.auth.cookie_name.clone(), cookie_value))
2025-05-02 23:48:54 -07:00
.same_site(SameSite::Lax)
.http_only(true)
.path("/"),
)
} else {
tracing::debug!("inferred that session cookie already in jar");
jar
}
} else {
// If request method is not GET then do not attempt to infer the
// redirect target, as there may be no GET handler defined for
// it.
jar
};
return Err(Self::Rejection::SetCookiesAndRedirect(
jar,
format!("{}/auth/login", state.settings.root_path),
2025-05-02 23:48:54 -07:00
));
};
2025-05-26 22:08:21 -07:00
let current_user = if let Some(value) =
query_as!(User, "select * from users where uid = $1", &auth_info.sub)
.fetch_optional(&state.app_db)
2025-05-26 22:08:21 -07:00
.await?
{
value
} else if let Some(value) = query_as!(
User,
"
insert into users
(id, uid, email)
values ($1, $2, $3)
on conflict (uid) do nothing
returning *
",
Uuid::now_v7(),
&auth_info.sub,
&auth_info.email
)
.fetch_optional(&state.app_db)
2025-05-26 22:08:21 -07:00
.await?
{
value
} else {
tracing::debug!("detected race to insert current user record");
query_as!(User, "select * from users where uid = $1", &auth_info.sub)
.fetch_one(&state.app_db)
2025-05-26 22:08:21 -07:00
.await?
};
2025-05-02 23:48:54 -07:00
Ok(CurrentUser(current_user))
}
}
pub enum CurrentUserRejection {
AppError(AppError),
SetCookiesAndRedirect(CookieJar, String),
}
// Easily convert semi-arbitrary errors to InternalServerError
impl<E> From<E> for CurrentUserRejection
where
E: Into<AppError>,
{
fn from(err: E) -> Self {
Self::AppError(err.into())
}
}
impl IntoResponse for CurrentUserRejection {
fn into_response(self) -> Response {
match self {
Self::AppError(err) => err.into_response(),
Self::SetCookiesAndRedirect(jar, redirect_to) => {
(jar, Redirect::to(&redirect_to)).into_response()
}
}
}
}