oidc authentication
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
use crate::domain::warren::ports::{AuthMetrics, FileSystemMetrics, WarrenMetrics};
|
||||
use crate::domain::{
|
||||
oidc::ports::OidcMetrics,
|
||||
warren::ports::{AuthMetrics, FileSystemMetrics, WarrenMetrics},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct MetricsDebugLogger;
|
||||
@@ -175,17 +178,17 @@ impl AuthMetrics for MetricsDebugLogger {
|
||||
tracing::debug!("[Metrics] Warren creation by admin failed");
|
||||
}
|
||||
|
||||
async fn record_auth_warren_edit_success(&self) -> () {
|
||||
async fn record_auth_warren_edit_success(&self) {
|
||||
tracing::debug!("[Metrics] Warren edit by admin succeeded");
|
||||
}
|
||||
async fn record_auth_warren_edit_failure(&self) -> () {
|
||||
async fn record_auth_warren_edit_failure(&self) {
|
||||
tracing::debug!("[Metrics] Warren edit by admin failed");
|
||||
}
|
||||
|
||||
async fn record_auth_warren_deletion_success(&self) -> () {
|
||||
async fn record_auth_warren_deletion_success(&self) {
|
||||
tracing::debug!("[Metrics] Warren deletion by admin succeeded");
|
||||
}
|
||||
async fn record_auth_warren_deletion_failure(&self) -> () {
|
||||
async fn record_auth_warren_deletion_failure(&self) {
|
||||
tracing::debug!("[Metrics] Warren deletion by admin failed");
|
||||
}
|
||||
|
||||
@@ -203,6 +206,13 @@ impl AuthMetrics for MetricsDebugLogger {
|
||||
tracing::debug!("[Metrics] User login failed");
|
||||
}
|
||||
|
||||
async fn record_user_login_oidc_success(&self) {
|
||||
tracing::debug!("[Metrics] User login succeeded");
|
||||
}
|
||||
async fn record_user_login_oidc_failure(&self) {
|
||||
tracing::debug!("[Metrics] User login failed");
|
||||
}
|
||||
|
||||
async fn record_user_creation_success(&self) {
|
||||
tracing::debug!("[Metrics] User creation succeeded");
|
||||
}
|
||||
@@ -350,3 +360,19 @@ impl AuthMetrics for MetricsDebugLogger {
|
||||
tracing::debug!("[Metrics] Auth warren cp failed");
|
||||
}
|
||||
}
|
||||
|
||||
impl OidcMetrics for MetricsDebugLogger {
|
||||
async fn record_get_redirect_success(&self) {
|
||||
tracing::debug!("[Metrics] OIDC get redirect succeeded");
|
||||
}
|
||||
async fn record_get_redirect_failure(&self) {
|
||||
tracing::debug!("[Metrics] OIDC get redirect failed");
|
||||
}
|
||||
|
||||
async fn record_get_user_info_success(&self) {
|
||||
tracing::debug!("[Metrics] OIDC get user info succeeded");
|
||||
}
|
||||
async fn record_get_user_info_failure(&self) {
|
||||
tracing::debug!("[Metrics] OIDC get user info failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod file_system;
|
||||
pub mod metrics_debug_logger;
|
||||
pub mod notifier_debug_logger;
|
||||
pub mod oidc;
|
||||
pub mod postgres;
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::warren::{
|
||||
models::{
|
||||
auth_session::requests::FetchAuthSessionResponse,
|
||||
file::{AbsoluteFilePath, LsResponse},
|
||||
user::{ListAllUsersAndWarrensResponse, LoginUserResponse, User},
|
||||
user_warren::UserWarren,
|
||||
warren::{
|
||||
Warren, WarrenCpResponse, WarrenLsResponse, WarrenMkdirResponse, WarrenMvResponse,
|
||||
WarrenRmResponse, WarrenSaveResponse, WarrenTouchResponse,
|
||||
},
|
||||
use crate::domain::{
|
||||
oidc::{
|
||||
ports::OidcNotifier,
|
||||
requests::{GetRedirectResponse, GetUserInfoResponse},
|
||||
},
|
||||
warren::{
|
||||
models::{
|
||||
auth_session::requests::FetchAuthSessionResponse,
|
||||
file::{AbsoluteFilePath, LsResponse},
|
||||
user::{
|
||||
ListAllUsersAndWarrensResponse, LoginUserOidcResponse, LoginUserResponse, User,
|
||||
},
|
||||
user_warren::UserWarren,
|
||||
warren::{
|
||||
Warren, WarrenCpResponse, WarrenLsResponse, WarrenMkdirResponse, WarrenMvResponse,
|
||||
WarrenRmResponse, WarrenSaveResponse, WarrenTouchResponse,
|
||||
},
|
||||
},
|
||||
ports::{AuthNotifier, FileSystemNotifier, WarrenNotifier},
|
||||
},
|
||||
ports::{AuthNotifier, FileSystemNotifier, WarrenNotifier},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -220,6 +228,13 @@ impl AuthNotifier for NotifierDebugLogger {
|
||||
tracing::debug!("[Notifier] Logged in user {}", response.user().name());
|
||||
}
|
||||
|
||||
async fn user_logged_in_oidc(&self, response: &LoginUserOidcResponse) {
|
||||
tracing::debug!(
|
||||
"[Notifier] Logged in user {} with OIDC",
|
||||
response.user().name()
|
||||
);
|
||||
}
|
||||
|
||||
async fn auth_session_created(&self, user_id: &Uuid) {
|
||||
tracing::debug!("[Notifier] Created auth session for user {}", user_id);
|
||||
}
|
||||
@@ -354,3 +369,19 @@ impl AuthNotifier for NotifierDebugLogger {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl OidcNotifier for NotifierDebugLogger {
|
||||
async fn get_redirect(&self, response: &GetRedirectResponse) {
|
||||
tracing::debug!("[Notifier] Got OIDC redirect: {}", response.url());
|
||||
}
|
||||
async fn get_user_info(&self, response: &GetUserInfoResponse) {
|
||||
tracing::debug!(
|
||||
"[Notifier] Got OIDC user info: {} ({})",
|
||||
response
|
||||
.info()
|
||||
.preferred_username()
|
||||
.unwrap_or(response.info().name()),
|
||||
response.info().sub(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
187
backend/src/lib/outbound/oidc.rs
Normal file
187
backend/src/lib/outbound/oidc.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
use std::{str::FromStr as _, sync::Arc};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use openid::{Client, CompactJson, CustomClaims, Discovered, Options, StandardClaims};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
config::Config,
|
||||
domain::{
|
||||
oidc::{
|
||||
models::UserInfo,
|
||||
ports::OidcRepository,
|
||||
requests::{
|
||||
GetRedirectError, GetRedirectRequest, GetRedirectResponse, GetUserInfoError,
|
||||
GetUserInfoRequest, GetUserInfoResponse,
|
||||
},
|
||||
},
|
||||
warren::models::user::{UserEmail, UserName},
|
||||
},
|
||||
};
|
||||
|
||||
type OidcClient = Client<Discovered, Claims>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OidcConfig {
|
||||
issuer_url: String,
|
||||
client_id: String,
|
||||
client_secret: String,
|
||||
redirect_url: String,
|
||||
origin_url: String,
|
||||
}
|
||||
|
||||
impl OidcConfig {
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
let issuer_url = Config::load_env("OIDC_ISSUER_URL")?;
|
||||
let client_id = Config::load_env("OIDC_CLIENT_ID")?;
|
||||
let client_secret = Config::load_env("OIDC_CLIENT_SECRET")?;
|
||||
let origin = Config::load_env("OIDC_ORIGIN_URL")?;
|
||||
let redirect_url = Config::load_env("OIDC_REDIRECT_URL")?;
|
||||
|
||||
Ok(Self::new(
|
||||
issuer_url,
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_url,
|
||||
origin,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
issuer_url: String,
|
||||
client_id: String,
|
||||
client_secret: String,
|
||||
redirect_url: String,
|
||||
origin_url: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
issuer_url,
|
||||
client_id,
|
||||
client_secret,
|
||||
redirect_url,
|
||||
origin_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Oidc {
|
||||
client: OidcClient,
|
||||
|
||||
auth_options: Arc<openid::Options>,
|
||||
}
|
||||
|
||||
impl Oidc {
|
||||
pub async fn new(config: &OidcConfig) -> anyhow::Result<Self> {
|
||||
let client = OidcClient::discover(
|
||||
config.client_id.clone(),
|
||||
config.client_secret.clone(),
|
||||
config.redirect_url.clone(),
|
||||
Url::from_str(&config.issuer_url)?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let auth_options = Options {
|
||||
scope: Some("openid profile email".into()),
|
||||
state: Some(config.origin_url.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
auth_options: Arc::new(auth_options),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct Claims {
|
||||
warren_admin: Option<bool>,
|
||||
#[serde(flatten)]
|
||||
standard_claims: StandardClaims,
|
||||
}
|
||||
|
||||
impl CustomClaims for Claims {
|
||||
fn standard_claims(&self) -> &StandardClaims {
|
||||
&self.standard_claims
|
||||
}
|
||||
}
|
||||
|
||||
impl CompactJson for Claims {}
|
||||
|
||||
impl OidcRepository for Oidc {
|
||||
async fn get_redirect(
|
||||
&self,
|
||||
_request: GetRedirectRequest,
|
||||
) -> Result<GetRedirectResponse, GetRedirectError> {
|
||||
let url = self.client.auth_url(&self.auth_options);
|
||||
|
||||
Ok(GetRedirectResponse::new(url.into()))
|
||||
}
|
||||
|
||||
async fn get_user_info(
|
||||
&self,
|
||||
request: GetUserInfoRequest,
|
||||
) -> Result<GetUserInfoResponse, GetUserInfoError> {
|
||||
let mut token: openid::Token<Claims> = self
|
||||
.client
|
||||
.request_token(request.code())
|
||||
.await
|
||||
.context("Failed to request token")?
|
||||
.into();
|
||||
|
||||
let id_token = token
|
||||
.id_token
|
||||
.as_mut()
|
||||
.context("Token didn't have id_token field")?;
|
||||
|
||||
self.client
|
||||
.decode_token(id_token)
|
||||
.context("Failed to decode token")?;
|
||||
|
||||
id_token
|
||||
.payload_mut()
|
||||
.context("Failed to get id_token payload")?
|
||||
.standard_claims
|
||||
.azp = Some(self.client.client_id.clone());
|
||||
|
||||
self.client
|
||||
.validate_token(id_token, None, None)
|
||||
.context("Failed to validate token")?;
|
||||
|
||||
let user_info = try_get_user_info(
|
||||
id_token
|
||||
.payload()
|
||||
.context("Failed to get id token payload")?,
|
||||
)?;
|
||||
|
||||
Ok(GetUserInfoResponse::new(user_info))
|
||||
}
|
||||
}
|
||||
|
||||
fn try_get_user_info(claims: &Claims) -> anyhow::Result<UserInfo> {
|
||||
let sub = claims.standard_claims.sub.clone();
|
||||
let raw = &claims.standard_claims.userinfo;
|
||||
|
||||
let name = UserName::new(raw.name.as_ref().context("Missing name")?)?;
|
||||
let email = UserEmail::new(raw.email.as_ref().context("Missing email")?)?;
|
||||
let preferred_username = if let Some(preferred_username) = raw.preferred_username.as_ref() {
|
||||
Some(UserName::new(preferred_username)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let user_info = UserInfo::new(
|
||||
sub,
|
||||
name,
|
||||
email,
|
||||
preferred_username,
|
||||
raw.picture.as_ref().map(|url| url.clone().into()),
|
||||
raw.locale.clone(),
|
||||
raw.updated_at,
|
||||
claims.warren_admin,
|
||||
);
|
||||
|
||||
Ok(user_info)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, anyhow};
|
||||
use anyhow::{Context as _, anyhow, bail};
|
||||
use argon2::{
|
||||
Argon2, PasswordHash, PasswordVerifier as _,
|
||||
password_hash::{
|
||||
@@ -23,8 +23,9 @@ use crate::domain::warren::{
|
||||
},
|
||||
},
|
||||
user::{
|
||||
CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError,
|
||||
EditUserRequest, ListAllUsersAndWarrensError, ListAllUsersAndWarrensRequest,
|
||||
CreateOrUpdateUserOidcError, CreateOrUpdateUserOidcRequest, CreateUserError,
|
||||
CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError, EditUserRequest,
|
||||
ListAllUsersAndWarrensError, ListAllUsersAndWarrensRequest,
|
||||
ListAllUsersAndWarrensResponse, ListUsersError, ListUsersRequest, User, UserEmail,
|
||||
UserName, UserPassword, VerifyUserPasswordError, VerifyUserPasswordRequest,
|
||||
},
|
||||
@@ -66,6 +67,30 @@ impl AuthRepository for Postgres {
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
async fn create_or_update_user_oidc(
|
||||
&self,
|
||||
request: CreateOrUpdateUserOidcRequest,
|
||||
) -> Result<User, CreateOrUpdateUserOidcError> {
|
||||
let mut connection = self
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
|
||||
let user = self
|
||||
.create_or_update_user(
|
||||
&mut connection,
|
||||
request.sub(),
|
||||
request.name(),
|
||||
request.email(),
|
||||
request.admin(),
|
||||
)
|
||||
.await
|
||||
.context(format!("Failed to create or update user"))?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
async fn edit_user(&self, request: EditUserRequest) -> Result<User, EditUserError> {
|
||||
let mut connection = self
|
||||
.pool
|
||||
@@ -127,7 +152,11 @@ impl AuthRepository for Postgres {
|
||||
}
|
||||
})?;
|
||||
|
||||
self.check_user_password_against_hash(request.password(), user.password_hash())?;
|
||||
self.check_user_password_against_hash(
|
||||
request.password(),
|
||||
user.password_hash()
|
||||
.ok_or(VerifyUserPasswordError::PasswordNotAllowed)?,
|
||||
)?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
@@ -423,6 +452,121 @@ impl Postgres {
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
async fn create_or_update_user(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
sub: &String,
|
||||
name: &UserName,
|
||||
email: &UserEmail,
|
||||
is_admin: bool,
|
||||
) -> anyhow::Result<User> {
|
||||
let mut tx = connection.begin().await?;
|
||||
|
||||
let existing_user: Option<User> = sqlx::query_as(
|
||||
"
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
users
|
||||
WHERE
|
||||
oidc_sub = $1 OR
|
||||
email = $2
|
||||
",
|
||||
)
|
||||
.bind(sub)
|
||||
.bind(email)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let user: User = match existing_user {
|
||||
Some(existing_user) => {
|
||||
if let Some(existing_oidc_sub) = existing_user.oidc_sub() {
|
||||
if existing_oidc_sub != sub {
|
||||
// TODO: Return a proper error
|
||||
bail!("The user is already linked to another OIDC subject");
|
||||
}
|
||||
|
||||
sqlx::query_as(
|
||||
"
|
||||
UPDATE
|
||||
users
|
||||
SET
|
||||
name = $3,
|
||||
email = $4,
|
||||
admin = $5
|
||||
WHERE
|
||||
id = $1 AND
|
||||
oidc_sub = $2
|
||||
RETURNING
|
||||
*
|
||||
",
|
||||
)
|
||||
.bind(existing_user.id())
|
||||
.bind(sub)
|
||||
.bind(name)
|
||||
.bind(email)
|
||||
.bind(is_admin)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as(
|
||||
"
|
||||
UPDATE
|
||||
users
|
||||
SET
|
||||
oidc_sub = $2,
|
||||
name = $3,
|
||||
email = $4,
|
||||
admin = $5
|
||||
WHERE
|
||||
id = $1 AND
|
||||
oidc_sub IS NULL
|
||||
RETURNING
|
||||
*
|
||||
",
|
||||
)
|
||||
.bind(existing_user.id())
|
||||
.bind(sub)
|
||||
.bind(name)
|
||||
.bind(email)
|
||||
.bind(is_admin)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
}
|
||||
}
|
||||
None => {
|
||||
sqlx::query_as(
|
||||
"
|
||||
INSERT INTO users (
|
||||
oidc_sub,
|
||||
name,
|
||||
email,
|
||||
admin
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4
|
||||
)
|
||||
RETURNING
|
||||
*
|
||||
",
|
||||
)
|
||||
.bind(sub)
|
||||
.bind(name)
|
||||
.bind(email)
|
||||
.bind(is_admin)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
async fn edit_user(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
|
||||
Reference in New Issue
Block a user