Skip to content

Commit

Permalink
Feat: Add endpoint for changing password
Browse files Browse the repository at this point in the history
Feat: Add endpoint for registering a new user
Chore: Slightly clean up CombinedAuthorizationProviderError conversions
  • Loading branch information
TobiasDeBruijn committed Dec 28, 2024
1 parent d198ba2 commit 5fad4c0
Show file tree
Hide file tree
Showing 11 changed files with 217 additions and 28 deletions.
2 changes: 1 addition & 1 deletion server/database/src/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use sqlx::{FromRow, Result};
use std::fmt::Debug;
use tracing::instrument;

#[derive(Debug, FromRow)]
#[derive(Debug, Clone, FromRow)]
pub struct User {
pub user_id: String,
pub name: String,
Expand Down
30 changes: 6 additions & 24 deletions server/wilford/src/authorization/combined.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ use crate::authorization::{AuthorizationError, AuthorizationProvider, UserInform
use crate::config::{AuthorizationProviderType, Config};
use database::driver::Database;
use espocrm_rs::EspoApiClient;
use std::error::Error;
use std::fmt::Debug;
use thiserror::Error;

Expand All @@ -18,23 +17,6 @@ pub enum CombinedAuthorizationProviderError {
EspoCrm(#[from] EspoAuthorizationProviderError),
}

/// Convert an authorization error with a generic inner type into a combined authorization error,
/// if E can be turned into a combined error.
fn into_combined_error<E>(
val: AuthorizationError<E>,
) -> AuthorizationError<CombinedAuthorizationProviderError>
where
E: Debug + Error + Into<CombinedAuthorizationProviderError>,
{
match val {
AuthorizationError::Other(e) => AuthorizationError::Other(e.into()),
AuthorizationError::InvalidCredentials => AuthorizationError::InvalidCredentials,
AuthorizationError::AlreadyExists => AuthorizationError::AlreadyExists,
AuthorizationError::TotpNeeded => AuthorizationError::TotpNeeded,
AuthorizationError::UnsupportedOperation => AuthorizationError::UnsupportedOperation,
}
}

/// Abstraction over all the different authorization providers,
/// providing a single object to work with.
pub enum CombinedAuthorizationProvider<'a> {
Expand Down Expand Up @@ -88,12 +70,12 @@ impl<'a> AuthorizationProvider for CombinedAuthorizationProvider<'a> {
Self::Local(credentials_provider) => credentials_provider
.validate_credentials(username, password, totp_code)
.await
.map_err(into_combined_error)?,
.map_err(AuthorizationError::convert)?,

Self::EspoCrm(espocrm) => espocrm
.validate_credentials(username, password, totp_code)
.await
.map_err(into_combined_error)?,
.map_err(AuthorizationError::convert)?,
})
}

Expand All @@ -113,11 +95,11 @@ impl<'a> AuthorizationProvider for CombinedAuthorizationProvider<'a> {
Self::Local(local) => local
.set_password(user_id, new_password)
.await
.map_err(into_combined_error)?,
.map_err(AuthorizationError::convert)?,
Self::EspoCrm(espocrm) => espocrm
.set_password(user_id, new_password)
.await
.map_err(into_combined_error)?,
.map_err(AuthorizationError::convert)?,
})
}

