auth: use a global password for the webapp

This commit is contained in:
Armaël Guéneau 2025-02-07 16:59:23 +01:00
parent 6342620f70
commit 7bd511eaae
8 changed files with 51 additions and 46 deletions

7
Cargo.lock generated
View file

@ -874,6 +874,12 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "constant_time_eq"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.4.0" version = "0.4.0"
@ -1202,6 +1208,7 @@ dependencies = [
"aws-config", "aws-config",
"aws-sdk-s3", "aws-sdk-s3",
"axum", "axum",
"constant_time_eq",
"forgejo-api", "forgejo-api",
"include_dir", "include_dir",
"lazy_static", "lazy_static",

View file

@ -29,6 +29,7 @@ tower = "0.5.2"
tower-http = { version = "0.6.2", features = ["trace"] } tower-http = { version = "0.6.2", features = ["trace"] }
tracing-subscriber = "0.3" tracing-subscriber = "0.3"
tracing = "0.1.41" tracing = "0.1.41"
constant_time_eq = "0.3.1"
[profile.profiling] [profile.profiling]
inherits = "dev" inherits = "dev"

View file

@ -16,9 +16,11 @@
Forgery reads the following environment variables: Forgery reads the following environment variables:
- `FORGE_URL` (**mandatory**): url of the forgejo instance (e.g. - `FORGE_URL` (**mandatory**): url of the forgejo instance (e.g.
https://git.deuxfleurs.fr) https://git.deuxfleurs.fr)
- `FORGE_API_TOKEN` (**mandatory**): Forgejo API token *granting admin access*. - `FORGE_API_TOKEN` (**mandatory**): Forgejo API token *granting read/write
You can generate an API token using the Forgejo web interface in `Settings -> access* to admin rights, users, issues and repos. You can generate an API
Applications -> Generate New Token`. token using the Forgejo web interface in `Settings -> Applications -> Generate
New Token`.
- `PASSWORD` (**mandatory**): password required to authenticate to forgery.
- `ACTUALLY_BAN_USERS` (default: `false`): define it to `true` to actually lock - `ACTUALLY_BAN_USERS` (default: `false`): define it to `true` to actually lock
user accounts, send notification emails and eventually delete user accounts. user accounts, send notification emails and eventually delete user accounts.
Otherwise, no actual action is taken: spammers are only listed in the Otherwise, no actual action is taken: spammers are only listed in the
@ -57,7 +59,6 @@ Environment variables read when `STORAGE_BACKEND=s3`:
(Current behavior is to periodically retry, avoid deleting if the account (Current behavior is to periodically retry, avoid deleting if the account
could not be locked, but delete the account after the grace period even if could not be locked, but delete the account after the grace period even if
the email could not be sent…) the email could not be sent…)
- auth: add support for connecting to the forge using oauth?
- improve error handling? currently the app will panic if writing to the storage - improve error handling? currently the app will panic if writing to the storage
backend fails. Can we do better? backend fails. Can we do better?
- error reporting when the auth token is not working/outdated and background jobs fail - 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

@ -62,6 +62,7 @@ const GUESS_LEGIT_THRESHOLD: f32 = 0.3;
pub struct Config { pub struct Config {
pub forge_url: Url, pub forge_url: Url,
pub forge_api_token: String, pub forge_api_token: String,
pub password: String,
pub actually_ban: ActuallyBan, pub actually_ban: ActuallyBan,
pub bind_addr: String, pub bind_addr: String,
} }
@ -70,6 +71,7 @@ impl Config {
async fn from_env() -> anyhow::Result<Self> { async fn from_env() -> anyhow::Result<Self> {
let forge_url_s = env_var("FORGE_URL")?; let forge_url_s = env_var("FORGE_URL")?;
let forge_api_token = env_var("FORGE_API_TOKEN")?; let forge_api_token = env_var("FORGE_API_TOKEN")?;
let password = env_var("PASSWORD")?;
let actually_ban = match env_var("ACTUALLY_BAN_USERS").as_deref() { let actually_ban = match env_var("ACTUALLY_BAN_USERS").as_deref() {
Ok("true") => ActuallyBan::Yes { Ok("true") => ActuallyBan::Yes {
@ -91,6 +93,7 @@ impl Config {
Ok(Config { Ok(Config {
forge_url: Url::parse(&forge_url_s).context("parsing FORGE_URL")?, forge_url: Url::parse(&forge_url_s).context("parsing FORGE_URL")?,
forge_api_token, forge_api_token,
password,
actually_ban, actually_ban,
bind_addr, bind_addr,
}) })
@ -441,15 +444,19 @@ async fn get_auth(State(data): State<Arc<AppState>>) -> Result<Html<String>, App
#[derive(Deserialize)] #[derive(Deserialize)]
struct AuthForm { struct AuthForm {
token: String, password: String,
} }
async fn post_auth(session: Session, Form(form): Form<AuthForm>) -> impl IntoResponse { async fn post_auth(State(data): State<Arc<AppState>>, session: Session, Form(form): Form<AuthForm>) -> impl IntoResponse {
session let p1 = form.password.as_bytes();
.insert(session::TOKEN_KEY, &form.token) let p2 = data.config.password.as_bytes();
.await if p1.len() == p2.len() && constant_time_eq::constant_time_eq(p1, p2) {
.unwrap(); session::set_authenticated(&session).await;
(StatusCode::SEE_OTHER, [(header::LOCATION, "/")]) (StatusCode::SEE_OTHER, [(header::LOCATION, "/")])
} else {
session.flush().await.unwrap();
(StatusCode::SEE_OTHER, [(header::LOCATION, "/auth")])
}
} }
async fn post_logout(session: Session) -> impl IntoResponse { async fn post_logout(session: Session) -> impl IntoResponse {

View file

@ -185,6 +185,7 @@ pub async fn get_user_data(forge: &Forgejo) -> anyhow::Result<HashMap<UserId, Us
)); ));
} }
info!("done fetching user data from forgejo");
// discard users with an entirely empty profile: there is nothing useful we // discard users with an entirely empty profile: there is nothing useful we
// can say about them // can say about them
let data = data let data = data

View file

@ -2,13 +2,17 @@ use axum::extract::FromRequestParts;
use axum::http::{header, request::Parts, HeaderMap, HeaderValue, StatusCode}; use axum::http::{header, request::Parts, HeaderMap, HeaderValue, StatusCode};
use tower_sessions::Session; use tower_sessions::Session;
#[allow(dead_code)] // TODO: WIP pub struct AuthenticatedUser(());
pub struct AuthenticatedUser {
session: Session, const IS_AUTH_KEY: &str = "IS_AUTH";
api_token: String,
pub async fn set_authenticated(session: &Session) {
session.insert(IS_AUTH_KEY, true).await.unwrap()
} }
pub const TOKEN_KEY: &str = "API_TOKEN"; pub async fn is_authenticated(session: &Session) -> bool {
matches!(session.get(IS_AUTH_KEY).await.unwrap(), Some(true))
}
impl<S> FromRequestParts<S> for AuthenticatedUser impl<S> FromRequestParts<S> for AuthenticatedUser
where where
@ -22,16 +26,9 @@ where
Err((status, body)) => return Err((status, HeaderMap::new(), body.to_string())), 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? if is_authenticated(&session).await {
Ok(Self(()))
match api_token { } else {
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(); session.flush().await.unwrap();
Err(( Err((
StatusCode::SEE_OTHER, StatusCode::SEE_OTHER,
@ -43,4 +40,3 @@ where
} }
} }
} }
}

View file

@ -4,17 +4,9 @@
{% block content %} {% block content %}
<h1>Authentication required</h1> <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"> <form method="post">
<label for="token">Forgejo API token:</label> <label for="password">Password:</label>
<input id="token" name="token" type="password" required/> <input id="password" name="password" type="password" required/>
<input type="submit" value="Submit" class="button"/> <input type="submit" value="Submit" class="button"/>
</form> </form>