Start to implement OpenID authentication

This commit is contained in:
Pierre HUBERT 2024-04-25 19:25:08 +02:00
parent 48f24e6ca1
commit d8946eb462
12 changed files with 427 additions and 6 deletions

View File

@ -58,6 +58,22 @@ dependencies = [
"zstd",
]
[[package]]
name = "actix-identity"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2c99b7a5614b72a78f04aa2021e5370fc1aef2475fffeffc0c1266b99007062"
dependencies = [
"actix-service",
"actix-session",
"actix-utils",
"actix-web",
"derive_more",
"futures-core",
"serde",
"tracing",
]
[[package]]
name = "actix-macros"
version = "0.2.4"
@ -130,6 +146,22 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "actix-session"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b671404ec72194d8af58c2bdaf51e3c477a0595056bd5010148405870dda8df2"
dependencies = [
"actix-service",
"actix-utils",
"actix-web",
"anyhow",
"derive_more",
"serde",
"serde_json",
"tracing",
]
[[package]]
name = "actix-utils"
version = "3.0.1"
@ -360,6 +392,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base64"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5"
[[package]]
name = "base64"
version = "0.21.7"
@ -565,7 +603,14 @@ version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb"
dependencies = [
"aes-gcm",
"base64 0.20.0",
"hkdf",
"hmac",
"percent-encoding",
"rand",
"sha2",
"subtle",
"time",
"version_check",
]
@ -1646,12 +1691,14 @@ checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56"
name = "remote_backend"
version = "0.1.0"
dependencies = [
"actix-identity",
"actix-remote-ip",
"actix-web",
"anyhow",
"basic-jwt",
"clap",
"env_logger",
"futures-util",
"lazy_static",
"light-openid",
"log",

View File

@ -12,10 +12,12 @@ clap = { version = "4.5.4", features = ["derive", "env"] }
serde = { version = "1.0.198", features = ["derive"] }
light-openid = { version = "1.0.2", features = ["crypto-wrapper"] }
basic-jwt = "0.2.0"
actix-remote-ip = "0.1.0"
lazy_static = "1.4.0"
actix-web = "4.5.1"
actix-remote-ip = "0.1.0"
actix-identity = "0.7.1"
lazy_static = "1.4.0"
anyhow = "1.0.82"
reqwest = { version = "0.12.4", features = ["json"] }
thiserror = "1.0.59"
uuid = { version = "1.8.0", features = ["v4"] }
uuid = { version = "1.8.0", features = ["v4"] }
futures-util = "0.3.30"

View File

@ -33,9 +33,9 @@ pub struct AppConfig {
)]
pub oidc_configuration_url: String,
/// Disable OpenID authentication
/// Disable authentication (for development purposes ONLY)
#[arg(long, env)]
pub disable_oidc: bool,
pub unsecure_disable_login: bool,
/// OpenID client ID
#[arg(long, env, default_value = "foo")]

View File

@ -0,0 +1,5 @@
pub const ROUTES_WITHOUT_AUTH: [&str; 3] = [
"/api/server/config",
"/api/auth/start_oidc",
"/api/auth/finish_oidc",
];

View File

