Rework browser sessions

This commit is contained in:
Simon Bihel
2022-02-21 11:29:37 +00:00
parent 950a493dc4
commit 66b2c51339
10 changed files with 173 additions and 617 deletions

View File

@@ -1,5 +1,4 @@
use anyhow::{anyhow, Result};
use async_redis_session::RedisSessionStore;
use axum::{
extract::{self, Extension, Form, Path, Query, TypedHeader},
http::{
@@ -22,7 +21,7 @@ use headers::{
};
use openidconnect::core::{
CoreClientMetadata, CoreClientRegistrationResponse, CoreJsonWebKeySet, CoreProviderMetadata,
CoreResponseType, CoreTokenResponse, CoreUserInfoClaims, CoreUserInfoJsonWebToken,
CoreTokenResponse, CoreUserInfoClaims, CoreUserInfoJsonWebToken,
};
use rand::rngs::OsRng;
use rsa::{
@@ -38,7 +37,6 @@ use tracing::info;
use super::config;
use super::oidc::{self, CustomError};
use super::session::*;
use ::siwe_oidc::db::*;
impl IntoResponse for CustomError {
@@ -82,14 +80,6 @@ async fn provider_metadata(
Ok(oidc::metadata(config.base_url)?.into())
}
// TODO should check Authorization header
// Actually, client secret can be
// 1. in the POST (currently supported) [x]
// 2. Authorization header [x]
// 3. JWT [ ]
// 4. signed JWT [ ]
// according to Keycloak
async fn token(
Form(form): Form<oidc::TokenForm>,
bearer: Option<TypedHeader<Authorization<Bearer>>>,
@@ -116,43 +106,16 @@ async fn token(
Ok(token_response.into())
}
// TODO handle `registration` parameter
async fn authorize(
session: UserSessionFromSession,
Query(params): Query<oidc::AuthorizeParams>,
Extension(redis_client): Extension<RedisClient>,
) -> Result<(HeaderMap, Redirect), CustomError> {
let (nonce, headers) = match session {
UserSessionFromSession::Found(nonce) => (nonce, HeaderMap::new()),
UserSessionFromSession::Invalid(cookie) => {
let mut headers = HeaderMap::new();
headers.insert(header::SET_COOKIE, cookie);
return Ok((
headers,
Redirect::to(
format!(
"/authorize?client_id={}&redirect_uri={}&scope={}&response_type={}&state={}&client_id={}{}",
&params.client_id,
&params.redirect_uri.to_string(),
&params.scope.to_string(),
&params.response_type.unwrap_or(CoreResponseType::Code).as_ref(),
&params.state.unwrap_or_default(),
&params.client_id,
&params.nonce.map(|n| format!("&nonce={}", n.secret())).unwrap_or_default()
)
.parse()
.map_err(|e| anyhow!("Could not parse URI: {}", e))?,
),
));
}
UserSessionFromSession::Created { header, nonce } => {
let mut headers = HeaderMap::new();
headers.insert(header::SET_COOKIE, header);
(nonce, headers)
}
};
let url = oidc::authorize(params, nonce, &redis_client).await?;
let (url, session_cookie) = oidc::authorize(params, &redis_client).await?;
let mut headers = HeaderMap::new();
headers.insert(
header::SET_COOKIE,
session_cookie.to_string().parse().unwrap(),
);
Ok((
headers,
Redirect::to(
@@ -164,58 +127,16 @@ async fn authorize(
}
async fn sign_in(
session: UserSessionFromSession,
Query(params): Query<oidc::SignInParams>,
TypedHeader(cookies): TypedHeader<headers::Cookie>,
Extension(redis_client): Extension<RedisClient>,
) -> Result<(HeaderMap, Redirect), CustomError> {
let (nonce, headers) = match session {
UserSessionFromSession::Found(nonce) => (nonce, HeaderMap::new()),
UserSessionFromSession::Invalid(header) => {
let mut headers = HeaderMap::new();
headers.insert(header::SET_COOKIE, header);
return Ok((
headers,
Redirect::to(
format!(
"/authorize?client_id={}&redirect_uri={}&scope=openid&response_type=code&state={}",
&params.client_id.clone(),
&params.redirect_uri.to_string(),
&params.state,
)
.parse()
.map_err(|e| anyhow!("Could not parse URI: {}", e))?,
),
));
}
UserSessionFromSession::Created { .. } => {
return Ok((
HeaderMap::new(),
Redirect::to(
format!(
"/authorize?client_id={}&redirect_uri={}&scope=openid&response_type=code&state={}",
&params.client_id.clone(),
&params.redirect_uri.to_string(),
&params.state,
)
.parse()
.map_err(|e| anyhow!("Could not parse URI: {}", e))?,
),
))
}
};
let url = oidc::sign_in(params, Some(nonce), cookies, &redis_client).await?;
Ok((
headers,
Redirect::to(
url.as_str()
.parse()
.map_err(|e| anyhow!("Could not parse URI: {}", e))?,
),
) -> Result<Redirect, CustomError> {
let url = oidc::sign_in(params, cookies, &redis_client).await?;
Ok(Redirect::to(
url.as_str()
.parse()
.map_err(|e| anyhow!("Could not parse URI: {}", e))?,
))
// TODO clear session
}
async fn register(
@@ -434,11 +355,6 @@ pub async fn main() {
.layer(AddExtensionLayer::new(private_key))
.layer(AddExtensionLayer::new(config.clone()))
.layer(AddExtensionLayer::new(redis_client))
.layer(AddExtensionLayer::new(
RedisSessionStore::new(config.redis_url.clone())
.unwrap()
.with_prefix("async-sessions/"),
))
.layer(TraceLayer::new_for_http());
let addr = SocketAddr::from((config.address, config.port));

View File

@@ -1,6 +1,5 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
// use cached::{stores::TimedCache, Cached};
use chrono::{DateTime, Duration, Utc};
use matchit::Node;
use std::collections::HashMap;
@@ -109,7 +108,6 @@ impl DBClient for CFClient {
.map_err(|e| anyhow!("Failed to serialize client entry: {}", e))?,
)
.map_err(|e| anyhow!("Failed to build KV put: {}", e))?
// TODO put some sort of expiration for dynamic registration
.execute()
.await
.map_err(|e| anyhow!("Failed to put KV: {}", e))?;
@@ -202,4 +200,32 @@ impl DBClient for CFClient {
code => Err(anyhow!("Error fetching from Durable Object: {}", code)),
}
}
async fn set_session(&self, id: String, entry: SessionEntry) -> Result<()> {
self.ctx
.kv(KV_NAMESPACE)
.map_err(|e| anyhow!("Failed to get KV store: {}", e))?
.put(
&format!("{}/{}", KV_SESSION_PREFIX, id),
serde_json::to_string(&entry)
.map_err(|e| anyhow!("Failed to serialize client entry: {}", e))?,
)
.map_err(|e| anyhow!("Failed to build KV put: {}", e))?
.expiration_ttl(SESSION_LIFETIME)
.execute()
.await
.map_err(|e| anyhow!("Failed to put KV: {}", e))?;
Ok(())
}
async fn get_session(&self, id: String) -> Result<Option<SessionEntry>> {
Ok(self
.ctx
.kv(KV_NAMESPACE)
.map_err(|e| anyhow!("Failed to get KV store: {}", e))?
.get(&format!("{}/{}", KV_SESSION_PREFIX, id))
.json()
.await
.map_err(|e| anyhow!("Failed to get KV: {}", e))?)
}
}

View File

@@ -15,7 +15,10 @@ mod cf;
pub use cf::CFClient;
const KV_CLIENT_PREFIX: &str = "clients";
const KV_SESSION_PREFIX: &str = "sessions";
pub const ENTRY_LIFETIME: usize = 30;
pub const SESSION_LIFETIME: u64 = 300; // 5min
pub const SESSION_COOKIE_NAME: &str = "session";
#[derive(Clone, Serialize, Deserialize)]
pub struct CodeEntry {
@@ -33,6 +36,14 @@ pub struct ClientEntry {
pub access_token: Option<RegistrationAccessToken>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct SessionEntry {
pub siwe_nonce: String,
pub oidc_nonce: Option<Nonce>,
pub secret: String,
pub signin_count: u64,
}
// Using a trait to easily pass async functions with async_trait
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
@@ -42,4 +53,6 @@ pub trait DBClient {
async fn delete_client(&self, client_id: String) -> Result<()>;
async fn set_code(&self, code: String, code_entry: CodeEntry) -> Result<()>;
async fn get_code(&self, code: String) -> Result<Option<CodeEntry>>;
async fn set_session(&self, id: String, entry: SessionEntry) -> Result<()>;
async fn get_session(&self, id: String) -> Result<Option<SessionEntry>>;
}

View File

@@ -98,4 +98,40 @@ impl DBClient for RedisClient {
.map_err(|e| anyhow!("Failed to deserialize code: {}", e))?;
Ok(Some(code_entry))
}
async fn set_session(&self, id: String, entry: SessionEntry) -> Result<()> {
let mut conn = self
.pool
.get()
.await
.map_err(|e| anyhow!("Failed to get connection to database: {}", e))?;
conn.set_ex(
format!("{}/{}", KV_SESSION_PREFIX, id),
serde_json::to_string(&entry)
.map_err(|e| anyhow!("Failed to serialize session entry: {}", e))?,
SESSION_LIFETIME.try_into().unwrap(),
)
.await
.map_err(|e| anyhow!("Failed to set kv: {}", e))?;
Ok(())
}
async fn get_session(&self, id: String) -> Result<Option<SessionEntry>> {
let mut conn = self
.pool
.get()
.await
.map_err(|e| anyhow!("Failed to get connection to database: {}", e))?;
let entry: Option<String> = conn
.get(format!("{}/{}", KV_SESSION_PREFIX, id))
.await
.map_err(|e| anyhow!("Failed to get kv: {}", e))?;
if let Some(e) = entry {
Ok(serde_json::from_str(&e)
.map_err(|e| anyhow!("Failed to deserialize session entry: {}", e))?)
} else {
Ok(None)
}
}
}

View File

@@ -5,8 +5,6 @@ mod config;
#[cfg(not(target_arch = "wasm32"))]
mod oidc;
#[cfg(not(target_arch = "wasm32"))]
mod session;
#[cfg(not(target_arch = "wasm32"))]
use axum_lib::main as axum_main;
#[cfg(not(target_arch = "wasm32"))]

View File

@@ -1,5 +1,6 @@
use anyhow::{anyhow, Result};
use chrono::{Duration, Utc};
use cookie::Cookie;
use ethers_core::{types::H160, utils::to_checksum};
use headers::{self, authorization::Bearer};
use hex::FromHex;
@@ -315,9 +316,8 @@ pub struct AuthorizeParams {
pub async fn authorize(
params: AuthorizeParams,
nonce: String,
db_client: &DBClientType,
) -> Result<String, CustomError> {
) -> Result<(String, Box<Cookie<'_>>), CustomError> {
let client_entry = db_client
.get_client(params.client_id.clone())
.await
@@ -328,6 +328,12 @@ pub async fn authorize(
));
}
let nonce: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(16)
.map(char::from)
.collect();
let mut r_u = params.redirect_uri.clone().url().clone();
r_u.set_query(None);
let mut r_us: Vec<Url> = client_entry
@@ -397,15 +403,45 @@ pub async fn authorize(
}
}
let session_id = Uuid::new_v4();
let session_secret: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(16)
.map(char::from)
.collect();
db_client
.set_session(
session_id.to_string(),
SessionEntry {
siwe_nonce: nonce.clone(),
oidc_nonce: params.nonce.clone(),
secret: session_secret.clone(),
signin_count: 0,
},
)
.await?;
let session_cookie = Cookie::build(SESSION_COOKIE_NAME, session_id.to_string())
// .domain(base)
// .path("/")
.secure(true)
.http_only(true)
.max_age(cookie::time::Duration::seconds(
SESSION_LIFETIME.try_into().unwrap(),
))
.finish();
let domain = params.redirect_uri.url().host().unwrap();
let oidc_nonce_param = if let Some(n) = &params.nonce {
format!("&oidc_nonce={}", n.secret())
} else {
"".to_string()
};
Ok(format!(
"/?nonce={}&domain={}&redirect_uri={}&state={}&client_id={}{}",
nonce, domain, *params.redirect_uri, state, params.client_id, oidc_nonce_param
Ok((
format!(
"/?nonce={}&domain={}&redirect_uri={}&state={}&client_id={}{}",
nonce, domain, *params.redirect_uri, state, params.client_id, oidc_nonce_param
),
Box::new(session_cookie),
))
}
@@ -467,10 +503,29 @@ pub struct SignInParams {
pub async fn sign_in(
params: SignInParams,
expected_nonce: Option<String>,
// cookies_header: String,
cookies: headers::Cookie,
db_client: &DBClientType,
) -> Result<Url, CustomError> {
// TODO redirect on session errors
let session_id = if let Some(c) = cookies.get(SESSION_COOKIE_NAME) {
c
} else {
return Err(CustomError::BadRequest(
"Session cookie not found".to_string(),
));
};
let session_entry = if let Some(e) = db_client.get_session(session_id.to_string()).await? {
e
} else {
return Err(CustomError::BadRequest("Session not found".to_string()));
};
if session_entry.signin_count > 0 {
return Err(CustomError::BadRequest(
"Session has already logged in".to_string(),
));
}
let siwe_cookie: SiweCookie = match cookies.get(SIWE_COOKIE_KEY) {
Some(c) => serde_json::from_str(
&decode(c).map_err(|e| anyhow!("Could not decode siwe cookie: {}", e))?,
@@ -508,7 +563,7 @@ pub async fn sign_in(
if domain.to_string() != *siwe_cookie.message.resources.get(0).unwrap().to_string() {
return Err(anyhow!("Conflicting domains in message and redirect").into());
}
if expected_nonce.is_some() && expected_nonce.unwrap() != siwe_cookie.message.nonce {
if session_entry.siwe_nonce != siwe_cookie.message.nonce {
return Err(anyhow!("Conflicting nonces in message and session").into());
}
@@ -520,6 +575,12 @@ pub async fn sign_in(
auth_time: Utc::now(),
};
let mut new_session_entry = session_entry.clone();
new_session_entry.signin_count += 1;
db_client
.set_session(session_id.to_string(), new_session_entry)
.await?;
let code = Uuid::new_v4();
db_client.set_code(code.to_string(), code_entry).await?;

View File

@@ -1,119 +0,0 @@
use async_redis_session::RedisSessionStore;
use async_session::{Session, SessionStore as _};
use axum::{
async_trait,
extract::{Extension, FromRequest, RequestParts},
http::{self, header::HeaderValue, StatusCode},
};
use cookie::Cookie;
use rand::{distributions::Alphanumeric, Rng};
use serde::{Deserialize, Serialize};
use tracing::debug;
use uuid::Uuid;
const SESSION_COOKIE_NAME: &str = "session";
const SESSION_KEY: &str = "user_session";
pub enum UserSessionFromSession {
Found(String),
Created { header: HeaderValue, nonce: String },
Invalid(HeaderValue),
}
#[async_trait]
impl<B> FromRequest<B> for UserSessionFromSession
where
B: Send,
{
type Rejection = (StatusCode, String);
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
let Extension(store) = match Extension::<RedisSessionStore>::from_request(req).await {
Ok(s) => s,
Err(e) => {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
format!("`MemoryStore` extension missing: {}", e),
))
}
};
let headers = if let Some(h) = req.headers() {
h
} else {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"other extractor taken headers".to_string(),
));
};
let session_cookie: Cookie = if let Some(session_cookie) = headers
.get(http::header::COOKIE)
.and_then(|value| value.to_str().ok())
.map(|header| {
header
.split(';')
.map(|cookie| Cookie::parse(cookie).ok())
.find(|cookie| {
cookie.is_some() && cookie.as_ref().unwrap().name() == SESSION_COOKIE_NAME
})
})
.flatten()
.flatten()
{
session_cookie
} else {
let user_session = UserSession::new();
let mut session = Session::new();
session.insert(SESSION_KEY, user_session.clone()).unwrap();
let cookie = store.store_session(session).await.unwrap().unwrap();
return Ok(Self::Created {
header: Cookie::new(SESSION_COOKIE_NAME, cookie)
.to_string()
.parse()
.unwrap(),
nonce: user_session.nonce,
});
};
let session = match store.load_session(session_cookie.value().to_string()).await {
Ok(Some(s)) => s,
_ => {
debug!("Could not load session");
let mut cookie = session_cookie.clone();
cookie.make_removal();
return Ok(Self::Invalid(cookie.to_string().parse().unwrap()));
}
};
let user_session = if let Some(user_session) = session.get::<UserSession>(SESSION_KEY) {
user_session
} else {
debug!("No `user_session` found in session");
let mut cookie = session_cookie.clone();
cookie.make_removal();
return Ok(Self::Invalid(cookie.to_string().parse().unwrap()));
};
Ok(Self::Found(user_session.nonce))
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct UserSession {
id: Uuid,
nonce: String,
}
impl UserSession {
fn new() -> Self {
Self {
id: Uuid::new_v4(),
nonce: rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(16)
.map(char::from)
.collect(),
}
}
}

View File

@@ -4,7 +4,6 @@ use headers::{
authorization::{Basic, Bearer, Credentials},
Authorization, ContentType, Header, HeaderValue,
};
use rand::{distributions::Alphanumeric, Rng};
use rsa::{pkcs1::FromRsaPrivateKey, RsaPrivateKey};
use worker::*;
@@ -197,7 +196,6 @@ pub async fn main(req: Request, env: Env) -> Result<Response> {
}
.and_then(|r| r.with_cors(&get_cors()))
})
// TODO add browser session
.get_async(oidc::AUTHORIZE_PATH, |req, ctx| async move {
let base_url: Url = ctx.var(BASE_URL_KEY)?.to_string().parse().unwrap();
let url = req.url()?;
@@ -206,15 +204,18 @@ pub async fn main(req: Request, env: Env) -> Result<Response> {
Ok(p) => p,
Err(_) => return CustomError::BadRequest("Bad query params".to_string()).into(),
};
let nonce = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(16)
.map(char::from)
.collect();
let url = req.url()?;
let db_client = CFClient { ctx, url };
match oidc::authorize(params, nonce, &db_client).await {
Ok(url) => Response::redirect(base_url.join(&url).unwrap()),
match oidc::authorize(params, &db_client).await {
Ok((url, session_cookie)) => {
Response::redirect(base_url.join(&url).unwrap()).map(|r| {
let mut headers = r.headers().clone();
headers
.set("set-cookie", &session_cookie.to_string())
.unwrap();
r.with_headers(headers)
})
}
Err(e) => match e {
CustomError::Redirect(url) => {
CustomError::Redirect(base_url.join(&url).unwrap().to_string())
@@ -320,7 +321,7 @@ pub async fn main(req: Request, env: Env) -> Result<Response> {
}
let url = req.url()?;
let db_client = CFClient { ctx, url };
match oidc::sign_in(params, None, cookies.unwrap(), &db_client).await {
match oidc::sign_in(params, cookies.unwrap(), &db_client).await {
Ok(url) => Response::redirect(url),
Err(e) => e.into(),
}