1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-07-28 11:02:02 +03:00

Make password-based login optional

This commit is contained in:
Quentin Gliech
2023-05-23 14:20:27 +02:00
parent 25f045130e
commit d2d68e9a27
16 changed files with 572 additions and 118 deletions

1
Cargo.lock generated
View File

@ -3178,6 +3178,7 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
"url", "url",
"watchman_client", "watchman_client",
"zeroize",
] ]
[[package]] [[package]]

View File

@ -8,6 +8,16 @@ opt-level = 3
[profile.dev.package.sqlx-macros] [profile.dev.package.sqlx-macros]
opt-level = 3 opt-level = 3
[profile.dev.package.cranelift-codegen]
opt-level = 3
[profile.dev.package.regalloc2]
opt-level = 3
[profile.dev.package.argon2]
opt-level = 3
# Until https://github.com/dylanhart/ulid-rs/pull/56 gets released # Until https://github.com/dylanhart/ulid-rs/pull/56 gets released
[patch.crates-io.ulid] [patch.crates-io.ulid]
git = "https://github.com/dylanhart/ulid-rs.git" git = "https://github.com/dylanhart/ulid-rs.git"

View File

@ -27,6 +27,7 @@ tower = { version = "0.4.13", features = ["full"] }
tower-http = { version = "0.4.0", features = ["fs", "compression-full"] } tower-http = { version = "0.4.0", features = ["fs", "compression-full"] }
url = "2.3.1" url = "2.3.1"
watchman_client = "0.8.0" watchman_client = "0.8.0"
zeroize = "1.6.0"
tracing = "0.1.37" tracing = "0.1.37"
tracing-appender = "0.2.2" tracing-appender = "0.2.2"

View File