@ -0,0 +1,97 @@
use actix_remote_ip::RemoteIP;
use actix_web::web::Data;
use actix_web::{web, HttpResponse, Responder};
use light_openid::basic_state_manager::BasicStateManager;
use crate::app_config::AppConfig;
use crate::controllers::HttpResult;
use crate::extractors::auth_extractor::AuthExtractor;
#[derive(serde::Serialize)]
struct StartOIDCResponse {
url: String,
}
/// Start OIDC authentication
pub async fn start_oidc(sm: Data<BasicStateManager>, ip: RemoteIP) -> HttpResult {
let prov = AppConfig::get().openid_provider();
let conf =
light_openid::primitives::OpenIDConfig::load_from_url(prov.configuration_url).await?;
Ok(HttpResponse::Ok().json(StartOIDCResponse {
url: conf.gen_authorization_url(
prov.client_id,
&sm.gen_state(ip.0)?,
&AppConfig::get().oidc_redirect_url(),
),
}))
}
#[derive(serde::Deserialize)]
pub struct FinishOpenIDLoginQuery {
code: String,
state: String,
}
/// Finish OIDC authentication
pub async fn finish_oidc(
sm: Data<BasicStateManager>,
remote_ip: RemoteIP,
req: web::Json<FinishOpenIDLoginQuery>,
auth: AuthExtractor,
) -> HttpResult {
if let Err(e) = sm.validate_state(remote_ip.0, &req.state) {
log::error!("Failed to validate OIDC CB state! {e}");
return Ok(HttpResponse::BadRequest().json("Invalid state!"));
}
let prov = AppConfig::get().openid_provider();
let conf =
light_openid::primitives::OpenIDConfig::load_from_url(prov.configuration_url).await?;
let (token, _) = conf
.request_token(
prov.client_id,
prov.client_secret,
&req.code,
&AppConfig::get().oidc_redirect_url(),
)
.await?;
let (user_info, _) = conf.request_user_info(&token).await?;
if user_info.email_verified != Some(true) {
log::error!("Email is not verified!");
return Ok(HttpResponse::Unauthorized().json("Email unverified by IDP!"));
}
let mail = match user_info.email {
Some(m) => m,
None => {
return Ok(HttpResponse::Unauthorized().json("Email not provided by the IDP!"));
}
};
auth.authenticate(mail);
Ok(HttpResponse::Ok().finish())
}
#[derive(serde::Serialize)]
struct CurrentUser {
id: String,
}
/// Get current authenticated user
pub async fn current_user(auth: AuthExtractor) -> impl Responder {
HttpResponse::Ok().json(CurrentUser {
id: auth.id().unwrap_or_else(|| "Anonymous".to_string()),
})
}
/// Sign out
pub async fn sign_out(auth: AuthExtractor) -> impl Responder {
auth.sign_out();
HttpResponse::Ok().finish()
}

View File

@ -0,0 +1,89 @@
use actix_web::body::BoxBody;
use actix_web::http::StatusCode;
use actix_web::HttpResponse;
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::io::ErrorKind;
pub mod auth_controller;
/// Custom error to ease controller writing
#[derive(Debug)]
pub enum HttpErr {
Err(anyhow::Error),
HTTPResponse(HttpResponse),
}
impl Display for HttpErr {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
HttpErr::Err(err) => Display::fmt(err, f),
HttpErr::HTTPResponse(res) => {
Display::fmt(&format!("HTTP RESPONSE {}", res.status().as_str()), f)
}
}
}
}
impl actix_web::error::ResponseError for HttpErr {
fn status_code(&self) -> StatusCode {
match self {
HttpErr::Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
HttpErr::HTTPResponse(r) => r.status(),
}
}
fn error_response(&self) -> HttpResponse<BoxBody> {
log::error!("Error while processing request! {}", self);
HttpResponse::InternalServerError().body("Failed to execute request!")
}
}
impl From<anyhow::Error> for HttpErr {
fn from(err: anyhow::Error) -> HttpErr {
HttpErr::Err(err)
}
}
impl From<Box<dyn Error>> for HttpErr {
fn from(value: Box<dyn Error>) -> Self {
HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into())
}
}
impl From<std::io::Error> for HttpErr {
fn from(value: std::io::Error) -> Self {
HttpErr::Err(value.into())
}
}
impl From<std::num::ParseIntError> for HttpErr {
fn from(value: std::num::ParseIntError) -> Self {
HttpErr::Err(value.into())
}
}
impl From<reqwest::Error> for HttpErr {
fn from(value: reqwest::Error) -> Self {
HttpErr::Err(value.into())
}
}
impl From<reqwest::header::ToStrError> for HttpErr {
fn from(value: reqwest::header::ToStrError) -> Self {
HttpErr::Err(value.into())
}
}
impl From<actix_web::Error> for HttpErr {
fn from(value: actix_web::Error) -> Self {
HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into())
}
}
impl From<HttpResponse> for HttpErr {
fn from(value: HttpResponse) -> Self {
HttpErr::HTTPResponse(value)
}
}
pub type HttpResult = Result<HttpResponse, HttpErr>;

View File

