basic support for user authentication (currently does nothing)

This commit is contained in:
Armaël Guéneau 2025-02-01 19:42:25 +01:00
parent 812eee1a5f
commit 56064c6259
9 changed files with 279 additions and 8 deletions

138
Cargo.lock generated
View file

@ -241,6 +241,17 @@ version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04"
[[package]]
name = "async-trait"
version = "0.1.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "644dd749086bf3771a2fbc5f256fdb982d53f011c7d5d560304eafeecebce79d"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
@ -634,6 +645,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8"
dependencies = [
"axum-core",
"axum-macros",
"bytes",
"form_urlencoded",
"futures-util",
@ -681,6 +693,17 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum-macros"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "backtrace"
version = "0.3.74"
@ -869,6 +892,17 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@ -1191,6 +1225,7 @@ dependencies = [
"serde_json",
"tera",
"tokio",
"tower-sessions",
"unicode-segmentation",
"url",
]
@ -1204,6 +1239,20 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.31"
@ -1211,6 +1260,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@ -1219,6 +1269,23 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-io"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.31"
@ -1238,9 +1305,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-core",
"futures-macro",
"futures-sink",
"futures-task",
"pin-project-lite",
"pin-utils",
"slab",
]
[[package]]
@ -1910,6 +1980,7 @@ checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
dependencies = [
"autocfg",
"scopeguard",
"serde",
]
[[package]]
@ -3189,6 +3260,22 @@ dependencies = [
"tracing",
]
[[package]]
name = "tower-cookies"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36"
dependencies = [
"axum-core",
"cookie",
"futures-util",
"http 1.2.0",
"parking_lot",
"pin-project-lite",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
@ -3201,6 +3288,57 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tower-sessions"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a05911f23e8fae446005fe9b7b97e66d95b6db589dc1c4d59f6a2d4d4927d3"
dependencies = [
"async-trait",
"http 1.2.0",
"time",
"tokio",
"tower-cookies",
"tower-layer",
"tower-service",
"tower-sessions-core",
"tower-sessions-memory-store",
"tracing",
]
[[package]]
name = "tower-sessions-core"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce8cce604865576b7751b7a6bc3058f754569a60d689328bb74c52b1d87e355b"
dependencies = [
"async-trait",
"axum-core",
"base64 0.22.1",
"futures",
"http 1.2.0",
"parking_lot",
"rand",
"serde",
"serde_json",
"thiserror 2.0.9",
"time",
"tokio",
"tracing",
]
[[package]]
name = "tower-sessions-memory-store"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb05909f2e1420135a831dd5df9f5596d69196d0a64c3499ca474c4bd3d33242"
dependencies = [
"async-trait",
"time",
"tokio",
"tower-sessions-core",
]
[[package]]
name = "tracing"
version = "0.1.41"

View file

@ -14,7 +14,7 @@ url = "2"
anyhow = "1"
serde_json = "1"
rand = "0.8"
axum = { version = "0.8", features = ["form"] }
axum = { version = "0.8", features = ["form", "macros"] }
tera = "1"
lazy_static = "1"
actix-files = "0.6"
@ -24,6 +24,7 @@ include_dir = "0.7"
aws-config = { version = "1.1.7", features = ["behavior-version-latest"] }
aws-sdk-s3 = "1.66.0"
new_mime_guess = "4"
tower-sessions = "0.14"
[profile.profiling]
inherits = "dev"

View file

@ -60,3 +60,4 @@ Environment variables read when `STORAGE_BACKEND=s3`:
- auth: add support for connecting to the forge using oauth?
- improve error handling? currently the app will panic if writing to the storage
backend fails. Can we do better?
- error reporting when the auth token is not working/outdated and background jobs fail

File diff suppressed because one or more lines are too long

View file

@ -3,7 +3,7 @@ use axum::{
extract::{OriginalUri, Path, Query, State},
http::{header, StatusCode},
response::{Html, IntoResponse},
routing::get,
routing::{get, post},
Form, Router,
};
use forgejo_api::{Auth, Forgejo};
@ -14,6 +14,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tera::Tera;
use tower_sessions::{MemoryStore, Session, SessionManagerLayer};
use url::Url;
mod classifier;
@ -21,6 +22,7 @@ mod data;
mod db;
mod email;
mod scrape;
mod session;
mod storage;
mod userdb;
mod util;
@ -29,6 +31,7 @@ mod workers;
use data::*;
use db::Db;
use email::SmtpConfig;
use session::AuthenticatedUser;
use storage::Storage;
use userdb::{IsSpam, UserDb};
use util::env_var;
@ -280,9 +283,11 @@ fn approx_score(score: f32) -> ApproxScore {
}
}
#[axum::debug_handler]
async fn get_index(
State(data): State<Arc<AppState>>,
Query(q): Query<SortSetting>,
_user: AuthenticatedUser,
OriginalUri(uri): OriginalUri,
) -> Result<Html<String>, AppError> {
eprintln!("GET {}", uri);
@ -347,8 +352,9 @@ async fn get_index(
async fn post_classified(
State(data): State<Arc<AppState>>,
Form(form): Form<HashMap<i64, String>>,
_user: AuthenticatedUser,
OriginalUri(uri): OriginalUri,
Form(form): Form<HashMap<i64, String>>,
overwrite: bool,
) -> Result<impl IntoResponse, AppError> {
eprintln!("POST {}", uri);
@ -381,22 +387,25 @@ async fn post_classified(
async fn post_classified_index(
data: State<Arc<AppState>>,
user: AuthenticatedUser,
uri: OriginalUri,
form: Form<HashMap<i64, String>>,
) -> impl IntoResponse {
post_classified(data, form, uri, false).await
post_classified(data, user, uri, form, false).await
}
async fn post_classified_edit(
data: State<Arc<AppState>>,
user: AuthenticatedUser,
uri: OriginalUri,
form: Form<HashMap<i64, String>>,
) -> impl IntoResponse {
post_classified(data, form, uri, true).await
post_classified(data, user, uri, form, true).await
}
async fn get_classified(
State(data): State<Arc<AppState>>,
_user: AuthenticatedUser,
OriginalUri(uri): OriginalUri,
) -> Result<Html<String>, AppError> {
eprintln!("GET {}", uri);
@ -442,6 +451,41 @@ async fn get_static_(
}
}
async fn get_auth(
State(data): State<Arc<AppState>>,
OriginalUri(uri): OriginalUri,
) -> Result<Html<String>, AppError> {
eprintln!("GET {}", uri);
let mut context = tera::Context::new();
context.insert("forge_url", &data.config.forge_url.to_string());
let page = TEMPLATES.render("auth.html", &context)?;
Ok(Html::from(page))
}
#[derive(Deserialize)]
struct AuthForm {
token: String,
}
async fn post_auth(
OriginalUri(uri): OriginalUri,
session: Session,
Form(form): Form<AuthForm>,
) -> impl IntoResponse {
eprintln!("POST {}", uri);
session
.insert(session::TOKEN_KEY, &form.token)
.await
.unwrap();
(StatusCode::SEE_OTHER, [(header::LOCATION, "/")])
}
async fn post_logout(OriginalUri(uri): OriginalUri, session: Session) -> impl IntoResponse {
eprintln!("POST {}", uri);
session.flush().await.unwrap();
(StatusCode::SEE_OTHER, [(header::LOCATION, "/auth")])
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let config = Arc::new(Config::from_env().await?);
@ -491,6 +535,8 @@ async fn main() -> anyhow::Result<()> {
println!("Listening on http://{}", &config.bind_addr);
let session_store = MemoryStore::default();
let session_layer = SessionManagerLayer::new(session_store);
let app = Router::new()
.route("/", get(get_index).post(post_classified_index))
.route(
@ -498,7 +544,10 @@ async fn main() -> anyhow::Result<()> {
get(get_classified).post(post_classified_edit),
)
.route("/static/{*filename}", get(get_static_))
.with_state(shared_state);
.route("/auth", get(get_auth).post(post_auth))
.route("/logout", post(post_logout))
.with_state(shared_state)
.layer(session_layer);
let webserver = {
let listener = tokio::net::TcpListener::bind(&config.bind_addr)

46
src/session.rs Normal file
View file

@ -0,0 +1,46 @@
use axum::extract::FromRequestParts;
use axum::http::{header, request::Parts, HeaderMap, HeaderValue, StatusCode};
use tower_sessions::Session;
#[allow(dead_code)] // TODO: WIP
pub struct AuthenticatedUser {
session: Session,
api_token: String,
}
pub const TOKEN_KEY: &str = "API_TOKEN";
impl<S> FromRequestParts<S> for AuthenticatedUser
where
S: Send + Sync,
{
type Rejection = (StatusCode, HeaderMap, String);
async fn from_request_parts(req: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let session = match Session::from_request_parts(req, state).await {
Ok(session) => session,
Err((status, body)) => return Err((status, HeaderMap::new(), body.to_string())),
};
let api_token: Option<String> = session.get(TOKEN_KEY).await.unwrap(); // TODO: is it safe to [unwrap] here?
match api_token {
Some(api_token) => Ok(Self { session, api_token }),
None => {
// Unclear that this case can actually happen, but in doubt,
// clear the session and force to re-auth.
// It is safe to [unwrap] the result here because we use
// [MemoryStore] as the underlying store, on which [delete]
// never fails.
session.flush().await.unwrap();
Err((
StatusCode::SEE_OTHER,
HeaderMap::from_iter(
[(header::LOCATION, HeaderValue::from_static("/auth"))].into_iter(),
),
"client is not authenticated, redirecting to /auth".to_string(),
))
}
}
}
}

View file

@ -83,3 +83,14 @@ input.radio-legit:checked + label {
.score-Low {
background: #5fd770;
}
.aslink {
display: inline;
padding: 0;
border: 0;
font: inherit;
text-decoration: underline;
cursor: pointer;
background: transparent;
color: currentColor;
}

22
templates/auth.html Normal file
View file

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Authentication{% endblock title %}
{% block content %}
<h1>Authentication required</h1>
<p>
To obtain a suitable API token, you must login to Forgejo using an
administrator account and go to <a href="{{forge_url}}user/settings/applications">{{forge_url}}user/settings/applications</a>.
Then, create an API token with access to all organizations and repos, allowing
read and writet access to: "admin", "misc", "organization", "issue", "repository" and "user". Store
the resulting token safely (e.g. in your password manager).
</p>
<form method="post">
<label for="token">Forgejo API token:</label>
<input id="token" name="token" type="password" required/>
<input type="submit" value="Submit" class="button"/>
</form>
{% endblock content %}

View file

@ -8,7 +8,10 @@
</div>
<div>
<a href="/classified">Edit classified users</a>
<a href="/classified">Edit classified users</a> |
<form action="/logout" method="post" style="display: inline">
<button name="logout" value="logout" class="aslink">Log out</button>
</form>
</div>
<div class="sort-options">