1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-07-29 22:01:14 +03:00

Implement a basic "my account" page with password change

This commit is contained in:
Quentin Gliech
2021-12-16 12:31:29 +01:00
parent 412997663b
commit 584294538b
10 changed files with 320 additions and 43 deletions

View File

@ -589,6 +589,26 @@
"nullable": []
}
},
"647a2a5bbde39d0ed3931d0287b468bc7dedf6171e1dc6171a5d9f079b9ed0fa": {
"query": "\n SELECT up.hashed_password\n FROM user_passwords up\n WHERE up.user_id = $1\n ORDER BY up.created_at DESC\n LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "hashed_password",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
}
},
"6765e725d31a1490ddee3f28e32dea41abdd9acefb1edd9a7b4e6790ec131173": {
"query": "\n SELECT\n rt.id AS refresh_token_id,\n rt.token AS refresh_token,\n rt.created_at AS refresh_token_created_at,\n at.id AS \"access_token_id?\",\n at.token AS \"access_token?\",\n at.expires_after AS \"access_token_expires_after?\",\n at.created_at AS \"access_token_created_at?\",\n os.id AS \"session_id!\",\n os.client_id AS \"client_id!\",\n os.scope AS \"scope!\",\n us.id AS \"user_session_id!\",\n us.created_at AS \"user_session_created_at!\",\n u.id AS \"user_id!\",\n u.username AS \"user_username!\",\n usa.id AS \"user_session_last_authentication_id?\",\n usa.created_at AS \"user_session_last_authentication_created_at?\"\n FROM oauth2_refresh_tokens rt\n LEFT JOIN oauth2_access_tokens at\n ON at.id = rt.oauth2_access_token_id\n INNER JOIN oauth2_sessions os\n ON os.id = rt.oauth2_session_id\n INNER JOIN user_sessions us\n ON us.id = os.user_session_id\n INNER JOIN users u\n ON u.id = us.user_id\n LEFT JOIN user_session_authentications usa\n ON usa.session_id = us.id\n\n WHERE rt.token = $1\n AND rt.next_token_id IS NULL\n AND us.active\n\n ORDER BY usa.created_at DESC\n LIMIT 1\n ",
"describe": {
@ -913,14 +933,14 @@
]
}
},
"fe1fcf14de164f06a8fa1a0512ff955beaf83df0eebc4d63ea28d837613f8fed": {
"query": "\n SELECT up.hashed_password\n FROM user_sessions s\n INNER JOIN user_passwords up\n ON up.id = s.user_id \n WHERE s.id = $1\n ORDER BY up.created_at DESC\n LIMIT 1\n ",
"e5cd99bdaf9c678fc659431fecc5d76b25bb08b781fd17e50eda82ea3aa8cea8": {
"query": "\n SELECT COUNT(*) as \"count!\"\n FROM user_sessions s\n WHERE s.user_id = $1 AND s.active\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "hashed_password",
"type_info": "Text"
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
@ -929,7 +949,7 @@
]
},
"nullable": [
false
null
]
}
}

View File