@ -0,0 +1,47 @@
use actix_identity::Identity;
use actix_web::dev::Payload;
use actix_web::{Error, FromRequest, HttpMessage, HttpRequest};
use futures_util::future::{ready, Ready};
use std::fmt::Display;
pub struct AuthExtractor {
identity: Option<Identity>,
request: HttpRequest,
}
impl AuthExtractor {
/// Check whether the user is authenticated or not
pub fn is_authenticated(&self) -> bool {
self.identity.is_some()
}
/// Authenticate the user
pub fn authenticate(&self, id: impl Display) {
Identity::login(&self.request.extensions(), id.to_string())
.expect("Unable to set authentication!");
}
pub fn id(&self) -> Option<String> {
self.identity.as_ref().map(|i| i.id().unwrap())
}
pub fn sign_out(self) {
if let Some(i) = self.identity {
i.logout()
}
}
}
impl FromRequest for AuthExtractor {
type Error = Error;
type Future = Ready<Result<Self, Error>>;
fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future {
let identity: Option<Identity> = Identity::from_request(req, payload).into_inner().ok();
ready(Ok(Self {
identity,
request: req.clone(),
}))
}
}

View File

@ -0,0 +1 @@
pub mod auth_extractor;

View File

@ -1,3 +1,7 @@
pub mod app_config;
pub mod constants;
pub mod controllers;
pub mod extractors;
pub mod middlewares;
pub mod utils;
pub mod virtweb_client;

View File

@ -1,9 +1,10 @@
use actix_remote_ip::RemoteIPConfig;
use actix_web::middleware::Logger;
use actix_web::web::Data;
use actix_web::{App, HttpServer};
use actix_web::{web, App, HttpServer};
use light_openid::basic_state_manager::BasicStateManager;
use remote_backend::app_config::AppConfig;
use remote_backend::controllers::auth_controller;
use remote_backend::virtweb_client;
#[actix_web::main]
@ -21,6 +22,22 @@ async fn main() -> std::io::Result<()> {
.app_data(Data::new(RemoteIPConfig {
proxy: AppConfig::get().proxy_ip.clone(),
}))
.route(
"/api/auth/start_oidc",
web::get().to(auth_controller::start_oidc),
)
.route(
"/api/auth/finish_oidc",
web::post().to(auth_controller::finish_oidc),
)
.route(
"/api/auth/user",
web::get().to(auth_controller::current_user),
)
.route(
"/api/auth/sign_out",
web::get().to(auth_controller::sign_out),
)
})
.bind(&AppConfig::get().listen_address)?
.run()

View File

@ -0,0 +1,111 @@
use std::future::{ready, Ready};
use std::rc::Rc;
use crate::app_config::AppConfig;
use crate::constants;
use crate::extractors::auth_extractor::AuthExtractor;
use actix_web::body::EitherBody;
use actix_web::dev::Payload;
use actix_web::{
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
Error, FromRequest, HttpResponse,
};
use futures_util::future::LocalBoxFuture;
// There are two steps in middleware processing.
// 1. Middleware initialization, middleware factory gets called with
// next service in chain as parameter.
// 2. Middleware's call method gets called with normal request.
#[derive(Default)]
pub struct AuthChecker;
// Middleware factory is `Transform` trait
// `S` - type of the next service
// `B` - type of response's body
impl<S, B> Transform<S, ServiceRequest> for AuthChecker
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = Error;
type Transform = AuthMiddleware<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(AuthMiddleware {
service: Rc::new(service),
}))
}
}
pub struct AuthMiddleware<S> {
service: Rc<S>,
}
impl<S, B> Service<ServiceRequest> for AuthMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
let service = Rc::clone(&self.service);
Box::pin(async move {
let remote_ip =
actix_remote_ip::RemoteIP::from_request(req.request(), &mut Payload::None)
.await
.unwrap();
// Check if no authentication is required
if constants::ROUTES_WITHOUT_AUTH.contains(&req.path())
|| !req.path().starts_with("/api/")
{
log::trace!("No authentication is required")
}
// Check cookie authentication
else if !&AppConfig::get().unsecure_disable_login {
let auth = match AuthExtractor::from_request(req.request(), &mut Payload::None)
.into_inner()
{
Ok(auth) => auth,
Err(e) => {
log::error!(
"Failed to extract authentication information from request! {e}"
);
return Ok(req
.into_response(HttpResponse::PreconditionFailed().finish())
.map_into_right_body());
}
};
if !auth.is_authenticated() {
log::error!(
"User attempted to access privileged route without authentication!"
);
return Ok(req
.into_response(
HttpResponse::PreconditionFailed().json("Please authenticate!"),
)
.map_into_right_body());
}
}
log::info!("{} - {}", remote_ip.0, req.path());
service
.call(req)
.await
.map(ServiceResponse::map_into_left_body)
})
}
}

View File

@ -0,0 +1 @@
pub mod auth_middleware;