Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,18 @@ JWT_SECRET= # generate with: openssl rand -hex 32
# SYNC_BLOB_RETENTION_DAYS=90


# Two-factor authentication (optional) ------------------------------------
# Encrypts the authenticator seeds of accounts that turn on two-factor
# sign-in. Unset, the server does not offer it and clients hide the option.
# Generate with: openssl rand -base64 32
#
# Back this key up with JWT_SECRET and never change it. Without it the
# server can no longer check anyone's codes: enrolled accounts are refused,
# not let through, until they use a recovery code or an admin clears their
# two-factor from the dashboard.
# TOTP_ENC_KEY=


# Account deletion (optional) ---------------------------------------------
# Days an admin soft-deleted account stays restorable. After that the daily
# purge hard-deletes it and its email can be registered again. An account
Expand Down
87 changes: 86 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ tokio-stream = { version = "0.1.18", features = ["sync"] }
hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
sha1 = "0.10"
chacha20poly1305 = "0.10"
data-encoding = "2"
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
dashmap = "6"
metrics = "0.24"
Expand Down
1 change: 1 addition & 0 deletions compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ services:
INVITE_RATE_LIMIT: ${INVITE_RATE_LIMIT:-20}
WAITLIST_RATE_LIMIT: ${WAITLIST_RATE_LIMIT:-10}
ADMIN_SECRET: ${ADMIN_SECRET}
TOTP_ENC_KEY: ${TOTP_ENC_KEY:-}
CORS_ORIGINS: ${CORS_ORIGINS:-https://voltius.app,https://www.voltius.app,https://app.voltius.app,tauri://localhost,https://tauri.localhost,http://localhost:1420,http://localhost:1421}
LEMONSQUEEZY_SIGNING_SECRET: ${LEMONSQUEEZY_SIGNING_SECRET}
LEMONSQUEEZY_STORE_ID: ${LEMONSQUEEZY_STORE_ID}
Expand Down
1 change: 1 addition & 0 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ services:
CORS_ORIGINS: ${CORS_ORIGINS:-}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-}
TRUSTED_PROXY_IP: ${TRUSTED_PROXY_IP:-}
TOTP_ENC_KEY: ${TOTP_ENC_KEY:-}
AUTH_RATE_LIMIT: ${AUTH_RATE_LIMIT:-}
SYNC_RATE_LIMIT: ${SYNC_RATE_LIMIT:-}
REGISTRATION_ENABLED: ${REGISTRATION_ENABLED:-}
Expand Down
5 changes: 4 additions & 1 deletion docs/runbooks/bootstrap-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ The server image itself comes from GHCR, so nothing is compiled on the host.

`.env.dockhand`, `.env.db`, `cloudflared/.env`, `cloudflared-api/.env`, `voltius-tofu/.env.tofu` and
`voltius-tofu/oci_api_key.pem` exist nowhere else. No database backup contains
them, and losing `JWT_SECRET` alone signs every user out permanently. `scripts/secrets-bundle.sh`
them, and losing `JWT_SECRET` alone signs every user out permanently. Losing `TOTP_ENC_KEY` locks
every account with two-factor sign-in out of its authenticator codes: the server refuses them
rather than skip the step, and each one then needs a recovery code or an admin clear.
`scripts/secrets-bundle.sh`
tars them, encrypts them to an age recipient, and uploads them to
`s3://<bucket>/voltius-prod/secrets/`, keeping `secrets-latest.tar.age` as the pointer.

Expand Down
13 changes: 13 additions & 0 deletions migrations/059_user_two_factor.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
ALTER TABLE users
ADD COLUMN totp_secret TEXT NULL,
ADD COLUMN totp_enabled_at TIMESTAMPTZ NULL,
ADD COLUMN totp_last_step BIGINT NULL,
ADD COLUMN totp_failed_attempts INTEGER NOT NULL DEFAULT 0,
ADD COLUMN totp_locked_until TIMESTAMPTZ NULL;

CREATE TABLE user_recovery_codes (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
used_at TIMESTAMPTZ NULL,
PRIMARY KEY (user_id, code_hash)
);
38 changes: 34 additions & 4 deletions src/auth/jwt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,21 @@ pub fn create_access_token(
)
}