Expand All @@ -139,11 +121,11 @@ impl<'a> AuthorizationProvider for CombinedAuthorizationProvider<'a> {
Self::Local(credentials_provider) => credentials_provider
.register_user(name, email, password, is_admin)
.await
.map_err(into_combined_error)?,
.map_err(AuthorizationError::convert)?,
Self::EspoCrm(espocrm) => espocrm
.register_user(name, email, password, is_admin)
.await
.map_err(into_combined_error)?,
.map_err(AuthorizationError::convert)?,
})
}
}
18 changes: 18 additions & 0 deletions server/wilford/src/authorization/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ pub enum AuthorizationError<E: Error + Debug> {
Other(#[from] E),
}

impl<E: Error + Debug> AuthorizationError<E> {
/// Convert an authorization error with a generic inner type into an authorization error with a different `Other` type,
/// if `E` can be turned into a `T`.
// This is not possible with a `impl<T, U> From<AuthorizationError<T>> for AuthorizationError<U> where U: From<T>` as it overlaps
// with the standard library in the case that `T = U`. We know it won't, but we can't yet tell the compiler that.
fn convert<T: Error + Debug + From<E>>(self) -> AuthorizationError<T> {
match self {
AuthorizationError::Other(e) => AuthorizationError::Other(T::from(e)),
AuthorizationError::InvalidCredentials => AuthorizationError::InvalidCredentials,
AuthorizationError::AlreadyExists => AuthorizationError::AlreadyExists,
AuthorizationError::TotpNeeded => AuthorizationError::TotpNeeded,
AuthorizationError::UnsupportedOperation => AuthorizationError::UnsupportedOperation,
}
}
}

/// Information about the authorized user
#[derive(Debug)]
pub struct UserInformation {
Expand All @@ -31,8 +47,10 @@ pub struct UserInformation {
/// If true, all scope checks should be ignored.
pub is_admin: bool,
/// The name of the user.
#[allow(unused)]
pub name: String,
/// The email address of the user.
#[allow(unused)]
pub email: String,
}

Expand Down
19 changes: 17 additions & 2 deletions server/wilford/src/routes/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub struct Auth {
pub user_id: String,
pub name: String,
pub is_admin: bool,
pub user: User,
token: AccessToken,
}

Expand Down Expand Up @@ -48,21 +49,24 @@ impl FromRequest for Auth {
.ok_or(WebError::from(WebErrorKind::InternalServerError))?;

Ok(Self {
user_id: user.user_id,
name: user.name,
user_id: user.user_id.clone(),
name: user.name.clone(),
is_admin: user.is_admin,
user,
token: token_info,
})
})
}
}

