This commit is contained in:
Jacob Lifshay 2024-04-08 18:25:32 -07:00
parent 8b76a51434
commit d6ebd3a4a6
Signed by: programmerjake
SSH key fingerprint: SHA256:B1iRVvUJkvd7upMIiMqn6OyxvD2SgJkAH3ZnUOj6z+c
14 changed files with 276 additions and 24 deletions

3
.gitignore vendored
View file

@ -1,3 +1,4 @@
/target
/.vscode
/config.toml
/config.toml
*.db

45
Cargo.lock generated
View file

@ -795,6 +795,39 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "diesel"
version = "2.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03fc05c17098f21b89bc7d98fe1dd3cce2c11c2ad8e145f2a44fe08ed28eb559"
dependencies = [
"chrono",
"diesel_derives",
"libsqlite3-sys",
"time",
]
[[package]]
name = "diesel_derives"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d02eecb814ae714ffe61ddc2db2dd03e6c49a42e269b5001355500d431cce0c"
dependencies = [
"diesel_table_macro_syntax",
"proc-macro2",
"quote",
"syn 2.0.58",
]
[[package]]
name = "diesel_table_macro_syntax"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc5557efc453706fed5e4fa85006fe9817c224c3f480a34c7e5959fd700921c5"
dependencies = [
"syn 2.0.58",
]
[[package]]
name = "digest"
version = "0.10.7"
@ -1449,6 +1482,16 @@ version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058"
[[package]]
name = "libsqlite3-sys"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
dependencies = [
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.4.13"
@ -2434,9 +2477,11 @@ version = "0.1.0"
dependencies = [
"actix-session",
"actix-web",
"chrono",
"clap",
"clio",
"color-eyre",
"diesel",
"env_logger",
"eyre",
"futures",

View file

@ -8,9 +8,11 @@ edition = "2021"
[dependencies]
actix-session = { version = "0.9.0", features = ["cookie-session"] }
actix-web = { version = "4.5.1", features = ["secure-cookies"] }
chrono = { version = "0.4.37", features = ["now"] }
clap = { version = "4.5.4", features = ["derive"] }
clio = { version = "0.3.5", features = ["clap-parse"] }
color-eyre = "0.6.3"
diesel = { version = "2.1.5", features = ["sqlite", "chrono"] }
env_logger = "0.11.3"
eyre = "0.6.12"
futures = "0.3.30"

View file

@ -1,3 +1,5 @@
sqlite_db = "subscribe-list.db"
[oidc.google]
pretty_name = "Google"
issuer_url = "https://accounts.google.com"
@ -12,4 +14,4 @@ issuer_url = "https://salsa.debian.org"
client_id = "<TODO>"
secret = "<TODO>"
redirect_url = "https://my-site/subscription/callback/debian-salsa"
scopes = ["email"]
scopes = ["email", "openid"]

9
diesel.toml Normal file
View file

@ -0,0 +1,9 @@
# For documentation on how to configure this file,
# see https://diesel.rs/guides/configuring-diesel-cli
[print_schema]
file = "src/schema.rs"
custom_type_derives = ["diesel::query_builder::QueryId"]
[migrations_directory]
dir = "migrations"

0
migrations/.keep Normal file
View file

View file

@ -0,0 +1 @@
DROP TABLE "accounts"

View file

@ -0,0 +1,4 @@
CREATE TABLE "accounts" (
"email" TEXT NOT NULL PRIMARY KEY ASC,
"date" TIMESTAMP NOT NULL
) WITHOUT ROWID;

View file

@ -1,7 +1,4 @@
use crate::{
client::async_http_client,
config::{Config, OIDCProvider},
};
use crate::{client::async_http_client, config::Config, db::DbThread, models::Account};
use actix_session::Session;
use actix_web::{
get,
@ -9,10 +6,10 @@ use actix_web::{
web::{self, ServiceConfig},
HttpResponse, Responder,
};
use eyre::{ensure, Context, OptionExt};
use eyre::{ensure, OptionExt};
use openidconnect::{
core::{CoreAuthenticationFlow, CoreUserInfoClaims},
AuthorizationCode, CsrfToken, EndUserEmail, Nonce, OAuth2TokenResponse,
AuthorizationCode, CsrfToken, EndUserEmail, Nonce, OAuth2TokenResponse, TokenResponse,
};
use serde::{Deserialize, Serialize};
use std::marker::PhantomData;
@ -74,6 +71,8 @@ pub struct SubscriptionLoggedOutTemplateProviders {
templates! {
#[text = r#"<head><title>Subscription</title></head>
<body>
<h1>Register or Sign In:</h1>
As an anti-spam measure, you need to register using a 3rd-party account:
<ul>
{{- for provider in providers -}}
<li><a href="{provider.url}">{provider.pretty_name}</a></li>
@ -86,7 +85,11 @@ templates! {
}
#[text = r#"<head><title>Subscription</title></head>
<body>
<p>Logged in as {email}</p>
<p>Signed in as {email}</p>
<ul>
<li><a href="/subscription/logout">Logout</a></li>
<li><b><a href="/subscription/unsubscribe">Unsubscribe and Delete Account</a></b></li>
</ul>
</body>"#]
#[derive(Debug, Serialize)]
pub struct SubscriptionLoggedInTemplate {
@ -129,7 +132,44 @@ impl SessionState {
}
}
impl OIDCProvider {}
pub async fn get_or_insert_account(
email: EndUserEmail,
session: &Session,
db: web::Data<DbThread>,
) -> SessionState {
let state = SessionState {
email: email.clone(),
};
db.run_on(move |connection| {
use crate::schema::accounts::dsl::accounts;
use diesel::prelude::*;
let account = Account {
email: email.to_string(),
date: chrono::Utc::now().naive_utc(),
};
match diesel::insert_into(accounts)
.values(&account)
.on_conflict_do_nothing()
.execute(connection)
{
Ok(0) => {
log::debug!("found existing account: {}", account.email.as_str());
}
Ok(_) => {
log::info!("created new account: {}", account.email.as_str());
}
Err(e) => {
log::error!(
"failed to get/insert account: {}\n{e}",
account.email.as_str()
)
}
}
})
.await;
state.set(session);
state
}
const NONCE: &'static str = "nonce";
const CSRF_TOKEN: &'static str = "csrf_token";
@ -146,6 +186,7 @@ pub async fn callback(
session: Session,
path: web::Path<(String,)>,
query: web::Query<AuthQuery>,
db: web::Data<DbThread>,
) -> impl Responder {
let mut resp = HttpResponse::SeeOther().body("");
resp.headers_mut()
@ -155,15 +196,15 @@ pub async fn callback(
}
let body = async {
let csrf_token = session
.get::<CsrfToken>(CSRF_TOKEN)
.context("invalid csrf token")?
.ok_or_eyre("missing csrf token")?;
.remove_as::<CsrfToken>(CSRF_TOKEN)
.ok_or_eyre("missing csrf token")?
.ok()
.ok_or_eyre("invalid csrf token")?;
let nonce = session
.get::<Nonce>(NONCE)
.context("invalid nonce")?
.ok_or_eyre("missing nonce")?;
session.remove(CSRF_TOKEN);
session.remove(NONCE);
.remove_as::<Nonce>(NONCE)
.ok_or_eyre("missing nonce")?
.ok()
.ok_or_eyre("invalid nonce")?;
ensure!(
csrf_token.secret() == query.state.secret(),
"csrf token doesn't match"
@ -175,6 +216,14 @@ pub async fn callback(
.exchange_code(query.into_inner().code)
.request_async(async_http_client)
.await?;
if let Some(id_token) = token.id_token() {
let id_token_claims =
id_token.claims(&provider.state().client.id_token_verifier(), &nonce)?;
if let Some(email) = id_token_claims.email().cloned() {
get_or_insert_account(email, &session, db).await;
return Ok(());
}
}
let user_info: CoreUserInfoClaims = provider
.state()
.client
@ -182,7 +231,7 @@ pub async fn callback(
.request_async(async_http_client)
.await?;
let email = user_info.email().ok_or_eyre("no email provided")?.clone();
SessionState { email }.set(&session);
get_or_insert_account(email, &session, db).await;
Ok(())
};
let result: eyre::Result<()> = body.await;
@ -224,10 +273,50 @@ pub async fn login(
resp
}
#[get("/subscription/logout")]
pub async fn logout(session: Session) -> impl Responder {
session.purge();
let mut resp = HttpResponse::SeeOther().body("");
resp.headers_mut()
.insert(LOCATION, "/subscription".parse().unwrap());
resp
}
#[get("/subscription/unsubscribe")]
pub async fn unsubscribe(session: Session, db: web::Data<DbThread>) -> impl Responder {
if let Some(state) = SessionState::get(&session) {
db.run_on(move |connection| {
use crate::schema::accounts::dsl::*;
use diesel::prelude::*;
match diesel::delete(accounts.filter(email.eq(state.email.as_str())))
.execute(connection)
{
Ok(_) => {
log::info!("deleted account: {}", state.email.as_str());
}
Err(e) => {
log::error!("failed to delete account: {}\n{e}", state.email.as_str())
}
}
})
.await;
}
session.purge();
let mut resp = HttpResponse::SeeOther().body("");
resp.headers_mut()
.insert(LOCATION, "/subscription".parse().unwrap());
resp
}
#[get("/subscription")]
pub async fn subscription(config: web::Data<Config>, session: Session) -> impl Responder {
pub async fn subscription(
config: web::Data<Config>,
session: Session,
db: web::Data<DbThread>,
) -> impl Responder {
if let Some(SessionState { email }) = SessionState::get(&session) {
let mut template = SubscriptionLoggedInTemplate { email };
get_or_insert_account(email.clone(), &session, db).await;
let template = SubscriptionLoggedInTemplate { email };
HttpResponse::Ok()
.content_type(ContentType::html())
.body(template.render().expect("rendering can't fail"))
@ -248,7 +337,11 @@ pub async fn subscription(config: web::Data<Config>, session: Session) -> impl R
}
pub fn all_services(cfg: &mut ServiceConfig) {
cfg.service(subscription).service(login).service(callback);
cfg.service(subscription)
.service(login)
.service(callback)
.service(logout)
.service(unsubscribe);
}
#[cfg(test)]

View file

@ -9,9 +9,17 @@ use openidconnect::{
ClientId, ClientSecret, IssuerUrl, RedirectUrl, Scope,
};
use serde::{Deserialize, Serialize};
use std::num::NonZeroU16;
pub fn default_db_thread_channel_capacity() -> NonZeroU16 {
NonZeroU16::new(10).unwrap()
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct Config {
pub sqlite_db: String,
#[serde(default = "default_db_thread_channel_capacity")]
pub db_thread_channel_capacity: NonZeroU16,
pub oidc: IndexMap<String, OIDCProvider>,
}
@ -27,6 +35,7 @@ impl ValueParserFactory for Config {
}
impl Config {
#[allow(dead_code)]
pub const EXAMPLE: &'static str =
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/config.toml.sample"));
pub fn load_str(input: &str, path: impl ToString) -> eyre::Result<Config> {
@ -35,7 +44,7 @@ impl Config {
pub fn load(input: CachedInput) -> eyre::Result<Config> {
let s = std::str::from_utf8(input.get_data()).wrap_err_with(|| input.path().to_string())?;
toml::from_str(s).wrap_err_with(|| input.path().to_string())
Self::load_str(s, input.path())
}
pub async fn resolve(&mut self) -> eyre::Result<()> {

56
src/db.rs Normal file
View file

@ -0,0 +1,56 @@
use tokio::sync::{mpsc, oneshot};
struct DbThreadInner {
op_sender: mpsc::Sender<Box<dyn FnOnce(&mut diesel::SqliteConnection) + Send>>,
join_handle: std::thread::JoinHandle<()>,
}
pub struct DbThread(Option<DbThreadInner>);
impl DbThread {
pub fn new(mut connection: diesel::SqliteConnection, buffer: usize) -> Self {
let (op_sender, mut op_receiver) =
mpsc::channel::<Box<dyn FnOnce(&mut diesel::SqliteConnection) + Send>>(buffer);
let join_handle = std::thread::spawn(move || loop {
let Some(f) = op_receiver.blocking_recv() else {
break;
};
f(&mut connection);
});
Self(Some(DbThreadInner {
op_sender,
join_handle,
}))
}
pub async fn run_on<
F: FnOnce(&mut diesel::SqliteConnection) -> T + Send + 'static,
T: Send + 'static,
>(
&self,
f: F,
) -> T {
let (result_sender, result_receiver) = oneshot::channel();
self.0
.as_ref()
.unwrap()
.op_sender
.send(Box::new(move |connection| {
// if send failed, the future must have been canceled, so ignore send failures
let _ = result_sender.send(f(connection));
}))
.await
.expect("send to succeed");
result_receiver.await.expect("result to be written")
}
}
impl Drop for DbThread {
fn drop(&mut self) {
let DbThreadInner {
op_sender,
join_handle,
} = self.0.take().unwrap();
drop(op_sender);
join_handle.join().unwrap();
}
}

View file

@ -1,19 +1,30 @@
use crate::cli::{Listen, ListenFdSocket, ListenFdSockets};
use crate::{
cli::{Listen, ListenFdSocket, ListenFdSockets},
db::DbThread,
};
use actix_session::{storage::CookieSessionStore, SessionMiddleware};
use actix_web::{cookie::Key, web, App, HttpServer};
use clap::Parser;
use diesel::Connection;
use listenfd::ListenFd;
mod app;
mod cli;
mod client;
mod config;
mod db;
mod models;
mod schema;
pub async fn main() -> eyre::Result<()> {
env_logger::init();
let mut listenfd = ListenFd::from_env();
color_eyre::install()?;
let mut args = cli::CLI::parse();
let db = web::Data::new(DbThread::new(
diesel::SqliteConnection::establish(&args.config.sqlite_db)?,
args.config.db_thread_channel_capacity.get().into(),
));
args.resolve(&mut listenfd).await?;
log::info!("retrieved config from auth servers");
let config = web::Data::new(args.config);
@ -26,6 +37,7 @@ pub async fn main() -> eyre::Result<()> {
App::new()
.wrap(cookie_session)
.app_data(config.clone())
.app_data(db.clone())
.configure(app::all_services)
});
let Listen {

10
src/models.rs Normal file
View file

@ -0,0 +1,10 @@
use chrono::NaiveDateTime;
use diesel::prelude::*;
#[derive(Queryable, Selectable, Insertable)]
#[diesel(table_name = crate::schema::accounts)]
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
pub struct Account {
pub email: String,
pub date: NaiveDateTime,
}

8
src/schema.rs Normal file
View file

@ -0,0 +1,8 @@
// @generated automatically by Diesel CLI.
diesel::table! {
accounts (email) {
email -> Text,
date -> Timestamp,
}
}