pub fn create_refresh_token(
pub const PENDING_SECOND_FACTOR: &str = "2fa_pending";

// Refresh and pending tokens carry no tier: it is re-read from the database when they are used.
fn bare_token(
user_id: Uuid,
kind: &str,
lifetime: Duration,
token_version: i32,
) -> Result<String, jsonwebtoken::errors::Error> {
let now = Utc::now();
// Refresh tokens carry no tier — tier is re-read from DB on each refresh.
let claims = Claims {
sub: user_id,
iat: now.timestamp(),
exp: (now + Duration::days(90)).timestamp(),
kind: "refresh".to_string(),
exp: (now + lifetime).timestamp(),
kind: kind.to_string(),
tier: "free".to_string(),
trial_ends_at: None,
trial_used: false,
Expand All @@ -92,6 +96,20 @@ pub fn create_refresh_token(
)
}

pub fn create_refresh_token(
user_id: Uuid,
token_version: i32,
) -> Result<String, jsonwebtoken::errors::Error> {
bare_token(user_id, "refresh", Duration::days(90), token_version)
}

pub fn create_pending_token(
user_id: Uuid,
token_version: i32,
) -> Result<String, jsonwebtoken::errors::Error> {
bare_token(user_id, PENDING_SECOND_FACTOR, Duration::minutes(5), token_version)
}

pub fn validate_token(
token: &str,
expected_kind: &str,
Expand Down Expand Up @@ -175,6 +193,18 @@ pub(crate) mod tests {
assert_eq!(claims.sub, user_id);
}

#[test]
fn a_pending_token_passes_for_nothing_else() {
let _guard = crate::test_support::env_lock();
std::env::set_var("JWT_SECRET", "test-secret");

let token = create_pending_token(Uuid::new_v4(), 3).unwrap();

assert_eq!(validate_token(&token, PENDING_SECOND_FACTOR).unwrap().ver, 3);
assert!(validate_token(&token, "access").is_err());
assert!(validate_token(&token, "refresh").is_err());
}

#[test]
fn refresh_token_carries_the_token_version() {
let _guard = crate::test_support::env_lock();
Expand Down
16 changes: 16 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ mod team_plan;
mod vault_key_exposure;
mod sync_notifier;
mod terminal_manager;
mod two_factor;
mod user_purge;
#[cfg(test)]
mod test_support;
Expand Down Expand Up @@ -271,6 +272,8 @@ async fn main() {
handles_from_email = features.handles_from_email,
"Configured features"
);
let seed_cipher = two_factor::SeedCipher::from_env();
tracing::info!(available = seed_cipher.available(), "Configured two-factor authentication");

// Register — stricter limit: 5/day per IP on top of the general auth 10/min
let register_route = Router::new()
Expand All @@ -286,6 +289,7 @@ async fn main() {
let public = Router::new()
.route("/v1/auth/challenge", get(routes::auth::challenge))
.route("/v1/auth/login", post(routes::auth::login))
.route("/v1/auth/login/2fa", post(routes::auth::login_second_factor))
.route("/v1/auth/verify-email", post(routes::auth::verify_email))
.route(
"/v1/invitations/:token",
Expand Down Expand Up @@ -369,6 +373,13 @@ async fn main() {
.route("/v1/auth/email", put(routes::auth::update_email))
.route("/v1/auth/password", put(routes::auth::update_password))
.route("/v1/auth/logout-all", post(routes::auth::logout_all))
.route("/v1/auth/2fa/setup", post(routes::two_factor::setup))
.route("/v1/auth/2fa/enable", post(routes::two_factor::enable))
.route("/v1/auth/2fa/disable", post(routes::two_factor::disable))
.route(
"/v1/auth/2fa/recovery-codes",
post(routes::two_factor::regenerate_recovery_codes),
)
.route(
"/v1/auth/wrapped-user-secrets",
put(routes::auth::upload_wrapped_user_secrets),
Expand Down Expand Up @@ -726,6 +737,10 @@ async fn main() {
.route("/v1/admin/users/:id/handle", put(routes::admin::set_user_handle))
.route("/v1/admin/handles/derive", post(routes::admin::derive_handles))
.route("/v1/admin/users/:id/unban", post(routes::admin::unban_user))
.route(
"/v1/admin/users/:id/2fa/clear",
post(routes::admin::clear_two_factor),
)
.route(
"/v1/admin/users/:id/extend-trial",
post(routes::admin::extend_trial),
Expand Down Expand Up @@ -810,6 +825,7 @@ async fn main() {
.on_failure(DefaultOnFailure::new().level(Level::ERROR)),
)
.layer(middleware::from_fn(observability::track_requests))
.layer(Extension(seed_cipher))
.with_state(pool);

let port = std::env::var("PORT").unwrap_or_else(|_| "8080".to_string());
Expand Down
Loading
Loading