impl Auth {
/// Check if the provided scope is present.
#[must_use]
pub fn has_scope(&self, scope: &str) -> bool {
self.token.scopes().contains(scope)
}

/// Get the set of scopes the authorization is authorized for.
pub fn scopes(&self) -> HashSet<String> {
self.token.scopes()
}
Expand All @@ -71,7 +75,11 @@ impl Auth {
/// Authentication using a constant token.
/// These tokens are created manually.
pub struct ConstantAccessTokenAuth {
/// The name of the client
#[allow(unused)]
pub name: String,
/// The secret token
#[allow(unused)]
pub token: String,
}

Expand Down Expand Up @@ -100,6 +108,13 @@ impl FromRequest for ConstantAccessTokenAuth {
}
}

/// Get an authorization token from, in order:
/// - The `Authorization` header.
/// - The `Authorization` cookie.
///
/// # Errors
/// - If the token is not provided.
/// - If the token is invalid, e.g. it does not start with `Bearer `.
fn get_authorization_token(req: &HttpRequest) -> WebResult<String> {
let header = req
.headers()
Expand Down
3 changes: 3 additions & 0 deletions server/wilford/src/routes/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ pub enum WebErrorKind {
InvalidInternalState,
#[error("Forbidden")]
Forbidden,
#[error("Unsupported")]
Unsupported,
#[error("{0}")]
Database(#[from] database::driver::Error),
#[error("EspoCRM error: {0}")]
Expand All @@ -66,6 +68,7 @@ impl ResponseError for WebError {
WebErrorKind::BadRequest => StatusCode::BAD_REQUEST,
WebErrorKind::Unauthorized => StatusCode::UNAUTHORIZED,
WebErrorKind::Forbidden => StatusCode::FORBIDDEN,
WebErrorKind::Unsupported => StatusCode::NOT_IMPLEMENTED,
WebErrorKind::InvalidInternalState => StatusCode::INTERNAL_SERVER_ERROR,
WebErrorKind::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
WebErrorKind::Espo(_) => StatusCode::BAD_GATEWAY,
Expand Down
48 changes: 48 additions & 0 deletions server/wilford/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ mod oauth;
mod v1;
mod well_known;

use crate::authorization::combined::CombinedAuthorizationProviderError;
use crate::authorization::espo::EspoAuthorizationProviderError;
use crate::authorization::local_provider::LocalCredentialsProviderError;
use crate::authorization::AuthorizationError;
use crate::routes::error::{WebError, WebErrorKind};
pub use appdata::*;

pub struct Router;
Expand All @@ -22,3 +27,46 @@ impl Routable for Router {
);
}
}

pub enum Either<A, B> {
Left(A),
Right(B),
}

impl<A, B> Either<A, B> {
fn unwrap_left(self) -> A {
match self {
Self::Left(a) => a,
Self::Right(_) => panic!("Unwrapped on a Right value"),
}
}
}

/// Convert a result with an authorization error to a result with a web error.
/// The `Ok` value is returned in the `Left` branch. The `Right` branch
/// returns an empty tuple if the error is `TotpRequired`.
fn auth_error_to_web_error<T>(
e: Result<T, AuthorizationError<CombinedAuthorizationProviderError>>,
) -> Result<Either<T, ()>, WebError> {
match e {
Ok(t) => Ok(Either::Left(t)),
Err(AuthorizationError::TotpNeeded) => Ok(Either::Right(())),
Err(AuthorizationError::InvalidCredentials) => Err(WebErrorKind::Unauthorized.into()),
Err(AuthorizationError::UnsupportedOperation) => Err(WebErrorKind::Unsupported.into()),
Err(AuthorizationError::AlreadyExists) => Err(WebErrorKind::BadRequest.into()),
Err(AuthorizationError::Other(e)) => Err(match e {
CombinedAuthorizationProviderError::Local(e) => match e {
LocalCredentialsProviderError::Database(e) => e.into(),
LocalCredentialsProviderError::Hashing(_) => {
WebErrorKind::InternalServerError.into()
}
},
CombinedAuthorizationProviderError::EspoCrm(e) => match e {
EspoAuthorizationProviderError::Database(e) => e.into(),
EspoAuthorizationProviderError::Espocrm(_) => {
WebErrorKind::InternalServerError.into()
}
},
}),
}
}
51 changes: 51 additions & 0 deletions server/wilford/src/routes/v1/user/change_password.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
use crate::authorization::combined::CombinedAuthorizationProvider;
use crate::authorization::AuthorizationProvider;
use crate::response_types::Empty;
use crate::routes::auth::Auth;
use crate::routes::error::{WebErrorKind, WebResult};
use crate::routes::{auth_error_to_web_error, WConfig, WDatabase};
use actix_web::web;
use serde::Deserialize;

#[derive(Deserialize)]
pub struct Request {
/// The old password
old_password: String,
/// The new password
new_password: String,
}

/// Change the password of the authorized user
///
/// # Errors
///
/// - If the provider does not support changing passwords
/// - If the provi`ded `old_password` does not match the stored password
/// - If the change operation fails
pub async fn change_password(
auth: Auth,
payload: web::Json<Request>,
config: WConfig,
database: WDatabase,
) -> WebResult<Empty> {
let provider = CombinedAuthorizationProvider::new(&config, &database);
if !provider.supports_password_change() {
return Err(WebErrorKind::Unsupported.into());
}

// Check the old password is correct
auth_error_to_web_error(
provider
.validate_credentials(&auth.user.email, &payload.old_password, None)
.await,
)?;

// Set new password
auth_error_to_web_error(
provider
.set_password(&auth.user.user_id, &payload.new_password)
.await,
)?;

Ok(Empty)
}
1 change: 1 addition & 0 deletions server/wilford/src/routes/v1/user/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub struct Response {
espo_user_id: String,
}

/// Get information about the user
pub async fn info(auth: Auth) -> web::Json<Response> {
web::Json(Response {
name: auth.name,
Expand Down
10 changes: 10 additions & 0 deletions server/wilford/src/routes/v1/user/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,26 @@ use crate::routes::v1::MANAGE_SCOPE;

#[derive(Serialize)]
pub struct Response {
/// All registered users
users: Vec<User>,
}

#[derive(Serialize)]
pub struct User {
/// The name of the user
name: String,
/// The user ID
espo_user_id: String,
/// Whether the user is an admin
is_admin: bool,
}

/// List all users.
///
/// # Errors
///
/// - If the requesting user does not have the required scope.
/// - If the underlying request fails.
pub async fn list(database: WDatabase, auth: Auth) -> WebResult<web::Json<Response>> {
if !auth.has_scope(MANAGE_SCOPE) {
return Err(WebErrorKind::Forbidden.into());
Expand Down
9 changes: 8 additions & 1 deletion server/wilford/src/routes/v1/user/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ use actix_route_config::Routable;
use actix_web::web;
use actix_web::web::ServiceConfig;

mod change_password;
mod info;
mod list;
mod permitted_scopes;
mod register;

pub struct Router;

Expand All @@ -14,7 +16,12 @@ impl Routable for Router {
web::scope("/user")
.configure(permitted_scopes::Router::configure)
.route("/info", web::get().to(info::info))
.route("/list", web::get().to(list::list)),
.route("/list", web::get().to(list::list))
.route(
"/change-password",
web::post().to(change_password::change_password),
)
.route("/register", web::post().to(register::register)),
);
}
}
Loading

0 comments on commit 5fad4c0

Please sign in to comment.