From c5eef85e6b48475436c6ac7b426868e9c41e4991 Mon Sep 17 00:00:00 2001 From: Olivier 'reivilibre Date: Sun, 14 Jan 2024 19:45:25 +0000 Subject: [PATCH] Add application sessions --- ...3d45f6faa7bfcdf8f5e4e60ef0f7070f06504.json | 16 ++++ ...d4dfa52961e7f683293fd334c24f14cffcac3.json | 23 ++++++ ...df9a156c6d5e75b2f29db9c101381459cebfa.json | 14 ++++ ...a7b19650ed4d3dc44c1519775a6d0c9026b7.json} | 10 ++- ...3205703fc00aaeeedaa198b1a8d386faf9fb6.json | 23 ++++++ migrations/20231205205358_login_sessions.sql | 2 + .../20240114144634_application_sessions.sql | 70 ++++++++++++++++ src/store.rs | 80 +++++++++++++++++-- src/web/login.rs | 1 + src/web/oauth_openid/authorisation.rs | 6 +- src/web/oauth_openid/ext_codes.rs | 9 ++- src/web/oauth_openid/token.rs | 77 ++++++++++++++---- 12 files changed, 301 insertions(+), 30 deletions(-) create mode 100644 .sqlx/query-0869ee80d31becfb73918a0cd4f3d45f6faa7bfcdf8f5e4e60ef0f7070f06504.json create mode 100644 .sqlx/query-8906051fcdb5602682a9beadeefd4dfa52961e7f683293fd334c24f14cffcac3.json create mode 100644 .sqlx/query-9a4151f5594ff961abd8e615fabdf9a156c6d5e75b2f29db9c101381459cebfa.json rename .sqlx/{query-7a2835e1bed065bddf7cec4a5a6740664e29e4faf414e4546e24c909a0bdfa67.json => query-d6b173d90cd227a65bfc4a47ed3ba7b19650ed4d3dc44c1519775a6d0c9026b7.json} (55%) create mode 100644 .sqlx/query-fd60725a6f572aaf53a2fb5d4163205703fc00aaeeedaa198b1a8d386faf9fb6.json create mode 100644 migrations/20240114144634_application_sessions.sql diff --git a/.sqlx/query-0869ee80d31becfb73918a0cd4f3d45f6faa7bfcdf8f5e4e60ef0f7070f06504.json b/.sqlx/query-0869ee80d31becfb73918a0cd4f3d45f6faa7bfcdf8f5e4e60ef0f7070f06504.json new file mode 100644 index 0000000..bb05d10 --- /dev/null +++ b/.sqlx/query-0869ee80d31becfb73918a0cd4f3d45f6faa7bfcdf8f5e4e60ef0f7070f06504.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO application_access_tokens (access_token_hash, session_id, issued_at_utc, expires_at_utc)\n VALUES ($1, $2, NOW(), $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bytea", + "Int4", + "Timestamp" + ] + }, + "nullable": [] + }, + "hash": "0869ee80d31becfb73918a0cd4f3d45f6faa7bfcdf8f5e4e60ef0f7070f06504" +} diff --git a/.sqlx/query-8906051fcdb5602682a9beadeefd4dfa52961e7f683293fd334c24f14cffcac3.json b/.sqlx/query-8906051fcdb5602682a9beadeefd4dfa52961e7f683293fd334c24f14cffcac3.json new file mode 100644 index 0000000..7f98efe --- /dev/null +++ b/.sqlx/query-8906051fcdb5602682a9beadeefd4dfa52961e7f683293fd334c24f14cffcac3.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT 1 AS ok FROM login_sessions\n WHERE login_session_id = $1 AND user_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "ok", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4", + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "8906051fcdb5602682a9beadeefd4dfa52961e7f683293fd334c24f14cffcac3" +} diff --git a/.sqlx/query-9a4151f5594ff961abd8e615fabdf9a156c6d5e75b2f29db9c101381459cebfa.json b/.sqlx/query-9a4151f5594ff961abd8e615fabdf9a156c6d5e75b2f29db9c101381459cebfa.json new file mode 100644 index 0000000..f2ce6a6 --- /dev/null +++ b/.sqlx/query-9a4151f5594ff961abd8e615fabdf9a156c6d5e75b2f29db9c101381459cebfa.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM application_access_tokens WHERE access_token_hash = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [] + }, + "hash": "9a4151f5594ff961abd8e615fabdf9a156c6d5e75b2f29db9c101381459cebfa" +} diff --git a/.sqlx/query-7a2835e1bed065bddf7cec4a5a6740664e29e4faf414e4546e24c909a0bdfa67.json b/.sqlx/query-d6b173d90cd227a65bfc4a47ed3ba7b19650ed4d3dc44c1519775a6d0c9026b7.json similarity index 55% rename from .sqlx/query-7a2835e1bed065bddf7cec4a5a6740664e29e4faf414e4546e24c909a0bdfa67.json rename to .sqlx/query-d6b173d90cd227a65bfc4a47ed3ba7b19650ed4d3dc44c1519775a6d0c9026b7.json index 934b03a..2033c85 100644 --- a/.sqlx/query-7a2835e1bed065bddf7cec4a5a6740664e29e4faf414e4546e24c909a0bdfa67.json +++ b/.sqlx/query-d6b173d90cd227a65bfc4a47ed3ba7b19650ed4d3dc44c1519775a6d0c9026b7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT user_name, user_id, csrf_secret\n FROM login_sessions INNER JOIN users USING (user_id)\n WHERE login_session_token_hash = $1\n ", + "query": "\n SELECT user_name, user_id, login_session_id, csrf_secret\n FROM login_sessions INNER JOIN users USING (user_id)\n WHERE login_session_token_hash = $1\n ", "describe": { "columns": [ { @@ -15,6 +15,11 @@ }, { "ordinal": 2, + "name": "login_session_id", + "type_info": "Int4" + }, + { + "ordinal": 3, "name": "csrf_secret", "type_info": "Bytea" } @@ -25,10 +30,11 @@ ] }, "nullable": [ + false, false, false, false ] }, - "hash": "7a2835e1bed065bddf7cec4a5a6740664e29e4faf414e4546e24c909a0bdfa67" + "hash": "d6b173d90cd227a65bfc4a47ed3ba7b19650ed4d3dc44c1519775a6d0c9026b7" } diff --git a/.sqlx/query-fd60725a6f572aaf53a2fb5d4163205703fc00aaeeedaa198b1a8d386faf9fb6.json b/.sqlx/query-fd60725a6f572aaf53a2fb5d4163205703fc00aaeeedaa198b1a8d386faf9fb6.json new file mode 100644 index 0000000..c6c0258 --- /dev/null +++ b/.sqlx/query-fd60725a6f572aaf53a2fb5d4163205703fc00aaeeedaa198b1a8d386faf9fb6.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO application_sessions (user_id, application_id, started_at_utc, last_seen_at_utc)\n VALUES ($1, $2, NOW(), NOW())\n RETURNING session_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "session_id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "fd60725a6f572aaf53a2fb5d4163205703fc00aaeeedaa198b1a8d386faf9fb6" +} diff --git a/migrations/20231205205358_login_sessions.sql b/migrations/20231205205358_login_sessions.sql index 9a7f54c..7be5d51 100644 --- a/migrations/20231205205358_login_sessions.sql +++ b/migrations/20231205205358_login_sessions.sql @@ -8,6 +8,8 @@ CREATE TABLE login_sessions ( csrf_secret BYTEA NOT NULL ); +CREATE INDEX ON login_sessions (user_id); + COMMENT ON TABLE login_sessions IS 'A login session is equivalent to one browser session where the user has logged in. The session is identified by a cookie.'; COMMENT ON COLUMN login_sessions.login_session_id IS 'Synthetic numeric ID for the session, used for cross-referencing only. Should not be public.'; diff --git a/migrations/20240114144634_application_sessions.sql b/migrations/20240114144634_application_sessions.sql new file mode 100644 index 0000000..04b3c78 --- /dev/null +++ b/migrations/20240114144634_application_sessions.sql @@ -0,0 +1,70 @@ + +CREATE TABLE application_sessions ( + session_id SERIAL NOT NULL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(user_id), + application_id TEXT NOT NULL, + + started_at_utc TIMESTAMP NOT NULL, + last_seen_at_utc TIMESTAMP NOT NULL +); + +CREATE INDEX ON application_sessions (user_id); + +COMMENT ON TABLE application_sessions IS 'Stores the sessions that involve an OIDC application and a user.'; + +COMMENT ON COLUMN application_sessions.user_id IS 'UUID of the user.'; + +COMMENT ON COLUMN application_sessions.application_id IS 'client_id of the OpenID Connect application for which this session is issued.'; + +COMMENT ON COLUMN application_sessions.started_at_utc IS 'Time when this session was started.'; + +COMMENT ON COLUMN application_sessions.last_seen_at_utc IS 'Time when we last heard of activity in this session. Does not mean the user has not been using the application since.'; + + +CREATE TABLE application_access_tokens ( + access_token_hash BYTEA NOT NULL PRIMARY KEY, + session_id INTEGER NOT NULL REFERENCES application_sessions(session_id) ON DELETE CASCADE, + issued_at_utc TIMESTAMP NOT NULL, + + expires_at_utc TIMESTAMP NOT NULL, + + created_from_refresh_token_hash BYTEA UNIQUE -- intentionally nullable +); + +CREATE INDEX ON application_access_tokens (session_id); + +COMMENT ON TABLE application_access_tokens IS 'Stores the hash of access tokens that have been issued to applications on the user''s behalf.'; + +COMMENT ON COLUMN application_access_tokens.access_token_hash IS 'BLAKE2s-256 hash of the refresh token.'; + +COMMENT ON COLUMN application_access_tokens.session_id IS 'ID of the session. This access token will cease to exist if the session is deleted.'; + +COMMENT ON COLUMN application_access_tokens.created_from_refresh_token_hash IS 'If set, this is the hash of the refresh token that was used to create this access token. This lets us track acknowledgement of a refresh.'; + +COMMENT ON COLUMN application_access_tokens.issued_at_utc IS 'Time when this access token was issued.'; + +COMMENT ON COLUMN application_access_tokens.expires_at_utc IS 'Time when this access token expires.'; + + +CREATE TABLE application_refresh_tokens ( + refresh_token_hash BYTEA NOT NULL PRIMARY KEY, + session_id INTEGER NOT NULL REFERENCES application_sessions(session_id) ON DELETE CASCADE, + created_from_refresh_token_hash BYTEA UNIQUE, -- intentionally nullable + + issued_at_utc TIMESTAMP NOT NULL, + expires_at_utc TIMESTAMP NOT NULL +); + +CREATE INDEX ON application_refresh_tokens (session_id); + +COMMENT ON TABLE application_refresh_tokens IS 'Stores the hash of refresh tokens that have been issued to applications on the user''s behalf.'; + +COMMENT ON COLUMN application_refresh_tokens.refresh_token_hash IS 'BLAKE2s-256 hash of the refresh token.'; + +COMMENT ON COLUMN application_refresh_tokens.session_id IS 'ID of the session. This refresh token will cease to exist if the session is deleted.'; + +COMMENT ON COLUMN application_refresh_tokens.created_from_refresh_token_hash IS 'If set, this is the hash of the refresh token that was used to create this refresh token. This lets us track acknowledgement of a refresh.'; + +COMMENT ON COLUMN application_refresh_tokens.issued_at_utc IS 'Time when this refresh token was issued.'; + +COMMENT ON COLUMN application_refresh_tokens.expires_at_utc IS 'Time when this refresh token expires.'; diff --git a/src/store.rs b/src/store.rs index 6e2045d..c270e07 100644 --- a/src/store.rs +++ b/src/store.rs @@ -1,4 +1,6 @@ +use chrono::DateTime; use chrono::NaiveDateTime; +use chrono::Utc; use eyre::eyre; use eyre::Context; use futures::future::BoxFuture; @@ -6,8 +8,7 @@ use sqlx::{types::Uuid, Connection, PgPool, Postgres, Transaction}; use tracing::error; use crate::web::login::{ - LoginSession, LOGIN_SESSION_CSRF_SECRET_BYTES, - LOGIN_SESSION_TOKEN_HASH_BYTES, + LoginSession, LOGIN_SESSION_CSRF_SECRET_BYTES, LOGIN_SESSION_TOKEN_HASH_BYTES, }; /// Postgres-backed storage for IdCoop @@ -83,13 +84,77 @@ pub struct IdCoopStoreTxn<'a, 'txn> { impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> { pub async fn invalidate_access_token_by_hash( &mut self, - access_token_hash: String, + access_token_hash: &[u8], ) -> eyre::Result<()> { - todo!() + // TODO presumably we also want to do the same for refresh tokens + sqlx::query!( + " + DELETE FROM application_access_tokens WHERE access_token_hash = $1 + ", + access_token_hash + ) + .execute(&mut **self.txn) + .await + .context("failed to invalidate access token by hash")?; + Ok(()) } - pub async fn issue_access_token(&mut self, access_token_hash: String) -> eyre::Result<()> { - error!("TODO: issue access token"); + /// Creates a new application session for the given (user, application) pair. + /// Requires the provided user login session ID to be valid and still active. + /// Returns a new application session ID, or None if the login session was not active. + pub async fn create_application_session( + &mut self, + user_id: Uuid, + application_id: &str, + login_session_id: i32, + ) -> eyre::Result> { + let login_session_opt = sqlx::query!( + " + SELECT 1 AS ok FROM login_sessions + WHERE login_session_id = $1 AND user_id = $2 + ", + login_session_id, + user_id + ) + .fetch_optional(&mut **self.txn) + .await + .context("failed to fetch login session when creating application session")?; + let Some(_login_session) = login_session_opt else { + return Ok(None); + }; + + let row = sqlx::query!( + " + INSERT INTO application_sessions (user_id, application_id, started_at_utc, last_seen_at_utc) + VALUES ($1, $2, NOW(), NOW()) + RETURNING session_id + ", + user_id, + application_id + ) + .fetch_one(&mut **self.txn) + .await + .context("failed to create application session")?; + Ok(Some(row.session_id)) + } + + pub async fn issue_access_token( + &mut self, + access_token_hash: &[u8], + session_id: i32, + expires_at: DateTime, + ) -> eyre::Result<()> { + let expires_at = expires_at.naive_utc(); + sqlx::query!( + " + INSERT INTO application_access_tokens (access_token_hash, session_id, issued_at_utc, expires_at_utc) + VALUES ($1, $2, NOW(), $3) + ", + access_token_hash, session_id, expires_at + ) + .execute(&mut **self.txn) + .await + .context("failed to issue access token")?; Ok(()) } @@ -184,7 +249,7 @@ impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> { ) -> eyre::Result> { let row_opt = sqlx::query!( " - SELECT user_name, user_id, csrf_secret + SELECT user_name, user_id, login_session_id, csrf_secret FROM login_sessions INNER JOIN users USING (user_id) WHERE login_session_token_hash = $1 ", @@ -199,6 +264,7 @@ impl<'a, 'txn> IdCoopStoreTxn<'a, 'txn> { Ok(Some(LoginSession { user_name: row.user_name, user_id: row.user_id, + login_session_id: row.login_session_id, csrf_secret: row .csrf_secret .try_into() diff --git a/src/web/login.rs b/src/web/login.rs index b220b3e..8b68356 100644 --- a/src/web/login.rs +++ b/src/web/login.rs @@ -40,6 +40,7 @@ pub const LOGIN_SESSION_CSRF_SECRET_BYTES: usize = 8; pub struct LoginSession { pub user_name: String, pub user_id: Uuid, + pub login_session_id: i32, pub csrf_secret: [u8; LOGIN_SESSION_CSRF_SECRET_BYTES], } diff --git a/src/web/oauth_openid/authorisation.rs b/src/web/oauth_openid/authorisation.rs index f461e91..024eb5f 100644 --- a/src/web/oauth_openid/authorisation.rs +++ b/src/web/oauth_openid/authorisation.rs @@ -10,7 +10,6 @@ use axum_csrf::CsrfToken; use eyre::{Context, ContextCompat}; - use serde::{Deserialize, Serialize}; use tracing::{error, info}; @@ -230,7 +229,7 @@ async fn show_consent_page( /// - query.request_uri has been validated as a safe redirect URI async fn process_authorisation( query: AuthorisationQuery, - _login_session: LoginSession, + login_session: LoginSession, client_id: String, _client_config: &OidcClientConfiguration, config: &Configuration, @@ -259,7 +258,8 @@ async fn process_authorisation( .code_challenge_method .unwrap_or_else(|| String::from("plain")), code_challenge, - // TODO this should be bound to the login session + user_id: login_session.user_id, + user_login_session_id: login_session.login_session_id, }, 0, ); diff --git a/src/web/oauth_openid/ext_codes.rs b/src/web/oauth_openid/ext_codes.rs index 3c12d26..9341c3f 100644 --- a/src/web/oauth_openid/ext_codes.rs +++ b/src/web/oauth_openid/ext_codes.rs @@ -8,6 +8,7 @@ use std::{ use base64::{display::Base64Display, prelude::BASE64_URL_SAFE_NO_PAD, Engine}; use rand::Rng; +use sqlx::types::Uuid; use tokio::sync::Notify; /// Display shows the auth code as base64 (URL-safe non-padded). @@ -15,7 +16,10 @@ use tokio::sync::Notify; #[derive(Clone, Hash, PartialEq, Eq, Ord, PartialOrd)] pub struct AuthCode([u8; 24]); -pub type AccessTokenHash = String; +pub type AccessToken = [u8; 32]; +pub type AccessTokenHash = [u8; 32]; +pub type RefreshToken = [u8; 32]; +pub type RefreshTokenHash = [u8; 32]; impl Display for AuthCode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -52,7 +56,8 @@ pub struct AuthCodeBinding { pub client_id: String, pub code_challenge_method: String, pub code_challenge: String, - // TODO info about the user... + pub user_id: Uuid, + pub user_login_session_id: i32, } pub struct RedeemedAuthCode { diff --git a/src/web/oauth_openid/token.rs b/src/web/oauth_openid/token.rs index 6f2b29d..1e9b942 100644 --- a/src/web/oauth_openid/token.rs +++ b/src/web/oauth_openid/token.rs @@ -8,22 +8,28 @@ use axum::{ Extension, Form, Json, TypedHeader, }; use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine}; +use blake2::Blake2s256; +use chrono::{DateTime, Duration, Utc}; use eyre::{bail, Context}; use josekit::{ jws::{alg::rsassa::RsassaJwsAlgorithm::Rs256, JwsHeader}, jwt::JwtPayload, }; +use rand::{thread_rng, Rng}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use subtle::ConstantTimeEq; -use tracing::error; +use tracing::{debug, error}; use crate::{ config::{Configuration, SecretConfig}, store::IdCoopStore, }; -use super::ext_codes::{AuthCode, CodeRedemption, VolatileCodeStore}; +use super::ext_codes::{ + AccessToken, AccessTokenHash, AuthCode, CodeRedemption, RefreshToken, RefreshTokenHash, + VolatileCodeStore, +}; #[derive(Deserialize)] pub struct TokenFormParams { @@ -162,10 +168,12 @@ pub async fn oidc_token( // Create an access token but don't actually issue it yet: // This lets us store the hash of the access token against the redemption of the auth code, // so double redemptions can invalidate the access token appropriately. - let access_token = String::new(); // TODO!!! - let access_token_hash = String::new(); // TODO!!! - let refresh_token_hash = String::new(); // TODO!!! - let refresh_token = String::new(); // TODO!!! + let access_token = thread_rng().gen::(); + let access_token_b64 = BASE64_URL_SAFE_NO_PAD.encode(access_token); + let access_token_hash: AccessTokenHash = Blake2s256::digest(&access_token).into(); + let refresh_token = thread_rng().gen::(); + let refresh_token_b64 = BASE64_URL_SAFE_NO_PAD.encode(refresh_token); + let refresh_token_hash: RefreshTokenHash = Blake2s256::digest(&refresh_token).into(); // Redeem the auth code so we can check it and then maybe issue an access token. let binding = match code_store.redeem(&auth_code, access_token_hash.clone()) { @@ -187,7 +195,7 @@ pub async fn oidc_token( if let Err(err) = store .txn(move |mut txn| { Box::pin(async move { - txn.invalidate_access_token_by_hash(access_token_to_invalidate) + txn.invalidate_access_token_by_hash(&access_token_to_invalidate) .await .context("failed to invalidate access token") }) @@ -246,22 +254,59 @@ pub async fn oidc_token( .into_response(); } - // Issue access token - if let Err(err) = store + let application_id = binding.client_id.clone(); + let user_id = binding.user_id; + let user_login_session_id = binding.user_login_session_id; + + // Issue access token for a new session + match store .txn(move |mut txn| { Box::pin(async move { - txn.issue_access_token(access_token_hash) + // TODO!!! User info + let Some(session_id) = txn + .create_application_session(user_id, &application_id, user_login_session_id) .await - .context("issue_access_token") + .context("create_application_session")? else { + return Ok(Err( + ( + StatusCode::BAD_REQUEST, + Json(TokenError { + code: TokenErrorCode::InvalidGrant, + description: "Auth code has expired or was not valid.".to_owned(), + }), + ).into_response() + )); + }; + txn.issue_access_token( + &access_token_hash, + session_id, + // TODO Support custom expiry, not 100 years + Utc::now() + Duration::days(365 * 100), + ) + .await + .context("issue_access_token")?; + + Ok(Ok(session_id)) }) }) .await { - error!("failed to issue access token: {err:?}"); - return ( + Ok(Ok(session_id)) => { + debug!( + "created application session {session_id:?} for {:?}", + binding.client_id + ); + } + Ok(Err(web_err)) => { + return web_err; + } + Err(err) => { + error!("failed to issue access token: {err:?}"); + return ( StatusCode::INTERNAL_SERVER_ERROR, "database error when issuing access token; ask the server administrator to check server logs" ).into_response(); + } } let id_token = IdToken { iss: config.oidc.issuer.clone(), @@ -285,10 +330,10 @@ pub async fn oidc_token( ( StatusCode::OK, Json(TokenSuccess { - access_token, + access_token: access_token_b64, token_type: "Bearer".to_owned(), - refresh_token, - expires_in: 60, // TODO + refresh_token: refresh_token_b64, + expires_in: 86400 * 365, // TODO // This assumes that we only support the OpenID scope at present. scope: "openid".to_owned(), id_token,