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

WIP: Refactor higher-level data-model to its own crate

This commit is contained in:
Quentin Gliech
2021-10-12 19:03:01 +02:00
parent 29bf149921
commit b3587c677c
17 changed files with 456 additions and 326 deletions

11
Cargo.lock generated
View File

@ -1390,6 +1390,7 @@ dependencies = [
"jwt-compact",
"k256",
"mas-config",
"mas-data-model",
"mime",
"oauth2-types",
"password-hash",
@ -1413,6 +1414,16 @@ dependencies = [
"warp",
]
[[package]]
name = "mas-data-model"
version = "0.1.0"
dependencies = [
"chrono",
"oauth2-types",
"serde",
"thiserror",
]
[[package]]
name = "matchers"
version = "0.0.1"

View File

@ -66,6 +66,7 @@ cookie = "0.15.1"
oauth2-types = { path = "../oauth2-types", features = ["sqlx_type"] }
mas-config = { path = "../config" }
mas-data-model = { path = "../data-model" }
[dependencies.jwt-compact]
# Waiting on the next release because of the bump of the `rsa` dependency

View File

@ -93,8 +93,8 @@
]
}
},
"35bedaa6fdf7ac91d54b458b4637f2182c2f82be3e2f80cd2db934ee279a7f2a": {
"query": "\n SELECT id, username\n FROM users\n WHERE id = $1\n ",
"307fd9f71e7a94a0a0d9ce523ee9792e127485d0d12480c43f179dd9b75afbab": {
"query": "\n INSERT INTO user_sessions (user_id)\n VALUES ($1)\n RETURNING id, created_at\n ",
"describe": {
"columns": [
{
@ -104,8 +104,8 @@
},
{
"ordinal": 1,
"name": "username",
"type_info": "Text"
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
@ -169,56 +169,6 @@
]
}
},
"4f925a277d73df779360f81e0cf5d7983b50ebe744f461559dd561b7e36c20d4": {
"query": "\n SELECT\n s.id,\n u.id as user_id,\n u.username,\n s.active,\n s.created_at,\n a.created_at as \"last_authd_at?\"\n FROM user_sessions s\n INNER JOIN users u \n ON s.user_id = u.id\n LEFT JOIN user_session_authentications a\n ON a.session_id = s.id\n WHERE s.id = $1 AND s.active\n ORDER BY a.created_at DESC\n LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "username",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "active",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "last_authd_at?",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false
]
}
},
"562b0d4dcf857e99c20e9288e9c8bd46232290715c0d2459b0398a1c746cf65d": {
"query": "\n SELECT\n rt.id,\n rt.oauth2_session_id,\n rt.oauth2_access_token_id,\n os.client_id AS \"client_id!\",\n os.scope AS \"scope!\"\n FROM oauth2_refresh_tokens rt\n INNER JOIN oauth2_sessions os\n ON os.id = rt.oauth2_session_id\n WHERE rt.token = $1 AND rt.next_token_id IS NULL\n ",
"describe": {
@ -273,56 +223,6 @@
"nullable": []
}
},
"62986972431bfc4649e3d8c8c7648f9049c4197773e53496422ad8b8aa15b459": {
"query": "\n SELECT\n s.id,\n u.id as user_id,\n u.username,\n s.active,\n s.created_at,\n a.created_at as \"last_authd_at?\"\n FROM user_sessions s\n INNER JOIN users u \n ON s.user_id = u.id\n LEFT JOIN user_session_authentications a\n ON a.session_id = s.id\n WHERE s.id = $1\n ORDER BY a.created_at DESC\n LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "username",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "active",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "last_authd_at?",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false
]
}
},
"73f2d928f7bf88af79a3685bd6346652b4e4454b0ce75e38343840c9765e3f27": {
"query": "\n INSERT INTO oauth2_refresh_tokens\n (oauth2_session_id, oauth2_access_token_id, token)\n VALUES\n ($1, $2, $3)\n RETURNING\n id, oauth2_session_id, oauth2_access_token_id, token, next_token_id, \n created_at, updated_at\n ",
"describe": {
@ -381,6 +281,56 @@
]
}
},
"7fa7d001583e98f5e4626751fa2c8743e7b83a240d1a51de50a7dba95c4e8a6b": {
"query": "\n SELECT\n s.id,\n u.id as user_id,\n u.username,\n s.created_at,\n a.id as \"last_authentication_id?\",\n a.created_at as \"last_authd_at?\"\n FROM user_sessions s\n INNER JOIN users u \n ON s.user_id = u.id\n LEFT JOIN user_session_authentications a\n ON a.session_id = s.id\n WHERE s.id = $1 AND s.active\n ORDER BY a.created_at DESC\n LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "username",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "last_authentication_id?",
"type_info": "Int8"
},
{
"ordinal": 5,
"name": "last_authd_at?",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false
]
}
},
"88ac8783bd5881c42eafd9cf87a16fe6031f3153fd6a8618e689694584aeb2de": {
"query": "\n DELETE FROM oauth2_access_tokens\n WHERE id = $1\n ",
"describe": {
@ -393,26 +343,6 @@
"nullable": []
}
},
"9ba45ab114b656105cc46b0c10fb05769860fcdc05eaf54d6225640fb914dab9": {
"query": "\n INSERT INTO user_session_authentications (session_id)\n VALUES ($1)\n RETURNING created_at\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
}
},
"a09dfe1019110f2ec6eba0d35bafa467ab4b7980dd8b556826f03863f8edb0ab": {
"query": "UPDATE user_sessions SET active = FALSE WHERE id = $1",
"describe": {
@ -611,6 +541,32 @@
]
}
},
"d9d27eb4a0c11818a636d407438c4bc567a39396e7e236b3e776504417988eab": {
"query": "\n INSERT INTO user_session_authentications (session_id)\n VALUES ($1)\n RETURNING id, created_at\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "created_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false
]
}
},
"eaddc1e33715ad31b4195fda72dbe870f179dd8da53a88d0543b72a278ed1d3d": {
"query": "\n DELETE FROM oauth2_codes\n WHERE id = $1\n ",
"describe": {

View File

@ -14,6 +14,7 @@
//! Load user sessions from the database
use mas_data_model::BrowserSession;
use serde::{Deserialize, Serialize};
use sqlx::{pool::PoolConnection, Executor, PgPool, Postgres};
use thiserror::Error;
@ -30,7 +31,7 @@ use super::{
};
use crate::{
config::CookiesConfig,
storage::{lookup_active_session, user::ActiveSessionLookupError, SessionInfo},
storage::{lookup_active_session, user::ActiveSessionLookupError, PostgresqlBackend},
};
/// The session is missing or failed to load
@ -58,19 +59,19 @@ pub struct SessionCookie {
}
impl SessionCookie {
/// Forge the cookie from a [`SessionInfo`]
/// Forge the cookie from a [`BrowserSession`]
#[must_use]
pub fn from_session_info(info: &SessionInfo) -> Self {
pub fn from_session(session: &BrowserSession<PostgresqlBackend>) -> Self {
Self {
current: info.key(),
current: session.data,
}
}
/// Load the [`SessionInfo`] from database
pub async fn load_session_info(
/// Load the [`BrowserSession`] from database
pub async fn load_session(
&self,
executor: impl Executor<'_, Database = Postgres>,
) -> Result<SessionInfo, ActiveSessionLookupError> {
) -> Result<BrowserSession<PostgresqlBackend>, ActiveSessionLookupError> {
let res = lookup_active_session(executor, self.current).await?;
Ok(res)
}
@ -87,8 +88,11 @@ impl EncryptableCookieValue for SessionCookie {
pub fn optional_session(
pool: &PgPool,
cookies_config: &CookiesConfig,
) -> impl Filter<Extract = (Option<SessionInfo>,), Error = Rejection> + Clone + Send + Sync + 'static
{
) -> impl Filter<Extract = (Option<BrowserSession<PostgresqlBackend>>,), Error = Rejection>
+ Clone
+ Send
+ Sync
+ 'static {
session(pool, cookies_config)
.map(Some)
.recover(none_on_error::<_, SessionLoadError>)
@ -106,7 +110,11 @@ pub fn optional_session(
pub fn session(
pool: &PgPool,
cookies_config: &CookiesConfig,
) -> impl Filter<Extract = (SessionInfo,), Error = Rejection> + Clone + Send + Sync + 'static {
) -> impl Filter<Extract = (BrowserSession<PostgresqlBackend>,), Error = Rejection>
+ Clone
+ Send
+ Sync
+ 'static {
encrypted(cookies_config)
.and(connection(pool))
.and_then(load_session)
@ -117,8 +125,8 @@ pub fn session(
async fn load_session(
session: SessionCookie,
mut conn: PoolConnection<Postgres>,
) -> Result<SessionInfo, Rejection> {
let session_info = session.load_session_info(&mut conn).await?;
) -> Result<BrowserSession<PostgresqlBackend>, Rejection> {
let session_info = session.load_session(&mut conn).await?;
Ok(session_info)
}

View File

@ -24,6 +24,7 @@ use hyper::{
StatusCode,
};
use itertools::Itertools;
use mas_data_model::BrowserSession;
use oauth2_types::{
errors::{ErrorResponse, InvalidRequest, OAuth2Error},
pkce,
@ -59,7 +60,7 @@ use crate::{
refresh_token::add_refresh_token,
session::{get_session_by_id, start_session},
},
SessionInfo,
PostgresqlBackend,
},
templates::{FormPostContext, Templates},
tokens::{AccessToken, RefreshToken},
@ -297,7 +298,7 @@ async fn actually_reply(
async fn get(
clients: Vec<OAuth2ClientConfig>,
params: Params,
maybe_session: Option<SessionInfo>,
maybe_session: Option<BrowserSession<PostgresqlBackend>>,
mut txn: Transaction<'_, Postgres>,
) -> Result<ReplyOrBackToClient, Rejection> {
// First, find out what client it is
@ -307,7 +308,7 @@ async fn get(
.ok_or_else(|| anyhow::anyhow!("could not find client"))
.wrap_error()?;
let maybe_session_id = maybe_session.as_ref().map(SessionInfo::key);
let maybe_session_id = maybe_session.as_ref().map(|s| s.data);
let scope: String = {
let it = params.auth.scope.iter().map(ToString::to_string);
@ -394,7 +395,7 @@ impl StepRequest {
async fn step(
oauth2_session_id: i64,
user_session: SessionInfo,
user_session: BrowserSession<PostgresqlBackend>,
mut txn: Transaction<'_, Postgres>,
) -> Result<ReplyOrBackToClient, Rejection> {
let mut oauth2_session = get_session_by_id(&mut txn, oauth2_session_id)
@ -411,8 +412,9 @@ async fn step(
let redirect_uri = oauth2_session.redirect_uri().wrap_error()?;
// Check if the active session is valid
let reply = if user_session.active
&& user_session.last_authd_at >= oauth2_session.max_auth_time()
// TODO: this is ugly & should check if the session is active
let reply = if user_session.last_authentication.map(|x| x.created_at)
>= oauth2_session.max_auth_time()
{
// Yep! Let's complete the auth now
let mut params = AuthorizationResponse::default();

View File

@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use mas_data_model::BrowserSession;
use sqlx::PgPool;
use url::Url;
use warp::{reply::html, Filter, Rejection, Reply};
@ -24,7 +25,7 @@ use crate::{
session::optional_session,
with_templates, CsrfToken,
},
storage::SessionInfo,
storage::PostgresqlBackend,
templates::{IndexContext, TemplateContext, Templates},
};
@ -51,10 +52,10 @@ async fn get(
templates: Templates,
cookie_saver: EncryptedCookieSaver,
csrf_token: CsrfToken,
session: Option<SessionInfo>,
maybe_session: Option<BrowserSession<PostgresqlBackend>>,
) -> Result<impl Reply, Rejection> {
let ctx = IndexContext::new(discovery_url)
.maybe_with_session(session)
.maybe_with_session(maybe_session)
.with_csrf(&csrf_token);
let content = templates.render_index(&ctx)?;

View File

@ -15,6 +15,7 @@
use std::convert::TryFrom;
use hyper::http::uri::{Parts, PathAndQuery, Uri};
use mas_data_model::BrowserSession;
use serde::{Deserialize, Serialize};
use sqlx::{pool::PoolConnection, PgPool, Postgres};
use warp::{reply::html, Filter, Rejection, Reply};
@ -29,7 +30,7 @@ use crate::{
session::{optional_session, SessionCookie},
with_templates, CsrfToken,
},
storage::{login, SessionInfo},
storage::{login, PostgresqlBackend},
templates::{LoginContext, LoginFormField, TemplateContext, Templates},
};
@ -108,7 +109,7 @@ async fn get(
cookie_saver: EncryptedCookieSaver,
csrf_token: CsrfToken,
query: LoginRequest,
maybe_session: Option<SessionInfo>,
maybe_session: Option<BrowserSession<PostgresqlBackend>>,
) -> Result<Box<dyn Reply>, Rejection> {
if maybe_session.is_some() {
Ok(Box::new(query.redirect()?))
@ -133,7 +134,7 @@ async fn post(
// TODO: recover
match login(&mut conn, &form.username, form.password).await {
Ok(session_info) => {
let session_cookie = SessionCookie::from_session_info(&session_info);
let session_cookie = SessionCookie::from_session(&session_info);
let reply = query.redirect()?;
let reply = cookie_saver.save_encrypted(&session_cookie, reply)?;
Ok(Box::new(reply))

View File

@ -12,14 +12,15 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use sqlx::{pool::PoolConnection, PgPool, Postgres};
use mas_data_model::BrowserSession;
use sqlx::{PgPool, Postgres, Transaction};
use warp::{hyper::Uri, Filter, Rejection, Reply};
use crate::{
config::CookiesConfig,
errors::WrapError,
filters::{csrf::protected_form, database::connection, session::session},
storage::SessionInfo,
filters::{csrf::protected_form, database::transaction, session::session},
storage::{user::end_session, PostgresqlBackend},
};
pub(super) fn filter(
@ -29,16 +30,18 @@ pub(super) fn filter(
warp::path!("logout")
.and(warp::post())
.and(session(pool, cookies_config))
.and(connection(pool))
.and(transaction(pool))
.and(protected_form(cookies_config))
.and_then(post)
}
async fn post(
session: SessionInfo,
mut conn: PoolConnection<Postgres>,
session: BrowserSession<PostgresqlBackend>,
mut txn: Transaction<'_, Postgres>,
_form: (),
) -> Result<impl Reply, Rejection> {
session.end(&mut conn).await.wrap_error()?;
end_session(&mut txn, &session).await.wrap_error()?;
txn.commit().await.wrap_error()?;
Ok(warp::redirect(Uri::from_static("/login")))
}

View File

@ -12,8 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use mas_data_model::BrowserSession;
use serde::Deserialize;
use sqlx::{pool::PoolConnection, PgPool, Postgres};
use sqlx::{PgPool, Postgres, Transaction};
use warp::{hyper::Uri, reply::html, Filter, Rejection, Reply};
use crate::{
@ -22,11 +23,11 @@ use crate::{
filters::{
cookies::{encrypted_cookie_saver, EncryptedCookieSaver},
csrf::{protected_form, updated_csrf_token},
database::connection,
database::transaction,
session::session,
with_templates, CsrfToken,
},
storage::SessionInfo,
storage::{user::authenticate_session, PostgresqlBackend},
templates::{EmptyContext, TemplateContext, Templates},
};
@ -50,7 +51,7 @@ pub(super) fn filter(
let post = warp::post()
.and(session(pool, cookies_config))
.and(connection(pool))
.and(transaction(pool))
.and(protected_form(cookies_config))
.and_then(post);
@ -61,7 +62,7 @@ async fn get(
templates: Templates,
cookie_saver: EncryptedCookieSaver,
csrf_token: CsrfToken,
session: SessionInfo,
session: BrowserSession<PostgresqlBackend>,
) -> Result<impl Reply, Rejection> {
let ctx = EmptyContext.with_session(session).with_csrf(&csrf_token);
@ -72,14 +73,14 @@ async fn get(
}
async fn post(
session: SessionInfo,
mut conn: PoolConnection<Postgres>,
session: BrowserSession<PostgresqlBackend>,
mut txn: Transaction<'_, Postgres>,
form: ReauthForm,
) -> Result<impl Reply, Rejection> {
let _session = session
.reauth(&mut conn, form.password)
authenticate_session(&mut txn, &session, form.password)
.await
.wrap_error()?;
txn.commit().await.wrap_error()?;
Ok(warp::redirect(Uri::from_static("/")))
}

View File

@ -16,6 +16,7 @@ use std::convert::TryFrom;
use argon2::Argon2;
use hyper::http::uri::{Parts, PathAndQuery, Uri};
use mas_data_model::BrowserSession;
use serde::{Deserialize, Serialize};
use sqlx::{pool::PoolConnection, PgPool, Postgres};
use warp::{reply::html, Filter, Rejection, Reply};
@ -30,7 +31,7 @@ use crate::{
session::{optional_session, SessionCookie},
with_templates, CsrfToken,
},
storage::{register_user, user::start_session, SessionInfo},
storage::{register_user, user::start_session, PostgresqlBackend},
templates::{EmptyContext, TemplateContext, Templates},
};
@ -110,7 +111,7 @@ async fn get(
cookie_saver: EncryptedCookieSaver,
csrf_token: CsrfToken,
query: RegisterRequest,
maybe_session: Option<SessionInfo>,
maybe_session: Option<BrowserSession<PostgresqlBackend>>,
) -> Result<Box<dyn Reply>, Rejection> {
if maybe_session.is_some() {
Ok(Box::new(query.redirect()?))
@ -140,7 +141,7 @@ async fn post(
let session_info = start_session(&mut conn, user).await.wrap_error()?;
let session_cookie = SessionCookie::from_session_info(&session_info);
let session_cookie = SessionCookie::from_session(&session_info);
let reply = query.redirect()?;
let reply = cookie_saver.save_encrypted(&session_cookie, reply)?;
Ok(reply)

View File

@ -16,12 +16,32 @@
#![allow(clippy::used_underscore_binding)] // This is needed by sqlx macros
use mas_data_model::StorageBackend;
use serde::Serialize;
use sqlx::migrate::Migrator;
use thiserror::Error;
#[derive(Debug, Error)]
#[error("databse query returned an inconsistent state")]
pub struct DatabaseInconsistencyError;
#[derive(Serialize, Debug, Clone, PartialEq)]
pub struct PostgresqlBackend;
impl StorageBackend for PostgresqlBackend {
type AccessTokenData = i64;
type AuthenticationData = i64;
type AuthorizationCodeData = i64;
type BrowserSessionData = i64;
type ClientData = ();
type SessionData = i64;
type UserData = i64;
}
pub mod oauth2;
pub mod user;
pub use self::user::{login, lookup_active_session, register_user, SessionInfo, User};
pub use self::user::{login, lookup_active_session, register_user};
/// Embedded migrations, allowing them to run on startup
pub static MIGRATOR: Migrator = sqlx::migrate!();

View File

@ -17,6 +17,7 @@ use std::{collections::HashSet, convert::TryFrom, str::FromStr, string::ToString
use anyhow::Context;
use chrono::{DateTime, Duration, Utc};
use itertools::Itertools;
use mas_data_model::BrowserSession;
use oauth2_types::{
pkce,
requests::{ResponseMode, ResponseType},
@ -25,10 +26,8 @@ use serde::Serialize;
use sqlx::{Executor, FromRow, Postgres};
use url::Url;
use super::{
super::{user::lookup_session, SessionInfo},
authorization_code::{add_code, OAuth2Code},
};
use super::authorization_code::{add_code, OAuth2Code};
use crate::storage::{lookup_active_session, PostgresqlBackend};
#[derive(FromRow, Serialize)]
pub struct OAuth2Session {
@ -60,10 +59,11 @@ impl OAuth2Session {
pub async fn fetch_session(
&self,
executor: impl Executor<'_, Database = Postgres>,
) -> anyhow::Result<Option<SessionInfo>> {
) -> anyhow::Result<Option<BrowserSession<PostgresqlBackend>>> {
match self.user_session_id {
Some(id) => {
let info = lookup_session(executor, id).await?;
// TODO: and if the session is inactive?
let info = lookup_active_session(executor, id).await?;
Ok(Some(info))
}
None => Ok(None),
@ -80,19 +80,19 @@ impl OAuth2Session {
pub async fn match_or_set_session(
&mut self,
executor: impl Executor<'_, Database = Postgres>,
session: SessionInfo,
) -> anyhow::Result<SessionInfo> {
session: BrowserSession<PostgresqlBackend>,
) -> anyhow::Result<BrowserSession<PostgresqlBackend>> {
match self.user_session_id {
Some(id) if id == session.key() => Ok(session),
Some(id) if id == session.data => Ok(session),
Some(id) => Err(anyhow::anyhow!(
"session mismatch, expected {}, got {}",
id,
session.key()
session.data
)),
None => {
sqlx::query!(
"UPDATE oauth2_sessions SET user_session_id = $1 WHERE id = $2",
session.key(),
session.data,
self.id,
)
.execute(executor)

View File

@ -12,76 +12,29 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use std::borrow::BorrowMut;
use std::{borrow::BorrowMut, convert::TryInto};
use anyhow::Context;
use argon2::Argon2;
use chrono::{DateTime, Utc};
use mas_data_model::{Authentication, BrowserSession, User};
use password_hash::{PasswordHash, PasswordHasher, SaltString};
use rand::rngs::OsRng;
use serde::Serialize;
use sqlx::{Acquire, Executor, FromRow, Postgres, Transaction};
use thiserror::Error;
use tokio::task;
use tracing::{info_span, Instrument};
use warp::reject::Reject;
use super::{DatabaseInconsistencyError, PostgresqlBackend};
use crate::errors::HtmlError;
#[derive(Serialize, Debug, Clone, FromRow)]
pub struct User {
#[derive(Debug, Clone, FromRow)]
struct UserLookup {
pub id: i64,
pub username: String,
}
#[derive(Serialize, Debug, Clone, FromRow)]
pub struct SessionInfo {
id: i64,
user_id: i64,
username: String,
pub active: bool,
created_at: DateTime<Utc>,
pub last_authd_at: Option<DateTime<Utc>>,
}
impl SessionInfo {
#[must_use]
pub fn key(&self) -> i64 {
self.id
}
pub async fn reauth(
mut self,
conn: impl Acquire<'_, Database = Postgres>,
password: String,
) -> anyhow::Result<Self> {
let mut txn = conn.begin().await?;
self.last_authd_at = Some(authenticate_session(&mut txn, self.id, password).await?);
txn.commit().await?;
Ok(self)
}
pub async fn end(
mut self,
executor: impl Executor<'_, Database = Postgres>,
) -> anyhow::Result<Self> {
end_session(executor, self.id).await?;
self.active = false;
Ok(self)
}
pub(crate) fn samples() -> Vec<Self> {
vec![SessionInfo {
id: 1,
user_id: 2,
username: "john".to_string(),
active: true,
created_at: Utc::now(),
last_authd_at: Some(Utc::now()),
}]
}
}
#[derive(Debug, Error)]
pub enum LoginError {
#[error("could not find user {username:?}")]
@ -116,7 +69,7 @@ pub async fn login(
conn: impl Acquire<'_, Database = Postgres>,
username: &str,
password: String,
) -> Result<SessionInfo, LoginError> {
) -> Result<BrowserSession<PostgresqlBackend>, LoginError> {
let mut txn = conn.begin().await.context("could not start transaction")?;
let user = lookup_user_by_username(&mut txn, username)
.await
@ -132,8 +85,8 @@ pub async fn login(
})?;
let mut session = start_session(&mut txn, user).await?;
session.last_authd_at = Some(
authenticate_session(&mut txn, session.id, password)
session.last_authentication = Some(
authenticate_session(&mut txn, &session, password)
.await
.map_err(|source| {
if matches!(source, AuthenticationError::Password { .. }) {
@ -152,30 +105,73 @@ pub async fn login(
#[derive(Debug, Error)]
#[error("could not fetch session")]
pub struct ActiveSessionLookupError(#[from] sqlx::Error);
pub enum ActiveSessionLookupError {
Fetch(#[from] sqlx::Error),
Conversion(#[from] DatabaseInconsistencyError),
}
impl Reject for ActiveSessionLookupError {}
impl ActiveSessionLookupError {
#[must_use]
pub fn not_found(&self) -> bool {
matches!(self.0, sqlx::Error::RowNotFound)
matches!(
self,
ActiveSessionLookupError::Fetch(sqlx::Error::RowNotFound)
)
}
}
struct SessionLookup {
id: i64,
user_id: i64,
username: String,
created_at: DateTime<Utc>,
last_authentication_id: Option<i64>,
last_authd_at: Option<DateTime<Utc>>,
}
impl TryInto<BrowserSession<PostgresqlBackend>> for SessionLookup {
type Error = DatabaseInconsistencyError;
fn try_into(self) -> Result<BrowserSession<PostgresqlBackend>, Self::Error> {
let user = User {
data: self.user_id,
username: self.username,
sub: format!("fake-sub-{}", self.user_id),
};
let last_authentication = match (self.last_authentication_id, self.last_authd_at) {
(Some(id), Some(created_at)) => Some(Authentication {
data: id,
created_at,
}),
(None, None) => None,
_ => return Err(DatabaseInconsistencyError),
};
Ok(BrowserSession {
data: self.id,
user,
created_at: self.created_at,
last_authentication,
})
}
}
pub async fn lookup_active_session(
executor: impl Executor<'_, Database = Postgres>,
id: i64,
) -> Result<SessionInfo, ActiveSessionLookupError> {
) -> Result<BrowserSession<PostgresqlBackend>, ActiveSessionLookupError> {
let res = sqlx::query_as!(
SessionInfo,
SessionLookup,
r#"
SELECT
s.id,
u.id as user_id,
u.username,
s.active,
s.created_at,
a.id as "last_authentication_id?",
a.created_at as "last_authd_at?"
FROM user_sessions s
INNER JOIN users u
@ -189,65 +185,43 @@ pub async fn lookup_active_session(
id,
)
.fetch_one(executor)
.await?;
.await?
.try_into()?;
Ok(res)
}
pub async fn lookup_session(
executor: impl Executor<'_, Database = Postgres>,
#[derive(FromRow)]
struct SessionStartResult {
id: i64,
) -> anyhow::Result<SessionInfo> {
sqlx::query_as!(
SessionInfo,
r#"
SELECT
s.id,
u.id as user_id,
u.username,
s.active,
s.created_at,
a.created_at as "last_authd_at?"
FROM user_sessions s
INNER JOIN users u
ON s.user_id = u.id
LEFT JOIN user_session_authentications a
ON a.session_id = s.id
WHERE s.id = $1
ORDER BY a.created_at DESC
LIMIT 1
"#,
id,
)
.fetch_one(executor)
.await
.context("could not fetch session")
created_at: DateTime<Utc>,
}
pub async fn start_session(
executor: impl Executor<'_, Database = Postgres>,
user: User,
) -> anyhow::Result<SessionInfo> {
let (id, created_at): (i64, DateTime<Utc>) = sqlx::query_as(
user: User<PostgresqlBackend>,
) -> anyhow::Result<BrowserSession<PostgresqlBackend>> {
let res = sqlx::query_as!(
SessionStartResult,
r#"
INSERT INTO user_sessions (user_id)
VALUES ($1)
RETURNING id, created_at
"#,
user.data,
)
.bind(user.id)
.fetch_one(executor)
.await
.context("could not create session")?;
Ok(SessionInfo {
id,
user_id: user.id,
username: user.username,
active: true,
created_at,
last_authd_at: None,
})
let session = BrowserSession {
data: res.id,
user,
created_at: res.created_at,
last_authentication: None,
};
Ok(session)
}
#[derive(Debug, Error)]
@ -265,11 +239,17 @@ pub enum AuthenticationError {
Internal(#[from] tokio::task::JoinError),
}
#[derive(FromRow)]
struct AuthenticationInsertionResult {
id: i64,
created_at: DateTime<Utc>,
}
pub async fn authenticate_session(
txn: &mut Transaction<'_, Postgres>,
session_id: i64,
session: &BrowserSession<PostgresqlBackend>,
password: String,
) -> Result<DateTime<Utc>, AuthenticationError> {
) -> Result<Authentication<PostgresqlBackend>, AuthenticationError> {
// First, fetch the hashed password from the user associated with that session
let hashed_password: String = sqlx::query_scalar!(
r#"
@ -279,7 +259,7 @@ pub async fn authenticate_session(
ON u.id = s.user_id
WHERE s.id = $1
"#,
session_id,
session.data,
)
.fetch_one(txn.borrow_mut())
.await
@ -297,19 +277,23 @@ pub async fn authenticate_session(
.await??;
// That went well, let's insert the auth info
let created_at: DateTime<Utc> = sqlx::query_scalar!(
let res = sqlx::query_as!(
AuthenticationInsertionResult,
r#"
INSERT INTO user_session_authentications (session_id)
VALUES ($1)
RETURNING created_at
RETURNING id, created_at
"#,
session_id,
session.data,
)
.fetch_one(txn.borrow_mut())
.await
.map_err(AuthenticationError::Save)?;
Ok(created_at)
Ok(Authentication {
data: res.id,
created_at: res.created_at,
})
}
pub async fn register_user(
@ -317,7 +301,7 @@ pub async fn register_user(
phf: impl PasswordHasher,
username: &str,
password: &str,
) -> anyhow::Result<User> {
) -> anyhow::Result<User<PostgresqlBackend>> {
let salt = SaltString::generate(&mut OsRng);
let hashed_password = PasswordHash::generate(phf, password, salt.as_str())?;
@ -336,20 +320,24 @@ pub async fn register_user(
.context("could not insert user")?;
Ok(User {
id,
data: id,
username: username.to_string(),
sub: format!("fake-sub-{}", id),
})
}
pub async fn end_session(
executor: impl Executor<'_, Database = Postgres>,
id: i64,
session: &BrowserSession<PostgresqlBackend>,
) -> anyhow::Result<()> {
let res = sqlx::query!("UPDATE user_sessions SET active = FALSE WHERE id = $1", id)
.execute(executor)
.instrument(info_span!("End session"))
.await
.context("could not end session")?;
let res = sqlx::query!(
"UPDATE user_sessions SET active = FALSE WHERE id = $1",
session.data,
)
.execute(executor)
.instrument(info_span!("End session"))
.await
.context("could not end session")?;
match res.rows_affected() {
1 => Ok(()),
@ -358,32 +346,12 @@ pub async fn end_session(
}
}
#[allow(dead_code)]
pub async fn lookup_user_by_id(
executor: impl Executor<'_, Database = Postgres>,
id: i64,
) -> anyhow::Result<User> {
sqlx::query_as!(
User,
r#"
SELECT id, username
FROM users
WHERE id = $1
"#,
id
)
.fetch_one(executor)
.instrument(info_span!("Fetch user"))
.await
.context("could not fetch user")
}
pub async fn lookup_user_by_username(
executor: impl Executor<'_, Database = Postgres>,
username: &str,
) -> Result<User, sqlx::Error> {
sqlx::query_as!(
User,
) -> Result<User<PostgresqlBackend>, sqlx::Error> {
let res = sqlx::query_as!(
UserLookup,
r#"
SELECT id, username
FROM users
@ -393,5 +361,11 @@ pub async fn lookup_user_by_username(
)
.fetch_one(executor)
.instrument(info_span!("Fetch user"))
.await
.await?;
Ok(User {
data: res.id,
username: res.username,
sub: format!("fake-sub-{}", res.id),
})
}

View File

@ -14,16 +14,17 @@
//! Contexts used in templates
use mas_data_model::BrowserSession;
use oauth2_types::errors::OAuth2Error;
use serde::{ser::SerializeStruct, Serialize};
use url::Url;
use crate::{errors::ErroredForm, filters::CsrfToken, storage::SessionInfo};
use crate::{errors::ErroredForm, filters::CsrfToken, storage::PostgresqlBackend};
/// Helper trait to construct context wrappers
pub trait TemplateContext {
/// Attach a user session to the template context
fn with_session(self, current_session: SessionInfo) -> WithSession<Self>
fn with_session(self, current_session: BrowserSession<PostgresqlBackend>) -> WithSession<Self>
where
Self: Sized,
{
@ -34,7 +35,10 @@ pub trait TemplateContext {
}
/// Attach an optional user session to the template context
fn maybe_with_session(self, current_session: Option<SessionInfo>) -> WithOptionalSession<Self>
fn maybe_with_session(
self,
current_session: Option<BrowserSession<PostgresqlBackend>>,
) -> WithOptionalSession<Self>
where
Self: Sized,
{
@ -91,7 +95,7 @@ impl<T: TemplateContext> TemplateContext for WithCsrf<T> {
/// Context with a user session in it
#[derive(Serialize)]
pub struct WithSession<T> {
current_session: SessionInfo,
current_session: BrowserSession<PostgresqlBackend>,
#[serde(flatten)]
inner: T,
@ -102,7 +106,7 @@ impl<T: TemplateContext> TemplateContext for WithSession<T> {
where
Self: Sized,
{
SessionInfo::samples()
BrowserSession::<PostgresqlBackend>::samples()
.into_iter()
.flat_map(|session| {
T::sample().into_iter().map(move |inner| WithSession {
@ -117,7 +121,7 @@ impl<T: TemplateContext> TemplateContext for WithSession<T> {
/// Context with an optional user session in it
#[derive(Serialize)]
pub struct WithOptionalSession<T> {
current_session: Option<SessionInfo>,
current_session: Option<BrowserSession<PostgresqlBackend>>,
#[serde(flatten)]
inner: T,
@ -128,7 +132,7 @@ impl<T: TemplateContext> TemplateContext for WithOptionalSession<T> {
where
Self: Sized,
{
SessionInfo::samples()
BrowserSession::<PostgresqlBackend>::samples()
.into_iter()
.map(Some) // Wrap all samples in an Option
.chain(std::iter::once(None)) // Add the "None" option

View File

@ -34,7 +34,7 @@ limitations under the License.
<div class="navbar-end">
{% if current_session %}
<div class="navbar-item">
Howdy {{ current_session.username }}!
Howdy {{ current_session.user.username }}!
</div>
<div class="navbar-item">
<form method="POST" action="/logout">

View File

@ -0,0 +1,13 @@
[package]
name = "mas-data-model"
version = "0.1.0"
authors = ["Quentin Gliech <quenting@element.io>"]
edition = "2018"
license = "Apache-2.0"
[dependencies]
chrono = "0.4.19"
thiserror = "1.0.29"
serde = "1.0.130"
oauth2-types = { path = "../oauth2-types" }

View File

@ -0,0 +1,134 @@
// 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 chrono::{DateTime, Duration, Utc};
use oauth2_types::{pkce::CodeChallengeMethod, scope::Scope};
use serde::Serialize;
pub trait StorageBackend {
type UserData: Clone + std::fmt::Debug + PartialEq;
type AuthenticationData: Clone + std::fmt::Debug + PartialEq;
type BrowserSessionData: Clone + std::fmt::Debug + PartialEq;
type ClientData: Clone + std::fmt::Debug + PartialEq;
type SessionData: Clone + std::fmt::Debug + PartialEq;
type AuthorizationCodeData: Clone + std::fmt::Debug + PartialEq;
type AccessTokenData: Clone + std::fmt::Debug + PartialEq;
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct User<T: StorageBackend> {
#[serde(skip_serializing)]
pub data: T::UserData,
pub username: String,
pub sub: String,
}
impl<T: StorageBackend> User<T>
where
T::UserData: Default,
{
pub fn samples() -> Vec<Self> {
vec![User {
data: Default::default(),
username: "john".to_string(),
sub: "123-456".to_string(),
}]
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Authentication<T: StorageBackend> {
#[serde(skip_serializing)]
pub data: T::AuthenticationData,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct BrowserSession<T: StorageBackend> {
#[serde(skip_serializing)]
pub data: T::BrowserSessionData,
pub user: User<T>,
pub created_at: DateTime<Utc>,
pub last_authentication: Option<Authentication<T>>,
}
impl<T: StorageBackend> BrowserSession<T>
where
T::BrowserSessionData: Default,
T::UserData: Default,
{
pub fn samples() -> Vec<Self> {
User::<T>::samples()
.into_iter()
.map(|user| BrowserSession {
data: Default::default(),
user,
created_at: Utc::now(),
last_authentication: None,
})
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Client<T: StorageBackend> {
#[serde(skip_serializing)]
pub data: T::ClientData,
pub client_id: String,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Session<T: StorageBackend> {
#[serde(skip_serializing)]
pub data: T::SessionData,
pub browser_session: Option<BrowserSession<T>>,
pub client: Client<T>,
pub scope: Scope,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Pkce {
challenge_method: CodeChallengeMethod,
challenge: String,
}
impl Pkce {
pub fn new(challenge_method: CodeChallengeMethod, challenge: String) -> Self {
Pkce {
challenge_method,
challenge,
}
}
pub fn verify(&self, verifier: &str) -> bool {
self.challenge_method.verify(&self.challenge, verifier)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AuthorizationCode<T: StorageBackend> {
#[serde(skip_serializing)]
pub data: T::AuthorizationCodeData,
pub code: String,
pub pkce: Pkce,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccessToken<T: StorageBackend> {
pub data: T::AccessTokenData,
pub jti: String,
pub token: String,
pub expires_after: Duration,
pub created_at: DateTime<Utc>,
}