use axum::body::Bytes;
use axum::extract::State;
use axum::http::header::{CACHE_CONTROL, PRAGMA, SET_COOKIE, VARY};
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use chrono::{DateTime, Utc};
use sbol_db_core::{DomainError, User, UserId};
use serde::{Deserialize, Serialize};
use super::auth::{reject_cross_origin_browser, Identity, PresentedCredential};
use super::error::V2Error;
use super::util::{parse_json, required};
use crate::error::ApiError;
use crate::session::{login_cookie, logout_cookie};
use crate::AppState;
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct LoginRequest {
#[serde(alias = "email", alias = "username")]
identifier: Option<String>,
password: Option<String>,
}
#[derive(Debug, Serialize)]
struct SessionResponse {
authenticated: bool,
user: Option<SessionUser>,
}
#[derive(Debug, Serialize)]
struct SessionUser {
id: UserId,
username: String,
name: String,
email: String,
affiliation: Option<String>,
graph_uri: String,
is_admin: bool,
is_curator: bool,
is_member: bool,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl SessionResponse {
fn from_user(user: Option<User>) -> Self {
Self {
authenticated: user.is_some(),
user: user.map(SessionUser::from),
}
}
}
impl From<User> for SessionUser {
fn from(user: User) -> Self {
Self {
id: user.id,
username: user.username,
name: user.name,
email: user.email,
affiliation: user.affiliation,
graph_uri: user.graph_uri,
is_admin: user.is_admin,
is_curator: user.is_curator,
is_member: user.is_member,
created_at: user.created_at,
updated_at: user.updated_at,
}
}
}
pub(super) async fn get(Extension(identity): Extension<Identity>) -> Response {
session_response(SessionResponse::from_user(identity.0))
}
pub(super) async fn create(
State(state): State<AppState>,
headers: HeaderMap,
body: Bytes,
) -> Result<Response, V2Error> {
reject_cross_origin_browser(&headers)?;
let request: LoginRequest = parse_json(&body)?;
let identifier = required(request.identifier, "identifier")?;
let password = required(request.password, "password")?;
let user = match state
.app
.auth
.authenticate(&identifier, &password, &state.config.password_salt)
.await
{
Ok(user) => user,
Err(DomainError::Validation(_)) => {
return Err(V2Error::from(ApiError::Unauthorized(
"invalid credentials".to_owned(),
)))
}
Err(error) => return Err(error.into()),
};
let token = state.app.auth.issue_token(user.id).await?;
let mut response = session_response(SessionResponse::from_user(Some(user)));
response.headers_mut().insert(
SET_COOKIE,
HeaderValue::from_str(&login_cookie(&token, state.config.session_cookie_secure))
.expect("opaque UUID token always forms a valid cookie header"),
);
Ok(response)
}
pub(super) async fn delete(
State(state): State<AppState>,
Extension(credential): Extension<PresentedCredential>,
) -> Result<Response, V2Error> {
if let Some(token) = credential.token() {
state.app.auth.revoke_token(token).await?;
}
let mut response = StatusCode::NO_CONTENT.into_response();
no_store(response.headers_mut());
response.headers_mut().insert(
SET_COOKIE,
HeaderValue::from_str(&logout_cookie(state.config.session_cookie_secure))
.expect("static cookie attributes form a valid header"),
);
Ok(response)
}
fn session_response(payload: SessionResponse) -> Response {
let mut response = Json(payload).into_response();
no_store(response.headers_mut());
response
}
fn no_store(headers: &mut HeaderMap) {
headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
headers.insert(PRAGMA, HeaderValue::from_static("no-cache"));
headers.insert(VARY, HeaderValue::from_static("Cookie, Authorization"));
}