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",
"url",
"watchman_client",
"zeroize",
]
[[package]]

View File

@ -8,6 +8,16 @@ opt-level = 3
[profile.dev.package.sqlx-macros]
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
[patch.crates-io.ulid]
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"] }
url = "2.3.1"
watchman_client = "0.8.0"
zeroize = "1.6.0"
tracing = "0.1.37"
tracing-appender = "0.2.2"

View File

@ -327,7 +327,7 @@ impl Options {
let encrypter = config.secrets.encrypter();
let pool = database_from_config(&config.database).await?;
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();
@ -362,6 +362,8 @@ impl Options {
)
.await?;
repo.save().await?;
let redirect_uri = url_builder.upstream_oauth_callback(provider.id);
let auth_uri = url_builder.upstream_oauth_authorize(provider.id);
tracing::info!(

View File

@ -33,6 +33,10 @@ use tracing::{error, info, log::LevelFilter};
pub async fn password_manager_from_config(
config: &PasswordsConfig,
) -> Result<PasswordManager, anyhow::Error> {
if !config.enabled() {
return Ok(PasswordManager::disabled());
}
let schemes = config
.load()
.await?
@ -227,3 +231,76 @@ pub async fn watch_templates(templates: &Templates) -> anyhow::Result<()> {
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
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PasswordsConfig {
/// Whether password-based authentication is enabled
#[serde(default = "default_enabled")]
enabled: bool,
#[serde(default = "default_schemes")]
schemes: Vec<HashingScheme>,
}
@ -41,6 +49,7 @@ pub struct PasswordsConfig {
impl Default for PasswordsConfig {
fn default() -> Self {
Self {
enabled: default_enabled(),
schemes: default_schemes(),
}
}
@ -65,6 +74,12 @@ impl ConfigurationSection<'_> for 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
///
/// # Errors

View File

@ -66,18 +66,28 @@ struct LoginTypes {
}
#[tracing::instrument(name = "handlers.compat.login.get", skip_all)]
pub(crate) async fn get() -> impl IntoResponse {
let res = LoginTypes {
flows: vec![
pub(crate) async fn get(State(password_manager): State<PasswordManager>) -> impl IntoResponse {
let flows = if password_manager.is_enabled() {
vec![
LoginType::Password,
LoginType::Sso {
identity_providers: vec![],
delegated_oidc_compatibility: true,
},
LoginType::Token,
],
]
} else {
vec![
LoginType::Sso {
identity_providers: vec![],
delegated_oidc_compatibility: true,
},
LoginType::Token,
]
};
let res = LoginTypes { flows };
Json(res)
}
@ -202,11 +212,14 @@ pub(crate) async fn post(
State(homeserver): State<MatrixHomeserver>,
Json(input): Json<RequestBody>,
) -> Result<impl IntoResponse, RouteError> {
let (session, user) = match input.credentials {
let (session, user) = match (password_manager.is_enabled(), input.credentials) {
(
true,
Credentials::Password {
identifier: Identifier::User { user },
password,
} => {
},
) => {
user_password_login(
&mut rng,
&clock,
@ -218,7 +231,7 @@ pub(crate) async fn post(
.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);
@ -407,7 +420,7 @@ mod tests {
init_tracing();
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 response = state.request(request).await;
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
/// compatibility API.
#[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 pbkdf2::Pbkdf2;
use rand::{CryptoRng, Rng, RngCore, SeedableRng};
use thiserror::Error;
use zeroize::Zeroizing;
pub type SchemeVersion = u16;
#[derive(Debug, Error)]
#[error("Password manager is disabled")]
pub struct PasswordManagerDisabledError;
#[derive(Clone)]
pub struct PasswordManager {
hashers: Arc<HashMap<SchemeVersion, Hasher>>,
default_hasher: SchemeVersion,
inner: Option<Arc<InnerPasswordManager>>,
}
struct InnerPasswordManager {
current_hasher: Hasher,
current_version: SchemeVersion,
/// A map of "old" hashers used only for verification
other_hashers: HashMap<SchemeVersion, Hasher>,
}
impl PasswordManager {
@ -51,58 +63,87 @@ impl PasswordManager {
pub fn new<I: IntoIterator<Item = (SchemeVersion, Hasher)>>(
iter: I,
) -> Result<Self, anyhow::Error> {
let mut iter = iter.into_iter().peekable();
let (default_hasher, _) = iter
.peek()
.context("Iterator must have at least one item")?;
let default_hasher = *default_hasher;
let mut iter = iter.into_iter();
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 {
hashers: Arc::new(hashers),
default_hasher,
inner: Some(Arc::new(InnerPasswordManager {
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.
/// Returns the version of the hashing scheme used and the hashed password.
///
/// # 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)]
pub async fn hash<R: CryptoRng + RngCore + Send>(
&self,
rng: R,
password: Zeroizing<Vec<u8>>,
) -> 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
// 'static
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 hashed = tokio::task::spawn_blocking(move || {
span.in_scope(move || {
let default_hasher = hashers
.get(&default_hasher_version)
.context("Default hasher not found")?;
// `inner` is being moved in the blocking task, so we need to copy the version
// first
let version = inner.current_version;
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??;
Ok((default_hasher_version, hashed))
Ok((version, hashed))
}
/// Verify a password hash for the given hashing scheme.
///
/// # 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))]
pub async fn verify(
&self,
@ -110,12 +151,20 @@ impl PasswordManager {
password: Zeroizing<Vec<u8>>,
hashed_password: String,
) -> Result<(), anyhow::Error> {
let hashers = self.hashers.clone();
let inner = self.get_inner()?;
let span = tracing::Span::current();
tokio::task::spawn_blocking(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)
})
})
@ -129,7 +178,8 @@ impl PasswordManager {
///
/// # 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))]
pub async fn verify_and_upgrade<R: CryptoRng + RngCore + Send>(
&self,
@ -138,9 +188,11 @@ impl PasswordManager {
password: Zeroizing<Vec<u8>>,
hashed_password: String,
) -> 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
// 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()))
.into();

View File

@ -15,6 +15,7 @@
use anyhow::Context;
use axum::{
extract::{Form, State},
http::StatusCode,
response::{Html, IntoResponse, Response},
};
use axum_extra::extract::PrivateCookieJar;
@ -48,9 +49,15 @@ pub(crate) async fn get(
mut rng: BoxRng,
clock: BoxClock,
State(templates): State<Templates>,
State(password_manager): State<PasswordManager>,
mut repo: BoxRepository,
cookie_jar: PrivateCookieJar<Encrypter>,
) -> 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 maybe_session = session_info.load_session(&mut repo).await?;
@ -91,6 +98,11 @@ pub(crate) async fn post(
cookie_jar: PrivateCookieJar<Encrypter>,
Form(form): Form<ProtectedForm<ChangeForm>>,
) -> 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 (session_info, cookie_jar) = cookie_jar.session_info();

View File

@ -17,12 +17,14 @@ use axum::{
response::{Html, IntoResponse, Response},
};
use axum_extra::extract::PrivateCookieJar;
use hyper::StatusCode;
use mas_axum_utils::{
csrf::{CsrfExt, CsrfToken, ProtectedForm},
FancyError, SessionInfoExt,
};
use mas_data_model::BrowserSession;
use mas_keystore::Encrypter;
use mas_router::{Route, UpstreamOAuth2Authorize};
use mas_storage::{
upstream_oauth2::UpstreamOAuthProviderRepository,
user::{BrowserSessionRepository, UserPasswordRepository, UserRepository},
@ -52,6 +54,7 @@ impl ToFormState for LoginForm {
pub(crate) async fn get(
mut rng: BoxRng,
clock: BoxClock,
State(password_manager): State<PasswordManager>,
State(templates): State<Templates>,
mut repo: BoxRepository,
Query(query): Query<OptionalPostAuthAction>,
@ -64,11 +67,30 @@ pub(crate) async fn get(
if maybe_session.is_some() {
let reply = query.go_next();
Ok((cookie_jar, reply).into_response())
} else {
return Ok((cookie_jar, reply).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().with_upstrem_providers(providers),
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,
@ -78,7 +100,6 @@ pub(crate) async fn get(
Ok((cookie_jar, Html(content)).into_response())
}
}
#[tracing::instrument(name = "handlers.views.login.post", skip_all, err)]
pub(crate) async fn post(
@ -91,6 +112,11 @@ pub(crate) async fn post(
cookie_jar: PrivateCookieJar<Encrypter>,
Form(form): Form<ProtectedForm<LoginForm>>,
) -> 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 (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
@ -115,7 +141,7 @@ pub(crate) async fn post(
let content = render(
LoginContext::default()
.with_form_state(state)
.with_upstrem_providers(providers),
.with_upstream_providers(providers),
query,
csrf_token,
&mut repo,
@ -251,3 +277,100 @@ async fn render(
let content = templates.render_login(&ctx).await?;
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},
};
use axum_extra::extract::PrivateCookieJar;
use hyper::StatusCode;
use mas_axum_utils::{
csrf::{CsrfExt, ProtectedForm},
FancyError, SessionInfoExt,
@ -44,11 +45,17 @@ pub(crate) struct ReauthForm {
pub(crate) async fn get(
mut rng: BoxRng,
clock: BoxClock,
State(password_manager): State<PasswordManager>,
State(templates): State<Templates>,
mut repo: BoxRepository,
Query(query): Query<OptionalPostAuthAction>,
cookie_jar: PrivateCookieJar<Encrypter>,
) -> 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 (session_info, cookie_jar) = cookie_jar.session_info();
@ -85,6 +92,11 @@ pub(crate) async fn post(
cookie_jar: PrivateCookieJar<Encrypter>,
Form(form): Form<ProtectedForm<ReauthForm>>,
) -> 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 (session_info, cookie_jar) = cookie_jar.session_info();

View File

@ -19,6 +19,7 @@ use axum::{
response::{Html, IntoResponse, Response},
};
use axum_extra::extract::PrivateCookieJar;
use hyper::StatusCode;
use lettre::Address;
use mas_axum_utils::{
csrf::{CsrfExt, CsrfToken, ProtectedForm},
@ -59,6 +60,7 @@ pub(crate) async fn get(
mut rng: BoxRng,
clock: BoxClock,
State(templates): State<Templates>,
State(password_manager): State<PasswordManager>,
mut repo: BoxRepository,
Query(query): Query<OptionalPostAuthAction>,
cookie_jar: PrivateCookieJar<Encrypter>,
@ -70,8 +72,16 @@ pub(crate) async fn get(
if maybe_session.is_some() {
let reply = query.go_next();
Ok((cookie_jar, reply).into_response())
} else {
return Ok((cookie_jar, reply).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,
@ -83,7 +93,6 @@ pub(crate) async fn get(
Ok((cookie_jar, Html(content)).into_response())
}
}
#[tracing::instrument(name = "handlers.views.register.post", skip_all, err)]
#[allow(clippy::too_many_lines, clippy::too_many_arguments)]
@ -98,6 +107,10 @@ pub(crate) async fn post(
cookie_jar: PrivateCookieJar<Encrypter>,
Form(form): Form<ProtectedForm<RegisterForm>>,
) -> 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 (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?;
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 {
form: FormState<LoginFormField>,
next: Option<PostAuthContext>,
password_disabled: bool,
providers: Vec<UpstreamOAuthProvider>,
}
@ -295,15 +296,33 @@ impl TemplateContext for LoginContext {
Self: Sized,
{
// TODO: samples with errors
vec![LoginContext {
vec![
LoginContext {
form: FormState::default(),
next: None,
password_disabled: true,
providers: Vec::new(),
}]
},
LoginContext {
form: FormState::default(),
next: None,
password_disabled: false,
providers: Vec::new(),
},
]
}
}
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
#[must_use]
pub fn with_form_state(self, form: FormState<LoginFormField>) -> Self {
@ -312,7 +331,7 @@ impl LoginContext {
/// Set the upstream OAuth 2.0 providers
#[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 }
}

View File

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

View File

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

View File

@ -19,6 +19,7 @@ limitations under the License.
{% block content %}
<section class="flex items-center justify-center flex-1">
<form method="POST" class="grid grid-cols-1 gap-6 w-96 m-2">
{% if not password_disabled %}
{% if next and next.kind == "link_upstream" %}
<div class="text-center">
<h1 class="text-lg text-center font-medium">Sign in to link</h1>
@ -66,19 +67,28 @@ limitations under the License.
{{ button::link_text(text="Create an account", href="/register" ~ params) }}
</div>
{% endif %}
{% endif %}
{% if providers %}
{% if not password_disabled %}
<div class="flex items-center">
<hr class="flex-1" />
<div class="mx-2">Or</div>
<hr class="flex-1" />
</div>
{% endif %}
{% for provider in providers %}
{% set params = next | safe_get(key="params") | to_params(prefix="?") %}
{{ button::link(text="Continue with " ~ provider.issuer, href="/upstream/authorize/" ~ provider.id ~ params) }}
{% endfor %}
{% endif %}
{% if not providers and password_disabled %}
<div class="text-center">
No login method available.
</div>
{% endif %}
</form>
</section>
{% endblock content %}