@ -0,0 +1,133 @@
// Copyright 2021 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use argon2::Argon2;
use mas_config::{CookiesConfig, CsrfConfig};
use mas_data_model::BrowserSession;
use mas_templates::{AccountContext, TemplateContext, Templates};
use serde::Deserialize;
use sqlx::{pool::PoolConnection, PgExecutor, PgPool, Postgres, Transaction};
use warp::{reply::html, Filter, Rejection, Reply};
use crate::{
errors::WrapError,
filters::{
cookies::{encrypted_cookie_saver, EncryptedCookieSaver},
csrf::{protected_form, updated_csrf_token},
database::{connection, transaction},
session::session,
with_templates, CsrfToken,
},
storage::{
user::{authenticate_session, count_active_sessions, set_password},
PostgresqlBackend,
},
};
pub(super) fn filter(
pool: &PgPool,
templates: &Templates,
csrf_config: &CsrfConfig,
cookies_config: &CookiesConfig,
) -> impl Filter<Extract = (impl Reply,), Error = Rejection> + Clone + Send + Sync + 'static {
let get = with_templates(templates)
.and(encrypted_cookie_saver(cookies_config))
.and(updated_csrf_token(cookies_config, csrf_config))
.and(session(pool, cookies_config))
.and(connection(pool))
.and_then(get)
.with(warp::filters::trace::trace(|_info| {
tracing::info_span!("GET /account")
}));
let post = with_templates(templates)
.and(encrypted_cookie_saver(cookies_config))
.and(updated_csrf_token(cookies_config, csrf_config))
.and(session(pool, cookies_config))
.and(transaction(pool))
.and(protected_form(cookies_config))
.and_then(post)
.with(warp::filters::trace::trace(|_info| {
tracing::info_span!("POST /account")
}));
let filter = warp::get().and(get).or(warp::post().and(post));
warp::path!("account").and(filter)
}
#[derive(Deserialize)]
struct Form {
current_password: String,
new_password: String,
new_password_confirm: String,
}
async fn get(
templates: Templates,
cookie_saver: EncryptedCookieSaver,
csrf_token: CsrfToken,
session: BrowserSession<PostgresqlBackend>,
mut conn: PoolConnection<Postgres>,
) -> Result<Box<dyn Reply>, Rejection> {
render(templates, cookie_saver, csrf_token, session, &mut conn).await
}
async fn render(
templates: Templates,
cookie_saver: EncryptedCookieSaver,
csrf_token: CsrfToken,
session: BrowserSession<PostgresqlBackend>,
executor: impl PgExecutor<'_>,
) -> Result<Box<dyn Reply>, Rejection> {
let active_sessions = count_active_sessions(executor, &session.user)
.await
.wrap_error()?;
let ctx = AccountContext::new(active_sessions)
.with_session(session)
.with_csrf(csrf_token.form_value());
let content = templates.render_account(&ctx).await?;
let reply = html(content);
let reply = cookie_saver.save_encrypted(&csrf_token, reply)?;
Ok(Box::new(reply))
}
async fn post(
templates: Templates,
cookie_saver: EncryptedCookieSaver,
csrf_token: CsrfToken,
mut session: BrowserSession<PostgresqlBackend>,
mut txn: Transaction<'_, Postgres>,
form: Form,
) -> Result<Box<dyn Reply>, Rejection> {
authenticate_session(&mut txn, &mut session, form.current_password)
.await
.wrap_error()?;
// TODO: display nice form errors
if form.new_password != form.new_password_confirm {
return Err(anyhow::anyhow!("password mismatch")).wrap_error();
}
let phf = Argon2::default();
set_password(&mut txn, phf, &session.user, &form.new_password)
.await
.wrap_error()?;
let reply = render(templates, cookie_saver, csrf_token, session, &mut txn).await?;
txn.commit().await.wrap_error()?;
Ok(reply)
}

View File

