From 584294538b129ab3855c40ce150260a6a99f5d2c Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 16 Dec 2021 12:31:29 +0100 Subject: [PATCH] Implement a basic "my account" page with password change --- crates/core/sqlx-data.json | 30 +++- crates/core/src/handlers/views/account.rs | 133 ++++++++++++++++++ crates/core/src/handlers/views/mod.rs | 6 +- crates/core/src/handlers/views/reauth.rs | 4 +- crates/core/src/storage/user.rs | 99 +++++++++---- crates/templates/src/context.rs | 22 +++ crates/templates/src/lib.rs | 10 +- crates/templates/src/res/account.html | 53 +++++++ crates/templates/src/res/base.html | 2 + .../templates/src/res/components/field.html | 4 +- 10 files changed, 320 insertions(+), 43 deletions(-) create mode 100644 crates/core/src/handlers/views/account.rs create mode 100644 crates/templates/src/res/account.html diff --git a/crates/core/sqlx-data.json b/crates/core/sqlx-data.json index c63862e2..42e945a3 100644 --- a/crates/core/sqlx-data.json +++ b/crates/core/sqlx-data.json @@ -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 ] } } diff --git a/crates/core/src/handlers/views/account.rs b/crates/core/src/handlers/views/account.rs new file mode 100644 index 00000000..6a434f74 --- /dev/null +++ b/crates/core/src/handlers/views/account.rs @@ -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 + 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, + mut conn: PoolConnection, +) -> Result, Rejection> { + render(templates, cookie_saver, csrf_token, session, &mut conn).await +} + +async fn render( + templates: Templates, + cookie_saver: EncryptedCookieSaver, + csrf_token: CsrfToken, + session: BrowserSession, + executor: impl PgExecutor<'_>, +) -> Result, 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, + mut txn: Transaction<'_, Postgres>, + form: Form, +) -> Result, 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) +} diff --git a/crates/core/src/handlers/views/mod.rs b/crates/core/src/handlers/views/mod.rs index a6c60dab..cc2994a6 100644 --- a/crates/core/src/handlers/views/mod.rs +++ b/crates/core/src/handlers/views/mod.rs @@ -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)) diff --git a/crates/core/src/handlers/views/reauth.rs b/crates/core/src/handlers/views/reauth.rs index 859341ae..f436bbef 100644 --- a/crates/core/src/handlers/views/reauth.rs +++ b/crates/core/src/handlers/views/reauth.rs @@ -137,13 +137,13 @@ async fn get( } async fn post( - session: BrowserSession, + mut session: BrowserSession, mut txn: Transaction<'_, Postgres>, form: ReauthForm, query: ReauthRequest, ) -> Result { // 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()?; diff --git a/crates/core/src/storage/user.rs b/crates/core/src/storage/user.rs index 4d5fb004..fdcc3663 100644 --- a/crates/core/src/storage/user.rs +++ b/crates/core/src/storage/user.rs @@ -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, +) -> Result { + 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, + session: &mut BrowserSession, password: String, -) -> Result, 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, + 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, @@ -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, diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 55f07f35..79289651 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -382,6 +382,28 @@ pub struct ReauthContext { next: Option, } +/// 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 + where + Self: Sized, + { + vec![Self::new(5)] + } +} + /// Context used by the `form_post.html` template #[derive(Serialize)] pub struct FormPostContext { diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 69013444..a89b47fe 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -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>) { "index.html" } + /// Render the account management page + pub fn render_account(WithCsrf>) { "account.html" } + /// Render the re-authentication form pub fn render_reauth(WithCsrf>) { "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::(self).await?; check::render_error(self).await?; diff --git a/crates/templates/src/res/account.html b/crates/templates/src/res/account.html new file mode 100644 index 00000000..ea91975d --- /dev/null +++ b/crates/templates/src/res/account.html @@ -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 %} +
+
+

Manage my account

+
Your username
+
{{ current_session.user.username }}
+
Unique identifier
+
{{ current_session.user.sub }}
+
Active sessions
+
{{ active_sessions }}
+
+
+

Change my password

+ + {{ 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") }} +
+
+

Current session

+
Started at
+
{{ current_session.created_at | date(format="%Y-%m-%d %H:%M:%S") }}
+
Last authentication
+
+ {% if current_session.last_authentication %} + {{ current_session.last_authentication.created_at | date(format="%Y-%m-%d %H:%M:%S") }} + {% else %} + Never + {% endif %} +
+ {{ button::link_ghost(text="Revalidate", href="/reauth", class="col-span-2 place-self-end") }} +
+
+{% endblock content %} diff --git a/crates/templates/src/res/base.html b/crates/templates/src/res/base.html index 06476078..cc5bfa87 100644 --- a/crates/templates/src/res/base.html +++ b/crates/templates/src/res/base.html @@ -39,6 +39,8 @@ limitations under the License. Logged in as {{ current_session.user.username }}. + {{ button::link(text="My account", href="/account") }} +
{{ button::button_ghost(text="Log out", name="logout", type="submit") }} diff --git a/crates/templates/src/res/components/field.html b/crates/templates/src/res/components/field.html index 63a59f5b..2fdd1fff 100644 --- a/crates/templates/src/res/components/field.html +++ b/crates/templates/src/res/components/field.html @@ -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 %} -