#![cfg_attr(not(any(feature = "mysql", feature = "postgres", feature = "sqlite")), allow(unused_variables, unreachable_code, dead_code))]
mod admin_routes;
pub mod audit;
mod commands;
pub mod config;
pub mod events;
mod migrations;
mod module;
mod openapi;
mod password;
mod routes;
mod service;
pub mod steam;
mod store;
mod tokens;
use std::marker::PhantomData;
use axum::extract::{FromRequestParts, OptionalFromRequestParts};
use futures_util::future::BoxFuture;
use http::request::Parts;
use http::StatusCode;
use net_backend_protocol::{codes, ApiError, UnixMillis, UserId};
use crate::error::AppError;
use crate::state::AppState;
pub use config::{AuthConfig, MailerKind, SmtpTls};
pub use events::{Revocation, RevocationReason, RevokedSessions};
pub use module::Auth;
pub use service::normalize_email;
pub use service::AuthService;
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct AuthContext {
pub user_id: UserId,
pub session_id: Option<i64>,
pub roles: Vec<String>,
pub session_started_at: Option<UnixMillis>,
}
impl AuthContext {
pub fn new(user_id: UserId) -> Self {
Self { user_id, session_id: None, roles: Vec::new(), session_started_at: None }
}
pub fn with_session_started_at(mut self, at: UnixMillis) -> Self {
self.session_started_at = Some(at);
self
}
pub fn is_recent_login(&self, now: UnixMillis, max_age_ms: i64) -> bool {
self.session_started_at.is_some_and(|at| now.get().saturating_sub(at.get()) <= max_age_ms)
}
pub fn with_session(mut self, session_id: i64) -> Self {
self.session_id = Some(session_id);
self
}
pub fn with_roles(mut self, roles: Vec<String>) -> Self {
self.roles = roles;
self
}
pub fn has_role(&self, role: &str) -> bool {
self.roles.iter().any(|r| r == role)
}
pub fn require_role(&self, role: &str) -> Result<(), AppError> {
if self.has_role(role) {
Ok(())
} else {
Err(AppError::forbidden("this needs another role"))
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct AuthFailure {
status: StatusCode,
error: ApiError,
}
impl AuthFailure {
pub(crate) fn from_error(error: AppError) -> Self {
if error.status().is_server_error() {
tracing::error!(error = %error, "authentication failed");
}
Self { status: error.status(), error: error.api_error().clone() }
}
pub(crate) fn to_error(&self) -> AppError {
AppError::from_parts(self.status, self.error.clone())
}
}
impl<S: Send + Sync> FromRequestParts<S> for AuthContext {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
if let Some(context) = parts.extensions.get::<AuthContext>() {
return Ok(context.clone());
}
Err(parts.extensions.get::<AuthFailure>().map_or_else(AppError::unauthorized, AuthFailure::to_error))
}
}
impl<S: Send + Sync> OptionalFromRequestParts<S> for AuthContext {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Option<Self>, Self::Rejection> {
Ok(parts.extensions.get::<AuthContext>().cloned())
}
}
#[derive(Clone, Debug)]
pub struct MaybeAuth(pub Option<AuthContext>);
impl<S: Send + Sync> FromRequestParts<S> for MaybeAuth {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
if let Some(context) = parts.extensions.get::<AuthContext>() {
return Ok(MaybeAuth(Some(context.clone())));
}
match parts.extensions.get::<AuthFailure>() {
Some(failure) => Err(failure.to_error()),
None => Ok(MaybeAuth(None)),
}
}
}
pub trait Authenticator: Send + Sync + 'static {
fn authenticate<'a>(&'a self, parts: &'a Parts, state: &'a AppState) -> BoxFuture<'a, Result<Option<AuthContext>, AppError>>;
}
pub trait Role: Send + Sync + 'static {
const NAME: &'static str;
}
#[derive(Clone, Copy, Debug)]
pub struct AdminRole;
impl Role for AdminRole {
const NAME: &'static str = net_backend_protocol::admin::ADMIN_ROLE;
}
pub struct RequireRole<R: Role>(pub AuthContext, pub PhantomData<R>);
impl<R: Role> std::fmt::Debug for RequireRole<R> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("RequireRole").field(&R::NAME).field(&self.0).finish()
}
}
pub type RequireAdmin = RequireRole<AdminRole>;
impl<S: Send + Sync, R: Role> FromRequestParts<S> for RequireRole<R> {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let context = <AuthContext as FromRequestParts<S>>::from_request_parts(parts, state).await?;
context.require_role(R::NAME)?;
Ok(RequireRole(context, PhantomData))
}
}
pub(crate) fn invalid_token() -> AppError {
AppError::new(codes::UNAUTHORIZED, "the access token is invalid or revoked")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roles() {
let ctx = AuthContext::new(UserId(1)).with_roles(vec!["admin".into()]).with_session(3);
assert!(ctx.has_role("admin") && !ctx.has_role("moderator"));
assert!(ctx.require_role("admin").is_ok());
assert_eq!(ctx.require_role("moderator").err().map(|e| e.status()), Some(StatusCode::FORBIDDEN));
assert_eq!(ctx.session_id, Some(3));
let ctx = ctx.with_session_started_at(UnixMillis(1_000));
assert!(ctx.is_recent_login(UnixMillis(1_500), 500) && !ctx.is_recent_login(UnixMillis(1_501), 500));
assert!(!AuthContext::new(UserId(1)).is_recent_login(UnixMillis(0), i64::MAX));
}
}