@ -327,7 +327,7 @@ impl Options {
let encrypter = config.secrets.encrypter(); let encrypter = config.secrets.encrypter();
let pool = database_from_config(&config.database).await?; let pool = database_from_config(&config.database).await?;
let url_builder = UrlBuilder::new(config.http.public_base); let url_builder = UrlBuilder::new(config.http.public_base);
let mut repo = PgRepository::from_pool(&pool).await?; let mut repo = PgRepository::from_pool(&pool).await?.boxed();
let requires_client_secret = token_endpoint_auth_method.requires_client_secret(); let requires_client_secret = token_endpoint_auth_method.requires_client_secret();
@ -362,6 +362,8 @@ impl Options {
) )
.await?; .await?;
repo.save().await?;
let redirect_uri = url_builder.upstream_oauth_callback(provider.id); let redirect_uri = url_builder.upstream_oauth_callback(provider.id);
let auth_uri = url_builder.upstream_oauth_authorize(provider.id); let auth_uri = url_builder.upstream_oauth_authorize(provider.id);
tracing::info!( tracing::info!(

View File

@ -33,6 +33,10 @@ use tracing::{error, info, log::LevelFilter};
pub async fn password_manager_from_config( pub async fn password_manager_from_config(
config: &PasswordsConfig, config: &PasswordsConfig,
) -> Result<PasswordManager, anyhow::Error> { ) -> Result<PasswordManager, anyhow::Error> {
if !config.enabled() {
return Ok(PasswordManager::disabled());
}
let schemes = config let schemes = config
.load() .load()
.await? .await?
@ -227,3 +231,76 @@ pub async fn watch_templates(templates: &Templates) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use rand::SeedableRng;
use zeroize::Zeroizing;
use super::*;
#[tokio::test]
async fn test_password_manager_from_config() {
let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42);
let password = Zeroizing::new(b"hunter2".to_vec());
// Test a valid, enabled config
let config = serde_json::from_value(serde_json::json!({
"schemes": [{
"version": 42,
"algorithm": "argon2id"
}, {
"version": 10,
"algorithm": "bcrypt"
}]
}))
.unwrap();
let manager = password_manager_from_config(&config).await;
assert!(manager.is_ok());
let manager = manager.unwrap();
assert!(manager.is_enabled());
let hashed = manager.hash(&mut rng, password.clone()).await;
assert!(hashed.is_ok());
let (version, hashed) = hashed.unwrap();
assert_eq!(version, 42);
assert!(hashed.starts_with("$argon2id$"));
// Test a valid, disabled config
let config = serde_json::from_value(serde_json::json!({
"enabled": false,
"schemes": []
}))
.unwrap();
let manager = password_manager_from_config(&config).await;
assert!(manager.is_ok());
let manager = manager.unwrap();
assert!(!manager.is_enabled());
let res = manager.hash(&mut rng, password.clone()).await;
assert!(res.is_err());
// Test an invalid config
// Repeat the same version twice
let config = serde_json::from_value(serde_json::json!({
"schemes": [{
"version": 42,
"algorithm": "argon2id"
}, {
"version": 42,
"algorithm": "bcrypt"
}]
}))
.unwrap();
let manager = password_manager_from_config(&config).await;
assert!(manager.is_err());
// Empty schemes
let config = serde_json::from_value(serde_json::json!({
"schemes": []
}))
.unwrap();
let manager = password_manager_from_config(&config).await;
assert!(manager.is_err());
}
}

View File

@ -31,9 +31,17 @@ fn default_schemes() -> Vec<HashingScheme> {
}] }]
} }
fn default_enabled() -> bool {
true
}
/// User password hashing config /// User password hashing config
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PasswordsConfig { pub struct PasswordsConfig {
/// Whether password-based authentication is enabled
#[serde(default = "default_enabled")]
enabled: bool,
#[serde(default = "default_schemes")] #[serde(default = "default_schemes")]
schemes: Vec<HashingScheme>, schemes: Vec<HashingScheme>,
} }
@ -41,6 +49,7 @@ pub struct PasswordsConfig {
impl Default for PasswordsConfig { impl Default for PasswordsConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
enabled: default_enabled(),
schemes: default_schemes(), schemes: default_schemes(),
} }
} }
@ -65,6 +74,12 @@ impl ConfigurationSection<'_> for PasswordsConfig {
} }
impl PasswordsConfig { impl PasswordsConfig {
/// Whether password-based authentication is enabled
#[must_use]
pub fn enabled(&self) -> bool {
self.enabled
}
/// Load the password hashing schemes defined by the config /// Load the password hashing schemes defined by the config
/// ///
/// # Errors /// # Errors

View File

@ -66,18 +66,28 @@ struct LoginTypes {
} }
#[tracing::instrument(name = "handlers.compat.login.get", skip_all)] #[tracing::instrument(name = "handlers.compat.login.get", skip_all)]
pub(crate) async fn get() -> impl IntoResponse { pub(crate) async fn get(State(password_manager): State<PasswordManager>) -> impl IntoResponse {
let res = LoginTypes { let flows = if password_manager.is_enabled() {
flows: vec![ vec![
LoginType::Password, LoginType::Password,
LoginType::Sso { LoginType::Sso {
identity_providers: vec![], identity_providers: vec![],
delegated_oidc_compatibility: true, delegated_oidc_compatibility: true,
}, },
LoginType::Token, LoginType::Token,
], ]
} else {
vec![
LoginType::Sso {
identity_providers: vec![],
delegated_oidc_compatibility: true,
},
LoginType::Token,
]
}; };
let res = LoginTypes { flows };
Json(res) Json(res)
} }
@ -202,11 +212,14 @@ pub(crate) async fn post(
State(homeserver): State<MatrixHomeserver>, State(homeserver): State<MatrixHomeserver>,
Json(input): Json<RequestBody>, Json(input): Json<RequestBody>,
) -> Result<impl IntoResponse, RouteError> { ) -> Result<impl IntoResponse, RouteError> {
let (session, user) = match input.credentials { let (session, user) = match (password_manager.is_enabled(), input.credentials) {
Credentials::Password { (
identifier: Identifier::User { user }, true,
password, Credentials::Password {
} => { identifier: Identifier::User { user },
password,
},
) => {
user_password_login( user_password_login(
&mut rng, &mut rng,
&clock, &clock,
@ -218,7 +231,7 @@ pub(crate) async fn post(
.await? .await?
} }
Credentials::Token { token } => token_login(&mut repo, &clock, &token).await?, (_, Credentials::Token { token }) => token_login(&mut repo, &clock, &token).await?,
_ => { _ => {
return Err(RouteError::Unsupported); return Err(RouteError::Unsupported);
@ -407,7 +420,7 @@ mod tests {
init_tracing(); init_tracing();
let state = TestState::from_pool(pool).await.unwrap(); let state = TestState::from_pool(pool).await.unwrap();
// Now let's try to login with the password, without asking for a refresh token. // Now let's get the login flows
let request = Request::get("/_matrix/client/v3/login").empty(); let request = Request::get("/_matrix/client/v3/login").empty();
let response = state.request(request).await; let response = state.request(request).await;
response.assert_status(StatusCode::OK); response.assert_status(StatusCode::OK);
@ -432,6 +445,54 @@ mod tests {
); );
} }
/// Test that the server doesn't allow login with a password if the password
/// manager is disabled
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_password_disabled(pool: PgPool) {
init_tracing();
let state = {
let mut state = TestState::from_pool(pool).await.unwrap();
state.password_manager = PasswordManager::disabled();
state
};
// Now let's get the login flows
let request = Request::get("/_matrix/client/v3/login").empty();
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let body: serde_json::Value = response.json();
assert_eq!(
body,
serde_json::json!({
"flows": [
{
"type": "m.login.sso",
"org.matrix.msc3824.delegated_oidc_compatibility": true,
},
{
"type": "m.login.token",
}
],
})
);
// Try to login with a password, it should be rejected
let request = Request::post("/_matrix/client/v3/login").json(serde_json::json!({
"type": "m.login.password",
"identifier": {
"type": "m.id.user",
"user": "alice",
},
"password": "password",
}));
let response = state.request(request).await;
response.assert_status(StatusCode::BAD_REQUEST);
let body: serde_json::Value = response.json();
assert_eq!(body["errcode"], "M_UNRECOGNIZED");
}
/// Test that a user can login with a password using the Matrix /// Test that a user can login with a password using the Matrix
/// compatibility API. /// compatibility API.
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]

View File

@ -19,14 +19,26 @@ use argon2::{password_hash::SaltString, Argon2, PasswordHash, PasswordHasher, Pa
use futures_util::future::OptionFuture; use futures_util::future::OptionFuture;
use pbkdf2::Pbkdf2; use pbkdf2::Pbkdf2;
use rand::{CryptoRng, Rng, RngCore, SeedableRng}; use rand::{CryptoRng, Rng, RngCore, SeedableRng};
use thiserror::Error;
use zeroize::Zeroizing; use zeroize::Zeroizing;
pub type SchemeVersion = u16; pub type SchemeVersion = u16;
#[derive(Debug, Error)]
#[error("Password manager is disabled")]
pub struct PasswordManagerDisabledError;
#[derive(Clone)] #[derive(Clone)]
pub struct PasswordManager { pub struct PasswordManager {
hashers: Arc<HashMap<SchemeVersion, Hasher>>, inner: Option<Arc<InnerPasswordManager>>,
default_hasher: SchemeVersion, }
struct InnerPasswordManager {
current_hasher: Hasher,
current_version: SchemeVersion,
/// A map of "old" hashers used only for verification
other_hashers: HashMap<SchemeVersion, Hasher>,
} }
impl PasswordManager { impl PasswordManager {
@ -51,58 +63,87 @@ impl PasswordManager {
pub fn new<I: IntoIterator<Item = (SchemeVersion, Hasher)>>( pub fn new<I: IntoIterator<Item = (SchemeVersion, Hasher)>>(
iter: I, iter: I,
) -> Result<Self, anyhow::Error> { ) -> Result<Self, anyhow::Error> {
let mut iter = iter.into_iter().peekable(); let mut iter = iter.into_iter();
let (default_hasher, _) = iter
.peek()
.context("Iterator must have at least one item")?;
let default_hasher = *default_hasher;
let hashers = iter.collect(); // Take the first hasher as the current hasher
let (current_version, current_hasher) = iter
.next()
.context("Iterator must have at least one item")?;
// Collect the other hashers in a map used only in verification
let other_hashers = iter.collect();
Ok(Self { Ok(Self {
hashers: Arc::new(hashers), inner: Some(Arc::new(InnerPasswordManager {
default_hasher, current_hasher,
current_version,
other_hashers,
})),
}) })
} }
/// Creates a new disabled password manager
#[must_use]
pub const fn disabled() -> Self {
Self { inner: None }
}
/// Checks if the password manager is enabled or not
#[must_use]
pub const fn is_enabled(&self) -> bool {
self.inner.is_some()
}
/// Get the inner password manager
///
/// # Errors
///
/// Returns an error if the password manager is disabled
fn get_inner(&self) -> Result<Arc<InnerPasswordManager>, PasswordManagerDisabledError> {
self.inner
.as_ref()
.map(Arc::clone)
.ok_or(PasswordManagerDisabledError)
}
/// Hash a password with the default hashing scheme. /// Hash a password with the default hashing scheme.
/// Returns the version of the hashing scheme used and the hashed password. /// Returns the version of the hashing scheme used and the hashed password.
/// ///
/// # Errors /// # Errors
/// ///
/// Returns an error if the hashing failed /// Returns an error if the hashing failed or if the password manager is
/// disabled
#[tracing::instrument(name = "passwords.hash", skip_all)] #[tracing::instrument(name = "passwords.hash", skip_all)]
pub async fn hash<R: CryptoRng + RngCore + Send>( pub async fn hash<R: CryptoRng + RngCore + Send>(
&self, &self,
rng: R, rng: R,
password: Zeroizing<Vec<u8>>, password: Zeroizing<Vec<u8>>,
) -> Result<(SchemeVersion, String), anyhow::Error> { ) -> Result<(SchemeVersion, String), anyhow::Error> {
let inner = self.get_inner()?;
// Seed a future-local RNG so the RNG passed in parameters doesn't have to be // Seed a future-local RNG so the RNG passed in parameters doesn't have to be
// 'static // 'static
let rng = rand_chacha::ChaChaRng::from_rng(rng)?; let rng = rand_chacha::ChaChaRng::from_rng(rng)?;
let hashers = self.hashers.clone();
let default_hasher_version = self.default_hasher;
let span = tracing::Span::current(); let span = tracing::Span::current();
let hashed = tokio::task::spawn_blocking(move || { // `inner` is being moved in the blocking task, so we need to copy the version
span.in_scope(move || { // first
let default_hasher = hashers let version = inner.current_version;
.get(&default_hasher_version)
.context("Default hasher not found")?;
default_hasher.hash_blocking(rng, &password) let hashed = tokio::task::spawn_blocking(move || {
}) span.in_scope(move || inner.current_hasher.hash_blocking(rng, &password))
}) })
.await??; .await??;
Ok((default_hasher_version, hashed)) Ok((version, hashed))
} }
/// Verify a password hash for the given hashing scheme. /// Verify a password hash for the given hashing scheme.
/// ///
/// # Errors /// # Errors
/// ///
/// Returns an error if the password hash verification failed /// Returns an error if the password hash verification failed or if the
/// password manager is disabled
#[tracing::instrument(name = "passwords.verify", skip_all, fields(%scheme))] #[tracing::instrument(name = "passwords.verify", skip_all, fields(%scheme))]
pub async fn verify( pub async fn verify(
&self, &self,
@ -110,12 +151,20 @@ impl PasswordManager {
password: Zeroizing<Vec<u8>>, password: Zeroizing<Vec<u8>>,
hashed_password: String, hashed_password: String,
) -> Result<(), anyhow::Error> { ) -> Result<(), anyhow::Error> {
let hashers = self.hashers.clone(); let inner = self.get_inner()?;
let span = tracing::Span::current(); let span = tracing::Span::current();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
span.in_scope(move || { span.in_scope(move || {
let hasher = hashers.get(&scheme).context("Hashing scheme not found")?; let hasher = if scheme == inner.current_version {
&inner.current_hasher
} else {
inner
.other_hashers
.get(&scheme)
.context("Hashing scheme not found")?
};
hasher.verify_blocking(&hashed_password, &password) hasher.verify_blocking(&hashed_password, &password)
}) })
}) })
@ -129,7 +178,8 @@ impl PasswordManager {
/// ///
/// # Errors /// # Errors
/// ///
/// Returns an error if the password hash verification failed /// Returns an error if the password hash verification failed or if the
/// password manager is disabled
#[tracing::instrument(name = "passwords.verify_and_upgrade", skip_all, fields(%scheme))] #[tracing::instrument(name = "passwords.verify_and_upgrade", skip_all, fields(%scheme))]
pub async fn verify_and_upgrade<R: CryptoRng + RngCore + Send>( pub async fn verify_and_upgrade<R: CryptoRng + RngCore + Send>(
&self, &self,
@ -138,9 +188,11 @@ impl PasswordManager {
password: Zeroizing<Vec<u8>>, password: Zeroizing<Vec<u8>>,
hashed_password: String, hashed_password: String,
) -> Result<Option<(SchemeVersion, String)>, anyhow::Error> { ) -> Result<Option<(SchemeVersion, String)>, anyhow::Error> {
let inner = self.get_inner()?;
// If the current scheme isn't the default one, we also hash with the default // If the current scheme isn't the default one, we also hash with the default
// one so that // one so that
let new_hash_fut: OptionFuture<_> = (scheme != self.default_hasher) let new_hash_fut: OptionFuture<_> = (scheme != inner.current_version)
.then(|| self.hash(rng, password.clone())) .then(|| self.hash(rng, password.clone()))
.into(); .into();

View File

@ -15,6 +15,7 @@
use anyhow::Context; use anyhow::Context;
use axum::{ use axum::{
extract::{Form, State}, extract::{Form, State},
http::StatusCode,
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
use axum_extra::extract::PrivateCookieJar; use axum_extra::extract::PrivateCookieJar;
@ -48,9 +49,15 @@ pub(crate) async fn get(
mut rng: BoxRng, mut rng: BoxRng,
clock: BoxClock, clock: BoxClock,
State(templates): State<Templates>, State(templates): State<Templates>,
State(password_manager): State<PasswordManager>,
mut repo: BoxRepository, mut repo: BoxRepository,
cookie_jar: PrivateCookieJar<Encrypter>, cookie_jar: PrivateCookieJar<Encrypter>,
) -> Result<Response, FancyError> { ) -> Result<Response, FancyError> {
// If the password manager is disabled, we can go back to the account page.
if !password_manager.is_enabled() {
return Ok(mas_router::Account.go().into_response());
}
let (session_info, cookie_jar) = cookie_jar.session_info(); let (session_info, cookie_jar) = cookie_jar.session_info();
let maybe_session = session_info.load_session(&mut repo).await?; let maybe_session = session_info.load_session(&mut repo).await?;
@ -91,6 +98,11 @@ pub(crate) async fn post(
cookie_jar: PrivateCookieJar<Encrypter>, cookie_jar: PrivateCookieJar<Encrypter>,
Form(form): Form<ProtectedForm<ChangeForm>>, Form(form): Form<ProtectedForm<ChangeForm>>,
) -> Result<Response, FancyError> { ) -> Result<Response, FancyError> {
if !password_manager.is_enabled() {
// XXX: do something better here
return Ok(StatusCode::METHOD_NOT_ALLOWED.into_response());
}
let form = cookie_jar.verify_form(&clock, form)?; let form = cookie_jar.verify_form(&clock, form)?;
let (session_info, cookie_jar) = cookie_jar.session_info(); let (session_info, cookie_jar) = cookie_jar.session_info();

View File

@ -17,12 +17,14 @@ use axum::{
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
use axum_extra::extract::PrivateCookieJar; use axum_extra::extract::PrivateCookieJar;
use hyper::StatusCode;
use mas_axum_utils::{ use mas_axum_utils::{
csrf::{CsrfExt, CsrfToken, ProtectedForm}, csrf::{CsrfExt, CsrfToken, ProtectedForm},
FancyError, SessionInfoExt, FancyError, SessionInfoExt,
}; };
use mas_data_model::BrowserSession; use mas_data_model::BrowserSession;
use mas_keystore::Encrypter; use mas_keystore::Encrypter;
use mas_router::{Route, UpstreamOAuth2Authorize};
use mas_storage::{ use mas_storage::{
upstream_oauth2::UpstreamOAuthProviderRepository, upstream_oauth2::UpstreamOAuthProviderRepository,
user::{BrowserSessionRepository, UserPasswordRepository, UserRepository}, user::{BrowserSessionRepository, UserPasswordRepository, UserRepository},
@ -52,6 +54,7 @@ impl ToFormState for LoginForm {
pub(crate) async fn get( pub(crate) async fn get(
mut rng: BoxRng, mut rng: BoxRng,
clock: BoxClock, clock: BoxClock,
State(password_manager): State<PasswordManager>,
State(templates): State<Templates>, State(templates): State<Templates>,
mut repo: BoxRepository, mut repo: BoxRepository,
Query(query): Query<OptionalPostAuthAction>, Query(query): Query<OptionalPostAuthAction>,
@ -64,20 +67,38 @@ pub(crate) async fn get(
if maybe_session.is_some() { if maybe_session.is_some() {
let reply = query.go_next(); let reply = query.go_next();
Ok((cookie_jar, reply).into_response()) return Ok((cookie_jar, reply).into_response());
} else { };
let providers = repo.upstream_oauth_provider().all().await?;
let content = render(
LoginContext::default().with_upstrem_providers(providers),
query,
csrf_token,
&mut repo,
&templates,
)
.await?;
Ok((cookie_jar, Html(content)).into_response()) let providers = repo.upstream_oauth_provider().all().await?;
}
// If password-based login is disabled, and there is only one upstream provider,
// we can directly start an authorization flow
if !password_manager.is_enabled() && providers.len() == 1 {
let provider = providers.into_iter().next().unwrap();
let mut destination = UpstreamOAuth2Authorize::new(provider.id);
if let Some(action) = query.post_auth_action {
destination = destination.and_then(action);
};
return Ok((cookie_jar, destination.go()).into_response());
};
let content = render(
LoginContext::default()
// XXX: we might want to have a site-wide config in the templates context instead?
.with_password_login(password_manager.is_enabled())
.with_upstream_providers(providers),
query,
csrf_token,
&mut repo,
&templates,
)
.await?;
Ok((cookie_jar, Html(content)).into_response())
} }
#[tracing::instrument(name = "handlers.views.login.post", skip_all, err)] #[tracing::instrument(name = "handlers.views.login.post", skip_all, err)]
@ -91,6 +112,11 @@ pub(crate) async fn post(
cookie_jar: PrivateCookieJar<Encrypter>, cookie_jar: PrivateCookieJar<Encrypter>,
Form(form): Form<ProtectedForm<LoginForm>>, Form(form): Form<ProtectedForm<LoginForm>>,
) -> Result<Response, FancyError> { ) -> Result<Response, FancyError> {
if !password_manager.is_enabled() {
// XXX: is it necessary to have better errors here?
return Ok(StatusCode::METHOD_NOT_ALLOWED.into_response());
}
let form = cookie_jar.verify_form(&clock, form)?; let form = cookie_jar.verify_form(&clock, form)?;
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
@ -115,7 +141,7 @@ pub(crate) async fn post(
let content = render( let content = render(
LoginContext::default() LoginContext::default()
.with_form_state(state) .with_form_state(state)
.with_upstrem_providers(providers), .with_upstream_providers(providers),
query, query,
csrf_token, csrf_token,
&mut repo, &mut repo,
@ -251,3 +277,100 @@ async fn render(
let content = templates.render_login(&ctx).await?; let content = templates.render_login(&ctx).await?;
Ok(content) Ok(content)
} }
#[cfg(test)]
mod test {
use hyper::{
header::{CONTENT_TYPE, LOCATION},
Request, StatusCode,
};
use mas_iana::oauth::OAuthClientAuthenticationMethod;
use mas_router::Route;
use mas_storage::{upstream_oauth2::UpstreamOAuthProviderRepository, RepositoryAccess};
use mas_templates::escape_html;
use oauth2_types::scope::OPENID;
use sqlx::PgPool;
use crate::{
passwords::PasswordManager,
test_utils::{init_tracing, RequestBuilderExt, ResponseExt, TestState},
};
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_password_disabled(pool: PgPool) {
init_tracing();
let state = {
let mut state = TestState::from_pool(pool).await.unwrap();
state.password_manager = PasswordManager::disabled();
state
};
let mut rng = state.rng();
// Without password login and no upstream providers, we should get an error
// message
let response = state.request(Request::get("/login").empty()).await;
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
assert!(response.body().contains("No login method available"));
// Adding an upstream provider should redirect to it
let mut repo = state.repository().await.unwrap();
let first_provider = repo
.upstream_oauth_provider()
.add(
&mut rng,
&state.clock,
"https://first.com/".into(),
[OPENID].into_iter().collect(),
OAuthClientAuthenticationMethod::None,
None,
"first_client".into(),
None,
)
.await
.unwrap();
repo.save().await.unwrap();
let first_provider_login = mas_router::UpstreamOAuth2Authorize::new(first_provider.id);
let response = state.request(Request::get("/login").empty()).await;
response.assert_status(StatusCode::SEE_OTHER);
response.assert_header_value(LOCATION, &first_provider_login.relative_url());
// Adding a second provider should show a login page with both providers
let mut repo = state.repository().await.unwrap();
let second_provider = repo
.upstream_oauth_provider()
.add(
&mut rng,
&state.clock,
"https://second.com/".into(),
[OPENID].into_iter().collect(),
OAuthClientAuthenticationMethod::None,
None,
"second_client".into(),
None,
)
.await
.unwrap();
repo.save().await.unwrap();
let second_provider_login = mas_router::UpstreamOAuth2Authorize::new(second_provider.id);
let response = state.request(Request::get("/login").empty()).await;
response.assert_status(StatusCode::OK);
response.assert_header_value(CONTENT_TYPE, "text/html; charset=utf-8");
assert!(response
.body()
.contains(&escape_html(&first_provider.issuer)));
assert!(response
.body()
.contains(&escape_html(&first_provider_login.relative_url())));
assert!(response
.body()
.contains(&escape_html(&second_provider.issuer)));
assert!(response
.body()
.contains(&escape_html(&second_provider_login.relative_url())));
}
}

View File

@ -18,6 +18,7 @@ use axum::{
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
use axum_extra::extract::PrivateCookieJar; use axum_extra::extract::PrivateCookieJar;
use hyper::StatusCode;
use mas_axum_utils::{ use mas_axum_utils::{
csrf::{CsrfExt, ProtectedForm}, csrf::{CsrfExt, ProtectedForm},
FancyError, SessionInfoExt, FancyError, SessionInfoExt,
@ -44,11 +45,17 @@ pub(crate) struct ReauthForm {
pub(crate) async fn get( pub(crate) async fn get(
mut rng: BoxRng, mut rng: BoxRng,
clock: BoxClock, clock: BoxClock,
State(password_manager): State<PasswordManager>,
State(templates): State<Templates>, State(templates): State<Templates>,
mut repo: BoxRepository, mut repo: BoxRepository,
Query(query): Query<OptionalPostAuthAction>, Query(query): Query<OptionalPostAuthAction>,
cookie_jar: PrivateCookieJar<Encrypter>, cookie_jar: PrivateCookieJar<Encrypter>,
) -> Result<Response, FancyError> { ) -> Result<Response, FancyError> {
if !password_manager.is_enabled() {
// XXX: do something better here
return Ok(mas_router::Account.go().into_response());
}
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
let (session_info, cookie_jar) = cookie_jar.session_info(); let (session_info, cookie_jar) = cookie_jar.session_info();
@ -85,6 +92,11 @@ pub(crate) async fn post(
cookie_jar: PrivateCookieJar<Encrypter>, cookie_jar: PrivateCookieJar<Encrypter>,
Form(form): Form<ProtectedForm<ReauthForm>>, Form(form): Form<ProtectedForm<ReauthForm>>,
) -> Result<Response, FancyError> { ) -> Result<Response, FancyError> {
if !password_manager.is_enabled() {
// XXX: do something better here
return Ok(StatusCode::METHOD_NOT_ALLOWED.into_response());
}
let form = cookie_jar.verify_form(&clock, form)?; let form = cookie_jar.verify_form(&clock, form)?;
let (session_info, cookie_jar) = cookie_jar.session_info(); let (session_info, cookie_jar) = cookie_jar.session_info();

View File

@ -19,6 +19,7 @@ use axum::{
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
use axum_extra::extract::PrivateCookieJar; use axum_extra::extract::PrivateCookieJar;
use hyper::StatusCode;
use lettre::Address; use lettre::Address;
use mas_axum_utils::{ use mas_axum_utils::{
csrf::{CsrfExt, CsrfToken, ProtectedForm}, csrf::{CsrfExt, CsrfToken, ProtectedForm},
@ -59,6 +60,7 @@ pub(crate) async fn get(
mut rng: BoxRng, mut rng: BoxRng,
clock: BoxClock, clock: BoxClock,
State(templates): State<Templates>, State(templates): State<Templates>,
State(password_manager): State<PasswordManager>,
mut repo: BoxRepository, mut repo: BoxRepository,
Query(query): Query<OptionalPostAuthAction>, Query(query): Query<OptionalPostAuthAction>,
cookie_jar: PrivateCookieJar<Encrypter>, cookie_jar: PrivateCookieJar<Encrypter>,
@ -70,19 +72,26 @@ pub(crate) async fn get(
if maybe_session.is_some() { if maybe_session.is_some() {
let reply = query.go_next(); let reply = query.go_next();
Ok((cookie_jar, reply).into_response()) return Ok((cookie_jar, reply).into_response());
} else {
let content = render(
RegisterContext::default(),
query,
csrf_token,
&mut repo,
&templates,
)
.await?;
Ok((cookie_jar, Html(content)).into_response())
} }
if !password_manager.is_enabled() {
// If password-based login is disabled, redirect to the login page here
return Ok(mas_router::Login::from(query.post_auth_action)
.go()
.into_response());
}
let content = render(
RegisterContext::default(),
query,
csrf_token,
&mut repo,
&templates,
)
.await?;
Ok((cookie_jar, Html(content)).into_response())
} }
#[tracing::instrument(name = "handlers.views.register.post", skip_all, err)] #[tracing::instrument(name = "handlers.views.register.post", skip_all, err)]
@ -98,6 +107,10 @@ pub(crate) async fn post(
cookie_jar: PrivateCookieJar<Encrypter>, cookie_jar: PrivateCookieJar<Encrypter>,
Form(form): Form<ProtectedForm<RegisterForm>>, Form(form): Form<ProtectedForm<RegisterForm>>,
) -> Result<Response, FancyError> { ) -> Result<Response, FancyError> {
if !password_manager.is_enabled() {
return Ok(StatusCode::METHOD_NOT_ALLOWED.into_response());
}
let form = cookie_jar.verify_form(&clock, form)?; let form = cookie_jar.verify_form(&clock, form)?;
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
@ -233,3 +246,42 @@ async fn render(
let content = templates.render_register(&ctx).await?; let content = templates.render_register(&ctx).await?;
Ok(content) Ok(content)
} }
#[cfg(test)]
mod tests {
use hyper::{header::LOCATION, Request, StatusCode};
use mas_router::Route;
use sqlx::PgPool;
use crate::{
passwords::PasswordManager,
test_utils::{init_tracing, RequestBuilderExt, ResponseExt, TestState},
};
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_password_disabled(pool: PgPool) {
init_tracing();
let state = {
let mut state = TestState::from_pool(pool).await.unwrap();
state.password_manager = PasswordManager::disabled();
state
};
let request = Request::get(&*mas_router::Register::default().relative_url()).empty();
let response = state.request(request).await;
response.assert_status(StatusCode::SEE_OTHER);
response.assert_header_value(LOCATION, "/login");
let request = Request::post(&*mas_router::Register::default().relative_url()).form(
serde_json::json!({
"csrf": "abc",
"username": "john",
"email": "john@example.com",
"password": "hunter2",
"password_confirm": "hunter2",
}),
);
let response = state.request(request).await;
response.assert_status(StatusCode::METHOD_NOT_ALLOWED);
}
}

View File

@ -286,6 +286,7 @@ pub struct PostAuthContext {
pub struct LoginContext { pub struct LoginContext {
form: FormState<LoginFormField>, form: FormState<LoginFormField>,
next: Option<PostAuthContext>, next: Option<PostAuthContext>,
password_disabled: bool,
providers: Vec<UpstreamOAuthProvider>, providers: Vec<UpstreamOAuthProvider>,
} }
@ -295,15 +296,33 @@ impl TemplateContext for LoginContext {
Self: Sized, Self: Sized,
{ {
// TODO: samples with errors // TODO: samples with errors
vec![LoginContext { vec![
form: FormState::default(), LoginContext {
next: None, form: FormState::default(),
providers: Vec::new(), next: None,
}] password_disabled: true,
providers: Vec::new(),
},
LoginContext {
form: FormState::default(),
next: None,
password_disabled: false,
providers: Vec::new(),
},
]
} }
} }
impl LoginContext { impl LoginContext {
/// Set whether password login is enabled or not
#[must_use]
pub fn with_password_login(self, enabled: bool) -> Self {
Self {
password_disabled: !enabled,
..self
}
}
/// Set the form state /// Set the form state
#[must_use] #[must_use]
pub fn with_form_state(self, form: FormState<LoginFormField>) -> Self { pub fn with_form_state(self, form: FormState<LoginFormField>) -> Self {
@ -312,7 +331,7 @@ impl LoginContext {
/// Set the upstream OAuth 2.0 providers /// Set the upstream OAuth 2.0 providers
#[must_use] #[must_use]
pub fn with_upstrem_providers(self, providers: Vec<UpstreamOAuthProvider>) -> Self { pub fn with_upstream_providers(self, providers: Vec<UpstreamOAuthProvider>) -> Self {
Self { providers, ..self } Self { providers, ..self }
} }

View File

@ -31,6 +31,7 @@ use camino::{Utf8Path, Utf8PathBuf};
use mas_router::UrlBuilder; use mas_router::UrlBuilder;
use rand::Rng; use rand::Rng;
use serde::Serialize; use serde::Serialize;
pub use tera::escape_html;
use tera::{Context, Error as TeraError, Tera}; use tera::{Context, Error as TeraError, Tera};
use thiserror::Error; use thiserror::Error;
use tokio::{sync::RwLock, task::JoinError}; use tokio::{sync::RwLock, task::JoinError};

View File

@ -130,6 +130,7 @@
"passwords": { "passwords": {
"description": "Configuration related to user passwords", "description": "Configuration related to user passwords",
"default": { "default": {
"enabled": true,
"schemes": [ "schemes": [
{ {
"algorithm": "argon2id", "algorithm": "argon2id",
@ -1215,6 +1216,11 @@
"description": "User password hashing config", "description": "User password hashing config",
"type": "object", "type": "object",
"properties": { "properties": {
"enabled": {
"description": "Whether password-based authentication is enabled",
"default": true,
"type": "boolean"
},
"schemes": { "schemes": {
"default": [ "default": [
{ {

View File

@ -19,66 +19,76 @@ limitations under the License.
{% block content %} {% block content %}
<section class="flex items-center justify-center flex-1"> <section class="flex items-center justify-center flex-1">
<form method="POST" class="grid grid-cols-1 gap-6 w-96 m-2"> <form method="POST" class="grid grid-cols-1 gap-6 w-96 m-2">
{% if next and next.kind == "link_upstream" %} {% if not password_disabled %}
<div class="text-center"> {% if next and next.kind == "link_upstream" %}
<h1 class="text-lg text-center font-medium">Sign in to link</h1> <div class="text-center">
<p class="text-sm">Linking your <span class="break-keep text-links">{{ next.provider.issuer }}</span> account</p> <h1 class="text-lg text-center font-medium">Sign in to link</h1>
</div> <p class="text-sm">Linking your <span class="break-keep text-links">{{ next.provider.issuer }}</span> account</p>
{% else %}
<div class="text-center">
<h1 class="text-lg text-center font-medium">Sign in</h1>
<p>Please sign in to continue:</p>
</div>
{% endif %}
{% if form.errors is not empty %}
{% for error in form.errors %}
<div class="text-alert font-medium">
{{ errors::form_error_message(error=error) }}
</div> </div>
{% endfor %} {% else %}
{% endif %} <div class="text-center">
<h1 class="text-lg text-center font-medium">Sign in</h1>
<p>Please sign in to continue:</p>
</div>
{% endif %}
<input type="hidden" name="csrf" value="{{ csrf_token }}" /> {% if form.errors is not empty %}
{{ field::input(label="Username", name="username", form_state=form, autocomplete="username", autocorrect="off", autocapitalize="none") }} {% for error in form.errors %}
{{ field::input(label="Password", name="password", type="password", form_state=form, autocomplete="password") }} <div class="text-alert font-medium">
{% if next and next.kind == "continue_authorization_grant" %} {{ errors::form_error_message(error=error) }}
<div class="grid grid-cols-2 gap-4"> </div>
{{ back_to_client::link( {% endfor %}
text="Cancel", {% endif %}
class=button::outline_error_class(),
uri=next.grant.redirect_uri,
mode=next.grant.response_mode,
params=dict(error="access_denied", state=next.grant.state)
) }}
{{ button::button(text="Next") }}
</div>
{% else %}
<div class="grid grid-cols-1 gap-4">
{{ button::button(text="Next") }}
</div>
{% endif %}
{% if not next or next.kind != "link_upstream" %} <input type="hidden" name="csrf" value="{{ csrf_token }}" />
<div class="text-center mt-4"> {{ field::input(label="Username", name="username", form_state=form, autocomplete="username", autocorrect="off", autocapitalize="none") }}
Don't have an account yet? {{ field::input(label="Password", name="password", type="password", form_state=form, autocomplete="password") }}
{% set params = next | safe_get(key="params") | to_params(prefix="?") %} {% if next and next.kind == "continue_authorization_grant" %}
{{ button::link_text(text="Create an account", href="/register" ~ params) }} <div class="grid grid-cols-2 gap-4">
</div> {{ back_to_client::link(
text="Cancel",
class=button::outline_error_class(),
uri=next.grant.redirect_uri,
mode=next.grant.response_mode,
params=dict(error="access_denied", state=next.grant.state)
) }}
{{ button::button(text="Next") }}
</div>
{% else %}
<div class="grid grid-cols-1 gap-4">
{{ button::button(text="Next") }}
</div>
{% endif %}
{% if not next or next.kind != "link_upstream" %}
<div class="text-center mt-4">
Don't have an account yet?
{% set params = next | safe_get(key="params") | to_params(prefix="?") %}
{{ button::link_text(text="Create an account", href="/register" ~ params) }}
</div>
{% endif %}
{% endif %} {% endif %}
{% if providers %} {% if providers %}
<div class="flex items-center"> {% if not password_disabled %}
<hr class="flex-1" /> <div class="flex items-center">
<div class="mx-2">Or</div> <hr class="flex-1" />
<hr class="flex-1" /> <div class="mx-2">Or</div>
</div> <hr class="flex-1" />
</div>
{% endif %}
{% for provider in providers %} {% for provider in providers %}
{% set params = next | safe_get(key="params") | to_params(prefix="?") %} {% set params = next | safe_get(key="params") | to_params(prefix="?") %}
{{ button::link(text="Continue with " ~ provider.issuer, href="/upstream/authorize/" ~ provider.id ~ params) }} {{ button::link(text="Continue with " ~ provider.issuer, href="/upstream/authorize/" ~ provider.id ~ params) }}
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if not providers and password_disabled %}
<div class="text-center">
No login method available.
</div>
{% endif %}
</form> </form>
</section> </section>
{% endblock content %} {% endblock content %}