@ -17,6 +17,7 @@ use mas_templates::Templates;
use sqlx::PgPool;
use warp::{filters::BoxedFilter, Filter, Reply};
mod account;
mod index;
mod login;
mod logout;
@ -25,8 +26,8 @@ mod register;
mod shared;
use self::{
index::filter as index, login::filter as login, logout::filter as logout,
reauth::filter as reauth, register::filter as register,
account::filter as account, index::filter as index, login::filter as login,
logout::filter as logout, reauth::filter as reauth, register::filter as register,
};
pub(crate) use self::{
login::LoginRequest, reauth::ReauthRequest, register::RegisterRequest, shared::PostAuthAction,
@ -40,6 +41,7 @@ pub(super) fn filter(
cookies_config: &CookiesConfig,
) -> BoxedFilter<(impl Reply,)> {
index(pool, templates, oauth2_config, csrf_config, cookies_config)
.or(account(pool, templates, csrf_config, cookies_config))
.or(login(pool, templates, csrf_config, cookies_config))
.or(register(pool, templates, csrf_config, cookies_config))
.or(logout(pool, cookies_config))

View File

@ -137,13 +137,13 @@ async fn get(
}
async fn post(
session: BrowserSession<PostgresqlBackend>,
mut session: BrowserSession<PostgresqlBackend>,
mut txn: Transaction<'_, Postgres>,
form: ReauthForm,
query: ReauthRequest<PostgresqlBackend>,
) -> Result<impl Reply, Rejection> {
// TODO: recover from errors here
authenticate_session(&mut txn, &session, form.password)
authenticate_session(&mut txn, &mut session, form.password)
.await
.wrap_error()?;
txn.commit().await.wrap_error()?;

View File

@ -65,6 +65,7 @@ impl HtmlError for LoginError {
}
}
#[tracing::instrument(skip(conn, password))]
pub async fn login(
conn: impl Acquire<'_, Database = Postgres>,
username: &str,
@ -85,20 +86,19 @@ pub async fn login(
})?;
let mut session = start_session(&mut txn, user).await?;
session.last_authentication = Some(
authenticate_session(&mut txn, &session, password)
.await
.map_err(|source| {
if matches!(source, AuthenticationError::Password { .. }) {
LoginError::Authentication {
username: username.to_string(),
source,
}
} else {
LoginError::Other(source.into())
authenticate_session(&mut txn, &mut session, password)
.await
.map_err(|source| {
if matches!(source, AuthenticationError::Password { .. }) {
LoginError::Authentication {
username: username.to_string(),
source,
}
})?,
);
} else {
LoginError::Other(source.into())
}
})?;
txn.commit().await.context("could not commit transaction")?;
Ok(session)
}
@ -218,6 +218,26 @@ pub async fn start_session(
Ok(session)
}
#[tracing::instrument(skip_all, fields(user.id = user.data))]
pub async fn count_active_sessions(
executor: impl PgExecutor<'_>,
user: &User<PostgresqlBackend>,
) -> Result<usize, anyhow::Error> {
let res = sqlx::query_scalar!(
r#"
SELECT COUNT(*) as "count!"
FROM user_sessions s
WHERE s.user_id = $1 AND s.active
"#,
user.data,
)
.fetch_one(executor)
.await?
.try_into()?;
Ok(res)
}
#[derive(Debug, Error)]
pub enum AuthenticationError {
#[error("could not verify password")]
@ -233,25 +253,25 @@ pub enum AuthenticationError {
Internal(#[from] tokio::task::JoinError),
}
#[tracing::instrument(skip_all, fields(session.id = session.data, user.id = session.user.data))]
pub async fn authenticate_session(
txn: &mut Transaction<'_, Postgres>,
session: &BrowserSession<PostgresqlBackend>,
session: &mut BrowserSession<PostgresqlBackend>,
password: String,
) -> Result<Authentication<PostgresqlBackend>, AuthenticationError> {
) -> Result<(), AuthenticationError> {
// First, fetch the hashed password from the user associated with that session
let hashed_password: String = sqlx::query_scalar!(
r#"
SELECT up.hashed_password
FROM user_sessions s
INNER JOIN user_passwords up
ON up.id = s.user_id
WHERE s.id = $1
FROM user_passwords up
WHERE up.user_id = $1
ORDER BY up.created_at DESC
LIMIT 1
"#,
session.data,
session.user.data,
)
.fetch_one(txn.borrow_mut())
.instrument(tracing::info_span!("Lookup hashed password"))
.await
.map_err(AuthenticationError::Fetch)?;
@ -264,6 +284,7 @@ pub async fn authenticate_session(
.verify_password(&[&context], &password)
.map_err(AuthenticationError::Password)
})
.instrument(tracing::info_span!("Verify hashed password"))
.await??;
// That went well, let's insert the auth info
@ -277,15 +298,19 @@ pub async fn authenticate_session(
session.data,
)
.fetch_one(txn.borrow_mut())
.instrument(tracing::info_span!("Save authentication"))
.await
.map_err(AuthenticationError::Save)?;
Ok(Authentication {
session.last_authentication = Some(Authentication {
data: res.id,
created_at: res.created_at,
})
});
Ok(())
}
#[tracing::instrument(skip(txn, phf, password))]
pub async fn register_user(
txn: &mut Transaction<'_, Postgres>,
phf: impl PasswordHasher,
@ -305,6 +330,24 @@ pub async fn register_user(
.await
.context("could not insert user")?;
let user = User {
data: id,
username: username.to_string(),
sub: format!("fake-sub-{}", id),
};
set_password(txn.borrow_mut(), phf, &user, password).await?;
Ok(user)
}
#[tracing::instrument(skip_all, fields(user.id = user.data))]
pub async fn set_password(
executor: impl PgExecutor<'_>,
phf: impl PasswordHasher,
user: &User<PostgresqlBackend>,
password: &str,
) -> anyhow::Result<()> {
let salt = SaltString::generate(&mut OsRng);
let hashed_password = PasswordHash::generate(phf, password, salt.as_str())?;
@ -313,21 +356,18 @@ pub async fn register_user(
INSERT INTO user_passwords (user_id, hashed_password)
VALUES ($1, $2)
"#,
id,
user.data,
hashed_password.to_string(),
)
.execute(txn.borrow_mut())
.execute(executor)
.instrument(info_span!("Save user credentials"))
.await
.context("could not insert user password")?;
Ok(User {
data: id,
username: username.to_string(),
sub: format!("fake-sub-{}", id),
})
Ok(())
}
#[tracing::instrument(skip_all, fields(session.id = session.data))]
pub async fn end_session(
executor: impl PgExecutor<'_>,
session: &BrowserSession<PostgresqlBackend>,
@ -348,6 +388,7 @@ pub async fn end_session(
}
}
#[tracing::instrument(skip(executor))]
pub async fn lookup_user_by_username(
executor: impl PgExecutor<'_>,
username: &str,

View File

@ -382,6 +382,28 @@ pub struct ReauthContext {
next: Option<PostAuthContext>,
}
/// Context used by the `account.html` template
#[derive(Serialize)]
pub struct AccountContext {
active_sessions: usize,
}
impl AccountContext {
#[must_use]
pub fn new(active_sessions: usize) -> Self {
Self { active_sessions }
}
}
impl TemplateContext for AccountContext {
fn sample() -> Vec<Self>
where
Self: Sized,
{
vec![Self::new(5)]
}
}
/// Context used by the `form_post.html` template
#[derive(Serialize)]
pub struct FormPostContext<T> {

View File

@ -47,9 +47,9 @@ mod functions;
mod macros;
pub use self::context::{
EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext, LoginFormField,
PostAuthContext, ReauthContext, ReauthFormField, RegisterContext, RegisterFormField,
TemplateContext, WithCsrf, WithOptionalSession, WithSession,
AccountContext, EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext,
LoginFormField, PostAuthContext, ReauthContext, ReauthFormField, RegisterContext,
RegisterFormField, TemplateContext, WithCsrf, WithOptionalSession, WithSession,
};
/// Wrapper around [`tera::Tera`] helping rendering the various templates
@ -293,6 +293,9 @@ register_templates! {
/// Render the home page
pub fn render_index(WithCsrf<WithOptionalSession<IndexContext>>) { "index.html" }
/// Render the account management page
pub fn render_account(WithCsrf<WithSession<AccountContext>>) { "account.html" }
/// Render the re-authentication form
pub fn render_reauth(WithCsrf<WithSession<ReauthContext>>) { "reauth.html" }
@ -310,6 +313,7 @@ impl Templates {
check::render_login(self).await?;
check::render_register(self).await?;
check::render_index(self).await?;
check::render_account(self).await?;
check::render_reauth(self).await?;
check::render_form_post::<EmptyContext>(self).await?;
check::render_error(self).await?;

View File

@ -0,0 +1,53 @@
{#
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
#}
{% extends "base.html" %}
{% block content %}
<section class="container mx-auto grid gap-4 grid-cols-1 md:grid-cols-2 xl:grid-cols-3 p-2">
<div class="rounded border-2 border-grey-50 dark:border-grey-450 p-4 grid gap-4 xl:grid-cols-2 grid-cols-1 place-content-start">
<h1 class="text-2xl font-bold xl:col-span-2">Manage my account</h1>
<div class="font-bold">Your username</div>
<div>{{ current_session.user.username }}</div>
<div class="font-bold">Unique identifier</div>
<div>{{ current_session.user.sub }}</div>
<div class="font-bold">Active sessions</div>
<div>{{ active_sessions }}</div>
</div>
<form class="rounded border-2 border-grey-50 dark:border-grey-450 p-4 grid gap-4 xl:grid-cols-2 grid-cols-1 place-content-start" method="POST">
<h2 class="text-xl font-bold xl:col-span-2">Change my password</h2>
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
{{ field::input(label="Current password", name="current_password", type="password", class="xl:col-span-2") }}
{{ field::input(label="New password", name="new_password", type="password") }}
{{ field::input(label="Confirm password", name="new_password_confirm", type="password") }}
{{ button::button(text="Change password", type="password", class="xl:col-span-2 place-self-end") }}
</form>
<div class="rounded border-2 border-grey-50 dark:border-grey-450 p-4 grid gap-4 xl:grid-cols-2 grid-cols-1 place-content-start">
<h2 class="text-xl font-bold xl:col-span-2">Current session</h2>
<div class="font-bold">Started at</div>
<div>{{ current_session.created_at | date(format="%Y-%m-%d %H:%M:%S") }}</div>
<div class="font-bold">Last authentication</div>
<div>
{% if current_session.last_authentication %}
{{ current_session.last_authentication.created_at | date(format="%Y-%m-%d %H:%M:%S") }}
{% else %}
Never
{% endif %}
</div>
{{ button::link_ghost(text="Revalidate", href="/reauth", class="col-span-2 place-self-end") }}
</div>
</section>
{% endblock content %}

View File

@ -39,6 +39,8 @@ limitations under the License.
Logged in as <span class="font-bold">{{ current_session.user.username }}</span>.
</div>
{{ button::link(text="My account", href="/account") }}
<form method="POST" action="/logout">
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
{{ button::button_ghost(text="Log out", name="logout", type="submit") }}

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
#}
{% macro input(label, name, type="text", errors=false) %}
{% macro input(label, name, type="text", errors=false, class="") %}
{% if errors is not empty %}
{% set border_color = "border-alert" %}
{% set text_color = "text-alert" %}
@ -22,7 +22,7 @@ limitations under the License.
{% set border_color = "border-grey-50 dark:border-grey-450" %}
{% set text_color = "text-black-800 dark:text-grey-300" %}
{% endif %}
<label class="flex flex-col block">
<label class="flex flex-col block {{ class }}">
<div class="mx-2 -mb-3 -mt-2 leading-5 px-1 z-10 self-start bg-white dark:bg-black-900 border-white border-1 dark:border-2 dark:border-black-900 rounded-full text-sm {{ text_color }}">{{ label }}</div>
<input name="{{ name }}" class="z-0 px-3 py-2 bg-white dark:bg-black-900 rounded-lg {{ border_color }} border-1 dark:border-2 focus:border-accent focus:ring-0 focus:outline-0" type="{{ type }}" />