Add application sessions

This commit is contained in:
Olivier 'reivilibre' 2024-01-14 19:45:25 +00:00
parent a330ced1aa
commit c5eef85e6b
12 changed files with 301 additions and 30 deletions

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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.';

View File

@ -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.';

View File

@ -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<Option<i32>> {
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<Utc>,
) -> 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<Option<LoginSession>> {
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()

View File

@ -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],
}

View File

@ -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,
);

View File

@ -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 {

View File

@ -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::<AccessToken>();
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::<RefreshToken>();
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,