commit
cf25e28c28
156 files changed
+38669
No files matched your search
+1009
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,616 @@
|
||||
//! Laravel's shared per-IP API limiter with first-request-relative expiry.
|
||||
use crate::{
|
||||
AppState,
|
||||
proxy_auth::{self, ProxyConfig},
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{ConnectInfo, Request, State},
|
||||
http::{HeaderMap, Method, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use std::{
|
||||
net::{IpAddr, SocketAddr},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
pub async fn init(db: &SqlitePool) -> Result<(), sqlx::Error> {
|
||||
sqlx::raw_sql("CREATE TABLE IF NOT EXISTS rust_api_limits(key TEXT PRIMARY KEY,attempts INTEGER NOT NULL,expires_at INTEGER NOT NULL); CREATE INDEX IF NOT EXISTS rust_api_limits_expiry ON rust_api_limits(expires_at);").execute(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct Limits {
|
||||
normal: i64,
|
||||
import: i64,
|
||||
proxy: ProxyConfig,
|
||||
}
|
||||
impl Limits {
|
||||
fn from_env() -> Self {
|
||||
Self {
|
||||
normal: std::env::var("THROTTLE_API")
|
||||
.map(|v| php_int(&v))
|
||||
.unwrap_or(60),
|
||||
import: std::env::var("THROTTLE_API_DURING_IMPORT")
|
||||
.map(|v| php_int(&v))
|
||||
.unwrap_or(1000),
|
||||
proxy: ProxyConfig::from_env(),
|
||||
}
|
||||
}
|
||||
fn for_request(&self, request: &Request) -> Option<i64> {
|
||||
if self.normal == 0 {
|
||||
return None;
|
||||
}
|
||||
let import = request.method() == Method::POST
|
||||
&& matches!(
|
||||
request.uri().path(),
|
||||
"/twofaccounts" | "/api/v1/twofaccounts"
|
||||
)
|
||||
&& request
|
||||
.headers()
|
||||
.get("referer")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|s| s.ends_with("account/import"));
|
||||
let maximum = if import {
|
||||
if self.import == 0 {
|
||||
return None;
|
||||
}
|
||||
self.normal.saturating_add(self.import)
|
||||
} else {
|
||||
self.normal
|
||||
};
|
||||
(maximum > 0).then_some(maximum)
|
||||
}
|
||||
}
|
||||
fn php_int(value: &str) -> i64 {
|
||||
let value = value.trim_start();
|
||||
let (negative, value) = if let Some(s) = value.strip_prefix('-') {
|
||||
(true, s)
|
||||
} else {
|
||||
(false, value.strip_prefix('+').unwrap_or(value))
|
||||
};
|
||||
let mut result = 0i64;
|
||||
for byte in value.bytes().take_while(u8::is_ascii_digit) {
|
||||
result = result
|
||||
.saturating_mul(10)
|
||||
.saturating_add(i64::from(byte - b'0'));
|
||||
}
|
||||
if negative { -result } else { result }
|
||||
}
|
||||
fn normalize(ip: IpAddr) -> IpAddr {
|
||||
match ip {
|
||||
IpAddr::V6(ip) => ip
|
||||
.to_ipv4_mapped()
|
||||
.map(IpAddr::V4)
|
||||
.unwrap_or(IpAddr::V6(ip)),
|
||||
ip => ip,
|
||||
}
|
||||
}
|
||||
fn client_ip(headers: &HeaderMap, peer: Option<SocketAddr>, config: &ProxyConfig) -> String {
|
||||
let direct = peer
|
||||
.map(|p| normalize(p.ip()).to_string())
|
||||
.unwrap_or_else(|| "unknown-peer".into());
|
||||
if !proxy_auth::trusted_peer(config, peer) {
|
||||
return direct;
|
||||
}
|
||||
let mut values = headers.get_all("x-forwarded-for").iter();
|
||||
let Some(value) = values.next() else {
|
||||
return direct;
|
||||
};
|
||||
if values.next().is_some() {
|
||||
return direct;
|
||||
}
|
||||
let Ok(value) = value.to_str() else {
|
||||
return direct;
|
||||
};
|
||||
// Reject the entire header if any component is invalid or the chain is excessive.
|
||||
let chain: Option<Vec<IpAddr>> = value
|
||||
.split(',')
|
||||
.map(|s| s.trim().parse::<IpAddr>().ok().map(normalize))
|
||||
.collect();
|
||||
let Some(chain) = chain.filter(|ips| !ips.is_empty() && ips.len() <= 64) else {
|
||||
return direct;
|
||||
};
|
||||
for ip in chain.iter().rev() {
|
||||
if !proxy_auth::trusted_peer(config, Some(SocketAddr::new(*ip, 0))) {
|
||||
return ip.to_string();
|
||||
}
|
||||
}
|
||||
chain[0].to_string()
|
||||
}
|
||||
fn now() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
.min(i64::MAX as u64) as i64
|
||||
}
|
||||
pub async fn handle(State(state): State<AppState>, request: Request, next: Next) -> Response {
|
||||
apply(state, request, next, &Limits::from_env(), now()).await
|
||||
}
|
||||
/// Passport's unnamed `throttle` middleware has its own fixed 60/minute
|
||||
/// bucket, independent of the application's named API/import quotas.
|
||||
pub async fn handle_oauth(State(state): State<AppState>, request: Request, next: Next) -> Response {
|
||||
let limits = Limits {
|
||||
normal: 60,
|
||||
import: 0,
|
||||
proxy: ProxyConfig::from_env(),
|
||||
};
|
||||
let mut response = apply_named(state, request, next, &limits, now(), "oauth:").await;
|
||||
response
|
||||
.headers_mut()
|
||||
.entry(axum::http::header::CACHE_CONTROL)
|
||||
.or_insert(axum::http::HeaderValue::from_static("no-cache, private"));
|
||||
response
|
||||
}
|
||||
async fn apply(
|
||||
state: AppState,
|
||||
request: Request,
|
||||
next: Next,
|
||||
limits: &Limits,
|
||||
timestamp: i64,
|
||||
) -> Response {
|
||||
apply_named(state, request, next, limits, timestamp, "").await
|
||||
}
|
||||
async fn apply_named(
|
||||
state: AppState,
|
||||
request: Request,
|
||||
next: Next,
|
||||
limits: &Limits,
|
||||
timestamp: i64,
|
||||
namespace: &str,
|
||||
) -> Response {
|
||||
let Some(maximum) = limits.for_request(&request) else {
|
||||
return next.run(request).await;
|
||||
};
|
||||
let peer = request
|
||||
.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|p| p.0);
|
||||
let ip = client_ip(request.headers(), peer, &limits.proxy);
|
||||
let key = format!("{namespace}{:x}", Sha256::digest(ip.as_bytes()));
|
||||
let outcome = hit(&state.db, &key, maximum, timestamp).await;
|
||||
let (accepted, attempts, expires) = match outcome {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
tracing::error!(kind=?std::mem::discriminant(&error),"API throttle storage failed");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"message":"Server Error"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let mut response = if accepted {
|
||||
next.run(request).await
|
||||
} else {
|
||||
(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({"message":"Too Many Attempts."})),
|
||||
)
|
||||
.into_response()
|
||||
};
|
||||
let remaining = if accepted {
|
||||
maximum.saturating_sub(attempts).max(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
// Preserve a stricter downstream limiter's headers, as Laravel does.
|
||||
if response
|
||||
.headers()
|
||||
.get("x-ratelimit-remaining")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.is_none_or(|n| n > remaining)
|
||||
{
|
||||
response.headers_mut().insert(
|
||||
"x-ratelimit-limit",
|
||||
maximum.to_string().parse().expect("integer header"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"x-ratelimit-remaining",
|
||||
remaining.to_string().parse().expect("integer header"),
|
||||
);
|
||||
if !accepted {
|
||||
response.headers_mut().insert(
|
||||
"retry-after",
|
||||
expires
|
||||
.saturating_sub(timestamp)
|
||||
.max(0)
|
||||
.to_string()
|
||||
.parse()
|
||||
.expect("integer header"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"x-ratelimit-reset",
|
||||
expires.to_string().parse().expect("integer header"),
|
||||
);
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
async fn hit(
|
||||
db: &SqlitePool,
|
||||
key: &str,
|
||||
maximum: i64,
|
||||
timestamp: i64,
|
||||
) -> Result<(bool, i64, i64), sqlx::Error> {
|
||||
let mut tx = db.begin().await?;
|
||||
// Cache expiry also bounds the sidecar: old peers disappear on the next limited request.
|
||||
sqlx::query("DELETE FROM rust_api_limits WHERE expires_at<=? AND key!=?")
|
||||
.bind(timestamp)
|
||||
.bind(key)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let row=sqlx::query("INSERT INTO rust_api_limits(key,attempts,expires_at) VALUES(?,1,?) ON CONFLICT(key) DO UPDATE SET attempts=CASE WHEN expires_at<=? THEN 1 ELSE attempts+1 END,expires_at=CASE WHEN expires_at<=? THEN ? ELSE expires_at END WHERE expires_at<=? OR attempts<? RETURNING attempts,expires_at")
|
||||
.bind(key).bind(timestamp.saturating_add(60)).bind(timestamp).bind(timestamp).bind(timestamp.saturating_add(60)).bind(timestamp).bind(maximum).fetch_optional(&mut *tx).await?;
|
||||
let result = if let Some(row) = row {
|
||||
(true, row.try_get("attempts")?, row.try_get("expires_at")?)
|
||||
} else {
|
||||
let row = sqlx::query("SELECT attempts,expires_at FROM rust_api_limits WHERE key=?")
|
||||
.bind(key)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
(false, row.try_get("attempts")?, row.try_get("expires_at")?)
|
||||
};
|
||||
tx.commit().await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{
|
||||
Router,
|
||||
body::{Body, to_bytes},
|
||||
routing::{get, post},
|
||||
};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicI64, Ordering},
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
fn proxy(trusted: &str) -> ProxyConfig {
|
||||
ProxyConfig {
|
||||
enabled: false,
|
||||
trusted_proxies: trusted.into(),
|
||||
user_header: "REMOTE_USER".into(),
|
||||
email_header: None,
|
||||
}
|
||||
}
|
||||
async fn fixture(
|
||||
normal: i64,
|
||||
import: i64,
|
||||
trusted: &str,
|
||||
) -> (SqlitePool, Router, Arc<AtomicI64>) {
|
||||
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
init(&db).await.unwrap();
|
||||
let state = AppState {
|
||||
db: db.clone(),
|
||||
cipher: None,
|
||||
encrypt_db: std::sync::atomic::AtomicBool::new(false).into(),
|
||||
};
|
||||
let limits = Limits {
|
||||
normal,
|
||||
import,
|
||||
proxy: proxy(trusted),
|
||||
};
|
||||
let clock = Arc::new(AtomicI64::new(59));
|
||||
let time = clock.clone();
|
||||
let app = Router::new()
|
||||
.route("/one", get(|| async { "ok" }))
|
||||
.route("/twofaccounts", post(|| async { "created" }))
|
||||
.route_layer(axum::middleware::from_fn(
|
||||
move |request: Request, next: Next| {
|
||||
let state = state.clone();
|
||||
let limits = limits.clone();
|
||||
let timestamp = time.load(Ordering::Relaxed);
|
||||
async move { apply(state, request, next, &limits, timestamp).await }
|
||||
},
|
||||
));
|
||||
(db, app, clock)
|
||||
}
|
||||
async fn request(
|
||||
app: &Router,
|
||||
path: &str,
|
||||
referer: Option<&str>,
|
||||
forwarded: Option<&str>,
|
||||
) -> Response {
|
||||
let mut builder = axum::http::Request::builder()
|
||||
.method(if path == "/twofaccounts" {
|
||||
"POST"
|
||||
} else {
|
||||
"GET"
|
||||
})
|
||||
.uri(path)
|
||||
.extension(ConnectInfo(
|
||||
"127.0.0.1:50000".parse::<SocketAddr>().unwrap(),
|
||||
));
|
||||
if let Some(referer) = referer {
|
||||
builder = builder.header("referer", referer);
|
||||
}
|
||||
if let Some(forwarded) = forwarded {
|
||||
builder = builder.header("x-forwarded-for", forwarded);
|
||||
}
|
||||
app.clone()
|
||||
.oneshot(builder.body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn passport_quota_is_fixed_and_separate_from_the_api_bucket() {
|
||||
let (db, api, clock) = fixture(1, 0, "").await;
|
||||
clock.store(now(), Ordering::Relaxed);
|
||||
let state = AppState {
|
||||
db,
|
||||
cipher: None,
|
||||
encrypt_db: std::sync::atomic::AtomicBool::new(false).into(),
|
||||
};
|
||||
let oauth = Router::new()
|
||||
.route("/one", get(|| async { "ok" }))
|
||||
.route_layer(axum::middleware::from_fn_with_state(state, handle_oauth));
|
||||
assert_eq!(
|
||||
request(&api, "/one", None, None).await.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
request(&api, "/one", None, None).await.status(),
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
);
|
||||
for index in 0..60 {
|
||||
let response = request(&oauth, "/one", None, None).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()["x-ratelimit-limit"], "60");
|
||||
assert_eq!(
|
||||
response.headers()["x-ratelimit-remaining"],
|
||||
(59 - index).to_string()
|
||||
);
|
||||
}
|
||||
let rejected = request(&oauth, "/one", None, None).await;
|
||||
assert_eq!(rejected.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
assert_eq!(rejected.headers()["x-ratelimit-remaining"], "0");
|
||||
assert!(rejected.headers().contains_key("retry-after"));
|
||||
assert_eq!(
|
||||
request(&api, "/one", None, None).await.status(),
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn first_request_starts_the_window_and_rejections_do_not_extend_it() {
|
||||
let (db, app, clock) = fixture(2, 3, "").await;
|
||||
let first = request(&app, "/one", None, None).await;
|
||||
assert_eq!(first.status(), StatusCode::OK);
|
||||
assert_eq!(first.headers()["x-ratelimit-limit"], "2");
|
||||
assert_eq!(first.headers()["x-ratelimit-remaining"], "1");
|
||||
assert!(!first.headers().contains_key("x-ratelimit-reset"));
|
||||
clock.store(60, Ordering::Relaxed);
|
||||
assert_eq!(
|
||||
request(&app, "/one", None, None).await.headers()["x-ratelimit-remaining"],
|
||||
"0"
|
||||
);
|
||||
clock.store(118, Ordering::Relaxed);
|
||||
let blocked = request(&app, "/one", None, None).await;
|
||||
assert_eq!(blocked.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
assert_eq!(blocked.headers()["retry-after"], "1");
|
||||
assert_eq!(blocked.headers()["x-ratelimit-reset"], "119");
|
||||
assert_eq!(blocked.headers()["x-ratelimit-remaining"], "0");
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(&to_bytes(blocked.into_body(), 1024).await.unwrap()).unwrap();
|
||||
assert_eq!(body, json!({"message":"Too Many Attempts."}));
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("SELECT attempts FROM rust_api_limits")
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
clock.store(119, Ordering::Relaxed);
|
||||
let reset = request(&app, "/one", None, None).await;
|
||||
assert_eq!(reset.status(), StatusCode::OK);
|
||||
assert_eq!(reset.headers()["x-ratelimit-remaining"], "1");
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("SELECT expires_at FROM rust_api_limits")
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap(),
|
||||
179
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn import_extends_the_same_ip_counter_and_only_for_exact_route_and_referer() {
|
||||
let (_, app, _) = fixture(2, 3, "").await;
|
||||
assert_eq!(
|
||||
request(&app, "/one", None, None).await.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
request(&app, "/one", None, None).await.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
request(
|
||||
&app,
|
||||
"/twofaccounts",
|
||||
Some("https://example.com/account/import?x=1"),
|
||||
None
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
);
|
||||
assert_eq!(
|
||||
request(
|
||||
&app,
|
||||
"/one",
|
||||
Some("https://example.com/account/import"),
|
||||
None
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
);
|
||||
for remaining in [2, 1, 0] {
|
||||
let response = request(
|
||||
&app,
|
||||
"/twofaccounts",
|
||||
Some("https://example.com/account/import"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()["x-ratelimit-limit"], "5");
|
||||
assert_eq!(
|
||||
response.headers()["x-ratelimit-remaining"],
|
||||
remaining.to_string()
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
request(&app, "/one", None, None).await.status(),
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
);
|
||||
assert_eq!(
|
||||
request(
|
||||
&app,
|
||||
"/twofaccounts",
|
||||
Some("https://example.com/account/import"),
|
||||
None
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn unlimited_limits_do_not_consume_a_counter() {
|
||||
let (db, app, _) = fixture(0, 3, "").await;
|
||||
for _ in 0..5 {
|
||||
let response = request(&app, "/one", None, None).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(!response.headers().contains_key("x-ratelimit-limit"));
|
||||
}
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM rust_api_limits")
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
let (db, app, _) = fixture(1, 0, "").await;
|
||||
for _ in 0..5 {
|
||||
let response = request(
|
||||
&app,
|
||||
"/twofaccounts",
|
||||
Some("https://example.com/account/import"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(!response.headers().contains_key("x-ratelimit-limit"));
|
||||
}
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM rust_api_limits")
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
request(&app, "/one", None, None).await.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
request(&app, "/one", None, None).await.status(),
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn spoofed_forwarding_cannot_reset_a_direct_peers_quota() {
|
||||
let (_, app, _) = fixture(1, 0, "").await;
|
||||
assert_eq!(
|
||||
request(&app, "/one", None, Some("198.51.100.1"))
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
request(&app, "/one", None, Some("198.51.100.2"))
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
);
|
||||
let (_, app, _) = fixture(1, 0, "127.0.0.1").await;
|
||||
assert_eq!(
|
||||
request(&app, "/one", None, Some("198.51.100.1"))
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
request(&app, "/one", None, Some("198.51.100.2"))
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
// The untrusted proxy nearest the server determines the client, not attacker-supplied left entries.
|
||||
assert_eq!(
|
||||
request(&app, "/one", None, Some("203.0.113.99, 198.51.100.2"))
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-forwarded-for",
|
||||
"malformed, 198.51.100.3".parse().unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
client_ip(
|
||||
&headers,
|
||||
Some("127.0.0.1:1".parse().unwrap()),
|
||||
&proxy("127.0.0.1")
|
||||
),
|
||||
"127.0.0.1"
|
||||
);
|
||||
headers.insert("x-forwarded-for", "198.51.100.1".parse().unwrap());
|
||||
headers.append("x-forwarded-for", "198.51.100.2".parse().unwrap());
|
||||
assert_eq!(
|
||||
client_ip(
|
||||
&headers,
|
||||
Some("127.0.0.1:1".parse().unwrap()),
|
||||
&proxy("127.0.0.1")
|
||||
),
|
||||
"127.0.0.1"
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn parallel_requests_cannot_exceed_the_limit() {
|
||||
let (_, app, _) = fixture(3, 0, "").await;
|
||||
let mut tasks = Vec::new();
|
||||
for _ in 0..12 {
|
||||
let app = app.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
request(&app, "/one", None, None).await.status()
|
||||
}));
|
||||
}
|
||||
let mut accepted = 0;
|
||||
for task in tasks {
|
||||
if task.await.unwrap() == StatusCode::OK {
|
||||
accepted += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(accepted, 3);
|
||||
}
|
||||
#[test]
|
||||
fn php_integer_configuration_rules() {
|
||||
assert_eq!(php_int(" +60requests"), 60);
|
||||
assert_eq!(php_int("disabled"), 0);
|
||||
assert_eq!(php_int("-1"), -1);
|
||||
}
|
||||
}
|
||||
+1574
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,214 @@
|
||||
//! Authentication event persistence and preference-controlled notifications.
|
||||
use crate::{AppState, mail, settings, users};
|
||||
use axum::{
|
||||
extract::{ConnectInfo, Request},
|
||||
http::{HeaderMap, request::Parts},
|
||||
};
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use serde_json::Value;
|
||||
use sqlx::Row;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Context {
|
||||
pub ip: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub locale: String,
|
||||
pub guard: String,
|
||||
}
|
||||
impl Context {
|
||||
pub fn from_headers(headers: &HeaderMap, peer: Option<SocketAddr>) -> Self {
|
||||
let locale = headers
|
||||
.get("accept-language")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|h| h.split(',').next())
|
||||
.and_then(|h| h.split(';').next())
|
||||
.unwrap_or("en")
|
||||
.trim()
|
||||
.to_owned();
|
||||
Self {
|
||||
ip: peer.map(|p| p.ip().to_string()),
|
||||
user_agent: headers
|
||||
.get("user-agent")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(str::to_owned),
|
||||
locale,
|
||||
guard: "web-guard".into(),
|
||||
}
|
||||
}
|
||||
pub fn from_parts(parts: &Parts) -> Self {
|
||||
Self::from_headers(
|
||||
&parts.headers,
|
||||
parts
|
||||
.extensions
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|p| p.0),
|
||||
)
|
||||
}
|
||||
pub fn from_request(request: &Request) -> Self {
|
||||
Self::from_headers(
|
||||
request.headers(),
|
||||
request
|
||||
.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|p| p.0),
|
||||
)
|
||||
}
|
||||
}
|
||||
fn notification_enabled(success: bool, known: bool, new_user: bool, prefs: &Value) -> bool {
|
||||
if success {
|
||||
!known && !new_user && settings::php_bool(&prefs["notifyOnNewAuthDevice"])
|
||||
} else {
|
||||
settings::php_bool(&prefs["notifyOnFailedLogin"])
|
||||
}
|
||||
}
|
||||
fn parse_date(raw: &str) -> Option<DateTime<Utc>> {
|
||||
DateTime::parse_from_rfc3339(raw)
|
||||
.ok()
|
||||
.map(|d| d.with_timezone(&Utc))
|
||||
.or_else(|| {
|
||||
NaiveDateTime::parse_from_str(raw, "%Y-%m-%d %H:%M:%S%.f")
|
||||
.ok()
|
||||
.map(|d| d.and_utc())
|
||||
})
|
||||
}
|
||||
async fn access(
|
||||
s: &AppState,
|
||||
id: i64,
|
||||
ctx: &Context,
|
||||
method: Option<&str>,
|
||||
successful: bool,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut tx = s.db.begin().await?;
|
||||
// Serialize the known-device check with insertion so concurrent logins send one alert.
|
||||
sqlx::query("UPDATE users SET last_seen_at=last_seen_at WHERE id=?")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let user = sqlx::query("SELECT name,email,preferences,created_at FROM users WHERE id=?")
|
||||
.bind(id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
let known:bool=sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM auth_logs WHERE authenticatable_id=? AND authenticatable_type='App\\Models\\User' AND ip_address IS ? AND user_agent IS ? AND guard=? AND login_successful=1)").bind(id).bind(&ctx.ip).bind(&ctx.user_agent).bind(&ctx.guard).fetch_one(&mut *tx).await?;
|
||||
let raw: Option<String> = user.try_get("preferences")?;
|
||||
let prefs = settings::preferences_from(raw.as_deref());
|
||||
let created: Option<String> = user.try_get("created_at")?;
|
||||
let new_user = created
|
||||
.as_deref()
|
||||
.and_then(parse_date)
|
||||
.is_none_or(|d| (Utc::now() - d).num_seconds().unsigned_abs() < 60);
|
||||
sqlx::query("INSERT INTO auth_logs(authenticatable_type,authenticatable_id,ip_address,user_agent,login_at,login_successful,guard,login_method) VALUES('App\\Models\\User',?,?,?,CURRENT_TIMESTAMP,?,?,?)").bind(id).bind(&ctx.ip).bind(&ctx.user_agent).bind(successful).bind(&ctx.guard).bind(method).execute(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
if notification_enabled(successful, known, new_user, &prefs) {
|
||||
let locale = prefs["lang"]
|
||||
.as_str()
|
||||
.filter(|s| *s != "browser")
|
||||
.unwrap_or(&ctx.locale);
|
||||
let kind = if successful {
|
||||
"new_device"
|
||||
} else {
|
||||
"failed_login"
|
||||
};
|
||||
let tr = |suffix: &str| {
|
||||
mail::translate(
|
||||
locale,
|
||||
&format!("message.notifications.{kind}.{suffix}"),
|
||||
&[],
|
||||
)
|
||||
};
|
||||
let name: String = user.try_get("name")?;
|
||||
let email: String = user.try_get("email")?;
|
||||
let hello = mail::translate(
|
||||
locale,
|
||||
"message.notifications.hello_user",
|
||||
&[("username", &name)],
|
||||
);
|
||||
let (browser, platform, _) = users::agent(ctx.user_agent.as_deref().unwrap_or(""));
|
||||
let device = mail::translate(
|
||||
locale,
|
||||
"message.browser_on_platform",
|
||||
&[
|
||||
("browser", browser.as_str().unwrap_or("")),
|
||||
("platform", platform.as_str().unwrap_or("")),
|
||||
],
|
||||
);
|
||||
let now = Utc::now().format("%A, %d-%b-%Y %H:%M:%S UTC").to_string();
|
||||
let body = format!(
|
||||
"{hello}\n\n{}\n{}:\n\n{}: {now}\n{}: {}\n{}: {device}\n\n{}\n\n{},\n{}",
|
||||
tr("resume"),
|
||||
tr("connection_details"),
|
||||
mail::translate(locale, "label.time", &[]),
|
||||
mail::translate(locale, "label.ip_address", &[]),
|
||||
ctx.ip.as_deref().unwrap_or(""),
|
||||
mail::translate(locale, "label.device", &[]),
|
||||
tr("recommandations"),
|
||||
mail::translate(locale, "message.notifications.regards", &[]),
|
||||
std::env::var("APP_NAME").unwrap_or_else(|_| "2FAuth".into())
|
||||
);
|
||||
if mail::notify(&email, &tr("subject"), &body).await.is_err() {
|
||||
tracing::warn!("Authentication notification delivery failed");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub async fn login(
|
||||
s: &AppState,
|
||||
id: i64,
|
||||
ctx: &Context,
|
||||
method: Option<&str>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
access(s, id, ctx, method, true).await
|
||||
}
|
||||
pub async fn failed(
|
||||
s: &AppState,
|
||||
id: i64,
|
||||
ctx: &Context,
|
||||
method: Option<&str>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
access(s, id, ctx, method, false).await
|
||||
}
|
||||
pub async fn logout(
|
||||
s: &AppState,
|
||||
id: i64,
|
||||
ctx: &Context,
|
||||
method: Option<&str>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
let mut tx = s.db.begin().await?;
|
||||
let row:Option<i64>=sqlx::query_scalar("SELECT id FROM auth_logs WHERE authenticatable_id=? AND authenticatable_type='App\\Models\\User' AND ip_address IS ? AND user_agent IS ? AND guard=? ORDER BY login_at DESC,id DESC LIMIT 1").bind(id).bind(&ctx.ip).bind(&ctx.user_agent).bind(&ctx.guard).fetch_optional(&mut *tx).await?;
|
||||
if let Some(log_id) = row {
|
||||
sqlx::query("UPDATE auth_logs SET logout_at=CURRENT_TIMESTAMP WHERE id=?")
|
||||
.bind(log_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
} else {
|
||||
sqlx::query("INSERT INTO auth_logs(authenticatable_type,authenticatable_id,ip_address,user_agent,logout_at,guard,login_method) VALUES('App\\Models\\User',?,?,?,CURRENT_TIMESTAMP,?,?)").bind(id).bind(&ctx.ip).bind(&ctx.user_agent).bind(&ctx.guard).bind(method).execute(&mut *tx).await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn notification_gates_match_upstream() {
|
||||
let prefs = json!({"notifyOnNewAuthDevice":true,"notifyOnFailedLogin":true});
|
||||
assert!(notification_enabled(true, false, false, &prefs));
|
||||
assert!(!notification_enabled(true, true, false, &prefs));
|
||||
assert!(!notification_enabled(true, false, true, &prefs));
|
||||
assert!(notification_enabled(false, true, true, &prefs));
|
||||
assert!(!notification_enabled(false, false, false, &json!({})));
|
||||
assert!(!notification_enabled(true, false, false, &json!({})));
|
||||
}
|
||||
#[test]
|
||||
fn request_metadata_uses_only_direct_socket_peer() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", "198.51.100.1".parse().unwrap());
|
||||
headers.insert("user-agent", "Public fixture agent".parse().unwrap());
|
||||
let ctx = Context::from_headers(&headers, Some("127.0.0.1:1234".parse().unwrap()));
|
||||
assert_eq!(ctx.ip.as_deref(), Some("127.0.0.1"));
|
||||
assert_eq!(ctx.user_agent.as_deref(), Some("Public fixture agent"));
|
||||
assert!(Context::from_headers(&headers, None).ip.is_none());
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
//! The pinned application's default CORS policy: public API origins, no credentials.
|
||||
use axum::{
|
||||
extract::Request,
|
||||
http::{HeaderValue, Method, StatusCode, header},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
pub async fn handle(request: Request, next: Next) -> Response {
|
||||
let path = request.uri().path();
|
||||
let applies = path.starts_with("/api/") || path == "/sanctum/csrf-cookie";
|
||||
if !applies {
|
||||
return next.run(request).await;
|
||||
}
|
||||
let method = request
|
||||
.headers()
|
||||
.get(header::ACCESS_CONTROL_REQUEST_METHOD)
|
||||
.cloned();
|
||||
let requested = request
|
||||
.headers()
|
||||
.get(header::ACCESS_CONTROL_REQUEST_HEADERS)
|
||||
.cloned();
|
||||
let preflight = request.method() == Method::OPTIONS && method.is_some();
|
||||
let mut response = if preflight {
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
} else {
|
||||
next.run(request).await
|
||||
};
|
||||
response.headers_mut().insert(
|
||||
header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
HeaderValue::from_static("*"),
|
||||
);
|
||||
if preflight {
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/html; charset=UTF-8"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::ACCESS_CONTROL_ALLOW_METHODS,
|
||||
HeaderValue::from_bytes(&method.unwrap().as_bytes().to_ascii_uppercase())
|
||||
.expect("validated request header"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::ACCESS_CONTROL_ALLOW_HEADERS,
|
||||
requested.unwrap_or(HeaderValue::from_static("")),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::ACCESS_CONTROL_MAX_AGE,
|
||||
HeaderValue::from_static("0"),
|
||||
);
|
||||
response.headers_mut().append(
|
||||
header::VARY,
|
||||
HeaderValue::from_static(
|
||||
"Access-Control-Request-Method, Access-Control-Request-Headers",
|
||||
),
|
||||
);
|
||||
}
|
||||
response
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{Router, body::Body, http::Request, middleware, routing::get};
|
||||
use tower::ServiceExt;
|
||||
#[tokio::test]
|
||||
async fn preflight_bypasses_handlers_and_never_enables_credentials() {
|
||||
let app = Router::new()
|
||||
.route("/api/v1/probe", get(|| async { StatusCode::UNAUTHORIZED }))
|
||||
.route("/user", get(|| async { StatusCode::UNAUTHORIZED }))
|
||||
.layer(middleware::from_fn(handle));
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("OPTIONS")
|
||||
.uri("/api/v1/probe")
|
||||
.header("origin", "https://example.invalid")
|
||||
.header("access-control-request-method", "POST")
|
||||
.header(
|
||||
"access-control-request-headers",
|
||||
"Authorization,Content-Type",
|
||||
)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
assert_eq!(
|
||||
response.headers()["content-type"],
|
||||
"text/html; charset=UTF-8"
|
||||
);
|
||||
assert_eq!(response.headers()["access-control-allow-origin"], "*");
|
||||
assert_eq!(response.headers()["access-control-allow-methods"], "POST");
|
||||
assert!(
|
||||
!response
|
||||
.headers()
|
||||
.contains_key("access-control-allow-credentials")
|
||||
);
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/probe")
|
||||
.header("origin", "https://example.invalid")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(response.headers()["access-control-allow-origin"], "*");
|
||||
let without_origin = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/probe")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(without_origin.headers()["access-control-allow-origin"], "*");
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/user")
|
||||
.header("origin", "https://example.invalid")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!response
|
||||
.headers()
|
||||
.contains_key("access-control-allow-origin")
|
||||
);
|
||||
}
|
||||
}
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
//! Laravel encryptString/decryptString envelope compatibility for AES CBC.
|
||||
//! Also implements Laravel cookie-name authentication; no PHP object deserialization.
|
||||
use aes::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit, block_padding::Pkcs7};
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LaravelCipher {
|
||||
key: Zeroizing<Vec<u8>>,
|
||||
previous_keys: Vec<Zeroizing<Vec<u8>>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Envelope {
|
||||
iv: String,
|
||||
value: String,
|
||||
mac: String,
|
||||
#[serde(default)]
|
||||
tag: String,
|
||||
}
|
||||
|
||||
impl LaravelCipher {
|
||||
pub fn from_app_key(app_key: &str) -> Result<Self> {
|
||||
Self::from_keys(app_key, &[])
|
||||
}
|
||||
|
||||
/// Encrypt with the primary key; previous keys are used only after MAC verification.
|
||||
pub fn from_keys(app_key: &str, previous_keys: &[String]) -> Result<Self> {
|
||||
fn decode(app_key: &str) -> Result<Zeroizing<Vec<u8>>> {
|
||||
let key = Zeroizing::new(if let Some(encoded) = app_key.strip_prefix("base64:") {
|
||||
STANDARD
|
||||
.decode(encoded)
|
||||
.map_err(|_| anyhow!("APP_KEY contains invalid base64"))?
|
||||
} else {
|
||||
app_key.as_bytes().to_vec()
|
||||
});
|
||||
if !matches!(key.len(), 16 | 32) {
|
||||
bail!("APP_KEY must decode to 16 or 32 bytes for AES CBC");
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
let key = decode(app_key)?;
|
||||
let previous_keys = previous_keys
|
||||
.iter()
|
||||
.map(|key| decode(key))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
if previous_keys
|
||||
.iter()
|
||||
.any(|previous| previous.len() != key.len())
|
||||
{
|
||||
bail!("previous keys must match the primary cipher key length");
|
||||
}
|
||||
Ok(Self { key, previous_keys })
|
||||
}
|
||||
|
||||
pub fn encrypt(&self, plaintext: &str) -> Result<String> {
|
||||
let mut iv = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut iv);
|
||||
self.encrypt_with_iv(plaintext, &iv)
|
||||
}
|
||||
|
||||
/// Encrypt an un-serialized Laravel middleware cookie value. CookieValuePrefix
|
||||
/// authenticates the cookie name independently of the AES envelope MAC.
|
||||
pub fn encrypt_cookie(&self, name: &str, value: &str) -> Result<String> {
|
||||
let prefix = Self::cookie_prefix(name, &self.key)?;
|
||||
self.encrypt(&format!("{prefix}{value}"))
|
||||
}
|
||||
|
||||
/// Accept the percent-encoded wire value, validate its envelope and name
|
||||
/// prefix against current/previous keys, and return only the cookie value.
|
||||
pub fn decrypt_cookie(&self, name: &str, wire: &str) -> Result<String> {
|
||||
use subtle::ConstantTimeEq;
|
||||
if wire.len() > 128 * 1024 {
|
||||
bail!("encrypted cookie is too large");
|
||||
}
|
||||
let decoded = percent_encoding::percent_decode_str(wire)
|
||||
.decode_utf8()
|
||||
.map_err(|_| anyhow!("invalid encrypted cookie encoding"))?;
|
||||
let plain = self.decrypt(&decoded)?;
|
||||
let Some((prefix, value)) = plain.split_once('|') else {
|
||||
bail!("cookie name authentication failed");
|
||||
};
|
||||
if prefix.len() != 40 {
|
||||
bail!("cookie name authentication failed");
|
||||
}
|
||||
for key in std::iter::once(&self.key).chain(self.previous_keys.iter()) {
|
||||
let expected = Self::cookie_prefix(name, key)?;
|
||||
if bool::from(prefix.as_bytes().ct_eq(&expected.as_bytes()[..40])) {
|
||||
return Ok(value.to_owned());
|
||||
}
|
||||
}
|
||||
bail!("cookie name authentication failed")
|
||||
}
|
||||
fn cookie_prefix(name: &str, key: &[u8]) -> Result<String> {
|
||||
let mut mac = <Hmac<sha1::Sha1> as Mac>::new_from_slice(key)
|
||||
.map_err(|_| anyhow!("invalid cookie key"))?;
|
||||
mac.update(name.as_bytes());
|
||||
mac.update(b"v2");
|
||||
Ok(format!("{}|", hex::encode(mac.finalize().into_bytes())))
|
||||
}
|
||||
|
||||
fn encrypt_with_iv(&self, plaintext: &str, iv: &[u8; 16]) -> Result<String> {
|
||||
let ciphertext = match self.key.len() {
|
||||
16 => cbc::Encryptor::<aes::Aes128>::new_from_slices(&self.key, iv)
|
||||
.map_err(|_| anyhow!("invalid cipher key"))?
|
||||
.encrypt_padded_vec_mut::<Pkcs7>(plaintext.as_bytes()),
|
||||
32 => cbc::Encryptor::<aes::Aes256>::new_from_slices(&self.key, iv)
|
||||
.map_err(|_| anyhow!("invalid cipher key"))?
|
||||
.encrypt_padded_vec_mut::<Pkcs7>(plaintext.as_bytes()),
|
||||
_ => bail!("invalid cipher key"),
|
||||
};
|
||||
let iv = STANDARD.encode(iv);
|
||||
let value = STANDARD.encode(ciphertext);
|
||||
let mac = hex::encode(self.mac(&iv, &value)?.finalize().into_bytes());
|
||||
Ok(STANDARD.encode(serde_json::to_vec(&Envelope {
|
||||
iv,
|
||||
value,
|
||||
mac,
|
||||
tag: String::new(),
|
||||
})?))
|
||||
}
|
||||
|
||||
pub fn decrypt(&self, encrypted: &str) -> Result<String> {
|
||||
let payload = STANDARD
|
||||
.decode(encrypted)
|
||||
.map_err(|_| anyhow!("invalid encrypted envelope"))?;
|
||||
let envelope: Envelope =
|
||||
serde_json::from_slice(&payload).map_err(|_| anyhow!("invalid encrypted envelope"))?;
|
||||
if !envelope.tag.is_empty() {
|
||||
bail!("GCM envelopes are not supported");
|
||||
}
|
||||
let mac = hex::decode(&envelope.mac)
|
||||
.map_err(|_| anyhow!("invalid message authentication code"))?;
|
||||
let mut authenticated_key = None;
|
||||
for key in std::iter::once(&self.key).chain(self.previous_keys.iter()) {
|
||||
if Self::mac_with_key(key, &envelope.iv, &envelope.value)?
|
||||
.verify_slice(&mac)
|
||||
.is_ok()
|
||||
{
|
||||
authenticated_key = Some(key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let key = authenticated_key.ok_or_else(|| anyhow!("message authentication failed"))?;
|
||||
let iv = STANDARD
|
||||
.decode(&envelope.iv)
|
||||
.map_err(|_| anyhow!("invalid initialization vector"))?;
|
||||
if iv.len() != 16 {
|
||||
bail!("invalid initialization vector");
|
||||
}
|
||||
let ciphertext = STANDARD
|
||||
.decode(&envelope.value)
|
||||
.map_err(|_| anyhow!("invalid ciphertext"))?;
|
||||
let plaintext = match key.len() {
|
||||
16 => cbc::Decryptor::<aes::Aes128>::new_from_slices(key, &iv)
|
||||
.map_err(|_| anyhow!("invalid cipher key"))?
|
||||
.decrypt_padded_vec_mut::<Pkcs7>(&ciphertext),
|
||||
32 => cbc::Decryptor::<aes::Aes256>::new_from_slices(key, &iv)
|
||||
.map_err(|_| anyhow!("invalid cipher key"))?
|
||||
.decrypt_padded_vec_mut::<Pkcs7>(&ciphertext),
|
||||
_ => bail!("invalid cipher key"),
|
||||
}
|
||||
.map_err(|_| anyhow!("invalid ciphertext padding"))?;
|
||||
String::from_utf8(plaintext).map_err(|_| anyhow!("decrypted string is not UTF-8"))
|
||||
}
|
||||
|
||||
fn mac(&self, iv: &str, value: &str) -> Result<Hmac<Sha256>> {
|
||||
Self::mac_with_key(&self.key, iv, value)
|
||||
}
|
||||
|
||||
fn mac_with_key(key: &[u8], iv: &str, value: &str) -> Result<Hmac<Sha256>> {
|
||||
let mut mac =
|
||||
<Hmac<Sha256> as Mac>::new_from_slice(key).map_err(|_| anyhow!("invalid MAC key"))?;
|
||||
mac.update(iv.as_bytes());
|
||||
mac.update(value.as_bytes());
|
||||
Ok(mac)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn cookies_bind_the_name_accept_rotation_and_encoded_wire_values() {
|
||||
let old = LaravelCipher::from_app_key("0123456789abcdef0123456789abcdef").unwrap();
|
||||
let rotated = LaravelCipher::from_keys(
|
||||
"abcdef0123456789abcdef0123456789",
|
||||
&["0123456789abcdef0123456789abcdef".into()],
|
||||
)
|
||||
.unwrap();
|
||||
let wire = old.encrypt_cookie("laravel_session", "session-id").unwrap();
|
||||
assert_eq!(
|
||||
rotated.decrypt_cookie("laravel_session", &wire).unwrap(),
|
||||
"session-id"
|
||||
);
|
||||
assert!(rotated.decrypt_cookie("XSRF-TOKEN", &wire).is_err());
|
||||
assert!(
|
||||
rotated
|
||||
.decrypt_cookie("laravel_session", &old.encrypt("not-prefixed").unwrap())
|
||||
.is_err()
|
||||
);
|
||||
let escaped =
|
||||
percent_encoding::utf8_percent_encode(&wire, percent_encoding::NON_ALPHANUMERIC)
|
||||
.to_string();
|
||||
assert_eq!(
|
||||
rotated.decrypt_cookie("laravel_session", &escaped).unwrap(),
|
||||
"session-id"
|
||||
);
|
||||
assert!(
|
||||
old.decrypt_cookie(
|
||||
"laravel_session",
|
||||
&rotated.encrypt_cookie("laravel_session", "new-id").unwrap()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn independent_python_fixtures() {
|
||||
let rows: Vec<serde_json::Value> =
|
||||
serde_json::from_str(include_str!("../tests/fixtures/laravel_cbc.json")).unwrap();
|
||||
for row in rows {
|
||||
let cipher = LaravelCipher::from_app_key(row["app_key"].as_str().unwrap()).unwrap();
|
||||
let plaintext = row["plaintext"].as_str().unwrap();
|
||||
let envelope = row["envelope"].as_str().unwrap();
|
||||
assert_eq!(cipher.decrypt(envelope).unwrap(), plaintext);
|
||||
assert_eq!(
|
||||
cipher
|
||||
.encrypt_with_iv(
|
||||
plaintext,
|
||||
&[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||
)
|
||||
.unwrap(),
|
||||
envelope
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn roundtrip_and_randomized_iv() {
|
||||
let c = LaravelCipher::from_app_key("base64:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=")
|
||||
.unwrap();
|
||||
for plain in ["", "secret", "Unicode 🔐 and a block-sized message"] {
|
||||
let a = c.encrypt(plain).unwrap();
|
||||
assert_eq!(c.decrypt(&a).unwrap(), plain);
|
||||
assert_ne!(a, c.encrypt(plain).unwrap());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn rejects_modified_envelope_and_wrong_key() {
|
||||
let c = LaravelCipher::from_app_key("0123456789abcdef0123456789abcdef").unwrap();
|
||||
let encrypted = c.encrypt("secret").unwrap();
|
||||
assert!(
|
||||
LaravelCipher::from_app_key("abcdef0123456789abcdef0123456789")
|
||||
.unwrap()
|
||||
.decrypt(&encrypted)
|
||||
.is_err()
|
||||
);
|
||||
let mut e: Envelope = serde_json::from_slice(&STANDARD.decode(encrypted).unwrap()).unwrap();
|
||||
e.value = STANDARD.encode([0u8; 16]);
|
||||
assert!(
|
||||
c.decrypt(&STANDARD.encode(serde_json::to_vec(&e).unwrap()))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn key_rotation_reads_old_and_writes_primary() {
|
||||
let old_key = "0123456789abcdef0123456789abcdef";
|
||||
let new_key = "abcdef0123456789abcdef0123456789";
|
||||
let old = LaravelCipher::from_app_key(old_key).unwrap();
|
||||
let new = LaravelCipher::from_app_key(new_key).unwrap();
|
||||
let rotated = LaravelCipher::from_keys(new_key, &[old_key.into()]).unwrap();
|
||||
assert_eq!(
|
||||
rotated
|
||||
.decrypt(&old.encrypt("old secret").unwrap())
|
||||
.unwrap(),
|
||||
"old secret"
|
||||
);
|
||||
let written = rotated.encrypt("new secret").unwrap();
|
||||
assert_eq!(new.decrypt(&written).unwrap(), "new secret");
|
||||
assert!(old.decrypt(&written).is_err());
|
||||
let unrelated = LaravelCipher::from_app_key("00000000000000000000000000000000").unwrap();
|
||||
assert!(
|
||||
rotated
|
||||
.decrypt(&unrelated.encrypt("wrong key").unwrap())
|
||||
.is_err()
|
||||
);
|
||||
assert!(LaravelCipher::from_keys(new_key, &["invalid".into()]).is_err());
|
||||
assert!(LaravelCipher::from_keys(new_key, &["0123456789abcdef".into()]).is_err());
|
||||
let cloned = rotated.clone();
|
||||
assert_eq!(
|
||||
cloned
|
||||
.decrypt(&old.encrypt("cloned keys").unwrap())
|
||||
.unwrap(),
|
||||
"cloned keys"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn supports_aes128_and_rejects_invalid_keys() {
|
||||
let c = LaravelCipher::from_app_key("0123456789abcdef").unwrap();
|
||||
assert_eq!(c.decrypt(&c.encrypt("test").unwrap()).unwrap(), "test");
|
||||
assert!(LaravelCipher::from_app_key("").is_err());
|
||||
assert!(LaravelCipher::from_app_key("base64:!").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Explicit initialization of a NEW development database, never automatic production migration.
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub async fn init(pool: &SqlitePool) -> Result<(), sqlx::Error> {
|
||||
sqlx::raw_sql(SCHEMA).execute(pool).await?;
|
||||
crate::icons::init(pool).await?;
|
||||
crate::users::init(pool).await?;
|
||||
crate::passport::init(pool).await?;
|
||||
crate::oauth_server::init(pool).await?;
|
||||
crate::oauth_management::init(pool).await?;
|
||||
crate::oauth_device::init(pool).await?;
|
||||
crate::sso::init(pool).await?;
|
||||
crate::webauthn::init(pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const SCHEMA: &str = r#"
|
||||
CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT,name TEXT NOT NULL UNIQUE,email TEXT NOT NULL UNIQUE,email_verified_at TEXT,password TEXT NOT NULL,is_admin INTEGER NOT NULL DEFAULT 0,preferences TEXT,remember_token TEXT,oauth_provider TEXT,oauth_id TEXT,last_seen_at TEXT,created_at TEXT,updated_at TEXT);
|
||||
CREATE TABLE IF NOT EXISTS options (id INTEGER PRIMARY KEY AUTOINCREMENT,key TEXT NOT NULL UNIQUE,value TEXT);
|
||||
CREATE TABLE IF NOT EXISTS sessions (id TEXT NOT NULL PRIMARY KEY,user_id INTEGER,ip_address TEXT,user_agent TEXT,payload TEXT NOT NULL,last_activity INTEGER NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS sessions_last_activity_index ON sessions(last_activity);
|
||||
CREATE INDEX IF NOT EXISTS sessions_user_id_index ON sessions(user_id);
|
||||
CREATE TABLE IF NOT EXISTS twofaccounts (id INTEGER PRIMARY KEY AUTOINCREMENT,user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,service TEXT,account TEXT,icon TEXT,legacy_uri TEXT,otp_type TEXT NOT NULL,secret TEXT NOT NULL,algorithm TEXT NOT NULL,digits INTEGER NOT NULL,period INTEGER,counter INTEGER,created_at TEXT,updated_at TEXT);
|
||||
CREATE TABLE IF NOT EXISTS groups (id INTEGER PRIMARY KEY AUTOINCREMENT,user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,name TEXT NOT NULL,order_column INTEGER,show_in_chips INTEGER NOT NULL DEFAULT 0,created_at TEXT,updated_at TEXT);
|
||||
CREATE TABLE IF NOT EXISTS twofaccount_group_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT,twofaccount_id INTEGER NOT NULL REFERENCES twofaccounts(id) ON DELETE CASCADE,user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,created_at TEXT,updated_at TEXT,UNIQUE(twofaccount_id,user_id));
|
||||
CREATE TABLE IF NOT EXISTS twofaccount_user_orders (id INTEGER PRIMARY KEY AUTOINCREMENT,user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,twofaccount_id INTEGER NOT NULL REFERENCES twofaccounts(id) ON DELETE CASCADE,position INTEGER NOT NULL,created_at TEXT,updated_at TEXT,UNIQUE(user_id,twofaccount_id));
|
||||
CREATE TABLE IF NOT EXISTS twofaccount_shares (id INTEGER PRIMARY KEY AUTOINCREMENT,twofaccount_id INTEGER NOT NULL REFERENCES twofaccounts(id) ON DELETE CASCADE,shared_with_user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,scope TEXT NOT NULL,created_by_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,created_at TEXT,updated_at TEXT,UNIQUE(twofaccount_id,scope,shared_with_user_id));
|
||||
CREATE TABLE IF NOT EXISTS otp_logs (id INTEGER PRIMARY KEY AUTOINCREMENT,requester_id INTEGER REFERENCES users(id) ON DELETE SET NULL,requester_name TEXT NOT NULL,requester_email TEXT NOT NULL,owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL,owner_name TEXT NOT NULL,owner_email TEXT NOT NULL,twofaccount_id INTEGER NOT NULL REFERENCES twofaccounts(id) ON DELETE CASCADE,ip_address TEXT NOT NULL,otp_type TEXT NOT NULL,counter INTEGER,generated_at TEXT NOT NULL);
|
||||
"#;
|
||||
|
||||
/// Check the columns used by this implementation without changing Laravel tables.
|
||||
/// Run on an offline copy of a database migrated by the pinned PHP application.
|
||||
pub async fn validate_existing(pool: &SqlitePool) -> anyhow::Result<()> {
|
||||
crate::schema_validation::validate_existing(pool).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[tokio::test]
|
||||
async fn development_schema_satisfies_startup_with_database_sessions() {
|
||||
let db = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
super::init(&db).await.unwrap();
|
||||
crate::schema_validation::validate_existing_with_sessions(&db, true)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
+550
@@ -0,0 +1,550 @@
|
||||
//! Pinned upstream Vue build with native-session bootstrap. This preserves the
|
||||
//! UI assets; it does not establish parity for the backend features they expose.
|
||||
use crate::{AppState, auth, settings};
|
||||
use axum::{
|
||||
Extension, Json, Router,
|
||||
body::to_bytes,
|
||||
extract::State,
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use rand::{RngCore, rngs::OsRng};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::path::PathBuf;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
const MANIFEST: &str = include_str!("../public/build/manifest.json");
|
||||
const LOCALES: &[&str] = &[
|
||||
"bg", "zh-CN", "zh-TW", "da", "nl", "en", "fr", "de", "hi", "it", "ja", "ko", "pt-BR", "ru",
|
||||
"es-ES", "tr",
|
||||
];
|
||||
const SPA_PATHS: &[&str] = &[
|
||||
"/",
|
||||
"/start",
|
||||
"/capture",
|
||||
"/accounts",
|
||||
"/account/create",
|
||||
"/account/import",
|
||||
"/account/{twofaccountId}/edit",
|
||||
"/account/{twofaccountId}/qrcode",
|
||||
"/account/{twofaccountId}/sharing",
|
||||
"/account/{twofaccountId}/share",
|
||||
"/account/{twofaccountId}/transfer",
|
||||
"/account/{twofaccountId}/otp-logs",
|
||||
"/groups",
|
||||
"/group/create",
|
||||
"/group/{groupId}/edit",
|
||||
"/settings/options",
|
||||
"/settings/account",
|
||||
"/settings/oauth",
|
||||
"/settings/webauthn",
|
||||
"/settings/webauthn/{credentialId}/edit",
|
||||
"/admin/app",
|
||||
"/admin/auth",
|
||||
"/admin/users",
|
||||
"/admin/users/create",
|
||||
"/admin/users/{userId}/manage",
|
||||
"/admin/logs/{userId}/access",
|
||||
"/login",
|
||||
"/register",
|
||||
"/password/request",
|
||||
"/user/password/reset",
|
||||
"/webauthn/lost",
|
||||
"/webauthn/recover",
|
||||
"/about",
|
||||
"/error",
|
||||
"/404",
|
||||
];
|
||||
|
||||
fn public_directory() -> PathBuf {
|
||||
if let Some(path) = std::env::var_os("RUST_PUBLIC_DIR") {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
for ancestor in exe.ancestors().skip(1).take(4) {
|
||||
let public = ancestor.join("public");
|
||||
if public.join("build/manifest.json").is_file() {
|
||||
return public;
|
||||
}
|
||||
}
|
||||
}
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("public")
|
||||
}
|
||||
pub fn routes() -> Router<AppState> {
|
||||
let public = public_directory();
|
||||
let mut router = Router::new().nest_service("/build", ServeDir::new(public.join("build")));
|
||||
// Never expose a filesystem root or PHP entrypoint as a fallback.
|
||||
for file in [
|
||||
"favicon.ico",
|
||||
"favicon.png",
|
||||
"favicon_lg.png",
|
||||
"logo.svg",
|
||||
"healthcheck.css",
|
||||
"robots.txt",
|
||||
] {
|
||||
router = router.route_service(&format!("/{file}"), ServeFile::new(public.join(file)));
|
||||
}
|
||||
for path in SPA_PATHS {
|
||||
router = router.route(path, get(landing));
|
||||
}
|
||||
router.route("/manifest.json", get(manifest))
|
||||
}
|
||||
fn enabled(name: &str, default: bool) -> bool {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| matches!(s.as_str(), "true" | "1" | "(true)"))
|
||||
.unwrap_or(default)
|
||||
}
|
||||
fn html_escape(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
fn script_json(value: &Value) -> String {
|
||||
// JSON escaping alone leaves </script> able to terminate an HTML script.
|
||||
value
|
||||
.to_string()
|
||||
.replace('&', "\\u0026")
|
||||
.replace('<', "\\u003c")
|
||||
.replace('>', "\\u003e")
|
||||
.replace('\u{2028}', "\\u2028")
|
||||
.replace('\u{2029}', "\\u2029")
|
||||
}
|
||||
fn nonce() -> String {
|
||||
let mut bytes = [0u8; 24];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
fn locked_preferences() -> Vec<String> {
|
||||
let names: Map<String, Value> =
|
||||
serde_json::from_str(include_str!("preference_env.json")).expect("built-in preference map");
|
||||
names
|
||||
.into_iter()
|
||||
.filter_map(|(key, name)| {
|
||||
if [
|
||||
"activeGroup",
|
||||
"defaultGroup",
|
||||
"useWebauthnOnly",
|
||||
"sortOrder",
|
||||
]
|
||||
.contains(&key.as_str())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let env = name
|
||||
.as_str()?
|
||||
.replacen("USERPREF_DEFAULT__", "USERPREF_LOCKED__", 1);
|
||||
enabled(&env, false).then_some(key)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn bootstrap(all: &Value) -> Value {
|
||||
bootstrap_at(all, "")
|
||||
}
|
||||
fn configured_secret(name: &str) -> bool {
|
||||
std::env::var(name).is_ok_and(|value| {
|
||||
!matches!(
|
||||
value.as_str(),
|
||||
"" | "0" | "null" | "(null)" | "false" | "(false)"
|
||||
)
|
||||
})
|
||||
}
|
||||
fn bootstrap_at(all: &Value, prefix: &str) -> Value {
|
||||
let mut visitor: Map<String, Value> = all
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flat_map(|m| m.keys().map(|key| (key.clone(), Value::Null)))
|
||||
.collect();
|
||||
for key in ["disableRegistration", "enableSso", "useSsoOnly"] {
|
||||
visitor.insert(key.into(), all[key].clone());
|
||||
}
|
||||
json!({
|
||||
"appSettings": visitor,
|
||||
"appConfig": {
|
||||
"proxyAuth":crate::proxy_auth::enabled(),
|
||||
"proxyLogoutUrl":std::env::var("PROXY_LOGOUT_URL").ok().filter(|v|!v.is_empty()&&v!="0"&&v!="null").map(Value::String).unwrap_or(json!(false)),
|
||||
"sso": {"openid":configured_secret("OPENID_CLIENT_SECRET"),"github":configured_secret("GITHUB_CLIENT_SECRET")},
|
||||
"subdirectory":prefix
|
||||
},
|
||||
"urls": {"installDocUrl":"https://docs.2fauth.app/getting-started/installation/self-hosted-server/", "ssoDocUrl":"https://docs.2fauth.app/security/authentication/sso/", "exportSchemaUrl":"https://docs.2fauth.app/usage/migration/#export-schema"},
|
||||
"defaultPreferences": settings::preferences_from(None),
|
||||
"lockedPreferences": locked_preferences(),
|
||||
"appVersion": "8.0.3",
|
||||
"isDemoApp": enabled("IS_DEMO_APP", false),
|
||||
"isTestingApp": enabled("IS_TESTING_APP", false),
|
||||
"appLocales": LOCALES
|
||||
})
|
||||
}
|
||||
fn render(all: &Value, csrf: &str, csp_nonce: &str, prefix: &str) -> String {
|
||||
let globals = bootstrap_at(all, prefix);
|
||||
let base = html_escape(prefix);
|
||||
let manifest: Value = serde_json::from_str(MANIFEST).expect("pinned Vite manifest");
|
||||
let entry = &manifest["resources/js/app.js"];
|
||||
let title = html_escape(&std::env::var("APP_NAME").unwrap_or_else(|_| "2FAuth".into()));
|
||||
let theme = html_escape(
|
||||
globals["defaultPreferences"]["theme"]
|
||||
.as_str()
|
||||
.unwrap_or("system"),
|
||||
);
|
||||
let mut scripts = String::new();
|
||||
for key in [
|
||||
"appSettings",
|
||||
"appConfig",
|
||||
"urls",
|
||||
"defaultPreferences",
|
||||
"lockedPreferences",
|
||||
"appVersion",
|
||||
"isDemoApp",
|
||||
"isTestingApp",
|
||||
"appLocales",
|
||||
] {
|
||||
scripts.push_str(&format!("var {key} = {};\n", script_json(&globals[key])));
|
||||
}
|
||||
let css = entry["css"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(|file| {
|
||||
format!(
|
||||
"<link rel=\"stylesheet\" href=\"{base}/build/{}\">",
|
||||
html_escape(file)
|
||||
)
|
||||
})
|
||||
.collect::<String>();
|
||||
let js = html_escape(entry["file"].as_str().expect("pinned JS entry"));
|
||||
format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html data-theme="{theme}" lang="en"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0, shrink-to-fit=no, viewport-fit=cover">
|
||||
<meta name="csrf-token" content="{}"><meta name="robots" content="noindex, nofollow"><meta name="mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<title>{title}</title><link rel="shortcut icon" href="{base}/favicon.ico"><link rel="icon" type="image/png" href="{base}/favicon.png"><link rel="apple-touch-icon" href="{base}/favicon_lg.png"><link rel="manifest" href="{base}/manifest.json">{css}
|
||||
</head><body><div id="app"><app></app></div>
|
||||
<script nonce="{csp_nonce}">{scripts}</script><script type="module" nonce="{csp_nonce}" src="{base}/build/{js}"></script>
|
||||
</body></html>"#,
|
||||
html_escape(csrf)
|
||||
)
|
||||
}
|
||||
pub(crate) async fn landing(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
method: axum::http::Method,
|
||||
mount: Option<Extension<crate::subdirectory::MountPath>>,
|
||||
) -> Response {
|
||||
if !matches!(method, axum::http::Method::GET | axum::http::Method::HEAD) {
|
||||
return StatusCode::METHOD_NOT_ALLOWED.into_response();
|
||||
}
|
||||
let prefix = mount
|
||||
.as_ref()
|
||||
.map(|Extension(path)| path.0.as_str())
|
||||
.unwrap_or("");
|
||||
let all = match settings::all(&state.db).await {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Frontend configuration unavailable",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let session_response = auth::refresh_csrf(State(state), headers).await;
|
||||
if !session_response.status().is_success() {
|
||||
return session_response;
|
||||
}
|
||||
let (parts, body) = session_response.into_parts();
|
||||
let bytes = match to_bytes(body, 1024).await {
|
||||
Ok(value) => value,
|
||||
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
};
|
||||
let csrf = match std::str::from_utf8(&bytes) {
|
||||
Ok(value) if value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit()) => value,
|
||||
_ => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
};
|
||||
let nonce = nonce();
|
||||
let mut response = Html(render(&all, csrf, &nonce, prefix)).into_response();
|
||||
for cookie in parts.headers.get_all(header::SET_COOKIE) {
|
||||
response
|
||||
.headers_mut()
|
||||
.append(header::SET_COOKIE, cookie.clone());
|
||||
}
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
response.headers_mut().insert(
|
||||
header::X_CONTENT_TYPE_OPTIONS,
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
if enabled("CONTENT_SECURITY_POLICY", true) {
|
||||
let policy = format!(
|
||||
"default-src 'self'; script-src 'nonce-{nonce}' 'strict-dynamic' 'wasm-unsafe-eval'; style-src 'self' https://fastly.jsdelivr.net:* 'unsafe-inline'; connect-src 'self' https://fastly.jsdelivr.net:*; img-src 'self' data: https://fastly.jsdelivr.net:*; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_SECURITY_POLICY,
|
||||
HeaderValue::from_str(&policy).expect("generated CSP"),
|
||||
);
|
||||
}
|
||||
response
|
||||
}
|
||||
fn public_base(headers: &HeaderMap, prefix: &str) -> String {
|
||||
let configured = std::env::var("APP_URL")
|
||||
.ok()
|
||||
.filter(|x| !x.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"http://{}",
|
||||
headers
|
||||
.get(header::HOST)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("localhost")
|
||||
)
|
||||
});
|
||||
if let Ok(mut url) = url::Url::parse(&configured) {
|
||||
url.set_path(prefix);
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
url.to_string().trim_end_matches('/').into()
|
||||
} else {
|
||||
prefix.into()
|
||||
}
|
||||
}
|
||||
async fn manifest(
|
||||
headers: HeaderMap,
|
||||
mount: Option<Extension<crate::subdirectory::MountPath>>,
|
||||
) -> Json<Value> {
|
||||
let prefix = mount
|
||||
.as_ref()
|
||||
.map(|Extension(path)| path.0.as_str())
|
||||
.unwrap_or("");
|
||||
let base = public_base(&headers, prefix);
|
||||
Json(
|
||||
json!({"short_name":std::env::var("APP_NAME").unwrap_or_else(|_|"2FAuth".into()),"name":std::env::var("APP_NAME").unwrap_or_else(|_|"2FAuth".into()),"start_url":base,"scope":format!("{prefix}/"),"display":"standalone","theme_color":"#242424","icons":[{"src":"favicon_lg.png","type":"image/png","sizes":"180x180"}],"shortcuts":[{"name":"New 2FA","url":format!("{prefix}/start"),"description":"Add a new 2FA account by flashing a QR code or filling out a form"},{"name":"Import 2FAs","url":format!("{prefix}/account/import"),"description":"Import 2FA accounts previously exported from another 2FA app"},{"name":"Settings","url":format!("{prefix}/settings/options"),"description":"Manage your 2FAuth user settings"}]}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Same health view as the pinned PHP application. The elapsed render time is
|
||||
/// fixed at zero because PHP's process start clock has no Rust equivalent.
|
||||
pub async fn health(
|
||||
headers: HeaderMap,
|
||||
mount: Option<Extension<crate::subdirectory::MountPath>>,
|
||||
) -> Html<String> {
|
||||
let name = std::env::var("APP_NAME").unwrap_or_else(|_| "2FAuth".into());
|
||||
let prefix = mount
|
||||
.as_ref()
|
||||
.map(|Extension(path)| path.0.as_str())
|
||||
.unwrap_or("");
|
||||
let base = public_base(&headers, prefix);
|
||||
Html(
|
||||
include_str!("health.html")
|
||||
.replace("{APP_NAME}", &html_escape(&name))
|
||||
.replace("{BASE_URL}", &html_escape(&base)),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::body::Body;
|
||||
use tower::ServiceExt;
|
||||
#[tokio::test]
|
||||
async fn complete_application_works_under_a_subdirectory() {
|
||||
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
crate::db::init(&db).await.unwrap();
|
||||
auth::init(&db).await.unwrap();
|
||||
let state = AppState {
|
||||
db,
|
||||
cipher: None,
|
||||
encrypt_db: std::sync::atomic::AtomicBool::new(false).into(),
|
||||
};
|
||||
let app = crate::router_at(state, "/tools/2fa");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri("/tools/2fa/login")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
response
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.all(|cookie| cookie.to_str().unwrap().contains("Path=/tools/2fa;"))
|
||||
);
|
||||
let html = String::from_utf8(
|
||||
to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("\"subdirectory\":\"/tools/2fa\""));
|
||||
assert!(html.contains("href=\"/tools/2fa/favicon.ico\""));
|
||||
assert!(html.contains("src=\"/tools/2fa/build/assets/app-dvJP63FL.js\""));
|
||||
assert!(html.contains("href=\"/tools/2fa/build/assets/"));
|
||||
assert!(!html.contains("href=\"/build/"));
|
||||
for path in [
|
||||
"/tools/2fa/build/assets/app-dvJP63FL.js",
|
||||
"/tools/2fa/api/v1/user/name",
|
||||
"/tools/2fa/refresh-csrf",
|
||||
"/tools/2fa/up",
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri(path)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK, "{path}");
|
||||
}
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri("/tools/2fa/manifest.json")
|
||||
.header("host", "example.test")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let manifest: Value =
|
||||
serde_json::from_slice(&to_bytes(response.into_body(), 1024 * 1024).await.unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(manifest["scope"], "/tools/2fa/");
|
||||
assert!(
|
||||
manifest["start_url"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.ends_with("/tools/2fa")
|
||||
);
|
||||
assert_eq!(manifest["shortcuts"][0]["url"], "/tools/2fa/start");
|
||||
let response = app
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri("/login")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
#[test]
|
||||
fn untrusted_bootstrap_values_cannot_terminate_script() {
|
||||
let attack = "</script><script>alert(1)</script>&\u{2028}\u{2029}";
|
||||
let encoded = script_json(&json!({"value":attack}));
|
||||
assert!(!encoded.contains('<'));
|
||||
assert!(!encoded.contains('&'));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(&encoded).unwrap()["value"],
|
||||
attack
|
||||
);
|
||||
assert_eq!(html_escape("\"<>&'"), ""<>&'");
|
||||
}
|
||||
#[test]
|
||||
fn visitor_bootstrap_redacts_nonpublic_settings() {
|
||||
let value = bootstrap(
|
||||
&json!({"restrictList":"private@example.invalid","disableRegistration":true,"enableSso":false,"useSsoOnly":false}),
|
||||
);
|
||||
assert!(value["appSettings"]["restrictList"].is_null());
|
||||
assert_eq!(value["appSettings"]["disableRegistration"], true);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn real_session_bootstrap_and_asset_serving() {
|
||||
let db = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
sqlx::raw_sql("CREATE TABLE users(id INTEGER PRIMARY KEY,is_admin INTEGER); CREATE TABLE options(key TEXT,value TEXT);").execute(&db).await.unwrap();
|
||||
auth::init(&db).await.unwrap();
|
||||
let state = AppState {
|
||||
db: db.clone(),
|
||||
cipher: None,
|
||||
encrypt_db: std::sync::atomic::AtomicBool::new(false).into(),
|
||||
};
|
||||
let router = routes().with_state(state);
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri("/login")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert!(
|
||||
response
|
||||
.headers()
|
||||
.contains_key(header::CONTENT_SECURITY_POLICY)
|
||||
);
|
||||
let html = String::from_utf8(
|
||||
to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.unwrap()
|
||||
.to_vec(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("assets/app-dvJP63FL.js"));
|
||||
assert!(html.contains("var defaultPreferences ="));
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM rust_sessions WHERE user_id IS NULL")
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
let asset = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri("/build/assets/app-dvJP63FL.js")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(asset.status(), StatusCode::OK);
|
||||
assert!(
|
||||
asset.headers()[header::CONTENT_TYPE]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.contains("javascript")
|
||||
);
|
||||
let php = router
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.uri("/index.php")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(php.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
use crate::{
|
||||
AppState,
|
||||
accounts::{self, ApiError, ApiResult, invalid, missing},
|
||||
auth::AuthUser,
|
||||
};
|
||||
use axum::{
|
||||
Extension, Json, Router,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::{Row, SqliteConnection};
|
||||
use std::collections::HashMap;
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/groups", get(index).post(create))
|
||||
.route("/groups/reorder", post(reorder))
|
||||
.route(
|
||||
"/groups/{id}",
|
||||
get(show).put(update).patch(update).delete(delete),
|
||||
)
|
||||
.route("/groups/{id}/twofaccounts", get(accounts))
|
||||
.route("/groups/{id}/assign", post(assign))
|
||||
}
|
||||
async fn check(conn: &mut SqliteConnection, user: i64, id: i64) -> Result<(), ApiError> {
|
||||
if id <= 0
|
||||
|| sqlx::query_scalar::<_, i64>("SELECT id FROM groups WHERE id=? AND user_id=?")
|
||||
.bind(id)
|
||||
.bind(user)
|
||||
.fetch_optional(conn)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Err(missing());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn value(s: &AppState, user: i64, id: i64) -> Result<Value, ApiError> {
|
||||
if id == 0 {
|
||||
let sharing = crate::sharing::flags(&s.db).await?;
|
||||
let count: i64 = sqlx::query_scalar(&format!(
|
||||
"SELECT COUNT(*) FROM twofaccounts a WHERE {}",
|
||||
accounts::VISIBLE
|
||||
))
|
||||
.bind(user)
|
||||
.bind(sharing.0)
|
||||
.bind(user)
|
||||
.bind(sharing.1)
|
||||
.fetch_one(&s.db)
|
||||
.await?;
|
||||
return Ok(json!({"id":0,"name":"All","show_in_chips":null,"twofaccounts_count":count}));
|
||||
}
|
||||
let sharing = crate::sharing::flags(&s.db).await?;
|
||||
let r=sqlx::query(&format!("SELECT g.*, (SELECT COUNT(*) FROM twofaccount_group_assignments x JOIN twofaccounts a ON a.id=x.twofaccount_id WHERE x.group_id=g.id AND x.user_id=? AND {}) AS n FROM groups g WHERE g.id=? AND g.user_id=?",accounts::VISIBLE)).bind(user).bind(user).bind(sharing.0).bind(user).bind(sharing.1).bind(id).bind(user).fetch_optional(&s.db).await?.ok_or_else(missing)?;
|
||||
Ok(
|
||||
json!({"id":id,"name":r.try_get::<String,_>("name")?,"show_in_chips":r.try_get::<bool,_>("show_in_chips")?,"twofaccounts_count":r.try_get::<i64,_>("n")?}),
|
||||
)
|
||||
}
|
||||
async fn index(State(s): State<AppState>, Extension(u): Extension<AuthUser>) -> ApiResult {
|
||||
let ids: Vec<i64> =
|
||||
sqlx::query_scalar("SELECT id FROM groups WHERE user_id=? ORDER BY order_column,id")
|
||||
.bind(u.id)
|
||||
.fetch_all(&s.db)
|
||||
.await?;
|
||||
let mut groups = vec![value(&s, u.id, 0).await?];
|
||||
for id in ids {
|
||||
groups.push(value(&s, u.id, id).await?)
|
||||
}
|
||||
Ok(Json(json!(groups)).into_response())
|
||||
}
|
||||
async fn show(
|
||||
State(s): State<AppState>,
|
||||
Extension(u): Extension<AuthUser>,
|
||||
Path(id): Path<i64>,
|
||||
) -> ApiResult {
|
||||
let mut resource = value(&s, u.id, id).await?;
|
||||
// Laravel's show/update resources do not loadCount for physical groups.
|
||||
if id != 0 {
|
||||
resource["twofaccounts_count"] = json!(0);
|
||||
}
|
||||
Ok(Json(resource).into_response())
|
||||
}
|
||||
fn fields(v: &Value) -> Result<(&str, Option<bool>), ApiError> {
|
||||
let name = v.get("name").and_then(Value::as_str).ok_or_else(invalid)?;
|
||||
if name.trim().is_empty()
|
||||
|| name.chars().count() > 32
|
||||
|| name == "All"
|
||||
|| !name
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c.is_whitespace() || "_- '".contains(c))
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
let chips = v
|
||||
.get("show_in_chips")
|
||||
.map(|x| {
|
||||
x.as_bool()
|
||||
.or_else(|| x.as_i64().filter(|n| *n == 0 || *n == 1).map(|n| n == 1))
|
||||
.ok_or_else(invalid)
|
||||
})
|
||||
.transpose()?;
|
||||
Ok((name, chips))
|
||||
}
|
||||
async fn save(s: &AppState, user: i64, id: Option<i64>, v: Value) -> Result<i64, ApiError> {
|
||||
let (name, chips) = fields(&v)?;
|
||||
let mut tx = s.db.begin().await?;
|
||||
if let Some(id) = id {
|
||||
check(&mut tx, user, id).await?;
|
||||
}
|
||||
let duplicate: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM groups WHERE user_id=? AND name=? AND id!=?")
|
||||
.bind(user)
|
||||
.bind(name)
|
||||
.bind(id.unwrap_or(-1))
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if duplicate > 0 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let id = if let Some(id) = id {
|
||||
sqlx::query("UPDATE groups SET name=?,show_in_chips=COALESCE(?,show_in_chips),updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=?").bind(name).bind(chips).bind(id).bind(user).execute(&mut *tx).await?;
|
||||
id
|
||||
} else {
|
||||
sqlx::query("INSERT INTO groups(user_id,name,show_in_chips,order_column,created_at,updated_at) VALUES(?,?,?,(SELECT COALESCE(MAX(order_column),0)+1 FROM groups WHERE user_id=?),CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)").bind(user).bind(name).bind(chips.unwrap_or(false)).bind(user).execute(&mut *tx).await?.last_insert_rowid()
|
||||
};
|
||||
tx.commit().await?;
|
||||
Ok(id)
|
||||
}
|
||||
async fn create(
|
||||
State(s): State<AppState>,
|
||||
Extension(u): Extension<AuthUser>,
|
||||
Json(v): Json<Value>,
|
||||
) -> ApiResult {
|
||||
let chips_missing = v.get("show_in_chips").is_none();
|
||||
let id = save(&s, u.id, None, v).await?;
|
||||
let mut resource = value(&s, u.id, id).await?;
|
||||
if chips_missing {
|
||||
resource["show_in_chips"] = Value::Null;
|
||||
}
|
||||
Ok((StatusCode::CREATED, Json(resource)).into_response())
|
||||
}
|
||||
async fn update(
|
||||
State(s): State<AppState>,
|
||||
Extension(u): Extension<AuthUser>,
|
||||
Path(id): Path<i64>,
|
||||
Json(v): Json<Value>,
|
||||
) -> ApiResult {
|
||||
save(&s, u.id, Some(id), v).await?;
|
||||
show(State(s), Extension(u), Path(id)).await
|
||||
}
|
||||
async fn delete(
|
||||
State(s): State<AppState>,
|
||||
Extension(u): Extension<AuthUser>,
|
||||
Path(id): Path<i64>,
|
||||
) -> ApiResult {
|
||||
let mut tx = s.db.begin().await?;
|
||||
check(&mut tx, u.id, id).await?;
|
||||
sqlx::query("DELETE FROM groups WHERE id=? AND user_id=?")
|
||||
.bind(id)
|
||||
.bind(u.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(StatusCode::NO_CONTENT.into_response())
|
||||
}
|
||||
async fn accounts(
|
||||
State(s): State<AppState>,
|
||||
Extension(u): Extension<AuthUser>,
|
||||
Path(id): Path<i64>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
) -> ApiResult {
|
||||
value(&s, u.id, id).await?;
|
||||
Ok(Json(accounts::list_for(&s, u.id, Some(id), &q).await?).into_response())
|
||||
}
|
||||
async fn assign(
|
||||
State(s): State<AppState>,
|
||||
Extension(u): Extension<AuthUser>,
|
||||
Path(id): Path<i64>,
|
||||
Json(v): Json<Value>,
|
||||
) -> ApiResult {
|
||||
let ids = v
|
||||
.get("ids")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(invalid)?
|
||||
.iter()
|
||||
.map(|x| x.as_i64().filter(|n| *n > 0).ok_or_else(invalid))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let sharing = crate::sharing::flags(&s.db).await?;
|
||||
let mut tx = s.db.begin().await?;
|
||||
check(&mut tx, u.id, id).await?;
|
||||
for account in &ids {
|
||||
accounts::visible(&mut tx, u.id, *account, sharing).await?
|
||||
}
|
||||
for account in ids {
|
||||
accounts::assign(&mut tx, u.id, account, Some(id)).await?
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(Json(value(&s, u.id, id).await?).into_response())
|
||||
}
|
||||
async fn reorder(
|
||||
State(s): State<AppState>,
|
||||
Extension(u): Extension<AuthUser>,
|
||||
Json(v): Json<Value>,
|
||||
) -> ApiResult {
|
||||
let ids = accounts::ordered_ids(&v)?;
|
||||
let mut tx = s.db.begin().await?;
|
||||
for id in &ids {
|
||||
check(&mut tx, u.id, *id).await?
|
||||
}
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
sqlx::query("UPDATE groups SET order_column=? WHERE id=? AND user_id=?")
|
||||
.bind(i as i64 + 1)
|
||||
.bind(id)
|
||||
.bind(u.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
let ids: Vec<i64> =
|
||||
sqlx::query_scalar("SELECT id FROM groups WHERE user_id=? ORDER BY order_column,id")
|
||||
.bind(u.id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Json(json!({"message":"order saved","orderedIds":ids})).into_response())
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{APP_NAME}</title>
|
||||
<!-- Styles -->
|
||||
<link rel="stylesheet" href="{BASE_URL}/healthcheck.css"></link>
|
||||
</head>
|
||||
<body class="antialiased">
|
||||
<div class="relative sm:flex sm:justify-center sm:items-center min-h-screen bg-gray-100 selection:bg-red-500 selection:text-white">
|
||||
<div class="w-full sm:w-3/4 xl:w-1/2 mx-auto p-6">
|
||||
<div class="px-6 py-4 bg-white from-gray-700/50 via-transparent rounded-lg shadow-2xl shadow-gray-500/20 flex items-center focus:outline focus:outline-2 focus:outline-red-500">
|
||||
<div class="relative flex h-3 w-3">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-3 w-3 bg-green-400"></span>
|
||||
</div>
|
||||
|
||||
<div class="ml-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900">{APP_NAME} up</h2>
|
||||
|
||||
<p class="mt-2 text-gray-500 dark:text-gray-400 text-sm leading-relaxed">
|
||||
HTTP request received.
|
||||
|
||||
Response successfully rendered in 0ms.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+1261
File diff suppressed because it is too large.
Load diff
+567
@@ -0,0 +1,567 @@
|
||||
//! Migration previews for the export formats accepted by 2FAuth.
|
||||
use crate::{
|
||||
AppState,
|
||||
accounts::{ApiError, ApiResult, invalid},
|
||||
otp::{self, OtpConfig},
|
||||
};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{FromRequest, Multipart, Request, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::post,
|
||||
};
|
||||
use base64::{
|
||||
Engine,
|
||||
engine::general_purpose::{STANDARD, STANDARD_NO_PAD},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new().route("/twofaccounts/migration", post(migrate))
|
||||
}
|
||||
fn bad(message: &'static str) -> ApiError {
|
||||
ApiError(StatusCode::BAD_REQUEST, message)
|
||||
}
|
||||
|
||||
fn missing_fields() -> axum::response::Response {
|
||||
(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Json(json!({
|
||||
"message": "The payload field is required when file is not present. (and 1 more error)",
|
||||
"errors": {
|
||||
"payload": ["The payload field is required when file is not present."],
|
||||
"file": ["The file field is required when payload is not present."]
|
||||
}
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn migrate(State(state): State<AppState>, request: Request) -> ApiResult {
|
||||
let content_type = request
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|x| x.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
let query: Value = serde_urlencoded::from_str::<std::collections::HashMap<String, String>>(
|
||||
request.uri().query().unwrap_or(""),
|
||||
)
|
||||
.map(|v| json!(v))
|
||||
.unwrap_or(json!({}));
|
||||
let mut with_secret = truthy(&query["withSecret"]);
|
||||
|
||||
let payload = if content_type.starts_with("multipart/form-data") {
|
||||
let mut multipart = Multipart::from_request(request, &state)
|
||||
.await
|
||||
.map_err(|_| invalid())?;
|
||||
let mut text = None;
|
||||
let mut file = None;
|
||||
while let Some(field) = multipart.next_field().await.map_err(|_| invalid())? {
|
||||
let name = field.name().unwrap_or("").to_owned();
|
||||
if name == "file" {
|
||||
let filename = field.file_name().unwrap_or("").to_ascii_lowercase();
|
||||
if ![".txt", ".json", ".csv"]
|
||||
.iter()
|
||||
.any(|ext| filename.ends_with(ext))
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
file = Some(field.text().await.map_err(|_| invalid())?);
|
||||
} else if name == "payload" {
|
||||
text = Some(field.text().await.map_err(|_| invalid())?);
|
||||
} else if name == "withSecret" {
|
||||
with_secret = truthy(&json!(field.text().await.map_err(|_| invalid())?));
|
||||
}
|
||||
}
|
||||
let Some(content) = file.or(text).filter(|s| !s.trim().is_empty()) else {
|
||||
return Ok(missing_fields());
|
||||
};
|
||||
content
|
||||
} else {
|
||||
let bytes = axum::body::to_bytes(request.into_body(), 2 * 1024 * 1024)
|
||||
.await
|
||||
.map_err(|_| invalid())?;
|
||||
let body: Value = if content_type.starts_with("application/x-www-form-urlencoded") {
|
||||
json!(
|
||||
serde_urlencoded::from_bytes::<std::collections::HashMap<String, String>>(&bytes)
|
||||
.map_err(|_| invalid())?
|
||||
)
|
||||
} else {
|
||||
serde_json::from_slice(&bytes).map_err(|_| invalid())?
|
||||
};
|
||||
if body.get("withSecret").is_some() {
|
||||
with_secret = truthy(&body["withSecret"]);
|
||||
}
|
||||
if body
|
||||
.get("payload")
|
||||
.is_none_or(|v| v.is_null() || v.as_str().is_some_and(|s| s.trim().is_empty()))
|
||||
{
|
||||
return Ok(missing_fields());
|
||||
}
|
||||
body["payload"]
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(invalid)?
|
||||
.to_owned()
|
||||
};
|
||||
let mut values = parse_payload(&payload)?;
|
||||
// Import icons alongside their preview rows, as upstream does before account storage.
|
||||
if let Ok(source) = serde_json::from_str::<Value>(&payload) {
|
||||
let entries = source["db"]["entries"]
|
||||
.as_array()
|
||||
.or_else(|| source["data"].as_array());
|
||||
if let Some(entries) = entries {
|
||||
for (row, source) in values.iter_mut().zip(entries) {
|
||||
if row["id"] == -2 {
|
||||
continue;
|
||||
}
|
||||
let encoded = source
|
||||
.get("icon_file")
|
||||
.or_else(|| source.get("icon"))
|
||||
.and_then(Value::as_str);
|
||||
if let (Some(mime), Some(encoded)) = (source["icon_mime"].as_str(), encoded)
|
||||
&& let Ok(name) = crate::icons::persist_embedded(&state, mime, encoded).await
|
||||
{
|
||||
row["icon"] = json!(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !with_secret {
|
||||
for value in &mut values {
|
||||
value.as_object_mut().unwrap().remove("secret");
|
||||
}
|
||||
}
|
||||
Ok(Json(values).into_response())
|
||||
}
|
||||
fn truthy(v: &Value) -> bool {
|
||||
v == true
|
||||
|| v == 1
|
||||
|| v.as_str()
|
||||
.is_some_and(|s| ["1", "true", "on", "yes"].contains(&s.to_ascii_lowercase().as_str()))
|
||||
}
|
||||
fn resource(mut c: OtpConfig) -> anyhow::Result<Value> {
|
||||
c.normalize();
|
||||
c.validate()?;
|
||||
if c.otp_type == "hotp" {
|
||||
c.period = None;
|
||||
c.counter = Some(c.counter.unwrap_or(0));
|
||||
} else {
|
||||
c.counter = None;
|
||||
c.period = Some(c.period.unwrap_or(30));
|
||||
}
|
||||
if c.counter.is_some_and(|n| n >= i64::MAX as u64)
|
||||
|| c.period.is_some_and(|n| n > i64::MAX as u64)
|
||||
{
|
||||
anyhow::bail!("invalid counter or period");
|
||||
}
|
||||
let mut v = serde_json::to_value(c)?;
|
||||
v["id"] = json!(0);
|
||||
v["group_id"] = Value::Null;
|
||||
v["icon"] = Value::Null;
|
||||
Ok(v)
|
||||
}
|
||||
fn config(v: Value) -> anyhow::Result<Value> {
|
||||
let mut v = v;
|
||||
for (name, default) in [
|
||||
("algorithm", json!("sha1")),
|
||||
("digits", json!(6)),
|
||||
("period", json!(30)),
|
||||
] {
|
||||
if v.get(name).is_none_or(Value::is_null) {
|
||||
v[name] = default;
|
||||
}
|
||||
}
|
||||
resource(serde_json::from_value(v)?)
|
||||
}
|
||||
fn failure(item: &Value, reason: &str) -> Value {
|
||||
json!({"id":-2,"group_id":null,"otp_type":item.get("otp_type").or_else(||item.get("type")).and_then(Value::as_str).unwrap_or("totp"),"account":item.get("account").or_else(||item.get("name")).and_then(Value::as_str).unwrap_or("Invalid account"),"service":item.get("service").or_else(||item.get("issuer")).and_then(Value::as_str).unwrap_or("Invalid service"),"icon":null,"secret":reason,"digits":0,"algorithm":null,"period":null,"counter":null})
|
||||
}
|
||||
fn item_result(item: &Value, result: anyhow::Result<Value>) -> Value {
|
||||
result.unwrap_or_else(|_| {
|
||||
failure(
|
||||
item,
|
||||
"The imported account contains invalid OTP parameters.",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse_payload(payload: &str) -> Result<Vec<Value>, ApiError> {
|
||||
let payload = payload.trim_start_matches('\u{feff}').trim();
|
||||
if payload.starts_with("otpauth-migration://offline?data=") {
|
||||
return google(payload).map_err(|_| bad("Invalid Google Authenticator migration data."));
|
||||
}
|
||||
if let Ok(v) = serde_json::from_str::<Value>(payload) {
|
||||
if v.get("db").is_some() {
|
||||
if v["db"].is_string() && v["header"]["slots"].is_array() {
|
||||
return Err(bad("Encrypted migration data cannot be imported."));
|
||||
}
|
||||
let entries = v["db"]["entries"]
|
||||
.as_array()
|
||||
.ok_or_else(|| bad("Invalid Aegis migration data."))?;
|
||||
return Ok(entries.iter().map(|item| {
|
||||
let p = json!({"otp_type":item["type"],"account":item.get("name").filter(|v|!v.is_null()).unwrap_or(&item["issuer"]),"service":item["issuer"],"secret":item["info"]["secret"],"algorithm":item["info"]["algo"],"digits":item["info"]["digits"],"period":item["info"]["period"],"counter":item["info"]["counter"]});
|
||||
item_result(item,config(p))
|
||||
}).collect());
|
||||
}
|
||||
if v.get("schemaVersion").is_some() {
|
||||
if v.get("servicesEncrypted").is_some() {
|
||||
return Err(bad("Encrypted migration data cannot be imported."));
|
||||
}
|
||||
let entries = v["services"]
|
||||
.as_array()
|
||||
.ok_or_else(|| bad("Invalid 2FAS migration data."))?;
|
||||
return Ok(entries.iter().map(|item| {
|
||||
let p = json!({"otp_type":item["otp"]["tokenType"],"account":item["otp"].get("account").filter(|v|!v.is_null()).unwrap_or(&item["name"]),"service":item["name"],"secret":item["secret"],"algorithm":item["otp"]["algorithm"],"digits":item["otp"]["digits"],"period":item["otp"]["period"],"counter":item["otp"]["counter"]});
|
||||
item_result(item,config(p))
|
||||
}).collect());
|
||||
}
|
||||
if v.get("schema").is_some() && v["app"].as_str().is_some_and(|s| s.starts_with("2fauth_"))
|
||||
{
|
||||
let entries = v["data"]
|
||||
.as_array()
|
||||
.ok_or_else(|| bad("Invalid 2FAuth migration data."))?;
|
||||
return Ok(entries
|
||||
.iter()
|
||||
.map(|item| item_result(item, config(item.clone())))
|
||||
.collect());
|
||||
}
|
||||
if v.get("encrypted").is_some() && v.get("items").is_some() {
|
||||
if truthy(&v["encrypted"]) {
|
||||
return Err(bad("Encrypted migration data cannot be imported."));
|
||||
}
|
||||
let entries = v["items"]
|
||||
.as_array()
|
||||
.ok_or_else(|| bad("Invalid Bitwarden migration data."))?;
|
||||
return Ok(entries
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let parsed = (|| {
|
||||
let uri = item["login"]["totp"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing URI"))?;
|
||||
let mut c = if let Some(secret) = uri.strip_prefix("steam://") {
|
||||
OtpConfig {
|
||||
otp_type: "steamtotp".into(),
|
||||
secret: secret.into(),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
otp::parse_uri(uri)?
|
||||
};
|
||||
c.service = item["name"].as_str().map(str::to_owned);
|
||||
c.account = item["login"]["username"]
|
||||
.as_str()
|
||||
.or(item["name"].as_str())
|
||||
.unwrap_or("")
|
||||
.into();
|
||||
resource(c)
|
||||
})();
|
||||
item_result(item, parsed)
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
return Err(bad("Unsupported migration format."));
|
||||
}
|
||||
let lines: Vec<_> = payload
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if lines.is_empty()
|
||||
|| lines.iter().any(|s| {
|
||||
!["otpauth://totp/", "otpauth://hotp/", "otpauth://steam/"]
|
||||
.iter()
|
||||
.any(|prefix| s.to_ascii_lowercase().starts_with(prefix))
|
||||
})
|
||||
{
|
||||
return Err(bad("Unsupported migration format."));
|
||||
}
|
||||
Ok(lines
|
||||
.into_iter()
|
||||
.map(|uri| item_result(&json!({}), otp::parse_uri(uri).and_then(resource)))
|
||||
.collect())
|
||||
}
|
||||
|
||||
// Bounded protobuf wire decoder; unknown fields are skipped for forward compatibility.
|
||||
fn varint(bytes: &[u8], pos: &mut usize) -> anyhow::Result<u64> {
|
||||
let mut n = 0u64;
|
||||
for shift in (0..70).step_by(7) {
|
||||
let b = *bytes
|
||||
.get(*pos)
|
||||
.ok_or_else(|| anyhow::anyhow!("truncated protobuf"))?;
|
||||
*pos += 1;
|
||||
if shift == 63 && b > 1 {
|
||||
anyhow::bail!("varint overflow");
|
||||
}
|
||||
n |= u64::from(b & 127) << shift;
|
||||
if b & 128 == 0 {
|
||||
return Ok(n);
|
||||
}
|
||||
}
|
||||
anyhow::bail!("invalid varint")
|
||||
}
|
||||
enum Wire<'a> {
|
||||
Int(u64),
|
||||
Bytes(&'a [u8]),
|
||||
Other,
|
||||
}
|
||||
fn fields(bytes: &[u8]) -> anyhow::Result<Vec<(u64, Wire<'_>)>> {
|
||||
let mut pos = 0;
|
||||
let mut out = Vec::new();
|
||||
while pos < bytes.len() {
|
||||
let key = varint(bytes, &mut pos)?;
|
||||
if key >> 3 == 0 {
|
||||
anyhow::bail!("invalid field");
|
||||
}
|
||||
let val = match key & 7 {
|
||||
0 => Wire::Int(varint(bytes, &mut pos)?),
|
||||
1 | 5 => {
|
||||
let n = if key & 7 == 1 { 8 } else { 4 };
|
||||
pos = pos
|
||||
.checked_add(n)
|
||||
.filter(|p| *p <= bytes.len())
|
||||
.ok_or_else(|| anyhow::anyhow!("truncated field"))?;
|
||||
Wire::Other
|
||||
}
|
||||
2 => {
|
||||
let n = usize::try_from(varint(bytes, &mut pos)?)?;
|
||||
let end = pos
|
||||
.checked_add(n)
|
||||
.filter(|p| *p <= bytes.len())
|
||||
.ok_or_else(|| anyhow::anyhow!("truncated field"))?;
|
||||
let b = &bytes[pos..end];
|
||||
pos = end;
|
||||
Wire::Bytes(b)
|
||||
}
|
||||
_ => anyhow::bail!("invalid wire type"),
|
||||
};
|
||||
out.push((key >> 3, val));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
fn google(payload: &str) -> anyhow::Result<Vec<Value>> {
|
||||
let url = url::Url::parse(payload)?;
|
||||
let data = url
|
||||
.query_pairs()
|
||||
.find(|(k, _)| k == "data")
|
||||
.ok_or_else(|| anyhow::anyhow!("missing data"))?
|
||||
.1
|
||||
.replace(' ', "+");
|
||||
let bytes = STANDARD
|
||||
.decode(&data)
|
||||
.or_else(|_| STANDARD_NO_PAD.decode(&data))?;
|
||||
let mut out = Vec::new();
|
||||
for (number, wire) in fields(&bytes)? {
|
||||
if number != 1 {
|
||||
continue;
|
||||
}
|
||||
let Wire::Bytes(bytes) = wire else {
|
||||
anyhow::bail!("invalid OTP field");
|
||||
};
|
||||
let mut item = json!({"otp_type":"totp","account":"","service":null,"secret":"","algorithm":"sha1","digits":6,"period":30,"counter":null});
|
||||
let mut invalid_enum = false;
|
||||
for (number, wire) in fields(bytes)? {
|
||||
match (number, wire) {
|
||||
(1, Wire::Bytes(v)) => {
|
||||
item["secret"] = json!(data_encoding::BASE32_NOPAD.encode(v))
|
||||
}
|
||||
(2, Wire::Bytes(v)) => item["account"] = json!(std::str::from_utf8(v)?),
|
||||
(3, Wire::Bytes(v)) => item["service"] = json!(std::str::from_utf8(v)?),
|
||||
(4, Wire::Int(v)) => {
|
||||
item["algorithm"] = json!(match v {
|
||||
0 | 1 => "sha1",
|
||||
2 => "sha256",
|
||||
3 => "sha512",
|
||||
4 => "md5",
|
||||
_ => {
|
||||
invalid_enum = true;
|
||||
"invalid"
|
||||
}
|
||||
})
|
||||
}
|
||||
(5, Wire::Int(v)) => {
|
||||
item["digits"] = json!(match v {
|
||||
0 | 1 => 6,
|
||||
2 => 8,
|
||||
_ => {
|
||||
invalid_enum = true;
|
||||
0
|
||||
}
|
||||
})
|
||||
}
|
||||
(6, Wire::Int(v)) => {
|
||||
item["otp_type"] = json!(match v {
|
||||
1 => "hotp",
|
||||
0 | 2 => "totp",
|
||||
_ => {
|
||||
invalid_enum = true;
|
||||
"invalid"
|
||||
}
|
||||
})
|
||||
}
|
||||
(7, Wire::Int(v)) => item["counter"] = json!(v),
|
||||
(8, Wire::Int(v)) if v > 0 => item["period"] = json!(v),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let (Some(service), Some(account)) = (item["service"].as_str(), item["account"].as_str())
|
||||
&& !service.is_empty()
|
||||
{
|
||||
item["account"] = json!(account.replace(&format!("{service}:"), ""));
|
||||
}
|
||||
let result = if invalid_enum {
|
||||
Err(anyhow::anyhow!("invalid enum"))
|
||||
} else {
|
||||
config(item.clone())
|
||||
};
|
||||
out.push(item_result(&item, result));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn previews_each_supported_json_format() {
|
||||
let exports = [
|
||||
json!({"schema":1,"app":"2fauth_v8.0.3","data":[{"otp_type":"totp","account":"alice","service":"Example","secret":"MY","digits":6,"algorithm":"sha1","period":30,"counter":null}]}),
|
||||
json!({"db":{"entries":[{"type":"totp","name":"alice","issuer":"Example","info":{"secret":"MY","algo":"SHA1","digits":6,"period":30}}]}}),
|
||||
json!({"schemaVersion":2,"services":[{"name":"Example","secret":"MY","otp":{"account":"alice","tokenType":"TOTP","algorithm":"SHA1","digits":6,"period":30}}]}),
|
||||
json!({"encrypted":false,"items":[{"name":"Example","login":{"username":"alice","totp":"otpauth://totp/Old:old?issuer=Old&secret=MY"}}]}),
|
||||
];
|
||||
for export in exports {
|
||||
let result =
|
||||
parse_payload(&export.to_string()).unwrap_or_else(|_| panic!("format failed"));
|
||||
assert_eq!(result[0]["id"], 0);
|
||||
assert_eq!(result[0]["account"], "alice");
|
||||
assert_eq!(result[0]["service"], "Example");
|
||||
assert_eq!(result[0]["secret"], "MY======");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn plaintext_keeps_invalid_rows_and_steam() {
|
||||
let result = parse_payload("otpauth://totp/alice?secret=MY\notpauth://totp/bad?secret=INVALID1\notpauth://steam/user?secret=MY").unwrap_or_else(|_|panic!("plaintext"));
|
||||
assert_eq!(result.len(), 3);
|
||||
assert_eq!(result[1]["id"], -2);
|
||||
assert_eq!(result[2]["otp_type"], "steamtotp");
|
||||
}
|
||||
#[test]
|
||||
fn rejects_encrypted_and_unrecognized_payloads() {
|
||||
for v in [
|
||||
json!({"db":"encrypted","header":{"slots":[]}}),
|
||||
json!({"schemaVersion":2,"servicesEncrypted":"encrypted"}),
|
||||
json!({"encrypted":true,"items":[]}),
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_payload(&v.to_string()).err().unwrap().0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
}
|
||||
assert!(parse_payload("random text").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn google_protobuf_and_bounds() {
|
||||
// OTP: secret=f, name=Example:alice, issuer=Example, SHA1, six digits, TOTP.
|
||||
let entry = b"\x0a\x01f\x12\x0dExample:alice\x1a\x07Example\x20\x01\x28\x01\x30\x02";
|
||||
let mut payload = vec![10, entry.len() as u8];
|
||||
payload.extend_from_slice(entry);
|
||||
let result = google(&format!(
|
||||
"otpauth-migration://offline?data={}",
|
||||
STANDARD.encode(payload)
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(result[0]["account"], "alice");
|
||||
assert_eq!(result[0]["secret"], "MY======");
|
||||
assert!(fields(&[10, 255, 255, 255, 255, 255, 255, 255, 255, 255, 2]).is_err());
|
||||
assert!(fields(&[10, 25, 1]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod http_tests {
|
||||
use super::*;
|
||||
use axum::{body::Body, http::Request};
|
||||
use http_body_util::BodyExt;
|
||||
use tower::ServiceExt;
|
||||
#[tokio::test]
|
||||
async fn migration_hides_secrets_by_default_and_accepts_uploads() {
|
||||
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
let state = AppState {
|
||||
db,
|
||||
cipher: None,
|
||||
encrypt_db: std::sync::atomic::AtomicBool::new(false).into(),
|
||||
};
|
||||
let app = routes().with_state(state);
|
||||
let missing = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/twofaccounts/migration")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(missing.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let errors: Value =
|
||||
serde_json::from_slice(&missing.into_body().collect().await.unwrap().to_bytes())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
errors,
|
||||
json!({
|
||||
"message": "The payload field is required when file is not present. (and 1 more error)",
|
||||
"errors": {
|
||||
"payload": ["The payload field is required when file is not present."],
|
||||
"file": ["The file field is required when payload is not present."]
|
||||
}
|
||||
})
|
||||
);
|
||||
let payload = "otpauth://totp/alice?secret=MY";
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/twofaccounts/migration")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(json!({"payload":payload}).to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body: Value =
|
||||
serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes())
|
||||
.unwrap();
|
||||
assert!(body[0].get("secret").is_none());
|
||||
let content = format!(
|
||||
"--boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"export.txt\"\r\nContent-Type: text/plain\r\n\r\n{payload}\r\n--boundary\r\nContent-Disposition: form-data; name=\"withSecret\"\r\n\r\ntrue\r\n--boundary--\r\n"
|
||||
);
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/twofaccounts/migration")
|
||||
.header("content-type", "multipart/form-data; boundary=boundary")
|
||||
.body(Body::from(content))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body: Value =
|
||||
serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes())
|
||||
.unwrap();
|
||||
assert_eq!(body[0]["secret"], "MY======");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,838 @@
|
||||
//! Laravel session storage and SessionGuard recaller compatibility.
|
||||
//!
|
||||
//! Only scalar/array PHP serialization is accepted: no PHP code, objects,
|
||||
//! references or custom serializers are ever evaluated.
|
||||
use crate::{ApiError, AppState, auth::AuthUser};
|
||||
use axum::http::{HeaderMap, StatusCode, header};
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use serde_json::{Map, Value};
|
||||
use sha1::{Digest, Sha1};
|
||||
use sqlx::Row;
|
||||
use std::path::PathBuf;
|
||||
use subtle::ConstantTimeEq;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
const MAX_SESSION: usize = 1024 * 1024;
|
||||
const LIFETIME: i64 = 90 * 24 * 3600; // pinned config/session.php
|
||||
const GUARD: &str = "web-guard";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Config {
|
||||
driver: String,
|
||||
files: PathBuf,
|
||||
table: String,
|
||||
cookie: String,
|
||||
encrypt: bool,
|
||||
}
|
||||
impl Config {
|
||||
fn environment() -> Result<Self, ApiError> {
|
||||
let table = std::env::var("SESSION_TABLE").unwrap_or_else(|_| "sessions".into());
|
||||
if table.is_empty()
|
||||
|| !table
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'_')
|
||||
{
|
||||
return Err(unavailable());
|
||||
}
|
||||
let name = std::env::var("APP_NAME").unwrap_or_else(|_| "laravel".into());
|
||||
let mut slug = String::new();
|
||||
for c in name.to_lowercase().replace('@', " at ").chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
slug.push(c);
|
||||
} else if (c.is_whitespace() || c == '-' || c == '_')
|
||||
&& !slug.ends_with('_')
|
||||
&& !slug.is_empty()
|
||||
{
|
||||
slug.push('_');
|
||||
}
|
||||
}
|
||||
let slug = slug.trim_end_matches('_');
|
||||
Ok(Self {
|
||||
driver: std::env::var("SESSION_DRIVER").unwrap_or_else(|_| "file".into()),
|
||||
files: PathBuf::from(
|
||||
std::env::var("STORAGE_PATH").unwrap_or_else(|_| "storage".into()),
|
||||
)
|
||||
.join("framework/sessions"),
|
||||
table,
|
||||
cookie: std::env::var("SESSION_COOKIE").unwrap_or_else(|_| format!("{slug}_session")),
|
||||
encrypt: std::env::var("SESSION_ENCRYPT")
|
||||
.is_ok_and(|v| matches!(v.as_str(), "true" | "1" | "(true)")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LegacySession {
|
||||
pub id: Option<String>,
|
||||
pub user: Option<AuthUser>,
|
||||
pub csrf: String,
|
||||
pub remembered: bool,
|
||||
config: Config,
|
||||
data: Value,
|
||||
php: bool,
|
||||
}
|
||||
fn unavailable() -> ApiError {
|
||||
ApiError(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Authentication unavailable",
|
||||
)
|
||||
}
|
||||
fn guard_key(prefix: &str) -> String {
|
||||
format!(
|
||||
"{prefix}_{GUARD}_{:x}",
|
||||
Sha1::digest(b"Illuminate\\Auth\\SessionGuard")
|
||||
)
|
||||
}
|
||||
pub fn login_key() -> String {
|
||||
guard_key("login")
|
||||
}
|
||||
pub fn recaller_name() -> String {
|
||||
guard_key("remember")
|
||||
}
|
||||
pub fn cookie_names() -> Result<Vec<String>, ApiError> {
|
||||
Ok(vec![Config::environment()?.cookie, recaller_name()])
|
||||
}
|
||||
|
||||
fn cookie(headers: &HeaderMap, name: &str) -> Result<Option<String>, ()> {
|
||||
let mut found = None;
|
||||
for h in headers.get_all(header::COOKIE) {
|
||||
for part in h.to_str().map_err(|_| ())?.split(';') {
|
||||
if let Some((n, value)) = part.trim().split_once('=')
|
||||
&& n == name
|
||||
{
|
||||
if found.is_some() || value.len() > MAX_SESSION {
|
||||
return Err(());
|
||||
}
|
||||
found = Some(value.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
fn valid_id(id: &str) -> bool {
|
||||
id.len() == 40 && id.bytes().all(|b| b.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
pub async fn authenticate(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<Option<LegacySession>, ApiError> {
|
||||
authenticate_with(state, headers, Config::environment()?).await
|
||||
}
|
||||
async fn authenticate_with(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
config: Config,
|
||||
) -> Result<Option<LegacySession>, ApiError> {
|
||||
let Some(cipher) = state.cipher.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let encrypted = match cookie(headers, &config.cookie) {
|
||||
Ok(v) => v,
|
||||
Err(()) => return Ok(None),
|
||||
};
|
||||
let mut session = None;
|
||||
if let Some(encrypted) = encrypted
|
||||
&& let Ok(id) = cipher.decrypt_cookie(&config.cookie, &encrypted)
|
||||
&& valid_id(&id)
|
||||
&& let Some(payload) = read(state, &config, &id).await?
|
||||
{
|
||||
// EncryptedStore uses Encrypter::encrypt (serialize=true),
|
||||
// unlike encrypted cookies' encryptString protocol.
|
||||
let plaintext = if config.encrypt {
|
||||
cipher
|
||||
.decrypt(&payload)
|
||||
.ok()
|
||||
.and_then(|v| php_value(&v))
|
||||
.and_then(|v| v.as_str().map(str::to_owned))
|
||||
} else {
|
||||
Some(payload)
|
||||
};
|
||||
if let Some(plaintext) = plaintext
|
||||
&& let Some((data, php)) = decode(&plaintext)
|
||||
&& let Some(csrf) = data
|
||||
.get("_token")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty() && s.len() <= 256)
|
||||
{
|
||||
let mut found = LegacySession {
|
||||
id: Some(id),
|
||||
user: None,
|
||||
csrf: csrf.to_owned(),
|
||||
remembered: false,
|
||||
config: config.clone(),
|
||||
data,
|
||||
php,
|
||||
};
|
||||
if let Some(id) = found.data.get(login_key()).and_then(user_id) {
|
||||
found.user = user(state, id).await?;
|
||||
}
|
||||
session = Some(found);
|
||||
}
|
||||
}
|
||||
if session.as_ref().is_some_and(|s| s.user.is_some()) {
|
||||
return Ok(session);
|
||||
}
|
||||
// SessionGuard falls back to the recaller when its session has no identity.
|
||||
let recaller = recaller_name();
|
||||
let encrypted = match cookie(headers, &recaller) {
|
||||
Ok(v) => v,
|
||||
Err(()) => return Ok(None),
|
||||
};
|
||||
if let Some(plain) = encrypted.and_then(|v| cipher.decrypt_cookie(&recaller, &v).ok()) {
|
||||
let plain = php_value(&plain)
|
||||
.and_then(|v| v.as_str().map(str::to_owned))
|
||||
.unwrap_or(plain);
|
||||
let parts: Vec<_> = plain.split('|').collect();
|
||||
if parts.len() >= 3
|
||||
&& !parts[0].trim().is_empty()
|
||||
&& !parts[1].trim().is_empty()
|
||||
&& let Ok(id) = parts[0].parse::<i64>()
|
||||
{
|
||||
let row = sqlx::query("SELECT id,is_admin,remember_token FROM users WHERE id=?")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
if let Some(row) = row {
|
||||
let token: Option<String> = row.try_get("remember_token")?;
|
||||
if token.as_ref().is_some_and(|t| {
|
||||
!t.is_empty() && bool::from(t.as_bytes().ct_eq(parts[1].as_bytes()))
|
||||
}) {
|
||||
let mut found = session.unwrap_or_else(|| LegacySession {
|
||||
id: None,
|
||||
user: None,
|
||||
csrf: crate::auth::random(),
|
||||
remembered: true,
|
||||
config,
|
||||
data: Value::Object(Map::new()),
|
||||
php: false,
|
||||
});
|
||||
found.user = Some(AuthUser {
|
||||
id,
|
||||
is_admin: row.try_get("is_admin")?,
|
||||
});
|
||||
found.remembered = true;
|
||||
return Ok(Some(found));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(session)
|
||||
}
|
||||
fn user_id(v: &Value) -> Option<i64> {
|
||||
v.as_i64().or_else(|| v.as_str()?.parse().ok())
|
||||
}
|
||||
async fn user(state: &AppState, id: i64) -> Result<Option<AuthUser>, ApiError> {
|
||||
Ok(sqlx::query("SELECT id,is_admin FROM users WHERE id=?")
|
||||
.bind(id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.map(|r| AuthUser {
|
||||
id: r.get("id"),
|
||||
is_admin: r.get("is_admin"),
|
||||
}))
|
||||
}
|
||||
async fn read(state: &AppState, config: &Config, id: &str) -> Result<Option<String>, ApiError> {
|
||||
match config.driver.as_str() {
|
||||
"file" => {
|
||||
let path = config.files.join(id);
|
||||
let metadata = match tokio::fs::symlink_metadata(&path).await {
|
||||
Ok(m) => m,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(_) => return Err(unavailable()),
|
||||
};
|
||||
if !metadata.is_file()
|
||||
|| metadata.len() > MAX_SESSION as u64
|
||||
|| metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.elapsed().ok())
|
||||
.is_none_or(|age| age.as_secs() > LIFETIME as u64)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
tokio::fs::File::open(&path)
|
||||
.await
|
||||
.map_err(|_| unavailable())?
|
||||
.take(MAX_SESSION as u64 + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.await
|
||||
.map_err(|_| unavailable())?;
|
||||
Ok(if bytes.len() <= MAX_SESSION {
|
||||
String::from_utf8(bytes).ok()
|
||||
} else {
|
||||
None
|
||||
})
|
||||
}
|
||||
"database" => {
|
||||
let query = format!(
|
||||
"SELECT payload FROM \"{}\" WHERE id=? AND last_activity>=?",
|
||||
config.table
|
||||
);
|
||||
let payload: Option<String> = sqlx::query_scalar(&query)
|
||||
.bind(id)
|
||||
.bind(crate::auth::now() - LIFETIME)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
Ok(payload.and_then(|v| {
|
||||
if v.len() > MAX_SESSION * 2 {
|
||||
None
|
||||
} else {
|
||||
STANDARD
|
||||
.decode(v)
|
||||
.ok()
|
||||
.filter(|v| v.len() <= MAX_SESSION)
|
||||
.and_then(|v| String::from_utf8(v).ok())
|
||||
}
|
||||
}))
|
||||
}
|
||||
// Unsupported stores are never confused with the experimental Rust store.
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the original store on an authenticated request, preserving attributes.
|
||||
pub async fn touch(state: &AppState, session: &LegacySession) -> Result<(), ApiError> {
|
||||
let Some(id) = session.id.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let payload = if session.php {
|
||||
encode_php(&session.data)
|
||||
} else {
|
||||
serde_json::to_string(&session.data).map_err(|_| unavailable())?
|
||||
};
|
||||
let payload = if session.config.encrypt {
|
||||
state
|
||||
.cipher
|
||||
.as_ref()
|
||||
.ok_or_else(unavailable)?
|
||||
.encrypt(&encode_php(&Value::String(payload)))
|
||||
.map_err(|_| unavailable())?
|
||||
} else {
|
||||
payload
|
||||
};
|
||||
match session.config.driver.as_str() {
|
||||
"file" => tokio::fs::write(session.config.files.join(id), payload)
|
||||
.await
|
||||
.map_err(|_| unavailable())?,
|
||||
"database" => {
|
||||
sqlx::query(&format!(
|
||||
"UPDATE \"{}\" SET payload=?,last_activity=? WHERE id=?",
|
||||
session.config.table
|
||||
))
|
||||
.bind(STANDARD.encode(payload))
|
||||
.bind(crate::auth::now())
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub async fn destroy(state: &AppState, session: &LegacySession) -> Result<(), ApiError> {
|
||||
let Some(id) = session.id.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
match session.config.driver.as_str() {
|
||||
"file" => match tokio::fs::remove_file(session.config.files.join(id)).await {
|
||||
Ok(()) => (),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
|
||||
Err(_) => return Err(unavailable()),
|
||||
},
|
||||
"database" => {
|
||||
sqlx::query(&format!(
|
||||
"DELETE FROM \"{}\" WHERE id=?",
|
||||
session.config.table
|
||||
))
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub async fn revoke_remember(state: &AppState, id: i64) -> Result<(), ApiError> {
|
||||
sqlx::query("UPDATE users SET remember_token=? WHERE id=?")
|
||||
.bind(crate::auth::random())
|
||||
.bind(id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
pub fn csrf_valid(state: &AppState, headers: &HeaderMap, session: &LegacySession) -> bool {
|
||||
let token = headers
|
||||
.get("x-csrf-token")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::to_owned)
|
||||
.or_else(|| {
|
||||
let encrypted = headers.get("x-xsrf-token")?.to_str().ok()?;
|
||||
state
|
||||
.cipher
|
||||
.as_ref()?
|
||||
.decrypt_cookie("XSRF-TOKEN", encrypted)
|
||||
.ok()
|
||||
});
|
||||
token.is_some_and(|t| bool::from(t.as_bytes().ct_eq(session.csrf.as_bytes())))
|
||||
}
|
||||
|
||||
fn decode(input: &str) -> Option<(Value, bool)> {
|
||||
if input.len() > MAX_SESSION {
|
||||
return None;
|
||||
}
|
||||
if let Ok(v @ Value::Object(_)) = serde_json::from_str::<Value>(input) {
|
||||
return Some((v, false));
|
||||
}
|
||||
let value = php_value(input)?;
|
||||
if value.is_object() {
|
||||
Some((value, true))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn php_value(input: &str) -> Option<Value> {
|
||||
if input.len() > MAX_SESSION {
|
||||
return None;
|
||||
}
|
||||
let mut parser = Php {
|
||||
bytes: input.as_bytes(),
|
||||
pos: 0,
|
||||
nodes: 0,
|
||||
};
|
||||
let value = parser.value(0)?;
|
||||
if parser.pos == input.len() {
|
||||
Some(value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
struct Php<'a> {
|
||||
bytes: &'a [u8],
|
||||
pos: usize,
|
||||
nodes: usize,
|
||||
}
|
||||
impl Php<'_> {
|
||||
fn expect(&mut self, b: &[u8]) -> Option<()> {
|
||||
if self.bytes.get(self.pos..self.pos.checked_add(b.len())?)? != b {
|
||||
return None;
|
||||
}
|
||||
self.pos += b.len();
|
||||
Some(())
|
||||
}
|
||||
fn until(&mut self, end: u8) -> Option<&[u8]> {
|
||||
let start = self.pos;
|
||||
let offset = self.bytes.get(start..)?.iter().position(|b| *b == end)?;
|
||||
self.pos += offset + 1;
|
||||
self.bytes.get(start..start + offset)
|
||||
}
|
||||
fn number(&mut self, end: u8) -> Option<usize> {
|
||||
std::str::from_utf8(self.until(end)?).ok()?.parse().ok()
|
||||
}
|
||||
fn value(&mut self, depth: usize) -> Option<Value> {
|
||||
self.nodes += 1;
|
||||
if depth > 32 || self.nodes > 10000 {
|
||||
return None;
|
||||
}
|
||||
let kind = *self.bytes.get(self.pos)?;
|
||||
self.pos += 1;
|
||||
match kind {
|
||||
b'N' => {
|
||||
self.expect(b";")?;
|
||||
Some(Value::Null)
|
||||
}
|
||||
b'b' => {
|
||||
self.expect(b":")?;
|
||||
let b = self.until(b';')?;
|
||||
Some(Value::Bool(match b {
|
||||
b"1" => true,
|
||||
b"0" => false,
|
||||
_ => return None,
|
||||
}))
|
||||
}
|
||||
b'i' => {
|
||||
self.expect(b":")?;
|
||||
let n: i64 = std::str::from_utf8(self.until(b';')?).ok()?.parse().ok()?;
|
||||
Some(n.into())
|
||||
}
|
||||
b'd' => {
|
||||
self.expect(b":")?;
|
||||
let n: f64 = std::str::from_utf8(self.until(b';')?).ok()?.parse().ok()?;
|
||||
serde_json::Number::from_f64(n).map(Value::Number)
|
||||
}
|
||||
b's' => {
|
||||
self.expect(b":")?;
|
||||
let len = self.number(b':')?;
|
||||
self.expect(b"\"")?;
|
||||
let end = self.pos.checked_add(len)?;
|
||||
let s = std::str::from_utf8(self.bytes.get(self.pos..end)?)
|
||||
.ok()?
|
||||
.to_owned();
|
||||
self.pos = end;
|
||||
self.expect(b"\";")?;
|
||||
Some(Value::String(s))
|
||||
}
|
||||
b'a' => {
|
||||
self.expect(b":")?;
|
||||
let len = self.number(b':')?;
|
||||
if len > 10000 {
|
||||
return None;
|
||||
}
|
||||
self.expect(b"{")?;
|
||||
let mut map = Map::new();
|
||||
for _ in 0..len {
|
||||
let key = match self.value(depth + 1)? {
|
||||
Value::String(s) => s,
|
||||
Value::Number(n) if n.is_i64() => n.to_string(),
|
||||
_ => return None,
|
||||
};
|
||||
let value = self.value(depth + 1)?;
|
||||
if map.insert(key, value).is_some() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
self.expect(b"}")?;
|
||||
Some(Value::Object(map))
|
||||
}
|
||||
_ => None, // objects, references and resources are deliberately rejected
|
||||
}
|
||||
}
|
||||
}
|
||||
fn encode_php(value: &Value) -> String {
|
||||
match value {
|
||||
Value::Null => "N;".into(),
|
||||
Value::Bool(v) => format!("b:{};", u8::from(*v)),
|
||||
Value::Number(v) => format!("{}:{v};", if v.is_i64() || v.is_u64() { "i" } else { "d" }),
|
||||
Value::String(v) => format!("s:{}:\"{v}\";", v.len()),
|
||||
Value::Object(v) => format!(
|
||||
"a:{}:{{{}}}",
|
||||
v.len(),
|
||||
v.iter()
|
||||
.map(|(k, v)| format!("{}{}", encode_php(&Value::String(k.clone())), encode_php(v)))
|
||||
.collect::<String>()
|
||||
),
|
||||
Value::Array(v) => format!(
|
||||
"a:{}:{{{}}}",
|
||||
v.len(),
|
||||
v.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| format!("i:{i};{}", encode_php(v)))
|
||||
.collect::<String>()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Arc, atomic::AtomicBool};
|
||||
const KEY: &str = "0123456789abcdef0123456789abcdef";
|
||||
async fn setup(driver: &str) -> (AppState, tempfile::TempDir, Config) {
|
||||
let db = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||
sqlx::raw_sql("CREATE TABLE users(id INTEGER PRIMARY KEY,is_admin INTEGER,remember_token TEXT); INSERT INTO users VALUES(7,1,'remember-fixture'); CREATE TABLE sessions(id TEXT PRIMARY KEY,user_id INTEGER,ip_address TEXT,user_agent TEXT,payload TEXT,last_activity INTEGER);") .execute(&db).await.unwrap();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = Config {
|
||||
driver: driver.into(),
|
||||
files: dir.path().to_owned(),
|
||||
cookie: "2fauth_session".into(),
|
||||
table: "sessions".into(),
|
||||
encrypt: false,
|
||||
};
|
||||
let state = AppState {
|
||||
db,
|
||||
cipher: Some(Arc::new(
|
||||
crate::crypto::LaravelCipher::from_app_key(KEY).unwrap(),
|
||||
)),
|
||||
encrypt_db: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
(state, dir, config)
|
||||
}
|
||||
fn headers(state: &AppState, name: &str, value: &str) -> HeaderMap {
|
||||
let encrypted = state
|
||||
.cipher
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.encrypt_cookie(name, value)
|
||||
.unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
format!("{name}={encrypted}").parse().unwrap(),
|
||||
);
|
||||
headers
|
||||
}
|
||||
#[test]
|
||||
fn scalar_parser_is_bounded_and_never_accepts_objects_or_references() {
|
||||
for value in [
|
||||
"O:8:\"stdClass\":0:{}",
|
||||
"a:1:{s:1:\"x\";O:8:\"stdClass\":0:{}}",
|
||||
"a:1:{s:1:\"x\";R:1;}",
|
||||
"a:999999999:{}",
|
||||
"a:1:{s:99999999:\"x\";N;}",
|
||||
"a:0:{}trailing",
|
||||
"a:2:{s:1:\"x\";i:1;s:1:\"x\";i:2;}",
|
||||
] {
|
||||
assert!(decode(value).is_none(), "{value}");
|
||||
}
|
||||
let value = serde_json::json!({"_token":"Unicode 🔐", "null":null, "nested":{"yes":true,"number":-42}});
|
||||
assert_eq!(decode(&encode_php(&value)).unwrap().0, value);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn database_session_csrf_touch_expiration_and_destroy() {
|
||||
let (state, _dir, config) = setup("database").await;
|
||||
let id = "a".repeat(40);
|
||||
let data = serde_json::json!({login_key():7,"_token":"php-csrf-token","_flash":{"old":[],"new":[]}});
|
||||
sqlx::query("INSERT INTO sessions(id,payload,last_activity) VALUES(?,?,?)")
|
||||
.bind(&id)
|
||||
.bind(STANDARD.encode(data.to_string()))
|
||||
.bind(crate::auth::now())
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut h = headers(&state, &config.cookie, &id);
|
||||
let session = authenticate_with(&state, &h, config.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(session.user.as_ref().unwrap().id, 7);
|
||||
assert!(!csrf_valid(&state, &h, &session));
|
||||
h.insert("x-csrf-token", "php-csrf-token".parse().unwrap());
|
||||
assert!(csrf_valid(&state, &h, &session));
|
||||
h.remove("x-csrf-token");
|
||||
h.insert(
|
||||
"x-xsrf-token",
|
||||
state
|
||||
.cipher
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.encrypt_cookie("XSRF-TOKEN", "php-csrf-token")
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(csrf_valid(&state, &h, &session));
|
||||
sqlx::query("UPDATE sessions SET last_activity=0")
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
authenticate_with(&state, &h, config.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
touch(&state, &session).await.unwrap();
|
||||
assert!(
|
||||
authenticate_with(&state, &h, config.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
destroy(&state, &session).await.unwrap();
|
||||
assert!(
|
||||
authenticate_with(&state, &h, config)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn file_php_and_encrypted_sessions_preserve_attributes() {
|
||||
let (state, _dir, mut config) = setup("file").await;
|
||||
let id = "B".repeat(40);
|
||||
let data =
|
||||
serde_json::json!({login_key():"7","_token":"fixture-csrf","other":{"example":"🔐"}});
|
||||
for encrypt in [false, true] {
|
||||
config.encrypt = encrypt;
|
||||
let php = encode_php(&data);
|
||||
let payload = if encrypt {
|
||||
state
|
||||
.cipher
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.encrypt(&encode_php(&Value::String(php)))
|
||||
.unwrap()
|
||||
} else {
|
||||
php
|
||||
};
|
||||
tokio::fs::write(config.files.join(&id), payload)
|
||||
.await
|
||||
.unwrap();
|
||||
let h = headers(&state, &config.cookie, &id);
|
||||
let session = authenticate_with(&state, &h, config.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(session.user.as_ref().unwrap().id, 7);
|
||||
touch(&state, &session).await.unwrap();
|
||||
let written = tokio::fs::read_to_string(config.files.join(&id))
|
||||
.await
|
||||
.unwrap();
|
||||
let written = if encrypt {
|
||||
php_value(&state.cipher.as_ref().unwrap().decrypt(&written).unwrap())
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
} else {
|
||||
written
|
||||
};
|
||||
assert_eq!(decode(&written).unwrap().0, data);
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(config.files.join(&id))
|
||||
.unwrap()
|
||||
.set_times(std::fs::FileTimes::new().set_modified(std::time::UNIX_EPOCH))
|
||||
.unwrap();
|
||||
assert!(
|
||||
authenticate_with(&state, &h, config.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
destroy(&state, &session).await.unwrap();
|
||||
}
|
||||
let h = headers(&state, &config.cookie, "../not-a-session");
|
||||
assert!(
|
||||
authenticate_with(&state, &h, config)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn recaller_requires_exact_token_and_logout_rotation_revokes_it() {
|
||||
let (state, _dir, config) = setup("file").await;
|
||||
for value in [
|
||||
"7|wrong|hash",
|
||||
"7||hash",
|
||||
"7|remember-fixture",
|
||||
"8|remember-fixture|hash",
|
||||
] {
|
||||
let h = headers(&state, &recaller_name(), value);
|
||||
assert!(
|
||||
authenticate_with(&state, &h, config.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
let h = headers(&state, &recaller_name(), "7|remember-fixture|password-hash");
|
||||
let session = authenticate_with(&state, &h, config.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(session.user.as_ref().unwrap().id, 7);
|
||||
assert!(session.remembered);
|
||||
assert!(session.id.is_none());
|
||||
let mut duplicate = h.clone();
|
||||
duplicate.append(header::COOKIE, h[header::COOKIE].clone());
|
||||
assert!(
|
||||
authenticate_with(&state, &duplicate, config.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
revoke_remember(&state, 7).await.unwrap();
|
||||
assert!(
|
||||
authenticate_with(&state, &h, config)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn pinned_php_generated_fixtures_work_for_both_session_stores() {
|
||||
let fixtures: Vec<Value> =
|
||||
serde_json::from_str(include_str!("../tests/fixtures/laravel_sessions.json")).unwrap();
|
||||
for fixture in fixtures {
|
||||
assert_eq!(fixture["login_key"], login_key());
|
||||
assert_eq!(fixture["recaller_name"], recaller_name());
|
||||
for driver in ["file", "database"] {
|
||||
let (state, _dir, mut config) = setup(driver).await;
|
||||
config.encrypt = fixture["encrypted"].as_bool().unwrap();
|
||||
let id = fixture["session_id"].as_str().unwrap();
|
||||
if driver == "file" {
|
||||
tokio::fs::write(config.files.join(id), fixture["payload"].as_str().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
} else {
|
||||
sqlx::query("INSERT INTO sessions(id,payload,last_activity) VALUES(?,?,?)")
|
||||
.bind(id)
|
||||
.bind(fixture["database_payload"].as_str().unwrap())
|
||||
.bind(crate::auth::now())
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert(
|
||||
header::COOKIE,
|
||||
format!(
|
||||
"2fauth_session={}",
|
||||
fixture["session_cookie"].as_str().unwrap()
|
||||
)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
h.insert(
|
||||
"x-xsrf-token",
|
||||
fixture["csrf_cookie"].as_str().unwrap().parse().unwrap(),
|
||||
);
|
||||
let session = authenticate_with(&state, &h, config.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(session.user.as_ref().unwrap().id, 7);
|
||||
assert_eq!(session.csrf, fixture["csrf"].as_str().unwrap());
|
||||
assert!(csrf_valid(&state, &h, &session));
|
||||
assert_eq!(session.data["preferences"]["language"], "fr");
|
||||
if fixture["serialization"] == "php" {
|
||||
let original = fixture["payload"].as_str().unwrap();
|
||||
let original = if config.encrypt {
|
||||
php_value(&state.cipher.as_ref().unwrap().decrypt(original).unwrap())
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
} else {
|
||||
original.to_owned()
|
||||
};
|
||||
// Exact equality with PHP serialize() output verifies that
|
||||
// Laravel can read the Rust writer, including byte lengths.
|
||||
assert_eq!(encode_php(&session.data), original);
|
||||
}
|
||||
touch(&state, &session).await.unwrap();
|
||||
let reread = authenticate_with(&state, &h, config.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(reread.data, session.data);
|
||||
destroy(&state, &session).await.unwrap();
|
||||
assert!(
|
||||
authenticate_with(&state, &h, config.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
h.insert(
|
||||
header::COOKIE,
|
||||
format!(
|
||||
"{}={}",
|
||||
recaller_name(),
|
||||
fixture["recaller_cookie"].as_str().unwrap()
|
||||
)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
let remembered = authenticate_with(&state, &h, config)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(remembered.remembered);
|
||||
assert_eq!(remembered.user.unwrap().id, 7);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
pub mod accounts;
|
||||
pub mod api_limits;
|
||||
pub mod auth;
|
||||
pub mod auth_audit;
|
||||
pub mod cors;
|
||||
pub mod crypto;
|
||||
pub mod db;
|
||||
pub mod frontend;
|
||||
pub mod groups;
|
||||
pub mod icons;
|
||||
pub mod imports;
|
||||
pub mod laravel_sessions;
|
||||
pub mod mail;
|
||||
pub mod oauth_device;
|
||||
pub mod oauth_management;
|
||||
pub mod oauth_server;
|
||||
pub mod otp;
|
||||
pub mod passport;
|
||||
pub mod profile;
|
||||
pub mod proxy_auth;
|
||||
pub mod schema_validation;
|
||||
pub mod settings;
|
||||
pub mod sharing;
|
||||
pub mod sso;
|
||||
pub mod subdirectory;
|
||||
pub mod system;
|
||||
pub mod users;
|
||||
pub mod webauthn;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
http::{
|
||||
StatusCode,
|
||||
header::{CACHE_CONTROL, HeaderValue},
|
||||
},
|
||||
middleware,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use serde_json::json;
|
||||
use sqlx::SqlitePool;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub db: SqlitePool,
|
||||
pub cipher: Option<Arc<crypto::LaravelCipher>>,
|
||||
pub encrypt_db: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn encryption_enabled(&self) -> bool {
|
||||
self.encrypt_db.load(std::sync::atomic::Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ApiError(pub StatusCode, pub &'static str);
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
if self.0 == StatusCode::FORBIDDEN
|
||||
&& ["Forbidden", "This action is unauthorized."].contains(&self.1)
|
||||
{
|
||||
(
|
||||
self.0,
|
||||
Json(json!({"message":"forbidden","reason":"This action is unauthorized."})),
|
||||
)
|
||||
.into_response()
|
||||
} else {
|
||||
(self.0, Json(json!({"message": self.1}))).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
impl From<sqlx::Error> for ApiError {
|
||||
fn from(error: sqlx::Error) -> Self {
|
||||
// Never expose SQL values, hashes, secrets or encryption keys in HTTP responses/logs.
|
||||
tracing::error!(kind = ?std::mem::discriminant(&error), "database operation failed");
|
||||
Self(StatusCode::INTERNAL_SERVER_ERROR, "Server Error")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
router_at(state, &subdirectory::configured_prefix())
|
||||
}
|
||||
pub(crate) fn router_at(state: AppState, prefix: &str) -> Router {
|
||||
// Keep account reads/writes and encryption transitions mutually exclusive.
|
||||
// Each router owns one gate, shared by all clones used by axum.
|
||||
let gate = Arc::new(tokio::sync::RwLock::new(()));
|
||||
let protected = accounts::routes()
|
||||
.merge(groups::routes())
|
||||
.merge(imports::routes())
|
||||
.merge(sharing::routes())
|
||||
.merge(users::routes())
|
||||
.merge(icons::routes())
|
||||
.merge(settings::routes())
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
auth::require_auth,
|
||||
))
|
||||
.layer(middleware::from_fn(
|
||||
move |request: axum::extract::Request, next: middleware::Next| {
|
||||
let gate = gate.clone();
|
||||
async move {
|
||||
if (request.uri().path().starts_with("/settings")
|
||||
|| request.uri().path().starts_with("/api/v1/settings"))
|
||||
&& !matches!(
|
||||
*request.method(),
|
||||
axum::http::Method::GET | axum::http::Method::HEAD
|
||||
)
|
||||
{
|
||||
let _guard = gate.write().await;
|
||||
next.run(request).await
|
||||
} else {
|
||||
let _guard = gate.read().await;
|
||||
next.run(request).await
|
||||
}
|
||||
}
|
||||
},
|
||||
));
|
||||
let app = Router::new()
|
||||
.nest(
|
||||
"/api/v1",
|
||||
protected
|
||||
.route(
|
||||
"/user/name",
|
||||
get(|| async {
|
||||
(
|
||||
[("deprecation", "Tue, 21 Mar 2023 00:00:00 GMT")],
|
||||
Json(json!({"deprecation":true})),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.fallback(|| async {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"message":"unknowkn endpoint"})),
|
||||
)
|
||||
})
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
api_limits::handle,
|
||||
)),
|
||||
)
|
||||
.merge(auth::routes())
|
||||
.merge(profile::routes(state.clone()))
|
||||
.merge(passport::routes(state.clone()))
|
||||
.merge(oauth_server::routes(state.clone()))
|
||||
.merge(oauth_management::routes(state.clone()))
|
||||
.merge(oauth_device::routes(state.clone()))
|
||||
.merge(mail::routes(state.clone()))
|
||||
.merge(sso::routes())
|
||||
.merge(webauthn::routes(state.clone()))
|
||||
.merge(system::routes().route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
auth::require_auth,
|
||||
)))
|
||||
.route("/up", get(frontend::health))
|
||||
.merge(frontend::routes())
|
||||
.merge(icons::public_routes())
|
||||
.fallback(frontend::landing)
|
||||
.layer(axum::extract::DefaultBodyLimit::max(2 * 1024 * 1024))
|
||||
.layer(middleware::from_fn(cache_policy))
|
||||
.layer(middleware::from_fn(cors::handle))
|
||||
.with_state(state);
|
||||
subdirectory::mount(app, prefix)
|
||||
}
|
||||
|
||||
async fn cache_policy(
|
||||
request: axum::extract::Request,
|
||||
next: middleware::Next,
|
||||
) -> axum::response::Response {
|
||||
let oauth = request.uri().path().starts_with("/oauth/");
|
||||
let mut response = next.run(request).await;
|
||||
if oauth {
|
||||
match response
|
||||
.headers()
|
||||
.get(CACHE_CONTROL)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
None => {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache, private"));
|
||||
}
|
||||
Some("no-store") => {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CACHE_CONTROL, HeaderValue::from_static("no-store, private"));
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
} else {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
}
|
||||
response
|
||||
}
|
||||
+493
@@ -0,0 +1,493 @@
|
||||
//! Notification mail transport. Configuration and transport errors never echo credentials.
|
||||
use crate::{AppState, auth::AuthUser};
|
||||
use axum::{
|
||||
Extension, Json, Router,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::post,
|
||||
};
|
||||
use lettre::{
|
||||
AsyncSendmailTransport, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
|
||||
message::{Mailbox, header::ContentType},
|
||||
transport::smtp::{
|
||||
authentication::Credentials,
|
||||
client::{Tls, TlsParameters},
|
||||
extension::ClientId,
|
||||
},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MailError {
|
||||
#[error("Invalid mail configuration: {0}")]
|
||||
Configuration(&'static str),
|
||||
#[error("The mail server did not accept the message.")]
|
||||
Delivery,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct Config {
|
||||
driver: String,
|
||||
from: String,
|
||||
from_name: String,
|
||||
host: String,
|
||||
port: u16,
|
||||
encryption: String,
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
verify_peer: bool,
|
||||
ehlo: Option<String>,
|
||||
sendmail: String,
|
||||
}
|
||||
impl Config {
|
||||
fn from_env() -> Result<Self, MailError> {
|
||||
Self::read(|name| std::env::var(name).ok())
|
||||
}
|
||||
fn read(get: impl Fn(&str) -> Option<String>) -> Result<Self, MailError> {
|
||||
let value = |key, default: &str| get(key).unwrap_or_else(|| default.to_owned());
|
||||
let mut config = Self {
|
||||
driver: get("MAIL_MAILER")
|
||||
.or_else(|| get("MAIL_DRIVER"))
|
||||
.unwrap_or_else(|| "smtp".into()),
|
||||
from: value("MAIL_FROM_ADDRESS", "hello@example.com"),
|
||||
from_name: get("MAIL_FROM_NAME")
|
||||
.or_else(|| get("APP_NAME"))
|
||||
.unwrap_or_else(|| "Laravel".into()),
|
||||
host: value("MAIL_HOST", "smtp.mailgun.org"),
|
||||
port: value("MAIL_PORT", "587").parse().unwrap_or(0),
|
||||
encryption: value("MAIL_ENCRYPTION", "tls"),
|
||||
username: get("MAIL_USERNAME").filter(|s| !s.is_empty() && s != "null"),
|
||||
password: get("MAIL_PASSWORD"),
|
||||
verify_peer: !matches!(
|
||||
value("MAIL_VERIFY_SSL_PEER", "true").as_str(),
|
||||
"false" | "0" | "(false)"
|
||||
),
|
||||
ehlo: get("MAIL_EHLO_DOMAIN"),
|
||||
sendmail: value("MAIL_SENDMAIL_PATH", "/usr/sbin/sendmail -bs -i"),
|
||||
};
|
||||
if let Some(raw) = get("MAIL_URL").filter(|s| config.driver == "smtp" && !s.is_empty()) {
|
||||
let url = url::Url::parse(&raw)
|
||||
.map_err(|_| MailError::Configuration("MAIL_URL is invalid"))?;
|
||||
config.host = url
|
||||
.host_str()
|
||||
.ok_or(MailError::Configuration("MAIL_URL requires a host"))?
|
||||
.into();
|
||||
config.encryption = match url.scheme() {
|
||||
"smtps" => "ssl",
|
||||
"smtp" => config.encryption.as_str(),
|
||||
_ => return Err(MailError::Configuration("MAIL_URL must use smtp or smtps")),
|
||||
}
|
||||
.into();
|
||||
config.port = url
|
||||
.port()
|
||||
.unwrap_or(if url.scheme() == "smtps" { 465 } else { 587 });
|
||||
if !url.username().is_empty() {
|
||||
config.username = Some(
|
||||
percent_encoding::percent_decode_str(url.username())
|
||||
.decode_utf8()
|
||||
.map_err(|_| MailError::Configuration("MAIL_URL user is invalid"))?
|
||||
.into_owned(),
|
||||
);
|
||||
}
|
||||
if let Some(password) = url.password() {
|
||||
config.password = Some(
|
||||
percent_encoding::percent_decode_str(password)
|
||||
.decode_utf8()
|
||||
.map_err(|_| MailError::Configuration("MAIL_URL password is invalid"))?
|
||||
.into_owned(),
|
||||
);
|
||||
}
|
||||
for (key, value) in url.query_pairs() {
|
||||
match key.as_ref() {
|
||||
"encryption" => config.encryption = value.into_owned(),
|
||||
"verify_peer" => config.verify_peer = !matches!(value.as_ref(), "false" | "0"),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
if config.ehlo.is_none() {
|
||||
config.ehlo = get("APP_URL")
|
||||
.and_then(|s| url::Url::parse(&s).ok())
|
||||
.and_then(|u| u.host_str().map(str::to_owned));
|
||||
}
|
||||
if config.driver == "smtp" {
|
||||
if config.host.is_empty() || config.host.contains(['\r', '\n']) || config.port == 0 {
|
||||
return Err(MailError::Configuration("SMTP host or port is invalid"));
|
||||
}
|
||||
if !matches!(
|
||||
config.encryption.as_str(),
|
||||
"tls" | "starttls" | "ssl" | "smtps" | "" | "null" | "none"
|
||||
) {
|
||||
return Err(MailError::Configuration(
|
||||
"MAIL_ENCRYPTION must be tls, ssl or null",
|
||||
));
|
||||
}
|
||||
if config
|
||||
.ehlo
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.is_empty() || s.contains(['\r', '\n']))
|
||||
{
|
||||
return Err(MailError::Configuration("MAIL_EHLO_DOMAIN is invalid"));
|
||||
}
|
||||
}
|
||||
if !matches!(config.driver.as_str(), "smtp" | "sendmail" | "log") {
|
||||
return Err(MailError::Configuration(
|
||||
"The configured mail transport is not supported",
|
||||
));
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
fn message(&self, to: &str, subject: &str, body: &str) -> Result<Message, MailError> {
|
||||
let from = Mailbox::new(
|
||||
Some(self.from_name.clone()),
|
||||
self.from
|
||||
.parse()
|
||||
.map_err(|_| MailError::Configuration("MAIL_FROM_ADDRESS is invalid"))?,
|
||||
);
|
||||
let recipient: Mailbox = to
|
||||
.parse()
|
||||
.map_err(|_| MailError::Configuration("The recipient email address is invalid"))?;
|
||||
Message::builder()
|
||||
.from(from)
|
||||
.to(recipient)
|
||||
.subject(subject)
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
.body(body.to_owned())
|
||||
.map_err(|_| MailError::Configuration("The notification message is invalid"))
|
||||
}
|
||||
fn smtp(&self) -> Result<AsyncSmtpTransport<Tokio1Executor>, MailError> {
|
||||
let mut builder = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&self.host)
|
||||
.port(self.port)
|
||||
.timeout(Some(Duration::from_secs(30)));
|
||||
let tls = match self.encryption.as_str() {
|
||||
"tls" | "starttls" | "ssl" | "smtps" => {
|
||||
let params = TlsParameters::builder(self.host.clone())
|
||||
.dangerous_accept_invalid_certs(!self.verify_peer)
|
||||
.build()
|
||||
.map_err(|_| MailError::Configuration("SMTP TLS parameters are invalid"))?;
|
||||
if matches!(self.encryption.as_str(), "ssl" | "smtps") {
|
||||
Tls::Wrapper(params)
|
||||
} else {
|
||||
Tls::Required(params)
|
||||
}
|
||||
}
|
||||
_ => Tls::None,
|
||||
};
|
||||
builder = builder.tls(tls);
|
||||
if let Some(name) = &self.ehlo {
|
||||
builder = builder.hello_name(ClientId::Domain(name.clone()));
|
||||
}
|
||||
if let Some(username) = &self.username {
|
||||
builder = builder.credentials(Credentials::new(
|
||||
username.clone(),
|
||||
self.password.clone().unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
Ok(builder.build())
|
||||
}
|
||||
async fn send(&self, to: &str, subject: &str, body: &str) -> Result<(), MailError> {
|
||||
let message = self.message(to, subject, body)?;
|
||||
match self.driver.as_str() {
|
||||
"smtp" => {
|
||||
self.smtp()?
|
||||
.send(message)
|
||||
.await
|
||||
.map_err(|_| MailError::Delivery)?;
|
||||
}
|
||||
"sendmail" => {
|
||||
// Never invoke a shell. Laravel's usual -bs/-i flags select modes;
|
||||
// lettre supplies its own safe submission arguments and envelope.
|
||||
let mut words = self.sendmail.split_ascii_whitespace();
|
||||
let binary =
|
||||
words
|
||||
.next()
|
||||
.filter(|s| s.starts_with('/'))
|
||||
.ok_or(MailError::Configuration(
|
||||
"MAIL_SENDMAIL_PATH must name an absolute executable",
|
||||
))?;
|
||||
if words.any(|s| !matches!(s, "-bs" | "-i" | "-t" | "-oi")) {
|
||||
return Err(MailError::Configuration(
|
||||
"MAIL_SENDMAIL_PATH contains unsupported arguments",
|
||||
));
|
||||
}
|
||||
let transport = AsyncSendmailTransport::<Tokio1Executor>::new_with_command(binary);
|
||||
tokio::time::timeout(Duration::from_secs(30), transport.send(message))
|
||||
.await
|
||||
.map_err(|_| MailError::Delivery)?
|
||||
.map_err(|_| MailError::Delivery)?;
|
||||
}
|
||||
"log" => {
|
||||
tracing::info!(recipient=%to,subject=%subject,body=%body,"Mail notification (explicit log transport)");
|
||||
}
|
||||
_ => {
|
||||
return Err(MailError::Configuration(
|
||||
"The configured mail transport is not supported",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub async fn password_reset(email: &str, url: &str) -> Result<(), MailError> {
|
||||
Config::from_env()?.send(email,"2FAuth Password Reset",&format!("You are receiving this email because you requested a password reset for your 2FAuth account.\n\nReset password: {url}\n\nThis password reset link will expire in 60 minutes.\nIf you did not request a password reset, no further action is required.")).await
|
||||
}
|
||||
pub async fn webauthn_recovery(email: &str, url: &str) -> Result<(), MailError> {
|
||||
Config::from_env()?.send(email, "Account Recovery Notification", &format!("You are receiving this email because we received an account recovery request for your account.\n\nRecover Account: {url}\n\nThis recovery link will expire in 60 minutes.\nIf you did not request an account recovery, no further action is required.")).await
|
||||
}
|
||||
/// Root web route protected by the normal authenticated/CSRF middleware.
|
||||
pub fn routes(state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/system/test-email", post(test_email))
|
||||
.route_layer(axum::middleware::from_fn_with_state(
|
||||
state,
|
||||
crate::auth::require_auth,
|
||||
))
|
||||
}
|
||||
async fn test_email(State(s): State<AppState>, Extension(u): Extension<AuthUser>) -> Response {
|
||||
if !u.is_admin {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({"message":"This action is unauthorized."})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let email = match sqlx::query_scalar::<_, String>("SELECT email FROM users WHERE id=?")
|
||||
.bind(u.id)
|
||||
.fetch_one(&s.db)
|
||||
.await
|
||||
{
|
||||
Ok(email) => email,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"message":"Server Error"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let result=match Config::from_env(){Ok(config)=>config.send(&email,"2FAuth test email","Hello!\n\nYou are receiving this email because you requested a test email to validate the email settings of your 2FAuth instance.\n\nGood news, it works :)").await,Err(e)=>Err(e)};
|
||||
match result {
|
||||
Ok(()) => Json(json!({"message":"Ok"})).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::FAILED_DEPENDENCY,
|
||||
Json(json!({"message":"Email sending failed","details":e.to_string()})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
|
||||
net::TcpListener,
|
||||
};
|
||||
fn config(entries: &[(&str, &str)]) -> Result<Config, MailError> {
|
||||
Config::read(|key| {
|
||||
entries
|
||||
.iter()
|
||||
.find(|(k, _)| *k == key)
|
||||
.map(|(_, v)| v.to_string())
|
||||
})
|
||||
}
|
||||
async fn smtp_fixture(reject: bool) -> (u16, tokio::task::JoinHandle<String>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let task = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let mut stream = BufReader::new(stream);
|
||||
stream
|
||||
.get_mut()
|
||||
.write_all(b"220 localhost ESMTP fixture\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut captured = String::new();
|
||||
let mut data = false;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if stream.read_line(&mut line).await.unwrap() == 0 {
|
||||
break;
|
||||
}
|
||||
if data {
|
||||
if line == ".\r\n" {
|
||||
data = false;
|
||||
stream.get_mut().write_all(b"250 queued\r\n").await.unwrap();
|
||||
} else {
|
||||
captured.push_str(&line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let reply = if line.starts_with("EHLO") {
|
||||
"250-localhost\r\n250 8BITMIME\r\n"
|
||||
} else if line.starts_with("MAIL FROM") && reject {
|
||||
"550 rejected\r\n"
|
||||
} else if line.starts_with("DATA") {
|
||||
data = true;
|
||||
"354 Send message\r\n"
|
||||
} else if line.starts_with("QUIT") {
|
||||
stream.get_mut().write_all(b"221 bye\r\n").await.unwrap();
|
||||
break;
|
||||
} else {
|
||||
"250 OK\r\n"
|
||||
};
|
||||
stream.get_mut().write_all(reply.as_bytes()).await.unwrap();
|
||||
}
|
||||
captured
|
||||
});
|
||||
(port, task)
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn local_smtp_delivers_message_and_reset_link() {
|
||||
let (port, server) = smtp_fixture(false).await;
|
||||
let c = config(&[
|
||||
("MAIL_HOST", "127.0.0.1"),
|
||||
("MAIL_PORT", &port.to_string()),
|
||||
("MAIL_ENCRYPTION", "null"),
|
||||
])
|
||||
.unwrap();
|
||||
c.send(
|
||||
"user@example.com",
|
||||
"Reset test",
|
||||
"Reset: https://example.com/user/password/reset?token=test-token",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let message = tokio::time::timeout(Duration::from_secs(5), server)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(message.contains("To: user@example.com"));
|
||||
assert!(message.contains("Subject: Reset test"));
|
||||
assert!(message.contains("token=test-token"));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn rejected_smtp_is_an_error_without_echoing_server_or_secrets() {
|
||||
let (port, server) = smtp_fixture(true).await;
|
||||
let c = config(&[
|
||||
("MAIL_HOST", "127.0.0.1"),
|
||||
("MAIL_PORT", &port.to_string()),
|
||||
("MAIL_ENCRYPTION", "null"),
|
||||
])
|
||||
.unwrap();
|
||||
let err = c
|
||||
.send("user@example.com", "Reset test", "secret-token")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"The mail server did not accept the message."
|
||||
);
|
||||
server.abort();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn starttls_requirement_cannot_silently_downgrade() {
|
||||
let (port, server) = smtp_fixture(false).await;
|
||||
let c = config(&[
|
||||
("MAIL_HOST", "127.0.0.1"),
|
||||
("MAIL_PORT", &port.to_string()),
|
||||
("MAIL_ENCRYPTION", "tls"),
|
||||
])
|
||||
.unwrap();
|
||||
assert!(c.send("user@example.com", "Test", "secret").await.is_err());
|
||||
server.abort();
|
||||
}
|
||||
#[test]
|
||||
fn configuration_validates_tls_and_url_credentials() {
|
||||
assert!(config(&[("MAIL_ENCRYPTION", "invalid")]).is_err());
|
||||
assert!(config(&[("MAIL_PORT", "zero")]).is_err());
|
||||
assert!(config(&[("MAIL_MAILER", "array")]).is_err());
|
||||
let c = config(&[(
|
||||
"MAIL_URL",
|
||||
"smtps://user:p%40ss@localhost:2465?verify_peer=false",
|
||||
)])
|
||||
.unwrap();
|
||||
assert_eq!(c.port, 2465);
|
||||
assert_eq!(c.encryption, "ssl");
|
||||
assert_eq!(c.password.as_deref(), Some("p@ss"));
|
||||
assert!(!c.verify_peer);
|
||||
assert!(c.smtp().is_ok());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn sendmail_failure_is_reported_and_shell_arguments_are_rejected() {
|
||||
let c = config(&[
|
||||
("MAIL_MAILER", "sendmail"),
|
||||
("MAIL_SENDMAIL_PATH", "/nonexistent/sendmail -bs -i"),
|
||||
])
|
||||
.unwrap();
|
||||
assert!(c.send("user@example.com", "Test", "message").await.is_err());
|
||||
let c = config(&[
|
||||
("MAIL_MAILER", "sendmail"),
|
||||
(
|
||||
"MAIL_SENDMAIL_PATH",
|
||||
"/usr/sbin/sendmail ; touch /tmp/unwanted",
|
||||
),
|
||||
])
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
c.send("user@example.com", "Test", "message").await,
|
||||
Err(MailError::Configuration(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a notification through the configured transport without exposing transport internals.
|
||||
pub async fn notify(email: &str, subject: &str, body: &str) -> Result<(), MailError> {
|
||||
Config::from_env()?.send(email, subject, body).await
|
||||
}
|
||||
/// Source translation strings with English fallback and Laravel-style named replacements.
|
||||
pub fn translate(locale: &str, key: &str, replacements: &[(&str, &str)]) -> String {
|
||||
static MESSAGES: std::sync::LazyLock<serde_json::Value> = std::sync::LazyLock::new(|| {
|
||||
serde_json::from_str(include_str!("mail_messages.json")).expect("bundled mail translations")
|
||||
});
|
||||
let normalized = locale.replace('_', "-");
|
||||
let language = normalized.split('-').next().unwrap_or("en");
|
||||
let locale_messages = MESSAGES.get(&normalized).or_else(|| {
|
||||
MESSAGES.as_object().and_then(|messages| {
|
||||
messages
|
||||
.iter()
|
||||
.find(|(name, _)| name.eq_ignore_ascii_case(&normalized))
|
||||
.map(|(_, messages)| messages)
|
||||
})
|
||||
});
|
||||
let mut message = locale_messages
|
||||
.and_then(|m| m.get(key))
|
||||
.or_else(|| MESSAGES.get(language).and_then(|m| m.get(key)))
|
||||
.or_else(|| MESSAGES["en"].get(key))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or(key)
|
||||
.to_owned();
|
||||
for (name, value) in replacements {
|
||||
message = message.replace(&format!(":{name}"), value);
|
||||
}
|
||||
message
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod translation_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn region_locales_and_named_values_use_source_translations() {
|
||||
let source: serde_json::Value =
|
||||
serde_json::from_str(include_str!("mail_messages.json")).unwrap();
|
||||
assert_eq!(
|
||||
translate("pt_BR", "message.notifications.failed_login.subject", &[]),
|
||||
source["pt-BR"]["message.notifications.failed_login.subject"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
translate(
|
||||
"fr",
|
||||
"message.notifications.hello_user",
|
||||
&[("username", "Alice")]
|
||||
)
|
||||
.contains("Alice")
|
||||
);
|
||||
assert_eq!(
|
||||
translate("unknown", "message.notifications.failed_login.subject", &[]),
|
||||
"Failed login to 2FAuth"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,794 @@
|
||||
{
|
||||
"pl": {
|
||||
"label.device": "Device",
|
||||
"label.ip_address": "IP Address",
|
||||
"label.time": "Time",
|
||||
"link.go_to_2fauth_host": "Go to 2FAuth",
|
||||
"message.browser_on_platform": ":browser on :platform",
|
||||
"message.notifications.failed_login.connection_details": "Here are the details of this connection attempt",
|
||||
"message.notifications.failed_login.recommandations": "Jeśli to Ty, możesz zignorować ten alert. Jeśli kolejne próby nie powiodą się, powinieneś skontaktować się z administratorem 2FA w celu sprawdzenia ustawień zabezpieczeń i podjęcia działań przeciwko temu atakującemu.",
|
||||
"message.notifications.failed_login.resume": "There has been a failed login attempt to your 2FAuth account.",
|
||||
"message.notifications.failed_login.subject": "Failed login to 2FAuth",
|
||||
"message.notifications.hello_user": "Hello :username,",
|
||||
"message.notifications.hello": "Witaj",
|
||||
"message.notifications.new_device.connection_details": "Here are the details of this connection",
|
||||
"message.notifications.new_device.recommandations": "If this was you, you can ignore this alert. If you suspect any suspicious activity on your account, please change your password.",
|
||||
"message.notifications.new_device.resume": "A new device has just connected to your 2FAuth account.",
|
||||
"message.notifications.new_device.subject": "Connection to 2FAuth from a new device",
|
||||
"message.notifications.twofaccount_ownership_transferred.check_shares": "As this 2FA account may have been shared with other users before this ownership transfer, you should review the account's sharing settings to ensure they are still appropriate.",
|
||||
"message.notifications.twofaccount_ownership_transferred.resume": "We wanted to let you know that :actor transferred ownership of 2FA account :twofaccount to you. You are now the owner of this 2FA account and have full control over it.",
|
||||
"message.notifications.twofaccount_ownership_transferred.subject": "Ownership transfer on a 2FA account",
|
||||
"message.notifications.twofaccount_share_revoked.cannot_use_it_to_generate_otp": "You can no longer view this 2FA account or use it to generate One-Time Passwords.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_all_users": "We wanted to let you know that :actor has revoked sharing of 2FA account :twofaccount for all users.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_user": "We wanted to let you know that :actor has revoked your access to 2FA account :twofaccount.",
|
||||
"message.notifications.twofaccount_share_revoked.subject": "2FA account sharing revoked",
|
||||
"message.notifications.twofaccount_shared.resume_all_users": "We wanted to let you know that :actor shared 2FA account :twofaccount with all users.",
|
||||
"message.notifications.twofaccount_shared.resume_user": "We wanted to let you know that :actor shared 2FA account :twofaccount with you.",
|
||||
"message.notifications.twofaccount_shared.subject": "A 2FA account has been shared with you",
|
||||
"message.notifications.twofaccount_shared.use_it_to_generate_otp": "You can now view this 2FA account and use it to generate One-Time Passwords as long as the share is active.",
|
||||
"message.notifications.regards": "Regards",
|
||||
"message.notifications.reset_password.subject": "2FAuth Password Reset",
|
||||
"message.notifications.reset_password.reason": "You are receiving this email because you requested a password reset for your 2FAuth account.",
|
||||
"message.notifications.reset_password.expire_in_x_minutes": "This password reset link will expire in :count minutes.",
|
||||
"message.notifications.reset_password.no_action_required": "If you did not request a password reset, no further action is required.",
|
||||
"message.notifications.test_email_settings.reason": "You are receiving this email because you requested a test email to validate the email settings of your 2FAuth instance.",
|
||||
"message.notifications.test_email_settings.subject": "2FAuth test email",
|
||||
"message.notifications.test_email_settings.success": "Good news, it works :)"
|
||||
},
|
||||
"it": {
|
||||
"label.device": "Dispositivo",
|
||||
"label.ip_address": "Indirizzo IP",
|
||||
"label.time": "Tempo",
|
||||
"link.go_to_2fauth_host": "Go to 2FAuth",
|
||||
"message.browser_on_platform": ":browser on :platform",
|
||||
"message.notifications.failed_login.connection_details": "Here are the details of this connection attempt",
|
||||
"message.notifications.failed_login.recommandations": "If this was you, you can ignore this alert. If further attempts fail, you should contact the 2FAuth administrator to review security settings and take action against this attacker.",
|
||||
"message.notifications.failed_login.resume": "There has been a failed login attempt to your 2FAuth account.",
|
||||
"message.notifications.failed_login.subject": "Failed login to 2FAuth",
|
||||
"message.notifications.hello_user": "Hello :username,",
|
||||
"message.notifications.hello": "Hello",
|
||||
"message.notifications.new_device.connection_details": "Here are the details of this connection",
|
||||
"message.notifications.new_device.recommandations": "If this was you, you can ignore this alert. If you suspect any suspicious activity on your account, please change your password.",
|
||||
"message.notifications.new_device.resume": "A new device has just connected to your 2FAuth account.",
|
||||
"message.notifications.new_device.subject": "Connection to 2FAuth from a new device",
|
||||
"message.notifications.twofaccount_ownership_transferred.check_shares": "As this 2FA account may have been shared with other users before this ownership transfer, you should review the account's sharing settings to ensure they are still appropriate.",
|
||||
"message.notifications.twofaccount_ownership_transferred.resume": "We wanted to let you know that :actor transferred ownership of 2FA account :twofaccount to you. You are now the owner of this 2FA account and have full control over it.",
|
||||
"message.notifications.twofaccount_ownership_transferred.subject": "Ownership transfer on a 2FA account",
|
||||
"message.notifications.twofaccount_share_revoked.cannot_use_it_to_generate_otp": "You can no longer view this 2FA account or use it to generate One-Time Passwords.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_all_users": "We wanted to let you know that :actor has revoked sharing of 2FA account :twofaccount for all users.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_user": "We wanted to let you know that :actor has revoked your access to 2FA account :twofaccount.",
|
||||
"message.notifications.twofaccount_share_revoked.subject": "2FA account sharing revoked",
|
||||
"message.notifications.twofaccount_shared.resume_all_users": "We wanted to let you know that :actor shared 2FA account :twofaccount with all users.",
|
||||
"message.notifications.twofaccount_shared.resume_user": "We wanted to let you know that :actor shared 2FA account :twofaccount with you.",
|
||||
"message.notifications.twofaccount_shared.subject": "A 2FA account has been shared with you",
|
||||
"message.notifications.twofaccount_shared.use_it_to_generate_otp": "You can now view this 2FA account and use it to generate One-Time Passwords as long as the share is active.",
|
||||
"message.notifications.regards": "Regards",
|
||||
"message.notifications.reset_password.subject": "2FAuth Password Reset",
|
||||
"message.notifications.reset_password.reason": "You are receiving this email because you requested a password reset for your 2FAuth account.",
|
||||
"message.notifications.reset_password.expire_in_x_minutes": "This password reset link will expire in :count minutes.",
|
||||
"message.notifications.reset_password.no_action_required": "If you did not request a password reset, no further action is required.",
|
||||
"message.notifications.test_email_settings.reason": "Stai ricevendo questa email perché hai richiesto una email di prova per convalidare le impostazioni di posta elettronica della tua istanza 2FAuth.",
|
||||
"message.notifications.test_email_settings.subject": "2FAuth test email",
|
||||
"message.notifications.test_email_settings.success": "Good news, it works :)"
|
||||
},
|
||||
"zh-TW": {
|
||||
"label.device": "設備",
|
||||
"label.ip_address": "IP 位址",
|
||||
"label.time": "時間",
|
||||
"link.go_to_2fauth_host": "轉到 2FAuth",
|
||||
"message.browser_on_platform": ":platform 上的 :browser",
|
||||
"message.notifications.failed_login.connection_details": "這是本次事件的相關資訊",
|
||||
"message.notifications.failed_login.recommandations": "如果這是您觸發的,您可以忽略此告警。但如果不是您,且持續有登入失敗的警告的話,您應立即通知 2FAuth 的管理員去檢查相關的安全設定,並對此攻擊者採取措施。",
|
||||
"message.notifications.failed_login.resume": "您的 2FAuth 帳號觸發了一次登入失敗警告。",
|
||||
"message.notifications.failed_login.subject": "2FAuth 登入失敗",
|
||||
"message.notifications.hello_user": "你好,:username,",
|
||||
"message.notifications.hello": "您好",
|
||||
"message.notifications.new_device.connection_details": "這是本次事件的相關資訊",
|
||||
"message.notifications.new_device.recommandations": "如果是您操作的,您可以忽略此告警。如果您懷疑自己的帳號有任何可疑活動,請立即更改您的密碼。",
|
||||
"message.notifications.new_device.resume": "您的 2FAuth 帳號有來自新設備的登入。",
|
||||
"message.notifications.new_device.subject": "來自新設備的訪問",
|
||||
"message.notifications.twofaccount_ownership_transferred.check_shares": "As this 2FA account may have been shared with other users before this ownership transfer, you should review the account's sharing settings to ensure they are still appropriate.",
|
||||
"message.notifications.twofaccount_ownership_transferred.resume": "We wanted to let you know that :actor transferred ownership of 2FA account :twofaccount to you. You are now the owner of this 2FA account and have full control over it.",
|
||||
"message.notifications.twofaccount_ownership_transferred.subject": "Ownership transfer on a 2FA account",
|
||||
"message.notifications.twofaccount_share_revoked.cannot_use_it_to_generate_otp": "You can no longer view this 2FA account or use it to generate One-Time Passwords.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_all_users": "We wanted to let you know that :actor has revoked sharing of 2FA account :twofaccount for all users.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_user": "We wanted to let you know that :actor has revoked your access to 2FA account :twofaccount.",
|
||||
"message.notifications.twofaccount_share_revoked.subject": "2FA account sharing revoked",
|
||||
"message.notifications.twofaccount_shared.resume_all_users": "We wanted to let you know that :actor shared 2FA account :twofaccount with all users.",
|
||||
"message.notifications.twofaccount_shared.resume_user": "We wanted to let you know that :actor shared 2FA account :twofaccount with you.",
|
||||
"message.notifications.twofaccount_shared.subject": "A 2FA account has been shared with you",
|
||||
"message.notifications.twofaccount_shared.use_it_to_generate_otp": "You can now view this 2FA account and use it to generate One-Time Passwords as long as the share is active.",
|
||||
"message.notifications.regards": "此致",
|
||||
"message.notifications.reset_password.subject": "2FAuth Password Reset",
|
||||
"message.notifications.reset_password.reason": "You are receiving this email because you requested a password reset for your 2FAuth account.",
|
||||
"message.notifications.reset_password.expire_in_x_minutes": "This password reset link will expire in :count minutes.",
|
||||
"message.notifications.reset_password.no_action_required": "If you did not request a password reset, no further action is required.",
|
||||
"message.notifications.test_email_settings.reason": "您之所以會收到這封郵件,是因為您要求發送一封測試郵件,來驗證您 2FAuth 的上郵件發送功能是否能正常工作。",
|
||||
"message.notifications.test_email_settings.subject": "2FAuth 測試郵件",
|
||||
"message.notifications.test_email_settings.success": "恭喜您,功能正常 :)"
|
||||
},
|
||||
"ar": {
|
||||
"label.device": "Device",
|
||||
"label.ip_address": "IP Address",
|
||||
"label.time": "Time",
|
||||
"link.go_to_2fauth_host": "Go to 2FAuth",
|
||||
"message.browser_on_platform": ":browser on :platform",
|
||||
"message.notifications.failed_login.connection_details": "Here are the details of this connection attempt",
|
||||
"message.notifications.failed_login.recommandations": "If this was you, you can ignore this alert. If further attempts fail, you should contact the 2FAuth administrator to review security settings and take action against this attacker.",
|
||||
"message.notifications.failed_login.resume": "There has been a failed login attempt to your 2FAuth account.",
|
||||
"message.notifications.failed_login.subject": "Failed login to 2FAuth",
|
||||
"message.notifications.hello_user": "Hello :username,",
|
||||
"message.notifications.hello": "Hello",
|
||||
"message.notifications.new_device.connection_details": "Here are the details of this connection",
|
||||
"message.notifications.new_device.recommandations": "If this was you, you can ignore this alert. If you suspect any suspicious activity on your account, please change your password.",
|
||||
"message.notifications.new_device.resume": "A new device has just connected to your 2FAuth account.",
|
||||
"message.notifications.new_device.subject": "Connection to 2FAuth from a new device",
|
||||
"message.notifications.twofaccount_ownership_transferred.check_shares": "As this 2FA account may have been shared with other users before this ownership transfer, you should review the account's sharing settings to ensure they are still appropriate.",
|
||||
"message.notifications.twofaccount_ownership_transferred.resume": "We wanted to let you know that :actor transferred ownership of 2FA account :twofaccount to you. You are now the owner of this 2FA account and have full control over it.",
|
||||
"message.notifications.twofaccount_ownership_transferred.subject": "Ownership transfer on a 2FA account",
|
||||
"message.notifications.twofaccount_share_revoked.cannot_use_it_to_generate_otp": "You can no longer view this 2FA account or use it to generate One-Time Passwords.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_all_users": "We wanted to let you know that :actor has revoked sharing of 2FA account :twofaccount for all users.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_user": "We wanted to let you know that :actor has revoked your access to 2FA account :twofaccount.",
|
||||
"message.notifications.twofaccount_share_revoked.subject": "2FA account sharing revoked",
|
||||
"message.notifications.twofaccount_shared.resume_all_users": "We wanted to let you know that :actor shared 2FA account :twofaccount with all users.",
|
||||
"message.notifications.twofaccount_shared.resume_user": "We wanted to let you know that :actor shared 2FA account :twofaccount with you.",
|
||||
"message.notifications.twofaccount_shared.subject": "A 2FA account has been shared with you",
|
||||
"message.notifications.twofaccount_shared.use_it_to_generate_otp": "You can now view this 2FA account and use it to generate One-Time Passwords as long as the share is active.",
|
||||
"message.notifications.regards": "Regards",
|
||||
"message.notifications.reset_password.subject": "2FAuth Password Reset",
|
||||
"message.notifications.reset_password.reason": "You are receiving this email because you requested a password reset for your 2FAuth account.",
|
||||
"message.notifications.reset_password.expire_in_x_minutes": "This password reset link will expire in :count minutes.",
|
||||
"message.notifications.reset_password.no_action_required": "If you did not request a password reset, no further action is required.",
|
||||
"message.notifications.test_email_settings.reason": "You are receiving this email because you requested a test email to validate the email settings of your 2FAuth instance.",
|
||||
"message.notifications.test_email_settings.subject": "2FAuth test email",
|
||||
"message.notifications.test_email_settings.success": "Good news, it works :)"
|
||||
},
|
||||
"pt-PT": {
|
||||
"label.device": "Device",
|
||||
"label.ip_address": "IP Address",
|
||||
"label.time": "Time",
|
||||
"link.go_to_2fauth_host": "Go to 2FAuth",
|
||||
"message.browser_on_platform": ":browser on :platform",
|
||||
"message.notifications.failed_login.connection_details": "Here are the details of this connection attempt",
|
||||
"message.notifications.failed_login.recommandations": "If this was you, you can ignore this alert. If further attempts fail, you should contact the 2FAuth administrator to review security settings and take action against this attacker.",
|
||||
"message.notifications.failed_login.resume": "There has been a failed login attempt to your 2FAuth account.",
|
||||
"message.notifications.failed_login.subject": "Failed login to 2FAuth",
|
||||
"message.notifications.hello_user": "Hello :username,",
|
||||
"message.notifications.hello": "Hello",
|
||||
"message.notifications.new_device.connection_details": "Here are the details of this connection",
|
||||
"message.notifications.new_device.recommandations": "If this was you, you can ignore this alert. If you suspect any suspicious activity on your account, please change your password.",
|
||||
"message.notifications.new_device.resume": "A new device has just connected to your 2FAuth account.",
|
||||
"message.notifications.new_device.subject": "Connection to 2FAuth from a new device",
|
||||
"message.notifications.twofaccount_ownership_transferred.check_shares": "As this 2FA account may have been shared with other users before this ownership transfer, you should review the account's sharing settings to ensure they are still appropriate.",
|
||||
"message.notifications.twofaccount_ownership_transferred.resume": "We wanted to let you know that :actor transferred ownership of 2FA account :twofaccount to you. You are now the owner of this 2FA account and have full control over it.",
|
||||
"message.notifications.twofaccount_ownership_transferred.subject": "Ownership transfer on a 2FA account",
|
||||
"message.notifications.twofaccount_share_revoked.cannot_use_it_to_generate_otp": "You can no longer view this 2FA account or use it to generate One-Time Passwords.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_all_users": "We wanted to let you know that :actor has revoked sharing of 2FA account :twofaccount for all users.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_user": "We wanted to let you know that :actor has revoked your access to 2FA account :twofaccount.",
|
||||
"message.notifications.twofaccount_share_revoked.subject": "2FA account sharing revoked",
|
||||
"message.notifications.twofaccount_shared.resume_all_users": "We wanted to let you know that :actor shared 2FA account :twofaccount with all users.",
|
||||
"message.notifications.twofaccount_shared.resume_user": "We wanted to let you know that :actor shared 2FA account :twofaccount with you.",
|
||||
"message.notifications.twofaccount_shared.subject": "A 2FA account has been shared with you",
|
||||
"message.notifications.twofaccount_shared.use_it_to_generate_otp": "You can now view this 2FA account and use it to generate One-Time Passwords as long as the share is active.",
|
||||
"message.notifications.regards": "Regards",
|
||||
"message.notifications.reset_password.subject": "2FAuth Password Reset",
|
||||
"message.notifications.reset_password.reason": "You are receiving this email because you requested a password reset for your 2FAuth account.",
|
||||
"message.notifications.reset_password.expire_in_x_minutes": "This password reset link will expire in :count minutes.",
|
||||
"message.notifications.reset_password.no_action_required": "If you did not request a password reset, no further action is required.",
|
||||
"message.notifications.test_email_settings.reason": "You are receiving this email because you requested a test email to validate the email settings of your 2FAuth instance.",
|
||||
"message.notifications.test_email_settings.subject": "2FAuth test email",
|
||||
"message.notifications.test_email_settings.success": "Good news, it works :)"
|
||||
},
|
||||
"zh-CN": {
|
||||
"label.device": "设备",
|
||||
"label.ip_address": "IP 地址",
|
||||
"label.time": "时间",
|
||||
"link.go_to_2fauth_host": "转到 2FAuth",
|
||||
"message.browser_on_platform": ":platform 上的 :browser",
|
||||
"message.notifications.failed_login.connection_details": "这是本次事件的相关信息",
|
||||
"message.notifications.failed_login.recommandations": "如果这是您的操作,那么请忽略此告警。但如果不是您的操作,且持续有登录失败的警告,那么您应立即通知 2FAuth 的管理员去检查相关的安全设置,并对此攻击者采取措施。",
|
||||
"message.notifications.failed_login.resume": "您的 2FAuth 账户触发了一次登录失败警告。",
|
||||
"message.notifications.failed_login.subject": "2FAuth 登录失败",
|
||||
"message.notifications.hello_user": "你好,:username,",
|
||||
"message.notifications.hello": "您好",
|
||||
"message.notifications.new_device.connection_details": "这是本次事件的相关信息",
|
||||
"message.notifications.new_device.recommandations": "如果是您操作的,您可以忽略此告警。如果您怀疑自己的账户有任何可疑活动,请立即更改您的密码。",
|
||||
"message.notifications.new_device.resume": "您的 2FAuth 账户有来自新设备的登录。",
|
||||
"message.notifications.new_device.subject": "来自新设备的访问",
|
||||
"message.notifications.twofaccount_ownership_transferred.check_shares": "此账号可能已被分享给其他用户,在转让之前,建议您先检查 (并适当调整) 分享设置。",
|
||||
"message.notifications.twofaccount_ownership_transferred.resume": "温馨提示::actor 已将 :twofaccount 的所有权转移给了您。您现在是此 2FA 账号的新所有者,拥有完全控制权限。",
|
||||
"message.notifications.twofaccount_ownership_transferred.subject": "2FA 账号所有权转移",
|
||||
"message.notifications.twofaccount_share_revoked.cannot_use_it_to_generate_otp": "您无权再查看此 2FA 账号或获取验证码。",
|
||||
"message.notifications.twofaccount_share_revoked.resume_all_users": "温馨提示::actor 已停止分享 :twofaccount 给所有用户。",
|
||||
"message.notifications.twofaccount_share_revoked.resume_user": "温馨提示::actor 已停止分享 :twofaccount 给您。",
|
||||
"message.notifications.twofaccount_share_revoked.subject": "此 2FA 账号已停止共享",
|
||||
"message.notifications.twofaccount_shared.resume_all_users": "温馨提示::actor 与所有用户分享了 :twofaccount 。",
|
||||
"message.notifications.twofaccount_shared.resume_user": "温馨提示::actor 与您分享了 :twofaccount 。",
|
||||
"message.notifications.twofaccount_shared.subject": "您有一个新分享的 2FA 账号",
|
||||
"message.notifications.twofaccount_shared.use_it_to_generate_otp": "您现在可以查看此 2FA 账号,并获取验证码。若此账号的所有者停止了分享,您将失去访问权限。",
|
||||
"message.notifications.regards": "此致",
|
||||
"message.notifications.reset_password.subject": "重置 2FAuth 密码",
|
||||
"message.notifications.reset_password.reason": "您之所以会收到此邮件,是因为您曾经申请过重置 2FAuth 账户的密码。",
|
||||
"message.notifications.reset_password.expire_in_x_minutes": "此密码重置链接将在 :count 分钟后过期。",
|
||||
"message.notifications.reset_password.no_action_required": "如果您没有申请过重置密码,请忽略此消息。",
|
||||
"message.notifications.test_email_settings.reason": "您之所以会收到这封邮件,是因为您要求发送一封测试邮件,来验证您 2FAuth 的上邮件发送功能是否能正常工作。",
|
||||
"message.notifications.test_email_settings.subject": "2FAuth 测试邮件",
|
||||
"message.notifications.test_email_settings.success": "恭喜您,功能正常 :)"
|
||||
},
|
||||
"tr": {
|
||||
"label.device": "Cihaz",
|
||||
"label.ip_address": "IP Adresi",
|
||||
"label.time": "Zaman",
|
||||
"link.go_to_2fauth_host": "2FAuth'a git",
|
||||
"message.browser_on_platform": ":platform üzerinde :browser",
|
||||
"message.notifications.failed_login.connection_details": "Bu giriş denemesinin detayları şöyle",
|
||||
"message.notifications.failed_login.recommandations": "Eğer bu sizseniz, bu uyarıyı göz ardı edebilirsiniz. Eğer daha fazla başarısız giriş denemesi görürseniz, güvenlik ayarlarını gözden geçirmek ve bu saldırgana karşı işlem yapmak için 2FAuth yöneticisiyle iletişime geçmelisiniz.",
|
||||
"message.notifications.failed_login.resume": "2FAuth hesabınıza başarısız bir giriş denemesi yapıldı.",
|
||||
"message.notifications.failed_login.subject": "2FAuth uygulamasına başarısız giriş",
|
||||
"message.notifications.hello_user": "Merhaba :username,",
|
||||
"message.notifications.hello": "Merhaba",
|
||||
"message.notifications.new_device.connection_details": "Bu bağlantının detayları",
|
||||
"message.notifications.new_device.recommandations": "Eğer bu sizseniz, bu uyarı gözardı edebilirsiniz. Eğer hesabınızda şüpheli bir hareket olduğunu düşünüyorsanız, lütfen parolanızı değiştirin.",
|
||||
"message.notifications.new_device.resume": "2FAuth hesabınıza yeni bir cihaz bağlandı.",
|
||||
"message.notifications.new_device.subject": "2FAuth'a yeni bir cihazdan erişim",
|
||||
"message.notifications.twofaccount_ownership_transferred.check_shares": "As this 2FA account may have been shared with other users before this ownership transfer, you should review the account's sharing settings to ensure they are still appropriate.",
|
||||
"message.notifications.twofaccount_ownership_transferred.resume": "We wanted to let you know that :actor transferred ownership of 2FA account :twofaccount to you. You are now the owner of this 2FA account and have full control over it.",
|
||||
"message.notifications.twofaccount_ownership_transferred.subject": "Ownership transfer on a 2FA account",
|
||||
"message.notifications.twofaccount_share_revoked.cannot_use_it_to_generate_otp": "You can no longer view this 2FA account or use it to generate One-Time Passwords.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_all_users": "We wanted to let you know that :actor has revoked sharing of 2FA account :twofaccount for all users.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_user": "We wanted to let you know that :actor has revoked your access to 2FA account :twofaccount.",
|
||||
"message.notifications.twofaccount_share_revoked.subject": "2FA account sharing revoked",
|
||||
"message.notifications.twofaccount_shared.resume_all_users": "",
|
||||
"message.notifications.twofaccount_shared.resume_user": "We wanted to let you know that :actor shared 2FA account :twofaccount with you.",
|
||||
"message.notifications.twofaccount_shared.subject": "A 2FA account has been shared with you",
|
||||
"message.notifications.twofaccount_shared.use_it_to_generate_otp": "You can now view this 2FA account and use it to generate One-Time Passwords as long as the share is active.",
|
||||
"message.notifications.regards": "Saygılar",
|
||||
"message.notifications.reset_password.subject": "2FAuth Password Reset",
|
||||
"message.notifications.reset_password.reason": "You are receiving this email because you requested a password reset for your 2FAuth account.",
|
||||
"message.notifications.reset_password.expire_in_x_minutes": "This password reset link will expire in :count minutes.",
|
||||
"message.notifications.reset_password.no_action_required": "Bir parola sıfırlama talebinde bulunmadıysanız, başka bir işlem yapmanıza gerek yoktur.",
|
||||
"message.notifications.test_email_settings.reason": "Bu ePostayı, ePosta yapılandırmanızın doğruluğunu test etmek amacıyla bir deneme ePostası istediğiniz için aldınız.",
|
||||
"message.notifications.test_email_settings.subject": "2FAuth deneme ePostası",
|
||||
"message.notifications.test_email_settings.success": "Haberler iyi, çalışıyor :)"
|
||||
},
|
||||
"fr": {
|
||||
"label.device": "Appareil",
|
||||
"label.ip_address": "Adresse IP",
|
||||
"label.time": "Heure",
|
||||
"link.go_to_2fauth_host": "Visiter 2FAuth",
|
||||
"message.browser_on_platform": ":browser sur :platform",
|
||||
"message.notifications.failed_login.connection_details": "Voici les détails de cette tentative de connexion",
|
||||
"message.notifications.failed_login.recommandations": "S'il s'agit de vous, vous pouvez ignorer cette alerte. Si d'autres tentatives infructueuses se produisent, vous devriez contacter l'administrateur de 2FAuth pour examiner les paramètres de sécurité et prendre des mesures contre cet attaquant.",
|
||||
"message.notifications.failed_login.resume": "Une tentative de connexion à votre compte 2FAuth a eu lieu mais a été rejetée.",
|
||||
"message.notifications.failed_login.subject": "Échec de connexion à 2FAuth",
|
||||
"message.notifications.hello_user": "Bonjour :username,",
|
||||
"message.notifications.hello": "Bonjour",
|
||||
"message.notifications.new_device.connection_details": "Voici les détails de cette connexion",
|
||||
"message.notifications.new_device.recommandations": "S'il s'agit de vous, vous pouvez ignorer cette alerte. Dans le cas contraire, et si vous soupçonnez une activité suspecte sur votre compte, changez votre mot de passe sans attendre.",
|
||||
"message.notifications.new_device.resume": "Un nouvel appareil vient de se connecter à votre compte 2FAuth.",
|
||||
"message.notifications.new_device.subject": "Connexion à 2FAuth depuis un nouvel appareil",
|
||||
"message.notifications.twofaccount_ownership_transferred.check_shares": "Ce compte 2FA peut avoir été partagé avec d'autres utilisateurs avant que vous en récupériez la propriété, vous devriez consulter les paramètres de partage du compte pour vous assurer qu'ils sont toujours appropriés.",
|
||||
"message.notifications.twofaccount_ownership_transferred.resume": "Nous voulions vous informer que :actor vous a transféré la propriété du compte 2FA :twofaccount. Vous en êtes maintenant le propriétaire et avez un contrôle total sur celui-ci.",
|
||||
"message.notifications.twofaccount_ownership_transferred.subject": "Transfert de propriété sur un compte 2FA",
|
||||
"message.notifications.twofaccount_share_revoked.cannot_use_it_to_generate_otp": "Vous ne pouvez plus voir ce compte 2FA ou l'utiliser pour générer des codes OTP.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_all_users": "Nous voulions vous informer que :actor a révoqué le partage du compte 2FA :twofaccount pour tous les utilisateurs.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_user": "Nous voulions vous informer que :actor ne partage plus le compte 2FA :twofaccount avec vous.",
|
||||
"message.notifications.twofaccount_share_revoked.subject": "Partage du compte 2FA révoqué",
|
||||
"message.notifications.twofaccount_shared.resume_all_users": "Nous voulions vous informer que :actor a partagé le compte 2FA :twofaccount avec tous les utilisateurs.",
|
||||
"message.notifications.twofaccount_shared.resume_user": "Nous voulions vous informer que :actor a partagé le compte 2FA :twofaccount avec vous.",
|
||||
"message.notifications.twofaccount_shared.subject": "Un compte 2FA a été partagé avec vous",
|
||||
"message.notifications.twofaccount_shared.use_it_to_generate_otp": "Vous pouvez maintenant voir ce compte 2FA et l'utiliser pour générer des codes OTP tant que le partage est actif.",
|
||||
"message.notifications.regards": "Cordialement",
|
||||
"message.notifications.reset_password.subject": "Réinitialisation de mot de passe 2FAuth",
|
||||
"message.notifications.reset_password.reason": "Vous recevez cet e-mail parce que vous avez demandé une réinitialisation du mot de passe de votre compte 2FAuth.",
|
||||
"message.notifications.reset_password.expire_in_x_minutes": "Ce lien de réinitialisation du mot de passe expirera dans :count minutes.",
|
||||
"message.notifications.reset_password.no_action_required": "Si vous n'avez pas demandé de réinitialisation de mot de passe, vous pouvez ignorer ce message.",
|
||||
"message.notifications.test_email_settings.reason": "Vous recevez cet email car vous avez demandé à vérifier le bon fonctionnement de l'envoi des emails sur votre instance 2FAuth.",
|
||||
"message.notifications.test_email_settings.subject": "Email de test 2FAuth",
|
||||
"message.notifications.test_email_settings.success": "Bonne nouvelle, ça fonctionne :)"
|
||||
},
|
||||
"uk": {
|
||||
"label.device": "Пристрій",
|
||||
"label.ip_address": "IP-адреса",
|
||||
"label.time": "Час",
|
||||
"link.go_to_2fauth_host": "Перейти до 2FAuth",
|
||||
"message.browser_on_platform": ":browser на :platform",
|
||||
"message.notifications.failed_login.connection_details": "Подробиці про цю спробу підключення",
|
||||
"message.notifications.failed_login.recommandations": "If this was you, you can ignore this alert. If further attempts fail, you should contact the 2FAuth administrator to review security settings and take action against this attacker.",
|
||||
"message.notifications.failed_login.resume": "There has been a failed login attempt to your 2FAuth account.",
|
||||
"message.notifications.failed_login.subject": "Не вдалося увійти до 2FAuth",
|
||||
"message.notifications.hello_user": "Привіт, :username,",
|
||||
"message.notifications.hello": "Привіт",
|
||||
"message.notifications.new_device.connection_details": "Інформація про це підключення",
|
||||
"message.notifications.new_device.recommandations": "If this was you, you can ignore this alert. If you suspect any suspicious activity on your account, please change your password.",
|
||||
"message.notifications.new_device.resume": "Новий пристрій щойно приєднано до вашого облікового запису 2FAuth.",
|
||||
"message.notifications.new_device.subject": "Підключення до 2FAuth з нового пристрою",
|
||||
"message.notifications.twofaccount_ownership_transferred.check_shares": "As this 2FA account may have been shared with other users before this ownership transfer, you should review the account's sharing settings to ensure they are still appropriate.",
|
||||
"message.notifications.twofaccount_ownership_transferred.resume": "We wanted to let you know that :actor transferred ownership of 2FA account :twofaccount to you. You are now the owner of this 2FA account and have full control over it.",
|
||||
"message.notifications.twofaccount_ownership_transferred.subject": "Ownership transfer on a 2FA account",
|
||||
"message.notifications.twofaccount_share_revoked.cannot_use_it_to_generate_otp": "You can no longer view this 2FA account or use it to generate One-Time Passwords.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_all_users": "We wanted to let you know that :actor has revoked sharing of 2FA account :twofaccount for all users.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_user": "We wanted to let you know that :actor has revoked your access to 2FA account :twofaccount.",
|
||||
"message.notifications.twofaccount_share_revoked.subject": "2FA account sharing revoked",
|
||||
"message.notifications.twofaccount_shared.resume_all_users": "We wanted to let you know that :actor shared 2FA account :twofaccount with all users.",
|
||||
"message.notifications.twofaccount_shared.resume_user": "We wanted to let you know that :actor shared 2FA account :twofaccount with you.",
|
||||
"message.notifications.twofaccount_shared.subject": "A 2FA account has been shared with you",
|
||||
"message.notifications.twofaccount_shared.use_it_to_generate_otp": "You can now view this 2FA account and use it to generate One-Time Passwords as long as the share is active.",
|
||||
"message.notifications.regards": "З найкращими побажаннями",
|
||||
"message.notifications.reset_password.subject": "2FAuth Password Reset",
|
||||
"message.notifications.reset_password.reason": "You are receiving this email because you requested a password reset for your 2FAuth account.",
|
||||
"message.notifications.reset_password.expire_in_x_minutes": "This password reset link will expire in :count minutes.",
|
||||
"message.notifications.reset_password.no_action_required": "If you did not request a password reset, no further action is required.",
|
||||
"message.notifications.test_email_settings.reason": "You are receiving this email because you requested a test email to validate the email settings of your 2FAuth instance.",
|
||||
"message.notifications.test_email_settings.subject": "Тестовий лист 2FAuth",
|
||||
"message.notifications.test_email_settings.success": "Гарна новина, все працює :)"
|
||||
},
|
||||
"da": {
|
||||
"label.device": "Enhed",
|
||||
"label.ip_address": "IP-adresse",
|
||||
"label.time": "Tid",
|
||||
"link.go_to_2fauth_host": "Go to 2FAuth",
|
||||
"message.browser_on_platform": ":browser on :platform",
|
||||
"message.notifications.failed_login.connection_details": "Here are the details of this connection attempt",
|
||||
"message.notifications.failed_login.recommandations": "If this was you, you can ignore this alert. If further attempts fail, you should contact the 2FAuth administrator to review security settings and take action against this attacker.",
|
||||
"message.notifications.failed_login.resume": "There has been a failed login attempt to your 2FAuth account.",
|
||||
"message.notifications.failed_login.subject": "Failed login to 2FAuth",
|
||||
"message.notifications.hello_user": "Hello :username,",
|
||||
"message.notifications.hello": "Hello",
|
||||
"message.notifications.new_device.connection_details": "Here are the details of this connection",
|
||||
"message.notifications.new_device.recommandations": "If this was you, you can ignore this alert. If you suspect any suspicious activity on your account, please change your password.",
|
||||
"message.notifications.new_device.resume": "A new device has just connected to your 2FAuth account.",
|
||||
"message.notifications.new_device.subject": "Connection to 2FAuth from a new device",
|
||||
"message.notifications.twofaccount_ownership_transferred.check_shares": "As this 2FA account may have been shared with other users before this ownership transfer, you should review the account's sharing settings to ensure they are still appropriate.",
|
||||
"message.notifications.twofaccount_ownership_transferred.resume": "We wanted to let you know that :actor transferred ownership of 2FA account :twofaccount to you. You are now the owner of this 2FA account and have full control over it.",
|
||||
"message.notifications.twofaccount_ownership_transferred.subject": "Ownership transfer on a 2FA account",
|
||||
"message.notifications.twofaccount_share_revoked.cannot_use_it_to_generate_otp": "You can no longer view this 2FA account or use it to generate One-Time Passwords.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_all_users": "We wanted to let you know that :actor has revoked sharing of 2FA account :twofaccount for all users.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_user": "We wanted to let you know that :actor has revoked your access to 2FA account :twofaccount.",
|
||||
"message.notifications.twofaccount_share_revoked.subject": "2FA account sharing revoked",
|
||||
"message.notifications.twofaccount_shared.resume_all_users": "We wanted to let you know that :actor shared 2FA account :twofaccount with all users.",
|
||||
"message.notifications.twofaccount_shared.resume_user": "We wanted to let you know that :actor shared 2FA account :twofaccount with you.",
|
||||
"message.notifications.twofaccount_shared.subject": "A 2FA account has been shared with you",
|
||||
"message.notifications.twofaccount_shared.use_it_to_generate_otp": "You can now view this 2FA account and use it to generate One-Time Passwords as long as the share is active.",
|
||||
"message.notifications.regards": "Regards",
|
||||
"message.notifications.reset_password.subject": "2FAuth Password Reset",
|
||||
"message.notifications.reset_password.reason": "You are receiving this email because you requested a password reset for your 2FAuth account.",
|
||||
"message.notifications.reset_password.expire_in_x_minutes": "This password reset link will expire in :count minutes.",
|
||||
"message.notifications.reset_password.no_action_required": "If you did not request a password reset, no further action is required.",
|
||||
"message.notifications.test_email_settings.reason": "You are receiving this email because you requested a test email to validate the email settings of your 2FAuth instance.",
|
||||
"message.notifications.test_email_settings.subject": "2FAuth test email",
|
||||
"message.notifications.test_email_settings.success": "Good news, it works :)"
|
||||
},
|
||||
"en": {
|
||||
"label.device": "Device",
|
||||
"label.ip_address": "IP Address",
|
||||
"label.time": "Time",
|
||||
"link.go_to_2fauth_host": "Go to 2FAuth",
|
||||
"message.browser_on_platform": ":browser on :platform",
|
||||
"message.notifications.failed_login.connection_details": "Here are the details of this connection attempt",
|
||||
"message.notifications.failed_login.recommandations": "If this was you, you can ignore this alert. If further attempts fail, you should contact the 2FAuth administrator to review security settings and take action against this attacker.",
|
||||
"message.notifications.failed_login.resume": "There has been a failed login attempt to your 2FAuth account.",
|
||||
"message.notifications.failed_login.subject": "Failed login to 2FAuth",
|
||||
"message.notifications.hello_user": "Hello :username,",
|
||||
"message.notifications.hello": "Hello",
|
||||
"message.notifications.new_device.connection_details": "Here are the details of this connection",
|
||||
"message.notifications.new_device.recommandations": "If this was you, you can ignore this alert. If you suspect any suspicious activity on your account, please change your password.",
|
||||
"message.notifications.new_device.resume": "A new device has just connected to your 2FAuth account.",
|
||||
"message.notifications.new_device.subject": "Connection to 2FAuth from a new device",
|
||||
"message.notifications.twofaccount_ownership_transferred.check_shares": "As this 2FA account may have been shared with other users before this ownership transfer, you should review the account's sharing settings to ensure they are still appropriate.",
|
||||
"message.notifications.twofaccount_ownership_transferred.resume": "We wanted to let you know that :actor transferred ownership of 2FA account :twofaccount to you. You are now the owner of this 2FA account and have full control over it.",
|
||||
"message.notifications.twofaccount_ownership_transferred.subject": "Ownership transfer on a 2FA account",
|
||||
"message.notifications.twofaccount_share_revoked.cannot_use_it_to_generate_otp": "You can no longer view this 2FA account or use it to generate One-Time Passwords.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_all_users": "We wanted to let you know that :actor has revoked sharing of 2FA account :twofaccount for all users.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_user": "We wanted to let you know that :actor has revoked your access to 2FA account :twofaccount.",
|
||||
"message.notifications.twofaccount_share_revoked.subject": "2FA account sharing revoked",
|
||||
"message.notifications.twofaccount_shared.resume_all_users": "We wanted to let you know that :actor shared 2FA account :twofaccount with all users.",
|
||||
"message.notifications.twofaccount_shared.resume_user": "We wanted to let you know that :actor shared 2FA account :twofaccount with you.",
|
||||
"message.notifications.twofaccount_shared.subject": "A 2FA account has been shared with you",
|
||||
"message.notifications.twofaccount_shared.use_it_to_generate_otp": "You can now view this 2FA account and use it to generate One-Time Passwords as long as the share is active.",
|
||||
"message.notifications.regards": "Regards",
|
||||
"message.notifications.reset_password.subject": "2FAuth Password Reset",
|
||||
"message.notifications.reset_password.reason": "You are receiving this email because you requested a password reset for your 2FAuth account.",
|
||||
"message.notifications.reset_password.expire_in_x_minutes": "This password reset link will expire in :count minutes.",
|
||||
"message.notifications.reset_password.no_action_required": "If you did not request a password reset, no further action is required.",
|
||||
"message.notifications.test_email_settings.reason": "You are receiving this email because you requested a test email to validate the email settings of your 2FAuth instance.",
|
||||
"message.notifications.test_email_settings.subject": "2FAuth test email",
|
||||
"message.notifications.test_email_settings.success": "Good news, it works :)"
|
||||
},
|
||||
"hi": {
|
||||
"label.device": "उपकरण (डिवाइस)",
|
||||
"label.ip_address": "आई पी अड्रेस",
|
||||
"label.time": "समय",
|
||||
"link.go_to_2fauth_host": "Go to 2FAuth",
|
||||
"message.browser_on_platform": ":browser on :platform",
|
||||
"message.notifications.failed_login.connection_details": "इस कनेक्शन की कोषसीसह का ब्योरा इस प्रकार है",
|
||||
"message.notifications.failed_login.recommandations": "यदि यह आप थे तो इस अलर्ट को नजरंदाज कर दें। यदि और कोशिश नाकाम होती है तो सुरक्षा सेटिंग के बारे में अपने 2FAuth ऐड्मिन से संपर्क करें और इस आक्रमणकारी के विरुद्ध कार्यवाही करें।",
|
||||
"message.notifications.failed_login.resume": "आपके 2FAuth अकाउंट में एक नाकाम लॉगिन की कोशिश की गई",
|
||||
"message.notifications.failed_login.subject": "2FAuth में नाकाम लॉगिन",
|
||||
"message.notifications.hello_user": "Hello :username,",
|
||||
"message.notifications.hello": "नमस्कार",
|
||||
"message.notifications.new_device.connection_details": "इस कनेक्शन का ब्योरा इस प्रकार है",
|
||||
"message.notifications.new_device.recommandations": "यदि यह आप थे तो इस अलर्ट को नजरंदाज कर दें। यदि अपने अकाउंट पर कोई संदिग्ध ऐक्टिविटी लगती है तो अपना पासवर्ड तुरंत बदलें।",
|
||||
"message.notifications.new_device.resume": "एक नया उपकरण आपके 2 Fauth अकाउंट से अभी कनेक्ट हुआ है",
|
||||
"message.notifications.new_device.subject": "Connection to 2FAuth from a new device",
|
||||
"message.notifications.twofaccount_ownership_transferred.check_shares": "As this 2FA account may have been shared with other users before this ownership transfer, you should review the account's sharing settings to ensure they are still appropriate.",
|
||||
"message.notifications.twofaccount_ownership_transferred.resume": "We wanted to let you know that :actor transferred ownership of 2FA account :twofaccount to you. You are now the owner of this 2FA account and have full control over it.",
|
||||
"message.notifications.twofaccount_ownership_transferred.subject": "Ownership transfer on a 2FA account",
|
||||
"message.notifications.twofaccount_share_revoked.cannot_use_it_to_generate_otp": "You can no longer view this 2FA account or use it to generate One-Time Passwords.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_all_users": "We wanted to let you know that :actor has revoked sharing of 2FA account :twofaccount for all users.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_user": "We wanted to let you know that :actor has revoked your access to 2FA account :twofaccount.",
|
||||
"message.notifications.twofaccount_share_revoked.subject": "2FA account sharing revoked",
|
||||
"message.notifications.twofaccount_shared.resume_all_users": "We wanted to let you know that :actor shared 2FA account :twofaccount with all users.",
|
||||
"message.notifications.twofaccount_shared.resume_user": "We wanted to let you know that :actor shared 2FA account :twofaccount with you.",
|
||||
"message.notifications.twofaccount_shared.subject": "A 2FA account has been shared with you",
|
||||
"message.notifications.twofaccount_shared.use_it_to_generate_otp": "You can now view this 2FA account and use it to generate One-Time Passwords as long as the share is active.",
|
||||
"message.notifications.regards": "सम्मान सहित",
|
||||
"message.notifications.reset_password.subject": "2FAuth Password Reset",
|
||||
"message.notifications.reset_password.reason": "You are receiving this email because you requested a password reset for your 2FAuth account.",
|
||||
"message.notifications.reset_password.expire_in_x_minutes": "This password reset link will expire in :count minutes.",
|
||||
"message.notifications.reset_password.no_action_required": "If you did not request a password reset, no further action is required.",
|
||||
"message.notifications.test_email_settings.reason": "आपको यह ईमेल इसलिए प्राप्त हुआ है क्योंकि आपने अपने 2FAuth इंस्टेंस की ईमेल सेटिंग्स को मान्य करने के लिए एक परीक्षण ईमेल का अनुरोध किया था।",
|
||||
"message.notifications.test_email_settings.subject": "2FAuth की टेस्ट ईमेल",
|
||||
"message.notifications.test_email_settings.success": "बधाई हो! यह काम करता है :)"
|
||||
},
|
||||
"ru": {
|
||||
"label.device": "Устройство",
|
||||
"label.ip_address": "IP Адрес",
|
||||
"label.time": "Время",
|
||||
"link.go_to_2fauth_host": "Перейти к 2FAuth",
|
||||
"message.browser_on_platform": ":browser на :platform",
|
||||
"message.notifications.failed_login.connection_details": "Подробности об этой попытке подключения",
|
||||
"message.notifications.failed_login.recommandations": "Если это были вы, можете проигнорировать это предупреждение. Если дальнейшие попытки не удались, свяжитесь с администратором 2FAuth для проверки настроек безопасности и принятия мер против этого атакующего.",
|
||||
"message.notifications.failed_login.resume": "Произошла неудачная попытка входа в ваш профиль 2FAuth.",
|
||||
"message.notifications.failed_login.subject": "Не удалось войти в 2FAuth",
|
||||
"message.notifications.hello_user": "Привет, :username,",
|
||||
"message.notifications.hello": "Привет",
|
||||
"message.notifications.new_device.connection_details": "Информация об этом подключении",
|
||||
"message.notifications.new_device.recommandations": "Если это вы, то вы можете проигнорировать это уведомление. Если вы подозреваете, что это подозрительное действие с вашей учётной записью, измените ваш пароль.",
|
||||
"message.notifications.new_device.resume": "Новое устройство только что подключилось к вашему профилю 2FAuth.",
|
||||
"message.notifications.new_device.subject": "Подключение к 2FAuth с нового устройства",
|
||||
"message.notifications.twofaccount_ownership_transferred.check_shares": "Если эта учётная запись 2FA была доступна другим пользователям до передачи права собственности, проверьте настройки общего доступа — возможно, их нужно обновить.",
|
||||
"message.notifications.twofaccount_ownership_transferred.resume": "Мы хотели сообщить, что :actor передал вам право собственности на учётную запись 2FA :twofaccount. Теперь вы являетесь владельцем этой учётной записи 2FA и имеете полный контроль над ней.",
|
||||
"message.notifications.twofaccount_ownership_transferred.subject": "Передача прав собственности на учётную запись 2FA",
|
||||
"message.notifications.twofaccount_share_revoked.cannot_use_it_to_generate_otp": "Вы больше не можете просматривать эту учётную запись 2FA или использовать её для создания одноразовых кодов.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_all_users": "Мы хотели сообщить, что :actor отозвал общий доступ к учётной записи 2FA :twofaccount для всех пользователей.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_user": "Мы хотели сообщить, что :actor отозвал общий доступ к учётной записи 2FA :twofaccount для вас.",
|
||||
"message.notifications.twofaccount_share_revoked.subject": "Общий доступ к учётной записи 2FA отозван",
|
||||
"message.notifications.twofaccount_shared.resume_all_users": "Мы хотели сообщить, что :actor предоставил общий доступ к учётной записи 2FA :twofaccount для всех пользователей.",
|
||||
"message.notifications.twofaccount_shared.resume_user": "Мы хотели сообщить, что :actor предоставил общий доступ к учётной записи 2FA :twofaccount для вас.",
|
||||
"message.notifications.twofaccount_shared.subject": "Вам предоставлен доступ к учётной записи 2FA",
|
||||
"message.notifications.twofaccount_shared.use_it_to_generate_otp": "Теперь вы можете просматривать эту учётную запись 2FA и использовать её для генерации одноразовых кодов, пока общий доступ к ней активен.",
|
||||
"message.notifications.regards": "С наилучшими пожеланиями",
|
||||
"message.notifications.reset_password.subject": "Сброс пароля 2FAuth",
|
||||
"message.notifications.reset_password.reason": "Вы получили это письмо, потому что вы запросили сброс пароля для вашего профиля 2FAuth.",
|
||||
"message.notifications.reset_password.expire_in_x_minutes": "Срок действия ссылки для сброса пароля истекает через :count минут.",
|
||||
"message.notifications.reset_password.no_action_required": "Если вы не запрашивали сброс пароля, то никаких дальнейших действий не требуется.",
|
||||
"message.notifications.test_email_settings.reason": "Вы получили это письмо, потому что вы запросили тестовое письмо, чтобы проверить настройки электронной почты вашего экземпляра 2FAuth.",
|
||||
"message.notifications.test_email_settings.subject": "Тестовое письмо 2FAuth",
|
||||
"message.notifications.test_email_settings.success": "Хорошие новости! Всё работает! :-)"
|
||||
},
|
||||
"ja": {
|
||||
"label.device": "端末",
|
||||
"label.ip_address": "IPアドレス",
|
||||
"label.time": "時刻",
|
||||
"link.go_to_2fauth_host": "2FAuthへ移動",
|
||||
"message.browser_on_platform": ":browser on :platform",
|
||||
"message.notifications.failed_login.connection_details": "具体的な情報は以下の通りです",
|
||||
"message.notifications.failed_login.recommandations": "心当たりがある場合は、この通知を無視しても構いません。今後も行為が続く場合は、2FAuth の管理者に相談し、セキュリティ設定の見直しや攻撃者への対処を検討してください。",
|
||||
"message.notifications.failed_login.resume": "あなたの 2FAuth アカウントへのログイン失敗が発生しました。",
|
||||
"message.notifications.failed_login.subject": "2FAuth へのログイン失敗発生",
|
||||
"message.notifications.hello_user": "Hello :username,",
|
||||
"message.notifications.hello": "こんにちは",
|
||||
"message.notifications.new_device.connection_details": "この接続の詳細は以下の通りです",
|
||||
"message.notifications.new_device.recommandations": "心当たりがある場合は、この通知を無視しても構いません。アカウントへの不審な行為が疑われる場合は、パスワードをご変更ください。",
|
||||
"message.notifications.new_device.resume": "新しいデバイスがあなたの2FAuthアカウントに接続されました。",
|
||||
"message.notifications.new_device.subject": "新しいデバイスから 2FAuth に接続しました",
|
||||
"message.notifications.twofaccount_ownership_transferred.check_shares": "As this 2FA account may have been shared with other users before this ownership transfer, you should review the account's sharing settings to ensure they are still appropriate.",
|
||||
"message.notifications.twofaccount_ownership_transferred.resume": "We wanted to let you know that :actor transferred ownership of 2FA account :twofaccount to you. You are now the owner of this 2FA account and have full control over it.",
|
||||
"message.notifications.twofaccount_ownership_transferred.subject": "Ownership transfer on a 2FA account",
|
||||
"message.notifications.twofaccount_share_revoked.cannot_use_it_to_generate_otp": "You can no longer view this 2FA account or use it to generate One-Time Passwords.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_all_users": "We wanted to let you know that :actor has revoked sharing of 2FA account :twofaccount for all users.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_user": "We wanted to let you know that :actor has revoked your access to 2FA account :twofaccount.",
|
||||
"message.notifications.twofaccount_share_revoked.subject": "2FA account sharing revoked",
|
||||
"message.notifications.twofaccount_shared.resume_all_users": "We wanted to let you know that :actor shared 2FA account :twofaccount with all users.",
|
||||
"message.notifications.twofaccount_shared.resume_user": "We wanted to let you know that :actor shared 2FA account :twofaccount with you.",
|
||||
"message.notifications.twofaccount_shared.subject": "A 2FA account has been shared with you",
|
||||
"message.notifications.twofaccount_shared.use_it_to_generate_otp": "You can now view this 2FA account and use it to generate One-Time Passwords as long as the share is active.",
|
||||
"message.notifications.regards": "よろしくお願いします",
|
||||
"message.notifications.reset_password.subject": "2FAuth Password Reset",
|
||||
"message.notifications.reset_password.reason": "You are receiving this email because you requested a password reset for your 2FAuth account.",
|
||||
"message.notifications.reset_password.expire_in_x_minutes": "This password reset link will expire in :count minutes.",
|
||||
"message.notifications.reset_password.no_action_required": "If you did not request a password reset, no further action is required.",
|
||||
"message.notifications.test_email_settings.reason": "このメールはあなたの要望に応じて送信された、2FAuth インスタンスのメール設定検証用のテストメールです。",
|
||||
"message.notifications.test_email_settings.subject": "2FAuth テストメール",
|
||||
"message.notifications.test_email_settings.success": "届いているようですね!"
|
||||
},
|
||||
"ca": {
|
||||
"label.device": "Dispositiu",
|
||||
"label.ip_address": "Adreça IP",
|
||||
"label.time": "Temps",
|
||||
"link.go_to_2fauth_host": "Anar a 2FAuth",
|
||||
"message.browser_on_platform": ":browser a :platform",
|
||||
"message.notifications.failed_login.connection_details": "Aquí teniu els detalls d'aquest intent de connexió",
|
||||
"message.notifications.failed_login.recommandations": "Si heu estat vosaltres, podeu ignorar aquesta alerta. Si fallen més intents, hauríeu de contactar amb l'administrador de 2FAuth per revisar la configuració de seguretat i prendre mesures contra aquest atacant.",
|
||||
"message.notifications.failed_login.resume": "Hi ha hagut un intent d'inici de sessió fallit al vostre compte de 2FAuth.",
|
||||
"message.notifications.failed_login.subject": "Inici de sessió fallit a 2FAuth",
|
||||
"message.notifications.hello_user": "Hola :username,",
|
||||
"message.notifications.hello": "Hola",
|
||||
"message.notifications.new_device.connection_details": "Aquí teniu els detalls d'aquesta connexió",
|
||||
"message.notifications.new_device.recommandations": "Si heu estat vosaltres, podeu ignorar aquesta alerta. Si sospiteu de qualsevol activitat sospitosa al vostre compte, si us plau canvieu la vostra contrasenya.",
|
||||
"message.notifications.new_device.resume": "Un nou dispositiu s'acaba de connectar al vostre compte de 2FAuth.",
|
||||
"message.notifications.new_device.subject": "Connexió a 2FAuth des d'un nou dispositiu",
|
||||
"message.notifications.twofaccount_ownership_transferred.check_shares": "As this 2FA account may have been shared with other users before this ownership transfer, you should review the account's sharing settings to ensure they are still appropriate.",
|
||||
"message.notifications.twofaccount_ownership_transferred.resume": "We wanted to let you know that :actor transferred ownership of 2FA account :twofaccount to you. You are now the owner of this 2FA account and have full control over it.",
|
||||
"message.notifications.twofaccount_ownership_transferred.subject": "Ownership transfer on a 2FA account",
|
||||
"message.notifications.twofaccount_share_revoked.cannot_use_it_to_generate_otp": "You can no longer view this 2FA account or use it to generate One-Time Passwords.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_all_users": "We wanted to let you know that :actor has revoked sharing of 2FA account :twofaccount for all users.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_user": "We wanted to let you know that :actor has revoked your access to 2FA account :twofaccount.",
|
||||
"message.notifications.twofaccount_share_revoked.subject": "2FA account sharing revoked",
|
||||
"message.notifications.twofaccount_shared.resume_all_users": "We wanted to let you know that :actor shared 2FA account :twofaccount with all users.",
|
||||
"message.notifications.twofaccount_shared.resume_user": "We wanted to let you know that :actor shared 2FA account :twofaccount with you.",
|
||||
"message.notifications.twofaccount_shared.subject": "A 2FA account has been shared with you",
|
||||
"message.notifications.twofaccount_shared.use_it_to_generate_otp": "You can now view this 2FA account and use it to generate One-Time Passwords as long as the share is active.",
|
||||
"message.notifications.regards": "Salutacions",
|
||||
"message.notifications.reset_password.subject": "Reseteja contrasenya 2Fauth",
|
||||
"message.notifications.reset_password.reason": "Reps aquest correu perquè has demanat restablir la contrasenya del compte de 2Fauth.",
|
||||
"message.notifications.reset_password.expire_in_x_minutes": "El link de restabliment de contrasenya expira en :count minutes.",
|
||||
"message.notifications.reset_password.no_action_required": "Si no has demanat el restabliment de contrasenya omet aquest correu.",
|
||||
"message.notifications.test_email_settings.reason": "Estàs rebent aquest correu perquè s'ha enviat una prova per validar les opcions de correu a la instància de 2fauth.",
|
||||
"message.notifications.test_email_settings.subject": "Correu de prova de 2fAuth",
|
||||
"message.notifications.test_email_settings.success": "Bones notícies, funciona :)"
|
||||
},
|
||||
"ko": {
|
||||
"label.device": "장치",
|
||||
"label.ip_address": "IP 주소",
|
||||
"label.time": "시간",
|
||||
"link.go_to_2fauth_host": "2FAuth 이동",
|
||||
"message.browser_on_platform": ":platform 플랫폼의 :browser 브라우저",
|
||||
"message.notifications.failed_login.connection_details": "이 로그인 시도에 대한 자세한 내용은 다음과 같습니다.",
|
||||
"message.notifications.failed_login.recommandations": "사용자 본인이라면 이 경고를 무시해도 됩니다. 추가 시도가 실패하면 2FAuth 관리자에게 연락하여 보안 설정을 검토하고 이 공격자에 대한 조치를 취해야 합니다.",
|
||||
"message.notifications.failed_login.resume": "사용자의 2FAuth 계정에서의 로그인 시도가 실패했습니다.",
|
||||
"message.notifications.failed_login.subject": "2FAuth 로그인 실패",
|
||||
"message.notifications.hello_user": "안녕하세요 :username,",
|
||||
"message.notifications.hello": "안녕하세요",
|
||||
"message.notifications.new_device.connection_details": "이 연결에 대한 자세한 내용은 다음과 같습니다.",
|
||||
"message.notifications.new_device.recommandations": "이 경우 이 알림을 무시해도 됩니다. 계정에서 의심스러운 활동이 의심되는 경우 비밀번호를 변경하시기 바랍니다.",
|
||||
"message.notifications.new_device.resume": "새 장치가 방금 2FAuth 계정에 연결되었습니다.",
|
||||
"message.notifications.new_device.subject": "새로운 기기에서 2fAuth에 연결했습니다.",
|
||||
"message.notifications.twofaccount_ownership_transferred.check_shares": "이 2FA 계정은 소유권을 이전하기 전에 다른 사용자에게 공유되었을 수 있으므로, 계정 공유 설정이 지금도 적절한지 검토해야 합니다.",
|
||||
"message.notifications.twofaccount_ownership_transferred.resume": ":actor이(가) 2FA 계정 :twofaccount의 소유권을 당신에게 이전했습니다. 이제 당신은 이 2FA 계정의 소유자이며 완전히 통제할 수 있습니다.",
|
||||
"message.notifications.twofaccount_ownership_transferred.subject": "2FA 계정 소유권 이전",
|
||||
"message.notifications.twofaccount_share_revoked.cannot_use_it_to_generate_otp": "더 이상 이 2FA 계정을 열람하거나 OTP를 생성하는 데에 사용할 수 없습니다.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_all_users": ":actor이(가) 2FA 계정 :twofaccount의 전체 사용자 공유를 철회했습니다.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_user": ":actor이(가) 2FA 계정 :twofaccount의 당신에 대한 공유를 철회했습니다.",
|
||||
"message.notifications.twofaccount_share_revoked.subject": "2FA 계정 공유 철회됨",
|
||||
"message.notifications.twofaccount_shared.resume_all_users": ":actor이(가) 2FA 계정 :twofaccount을(를) 전체 사용자에게 공유했습니다.",
|
||||
"message.notifications.twofaccount_shared.resume_user": ":actor이(가) 2FA 계정 :twofaccount을(를) 당신에게 공유했습니다.",
|
||||
"message.notifications.twofaccount_shared.subject": "2FA 계정이 당신에게 공유되었습니다",
|
||||
"message.notifications.twofaccount_shared.use_it_to_generate_otp": "이제 공유가 활성화되어 있는 한 이 2FA 계정을 열람하고 OTP를 생성할 수 있습니다.",
|
||||
"message.notifications.regards": "만나서 반갑습니다",
|
||||
"message.notifications.reset_password.subject": "2FAuth 비밀번호 초기화",
|
||||
"message.notifications.reset_password.reason": "귀하께서 2FAuth 계정의 비밀번호 초기화를 요청했기 때문에 이 메일을 수신하였습니다.",
|
||||
"message.notifications.reset_password.expire_in_x_minutes": "이 비밀번호 초기화 링크는 :count 분 후에 만료됩니다.",
|
||||
"message.notifications.reset_password.no_action_required": "비밀번호 초기화를 요청하지 않은 경우, 추가 조치는 필요하지 않습니다.",
|
||||
"message.notifications.test_email_settings.reason": "2FAuth 인스턴스의 이메일 설정을 확인하기 위해 테스트 이메일을 요청했으므로 이 이메일이 전송되었습니다.",
|
||||
"message.notifications.test_email_settings.subject": "2FAuth 테스트 이메일",
|
||||
"message.notifications.test_email_settings.success": "좋은 소식이네요, 잘 작동합니다 :)"
|
||||
},
|
||||
"id": {
|
||||
"label.device": "Device",
|
||||
"label.ip_address": "IP Address",
|
||||
"label.time": "Time",
|
||||
"link.go_to_2fauth_host": "Go to 2FAuth",
|
||||
"message.browser_on_platform": ":browser on :platform",
|
||||
"message.notifications.failed_login.connection_details": "Here are the details of this connection attempt",
|
||||
"message.notifications.failed_login.recommandations": "If this was you, you can ignore this alert. If further attempts fail, you should contact the 2FAuth administrator to review security settings and take action against this attacker.",
|
||||
"message.notifications.failed_login.resume": "There has been a failed login attempt to your 2FAuth account.",
|
||||
"message.notifications.failed_login.subject": "Failed login to 2FAuth",
|
||||
"message.notifications.hello_user": "Hello :username,",
|
||||
"message.notifications.hello": "Hello",
|
||||
"message.notifications.new_device.connection_details": "Here are the details of this connection",
|
||||
"message.notifications.new_device.recommandations": "If this was you, you can ignore this alert. If you suspect any suspicious activity on your account, please change your password.",
|
||||
"message.notifications.new_device.resume": "A new device has just connected to your 2FAuth account.",
|
||||
"message.notifications.new_device.subject": "Connection to 2FAuth from a new device",
|
||||
"message.notifications.twofaccount_ownership_transferred.check_shares": "As this 2FA account may have been shared with other users before this ownership transfer, you should review the account's sharing settings to ensure they are still appropriate.",
|
||||
"message.notifications.twofaccount_ownership_transferred.resume": "We wanted to let you know that :actor transferred ownership of 2FA account :twofaccount to you. You are now the owner of this 2FA account and have full control over it.",
|
||||
"message.notifications.twofaccount_ownership_transferred.subject": "Ownership transfer on a 2FA account",
|
||||
"message.notifications.twofaccount_share_revoked.cannot_use_it_to_generate_otp": "You can no longer view this 2FA account or use it to generate One-Time Passwords.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_all_users": "We wanted to let you know that :actor has revoked sharing of 2FA account :twofaccount for all users.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_user": "We wanted to let you know that :actor has revoked your access to 2FA account :twofaccount.",
|
||||
"message.notifications.twofaccount_share_revoked.subject": "2FA account sharing revoked",
|
||||
"message.notifications.twofaccount_shared.resume_all_users": "We wanted to let you know that :actor shared 2FA account :twofaccount with all users.",
|
||||
"message.notifications.twofaccount_shared.resume_user": "We wanted to let you know that :actor shared 2FA account :twofaccount with you.",
|
||||
"message.notifications.twofaccount_shared.subject": "A 2FA account has been shared with you",
|
||||
"message.notifications.twofaccount_shared.use_it_to_generate_otp": "You can now view this 2FA account and use it to generate One-Time Passwords as long as the share is active.",
|
||||
"message.notifications.regards": "Regards",
|
||||
"message.notifications.reset_password.subject": "2FAuth Password Reset",
|
||||
"message.notifications.reset_password.reason": "You are receiving this email because you requested a password reset for your 2FAuth account.",
|
||||
"message.notifications.reset_password.expire_in_x_minutes": "This password reset link will expire in :count minutes.",
|
||||
"message.notifications.reset_password.no_action_required": "If you did not request a password reset, no further action is required.",
|
||||
"message.notifications.test_email_settings.reason": "You are receiving this email because you requested a test email to validate the email settings of your 2FAuth instance.",
|
||||
"message.notifications.test_email_settings.subject": "2FAuth test email",
|
||||
"message.notifications.test_email_settings.success": "Good news, it works :)"
|
||||
},
|
||||
"bg": {
|
||||
"label.device": "Device",
|
||||
"label.ip_address": "IP Address",
|
||||
"label.time": "Time",
|
||||
"link.go_to_2fauth_host": "Go to 2FAuth",
|
||||
"message.browser_on_platform": ":browser on :platform",
|
||||
"message.notifications.failed_login.connection_details": "Here are the details of this connection attempt",
|
||||
"message.notifications.failed_login.recommandations": "If this was you, you can ignore this alert. If further attempts fail, you should contact the 2FAuth administrator to review security settings and take action against this attacker.",
|
||||
"message.notifications.failed_login.resume": "There has been a failed login attempt to your 2FAuth account.",
|
||||
"message.notifications.failed_login.subject": "Failed login to 2FAuth",
|
||||
"message.notifications.hello_user": "Hello :username,",
|
||||
"message.notifications.hello": "Hello",
|
||||
"message.notifications.new_device.connection_details": "Here are the details of this connection",
|
||||
"message.notifications.new_device.recommandations": "If this was you, you can ignore this alert. If you suspect any suspicious activity on your account, please change your password.",
|
||||
"message.notifications.new_device.resume": "A new device has just connected to your 2FAuth account.",
|
||||
"message.notifications.new_device.subject": "Connection to 2FAuth from a new device",
|
||||
"message.notifications.twofaccount_ownership_transferred.check_shares": "As this 2FA account may have been shared with other users before this ownership transfer, you should review the account's sharing settings to ensure they are still appropriate.",
|
||||
"message.notifications.twofaccount_ownership_transferred.resume": "We wanted to let you know that :actor transferred ownership of 2FA account :twofaccount to you. You are now the owner of this 2FA account and have full control over it.",
|
||||
"message.notifications.twofaccount_ownership_transferred.subject": "Ownership transfer on a 2FA account",
|
||||
"message.notifications.twofaccount_share_revoked.cannot_use_it_to_generate_otp": "You can no longer view this 2FA account or use it to generate One-Time Passwords.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_all_users": "We wanted to let you know that :actor has revoked sharing of 2FA account :twofaccount for all users.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_user": "We wanted to let you know that :actor has revoked your access to 2FA account :twofaccount.",
|
||||
"message.notifications.twofaccount_share_revoked.subject": "2FA account sharing revoked",
|
||||
"message.notifications.twofaccount_shared.resume_all_users": "We wanted to let you know that :actor shared 2FA account :twofaccount with all users.",
|
||||
"message.notifications.twofaccount_shared.resume_user": "We wanted to let you know that :actor shared 2FA account :twofaccount with you.",
|
||||
"message.notifications.twofaccount_shared.subject": "A 2FA account has been shared with you",
|
||||
"message.notifications.twofaccount_shared.use_it_to_generate_otp": "You can now view this 2FA account and use it to generate One-Time Passwords as long as the share is active.",
|
||||
"message.notifications.regards": "Regards",
|
||||
"message.notifications.reset_password.subject": "2FAuth Password Reset",
|
||||
"message.notifications.reset_password.reason": "You are receiving this email because you requested a password reset for your 2FAuth account.",
|
||||
"message.notifications.reset_password.expire_in_x_minutes": "This password reset link will expire in :count minutes.",
|
||||
"message.notifications.reset_password.no_action_required": "If you did not request a password reset, no further action is required.",
|
||||
"message.notifications.test_email_settings.reason": "You are receiving this email because you requested a test email to validate the email settings of your 2FAuth instance.",
|
||||
"message.notifications.test_email_settings.subject": "2FAuth test email",
|
||||
"message.notifications.test_email_settings.success": "Good news, it works :)"
|
||||
},
|
||||
"nl": {
|
||||
"label.device": "Apparaat",
|
||||
"label.ip_address": "IP-adres",
|
||||
"label.time": "Tijd",
|
||||
"link.go_to_2fauth_host": "Go to 2FAuth",
|
||||
"message.browser_on_platform": ":browser on :platform",
|
||||
"message.notifications.failed_login.connection_details": "Hier zijn de details van deze verbindingspoging",
|
||||
"message.notifications.failed_login.recommandations": "Als jij dit was, kan je deze melding negeren. Als verdere pogingen mislukken, moet u contact opnemen met de 2FAuth beheerder om de beveiligingsinstellingen te bekijken en actie ondernemen tegen deze aanvaller.",
|
||||
"message.notifications.failed_login.resume": "Er is een mislukte inlog poging gedaan voor je 2FAuth account.",
|
||||
"message.notifications.failed_login.subject": "Mislukte login naar 2FAuth",
|
||||
"message.notifications.hello_user": "Hello :username,",
|
||||
"message.notifications.hello": "Hallo",
|
||||
"message.notifications.new_device.connection_details": "Hier zijn de details van deze verbinding",
|
||||
"message.notifications.new_device.recommandations": "Als u dit was, kunt u deze waarschuwing negeren. Als u vermoedt dat er sprake is van een verdachte activiteit op uw account, wijzig dan uw wachtwoord.",
|
||||
"message.notifications.new_device.resume": "Een nieuw apparaat heeft zojuist verbinding gemaakt met je 2FAuth account.",
|
||||
"message.notifications.new_device.subject": "Verbinding met 2FAuth vanaf een nieuw apparaat",
|
||||
"message.notifications.twofaccount_ownership_transferred.check_shares": "As this 2FA account may have been shared with other users before this ownership transfer, you should review the account's sharing settings to ensure they are still appropriate.",
|
||||
"message.notifications.twofaccount_ownership_transferred.resume": "We wanted to let you know that :actor transferred ownership of 2FA account :twofaccount to you. You are now the owner of this 2FA account and have full control over it.",
|
||||
"message.notifications.twofaccount_ownership_transferred.subject": "Ownership transfer on a 2FA account",
|
||||
"message.notifications.twofaccount_share_revoked.cannot_use_it_to_generate_otp": "You can no longer view this 2FA account or use it to generate One-Time Passwords.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_all_users": "We wanted to let you know that :actor has revoked sharing of 2FA account :twofaccount for all users.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_user": "We wanted to let you know that :actor has revoked your access to 2FA account :twofaccount.",
|
||||
"message.notifications.twofaccount_share_revoked.subject": "2FA account sharing revoked",
|
||||
"message.notifications.twofaccount_shared.resume_all_users": "We wanted to let you know that :actor shared 2FA account :twofaccount with all users.",
|
||||
"message.notifications.twofaccount_shared.resume_user": "We wanted to let you know that :actor shared 2FA account :twofaccount with you.",
|
||||
"message.notifications.twofaccount_shared.subject": "A 2FA account has been shared with you",
|
||||
"message.notifications.twofaccount_shared.use_it_to_generate_otp": "You can now view this 2FA account and use it to generate One-Time Passwords as long as the share is active.",
|
||||
"message.notifications.regards": "Groeten",
|
||||
"message.notifications.reset_password.subject": "2FAuth Password Reset",
|
||||
"message.notifications.reset_password.reason": "You are receiving this email because you requested a password reset for your 2FAuth account.",
|
||||
"message.notifications.reset_password.expire_in_x_minutes": "This password reset link will expire in :count minutes.",
|
||||
"message.notifications.reset_password.no_action_required": "If you did not request a password reset, no further action is required.",
|
||||
"message.notifications.test_email_settings.reason": "Je ontvangt deze e-mail omdat je een test e-mail hebt aangevraagd om de e-mailinstellingen van je 2FAuth installatie te valideren.",
|
||||
"message.notifications.test_email_settings.subject": "2FAuth test e-mail",
|
||||
"message.notifications.test_email_settings.success": "Goed nieuws, het werkt :)"
|
||||
},
|
||||
"es-ES": {
|
||||
"label.device": "Dispositivo",
|
||||
"label.ip_address": "Dirección IP",
|
||||
"label.time": "Hora",
|
||||
"link.go_to_2fauth_host": "Ir a 2FAuth",
|
||||
"message.browser_on_platform": ":browser en :platform",
|
||||
"message.notifications.failed_login.connection_details": "Aquí están los detalles de este intento de conexión",
|
||||
"message.notifications.failed_login.recommandations": "Si ha sido usted, puede ignorar esta alerta. Si continúa recibiendo esta notificación, póngase en contacto con el administrador para revisar la configuración de seguridad y tomar medidas contra este ataque.",
|
||||
"message.notifications.failed_login.resume": "Ha habido un intento de inicio de sesión fallido en su cuenta de 2FAuth.",
|
||||
"message.notifications.failed_login.subject": "Error al iniciar sesión en 2FAuth",
|
||||
"message.notifications.hello_user": "Hola :username,",
|
||||
"message.notifications.hello": "Hola",
|
||||
"message.notifications.new_device.connection_details": "Aquí tienes los detalles de esta conexión",
|
||||
"message.notifications.new_device.recommandations": "Si ha sido usted, puede ignorar esta alerta. Si observa alguna actividad sospechosa en su cuenta, por favor cambie su contraseña.",
|
||||
"message.notifications.new_device.resume": "Un nuevo dispositivo acaba de conectarse a tu cuenta de 2FAuth.",
|
||||
"message.notifications.new_device.subject": "Conexión a 2FAuth desde un nuevo dispositivo",
|
||||
"message.notifications.twofaccount_ownership_transferred.check_shares": "Esta cuenta 2FA podría haber sido compartida con otros usuarios antes de la transferencia de propiedad. Revisa la configuración de uso compartido para asegurarte de que los permisos siguen siendo los adecuados.",
|
||||
"message.notifications.twofaccount_ownership_transferred.resume": "Queremos informarte de que :actor te ha transferido la propiedad de la cuenta 2FA :twofaccount. Ahora eres el propietario de esta cuenta 2FA y tienes control total sobre ella.",
|
||||
"message.notifications.twofaccount_ownership_transferred.subject": "Transferencia de propiedad de una cuenta 2FA",
|
||||
"message.notifications.twofaccount_share_revoked.cannot_use_it_to_generate_otp": "Ya no puedes ver esta cuenta 2FA ni utilizarla para generar códigos de un solo uso.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_all_users": "Queremos informarte de que :actor ha revocado el uso compartido de la cuenta 2FA :twofaccount para todos los usuarios.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_user": "Te informamos de que :actor ha revocado tu acceso a la cuenta 2FA :twofaccount.",
|
||||
"message.notifications.twofaccount_share_revoked.subject": "Se ha revocado el uso compartido de la cuenta 2FA",
|
||||
"message.notifications.twofaccount_shared.resume_all_users": "Te informamos de que :actor ha compartido la cuenta 2FA :twofaccount con todos los usuarios.",
|
||||
"message.notifications.twofaccount_shared.resume_user": "Te informamos de que :actor ha compartido contigo la cuenta 2FA :twofaccount.",
|
||||
"message.notifications.twofaccount_shared.subject": "Se ha compartido una cuenta 2FA contigo",
|
||||
"message.notifications.twofaccount_shared.use_it_to_generate_otp": "Ahora puedes ver esta cuenta 2FA y utilizarla para generar códigos OTP mientras el uso compartido esté activo.",
|
||||
"message.notifications.regards": "Saludos",
|
||||
"message.notifications.reset_password.subject": "Restablecimiento de contraseña de 2FAuth",
|
||||
"message.notifications.reset_password.reason": "Estás recibiendo este correo porque has solicitado restablecer la contraseña de tu cuenta de 2FAuth.",
|
||||
"message.notifications.reset_password.expire_in_x_minutes": "Este enlace para restablecer la contraseña caducará en :count minutos.",
|
||||
"message.notifications.reset_password.no_action_required": "Si no has solicitado restablecer la contraseña, no es necesario realizar ninguna otra acción.",
|
||||
"message.notifications.test_email_settings.reason": "Estás recibiendo este correo electrónico porque has solicitado un correo de prueba para validar la configuración de tu instancia de 2FAuth.",
|
||||
"message.notifications.test_email_settings.subject": "Correo de prueba 2FAuth",
|
||||
"message.notifications.test_email_settings.success": "Buenas noticias, funciona correctamente :)"
|
||||
},
|
||||
"de": {
|
||||
"label.device": "Gerät",
|
||||
"label.ip_address": "IP-Adresse",
|
||||
"label.time": "Zeit",
|
||||
"link.go_to_2fauth_host": "Zu 2FAuth gehen",
|
||||
"message.browser_on_platform": ":browser auf :platform",
|
||||
"message.notifications.failed_login.connection_details": "Hier sind die Details zu diesem Verbindungsversuch",
|
||||
"message.notifications.failed_login.recommandations": "Wenn Sie dies waren, können Sie diese Warnung ignorieren. Falls weitere Versuche fehlschlagen, sollten Sie den 2FAuth-Administrator kontaktieren, um die Sicherheitseinstellungen zu überprüfen und Maßnahmen gegen diesen Angreifer zu ergreifen.",
|
||||
"message.notifications.failed_login.resume": "Es gab einen fehlgeschlagenen Anmeldeversuch auf Ihr 2FAuth Konto.",
|
||||
"message.notifications.failed_login.subject": "Anmeldung bei 2FAuth fehlgeschlagen",
|
||||
"message.notifications.hello_user": "Hallo :username,",
|
||||
"message.notifications.hello": "Hallo",
|
||||
"message.notifications.new_device.connection_details": "Hier sind die Details dieser Verbindung",
|
||||
"message.notifications.new_device.recommandations": "Wenn dies auf Sie zutrifft, können Sie diese Meldung ignorieren. Wenn Sie eine verdächtige Aktivität in Ihrem Konto vermuten, ändern Sie bitte Ihr Passwort.",
|
||||
"message.notifications.new_device.resume": "Ein neues Gerät hat sich gerade mit Ihrem 2FAuth-Konto verbunden.",
|
||||
"message.notifications.new_device.subject": "Verbindung zu 2FAuth von einem neuen Gerät",
|
||||
"message.notifications.twofaccount_ownership_transferred.check_shares": "As this 2FA account may have been shared with other users before this ownership transfer, you should review the account's sharing settings to ensure they are still appropriate.",
|
||||
"message.notifications.twofaccount_ownership_transferred.resume": "We wanted to let you know that :actor transferred ownership of 2FA account :twofaccount to you. You are now the owner of this 2FA account and have full control over it.",
|
||||
"message.notifications.twofaccount_ownership_transferred.subject": "Ownership transfer on a 2FA account",
|
||||
"message.notifications.twofaccount_share_revoked.cannot_use_it_to_generate_otp": "You can no longer view this 2FA account or use it to generate One-Time Passwords.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_all_users": "We wanted to let you know that :actor has revoked sharing of 2FA account :twofaccount for all users.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_user": "We wanted to let you know that :actor has revoked your access to 2FA account :twofaccount.",
|
||||
"message.notifications.twofaccount_share_revoked.subject": "2FA account sharing revoked",
|
||||
"message.notifications.twofaccount_shared.resume_all_users": "We wanted to let you know that :actor shared 2FA account :twofaccount with all users.",
|
||||
"message.notifications.twofaccount_shared.resume_user": "We wanted to let you know that :actor shared 2FA account :twofaccount with you.",
|
||||
"message.notifications.twofaccount_shared.subject": "A 2FA account has been shared with you",
|
||||
"message.notifications.twofaccount_shared.use_it_to_generate_otp": "You can now view this 2FA account and use it to generate One-Time Passwords as long as the share is active.",
|
||||
"message.notifications.regards": "Mit freundlichen Grüßen",
|
||||
"message.notifications.reset_password.subject": "2FAuth-Passwort zurücksetzen",
|
||||
"message.notifications.reset_password.reason": "Sie erhalten diese E-Mail, weil Sie ein Passwort für Ihr 2FAuth-Konto angefordert haben.",
|
||||
"message.notifications.reset_password.expire_in_x_minutes": "Dieser Link zum Zurücksetzen des Passworts läuft in :count Minuten ab.",
|
||||
"message.notifications.reset_password.no_action_required": "Falls Sie keine Anfrage zum Passwort zurücksetzen gestellt haben, müssen Sie nichts weiter machen.",
|
||||
"message.notifications.test_email_settings.reason": "Sie erhalten diese E-Mail, weil Sie eine Test-E-Mail angefordert haben, um die E-Mail-Einstellungen Ihrer 2FAuth Instanz zu überprüfen.",
|
||||
"message.notifications.test_email_settings.subject": "2FAuth-Test-E-Mail",
|
||||
"message.notifications.test_email_settings.success": "Gute Nachricht, es funktioniert :)"
|
||||
},
|
||||
"pt-BR": {
|
||||
"label.device": "Dispositivo",
|
||||
"label.ip_address": "Endereço IP",
|
||||
"label.time": "Hora",
|
||||
"link.go_to_2fauth_host": "Go to 2FAuth",
|
||||
"message.browser_on_platform": ":browser on :platform",
|
||||
"message.notifications.failed_login.connection_details": "Aqui estão os detalhes desta tentativa de conexão",
|
||||
"message.notifications.failed_login.recommandations": "Se foi você, pode ignorar este alerta. Se outras tentativas falharem, você deve entrar em contato com o administrador do 2FAuth para rever as configurações de segurança e tomar ações contra este atacante.",
|
||||
"message.notifications.failed_login.resume": "Houve uma tentativa de login falhada em sua conta 2FAuth.",
|
||||
"message.notifications.failed_login.subject": "Falha no login para 2FAuth",
|
||||
"message.notifications.hello_user": "Hello :username,",
|
||||
"message.notifications.hello": "Olá",
|
||||
"message.notifications.new_device.connection_details": "Aqui estão os detalhes desta conexão",
|
||||
"message.notifications.new_device.recommandations": "Se foi você, você pode ignorar este alerta. Se suspeitar de qualquer atividade suspeita em sua conta, altere sua senha imediatamente.",
|
||||
"message.notifications.new_device.resume": "Um novo dispositivo acabou de se conectar à sua conta 2FAuth.",
|
||||
"message.notifications.new_device.subject": "Conexão com o 2FAuth de um novo dispositivo",
|
||||
"message.notifications.twofaccount_ownership_transferred.check_shares": "As this 2FA account may have been shared with other users before this ownership transfer, you should review the account's sharing settings to ensure they are still appropriate.",
|
||||
"message.notifications.twofaccount_ownership_transferred.resume": "We wanted to let you know that :actor transferred ownership of 2FA account :twofaccount to you. You are now the owner of this 2FA account and have full control over it.",
|
||||
"message.notifications.twofaccount_ownership_transferred.subject": "Ownership transfer on a 2FA account",
|
||||
"message.notifications.twofaccount_share_revoked.cannot_use_it_to_generate_otp": "You can no longer view this 2FA account or use it to generate One-Time Passwords.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_all_users": "We wanted to let you know that :actor has revoked sharing of 2FA account :twofaccount for all users.",
|
||||
"message.notifications.twofaccount_share_revoked.resume_user": "We wanted to let you know that :actor has revoked your access to 2FA account :twofaccount.",
|
||||
"message.notifications.twofaccount_share_revoked.subject": "2FA account sharing revoked",
|
||||
"message.notifications.twofaccount_shared.resume_all_users": "We wanted to let you know that :actor shared 2FA account :twofaccount with all users.",
|
||||
"message.notifications.twofaccount_shared.resume_user": "We wanted to let you know that :actor shared 2FA account :twofaccount with you.",
|
||||
"message.notifications.twofaccount_shared.subject": "A 2FA account has been shared with you",
|
||||
"message.notifications.twofaccount_shared.use_it_to_generate_otp": "You can now view this 2FA account and use it to generate One-Time Passwords as long as the share is active.",
|
||||
"message.notifications.regards": "Atenciosamente",
|
||||
"message.notifications.reset_password.subject": "2FAuth Password Reset",
|
||||
"message.notifications.reset_password.reason": "You are receiving this email because you requested a password reset for your 2FAuth account.",
|
||||
"message.notifications.reset_password.expire_in_x_minutes": "This password reset link will expire in :count minutes.",
|
||||
"message.notifications.reset_password.no_action_required": "If you did not request a password reset, no further action is required.",
|
||||
"message.notifications.test_email_settings.reason": "Você está recebendo este e-mail porque você solicitou um e-mail de teste para validar as configurações de e-mail da sua instância 2FAuth.",
|
||||
"message.notifications.test_email_settings.subject": "E-mail de teste 2FAuth",
|
||||
"message.notifications.test_email_settings.success": "Boas notícias, funcionou :)"
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
|
||||
use std::{path::PathBuf, str::FromStr, sync::Arc};
|
||||
use twofauth_rs::{AppState, crypto::LaravelCipher};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(about = "Experimental 2FAuth Rust backend; not a production replacement")]
|
||||
struct Cli {
|
||||
#[arg(long, env = "DATABASE_URL")]
|
||||
database_url: Option<String>,
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Create an empty development database; refuses existing user tables.
|
||||
Init,
|
||||
/// Validate an existing pinned Laravel SQLite schema and add Rust session tables.
|
||||
PrepareSqlite,
|
||||
/// Start the experimental API on a loopback interface by default.
|
||||
Serve {
|
||||
#[arg(long, env = "LISTEN_ADDR", default_value = "127.0.0.1:8080")]
|
||||
listen: String,
|
||||
},
|
||||
/// Offline password provisioning for a NEW local test user. Reads password from file.
|
||||
CreateUser {
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
#[arg(long)]
|
||||
email: String,
|
||||
#[arg(long)]
|
||||
password_file: PathBuf,
|
||||
},
|
||||
}
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||
.init();
|
||||
let cli = Cli::parse();
|
||||
let init = matches!(cli.command, Command::Init);
|
||||
let database_url = cli.database_url.unwrap_or_else(|| {
|
||||
std::env::var("DB_DATABASE")
|
||||
.map(|p| format!("sqlite://{p}"))
|
||||
.unwrap_or_else(|_| "sqlite://data/2fauth.sqlite".into())
|
||||
});
|
||||
anyhow::ensure!(
|
||||
std::env::var("DB_CONNECTION").map_or(true, |v| v == "sqlite"),
|
||||
"This build supports SQLite; other database drivers have not been implemented"
|
||||
);
|
||||
let options = SqliteConnectOptions::from_str(&database_url)?
|
||||
.create_if_missing(init)
|
||||
.foreign_keys(true)
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.busy_timeout(std::time::Duration::from_secs(10));
|
||||
let db = SqlitePoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect_with(options)
|
||||
.await?;
|
||||
if init {
|
||||
let exists: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'",
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
exists == 0,
|
||||
"refusing to initialize an existing 2FAuth database"
|
||||
);
|
||||
twofauth_rs::db::init(&db).await?;
|
||||
twofauth_rs::auth::init(&db).await?;
|
||||
println!("Development database initialized; no production compatibility is implied.");
|
||||
return Ok(());
|
||||
}
|
||||
if matches!(cli.command, Command::PrepareSqlite) {
|
||||
twofauth_rs::db::validate_existing(&db).await?;
|
||||
twofauth_rs::auth::init(&db).await?;
|
||||
twofauth_rs::sso::init(&db).await?;
|
||||
twofauth_rs::webauthn::init(&db).await?;
|
||||
println!(
|
||||
"Schema validated and Rust session tables prepared. Existing application tables were preserved."
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if let Command::CreateUser {
|
||||
name,
|
||||
email,
|
||||
password_file,
|
||||
} = cli.command
|
||||
{
|
||||
let password = std::fs::read_to_string(password_file)?;
|
||||
let password = password.trim_end_matches(['\n', '\r']).to_owned();
|
||||
anyhow::ensure!(
|
||||
password.len() >= 12 && password.len() <= 72,
|
||||
"test password must contain 12..72 bytes"
|
||||
);
|
||||
let hash =
|
||||
tokio::task::spawn_blocking(move || bcrypt::hash(password, bcrypt::DEFAULT_COST))
|
||||
.await??;
|
||||
sqlx::query("INSERT INTO users(name,email,password,is_admin,preferences,created_at,updated_at) VALUES(?,?,?,0,'{}',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)")
|
||||
.bind(name).bind(email.to_lowercase()).bind(hash).execute(&db).await?;
|
||||
println!("Non-administrator test user created.");
|
||||
return Ok(());
|
||||
}
|
||||
let encrypt_db = twofauth_rs::settings::all(&db)
|
||||
.await?
|
||||
.get("useEncryption")
|
||||
.map(twofauth_rs::settings::php_bool)
|
||||
.unwrap_or(false);
|
||||
let previous_keys: Vec<String> = std::env::var("APP_PREVIOUS_KEYS")
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|key| !key.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let cipher = std::env::var("APP_KEY")
|
||||
.ok()
|
||||
.map(|k| LaravelCipher::from_keys(&k, &previous_keys))
|
||||
.transpose()?
|
||||
.map(Arc::new);
|
||||
anyhow::ensure!(
|
||||
!encrypt_db || cipher.is_some(),
|
||||
"encrypted database requires original APP_KEY"
|
||||
);
|
||||
let Command::Serve { listen } = cli.command else {
|
||||
unreachable!()
|
||||
};
|
||||
// Existing application data is never recreated. Only private Rust session/
|
||||
// ceremony metadata is added after checking the application's table shape.
|
||||
if sqlx::query("SELECT token_hash FROM rust_sessions LIMIT 0")
|
||||
.execute(&db)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
twofauth_rs::db::validate_existing(&db).await?;
|
||||
twofauth_rs::auth::init(&db).await?;
|
||||
}
|
||||
twofauth_rs::oauth_server::init(&db).await?;
|
||||
twofauth_rs::oauth_management::init(&db).await?;
|
||||
twofauth_rs::oauth_device::init(&db).await?;
|
||||
twofauth_rs::api_limits::init(&db).await?;
|
||||
twofauth_rs::sso::init(&db).await?;
|
||||
twofauth_rs::webauthn::init(&db).await?;
|
||||
let app = twofauth_rs::router(AppState {
|
||||
db,
|
||||
cipher,
|
||||
encrypt_db: std::sync::atomic::AtomicBool::new(encrypt_db).into(),
|
||||
});
|
||||
tracing::warn!("Experimental backend: do not switch production traffic to this implementation");
|
||||
let listener = tokio::net::TcpListener::bind(&listen).await?;
|
||||
tracing::info!(address=%listener.local_addr()?, "listening");
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||
)
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
//! Passport 13 / League device grant, using the original device-code table.
|
||||
//! The pinned application's device browser views are unbound by default. Polling
|
||||
//! nevertheless works with approvals persisted by its PHP device controller.
|
||||
use crate::{
|
||||
AppState,
|
||||
oauth_server::{self, OAuthError, Parameters},
|
||||
};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Request, State},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use rand::{Rng, rngs::OsRng};
|
||||
use serde_json::json;
|
||||
use sqlx::{Row, SqlitePool, sqlite::SqliteRow};
|
||||
|
||||
pub const GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code";
|
||||
const CONSONANTS: &[u8] = b"BCDFGHJKLMNPQRSTVWXZ";
|
||||
const PENDING: &str = "The authorization request is still pending as the end user hasn't yet completed the user interaction steps. The client SHOULD repeat the Access Token Request to the token endpoint";
|
||||
const SLOW_DOWN: &str = "The authorization request is still pending and polling should continue, but the interval MUST be increased by 5 seconds for this and all subsequent requests.";
|
||||
|
||||
pub async fn init(db: &SqlitePool) -> Result<(), sqlx::Error> {
|
||||
sqlx::raw_sql("CREATE TABLE IF NOT EXISTS oauth_device_codes(id TEXT PRIMARY KEY,user_id INTEGER,client_id TEXT NOT NULL,user_code TEXT NOT NULL UNIQUE,scopes TEXT NOT NULL,revoked INTEGER NOT NULL DEFAULT 0,user_approved_at TEXT,last_polled_at TEXT,expires_at TEXT); CREATE INDEX IF NOT EXISTS oauth_device_codes_user_id_index ON oauth_device_codes(user_id); CREATE INDEX IF NOT EXISTS oauth_device_codes_client_id_index ON oauth_device_codes(client_id);").execute(db).await?;
|
||||
crate::api_limits::init(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
pub fn routes(state: AppState) -> Router<AppState> {
|
||||
let issuer = Router::new()
|
||||
.route("/oauth/device/code", post(issue))
|
||||
.route_layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
crate::api_limits::handle_oauth,
|
||||
));
|
||||
let protected = Router::new()
|
||||
.route(
|
||||
"/oauth/device/authorize",
|
||||
get(broken_view)
|
||||
.post(missing_auth_token)
|
||||
.delete(missing_auth_token),
|
||||
)
|
||||
.route_layer(axum::middleware::from_fn_with_state(
|
||||
state,
|
||||
crate::auth::require_web_auth,
|
||||
));
|
||||
issuer
|
||||
.merge(protected)
|
||||
.route("/oauth/device", get(broken_view))
|
||||
}
|
||||
async fn broken_view() -> Response {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
[(header::CACHE_CONTROL, "no-cache, private")],
|
||||
Json(json!({"message":"Server Error"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
async fn missing_auth_token() -> Response {
|
||||
(StatusCode::FORBIDDEN, [(header::CACHE_CONTROL,"no-cache, private")], Json(json!({"message":"forbidden","reason":"The provided auth token for the request is different from the session auth token."}))).into_response()
|
||||
}
|
||||
struct Client {
|
||||
id: String,
|
||||
grants: Vec<String>,
|
||||
}
|
||||
async fn client(
|
||||
db: &SqlitePool,
|
||||
headers: &HeaderMap,
|
||||
input: &Parameters,
|
||||
authenticate: bool,
|
||||
) -> Result<Client, OAuthError> {
|
||||
let basic = oauth_server::basic_credentials(headers);
|
||||
let id = oauth_server::parameter(input, "client_id")?
|
||||
.or_else(|| {
|
||||
basic
|
||||
.as_ref()
|
||||
.map(|value| oauth_server::php_trim(&value.0).to_owned())
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
.ok_or_else(|| oauth_server::invalid_request("client_id"))?;
|
||||
// Device authorization issuance intentionally does not authenticate the secret.
|
||||
// Polling uses the normal confidential-client validation order instead.
|
||||
let supplied = if authenticate {
|
||||
oauth_server::parameter(input, "client_secret")?.or_else(|| {
|
||||
basic
|
||||
.as_ref()
|
||||
.map(|value| oauth_server::php_trim(&value.1).to_owned())
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let row = sqlx::query(
|
||||
"SELECT secret,grant_types FROM oauth_clients WHERE CAST(id AS TEXT)=? AND revoked=0",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.ok_or_else(|| oauth_server::invalid_client(headers))?;
|
||||
let grants: Vec<String> = serde_json::from_str(&row.get::<String, _>("grant_types"))
|
||||
.map_err(|_| oauth_server::invalid_client(headers))?;
|
||||
if !grants.iter().any(|grant| grant == GRANT) {
|
||||
return Err(OAuthError::new(
|
||||
"unauthorized_client",
|
||||
"The authenticated client is not authorized to use this authorization grant type.",
|
||||
));
|
||||
}
|
||||
if authenticate
|
||||
&& let Some(hash) = row
|
||||
.get::<Option<String>, _>("secret")
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
let secret = supplied
|
||||
.as_deref()
|
||||
.ok_or_else(|| oauth_server::invalid_request("client_secret"))?;
|
||||
if !oauth_server::verify_secret(secret, &hash) {
|
||||
return Err(oauth_server::invalid_client(headers));
|
||||
}
|
||||
}
|
||||
Ok(Client { id, grants })
|
||||
}
|
||||
fn verification_uri(headers: &HeaderMap) -> String {
|
||||
let mut base = std::env::var("APP_URL")
|
||||
.ok()
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"http://{}",
|
||||
headers
|
||||
.get(header::HOST)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("localhost")
|
||||
)
|
||||
})
|
||||
.trim_end_matches('/')
|
||||
.to_owned();
|
||||
let prefix = crate::subdirectory::configured_prefix();
|
||||
if !prefix.is_empty() && !base.ends_with(&prefix) {
|
||||
base.push_str(&prefix);
|
||||
}
|
||||
format!("{base}/oauth/device")
|
||||
}
|
||||
async fn issue(State(state): State<AppState>, request: Request) -> Result<Response, OAuthError> {
|
||||
let (headers, input) = match oauth_server::read_request(request).await {
|
||||
Ok(value) => value,
|
||||
Err(response) => return Ok(*response),
|
||||
};
|
||||
let uri = verification_uri(&headers);
|
||||
issue_code(&state, &headers, &input, &uri).await
|
||||
}
|
||||
async fn issue_code(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
input: &Parameters,
|
||||
uri: &str,
|
||||
) -> Result<Response, OAuthError> {
|
||||
let client = client(&state.db, headers, input, false).await?;
|
||||
let scopes = oauth_server::scope(oauth_server::parameter(input, "scope")?.as_ref())?;
|
||||
let now = Utc::now();
|
||||
let expires = now + chrono::Duration::seconds(600);
|
||||
for _ in 0..10 {
|
||||
let id = oauth_server::random_id();
|
||||
let mut rng = OsRng;
|
||||
let user_code: String = (0..8)
|
||||
.map(|_| CONSONANTS[rng.gen_range(0..CONSONANTS.len())] as char)
|
||||
.collect();
|
||||
let result=sqlx::query("INSERT INTO oauth_device_codes(id,user_id,client_id,user_code,scopes,revoked,user_approved_at,last_polled_at,expires_at) VALUES(?,NULL,?,?,?,0,NULL,NULL,?)").bind(&id).bind(&client.id).bind(&user_code).bind(serde_json::to_string(&scopes).unwrap()).bind(expires.format("%Y-%m-%d %H:%M:%S").to_string()).execute(&state.db).await;
|
||||
match result {
|
||||
Ok(_)=>return Ok(([(header::CACHE_CONTROL,"no-store, private"),(header::PRAGMA,"no-cache"),(header::CONTENT_TYPE,"application/json; charset=UTF-8")],Json(json!({"device_code":id,"user_code":user_code,"verification_uri":uri,"expires_in":expires.timestamp()-Utc::now().timestamp(),"verification_uri_complete":format!("{uri}?user_code={user_code}"),"interval":5}))).into_response()),
|
||||
Err(sqlx::Error::Database(error)) if error.is_unique_violation()=>continue,
|
||||
Err(error)=>return Err(error.into()),
|
||||
}
|
||||
}
|
||||
Err(OAuthError::server("Could not generate a random string"))
|
||||
}
|
||||
fn expired() -> OAuthError {
|
||||
OAuthError::new(
|
||||
"expired_token",
|
||||
"The `device_code` has expired and the device authorization session has concluded.",
|
||||
)
|
||||
.hint("device_code")
|
||||
}
|
||||
fn expiry(row: &SqliteRow) -> Result<i64, OAuthError> {
|
||||
let value: Option<String> = row.try_get("expires_at")?;
|
||||
value
|
||||
.and_then(|value| chrono::NaiveDateTime::parse_from_str(&value, "%Y-%m-%d %H:%M:%S").ok())
|
||||
.map(|value| value.and_utc().timestamp())
|
||||
.ok_or_else(|| OAuthError::server("An unexpected error has occurred"))
|
||||
}
|
||||
pub(crate) async fn issue_token(
|
||||
state: AppState,
|
||||
headers: HeaderMap,
|
||||
input: Parameters,
|
||||
) -> Result<Response, OAuthError> {
|
||||
let client = client(&state.db, &headers, &input, true).await?;
|
||||
let code = oauth_server::parameter(&input, "device_code")?
|
||||
.ok_or_else(|| oauth_server::invalid_request("device_code"))?;
|
||||
let row = sqlx::query("SELECT * FROM oauth_device_codes WHERE id=? AND revoked=0")
|
||||
.bind(&code)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or_else(|| oauth_server::grant_error_hint(""))?;
|
||||
let now = Utc::now();
|
||||
if expiry(&row)? < now.timestamp() {
|
||||
return Err(expired());
|
||||
}
|
||||
if row.get::<String, _>("client_id") != client.id {
|
||||
return Err(oauth_server::invalid_request_hint(
|
||||
"Device code was not issued to this client",
|
||||
));
|
||||
}
|
||||
let user: Option<i64> = row.try_get("user_id")?;
|
||||
if user.is_none() {
|
||||
let at = now.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
let cutoff = (now - chrono::Duration::seconds(5))
|
||||
.format("%Y-%m-%d %H:%M:%S")
|
||||
.to_string();
|
||||
// One atomic gate lets only one concurrent first poll return pending.
|
||||
let pending=sqlx::query("UPDATE oauth_device_codes SET last_polled_at=? WHERE id=? AND revoked=0 AND user_id IS NULL AND (last_polled_at IS NULL OR datetime(last_polled_at)<=datetime(?))").bind(&at).bind(&code).bind(cutoff).execute(&state.db).await?.rows_affected();
|
||||
if pending == 1 {
|
||||
return Err(OAuthError::new("authorization_pending", PENDING).hint(""));
|
||||
}
|
||||
let active=sqlx::query("UPDATE oauth_device_codes SET last_polled_at=? WHERE id=? AND revoked=0 AND user_id IS NULL").bind(at).bind(&code).execute(&state.db).await?.rows_affected();
|
||||
if active == 0 {
|
||||
return Err(oauth_server::grant_error_hint(""));
|
||||
}
|
||||
return Err(OAuthError::new("slow_down", SLOW_DOWN).hint(""));
|
||||
}
|
||||
let user = user.unwrap();
|
||||
if row.get::<Option<String>, _>("user_approved_at").is_none() {
|
||||
return Err(OAuthError::new(
|
||||
"access_denied",
|
||||
"The resource owner or authorization server denied the request.",
|
||||
));
|
||||
}
|
||||
// The pinned application registers only wildcard scope; Passport strips it
|
||||
// from device tokens, and filters any unregistered persisted scopes.
|
||||
let _: Vec<String> = serde_json::from_str(&row.get::<String, _>("scopes"))
|
||||
.map_err(|_| OAuthError::server("An unexpected error has occurred"))?;
|
||||
let scopes: Vec<String> = Vec::new();
|
||||
let expires = crate::passport::one_year_from(now)
|
||||
.ok_or_else(|| OAuthError::server("Unable to determine token expiry"))?;
|
||||
let token_id = oauth_server::random_id();
|
||||
let access = crate::passport::sign_access_token(
|
||||
&client.id,
|
||||
&user.to_string(),
|
||||
&token_id,
|
||||
scopes.clone(),
|
||||
now.timestamp(),
|
||||
expires.timestamp(),
|
||||
)?;
|
||||
let refresh = if client.grants.iter().any(|grant| grant == "refresh_token") {
|
||||
let id = oauth_server::random_id();
|
||||
let payload = json!({"client_id":client.id,"refresh_token_id":id,"access_token_id":token_id,"scopes":scopes,"user_id":user.to_string(),"expire_time":expires.timestamp()});
|
||||
Some((id, oauth_server::encrypt_payload(&payload)?))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut tx = state.db.begin().await?;
|
||||
let changed=sqlx::query("UPDATE oauth_device_codes SET revoked=1 WHERE id=? AND revoked=0 AND user_id=? AND user_approved_at IS NOT NULL").bind(&code).bind(user).execute(&mut *tx).await?.rows_affected();
|
||||
if changed != 1 {
|
||||
return Err(oauth_server::grant_error_hint(""));
|
||||
}
|
||||
sqlx::query("INSERT INTO oauth_access_tokens(id,user_id,client_id,scopes,revoked,created_at,updated_at,expires_at) VALUES(?,?,?,'[]',0,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)").bind(&token_id).bind(user).bind(&client.id).bind(expires.format("%Y-%m-%d %H:%M:%S").to_string()).execute(&mut *tx).await?;
|
||||
if let Some((id, _)) = &refresh {
|
||||
sqlx::query("INSERT INTO oauth_refresh_tokens(id,access_token_id,revoked,expires_at) VALUES(?,?,0,?)").bind(id).bind(&token_id).bind(expires.format("%Y-%m-%d %H:%M:%S").to_string()).execute(&mut *tx).await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
let mut body = json!({"token_type":"Bearer","expires_in":expires.timestamp()-Utc::now().timestamp(),"access_token":access});
|
||||
if let Some((_, token)) = refresh {
|
||||
body["refresh_token"] = json!(token);
|
||||
}
|
||||
Ok((
|
||||
[
|
||||
(header::CACHE_CONTROL, "no-store, private"),
|
||||
(header::PRAGMA, "no-cache"),
|
||||
(header::CONTENT_TYPE, "application/json; charset=UTF-8"),
|
||||
],
|
||||
Json(body),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::Value;
|
||||
fn input(values: &[(&str, &str)]) -> Parameters {
|
||||
values
|
||||
.iter()
|
||||
.map(|(key, value)| (key.to_string(), json!(value)))
|
||||
.collect()
|
||||
}
|
||||
async fn fixture() -> AppState {
|
||||
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
crate::db::init(&db).await.unwrap();
|
||||
crate::passport::init(&db).await.unwrap();
|
||||
init(&db).await.unwrap();
|
||||
sqlx::query("INSERT INTO oauth_clients(id,name,secret,redirect_uris,grant_types) VALUES(1,'device',NULL,'[]',?),(2,'other',NULL,'[]',?)").bind(json!([GRANT,"refresh_token"]).to_string()).bind(json!([GRANT]).to_string()).execute(&db).await.unwrap();
|
||||
AppState {
|
||||
db,
|
||||
cipher: None,
|
||||
encrypt_db: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
fn php_case(label: &str) -> Value {
|
||||
let report: Value = serde_json::from_str(include_str!(
|
||||
"../compatibility/oauth-device-php-reference.json"
|
||||
))
|
||||
.unwrap();
|
||||
report["results"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|case| case["case"] == label)
|
||||
.unwrap()
|
||||
.clone()
|
||||
}
|
||||
async fn assert_php(label: &str, response: Response) -> Value {
|
||||
let expected = php_case(label);
|
||||
assert_eq!(
|
||||
response.status().as_u16(),
|
||||
expected["status"].as_u64().unwrap() as u16,
|
||||
"{label}"
|
||||
);
|
||||
for name in [
|
||||
"Content-Type",
|
||||
"Cache-Control",
|
||||
"Pragma",
|
||||
"WWW-Authenticate",
|
||||
] {
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(name)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
expected["headers"][name].as_str(),
|
||||
"{label}:{name}"
|
||||
);
|
||||
}
|
||||
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let body: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(body, expected["body"], "{label}");
|
||||
body
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn issuer_and_pending_poll_match_real_php_and_apply_backoff() {
|
||||
let state = fixture().await;
|
||||
let error = issue_code(
|
||||
&state,
|
||||
&HeaderMap::new(),
|
||||
&Parameters::new(),
|
||||
"https://example.invalid/oauth/device",
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_php("issuer-missing-client", error.into_response()).await;
|
||||
let response = issue_code(
|
||||
&state,
|
||||
&HeaderMap::new(),
|
||||
&input(&[("client_id", "1"), ("scope", "*")]),
|
||||
"https://example.invalid/oauth/device",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()[header::CACHE_CONTROL],
|
||||
"no-store, private"
|
||||
);
|
||||
let body: Value =
|
||||
serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes())
|
||||
.unwrap();
|
||||
let code = body["device_code"].as_str().unwrap();
|
||||
let user_code = body["user_code"].as_str().unwrap();
|
||||
assert_eq!(code.len(), 80);
|
||||
assert!(code.bytes().all(|value| value.is_ascii_hexdigit()));
|
||||
assert_eq!(user_code.len(), 8);
|
||||
assert!(user_code.bytes().all(|value| CONSONANTS.contains(&value)));
|
||||
assert_eq!(body["interval"], 5);
|
||||
assert!((595..=600).contains(&body["expires_in"].as_i64().unwrap()));
|
||||
assert_eq!(
|
||||
body["verification_uri_complete"],
|
||||
format!("https://example.invalid/oauth/device?user_code={user_code}")
|
||||
);
|
||||
let parameters = input(&[("client_id", "1"), ("device_code", code)]);
|
||||
let error = issue_token(state.clone(), HeaderMap::new(), parameters.clone())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_php("poll-pending", error.into_response()).await;
|
||||
let error = issue_token(state.clone(), HeaderMap::new(), parameters.clone())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_php("poll-too-soon", error.into_response()).await;
|
||||
sqlx::query(
|
||||
"UPDATE oauth_device_codes SET last_polled_at=datetime('now','-6 seconds') WHERE id=?",
|
||||
)
|
||||
.bind(code)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let error = issue_token(state, HeaderMap::new(), parameters)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_php("poll-pending", error.into_response()).await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn unknown_revoked_expired_denied_and_wrong_client_codes_are_distinct() {
|
||||
let state = fixture().await;
|
||||
let missing = input(&[("client_id", "1")]);
|
||||
let error = issue_token(state.clone(), HeaderMap::new(), missing)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_php("poll-missing-device-code", error.into_response()).await;
|
||||
let parameters = input(&[("client_id", "1"), ("device_code", "unknown")]);
|
||||
let error = issue_token(state.clone(), HeaderMap::new(), parameters)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_php("poll-unknown-device-code", error.into_response()).await;
|
||||
sqlx::query("INSERT INTO oauth_device_codes(id,client_id,user_code,scopes,revoked,expires_at) VALUES('test','1','BCDFGHJK','[]',0,datetime('now','+10 minutes'))").execute(&state.db).await.unwrap();
|
||||
let error = issue_token(
|
||||
state.clone(),
|
||||
HeaderMap::new(),
|
||||
input(&[("client_id", "2"), ("device_code", "test")]),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let response = error.into_response();
|
||||
let body: Value =
|
||||
serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes())
|
||||
.unwrap();
|
||||
assert_eq!(body["hint"], "Device code was not issued to this client");
|
||||
sqlx::query(
|
||||
"UPDATE oauth_device_codes SET expires_at=datetime('now','-1 second') WHERE id='test'",
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let error = issue_token(
|
||||
state.clone(),
|
||||
HeaderMap::new(),
|
||||
input(&[("client_id", "1"), ("device_code", "test")]),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let response = error.into_response();
|
||||
let body: Value =
|
||||
serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes())
|
||||
.unwrap();
|
||||
assert_eq!(body["error"], "expired_token");
|
||||
assert_eq!(body["hint"], "device_code");
|
||||
sqlx::query("UPDATE oauth_device_codes SET expires_at=datetime('now','+10 minutes'),user_id=1,user_approved_at=NULL WHERE id='test'").execute(&state.db).await.unwrap();
|
||||
let response = issue_token(
|
||||
state.clone(),
|
||||
HeaderMap::new(),
|
||||
input(&[("client_id", "1"), ("device_code", "test")]),
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.into_response();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
let body: Value =
|
||||
serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
body,
|
||||
json!({"error":"access_denied","error_description":"The resource owner or authorization server denied the request."})
|
||||
);
|
||||
sqlx::query("UPDATE oauth_device_codes SET revoked=1 WHERE id='test'")
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let error = issue_token(
|
||||
state,
|
||||
HeaderMap::new(),
|
||||
input(&[("client_id", "1"), ("device_code", "test")]),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_php("poll-unknown-device-code", error.into_response()).await;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+1084
File diff suppressed because it is too large.
Load diff
+368
@@ -0,0 +1,368 @@
|
||||
//! OTP primitives and provisioning URIs. Compatibility covers the documented
|
||||
//! algorithms; provider-specific import extensions are intentionally separate.
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use data_encoding::BASE32_NOPAD;
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct OtpConfig {
|
||||
pub otp_type: String,
|
||||
pub account: String,
|
||||
pub service: Option<String>,
|
||||
pub secret: String,
|
||||
pub algorithm: String,
|
||||
pub digits: u32,
|
||||
pub period: Option<u64>,
|
||||
pub counter: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for OtpConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
otp_type: "totp".into(),
|
||||
account: String::new(),
|
||||
service: None,
|
||||
secret: String::new(),
|
||||
algorithm: "sha1".into(),
|
||||
digits: 6,
|
||||
period: Some(30),
|
||||
counter: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_secret(secret: &str) -> Result<Vec<u8>> {
|
||||
let normalized = secret.trim().trim_end_matches('=').to_ascii_uppercase();
|
||||
if normalized.is_empty() {
|
||||
bail!("secret must not be empty");
|
||||
}
|
||||
BASE32_NOPAD
|
||||
.decode(normalized.as_bytes())
|
||||
.map_err(|_| anyhow!("secret must be valid base32"))
|
||||
}
|
||||
|
||||
impl OtpConfig {
|
||||
pub fn normalize(&mut self) {
|
||||
self.otp_type = self.otp_type.to_ascii_lowercase();
|
||||
self.algorithm = self.algorithm.to_ascii_lowercase();
|
||||
self.secret = self.secret.trim().to_ascii_uppercase();
|
||||
if !self.secret.is_empty() {
|
||||
let padded_length = self.secret.len().div_ceil(8) * 8;
|
||||
self.secret
|
||||
.extend(std::iter::repeat_n('=', padded_length - self.secret.len()));
|
||||
}
|
||||
if self.otp_type == "steam"
|
||||
|| self.otp_type == "steamtotp"
|
||||
|| self
|
||||
.service
|
||||
.as_deref()
|
||||
.is_some_and(|s| s.eq_ignore_ascii_case("steam"))
|
||||
{
|
||||
self.otp_type = "steamtotp".into();
|
||||
self.service = Some("Steam".into());
|
||||
self.algorithm = "sha1".into();
|
||||
self.digits = 5;
|
||||
self.period = Some(30);
|
||||
self.counter = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if !matches!(self.otp_type.as_str(), "totp" | "hotp" | "steamtotp") {
|
||||
bail!("unsupported otp_type");
|
||||
}
|
||||
if self.account.trim().is_empty() || self.account.contains(':') {
|
||||
bail!("account is required and cannot contain ':'");
|
||||
}
|
||||
if self.service.as_deref().is_some_and(|s| s.contains(':')) {
|
||||
bail!("service cannot contain ':'");
|
||||
}
|
||||
decode_secret(&self.secret)?;
|
||||
if !matches!(
|
||||
self.algorithm.as_str(),
|
||||
"sha1" | "sha256" | "sha512" | "md5"
|
||||
) {
|
||||
bail!("unsupported algorithm");
|
||||
}
|
||||
if !(5..=10).contains(&self.digits) {
|
||||
bail!("digits must be between 5 and 10");
|
||||
}
|
||||
if self.otp_type != "hotp" && self.period.unwrap_or(30) == 0 {
|
||||
bail!("period must be positive");
|
||||
}
|
||||
if self.otp_type == "steamtotp"
|
||||
&& (self.algorithm != "sha1" || self.digits != 5 || self.period != Some(30))
|
||||
{
|
||||
bail!("invalid Steam parameters");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn generate(&self, timestamp: u64) -> Result<String> {
|
||||
self.validate()?;
|
||||
let counter = if self.otp_type == "hotp" {
|
||||
self.counter.unwrap_or(0)
|
||||
} else {
|
||||
timestamp / self.period.unwrap_or(30)
|
||||
};
|
||||
let value = truncated(&decode_secret(&self.secret)?, counter, &self.algorithm)?;
|
||||
if self.otp_type == "steamtotp" {
|
||||
const CHARS: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
|
||||
let mut n = value;
|
||||
let mut result = String::with_capacity(5);
|
||||
for _ in 0..5 {
|
||||
result.push(CHARS[(n % 26) as usize] as char);
|
||||
n /= 26;
|
||||
}
|
||||
Ok(result)
|
||||
} else {
|
||||
Ok(format!(
|
||||
"{:0width$}",
|
||||
u64::from(value) % 10_u64.pow(self.digits),
|
||||
width = self.digits as usize
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_uri(&self) -> Result<String> {
|
||||
self.validate()?;
|
||||
// 2FAuth exports Steam using a TOTP URI with issuer=Steam.
|
||||
let kind = if self.otp_type == "hotp" {
|
||||
"hotp"
|
||||
} else {
|
||||
"totp"
|
||||
};
|
||||
let label = match self.service.as_deref().filter(|s| !s.is_empty()) {
|
||||
Some(s) => format!("{s}:{}", self.account),
|
||||
None => self.account.clone(),
|
||||
};
|
||||
let mut uri = Url::parse(&format!("otpauth://{kind}/"))?;
|
||||
uri.path_segments_mut()
|
||||
.map_err(|_| anyhow!("invalid URI"))?
|
||||
.push(&label);
|
||||
let mut query = uri.query_pairs_mut();
|
||||
query.append_pair("secret", &self.secret);
|
||||
if let Some(service) = self.service.as_deref().filter(|s| !s.is_empty()) {
|
||||
query.append_pair("issuer", service);
|
||||
}
|
||||
query.append_pair("algorithm", &self.algorithm.to_ascii_uppercase());
|
||||
query.append_pair("digits", &self.digits.to_string());
|
||||
if kind == "hotp" {
|
||||
query.append_pair("counter", &self.counter.unwrap_or(0).to_string());
|
||||
} else {
|
||||
query.append_pair("period", &self.period.unwrap_or(30).to_string());
|
||||
}
|
||||
drop(query);
|
||||
Ok(uri.into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_uri(input: &str) -> Result<OtpConfig> {
|
||||
// Match upstream's Microsoft guest-label '#' sanitization.
|
||||
let uri = Url::parse(&input.replace('#', "%23"))?;
|
||||
if uri.scheme() != "otpauth"
|
||||
|| !uri.username().is_empty()
|
||||
|| uri.password().is_some()
|
||||
|| uri.port().is_some()
|
||||
{
|
||||
bail!("invalid otpauth URI");
|
||||
}
|
||||
let kind = uri.host_str().ok_or_else(|| anyhow!("missing OTP type"))?;
|
||||
if !matches!(kind, "totp" | "hotp" | "steam") {
|
||||
bail!("unsupported OTP type");
|
||||
}
|
||||
let label = percent_encoding::percent_decode_str(uri.path().trim_start_matches('/'))
|
||||
.decode_utf8()?
|
||||
.into_owned();
|
||||
let (prefix, account) = label
|
||||
.split_once(':')
|
||||
.map_or((None, label.as_str()), |(s, a)| {
|
||||
(Some(s.to_string()), a.trim_start())
|
||||
});
|
||||
let mut config = OtpConfig {
|
||||
otp_type: kind.into(),
|
||||
account: account.into(),
|
||||
service: prefix.clone(),
|
||||
period: if kind == "hotp" { None } else { Some(30) },
|
||||
counter: if kind == "hotp" { Some(0) } else { None },
|
||||
..Default::default()
|
||||
};
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (key, value) in uri.query_pairs() {
|
||||
if !seen.insert(key.to_string()) {
|
||||
bail!("duplicate URI parameter: {key}");
|
||||
}
|
||||
match key.as_ref() {
|
||||
"secret" => config.secret = value.into_owned(),
|
||||
"issuer" => {
|
||||
if !value.eq_ignore_ascii_case("Microsoft")
|
||||
&& prefix.as_ref().is_some_and(|s| s != value.as_ref())
|
||||
{
|
||||
bail!("issuer does not match label prefix");
|
||||
}
|
||||
config.service = Some(value.into_owned());
|
||||
}
|
||||
"algorithm" => config.algorithm = value.to_ascii_lowercase(),
|
||||
"digits" => config.digits = value.parse().map_err(|_| anyhow!("invalid digits"))?,
|
||||
"period" => config.period = Some(value.parse().map_err(|_| anyhow!("invalid period"))?),
|
||||
"counter" => {
|
||||
config.counter = Some(value.parse().map_err(|_| anyhow!("invalid counter"))?)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
config.normalize();
|
||||
config.validate()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn truncated(secret: &[u8], counter: u64, algorithm: &str) -> Result<u32> {
|
||||
macro_rules! digest {
|
||||
($typ:ty) => {{
|
||||
let mut mac =
|
||||
Hmac::<$typ>::new_from_slice(secret).map_err(|_| anyhow!("invalid HMAC key"))?;
|
||||
mac.update(&counter.to_be_bytes());
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}};
|
||||
}
|
||||
let digest = match algorithm {
|
||||
"sha1" => digest!(sha1::Sha1),
|
||||
"sha256" => digest!(sha2::Sha256),
|
||||
"sha512" => digest!(sha2::Sha512),
|
||||
"md5" => digest!(md5::Md5),
|
||||
_ => bail!("unsupported algorithm"),
|
||||
};
|
||||
let offset = usize::from(digest[digest.len() - 1] & 15);
|
||||
// MD5 has only 16 bytes; upstream permits it but dynamic truncation may fail.
|
||||
let bytes: [u8; 4] = digest
|
||||
.get(offset..offset + 4)
|
||||
.ok_or_else(|| anyhow!("digest too short for OTP truncation"))?
|
||||
.try_into()?;
|
||||
Ok(u32::from_be_bytes(bytes) & 0x7fff_ffff)
|
||||
}
|
||||
|
||||
/// PHP urldecode semantics used by export and decoded QR text.
|
||||
pub fn php_urldecode(value: &str) -> String {
|
||||
percent_encoding::percent_decode_str(&value.replace('+', " "))
|
||||
.decode_utf8_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn config() -> OtpConfig {
|
||||
OtpConfig {
|
||||
account: "alice@example.com".into(),
|
||||
secret: BASE32_NOPAD.encode(b"12345678901234567890"),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn rfc4226() {
|
||||
let mut c = config();
|
||||
c.otp_type = "hotp".into();
|
||||
for (i, expected) in [
|
||||
"755224", "287082", "359152", "969429", "338314", "254676", "287922", "162583",
|
||||
"399871", "520489",
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
c.counter = Some(i as u64);
|
||||
assert_eq!(c.generate(0).unwrap(), *expected);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn rfc6238() {
|
||||
let cases = [
|
||||
(59_u64, ["94287082", "46119246", "90693936"]),
|
||||
(1_111_111_109, ["07081804", "68084774", "25091201"]),
|
||||
(1_111_111_111, ["14050471", "67062674", "99943326"]),
|
||||
(1_234_567_890, ["89005924", "91819424", "93441116"]),
|
||||
(2_000_000_000, ["69279037", "90698825", "38618901"]),
|
||||
(20_000_000_000, ["65353130", "77737706", "47863826"]),
|
||||
];
|
||||
for (index, (algorithm, key)) in [
|
||||
("sha1", b"12345678901234567890".as_slice()),
|
||||
("sha256", b"12345678901234567890123456789012".as_slice()),
|
||||
(
|
||||
"sha512",
|
||||
b"1234567890123456789012345678901234567890123456789012345678901234".as_slice(),
|
||||
),
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let mut c = config();
|
||||
c.algorithm = (*algorithm).into();
|
||||
c.digits = 8;
|
||||
c.secret = BASE32_NOPAD.encode(key);
|
||||
for (timestamp, expected) in cases {
|
||||
assert_eq!(
|
||||
c.generate(timestamp).unwrap(),
|
||||
expected[index],
|
||||
"{algorithm} at {timestamp}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn upstream_uri_defaults_and_microsoft_guest_label() {
|
||||
// OtpTestData::HOTP_SHORT_URI and the two Microsoft corporate cases.
|
||||
let c = parse_uri("otpauth://hotp/account?secret=A4GRFHVVRBGY7UIW").unwrap();
|
||||
assert_eq!(c.counter, Some(0));
|
||||
assert_eq!(c.period, None);
|
||||
assert_eq!(c.digits, 6);
|
||||
let c=parse_uri("otpauth://totp/MyOrganization:account#EXT#@microsoft.com?secret=A4GRFHVVRBGY7UIW&issuer=Microsoft").unwrap();
|
||||
assert_eq!(c.service.as_deref(), Some("Microsoft"));
|
||||
assert_eq!(c.account, "account#EXT#@microsoft.com");
|
||||
let roundtrip = parse_uri(&c.to_uri().unwrap()).unwrap();
|
||||
assert_eq!(roundtrip.account, c.account);
|
||||
}
|
||||
#[test]
|
||||
fn preserves_base32_padding() {
|
||||
// Helpers::PadToBase32Format pads short/lowercase secrets on storage.
|
||||
for secret in ["my", "MY======"] {
|
||||
let c = parse_uri(&format!("otpauth://totp/account?secret={secret}")).unwrap();
|
||||
assert_eq!(c.secret, "MY======");
|
||||
assert_eq!(parse_uri(&c.to_uri().unwrap()).unwrap().secret, "MY======");
|
||||
assert_eq!(decode_secret(&c.secret).unwrap(), b"f");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn uri_roundtrip() {
|
||||
let mut c = config();
|
||||
c.service = Some("Example Co".into());
|
||||
let d = parse_uri(&c.to_uri().unwrap()).unwrap();
|
||||
assert_eq!(d.account, c.account);
|
||||
assert_eq!(d.service, c.service);
|
||||
assert_eq!(d.generate(59).unwrap(), c.generate(59).unwrap());
|
||||
}
|
||||
#[test]
|
||||
fn rejects_invalid_uri() {
|
||||
for uri in [
|
||||
"https://totp/a?secret=MY",
|
||||
"otpauth://totp/a?secret=MY&period=0",
|
||||
"otpauth://totp/A:b?secret=MY&issuer=B",
|
||||
"otpauth://totp/a?secret=MY&secret=MY",
|
||||
] {
|
||||
assert!(parse_uri(uri).is_err(), "{uri}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn steam_uses_fixed_parameters() {
|
||||
let c = parse_uri("otpauth://steam/user?secret=JBSWY3DPEHPK3PXP&digits=8").unwrap();
|
||||
assert_eq!(c.otp_type, "steamtotp");
|
||||
assert_eq!(c.generate(59).unwrap(), "2YXGV");
|
||||
assert!(
|
||||
c.generate(59)
|
||||
.unwrap()
|
||||
.bytes()
|
||||
.all(|b| b"23456789BCDFGHJKMNPQRTVWXY".contains(&b))
|
||||
);
|
||||
}
|
||||
}
|
||||
+715
@@ -0,0 +1,715 @@
|
||||
//! Passport 13 personal-access tokens. The JWT wire format and v13 client schema
|
||||
//! follow the pinned PHP dependencies, rather than a Rust-specific bearer format.
|
||||
use crate::{ApiError, AppState, auth::AuthUser};
|
||||
use axum::{
|
||||
Extension, Json, Router,
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
routing::{delete, get},
|
||||
};
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{DateTime, Datelike, Duration, Utc};
|
||||
use rand::{RngCore, rngs::OsRng};
|
||||
use rsa::{
|
||||
RsaPrivateKey, RsaPublicKey,
|
||||
pkcs1::{DecodeRsaPrivateKey, DecodeRsaPublicKey},
|
||||
pkcs1v15::{Signature, SigningKey, VerifyingKey},
|
||||
pkcs8::{DecodePrivateKey, DecodePublicKey},
|
||||
signature::{SignatureEncoding, Signer, Verifier},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::Sha256;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
pub async fn init(db: &SqlitePool) -> Result<(), sqlx::Error> {
|
||||
sqlx::raw_sql(r#"
|
||||
CREATE TABLE IF NOT EXISTS oauth_clients (id INTEGER PRIMARY KEY AUTOINCREMENT,owner_id INTEGER,owner_type TEXT,name TEXT NOT NULL,secret TEXT,provider TEXT,redirect_uris TEXT NOT NULL,grant_types TEXT NOT NULL,revoked INTEGER NOT NULL DEFAULT 0,created_at TEXT,updated_at TEXT);
|
||||
CREATE TABLE IF NOT EXISTS oauth_access_tokens (id TEXT PRIMARY KEY,user_id INTEGER,client_id INTEGER NOT NULL,name TEXT,scopes TEXT,revoked INTEGER NOT NULL DEFAULT 0,created_at TEXT,updated_at TEXT,expires_at TEXT);
|
||||
"#).execute(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PHP DateInterval('P1Y') carries February 29 into March in a non-leap
|
||||
/// target year; chrono's month addition would clamp it to February 28.
|
||||
pub(crate) fn one_year_from(issued: DateTime<Utc>) -> Option<DateTime<Utc>> {
|
||||
issued
|
||||
.with_day(1)?
|
||||
.with_year(issued.year().checked_add(1)?)?
|
||||
.checked_add_signed(Duration::days(i64::from(issued.day() - 1)))
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BearerIdentity {
|
||||
pub user: AuthUser,
|
||||
pub token_id: String,
|
||||
pub scopes: Vec<String>,
|
||||
}
|
||||
impl BearerIdentity {
|
||||
pub fn can(&self, scope: &str) -> bool {
|
||||
self.scopes.iter().any(|s| s == "*" || s == scope)
|
||||
}
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Claims {
|
||||
aud: Value,
|
||||
jti: String,
|
||||
iat: f64,
|
||||
nbf: f64,
|
||||
exp: f64,
|
||||
sub: String,
|
||||
scopes: Vec<String>,
|
||||
}
|
||||
fn invalid() -> ApiError {
|
||||
ApiError(StatusCode::UNAUTHORIZED, "Unauthenticated.")
|
||||
}
|
||||
fn unavailable() -> ApiError {
|
||||
ApiError(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Passport signing keys unavailable",
|
||||
)
|
||||
}
|
||||
pub(crate) fn key_material(kind: &str) -> Result<String, ApiError> {
|
||||
if let Ok(key) = std::env::var(format!("PASSPORT_{}_KEY", kind.to_uppercase())) {
|
||||
return Ok(key.replace("\\n", "\n"));
|
||||
}
|
||||
let root = std::env::var("PASSPORT_KEY_PATH").unwrap_or_else(|_| "storage".into());
|
||||
std::fs::read_to_string(std::path::Path::new(&root).join(format!("oauth-{kind}.key")))
|
||||
.map_err(|_| unavailable())
|
||||
}
|
||||
fn public_key(pem: &str) -> Result<RsaPublicKey, ApiError> {
|
||||
RsaPublicKey::from_public_key_pem(pem)
|
||||
.or_else(|_| RsaPublicKey::from_pkcs1_pem(pem))
|
||||
.map_err(|_| unavailable())
|
||||
}
|
||||
fn private_key(pem: &str) -> Result<RsaPrivateKey, ApiError> {
|
||||
RsaPrivateKey::from_pkcs8_pem(pem)
|
||||
.or_else(|_| RsaPrivateKey::from_pkcs1_pem(pem))
|
||||
.map_err(|_| unavailable())
|
||||
}
|
||||
fn decode(token: &str, key: &RsaPublicKey) -> Result<Claims, ApiError> {
|
||||
if token.len() > 32 * 1024 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let parts: Vec<_> = token.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let head: Value =
|
||||
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[0]).map_err(|_| invalid())?)
|
||||
.map_err(|_| invalid())?;
|
||||
if head["alg"] != "RS256" || head.get("crit").is_some() {
|
||||
return Err(invalid());
|
||||
}
|
||||
let sig = Signature::try_from(
|
||||
URL_SAFE_NO_PAD
|
||||
.decode(parts[2])
|
||||
.map_err(|_| invalid())?
|
||||
.as_slice(),
|
||||
)
|
||||
.map_err(|_| invalid())?;
|
||||
VerifyingKey::<Sha256>::new(key.clone())
|
||||
.verify(format!("{}.{}", parts[0], parts[1]).as_bytes(), &sig)
|
||||
.map_err(|_| invalid())?;
|
||||
let claims: Claims =
|
||||
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1]).map_err(|_| invalid())?)
|
||||
.map_err(|_| invalid())?;
|
||||
let now = Utc::now().timestamp_millis() as f64 / 1000.;
|
||||
if !claims.exp.is_finite()
|
||||
|| claims.exp <= now
|
||||
|| claims.nbf > now
|
||||
|| claims.iat > now
|
||||
|| claims.sub.is_empty()
|
||||
|| claims.jti.is_empty()
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
Ok(claims)
|
||||
}
|
||||
fn encode(claims: &Claims, key: &RsaPrivateKey) -> Result<String, ApiError> {
|
||||
let h = URL_SAFE_NO_PAD.encode(br#"{"typ":"JWT","alg":"RS256"}"#);
|
||||
let c = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).map_err(|_| unavailable())?);
|
||||
let message = format!("{h}.{c}");
|
||||
let signature = SigningKey::<Sha256>::new(key.clone()).sign(message.as_bytes());
|
||||
Ok(format!(
|
||||
"{message}.{}",
|
||||
URL_SAFE_NO_PAD.encode(signature.to_bytes())
|
||||
))
|
||||
}
|
||||
/// Shared issuer used by Passport's PAT and standard OAuth grants.
|
||||
pub(crate) fn sign_access_token(
|
||||
client: &str,
|
||||
user: &str,
|
||||
id: &str,
|
||||
scopes: Vec<String>,
|
||||
issued: i64,
|
||||
expires: i64,
|
||||
) -> Result<String, ApiError> {
|
||||
let claims = Claims {
|
||||
aud: json!([client]),
|
||||
jti: id.to_owned(),
|
||||
iat: issued as f64,
|
||||
nbf: issued as f64,
|
||||
exp: expires as f64,
|
||||
sub: user.to_owned(),
|
||||
scopes,
|
||||
};
|
||||
encode(&claims, &private_key(&key_material("private")?)?)
|
||||
}
|
||||
pub(crate) fn fresh_cookie_value(
|
||||
state: &AppState,
|
||||
user: i64,
|
||||
csrf: &str,
|
||||
) -> Result<String, ApiError> {
|
||||
use hmac::{Hmac, Mac};
|
||||
let raw = std::env::var("APP_KEY").map_err(|_| invalid())?;
|
||||
let key = if let Some(v) = raw.strip_prefix("base64:") {
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(v)
|
||||
.map_err(|_| invalid())?
|
||||
} else {
|
||||
raw.into_bytes()
|
||||
};
|
||||
let header = URL_SAFE_NO_PAD.encode(br#"{"typ":"JWT","alg":"HS256"}"#);
|
||||
let claims = URL_SAFE_NO_PAD.encode(
|
||||
serde_json::to_vec(
|
||||
&json!({"sub":user,"csrf":csrf,"exp":crate::auth::now()+crate::auth::TTL}),
|
||||
)
|
||||
.map_err(|_| invalid())?,
|
||||
);
|
||||
let message = format!("{header}.{claims}");
|
||||
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(&key).map_err(|_| invalid())?;
|
||||
mac.update(message.as_bytes());
|
||||
let jwt = format!(
|
||||
"{message}.{}",
|
||||
URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
|
||||
);
|
||||
state
|
||||
.cipher
|
||||
.as_ref()
|
||||
.ok_or_else(invalid)?
|
||||
.encrypt_cookie("laravel_token", &jwt)
|
||||
.map_err(|_| invalid())
|
||||
}
|
||||
/// A present malformed Authorization header fails authentication and must never
|
||||
/// fall back to a browser session. The caller handles absent headers separately.
|
||||
pub async fn authenticate(
|
||||
db: &SqlitePool,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<Option<BearerIdentity>, ApiError> {
|
||||
if !headers.contains_key(header::AUTHORIZATION) {
|
||||
return Ok(None);
|
||||
}
|
||||
if headers.get_all(header::AUTHORIZATION).iter().count() != 1 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let value = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(invalid)?;
|
||||
let (scheme, token) = value.split_once(' ').ok_or_else(invalid)?;
|
||||
if !scheme.eq_ignore_ascii_case("bearer") || token.trim().is_empty() {
|
||||
return Err(invalid());
|
||||
}
|
||||
let claims = decode(token.trim(), &public_key(&key_material("public")?)?)?;
|
||||
let audience = claims
|
||||
.aud
|
||||
.as_str()
|
||||
.or_else(|| {
|
||||
claims
|
||||
.aud
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.ok_or_else(invalid)?;
|
||||
let row = sqlx::query("SELECT u.id,u.is_admin,c.provider,c.grant_types FROM oauth_access_tokens t JOIN oauth_clients c ON CAST(c.id AS TEXT)=CAST(t.client_id AS TEXT) JOIN users u ON u.id=t.user_id WHERE t.id=? AND t.revoked=0 AND c.revoked=0 AND CAST(c.id AS TEXT)=? AND CAST(u.id AS TEXT)=? AND datetime(t.expires_at)>datetime('now')")
|
||||
.bind(&claims.jti).bind(audience).bind(&claims.sub).fetch_optional(db).await?.ok_or_else(invalid)?;
|
||||
if row
|
||||
.get::<Option<String>, _>("provider")
|
||||
.is_some_and(|p| p != "users")
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
let grants: Vec<String> =
|
||||
serde_json::from_str(&row.get::<String, _>("grant_types")).map_err(|_| invalid())?;
|
||||
if claims.sub == audience && grants.iter().any(|g| g == "client_credentials") {
|
||||
return Err(invalid());
|
||||
}
|
||||
Ok(Some(BearerIdentity {
|
||||
user: AuthUser {
|
||||
id: row.get("id"),
|
||||
is_admin: row.get("is_admin"),
|
||||
},
|
||||
token_id: claims.jti,
|
||||
scopes: claims.scopes,
|
||||
}))
|
||||
}
|
||||
pub fn routes(state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/oauth/scopes", get(|| async { Json(json!([])) }))
|
||||
.route("/oauth/personal-access-tokens", get(list).post(create))
|
||||
.route("/oauth/personal-access-tokens/{token_id}", delete(revoke))
|
||||
.route_layer(axum::middleware::from_fn_with_state(
|
||||
state,
|
||||
crate::auth::require_auth,
|
||||
))
|
||||
}
|
||||
async fn allowed(state: &AppState, user: &AuthUser) -> Result<(), ApiError> {
|
||||
if matches!(
|
||||
std::env::var("AUTH_GUARD")
|
||||
.or_else(|_| std::env::var("AUTHENTICATION_GUARD"))
|
||||
.as_deref(),
|
||||
Ok("reverse-proxy-guard")
|
||||
) {
|
||||
return Err(ApiError(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Unsupported with reverse proxy authentication",
|
||||
));
|
||||
}
|
||||
let s = crate::settings::all(&state.db).await?;
|
||||
if !user.is_admin && s["useSsoOnly"] == true && s["allowPatWhileSsoOnly"] != true {
|
||||
return Err(ApiError(StatusCode::FORBIDDEN, "Unsupported with SSO only"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn ensure_client(db: &SqlitePool) -> Result<String, ApiError> {
|
||||
let rows = sqlx::query("SELECT CAST(id AS TEXT) AS id,grant_types FROM oauth_clients WHERE revoked=0 AND (provider='users' OR provider IS NULL) ORDER BY created_at DESC,id DESC").fetch_all(db).await?;
|
||||
for row in rows {
|
||||
let grants: Vec<String> =
|
||||
serde_json::from_str(&row.get::<String, _>("grant_types")).unwrap_or_default();
|
||||
if grants.iter().any(|g| g == "personal_access") {
|
||||
return Ok(row.get("id"));
|
||||
}
|
||||
}
|
||||
let result = sqlx::query("INSERT INTO oauth_clients(name,provider,redirect_uris,grant_types,revoked,created_at,updated_at) VALUES(?,'users','[]','[\"personal_access\"]',0,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)").bind(std::env::var("APP_NAME").unwrap_or_else(|_|"2FAuth".into())).execute(db).await?;
|
||||
Ok(result.last_insert_rowid().to_string())
|
||||
}
|
||||
fn date(row: &sqlx::sqlite::SqliteRow, name: &str) -> Value {
|
||||
row.get::<Option<String>, _>(name)
|
||||
.map(|s| {
|
||||
chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S")
|
||||
.map(|d| json!(d.and_utc().format("%Y-%m-%dT%H:%M:%S.000000Z").to_string()))
|
||||
.unwrap_or(json!(s))
|
||||
})
|
||||
.unwrap_or(Value::Null)
|
||||
}
|
||||
fn token_json(row: &sqlx::sqlite::SqliteRow) -> Value {
|
||||
json!({"id":row.get::<String,_>("id"),"user_id":row.get::<Option<i64>,_>("user_id"),"client_id":row.get::<i64,_>("client_id"),"name":row.get::<Option<String>,_>("name"),"scopes":serde_json::from_str::<Value>(&row.get::<Option<String>,_>("scopes").unwrap_or_else(||"[]".into())).unwrap_or(json!([])),"revoked":row.get::<bool,_>("revoked"),"created_at":date(row,"created_at"),"updated_at":date(row,"updated_at"),"expires_at":date(row,"expires_at")})
|
||||
}
|
||||
async fn list(
|
||||
State(state): State<AppState>,
|
||||
Extension(user): Extension<AuthUser>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
allowed(&state, &user).await?;
|
||||
ensure_client(&state.db).await?;
|
||||
let rows=sqlx::query("SELECT t.* FROM oauth_access_tokens t JOIN oauth_clients c ON c.id=t.client_id WHERE t.user_id=? AND t.revoked=0 AND c.revoked=0 AND datetime(t.expires_at)>datetime('now') AND EXISTS(SELECT 1 FROM json_each(c.grant_types) WHERE value='personal_access')").bind(user.id).fetch_all(&state.db).await?;
|
||||
let mut tokens = Vec::with_capacity(rows.len());
|
||||
for row in &rows {
|
||||
let mut token = token_json(row);
|
||||
let client=sqlx::query("SELECT id,owner_id,owner_type,name,provider,redirect_uris,grant_types,revoked,created_at,updated_at FROM oauth_clients WHERE id=?").bind(row.get::<i64,_>("client_id")).fetch_one(&state.db).await?;
|
||||
token["client"] = json!({"id":client.get::<i64,_>("id"),"owner_id":client.get::<Option<i64>,_>("owner_id"),"owner_type":client.get::<Option<String>,_>("owner_type"),"name":client.get::<String,_>("name"),"provider":client.get::<Option<String>,_>("provider"),"redirect_uris":serde_json::from_str::<Value>(&client.get::<String,_>("redirect_uris")).unwrap_or(json!([])),"grant_types":serde_json::from_str::<Value>(&client.get::<String,_>("grant_types")).unwrap_or(json!([])),"revoked":client.get::<bool,_>("revoked"),"created_at":date(&client,"created_at"),"updated_at":date(&client,"updated_at")});
|
||||
tokens.push(token);
|
||||
}
|
||||
Ok(Json(Value::Array(tokens)))
|
||||
}
|
||||
async fn create(
|
||||
State(state): State<AppState>,
|
||||
Extension(user): Extension<AuthUser>,
|
||||
Json(input): Json<Value>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
allowed(&state, &user).await?;
|
||||
let name = input["name"]
|
||||
.as_str()
|
||||
.filter(|s| !s.trim().is_empty() && s.chars().count() <= 255)
|
||||
.ok_or(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"The name field is required and must not exceed 255 characters.",
|
||||
))?;
|
||||
// The pinned app never registers scopes with Passport::tokensCan().
|
||||
if input
|
||||
.get("scopes")
|
||||
.is_some_and(|s| !s.as_array().is_some_and(|a| a.is_empty()))
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"The selected scopes are invalid.",
|
||||
));
|
||||
}
|
||||
let key = private_key(&key_material("private")?)?;
|
||||
let client = ensure_client(&state.db).await?;
|
||||
let issued = Utc::now();
|
||||
let expiry = one_year_from(issued).ok_or_else(unavailable)?;
|
||||
let mut bytes = [0u8; 40];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
let id = hex::encode(bytes);
|
||||
let claims = Claims {
|
||||
aud: json!(client),
|
||||
jti: id.clone(),
|
||||
iat: issued.timestamp() as f64,
|
||||
nbf: issued.timestamp() as f64,
|
||||
exp: expiry.timestamp() as f64,
|
||||
sub: user.id.to_string(),
|
||||
scopes: vec![],
|
||||
};
|
||||
let token = encode(&claims, &key)?;
|
||||
sqlx::query("INSERT INTO oauth_access_tokens(id,user_id,client_id,name,scopes,revoked,created_at,updated_at,expires_at) VALUES(?,?,?,?,'[]',0,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,?)").bind(&id).bind(user.id).bind(&client).bind(name).bind(expiry.format("%Y-%m-%d %H:%M:%S").to_string()).execute(&state.db).await?;
|
||||
Ok(Json(
|
||||
json!({"accessToken":token,"accessTokenId":id,"tokenType":"Bearer","expiresIn":expiry.timestamp()-issued.timestamp()}),
|
||||
))
|
||||
}
|
||||
async fn revoke(
|
||||
State(state): State<AppState>,
|
||||
Extension(user): Extension<AuthUser>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
allowed(&state, &user).await?;
|
||||
let n=sqlx::query("UPDATE oauth_access_tokens SET revoked=1,updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=? AND revoked=0 AND datetime(expires_at)>datetime('now')").bind(id).bind(user.id).execute(&state.db).await?.rows_affected();
|
||||
Ok(if n == 0 {
|
||||
StatusCode::NOT_FOUND
|
||||
} else {
|
||||
StatusCode::NO_CONTENT
|
||||
})
|
||||
}
|
||||
|
||||
/// Passport's first-party HS256 JWT cookie is encrypted by Laravel's cookie
|
||||
/// middleware. Unlike bearer JWTs it needs a CSRF header even on read requests.
|
||||
pub async fn cookie_authenticate(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<Option<AuthUser>, ApiError> {
|
||||
let mut cookie = None;
|
||||
for value in headers.get_all(header::COOKIE) {
|
||||
for part in value.to_str().map_err(|_| invalid())?.split(';') {
|
||||
if let Some(("laravel_token", value)) = part.trim().split_once('=') {
|
||||
if cookie.is_some() {
|
||||
return Err(invalid());
|
||||
}
|
||||
cookie = Some(value.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(cookie) = cookie else {
|
||||
return Ok(None);
|
||||
};
|
||||
let key = std::env::var("APP_KEY").map_err(|_| invalid())?;
|
||||
cookie_identity(state, headers, &cookie, &key).await
|
||||
}
|
||||
async fn cookie_identity(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
cookie: &str,
|
||||
app_key: &str,
|
||||
) -> Result<Option<AuthUser>, ApiError> {
|
||||
use hmac::{Hmac, Mac};
|
||||
use subtle::ConstantTimeEq;
|
||||
let cipher = state.cipher.as_ref().ok_or_else(invalid)?;
|
||||
let key = app_key.to_owned();
|
||||
let key = if let Some(s) = key.strip_prefix("base64:") {
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(s)
|
||||
.map_err(|_| invalid())?
|
||||
} else {
|
||||
key.into_bytes()
|
||||
};
|
||||
let plain = cipher
|
||||
.decrypt(
|
||||
&percent_encoding::percent_decode_str(cookie)
|
||||
.decode_utf8()
|
||||
.map_err(|_| invalid())?,
|
||||
)
|
||||
.map_err(|_| invalid())?;
|
||||
let mut prefix = <Hmac<sha1::Sha1> as Mac>::new_from_slice(&key).map_err(|_| invalid())?;
|
||||
prefix.update(b"laravel_tokenv2");
|
||||
let expected = format!("{}|", hex::encode(prefix.finalize().into_bytes()));
|
||||
// Passport's own guard removes this prefix; checking it also prevents cookie
|
||||
// substitution using another encrypted Laravel value.
|
||||
if plain.len() < 41 || !bool::from(plain.as_bytes()[..41].ct_eq(expected.as_bytes())) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let parts: Vec<_> = plain[41..].split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let head: Value =
|
||||
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[0]).map_err(|_| invalid())?)
|
||||
.map_err(|_| invalid())?;
|
||||
if head["alg"] != "HS256" {
|
||||
return Err(invalid());
|
||||
}
|
||||
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(&key).map_err(|_| invalid())?;
|
||||
mac.update(format!("{}.{}", parts[0], parts[1]).as_bytes());
|
||||
mac.verify_slice(&URL_SAFE_NO_PAD.decode(parts[2]).map_err(|_| invalid())?)
|
||||
.map_err(|_| invalid())?;
|
||||
let claims: Value =
|
||||
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[1]).map_err(|_| invalid())?)
|
||||
.map_err(|_| invalid())?;
|
||||
let now = Utc::now().timestamp() as f64;
|
||||
if claims
|
||||
.get("exp")
|
||||
.is_some_and(|v| v.as_f64().is_none_or(|e| e <= now))
|
||||
|| claims
|
||||
.get("expiry")
|
||||
.is_some_and(|v| v.as_f64().is_none_or(|e| e <= now))
|
||||
|| claims
|
||||
.get("nbf")
|
||||
.is_some_and(|v| v.as_f64().is_none_or(|e| e > now))
|
||||
|| claims
|
||||
.get("iat")
|
||||
.is_some_and(|v| v.as_f64().is_none_or(|e| e > now))
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
let csrf = if let Some(h) = headers.get("x-csrf-token") {
|
||||
h.to_str().map_err(|_| invalid())?.to_owned()
|
||||
} else {
|
||||
let h = headers
|
||||
.get("x-xsrf-token")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or_else(invalid)?;
|
||||
// The refreshed Passport cookie can coexist with a validated Rust
|
||||
// browser session whose Axios XSRF cookie is deliberately plain text.
|
||||
// Accept that representation only when its native session belongs to
|
||||
// this signed JWT subject and its server-side CSRF hash also matches.
|
||||
let native = crate::auth::session(&state.db, headers).await?;
|
||||
let subject = claims["sub"]
|
||||
.as_i64()
|
||||
.or_else(|| claims["sub"].as_str()?.parse().ok());
|
||||
if native.as_ref().is_some_and(|row| {
|
||||
row.get::<Option<i64>, _>("user_id") == subject
|
||||
&& crate::auth::csrf_valid(headers, row.get("csrf_hash"))
|
||||
}) {
|
||||
h.to_owned()
|
||||
} else {
|
||||
cipher
|
||||
.decrypt_cookie("XSRF-TOKEN", h)
|
||||
.map_err(|_| invalid())?
|
||||
}
|
||||
};
|
||||
let expected = claims["csrf"].as_str().ok_or_else(invalid)?;
|
||||
if !bool::from(csrf.as_bytes().ct_eq(expected.as_bytes())) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let subject = claims["sub"]
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.or_else(|| claims["sub"].as_i64().map(|n| n.to_string()))
|
||||
.ok_or_else(invalid)?;
|
||||
let row = sqlx::query("SELECT id,is_admin FROM users WHERE CAST(id AS TEXT)=?")
|
||||
.bind(subject)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or_else(invalid)?;
|
||||
Ok(Some(AuthUser {
|
||||
id: row.get("id"),
|
||||
is_admin: row.get("is_admin"),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn token_year_matches_php_leap_day_carry() {
|
||||
let issued = DateTime::parse_from_rfc3339("2028-02-29T12:34:56+00:00")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc);
|
||||
assert_eq!(
|
||||
one_year_from(issued).unwrap().to_rfc3339(),
|
||||
"2029-03-01T12:34:56+00:00"
|
||||
);
|
||||
let issued = DateTime::parse_from_rfc3339("2027-03-01T12:34:56+00:00")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc);
|
||||
assert_eq!(
|
||||
one_year_from(issued)
|
||||
.unwrap()
|
||||
.signed_duration_since(issued)
|
||||
.num_days(),
|
||||
366
|
||||
);
|
||||
}
|
||||
fn keys() -> &'static RsaPrivateKey {
|
||||
static KEY: std::sync::OnceLock<RsaPrivateKey> = std::sync::OnceLock::new();
|
||||
KEY.get_or_init(|| RsaPrivateKey::new(&mut OsRng, 2048).unwrap())
|
||||
}
|
||||
fn claims() -> Claims {
|
||||
let n = Utc::now().timestamp() as f64;
|
||||
Claims {
|
||||
aud: json!(["1"]),
|
||||
jti: "token-id".into(),
|
||||
iat: n - 1.,
|
||||
nbf: n - 1.,
|
||||
exp: n + 3600.,
|
||||
sub: "7".into(),
|
||||
scopes: vec!["read".into()],
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn rsa_jwt_checks_signature_time_and_algorithm() {
|
||||
let private = keys();
|
||||
let public = RsaPublicKey::from(private);
|
||||
let token = encode(&claims(), private).unwrap();
|
||||
let parsed = decode(&token, &public).unwrap();
|
||||
assert_eq!(parsed.sub, "7");
|
||||
assert_eq!(parsed.scopes, vec!["read"]);
|
||||
let mut parts: Vec<_> = token.split('.').map(str::to_owned).collect();
|
||||
parts[1] = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json!({"sub":"1"})).unwrap());
|
||||
assert!(decode(&parts.join("."), &public).is_err());
|
||||
let mut c = claims();
|
||||
c.exp = 1.;
|
||||
assert!(decode(&encode(&c, private).unwrap(), &public).is_err());
|
||||
c = claims();
|
||||
c.nbf += 7200.;
|
||||
assert!(decode(&encode(&c, private).unwrap(), &public).is_err());
|
||||
assert!(decode("eyJhbGciOiJub25lIn0.e30.", &public).is_err());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn v13_schema_client_selection_listing_and_owner_revocation() {
|
||||
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
crate::db::init(&db).await.unwrap();
|
||||
init(&db).await.unwrap();
|
||||
sqlx::query("INSERT INTO users(id,name,email,password) VALUES(7,'test','test@example.test','unused')").execute(&db).await.unwrap();
|
||||
let client = ensure_client(&db).await.unwrap();
|
||||
assert_eq!(ensure_client(&db).await.unwrap(), client);
|
||||
sqlx::query("INSERT INTO oauth_access_tokens(id,user_id,client_id,name,scopes,revoked,expires_at) VALUES('valid',7,?,'test','[]',0,datetime('now','+1 day')),('expired',7,?,'test','[]',0,'2000-01-01 00:00:00'),('revoked',7,?,'test','[]',1,datetime('now','+1 day'))").bind(&client).bind(&client).bind(&client).execute(&db).await.unwrap();
|
||||
let state = AppState {
|
||||
db,
|
||||
cipher: None,
|
||||
encrypt_db: std::sync::atomic::AtomicBool::new(false).into(),
|
||||
};
|
||||
let user = AuthUser {
|
||||
id: 7,
|
||||
is_admin: true,
|
||||
};
|
||||
let Json(rows) = list(State(state.clone()), Extension(user.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows.as_array().unwrap().len(), 1);
|
||||
assert_eq!(rows[0]["id"], "valid");
|
||||
assert_eq!(
|
||||
revoke(
|
||||
State(state.clone()),
|
||||
Extension(AuthUser {
|
||||
id: 8,
|
||||
is_admin: true
|
||||
}),
|
||||
Path("valid".into())
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
assert_eq!(
|
||||
revoke(
|
||||
State(state.clone()),
|
||||
Extension(user.clone()),
|
||||
Path("valid".into())
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
assert_eq!(
|
||||
revoke(
|
||||
State(state.clone()),
|
||||
Extension(user.clone()),
|
||||
Path("valid".into())
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
let Json(rows) = list(State(state), Extension(user)).await.unwrap();
|
||||
assert!(rows.as_array().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cookie_tests {
|
||||
use super::*;
|
||||
use hmac::{Hmac, Mac};
|
||||
fn prefix(name: &str, key: &str) -> String {
|
||||
let mut m = <Hmac<sha1::Sha1> as Mac>::new_from_slice(key.as_bytes()).unwrap();
|
||||
m.update(format!("{name}v2").as_bytes());
|
||||
format!("{}|", hex::encode(m.finalize().into_bytes()))
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn first_party_cookie_checks_csrf_expiry_and_cipher() {
|
||||
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
crate::db::init(&db).await.unwrap();
|
||||
sqlx::query("INSERT INTO users(id,name,email,password) VALUES(7,'test','test@example.test','unused')").execute(&db).await.unwrap();
|
||||
let key = "0123456789abcdef0123456789abcdef";
|
||||
let cipher = crate::crypto::LaravelCipher::from_app_key(key).unwrap();
|
||||
let state = AppState {
|
||||
db,
|
||||
cipher: Some(std::sync::Arc::new(cipher.clone())),
|
||||
encrypt_db: std::sync::atomic::AtomicBool::new(false).into(),
|
||||
};
|
||||
let header = URL_SAFE_NO_PAD.encode(br#"{"typ":"JWT","alg":"HS256"}"#);
|
||||
let payload = URL_SAFE_NO_PAD.encode(
|
||||
json!({"sub":7,"csrf":"csrf-token","exp":Utc::now().timestamp()+3600}).to_string(),
|
||||
);
|
||||
let message = format!("{header}.{payload}");
|
||||
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(key.as_bytes()).unwrap();
|
||||
mac.update(message.as_bytes());
|
||||
let jwt = format!(
|
||||
"{message}.{}",
|
||||
URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
|
||||
);
|
||||
let cookie = cipher
|
||||
.encrypt(&format!("{}{jwt}", prefix("laravel_token", key)))
|
||||
.unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
assert!(
|
||||
cookie_identity(&state, &headers, &cookie, key)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
headers.insert("x-csrf-token", "wrong".parse().unwrap());
|
||||
assert!(
|
||||
cookie_identity(&state, &headers, &cookie, key)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
headers.insert("x-csrf-token", "csrf-token".parse().unwrap());
|
||||
assert_eq!(
|
||||
cookie_identity(&state, &headers, &cookie, key)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id,
|
||||
7
|
||||
);
|
||||
headers.remove("x-csrf-token");
|
||||
let xsrf = cipher
|
||||
.encrypt(&format!("{}csrf-token", prefix("XSRF-TOKEN", key)))
|
||||
.unwrap();
|
||||
headers.insert("x-xsrf-token", xsrf.parse().unwrap());
|
||||
assert_eq!(
|
||||
cookie_identity(&state, &headers, &cookie, key)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id,
|
||||
7
|
||||
);
|
||||
assert!(
|
||||
cookie_identity(
|
||||
&state,
|
||||
&headers,
|
||||
&cookie,
|
||||
"wrong-key-wrong-key-wrong-key-123"
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"showOtpAsDot": "USERPREF_DEFAULT__SHOW_OTP_AS_DOT",
|
||||
"showNextOtp": "USERPREF_DEFAULT__SHOW_NEXT_OTP",
|
||||
"revealDottedOTP": "USERPREF_DEFAULT__REVEAL_DOTTED_OTP",
|
||||
"closeOtpOnCopy": "USERPREF_DEFAULT__CLOSE_OTP_ON_COPY",
|
||||
"copyOtpOnDisplay": "USERPREF_DEFAULT__COPY_OTP_ON_DISPLAY",
|
||||
"clearSearchOnCopy": "USERPREF_DEFAULT__CLEAR_SEARCH_ON_COPY",
|
||||
"useBasicQrcodeReader": "USERPREF_DEFAULT__USE_BASIC_QRCODE_READER",
|
||||
"displayMode": "USERPREF_DEFAULT__DISPLAY_MODE",
|
||||
"showAccountsIcons": "USERPREF_DEFAULT__SHOW_ACCOUNTS_ICONS",
|
||||
"iconSource": "USERPREF_DEFAULT__ICON_SOURCE",
|
||||
"iconCollection": "USERPREF_DEFAULT__ICON_COLLECTION",
|
||||
"iconPack": "USERPREF_DEFAULT__ICON_PACK",
|
||||
"iconVariant": "USERPREF_DEFAULT__ICON_VARIANT",
|
||||
"iconVariantStrictFetch": "USERPREF_DEFAULT__ICON_VARIANT_STRICT_FETCH",
|
||||
"kickUserAfter": "USERPREF_DEFAULT__KICK_USER_AFTER",
|
||||
"useGroupChips": "USERPREF_DEFAULT__USE_GROUP_CHIPS",
|
||||
"showVirtualChips": "USERPREF_DEFAULT__SHOW_VIRTUAL_CHIPS",
|
||||
"rememberActiveGroup": "USERPREF_DEFAULT__REMEMBER_ACTIVE_GROUP",
|
||||
"viewDefaultGroupOnCopy": "USERPREF_DEFAULT__VIEW_DEFAULT_GROUP_ON_COPY",
|
||||
"defaultCaptureMode": "USERPREF_DEFAULT__DEFAULT_CAPTURE_MODE",
|
||||
"useDirectCapture": "USERPREF_DEFAULT__USE_DIRECT_CAPTURE",
|
||||
"useWebauthnOnly": "USERPREF_DEFAULT__USE_WEBAUTHN_ONLY",
|
||||
"getOfficialIcons": "USERPREF_DEFAULT__GET_OFFICIAL_ICONS",
|
||||
"theme": "USERPREF_DEFAULT__THEME",
|
||||
"formatPassword": "USERPREF_DEFAULT__FORMAT_PASSWORD",
|
||||
"formatPasswordBy": "USERPREF_DEFAULT__FORMAT_PASSWORD_BY",
|
||||
"lang": "USERPREF_DEFAULT__LANG",
|
||||
"getOtpOnRequest": "USERPREF_DEFAULT__GET_OTP_ON_REQUEST",
|
||||
"notifyOnNewAuthDevice": "USERPREF_DEFAULT__NOTIFY_ON_NEW_AUTH_DEVICE",
|
||||
"notifyOnFailedLogin": "USERPREF_DEFAULT__NOTIFY_ON_FAILED_LOGIN",
|
||||
"notifyOnOwnershipTransfer": "USERPREF_DEFAULT__NOTIFY_ON_OWNERSHIP_TRANSFER",
|
||||
"notifyOnShare": "USERPREF_DEFAULT__NOTIFY_ON_SHARE",
|
||||
"timezone": "USERPREF_DEFAULT__TIMEZONE",
|
||||
"sortOrder": "USERPREF_DEFAULT__SORT_ORDER",
|
||||
"sortCaseSensitive": "USERPREF_DEFAULT__SORT_CASE_SENSITIVE",
|
||||
"autoCloseTimeout": "USERPREF_DEFAULT__AUTO_CLOSE_TIMEOUT",
|
||||
"AutoSaveQrcodedAccount": "USERPREF_DEFAULT__AUTO_SAVE_QRCODED_ACCOUNT",
|
||||
"showEmailInFooter": "USERPREF_DEFAULT__SHOW_EMAIL_IN_FOOTER",
|
||||
"showQuickNavMenus": "USERPREF_DEFAULT__SHOW_QUICK_NAV_MENUS"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"showOtpAsDot": false,
|
||||
"showNextOtp": true,
|
||||
"revealDottedOTP": false,
|
||||
"closeOtpOnCopy": false,
|
||||
"copyOtpOnDisplay": false,
|
||||
"clearSearchOnCopy": false,
|
||||
"useBasicQrcodeReader": false,
|
||||
"displayMode": "list",
|
||||
"showAccountsIcons": true,
|
||||
"iconSource": "logolib",
|
||||
"iconCollection": "selfh",
|
||||
"iconPack": null,
|
||||
"iconVariant": "regular",
|
||||
"iconVariantStrictFetch": false,
|
||||
"kickUserAfter": 15,
|
||||
"activeGroup": 0,
|
||||
"useGroupChips": true,
|
||||
"showVirtualChips": true,
|
||||
"rememberActiveGroup": true,
|
||||
"viewDefaultGroupOnCopy": false,
|
||||
"defaultGroup": 0,
|
||||
"defaultCaptureMode": "livescan",
|
||||
"useDirectCapture": false,
|
||||
"useWebauthnOnly": false,
|
||||
"getOfficialIcons": true,
|
||||
"theme": "system",
|
||||
"formatPassword": true,
|
||||
"formatPasswordBy": 0.5,
|
||||
"lang": "browser",
|
||||
"getOtpOnRequest": true,
|
||||
"notifyOnNewAuthDevice": false,
|
||||
"notifyOnFailedLogin": false,
|
||||
"notifyOnOwnershipTransfer": true,
|
||||
"notifyOnShare": true,
|
||||
"timezone": "UTC",
|
||||
"sortOrder": "asc",
|
||||
"sortCaseSensitive": false,
|
||||
"autoCloseTimeout": 2,
|
||||
"AutoSaveQrcodedAccount": false,
|
||||
"showEmailInFooter": true,
|
||||
"showQuickNavMenus": true
|
||||
}
|
||||
+755
@@ -0,0 +1,755 @@
|
||||
//! Account registration and password-confirmed self-service profile changes.
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::{self, AuthUser},
|
||||
settings,
|
||||
};
|
||||
use axum::{
|
||||
Extension, Json, Router,
|
||||
extract::{Request, State},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
middleware,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{patch, post, put},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::Row;
|
||||
|
||||
pub struct Error(StatusCode, Value);
|
||||
type Result<T> = std::result::Result<T, Error>;
|
||||
impl IntoResponse for Error {
|
||||
fn into_response(self) -> Response {
|
||||
(self.0, Json(self.1)).into_response()
|
||||
}
|
||||
}
|
||||
impl From<sqlx::Error> for Error {
|
||||
fn from(_: sqlx::Error) -> Self {
|
||||
error(StatusCode::INTERNAL_SERVER_ERROR, "Server Error")
|
||||
}
|
||||
}
|
||||
impl From<crate::ApiError> for Error {
|
||||
fn from(e: crate::ApiError) -> Self {
|
||||
error(e.0, e.1)
|
||||
}
|
||||
}
|
||||
fn error(status: StatusCode, message: &str) -> Error {
|
||||
Error(status, json!({"message":message}))
|
||||
}
|
||||
fn invalid(field: &str, message: &str) -> Error {
|
||||
Error(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({"message":"The given data was invalid.","errors":{field:[message]}}),
|
||||
)
|
||||
}
|
||||
fn csrf_error() -> Error {
|
||||
error(StatusCode::from_u16(419).unwrap(), "CSRF token mismatch")
|
||||
}
|
||||
fn demo() -> bool {
|
||||
std::env::var("IS_DEMO_APP").is_ok_and(|v| ["true", "1"].contains(&v.as_str()))
|
||||
}
|
||||
pub fn routes(state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/user", put(update).delete(delete))
|
||||
.route("/user/password", patch(password))
|
||||
.route_layer(middleware::from_fn_with_state(state, auth::require_auth))
|
||||
.merge(
|
||||
Router::new()
|
||||
.route("/user", post(register))
|
||||
.route("/user/password/lost", post(lost))
|
||||
.route("/user/password/reset", post(reset)),
|
||||
)
|
||||
}
|
||||
async fn body(request: Request) -> Result<(HeaderMap, Value)> {
|
||||
let (parts, body) = request.into_parts();
|
||||
let content = parts
|
||||
.headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_owned();
|
||||
let bytes = axum::body::to_bytes(body, 32 * 1024)
|
||||
.await
|
||||
.map_err(|_| error(StatusCode::PAYLOAD_TOO_LARGE, "Request body too large"))?;
|
||||
let v = if content.starts_with("application/x-www-form-urlencoded") {
|
||||
serde_urlencoded::from_bytes::<std::collections::HashMap<String, String>>(&bytes)
|
||||
.map(|v| json!(v))
|
||||
.map_err(|_| invalid("body", "Invalid request body."))?
|
||||
} else {
|
||||
serde_json::from_slice(&bytes).map_err(|_| invalid("body", "Invalid request body."))?
|
||||
};
|
||||
Ok((parts.headers, v))
|
||||
}
|
||||
fn text<'a>(v: &'a Value, key: &str) -> Result<&'a str> {
|
||||
v[key]
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| invalid(key, "This field is required."))
|
||||
}
|
||||
fn new_password(v: &Value) -> Result<String> {
|
||||
let password = text(v, "password")?;
|
||||
if password.chars().count() < 8 {
|
||||
return Err(invalid(
|
||||
"password",
|
||||
"The password must be at least 8 characters.",
|
||||
));
|
||||
}
|
||||
if password.len() > 4096 {
|
||||
return Err(invalid("password", "The password is too long."));
|
||||
}
|
||||
if v["password_confirmation"].as_str() != Some(password) {
|
||||
return Err(invalid(
|
||||
"password",
|
||||
"The password confirmation does not match.",
|
||||
));
|
||||
}
|
||||
Ok(password.into())
|
||||
}
|
||||
async fn identity(s: &AppState, v: &Value, exclude: i64) -> Result<(String, String)> {
|
||||
let name = text(v, "name")?.trim().to_owned();
|
||||
let email = text(v, "email")?.trim().to_lowercase();
|
||||
if name.is_empty() || name.chars().count() > 191 {
|
||||
return Err(invalid("name", "The name must not exceed 191 characters."));
|
||||
}
|
||||
let valid = email.rsplit_once('@').is_some_and(|(local, domain)| {
|
||||
!local.is_empty()
|
||||
&& !domain.is_empty()
|
||||
&& !local.contains('@')
|
||||
&& !email.chars().any(char::is_whitespace)
|
||||
&& domain.contains('.')
|
||||
&& !domain.starts_with('.')
|
||||
&& !domain.ends_with('.')
|
||||
});
|
||||
if email.chars().count() > 191 || !valid {
|
||||
return Err(invalid("email", "The email must be a valid email address."));
|
||||
}
|
||||
for (field, value) in [("name", name.as_str()), ("email", email.as_str())] {
|
||||
let taken: bool = sqlx::query_scalar(&format!(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE {field}=? AND id!=?)"
|
||||
))
|
||||
.bind(value)
|
||||
.bind(exclude)
|
||||
.fetch_one(&s.db)
|
||||
.await?;
|
||||
if taken {
|
||||
return Err(invalid(
|
||||
field,
|
||||
&format!("The {field} has already been taken."),
|
||||
));
|
||||
}
|
||||
}
|
||||
if !crate::users::email_allowed(&s.db, &email).await? {
|
||||
return Err(invalid(
|
||||
"email",
|
||||
"This email address is not allowed to register.",
|
||||
));
|
||||
}
|
||||
Ok((name, email))
|
||||
}
|
||||
static HASH_LIMIT: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
|
||||
async fn hash(password: String) -> Result<String> {
|
||||
let permit = HASH_LIMIT
|
||||
.try_acquire()
|
||||
.map_err(|_| error(StatusCode::TOO_MANY_REQUESTS, "Authentication busy"))?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
bcrypt::hash(password, 12)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| error(StatusCode::INTERNAL_SERVER_ERROR, "Password hashing failed"))?
|
||||
.map_err(|_| invalid("password", "The password could not be hashed."))
|
||||
}
|
||||
async fn check(s: &AppState, id: i64, password: String) -> Result<sqlx::sqlite::SqliteRow> {
|
||||
if password.len() > 4096 {
|
||||
return Err(invalid("password", "The password is too long."));
|
||||
}
|
||||
let row = sqlx::query("SELECT * FROM users WHERE id=?")
|
||||
.bind(id)
|
||||
.fetch_optional(&s.db)
|
||||
.await?
|
||||
.ok_or_else(|| error(StatusCode::UNAUTHORIZED, "Unauthenticated"))?;
|
||||
let encoded: String = row.try_get("password")?;
|
||||
let permit = HASH_LIMIT
|
||||
.try_acquire()
|
||||
.map_err(|_| error(StatusCode::TOO_MANY_REQUESTS, "Authentication busy"))?;
|
||||
let valid = tokio::task::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
auth::verify(password, encoded)
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !valid {
|
||||
return Err(error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"The current password is incorrect.",
|
||||
));
|
||||
}
|
||||
Ok(row)
|
||||
}
|
||||
async fn resource(s: &AppState, id: i64) -> Result<Value> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id,name,email,oauth_provider,is_admin,preferences FROM users WHERE id=?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_one(&s.db)
|
||||
.await?;
|
||||
let prefs: Option<String> = row.try_get("preferences")?;
|
||||
Ok(
|
||||
json!({"id":id,"name":row.try_get::<String,_>("name")?,"email":row.try_get::<String,_>("email")?,"oauth_provider":row.try_get::<Option<String>,_>("oauth_provider")?,"authenticated_by_proxy":crate::proxy_auth::enabled(),"is_admin":row.try_get::<bool,_>("is_admin")?,"preferences":settings::preferences_from(prefs.as_deref()),"appSettings":settings::regular_settings(&s.db).await?}),
|
||||
)
|
||||
}
|
||||
async fn register(State(s): State<AppState>, request: Request) -> Result<Response> {
|
||||
let context = crate::auth_audit::Context::from_request(&request);
|
||||
let (headers, v) = body(request).await?;
|
||||
let old = auth::session(&s.db, &headers)
|
||||
.await?
|
||||
.ok_or_else(csrf_error)?;
|
||||
if !auth::csrf_valid(&headers, old.try_get("csrf_hash")?) {
|
||||
return Err(csrf_error());
|
||||
}
|
||||
let settings = settings::all(&s.db).await?;
|
||||
if demo() || settings["disableRegistration"] == true || settings["useSsoOnly"] == true {
|
||||
return Err(error(StatusCode::FORBIDDEN, "forbidden"));
|
||||
}
|
||||
let (name, email) = identity(&s, &v, 0).await?;
|
||||
let password = hash(new_password(&v)?).await?;
|
||||
let sid = auth::random();
|
||||
let csrf = auth::random();
|
||||
let mut tx = s.db.begin().await?;
|
||||
let consumed = sqlx::query("DELETE FROM rust_sessions WHERE token_hash=? AND expires_at>?")
|
||||
.bind(old.try_get::<String, _>("token_hash")?)
|
||||
.bind(auth::now())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if consumed.rows_affected() != 1 {
|
||||
return Err(csrf_error());
|
||||
}
|
||||
let inserted=sqlx::query("INSERT INTO users(name,email,password,is_admin,created_at,updated_at,last_seen_at) SELECT ?,?,?,NOT EXISTS(SELECT 1 FROM users),CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP WHERE NOT EXISTS(SELECT 1 FROM users WHERE name=? OR email=?)").bind(&name).bind(&email).bind(password).bind(&name).bind(&email).execute(&mut *tx).await?;
|
||||
if inserted.rows_affected() != 1 {
|
||||
return Err(invalid(
|
||||
"email",
|
||||
"The name or email has already been taken.",
|
||||
));
|
||||
}
|
||||
let id = inserted.last_insert_rowid();
|
||||
sqlx::query(
|
||||
"INSERT INTO rust_sessions(token_hash,user_id,csrf_hash,expires_at) VALUES(?,?,?,?)",
|
||||
)
|
||||
.bind(auth::digest(&sid))
|
||||
.bind(id)
|
||||
.bind(auth::digest(&csrf))
|
||||
.bind(auth::now() + auth::TTL)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
crate::auth_audit::login(&s, id, &context, Some("password")).await?;
|
||||
let mut payload = resource(&s, id).await?;
|
||||
payload
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("authenticated_by_proxy");
|
||||
payload["message"] = json!("account created");
|
||||
let mut response = (StatusCode::CREATED, Json(payload)).into_response();
|
||||
auth::cookies(&mut response, &sid, &csrf, auth::TTL);
|
||||
auth::clear_legacy_cookie(&mut response);
|
||||
Ok(response)
|
||||
}
|
||||
async fn update(
|
||||
State(s): State<AppState>,
|
||||
Extension(u): Extension<AuthUser>,
|
||||
request: Request,
|
||||
) -> Result<Response> {
|
||||
let (_, v) = body(request).await?;
|
||||
let (name, email) = identity(&s, &v, u.id).await?;
|
||||
let row = check(&s, u.id, text(&v, "password")?.into()).await?;
|
||||
if row
|
||||
.try_get::<Option<String>, _>("oauth_provider")?
|
||||
.is_some()
|
||||
{
|
||||
return Err(error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"This account is managed by an external provider.",
|
||||
));
|
||||
}
|
||||
let settings = settings::all(&s.db).await?;
|
||||
if settings["useSsoOnly"] == true && !u.is_admin {
|
||||
return Err(error(StatusCode::FORBIDDEN, "forbidden"));
|
||||
}
|
||||
if !demo() {
|
||||
let updated=sqlx::query("UPDATE users SET name=?,email=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND NOT EXISTS(SELECT 1 FROM users WHERE id!=? AND (name=? OR email=?))").bind(&name).bind(&email).bind(u.id).bind(u.id).bind(&name).bind(&email).execute(&s.db).await?;
|
||||
if updated.rows_affected() != 1 {
|
||||
return Err(invalid(
|
||||
"email",
|
||||
"The name or email has already been taken.",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(Json(resource(&s, u.id).await?).into_response())
|
||||
}
|
||||
async fn password(
|
||||
State(s): State<AppState>,
|
||||
Extension(u): Extension<AuthUser>,
|
||||
request: Request,
|
||||
) -> Result<Response> {
|
||||
if demo() {
|
||||
return Err(error(StatusCode::FORBIDDEN, "forbidden"));
|
||||
}
|
||||
let (_, v) = body(request).await?;
|
||||
let new = new_password(&v)?;
|
||||
let row = check(&s, u.id, text(&v, "currentPassword")?.into()).await?;
|
||||
if row
|
||||
.try_get::<Option<String>, _>("oauth_provider")?
|
||||
.is_some()
|
||||
{
|
||||
return Err(error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"This account is managed by an external provider.",
|
||||
));
|
||||
}
|
||||
let encoded = hash(new).await?;
|
||||
// Compare the checked hash in the write so concurrent password changes cannot both succeed.
|
||||
let updated=sqlx::query("UPDATE users SET password=?,remember_token=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=? AND password=?").bind(encoded).bind(u.id).bind(row.try_get::<String,_>("password")?).execute(&s.db).await?;
|
||||
if updated.rows_affected() != 1 {
|
||||
return Err(error(
|
||||
StatusCode::CONFLICT,
|
||||
"The password changed during this request.",
|
||||
));
|
||||
}
|
||||
Ok(Json(json!({"message":"Password successfully changed"})).into_response())
|
||||
}
|
||||
async fn delete(
|
||||
State(s): State<AppState>,
|
||||
Extension(u): Extension<AuthUser>,
|
||||
request: Request,
|
||||
) -> Result<Response> {
|
||||
if demo() {
|
||||
return Err(error(StatusCode::FORBIDDEN, "forbidden"));
|
||||
}
|
||||
let (_, v) = body(request).await?;
|
||||
check(&s, u.id, text(&v, "password")?.into()).await?;
|
||||
crate::users::delete_user(&s, u.id).await.map_err(|e| {
|
||||
if e.0 == StatusCode::FORBIDDEN {
|
||||
error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Cannot delete the only admin account",
|
||||
)
|
||||
} else {
|
||||
error(e.0, e.1)
|
||||
}
|
||||
})?;
|
||||
let mut response = StatusCode::NO_CONTENT.into_response();
|
||||
auth::cookies(&mut response, "", "", 0);
|
||||
auth::clear_legacy_cookie(&mut response);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn anonymous_session(s: &AppState, headers: &HeaderMap) -> Result<sqlx::sqlite::SqliteRow> {
|
||||
let row = auth::session(&s.db, headers)
|
||||
.await?
|
||||
.ok_or_else(csrf_error)?;
|
||||
if !auth::csrf_valid(headers, row.try_get("csrf_hash")?) {
|
||||
return Err(csrf_error());
|
||||
}
|
||||
Ok(row)
|
||||
}
|
||||
async fn lost(State(s): State<AppState>, request: Request) -> Result<Response> {
|
||||
if demo() {
|
||||
return Err(error(StatusCode::FORBIDDEN, "forbidden"));
|
||||
}
|
||||
let (headers, v) = body(request).await?;
|
||||
anonymous_session(&s, &headers).await?;
|
||||
let email = text(&v, "email")?.trim().to_lowercase();
|
||||
if email.len() > 191 {
|
||||
return Err(invalid("email", "The email is invalid."));
|
||||
}
|
||||
let user = sqlx::query("SELECT oauth_provider FROM users WHERE email=?")
|
||||
.bind(&email)
|
||||
.fetch_optional(&s.db)
|
||||
.await?
|
||||
.ok_or_else(|| invalid("email", "We can't find a user with that email address."))?;
|
||||
if user
|
||||
.try_get::<Option<String>, _>("oauth_provider")?
|
||||
.is_some()
|
||||
{
|
||||
return Err(error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"This account is managed by an external provider.",
|
||||
));
|
||||
}
|
||||
if settings::all(&s.db).await?["useSsoOnly"] == true {
|
||||
return Err(error(StatusCode::FORBIDDEN, "forbidden"));
|
||||
}
|
||||
let token = auth::random();
|
||||
let encoded = hash(token.clone()).await?;
|
||||
let app_url = std::env::var("APP_URL").unwrap_or_else(|_| "http://localhost".into());
|
||||
let mut url = url::Url::parse(&format!(
|
||||
"{}/user/password/reset",
|
||||
app_url.trim_end_matches('/')
|
||||
))
|
||||
.map_err(|_| error(StatusCode::INTERNAL_SERVER_ERROR, "Invalid application URL"))?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("token", &token)
|
||||
.append_pair("email", &email);
|
||||
let mut tx = s.db.begin().await?;
|
||||
// A write lock serializes concurrent broker requests before the cooldown check.
|
||||
sqlx::query("UPDATE password_resets SET token=token WHERE email=?")
|
||||
.bind(&email)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let recent:bool=sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM password_resets WHERE email=? AND created_at>datetime('now','-60 seconds'))").bind(&email).fetch_one(&mut *tx).await?;
|
||||
if recent {
|
||||
return Err(error(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"Please wait before retrying.",
|
||||
));
|
||||
}
|
||||
sqlx::query("DELETE FROM password_resets WHERE email=?")
|
||||
.bind(&email)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO password_resets(email,token,created_at) VALUES(?,?,CURRENT_TIMESTAMP)",
|
||||
)
|
||||
.bind(&email)
|
||||
.bind(encoded)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
crate::mail::password_reset(&email, url.as_str())
|
||||
.await
|
||||
.map_err(|e| error(StatusCode::SERVICE_UNAVAILABLE, &e.to_string()))?;
|
||||
tx.commit().await?;
|
||||
Ok(Json(json!({"message":"We have emailed your password reset link."})).into_response())
|
||||
}
|
||||
async fn reset(State(s): State<AppState>, request: Request) -> Result<Response> {
|
||||
let context = crate::auth_audit::Context::from_request(&request);
|
||||
if demo() {
|
||||
return Err(error(StatusCode::FORBIDDEN, "forbidden"));
|
||||
}
|
||||
let (headers, v) = body(request).await?;
|
||||
let old = anonymous_session(&s, &headers).await?;
|
||||
let email = text(&v, "email")?.trim().to_lowercase();
|
||||
let token = text(&v, "token")?.to_owned();
|
||||
let password = new_password(&v)?;
|
||||
if token.len() > 4096 || email.len() > 191 {
|
||||
return Err(invalid("email", "This password reset token is invalid."));
|
||||
}
|
||||
let row=sqlx::query("SELECT p.token,u.id,u.oauth_provider FROM password_resets p JOIN users u ON u.email=p.email WHERE p.email=? AND p.created_at>datetime('now','-60 minutes')").bind(&email).fetch_optional(&s.db).await?.ok_or_else(||invalid("email","This password reset token is invalid."))?;
|
||||
if row
|
||||
.try_get::<Option<String>, _>("oauth_provider")?
|
||||
.is_some()
|
||||
{
|
||||
return Err(error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"This account is managed by an external provider.",
|
||||
));
|
||||
}
|
||||
let stored: String = row.try_get("token")?;
|
||||
let compare = stored.clone();
|
||||
let permit = HASH_LIMIT
|
||||
.try_acquire()
|
||||
.map_err(|_| error(StatusCode::TOO_MANY_REQUESTS, "Authentication busy"))?;
|
||||
let valid = tokio::task::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
auth::verify(token, compare)
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !valid {
|
||||
return Err(invalid("email", "This password reset token is invalid."));
|
||||
}
|
||||
let encoded = hash(password).await?;
|
||||
let id: i64 = row.try_get("id")?;
|
||||
let sid = auth::random();
|
||||
let csrf = auth::random();
|
||||
let mut tx = s.db.begin().await?;
|
||||
// Consume the checked token atomically. A racing reset may only succeed once.
|
||||
let consumed=sqlx::query("DELETE FROM password_resets WHERE email=? AND token=? AND created_at>datetime('now','-60 minutes')").bind(&email).bind(stored).execute(&mut *tx).await?;
|
||||
if consumed.rows_affected() != 1 {
|
||||
return Err(invalid("email", "This password reset token is invalid."));
|
||||
}
|
||||
let old_session = sqlx::query("DELETE FROM rust_sessions WHERE token_hash=? AND expires_at>?")
|
||||
.bind(old.try_get::<String, _>("token_hash")?)
|
||||
.bind(auth::now())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if old_session.rows_affected() != 1 {
|
||||
return Err(csrf_error());
|
||||
}
|
||||
sqlx::query(
|
||||
"UPDATE users SET password=?,remember_token=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
)
|
||||
.bind(encoded)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("DELETE FROM rust_sessions WHERE user_id=?")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO rust_sessions(token_hash,user_id,csrf_hash,expires_at) VALUES(?,?,?,?)",
|
||||
)
|
||||
.bind(auth::digest(&sid))
|
||||
.bind(id)
|
||||
.bind(auth::digest(&csrf))
|
||||
.bind(auth::now() + auth::TTL)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
crate::auth_audit::login(&s, id, &context, Some("password")).await?;
|
||||
let mut response = Json(json!({"message":"Your password has been reset."})).into_response();
|
||||
auth::cookies(&mut response, &sid, &csrf, auth::TTL);
|
||||
auth::clear_legacy_cookie(&mut response);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::Request,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
async fn request(
|
||||
app: Router,
|
||||
method: &str,
|
||||
path: &str,
|
||||
cookie: &str,
|
||||
csrf: &str,
|
||||
body: Value,
|
||||
) -> Response {
|
||||
app.oneshot(
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", cookie)
|
||||
.header("x-xsrf-token", csrf)
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
fn credentials(response: &Response) -> (String, String) {
|
||||
let headers: Vec<_> = response
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.map(|h| h.to_str().unwrap().split(';').next().unwrap().to_owned())
|
||||
.collect();
|
||||
let cookie = headers
|
||||
.iter()
|
||||
.find(|h| h.starts_with("rust_2fauth_session="))
|
||||
.unwrap()
|
||||
.clone();
|
||||
let csrf = headers
|
||||
.iter()
|
||||
.find_map(|h| h.strip_prefix("XSRF-TOKEN="))
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
(cookie, csrf)
|
||||
}
|
||||
async fn setup() -> (AppState, Router) {
|
||||
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
crate::db::init(&db).await.unwrap();
|
||||
auth::init(&db).await.unwrap();
|
||||
let state = AppState {
|
||||
db,
|
||||
cipher: None,
|
||||
encrypt_db: std::sync::atomic::AtomicBool::new(false).into(),
|
||||
};
|
||||
let app = routes(state.clone())
|
||||
.merge(auth::routes())
|
||||
.with_state(state.clone());
|
||||
(state, app)
|
||||
}
|
||||
async fn register_user(app: Router, name: &str, email: &str) -> Response {
|
||||
let bootstrap = request(app.clone(), "GET", "/refresh-csrf", "", "", Value::Null).await;
|
||||
let (cookie, csrf) = credentials(&bootstrap);
|
||||
request(app,"POST","/user",&cookie,&csrf,json!({"name":name,"email":email,"password":"correct-password","password_confirmation":"correct-password"})).await
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn account_lifecycle_checks_password_csrf_and_last_admin() {
|
||||
let (state, app) = setup().await;
|
||||
assert_eq!(
|
||||
request(app.clone(), "POST", "/user", "", "", json!({}))
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::from_u16(419).unwrap()
|
||||
);
|
||||
let response = register_user(app.clone(), "Alice", "ALICE@example.com").await;
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
let (cookie, csrf) = credentials(&response);
|
||||
let payload: Value =
|
||||
serde_json::from_slice(&to_bytes(response.into_body(), 65536).await.unwrap()).unwrap();
|
||||
assert_eq!(payload["is_admin"], true);
|
||||
assert_eq!(payload["email"], "alice@example.com");
|
||||
assert!(payload.get("csrf_token").is_none());
|
||||
assert_eq!(
|
||||
request(
|
||||
app.clone(),
|
||||
"PUT",
|
||||
"/user",
|
||||
&cookie,
|
||||
"",
|
||||
json!({"name":"Changed","email":"alice@example.com","password":"correct-password"})
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::from_u16(419).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
request(
|
||||
app.clone(),
|
||||
"PUT",
|
||||
"/user",
|
||||
&cookie,
|
||||
&csrf,
|
||||
json!({"name":"Changed","email":"alice@example.com","password":"incorrect"})
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
assert_eq!(
|
||||
request(
|
||||
app.clone(),
|
||||
"PUT",
|
||||
"/user",
|
||||
&cookie,
|
||||
&csrf,
|
||||
json!({"name":"Changed","email":"alice@example.com","password":"correct-password"})
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
request(
|
||||
app.clone(),
|
||||
"DELETE",
|
||||
"/user",
|
||||
&cookie,
|
||||
&csrf,
|
||||
json!({"password":"correct-password"})
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
let response = register_user(app.clone(), "Bob", "bob@example.com").await;
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
let (bob_cookie, bob_csrf) = credentials(&response);
|
||||
let response=request(app.clone(),"PATCH","/user/password",&bob_cookie,&bob_csrf,json!({"currentPassword":"correct-password","password":"new-password","password_confirmation":"new-password"})).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let hash: String =
|
||||
sqlx::query_scalar("SELECT password FROM users WHERE email='bob@example.com'")
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(auth::verify("new-password".into(), hash));
|
||||
let response = request(
|
||||
app,
|
||||
"DELETE",
|
||||
"/user",
|
||||
&bob_cookie,
|
||||
&bob_csrf,
|
||||
json!({"password":"new-password"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(n, 1);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn reset_tokens_expire_are_single_use_and_rotate_sessions() {
|
||||
let (state, app) = setup().await;
|
||||
let response = register_user(app.clone(), "Alice", "alice@example.com").await;
|
||||
let (old_cookie, old_csrf) = credentials(&response);
|
||||
let token = "known-reset-token";
|
||||
let encoded = bcrypt::hash(token, 4).unwrap();
|
||||
sqlx::query("INSERT INTO password_resets(email,token,created_at) VALUES('alice@example.com',?,datetime('now','-61 minutes'))").bind(&encoded).execute(&state.db).await.unwrap();
|
||||
let payload = json!({"email":"alice@example.com","token":token,"password":"reset-password","password_confirmation":"reset-password"});
|
||||
assert_eq!(
|
||||
request(
|
||||
app.clone(),
|
||||
"POST",
|
||||
"/user/password/reset",
|
||||
&old_cookie,
|
||||
&old_csrf,
|
||||
payload.clone()
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
);
|
||||
sqlx::query("UPDATE password_resets SET created_at=CURRENT_TIMESTAMP")
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let response = request(
|
||||
app.clone(),
|
||||
"POST",
|
||||
"/user/password/reset",
|
||||
&old_cookie,
|
||||
&old_csrf,
|
||||
payload.clone(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let (cookie, csrf) = credentials(&response);
|
||||
assert_ne!(cookie, old_cookie);
|
||||
assert_eq!(
|
||||
request(
|
||||
app.clone(),
|
||||
"POST",
|
||||
"/user/password/reset",
|
||||
&cookie,
|
||||
&csrf,
|
||||
payload
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
);
|
||||
assert_eq!(
|
||||
request(app, "PUT", "/user", &old_cookie, &old_csrf, json!({}))
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
let password: String =
|
||||
sqlx::query_scalar("SELECT password FROM users WHERE email='alice@example.com'")
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(auth::verify("reset-password".into(), password));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn registration_policy_blocks_without_creating_user() {
|
||||
let (state, app) = setup().await;
|
||||
sqlx::query("INSERT INTO options(key,value) VALUES('disableRegistration','{{1}}')")
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
register_user(app, "Alice", "alice@example.com")
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(n, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
//! Explicit reverse-proxy authentication; headers are accepted only from configured socket peers.
|
||||
use crate::{ApiError, AppState, auth::AuthUser};
|
||||
use axum::http::{HeaderMap, HeaderName, Method, StatusCode};
|
||||
use sqlx::Row;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ProxyConfig {
|
||||
pub enabled: bool,
|
||||
pub trusted_proxies: String,
|
||||
pub user_header: String,
|
||||
pub email_header: Option<String>,
|
||||
}
|
||||
impl ProxyConfig {
|
||||
pub fn from_env() -> Self {
|
||||
let guard = std::env::var("AUTH_GUARD")
|
||||
.or_else(|_| std::env::var("AUTHENTICATION_GUARD"))
|
||||
.unwrap_or_else(|_| "web-guard".into());
|
||||
Self {
|
||||
enabled: guard == "reverse-proxy-guard",
|
||||
trusted_proxies: std::env::var("TRUSTED_PROXIES").unwrap_or_default(),
|
||||
user_header: std::env::var("AUTH_PROXY_HEADER_FOR_USER")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty() && s != "null" && s != "(null)")
|
||||
.unwrap_or_else(|| "REMOTE_USER".into()),
|
||||
email_header: std::env::var("AUTH_PROXY_HEADER_FOR_EMAIL")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty() && s != "null" && s != "(null)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn enabled() -> bool {
|
||||
ProxyConfig::from_env().enabled
|
||||
}
|
||||
fn normalize(ip: IpAddr) -> IpAddr {
|
||||
match ip {
|
||||
IpAddr::V6(ip) => ip
|
||||
.to_ipv4_mapped()
|
||||
.map(IpAddr::V4)
|
||||
.unwrap_or(IpAddr::V6(ip)),
|
||||
ip => ip,
|
||||
}
|
||||
}
|
||||
fn network_contains(network: &str, ip: IpAddr) -> bool {
|
||||
let Some((network, prefix)) = network.split_once('/') else {
|
||||
return network
|
||||
.parse::<IpAddr>()
|
||||
.is_ok_and(|network| normalize(network) == normalize(ip));
|
||||
};
|
||||
let Ok(network) = network.parse::<IpAddr>() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(prefix) = prefix.parse::<u32>() else {
|
||||
return false;
|
||||
};
|
||||
match (network, normalize(ip)) {
|
||||
(IpAddr::V4(network), IpAddr::V4(ip)) if prefix <= 32 => {
|
||||
let mask = if prefix == 0 {
|
||||
0
|
||||
} else {
|
||||
u32::MAX << (32 - prefix)
|
||||
};
|
||||
u32::from(network) & mask == u32::from(ip) & mask
|
||||
}
|
||||
(IpAddr::V6(network), IpAddr::V6(ip)) if prefix <= 128 => {
|
||||
let mask = if prefix == 0 {
|
||||
0
|
||||
} else {
|
||||
u128::MAX << (128 - prefix)
|
||||
};
|
||||
u128::from(network) & mask == u128::from(ip) & mask
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
pub fn trusted_peer(config: &ProxyConfig, peer: Option<SocketAddr>) -> bool {
|
||||
let Some(peer) = peer else {
|
||||
return false;
|
||||
};
|
||||
config
|
||||
.trusted_proxies
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.any(|entry| entry == "*" || (!entry.is_empty() && network_contains(entry, peer.ip())))
|
||||
}
|
||||
fn single_header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||
// PHP's HTTP_REMOTE_USER server key corresponds to the Remote-User wire header.
|
||||
// Accept that mapping while refusing multiple aliases or duplicate identities.
|
||||
let mut names = vec![name.to_string()];
|
||||
if let Some(server_name) = name.strip_prefix("HTTP_") {
|
||||
names.push(server_name.replace('_', "-"));
|
||||
}
|
||||
let mut found = None;
|
||||
for name in names {
|
||||
let name = HeaderName::from_bytes(name.as_bytes()).ok()?;
|
||||
for value in headers.get_all(name) {
|
||||
if found.is_some() {
|
||||
return None;
|
||||
}
|
||||
found = Some(value.to_str().ok()?);
|
||||
}
|
||||
}
|
||||
found.filter(|value| !value.is_empty() && *value != "0")
|
||||
}
|
||||
fn truncate_bytes(text: &str, limit: usize) -> &str {
|
||||
let mut end = text.len().min(limit);
|
||||
while !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
&text[..end]
|
||||
}
|
||||
fn valid_email(value: &str) -> bool {
|
||||
value.len() <= 254
|
||||
&& !value.chars().any(|c| c.is_whitespace() || c.is_control())
|
||||
&& value.rsplit_once('@').is_some_and(|(local, domain)| {
|
||||
!local.is_empty()
|
||||
&& !local.contains('@')
|
||||
&& domain.contains('.')
|
||||
&& !domain.starts_with('.')
|
||||
&& !domain.ends_with('.')
|
||||
&& domain.split('.').all(|label| {
|
||||
!label.is_empty()
|
||||
&& label
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-')
|
||||
})
|
||||
})
|
||||
}
|
||||
pub async fn authenticate(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
peer: Option<SocketAddr>,
|
||||
) -> Result<Option<AuthUser>, ApiError> {
|
||||
authenticate_with_config(state, headers, peer, &ProxyConfig::from_env()).await
|
||||
}
|
||||
pub async fn authenticate_with_config(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
peer: Option<SocketAddr>,
|
||||
config: &ProxyConfig,
|
||||
) -> Result<Option<AuthUser>, ApiError> {
|
||||
if !config.enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
if !trusted_peer(config, peer) {
|
||||
return Err(ApiError(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Request does not come from a trusted proxy",
|
||||
));
|
||||
}
|
||||
let identifier = single_header(headers, &config.user_header).ok_or(ApiError(
|
||||
StatusCode::PROXY_AUTHENTICATION_REQUIRED,
|
||||
"Unauthenticated.",
|
||||
))?;
|
||||
let name = truncate_bytes(identifier, 191);
|
||||
let requested_email = config
|
||||
.email_header
|
||||
.as_deref()
|
||||
.and_then(|name| single_header(headers, name))
|
||||
.filter(|email| valid_email(email))
|
||||
.map(str::to_lowercase);
|
||||
let mut tx = state.db.begin().await?;
|
||||
// Acquire SQLite's writer lock before lookup to serialize first-user promotion and provisioning.
|
||||
sqlx::query("UPDATE users SET id=id WHERE id=0")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let row =
|
||||
sqlx::query("SELECT id,is_admin,last_seen_at FROM users WHERE name=? ORDER BY id LIMIT 1")
|
||||
.bind(name)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let existing_id = row.as_ref().map(|row| row.get::<i64, _>("id"));
|
||||
let email = if let Some(email) = requested_email {
|
||||
let used: bool =
|
||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE id<>? AND lower(email)=?)")
|
||||
.bind(existing_id.unwrap_or(0))
|
||||
.bind(&email)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
(!used).then_some(email)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let user = if let Some(row) = row {
|
||||
let id: i64 = row.try_get("id")?;
|
||||
if let Some(email) = email {
|
||||
sqlx::query(
|
||||
"UPDATE users SET email=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND email<>?",
|
||||
)
|
||||
.bind(&email)
|
||||
.bind(id)
|
||||
.bind(&email)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
AuthUser {
|
||||
id,
|
||||
is_admin: row.try_get("is_admin")?,
|
||||
}
|
||||
} else {
|
||||
let email = email.unwrap_or_else(|| {
|
||||
format!("{}@remote", truncate_bytes(identifier, 184)).to_lowercase()
|
||||
});
|
||||
let password = tokio::task::spawn_blocking(|| {
|
||||
bcrypt::hash(crate::auth::random(), bcrypt::DEFAULT_COST)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "Server Error"))?
|
||||
.map_err(|_| ApiError(StatusCode::INTERNAL_SERVER_ERROR, "Server Error"))?;
|
||||
let admin: bool = sqlx::query_scalar("SELECT NOT EXISTS(SELECT 1 FROM users)")
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
let id=sqlx::query("INSERT INTO users(name,email,password,is_admin,created_at,updated_at) VALUES(?,?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)").bind(name).bind(email).bind(password).bind(admin).execute(&mut *tx).await?.last_insert_rowid();
|
||||
AuthUser {
|
||||
id,
|
||||
is_admin: admin,
|
||||
}
|
||||
};
|
||||
let visited=sqlx::query("UPDATE users SET last_seen_at=CURRENT_TIMESTAMP WHERE id=? AND (last_seen_at IS NULL OR last_seen_at<datetime('now','-15 minutes'))").bind(user.id).execute(&mut *tx).await?.rows_affected();
|
||||
if visited > 0 {
|
||||
sqlx::query("INSERT INTO auth_logs(authenticatable_type,authenticatable_id,ip_address,user_agent,login_at,login_successful,guard,login_method) VALUES('App\\Models\\User',?,?,?,CURRENT_TIMESTAMP,1,'reverse-proxy-guard',NULL)").bind(user.id).bind(peer.map(|p|p.ip().to_string())).bind(headers.get("user-agent").and_then(|v|v.to_str().ok())).execute(&mut *tx).await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(Some(user))
|
||||
}
|
||||
/// Paths covered by upstream RejectIfReverseProxy middleware (after authentication).
|
||||
pub fn blocked(method: &Method, path: &str) -> bool {
|
||||
let path = path.trim_end_matches('/');
|
||||
(path == "/user" && matches!(*method, Method::PUT | Method::DELETE))
|
||||
|| (path == "/user/password" && *method == Method::PATCH)
|
||||
|| (path == "/user/logout" && matches!(*method, Method::GET | Method::POST))
|
||||
|| (path == "/oauth/personal-access-tokens"
|
||||
&& matches!(*method, Method::GET | Method::POST))
|
||||
|| (path.starts_with("/oauth/personal-access-tokens/") && *method == Method::DELETE)
|
||||
|| (matches!(path, "/webauthn/register/options" | "/webauthn/register")
|
||||
&& *method == Method::POST)
|
||||
|| (path == "/webauthn/credentials" && *method == Method::GET)
|
||||
|| (path.starts_with("/webauthn/credentials/")
|
||||
&& matches!(*method, Method::PATCH | Method::DELETE))
|
||||
}
|
||||
pub fn blocked_error() -> ApiError {
|
||||
ApiError(
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
"Not applicable when using an auth proxy or SSO",
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn config() -> ProxyConfig {
|
||||
ProxyConfig {
|
||||
enabled: true,
|
||||
trusted_proxies: "127.0.0.1,10.0.0.0/8,2001:db8::/32".into(),
|
||||
user_header: "REMOTE_USER".into(),
|
||||
email_header: Some("REMOTE_EMAIL".into()),
|
||||
}
|
||||
}
|
||||
fn peer() -> Option<SocketAddr> {
|
||||
Some("127.0.0.1:1234".parse().unwrap())
|
||||
}
|
||||
fn headers(name: &str, email: &str) -> HeaderMap {
|
||||
let mut h = HeaderMap::new();
|
||||
h.insert("remote_user", name.parse().unwrap());
|
||||
h.insert("remote_email", email.parse().unwrap());
|
||||
h
|
||||
}
|
||||
#[test]
|
||||
fn trusts_only_configured_socket_addresses() {
|
||||
let c = config();
|
||||
assert!(trusted_peer(&c, peer()));
|
||||
assert!(trusted_peer(&c, Some("10.23.4.5:1".parse().unwrap())));
|
||||
assert!(trusted_peer(&c, Some("[2001:db8::1]:1".parse().unwrap())));
|
||||
assert!(!trusted_peer(&c, Some("192.0.2.1:1".parse().unwrap())));
|
||||
assert!(!trusted_peer(&c, None));
|
||||
assert!(!trusted_peer(
|
||||
&ProxyConfig {
|
||||
trusted_proxies: String::new(),
|
||||
..c
|
||||
},
|
||||
peer()
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn blocked_routes_match_upstream_scope() {
|
||||
assert!(blocked(&Method::DELETE, "/user"));
|
||||
assert!(blocked(&Method::GET, "/user/logout"));
|
||||
assert!(blocked(&Method::DELETE, "/oauth/personal-access-tokens/42"));
|
||||
assert!(!blocked(&Method::GET, "/api/v1/user"));
|
||||
assert!(!blocked(&Method::PUT, "/api/v1/user/preferences/lang"));
|
||||
assert!(!blocked(&Method::GET, "/api/v1/twofaccounts"));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn provisions_by_name_promotes_only_first_and_synchronizes_free_email() {
|
||||
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
crate::db::init(&db).await.unwrap();
|
||||
let state = AppState {
|
||||
db,
|
||||
cipher: None,
|
||||
encrypt_db: std::sync::atomic::AtomicBool::new(false).into(),
|
||||
};
|
||||
let c = config();
|
||||
let alice =
|
||||
authenticate_with_config(&state, &headers("alice", "ALICE@example.com"), peer(), &c)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(alice.is_admin);
|
||||
let bob =
|
||||
authenticate_with_config(&state, &headers("bob", "alice@example.com"), peer(), &c)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(!bob.is_admin);
|
||||
let email: String = sqlx::query_scalar("SELECT email FROM users WHERE id=?")
|
||||
.bind(bob.id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(email, "bob@remote");
|
||||
let updated =
|
||||
authenticate_with_config(&state, &headers("bob", "new@example.com"), peer(), &c)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(updated.id, bob.id);
|
||||
let email: String = sqlx::query_scalar("SELECT email FROM users WHERE id=?")
|
||||
.bind(bob.id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(email, "new@example.com");
|
||||
let count: i64 = sqlx::query_scalar("SELECT count(*) FROM auth_logs")
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
assert_eq!(
|
||||
authenticate_with_config(&state, &HeaderMap::new(), peer(), &c)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.0,
|
||||
StatusCode::PROXY_AUTHENTICATION_REQUIRED
|
||||
);
|
||||
assert_eq!(
|
||||
authenticate_with_config(
|
||||
&state,
|
||||
&headers("alice", ""),
|
||||
Some("192.0.2.1:2".parse().unwrap()),
|
||||
&c
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.0,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
let mut duplicate = headers("alice", "");
|
||||
duplicate.append("remote_user", "bob".parse().unwrap());
|
||||
assert_eq!(
|
||||
authenticate_with_config(&state, &duplicate, peer(), &c)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.0,
|
||||
StatusCode::PROXY_AUTHENTICATION_REQUIRED
|
||||
);
|
||||
assert!(
|
||||
authenticate_with_config(
|
||||
&state,
|
||||
&headers("alice", ""),
|
||||
peer(),
|
||||
&ProxyConfig {
|
||||
enabled: false,
|
||||
..c
|
||||
}
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
//! Read-only checks against the SQLite schema produced by the pinned PHP migrations.
|
||||
//! Only application objects used by this backend are required; Rust metadata and
|
||||
//! framework cache/migration tables are intentionally outside this contract.
|
||||
use sqlx::{Row, SqlitePool, sqlite::SqlitePoolOptions};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const REFERENCE: &str = include_str!("../compatibility/sqlite-reference-schema.sql");
|
||||
const TABLES: &[&str] = &[
|
||||
"users",
|
||||
"options",
|
||||
"twofaccounts",
|
||||
"groups",
|
||||
"icons",
|
||||
"twofaccount_group_assignments",
|
||||
"twofaccount_user_orders",
|
||||
"twofaccount_shares",
|
||||
"otp_logs",
|
||||
"auth_logs",
|
||||
"password_resets",
|
||||
"webauthn_recoveries",
|
||||
"webauthn_credentials",
|
||||
"oauth_clients",
|
||||
"oauth_access_tokens",
|
||||
"oauth_auth_codes",
|
||||
"oauth_refresh_tokens",
|
||||
"oauth_device_codes",
|
||||
];
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Column {
|
||||
kind: String,
|
||||
primary: i64,
|
||||
}
|
||||
fn quote(name: &str) -> String {
|
||||
format!("\"{}\"", name.replace('"', "\"\""))
|
||||
}
|
||||
async fn columns(db: &SqlitePool, table: &str) -> Result<BTreeMap<String, Column>, sqlx::Error> {
|
||||
Ok(sqlx::query(&format!("PRAGMA table_info({})", quote(table)))
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
(
|
||||
row.get("name"),
|
||||
Column {
|
||||
kind: row.get::<String, _>("type").to_ascii_uppercase(),
|
||||
primary: row.get("pk"),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
fn family(kind: &str) -> &'static str {
|
||||
if kind.contains("INT") {
|
||||
"integer"
|
||||
} else if kind.contains("CHAR") || kind.contains("CLOB") || kind.contains("TEXT") {
|
||||
"text"
|
||||
} else if kind.contains("DATE") || kind.contains("TIME") {
|
||||
"timestamp"
|
||||
} else if kind.contains("BLOB") || kind.is_empty() {
|
||||
"blob"
|
||||
} else if kind.contains("REAL") || kind.contains("FLOA") || kind.contains("DOUB") {
|
||||
"real"
|
||||
} else {
|
||||
"numeric"
|
||||
}
|
||||
}
|
||||
fn compatible(expected: &str, actual: &str) -> bool {
|
||||
family(expected) == family(actual)
|
||||
|| (family(expected) == "timestamp" && family(actual) == "text")
|
||||
}
|
||||
async fn unique_keys(db: &SqlitePool, table: &str) -> Result<Vec<Vec<String>>, sqlx::Error> {
|
||||
let mut keys = Vec::new();
|
||||
let cols = columns(db, table).await?;
|
||||
let mut pk: Vec<_> = cols.iter().filter(|(_, c)| c.primary > 0).collect();
|
||||
pk.sort_by_key(|(_, c)| c.primary);
|
||||
if !pk.is_empty() {
|
||||
keys.push(pk.into_iter().map(|(n, _)| n.clone()).collect());
|
||||
}
|
||||
for index in sqlx::query(&format!("PRAGMA index_list({})", quote(table)))
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
{
|
||||
if index.get::<i64, _>("unique") != 1 || index.get::<i64, _>("partial") != 0 {
|
||||
continue;
|
||||
}
|
||||
let name: String = index.get("name");
|
||||
let rows = sqlx::query(&format!("PRAGMA index_info({})", quote(&name)))
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
// Expression indexes cannot satisfy a plain-column ON CONFLICT target.
|
||||
let names: Option<Vec<String>> = rows
|
||||
.into_iter()
|
||||
.map(|r| r.get::<Option<String>, _>("name"))
|
||||
.collect();
|
||||
if let Some(names) = names {
|
||||
keys.push(names);
|
||||
}
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
/// Require Laravel's sessions table only for the configured database session driver.
|
||||
pub async fn validate_existing(db: &SqlitePool) -> anyhow::Result<()> {
|
||||
let table = std::env::var("SESSION_TABLE").unwrap_or_else(|_| "sessions".into());
|
||||
let database_sessions = std::env::var("SESSION_DRIVER").is_ok_and(|v| v == "database");
|
||||
if database_sessions {
|
||||
anyhow::ensure!(
|
||||
!table.is_empty()
|
||||
&& table
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'_'),
|
||||
"SESSION_TABLE must be a nonempty SQLite identifier"
|
||||
);
|
||||
}
|
||||
validate_schema(db, database_sessions.then_some(table.as_str())).await
|
||||
}
|
||||
|
||||
/// Validate structure without reading user records or executing changes on `db`.
|
||||
pub async fn validate_existing_with_sessions(
|
||||
db: &SqlitePool,
|
||||
database_sessions: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
validate_schema(db, database_sessions.then_some("sessions")).await
|
||||
}
|
||||
|
||||
async fn validate_schema(db: &SqlitePool, session_table: Option<&str>) -> anyhow::Result<()> {
|
||||
let reference = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await?;
|
||||
sqlx::raw_sql(REFERENCE).execute(&reference).await?;
|
||||
let mut issues = Vec::new();
|
||||
let mut tables: Vec<_> = TABLES.iter().map(|&table| (table, table)).collect();
|
||||
if let Some(table) = session_table {
|
||||
tables.push(("sessions", table));
|
||||
}
|
||||
for (reference_table, table) in tables {
|
||||
let expected = columns(&reference, reference_table).await?;
|
||||
let actual = columns(db, table).await?;
|
||||
if actual.is_empty() {
|
||||
issues.push(format!("missing table {table}"));
|
||||
continue;
|
||||
}
|
||||
for (name, column) in &expected {
|
||||
// These obsolete columns survive the pinned SQLite rebuild but are
|
||||
// absent on other migration paths and are not used by this backend.
|
||||
if table == "twofaccounts" && matches!(name.as_str(), "group_id" | "order_column") {
|
||||
continue;
|
||||
}
|
||||
match actual.get(name) {
|
||||
None => issues.push(format!("missing column {table}.{name}")),
|
||||
Some(found) if !compatible(&column.kind, &found.kind) => issues.push(format!(
|
||||
"incompatible type for {table}.{name}: expected {}, found {}",
|
||||
column.kind, found.kind
|
||||
)),
|
||||
Some(found) if column.primary > 0 && found.primary != column.primary => issues
|
||||
.push(format!(
|
||||
"missing primary key position {} on {table}.{name}",
|
||||
column.primary
|
||||
)),
|
||||
Some(found)
|
||||
if column.primary > 0
|
||||
&& column.kind == "INTEGER"
|
||||
&& found.kind != "INTEGER" =>
|
||||
{
|
||||
issues.push(format!(
|
||||
"{table}.{name} must be INTEGER PRIMARY KEY for generated row IDs"
|
||||
))
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
let actual_keys = unique_keys(db, table).await?;
|
||||
for key in unique_keys(&reference, reference_table).await? {
|
||||
if !actual_keys.contains(&key) {
|
||||
issues.push(format!(
|
||||
"missing non-partial unique key {table}({})",
|
||||
key.join(",")
|
||||
));
|
||||
}
|
||||
}
|
||||
let expected_fks = sqlx::query(&format!(
|
||||
"PRAGMA foreign_key_list({})",
|
||||
quote(reference_table)
|
||||
))
|
||||
.fetch_all(&reference)
|
||||
.await?;
|
||||
let actual_fks = sqlx::query(&format!("PRAGMA foreign_key_list({})", quote(table)))
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
for fk in expected_fks {
|
||||
let from: String = fk.get("from");
|
||||
if table == "twofaccounts" && from == "group_id" {
|
||||
continue;
|
||||
}
|
||||
let target: String = fk.get("table");
|
||||
let to: String = fk.get("to");
|
||||
let deletion: String = fk.get("on_delete");
|
||||
if !actual_fks.iter().any(|found| {
|
||||
found.get::<String, _>("from") == from
|
||||
&& found.get::<String, _>("table") == target
|
||||
&& found.get::<String, _>("to") == to
|
||||
&& found
|
||||
.get::<String, _>("on_delete")
|
||||
.eq_ignore_ascii_case(&deletion)
|
||||
}) {
|
||||
issues.push(format!(
|
||||
"missing foreign key {table}.{from} -> {target}.{to} ON DELETE {deletion}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
reference.close().await;
|
||||
anyhow::ensure!(
|
||||
issues.is_empty(),
|
||||
"Database schema is incompatible with pinned Laravel migrations: {}. Migrate an offline copy with the pinned PHP application before retrying",
|
||||
issues.join("; ")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
async fn fixture(ddl: &str) -> SqlitePool {
|
||||
let db = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::raw_sql(ddl).execute(&db).await.unwrap();
|
||||
db
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn pinned_migrations_pass_without_requiring_framework_cache() {
|
||||
let db = fixture(REFERENCE).await;
|
||||
sqlx::raw_sql("DROP TABLE cache; DROP TABLE cache_locks; DROP TABLE migrations")
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
validate_existing_with_sessions(&db, true).await.unwrap();
|
||||
let rows: i64 = sqlx::query_scalar("SELECT count(*) FROM users")
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rows, 0);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn reports_missing_columns_from_previously_unchecked_tables() {
|
||||
let db = fixture(REFERENCE).await;
|
||||
sqlx::raw_sql("ALTER TABLE webauthn_credentials DROP COLUMN public_key; ALTER TABLE password_resets DROP COLUMN token; ALTER TABLE auth_logs DROP COLUMN guard; ALTER TABLE sessions DROP COLUMN payload").execute(&db).await.unwrap();
|
||||
let error = validate_existing_with_sessions(&db, true)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
for field in [
|
||||
"webauthn_credentials.public_key",
|
||||
"password_resets.token",
|
||||
"auth_logs.guard",
|
||||
"sessions.payload",
|
||||
] {
|
||||
assert!(error.contains(field), "{error}");
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn requires_full_unique_keys_independent_of_index_names() {
|
||||
let db = fixture(REFERENCE).await;
|
||||
sqlx::raw_sql("DROP INDEX users_name_unique; DROP INDEX twofaccount_user_orders_unique_scope; CREATE UNIQUE INDEX partial_name ON users(name) WHERE is_admin=1").execute(&db).await.unwrap();
|
||||
let error = validate_existing_with_sessions(&db, false)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("users(name)"));
|
||||
assert!(error.contains("twofaccount_user_orders(user_id,twofaccount_id)"));
|
||||
sqlx::raw_sql("CREATE UNIQUE INDEX renamed_name ON users(name); CREATE UNIQUE INDEX renamed_orders ON twofaccount_user_orders(user_id,twofaccount_id)").execute(&db).await.unwrap();
|
||||
validate_existing_with_sessions(&db, false).await.unwrap();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn detects_wrong_foreign_key_action_and_numeric_type() {
|
||||
let ddl = REFERENCE.replace("\"digits\" integer not null", "\"digits\" text not null")
|
||||
.replace("foreign key(\"authenticatable_id\") references \"users\"(\"id\") on delete cascade", "foreign key(\"authenticatable_id\") references \"users\"(\"id\") on delete set null");
|
||||
let db = fixture(&ddl).await;
|
||||
let error = validate_existing_with_sessions(&db, false)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("twofaccounts.digits"));
|
||||
assert!(error.contains("auth_logs.authenticatable_id -> users.id ON DELETE CASCADE"));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn custom_database_session_table_is_validated() {
|
||||
let db = fixture(REFERENCE).await;
|
||||
sqlx::query("ALTER TABLE sessions RENAME TO custom_sessions")
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
validate_schema(&db, Some("custom_sessions")).await.unwrap();
|
||||
sqlx::query("ALTER TABLE custom_sessions DROP COLUMN payload")
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
validate_schema(&db, Some("custom_sessions"))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("custom_sessions.payload")
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn sessions_are_only_required_for_database_session_driver() {
|
||||
let db = fixture(REFERENCE).await;
|
||||
sqlx::query("DROP TABLE sessions")
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
validate_existing_with_sessions(&db, false).await.unwrap();
|
||||
assert!(
|
||||
validate_existing_with_sessions(&db, true)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("missing table sessions")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"useEncryption": false,
|
||||
"checkForUpdate": true,
|
||||
"lastRadarScan": 0,
|
||||
"latestRelease": false,
|
||||
"disableRegistration": false,
|
||||
"enableSso": true,
|
||||
"useSsoOnly": false,
|
||||
"allowPatWhileSsoOnly": false,
|
||||
"restrictRegistration": false,
|
||||
"restrictList": "",
|
||||
"restrictRule": "",
|
||||
"keepSsoRegistrationEnabled": false,
|
||||
"storeIconsInDatabase": false,
|
||||
"enableSharing": false,
|
||||
"enableAllUsersSharingScope": false
|
||||
}
|
||||
+664
@@ -0,0 +1,664 @@
|
||||
//! User resources, preferences and transactional global settings.
|
||||
use crate::{ApiError, AppState, auth::AuthUser};
|
||||
use axum::{
|
||||
Extension, Json, Router,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/user", get(user))
|
||||
.route("/user/preferences", get(preferences))
|
||||
.route(
|
||||
"/user/preferences/{name}",
|
||||
get(preference).put(set_preference),
|
||||
)
|
||||
.route("/features", get(features))
|
||||
.route("/features/{name}", get(feature))
|
||||
.route("/settings", get(settings).post(store_setting))
|
||||
.route(
|
||||
"/settings/{name}",
|
||||
get(setting).put(update_setting).delete(delete_setting),
|
||||
)
|
||||
}
|
||||
pub fn preferences_from(raw: Option<&str>) -> Value {
|
||||
let mut value: Value =
|
||||
serde_json::from_str(include_str!("preferences.json")).expect("built-in preferences");
|
||||
let envmap: Map<String, Value> =
|
||||
serde_json::from_str(include_str!("preference_env.json")).expect("env keys");
|
||||
for (key, envkey) in envmap {
|
||||
if let Ok(v) = std::env::var(envkey.as_str().unwrap_or_default())
|
||||
&& !v.is_empty()
|
||||
{
|
||||
let original = &value[&key];
|
||||
value[&key] = if original.is_boolean() {
|
||||
json!(v == "true" || v == "1" || v == "(true)")
|
||||
} else if original.is_number() {
|
||||
serde_json::from_str(&v).unwrap_or(json!(v))
|
||||
} else {
|
||||
json!(v)
|
||||
};
|
||||
}
|
||||
}
|
||||
if let Some(Value::Object(extra)) = raw.and_then(|s| serde_json::from_str::<Value>(s).ok()) {
|
||||
value.as_object_mut().unwrap().extend(extra);
|
||||
}
|
||||
value
|
||||
}
|
||||
fn locked(name: &str) -> bool {
|
||||
if [
|
||||
"activeGroup",
|
||||
"defaultGroup",
|
||||
"useWebauthnOnly",
|
||||
"sortOrder",
|
||||
]
|
||||
.contains(&name)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let envmap: Map<String, Value> =
|
||||
serde_json::from_str(include_str!("preference_env.json")).unwrap();
|
||||
envmap
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|n| std::env::var(n.replacen("USERPREF_DEFAULT__", "USERPREF_LOCKED__", 1)).ok())
|
||||
.is_some_and(|v| matches!(v.as_str(), "true" | "1" | "(true)"))
|
||||
}
|
||||
fn restore(s: &str) -> Value {
|
||||
match s {
|
||||
"{{}}" => json!(false),
|
||||
"{{1}}" => json!(true),
|
||||
_ => {
|
||||
// PHP treats numeric strings as numbers, but other JSON-looking text as strings.
|
||||
s.parse::<i64>()
|
||||
.map(|v| json!(v))
|
||||
.or_else(|_| s.parse::<f64>().map(|v| json!(v)))
|
||||
.unwrap_or_else(|_| json!(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
pub async fn all(db: &SqlitePool) -> Result<Value, sqlx::Error> {
|
||||
let mut settings: Value =
|
||||
serde_json::from_str(include_str!("settings.json")).expect("built-in settings");
|
||||
for row in sqlx::query("SELECT key,value FROM options")
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
{
|
||||
let key: String = row.try_get("key")?;
|
||||
let val: Option<String> = row.try_get("value")?;
|
||||
settings[&key] = val.map(|s| restore(&s)).unwrap_or(Value::Null);
|
||||
}
|
||||
Ok(settings)
|
||||
}
|
||||
pub async fn regular_settings(db: &SqlitePool) -> Result<Value, sqlx::Error> {
|
||||
let all = all(db).await?;
|
||||
let mut m = Map::new();
|
||||
for key in [
|
||||
"disableRegistration",
|
||||
"enableSso",
|
||||
"useSsoOnly",
|
||||
"enableSharing",
|
||||
"enableAllUsersSharingScope",
|
||||
"allowPatWhileSsoOnly",
|
||||
] {
|
||||
m.insert(key.into(), all[key].clone());
|
||||
}
|
||||
Ok(Value::Object(m))
|
||||
}
|
||||
async fn user(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let row = sqlx::query(
|
||||
"SELECT id,name,email,oauth_provider,preferences,is_admin FROM users WHERE id=?",
|
||||
)
|
||||
.bind(auth.id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
let raw: Option<String> = row.try_get("preferences")?;
|
||||
Ok(Json(
|
||||
json!({"id":row.try_get::<i64,_>("id")?,"name":row.try_get::<String,_>("name")?,"email":row.try_get::<String,_>("email")?,"oauth_provider":row.try_get::<Option<String>,_>("oauth_provider")?,"authenticated_by_proxy":crate::proxy_auth::enabled(),"preferences":preferences_from(raw.as_deref()),"appSettings":regular_settings(&state.db).await?,"is_admin":row.try_get::<bool,_>("is_admin")?}),
|
||||
))
|
||||
}
|
||||
async fn load_prefs(state: &AppState, id: i64) -> Result<Value, ApiError> {
|
||||
let raw: Option<String> = sqlx::query_scalar("SELECT preferences FROM users WHERE id=?")
|
||||
.bind(id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
Ok(preferences_from(raw.as_deref()))
|
||||
}
|
||||
async fn preferences(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let prefs = load_prefs(&state, auth.id).await?;
|
||||
Ok(Json(Value::Array(
|
||||
prefs
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|(key, value)| json!({"key":key,"value":value,"locked":locked(key)}))
|
||||
.collect(),
|
||||
)))
|
||||
}
|
||||
async fn preference(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let p = load_prefs(&state, auth.id).await?;
|
||||
let v = p
|
||||
.get(&name)
|
||||
.ok_or(ApiError(StatusCode::NOT_FOUND, "not found"))?;
|
||||
Ok(Json(json!({"key":name,"value":v,"locked":locked(&name)})))
|
||||
}
|
||||
async fn set_preference(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(name): Path<String>,
|
||||
Json(input): Json<Value>,
|
||||
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
||||
if locked(&name) {
|
||||
return Err(ApiError(StatusCode::FORBIDDEN, "Forbidden"));
|
||||
}
|
||||
let v = input
|
||||
.get("value")
|
||||
.filter(|v| !v.is_null() && v.as_str() != Some(""))
|
||||
.ok_or(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"The value field is required.",
|
||||
))?;
|
||||
let mut tx = state.db.begin().await?;
|
||||
let raw: Option<String> = sqlx::query_scalar("SELECT preferences FROM users WHERE id=?")
|
||||
.bind(auth.id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
let p = preferences_from(raw.as_deref());
|
||||
if p.get(&name).is_none() {
|
||||
return Err(ApiError(StatusCode::NOT_FOUND, "not found"));
|
||||
}
|
||||
let mut stored: Value = raw
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.filter(Value::is_object)
|
||||
.unwrap_or(json!({}));
|
||||
stored[&name] = v.clone();
|
||||
sqlx::query("UPDATE users SET preferences=?,updated_at=CURRENT_TIMESTAMP WHERE id=?")
|
||||
.bind(stored.to_string())
|
||||
.bind(auth.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok((StatusCode::CREATED, Json(json!({"key":name,"value":v}))))
|
||||
}
|
||||
const FEATURES: [&str; 5] = [
|
||||
"iconPack",
|
||||
"sharing",
|
||||
"allUsersSharingScope",
|
||||
"showNextOtp",
|
||||
"groupChips",
|
||||
];
|
||||
fn feature_value(name: &str, s: &Value) -> Value {
|
||||
let enabled = match name {
|
||||
"sharing" => php_bool(&s["enableSharing"]),
|
||||
"allUsersSharingScope" => php_bool(&s["enableAllUsersSharingScope"]),
|
||||
_ => true,
|
||||
};
|
||||
json!({"name":name,"state":if enabled{"enabled"}else{"disabled"}})
|
||||
}
|
||||
async fn features(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
|
||||
let s = all(&state.db).await?;
|
||||
Ok(Json(Value::Array(
|
||||
FEATURES.iter().map(|f| feature_value(f, &s)).collect(),
|
||||
)))
|
||||
}
|
||||
async fn feature(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
if !FEATURES.contains(&name.as_str()) {
|
||||
return Err(ApiError(StatusCode::NOT_FOUND, "not found"));
|
||||
}
|
||||
Ok(Json(feature_value(&name, &all(&state.db).await?)))
|
||||
}
|
||||
async fn settings(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
admin(&auth)?;
|
||||
Ok(Json(Value::Array(
|
||||
all(&state.db)
|
||||
.await?
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|(k, v)| json!({"key":k,"value":v}))
|
||||
.collect(),
|
||||
)))
|
||||
}
|
||||
async fn setting(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
admin(&auth)?;
|
||||
let s = all(&state.db).await?;
|
||||
let v = s
|
||||
.get(&name)
|
||||
.ok_or(ApiError(StatusCode::NOT_FOUND, "not found"))?;
|
||||
Ok(Json(json!({"key":name,"value":v})))
|
||||
}
|
||||
fn admin(a: &AuthUser) -> Result<(), ApiError> {
|
||||
if a.is_admin {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError(StatusCode::FORBIDDEN, "Forbidden"))
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_value(v: &Value) -> bool {
|
||||
v.is_null()
|
||||
|| v.as_str().is_some_and(|x| x.trim().is_empty())
|
||||
|| v.as_array().is_some_and(Vec::is_empty)
|
||||
|| v.as_object().is_some_and(Map::is_empty)
|
||||
}
|
||||
pub fn php_bool(v: &Value) -> bool {
|
||||
match v {
|
||||
Value::Null => false,
|
||||
Value::Bool(x) => *x,
|
||||
Value::String(x) => !x.is_empty() && x != "0",
|
||||
Value::Number(x) => x.as_f64() != Some(0.),
|
||||
Value::Array(x) => !x.is_empty(),
|
||||
Value::Object(x) => !x.is_empty(),
|
||||
}
|
||||
}
|
||||
fn option_string(v: &Value) -> String {
|
||||
match v {
|
||||
Value::Bool(true) => "{{1}}".into(),
|
||||
Value::Bool(false) => "{{}}".into(),
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Null => String::new(),
|
||||
Value::Array(_) | Value::Object(_) => "Array".into(),
|
||||
_ => v.to_string(),
|
||||
}
|
||||
}
|
||||
fn validation(field: &str, message: &str) -> Response {
|
||||
(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Json(json!({"message":message,"errors":{field:[message]}})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
async fn store_setting(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Json(input): Json<Value>,
|
||||
) -> Result<Response, ApiError> {
|
||||
admin(&auth)?;
|
||||
let Some(key) = input
|
||||
.get("key")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|x| !x.is_empty())
|
||||
else {
|
||||
return Ok(validation("key", "The key field is required."));
|
||||
};
|
||||
if key.chars().count() > 128 || !key.chars().all(char::is_alphabetic) {
|
||||
return Ok(validation(
|
||||
"key",
|
||||
"The key field must only contain letters.",
|
||||
));
|
||||
}
|
||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM options WHERE key=?)")
|
||||
.bind(key)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
if exists {
|
||||
return Ok(validation("key", "The key has already been taken."));
|
||||
}
|
||||
let v = input.get("value").unwrap_or(&Value::Null);
|
||||
if empty_value(v) {
|
||||
return Ok(validation("value", "The value field is required."));
|
||||
}
|
||||
apply_setting(&state, key, v).await?;
|
||||
Ok((StatusCode::CREATED, Json(json!({"key":key,"value":v}))).into_response())
|
||||
}
|
||||
async fn update_setting(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(key): Path<String>,
|
||||
Json(input): Json<Value>,
|
||||
) -> Result<Response, ApiError> {
|
||||
admin(&auth)?;
|
||||
if !key.bytes().all(|c| c.is_ascii_alphabetic()) {
|
||||
return Err(ApiError(StatusCode::NOT_FOUND, "unknowkn endpoint"));
|
||||
}
|
||||
let v = input.get("value").unwrap_or(&Value::Null);
|
||||
if key == "restrictRule" {
|
||||
if let Some(pattern) = v.as_str().filter(|p| !p.is_empty())
|
||||
&& !crate::users::validate_regex(pattern)
|
||||
{
|
||||
return Ok(validation(
|
||||
"value",
|
||||
"The value is not a valid regular expression.",
|
||||
));
|
||||
}
|
||||
} else if key == "restrictList" {
|
||||
if let Some(list) = v.as_str().filter(|p| !p.is_empty())
|
||||
&& list
|
||||
.split('|')
|
||||
.any(|email| !email.contains('@') || email.contains(char::is_whitespace))
|
||||
{
|
||||
return Ok(validation(
|
||||
"value",
|
||||
"The value must contain valid email addresses.",
|
||||
));
|
||||
}
|
||||
} else if empty_value(v) {
|
||||
return Ok(validation("value", "The value field is required."));
|
||||
}
|
||||
apply_setting(&state, &key, v).await?;
|
||||
Ok(Json(json!({"key":key,"value":v})).into_response())
|
||||
}
|
||||
async fn delete_setting(
|
||||
State(state): State<AppState>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(key): Path<String>,
|
||||
) -> Result<Response, ApiError> {
|
||||
admin(&auth)?;
|
||||
if all(&state.db).await?.get(&key).is_none_or(Value::is_null) {
|
||||
return Err(ApiError(StatusCode::NOT_FOUND, "not found"));
|
||||
}
|
||||
let defaults: Value = serde_json::from_str(include_str!("settings.json")).unwrap();
|
||||
if defaults.get(&key).is_some_and(|v| v.as_str() != Some("")) {
|
||||
return Ok((StatusCode::BAD_REQUEST,Json(json!({"message":"bad request","reason":["Only user-defined settings can be deleted."]}))).into_response());
|
||||
}
|
||||
sqlx::query("DELETE FROM options WHERE key=?")
|
||||
.bind(key)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT.into_response())
|
||||
}
|
||||
async fn apply_setting(state: &AppState, key: &str, value: &Value) -> Result<(), ApiError> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
// Acquire the write reservation before reading the encryption flag/data.
|
||||
sqlx::query("UPDATE options SET value=value WHERE key=?")
|
||||
.bind(key)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let mut next_encryption = None;
|
||||
if key == "useEncryption" {
|
||||
let enabled = php_bool(value);
|
||||
if enabled != state.encryption_enabled() {
|
||||
let cipher = state.cipher.as_ref().ok_or(ApiError(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Encryption key is required",
|
||||
))?;
|
||||
let convert = |v: &str| {
|
||||
if enabled {
|
||||
cipher.encrypt(v)
|
||||
} else {
|
||||
cipher.decrypt(v)
|
||||
}
|
||||
};
|
||||
let err = || {
|
||||
ApiError(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Unable to change database encryption; no data were changed.",
|
||||
)
|
||||
};
|
||||
for row in sqlx::query("SELECT id,service,legacy_uri,account,secret FROM twofaccounts")
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
{
|
||||
let service: Option<String> = row.try_get("service")?;
|
||||
let service = service
|
||||
.map(|v| if v.is_empty() { Ok(v) } else { convert(&v) })
|
||||
.transpose()
|
||||
.map_err(|_| err())?;
|
||||
let legacy: Option<String> = row.try_get("legacy_uri")?;
|
||||
let legacy = convert(legacy.as_deref().unwrap_or("")).map_err(|_| err())?;
|
||||
let account: String = row.try_get("account")?;
|
||||
let secret: String = row.try_get("secret")?;
|
||||
sqlx::query(
|
||||
"UPDATE twofaccounts SET service=?,legacy_uri=?,account=?,secret=? WHERE id=?",
|
||||
)
|
||||
.bind(service)
|
||||
.bind(legacy)
|
||||
.bind(convert(&account).map_err(|_| err())?)
|
||||
.bind(convert(&secret).map_err(|_| err())?)
|
||||
.bind(row.try_get::<i64, _>("id")?)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
for row in sqlx::query("SELECT name,content FROM icons")
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
{
|
||||
let content: String = row.try_get("content")?;
|
||||
sqlx::query("UPDATE icons SET content=? WHERE name=?")
|
||||
.bind(convert(&content).map_err(|_| err())?)
|
||||
.bind(row.try_get::<String, _>("name")?)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
next_encryption = Some(enabled);
|
||||
}
|
||||
if key == "storeIconsInDatabase" {
|
||||
crate::icons::replicate_to_database(&mut tx, state, php_bool(value)).await?;
|
||||
}
|
||||
if key == "enableAllUsersSharingScope" && php_bool(value) {
|
||||
sqlx::query("DELETE FROM twofaccount_shares WHERE scope='all_users' AND twofaccount_id IN (SELECT twofaccount_id FROM twofaccount_shares WHERE scope='user')").execute(&mut *tx).await?;
|
||||
}
|
||||
sqlx::query("INSERT INTO options(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(key).bind(option_string(value)).execute(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
if let Some(v) = next_encryption {
|
||||
state
|
||||
.encrypt_db
|
||||
.store(v, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn state() -> AppState {
|
||||
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
crate::db::init(&db).await.unwrap();
|
||||
sqlx::query("INSERT INTO users(id,name,email,password,is_admin) VALUES(1,'admin','admin@example.test','unused',1)").execute(&db).await.unwrap();
|
||||
AppState {
|
||||
db,
|
||||
encrypt_db: std::sync::atomic::AtomicBool::new(false).into(),
|
||||
cipher: Some(std::sync::Arc::new(
|
||||
crate::crypto::LaravelCipher::from_app_key(
|
||||
"base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
)
|
||||
.unwrap(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn encryption_transitions_roundtrip_and_rollback_corruption() {
|
||||
let s = state().await;
|
||||
sqlx::query("INSERT INTO twofaccounts(user_id,account,service,secret,otp_type,algorithm,digits,legacy_uri) VALUES(1,'alice',NULL,'MY======','totp','sha1',6,NULL)").execute(&s.db).await.unwrap();
|
||||
sqlx::query("INSERT INTO icons(name,content) VALUES('x.png','aWNvbg==')")
|
||||
.execute(&s.db)
|
||||
.await
|
||||
.unwrap();
|
||||
apply_setting(&s, "useEncryption", &json!(true))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(s.encryption_enabled());
|
||||
let secret: String = sqlx::query_scalar("SELECT secret FROM twofaccounts")
|
||||
.fetch_one(&s.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.cipher.as_ref().unwrap().decrypt(&secret).unwrap(),
|
||||
"MY======"
|
||||
);
|
||||
apply_setting(&s, "useEncryption", &json!(true))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, String>("SELECT secret FROM twofaccounts")
|
||||
.fetch_one(&s.db)
|
||||
.await
|
||||
.unwrap(),
|
||||
secret
|
||||
);
|
||||
sqlx::query("UPDATE icons SET content='corrupt'")
|
||||
.execute(&s.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
apply_setting(&s, "useEncryption", &json!(false))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(s.encryption_enabled());
|
||||
assert_eq!(all(&s.db).await.unwrap()["useEncryption"], true);
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, String>("SELECT secret FROM twofaccounts")
|
||||
.fetch_one(&s.db)
|
||||
.await
|
||||
.unwrap(),
|
||||
secret
|
||||
);
|
||||
sqlx::query("DELETE FROM icons")
|
||||
.execute(&s.db)
|
||||
.await
|
||||
.unwrap();
|
||||
apply_setting(&s, "useEncryption", &json!(false))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!s.encryption_enabled());
|
||||
let row = sqlx::query("SELECT account,service,secret,legacy_uri FROM twofaccounts")
|
||||
.fetch_one(&s.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(row.get::<String, _>("account"), "alice");
|
||||
assert_eq!(row.get::<String, _>("secret"), "MY======");
|
||||
assert_eq!(row.get::<Option<String>, _>("service"), None);
|
||||
assert_eq!(row.get::<String, _>("legacy_uri"), "");
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn custom_settings_crud_and_admin_boundary() {
|
||||
let s = state().await;
|
||||
let admin = AuthUser {
|
||||
id: 1,
|
||||
is_admin: true,
|
||||
};
|
||||
let regular = AuthUser {
|
||||
id: 1,
|
||||
is_admin: false,
|
||||
};
|
||||
assert!(
|
||||
store_setting(
|
||||
State(s.clone()),
|
||||
Extension(regular),
|
||||
Json(json!({"key":"Custom","value":true}))
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let r = store_setting(
|
||||
State(s.clone()),
|
||||
Extension(admin.clone()),
|
||||
Json(json!({"key":"Custom","value":false})),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.status(), StatusCode::CREATED);
|
||||
assert_eq!(all(&s.db).await.unwrap()["Custom"], false);
|
||||
let duplicate = store_setting(
|
||||
State(s.clone()),
|
||||
Extension(admin.clone()),
|
||||
Json(json!({"key":"Custom","value":true})),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(duplicate.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
update_setting(
|
||||
State(s.clone()),
|
||||
Extension(admin.clone()),
|
||||
Path("Custom".into()),
|
||||
Json(json!({"value":"42"})),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(all(&s.db).await.unwrap()["Custom"], 42);
|
||||
assert_eq!(
|
||||
delete_setting(
|
||||
State(s.clone()),
|
||||
Extension(admin.clone()),
|
||||
Path("useEncryption".into())
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.status(),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
assert_eq!(
|
||||
delete_setting(State(s.clone()), Extension(admin), Path("Custom".into()))
|
||||
.await
|
||||
.unwrap()
|
||||
.status(),
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
assert!(all(&s.db).await.unwrap().get("Custom").is_none());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn all_users_scope_reactivation_reconciles_explicit_shares() {
|
||||
let s = state().await;
|
||||
sqlx::query(
|
||||
"INSERT INTO users(id,name,email,password) VALUES(2,'bob','bob@example.test','unused')",
|
||||
)
|
||||
.execute(&s.db)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO twofaccounts(id,user_id,account,secret,otp_type,algorithm,digits) VALUES(1,1,'alice','MY','totp','sha1',6),(2,1,'alice','MY','totp','sha1',6)").execute(&s.db).await.unwrap();
|
||||
sqlx::query("INSERT INTO twofaccount_shares(twofaccount_id,shared_with_user_id,scope,created_by_user_id) VALUES(1,NULL,'all_users',1),(1,2,'user',1),(2,NULL,'all_users',1)").execute(&s.db).await.unwrap();
|
||||
apply_setting(&s, "enableAllUsersSharingScope", &json!(true))
|
||||
.await
|
||||
.unwrap();
|
||||
let scopes: Vec<String> =
|
||||
sqlx::query_scalar("SELECT scope FROM twofaccount_shares WHERE twofaccount_id=1")
|
||||
.fetch_all(&s.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(scopes, vec!["user"]);
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM twofaccount_shares WHERE twofaccount_id=2"
|
||||
)
|
||||
.fetch_one(&s.db)
|
||||
.await
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn restores_laravel_options() {
|
||||
assert_eq!(restore("{{}}"), json!(false));
|
||||
assert_eq!(restore("{{1}}"), json!(true));
|
||||
assert_eq!(restore("15"), json!(15));
|
||||
assert_eq!(restore("true"), json!("true"));
|
||||
}
|
||||
#[test]
|
||||
fn preferences_merge() {
|
||||
let p = preferences_from(Some(r#"{"theme":"dark","kickUserAfter":0}"#));
|
||||
assert_eq!(p["theme"], "dark");
|
||||
assert_eq!(p["kickUserAfter"], 0);
|
||||
assert!(p["showNextOtp"].is_boolean());
|
||||
}
|
||||
}
|
||||
+1065
File diff suppressed because it is too large.
Load diff
+897
@@ -0,0 +1,897 @@
|
||||
//! Stateful GitHub OAuth and OpenID Connect login.
|
||||
use crate::{AppState, auth, settings};
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Path, Query, Request, State},
|
||||
http::{HeaderMap, HeaderValue, header},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
routing::get,
|
||||
};
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
|
||||
const FLOW_COOKIE: &str = "twofauth_sso_flow";
|
||||
static HASH_LIMIT: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
|
||||
pub async fn init(db: &SqlitePool) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("CREATE TABLE IF NOT EXISTS rust_sso_flows(state_hash TEXT PRIMARY KEY,browser_hash TEXT NOT NULL,session_hash TEXT NOT NULL,driver TEXT NOT NULL,verifier TEXT NOT NULL,nonce TEXT NOT NULL,expires_at INTEGER NOT NULL)").execute(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/socialite/redirect/{driver}", get(redirect))
|
||||
.route("/socialite/callback/{driver}", get(callback))
|
||||
}
|
||||
fn failure(code: &str) -> Response {
|
||||
Redirect::to(&format!("/error?err={code}")).into_response()
|
||||
}
|
||||
fn flow_cookie(response: &mut Response, value: &str, age: i64) {
|
||||
let secure = if std::env::var("RUST_COOKIE_SECURE").as_deref() != Ok("false") {
|
||||
"; Secure"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
if let Ok(header) = HeaderValue::from_str(&format!(
|
||||
"{FLOW_COOKIE}={value}; Path=/socialite; HttpOnly; SameSite=Lax; Max-Age={age}{secure}"
|
||||
)) {
|
||||
response.headers_mut().append(header::SET_COOKIE, header);
|
||||
}
|
||||
}
|
||||
fn browser_cookie(headers: &HeaderMap) -> Result<String> {
|
||||
let mut found = None;
|
||||
for header in headers.get_all(header::COOKIE) {
|
||||
for part in header.to_str()?.split(';') {
|
||||
if let Some((key, value)) = part.trim().split_once('=')
|
||||
&& key == FLOW_COOKIE
|
||||
{
|
||||
if found.is_some()
|
||||
|| value.len() != 64
|
||||
|| !value.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
{
|
||||
bail!("invalid browser binding");
|
||||
}
|
||||
found = Some(value.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
found.ok_or_else(|| anyhow!("missing browser binding"))
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct Provider {
|
||||
driver: String,
|
||||
client_id: String,
|
||||
secret: String,
|
||||
redirect: String,
|
||||
authorize: String,
|
||||
token: String,
|
||||
userinfo: String,
|
||||
emails: Option<String>,
|
||||
issuer: Option<String>,
|
||||
jwks: Option<String>,
|
||||
algorithms: Vec<String>,
|
||||
verify_ssl: bool,
|
||||
}
|
||||
fn env(key: &str) -> Option<String> {
|
||||
std::env::var(key).ok().filter(|s| !s.is_empty())
|
||||
}
|
||||
fn checked_url(url: &str) -> Result<()> {
|
||||
let u = url::Url::parse(url)?;
|
||||
if !["https", "http"].contains(&u.scheme())
|
||||
|| u.host_str().is_none()
|
||||
|| !u.username().is_empty()
|
||||
|| u.password().is_some()
|
||||
|| u.fragment().is_some()
|
||||
{
|
||||
bail!("invalid provider URL");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn client(provider: &Provider) -> Result<reqwest::Client> {
|
||||
let mut builder = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.user_agent("2FAuth-Rust")
|
||||
.danger_accept_invalid_certs(!provider.verify_ssl);
|
||||
if let Some(proxy) = env("PROXY_FOR_OUTGOING_REQUESTS") {
|
||||
builder = builder.proxy(reqwest::Proxy::all(proxy)?);
|
||||
}
|
||||
Ok(builder.build()?)
|
||||
}
|
||||
async fn response_json(mut response: reqwest::Response) -> Result<Value> {
|
||||
if !response.status().is_success() {
|
||||
bail!("provider request failed");
|
||||
}
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|n| n > 2 * 1024 * 1024)
|
||||
{
|
||||
bail!("provider response too large");
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await? {
|
||||
if data.len() + chunk.len() > 2 * 1024 * 1024 {
|
||||
bail!("provider response too large");
|
||||
}
|
||||
data.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(serde_json::from_slice(&data)?)
|
||||
}
|
||||
impl Provider {
|
||||
async fn load(driver: &str) -> Result<Self> {
|
||||
if !["github", "openid"].contains(&driver) {
|
||||
bail!("unknown provider");
|
||||
}
|
||||
let key = driver.to_ascii_uppercase();
|
||||
let app = env("APP_URL").unwrap_or_else(|| "http://localhost".into());
|
||||
let mut p = Self {
|
||||
driver: driver.into(),
|
||||
client_id: env(&format!("{key}_CLIENT_ID")).ok_or_else(|| anyhow!("missing client"))?,
|
||||
secret: env(&format!("{key}_CLIENT_SECRET"))
|
||||
.ok_or_else(|| anyhow!("missing secret"))?,
|
||||
redirect: format!("{}/socialite/callback/{driver}", app.trim_end_matches('/')),
|
||||
authorize: String::new(),
|
||||
token: String::new(),
|
||||
userinfo: String::new(),
|
||||
emails: None,
|
||||
issuer: None,
|
||||
jwks: None,
|
||||
algorithms: vec!["RS256".into()],
|
||||
verify_ssl: env("OPENID_HTTP_VERIFY_SSL_PEER")
|
||||
.is_none_or(|s| !["false", "0"].contains(&s.as_str())),
|
||||
};
|
||||
if driver == "github" {
|
||||
p.authorize = env("GITHUB_AUTHORIZE_URL")
|
||||
.unwrap_or_else(|| "https://github.com/login/oauth/authorize".into());
|
||||
p.token = env("GITHUB_TOKEN_URL")
|
||||
.unwrap_or_else(|| "https://github.com/login/oauth/access_token".into());
|
||||
let api = env("GITHUB_API_URL").unwrap_or_else(|| "https://api.github.com".into());
|
||||
p.userinfo = format!("{}/user", api.trim_end_matches('/'));
|
||||
p.emails = Some(format!("{}/user/emails", api.trim_end_matches('/')));
|
||||
p.verify_ssl = true;
|
||||
} else {
|
||||
p.authorize = env("OPENID_AUTHORIZE_URL").unwrap_or_default();
|
||||
p.token = env("OPENID_TOKEN_URL").unwrap_or_default();
|
||||
p.userinfo = env("OPENID_USERINFO_URL").unwrap_or_default();
|
||||
p.issuer = env("OPENID_ISSUER");
|
||||
p.jwks = env("OPENID_JWKS_URL");
|
||||
if let Some(issuer) = p.issuer.clone() {
|
||||
let discovery = env("OPENID_DISCOVERY_URL").unwrap_or_else(|| {
|
||||
format!(
|
||||
"{}/.well-known/openid-configuration",
|
||||
issuer.trim_end_matches('/')
|
||||
)
|
||||
});
|
||||
checked_url(&discovery)?;
|
||||
let doc = response_json(client(&p)?.get(discovery).send().await?).await?;
|
||||
if doc["issuer"].as_str() != Some(issuer.as_str()) {
|
||||
bail!("discovery issuer mismatch");
|
||||
}
|
||||
for (key, target) in [
|
||||
("authorization_endpoint", &mut p.authorize),
|
||||
("token_endpoint", &mut p.token),
|
||||
("userinfo_endpoint", &mut p.userinfo),
|
||||
] {
|
||||
if target.is_empty() {
|
||||
*target = doc[key]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("missing endpoint"))?
|
||||
.into();
|
||||
}
|
||||
}
|
||||
if p.jwks.is_none() {
|
||||
p.jwks = doc["jwks_uri"].as_str().map(str::to_owned);
|
||||
}
|
||||
p.algorithms = doc["id_token_signing_alg_values_supported"]
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_else(|| vec!["RS256".into()]);
|
||||
if p.jwks.is_none() {
|
||||
bail!("missing JWKS endpoint");
|
||||
}
|
||||
}
|
||||
}
|
||||
for endpoint in [&p.authorize, &p.token, &p.userinfo, &p.redirect] {
|
||||
checked_url(endpoint)?;
|
||||
}
|
||||
if let Some(jwks) = p.jwks.as_deref() {
|
||||
checked_url(jwks)?;
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
}
|
||||
async fn redirect(
|
||||
State(s): State<AppState>,
|
||||
Path(driver): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
if settings::all(&s.db)
|
||||
.await
|
||||
.is_ok_and(|v| v["enableSso"] != true)
|
||||
{
|
||||
return failure("sso_disabled");
|
||||
}
|
||||
let p = match Provider::load(&driver).await {
|
||||
Ok(p) => p,
|
||||
Err(_) => return failure("sso_bad_provider_setup"),
|
||||
};
|
||||
begin(&s, &headers, &p)
|
||||
.await
|
||||
.unwrap_or_else(|_| failure("sso_failed"))
|
||||
}
|
||||
async fn begin(s: &AppState, headers: &HeaderMap, p: &Provider) -> Result<Response> {
|
||||
init(&s.db).await?;
|
||||
if !auth::throttle_limit(&s.db, "sso-start-global".into(), 120).await? {
|
||||
bail!("too many requests");
|
||||
}
|
||||
let state = auth::random();
|
||||
let browser = auth::random();
|
||||
let sid = auth::random();
|
||||
let csrf = auth::random();
|
||||
let verifier = auth::random();
|
||||
let nonce = auth::random();
|
||||
let old = auth::session(&s.db, headers).await?;
|
||||
let mut tx = s.db.begin().await?;
|
||||
sqlx::query("DELETE FROM rust_sso_flows WHERE expires_at<=?")
|
||||
.bind(auth::now())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if let Some(old) = old {
|
||||
sqlx::query("DELETE FROM rust_sessions WHERE token_hash=?")
|
||||
.bind(old.try_get::<String, _>("token_hash")?)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO rust_sessions(token_hash,user_id,csrf_hash,expires_at) VALUES(?,NULL,?,?)",
|
||||
)
|
||||
.bind(auth::digest(&sid))
|
||||
.bind(auth::digest(&csrf))
|
||||
.bind(auth::now() + auth::TTL)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("INSERT INTO rust_sso_flows(state_hash,browser_hash,session_hash,driver,verifier,nonce,expires_at) VALUES(?,?,?,?,?,?,?)").bind(auth::digest(&state)).bind(auth::digest(&browser)).bind(auth::digest(&sid)).bind(&p.driver).bind(&verifier).bind(&nonce).bind(auth::now()+600).execute(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
let mut url = url::Url::parse(&p.authorize)?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("client_id", &p.client_id)
|
||||
.append_pair("redirect_uri", &p.redirect)
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair(
|
||||
"scope",
|
||||
if p.driver == "github" {
|
||||
"user:email"
|
||||
} else {
|
||||
"openid profile email"
|
||||
},
|
||||
)
|
||||
.append_pair("state", &state)
|
||||
.append_pair(
|
||||
"code_challenge",
|
||||
&URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())),
|
||||
)
|
||||
.append_pair("code_challenge_method", "S256");
|
||||
if p.driver == "openid" {
|
||||
url.query_pairs_mut().append_pair("nonce", &nonce);
|
||||
}
|
||||
let mut response = Redirect::to(url.as_str()).into_response();
|
||||
auth::cookies(&mut response, &sid, &csrf, auth::TTL);
|
||||
auth::clear_legacy_cookie(&mut response);
|
||||
flow_cookie(&mut response, &browser, 600);
|
||||
Ok(response)
|
||||
}
|
||||
async fn callback(
|
||||
State(s): State<AppState>,
|
||||
Path(driver): Path<String>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let context = crate::auth_audit::Context::from_request(&request);
|
||||
let headers = request.headers();
|
||||
let p = match Provider::load(&driver).await {
|
||||
Ok(p) => p,
|
||||
Err(_) => return failure("sso_bad_provider_setup"),
|
||||
};
|
||||
let mut response = finish(&s, headers, &q, &p, &context)
|
||||
.await
|
||||
.unwrap_or_else(|_| failure("sso_failed"));
|
||||
flow_cookie(&mut response, "", 0);
|
||||
response
|
||||
}
|
||||
async fn finish(
|
||||
s: &AppState,
|
||||
headers: &HeaderMap,
|
||||
q: &HashMap<String, String>,
|
||||
p: &Provider,
|
||||
context: &crate::auth_audit::Context,
|
||||
) -> Result<Response> {
|
||||
if settings::all(&s.db).await?["enableSso"] != true {
|
||||
return Ok(failure("sso_disabled"));
|
||||
}
|
||||
let state = q
|
||||
.get("state")
|
||||
.filter(|s| s.len() == 64)
|
||||
.ok_or_else(|| anyhow!("missing state"))?;
|
||||
let browser = browser_cookie(headers)?;
|
||||
let flow=sqlx::query("DELETE FROM rust_sso_flows WHERE state_hash=? AND browser_hash=? AND driver=? AND expires_at>? RETURNING session_hash,verifier,nonce").bind(auth::digest(state)).bind(auth::digest(&browser)).bind(&p.driver).bind(auth::now()).fetch_optional(&s.db).await?.ok_or_else(||anyhow!("invalid or consumed state"))?;
|
||||
let session_hash: String = flow.try_get("session_hash")?;
|
||||
let exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM rust_sessions WHERE token_hash=? AND expires_at>?)",
|
||||
)
|
||||
.bind(&session_hash)
|
||||
.bind(auth::now())
|
||||
.fetch_one(&s.db)
|
||||
.await?;
|
||||
if !exists {
|
||||
bail!("expired session");
|
||||
}
|
||||
let code = q
|
||||
.get("code")
|
||||
.filter(|c| !c.is_empty() && c.len() < 8192)
|
||||
.ok_or_else(|| anyhow!("missing authorization code"))?;
|
||||
let http = client(p)?;
|
||||
let verifier: String = flow.try_get("verifier")?;
|
||||
let tokens = response_json(
|
||||
http.post(&p.token)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("code", code),
|
||||
("client_id", &p.client_id),
|
||||
("client_secret", &p.secret),
|
||||
("redirect_uri", &p.redirect),
|
||||
("code_verifier", &verifier),
|
||||
])
|
||||
.send()
|
||||
.await?,
|
||||
)
|
||||
.await?;
|
||||
let access = tokens["access_token"]
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| anyhow!("missing access token"))?;
|
||||
if tokens
|
||||
.get("token_type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|v| !v.eq_ignore_ascii_case("bearer"))
|
||||
{
|
||||
bail!("unsupported token type");
|
||||
}
|
||||
let user = response_json(
|
||||
http.get(&p.userinfo)
|
||||
.bearer_auth(access)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await?,
|
||||
)
|
||||
.await?;
|
||||
if p.driver == "openid" && p.issuer.is_some() {
|
||||
let token = tokens["id_token"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("missing ID token"))?;
|
||||
let nonce: String = flow.try_get("nonce")?;
|
||||
let jwks = response_json(
|
||||
http.get(p.jwks.as_deref().ok_or_else(|| anyhow!("missing keys"))?)
|
||||
.send()
|
||||
.await?,
|
||||
)
|
||||
.await?;
|
||||
let claims = validate_id_token(token, &jwks, p, &nonce, access)?;
|
||||
if user["sub"] != claims["sub"] {
|
||||
bail!("userinfo subject mismatch");
|
||||
}
|
||||
}
|
||||
let id = if p.driver == "github" {
|
||||
user["id"]
|
||||
.as_i64()
|
||||
.map(|n| n.to_string())
|
||||
.or_else(|| user["id"].as_str().map(str::to_owned))
|
||||
} else {
|
||||
user["sub"].as_str().map(str::to_owned)
|
||||
}
|
||||
.filter(|s| !s.is_empty() && s.len() <= 191)
|
||||
.ok_or_else(|| anyhow!("missing provider identity"))?;
|
||||
let mut email = user["email"]
|
||||
.as_str()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned);
|
||||
if p.driver == "github"
|
||||
&& let Some(endpoint) = &p.emails
|
||||
{
|
||||
let emails = response_json(
|
||||
http.get(endpoint)
|
||||
.bearer_auth(access)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await?,
|
||||
)
|
||||
.await?;
|
||||
email = emails
|
||||
.as_array()
|
||||
.and_then(|a| {
|
||||
a.iter()
|
||||
.find(|item| item["primary"] == true && item["verified"] == true)
|
||||
})
|
||||
.and_then(|v| v["email"].as_str())
|
||||
.map(str::to_owned)
|
||||
.or(email);
|
||||
}
|
||||
let unique = format!("{id}@{}", p.driver);
|
||||
let email = email.unwrap_or_else(|| unique.clone());
|
||||
if email.len() > 191 || email.contains(['\r', '\n']) {
|
||||
bail!("invalid email");
|
||||
}
|
||||
let display = if p.driver == "github" {
|
||||
user["login"].as_str().or(user["name"].as_str())
|
||||
} else {
|
||||
user["nickname"].as_str().or(user["name"].as_str())
|
||||
}
|
||||
.unwrap_or("");
|
||||
let name = format!("{display} ({unique})");
|
||||
let settings = settings::all(&s.db).await?;
|
||||
let random_password = auth::random();
|
||||
let permit = HASH_LIMIT
|
||||
.try_acquire()
|
||||
.map_err(|_| anyhow!("authentication busy"))?;
|
||||
let password = tokio::task::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
bcrypt::hash(random_password, 12)
|
||||
})
|
||||
.await??;
|
||||
let sid = auth::random();
|
||||
let csrf = auth::random();
|
||||
let mut tx = s.db.begin().await?;
|
||||
let consumed = sqlx::query("DELETE FROM rust_sessions WHERE token_hash=? AND expires_at>?")
|
||||
.bind(&session_hash)
|
||||
.bind(auth::now())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if consumed.rows_affected() != 1 {
|
||||
bail!("session consumed");
|
||||
}
|
||||
let existing=sqlx::query_scalar::<_,i64>("SELECT id FROM users WHERE lower(oauth_provider)=lower(?) AND lower(oauth_id)=lower(?) LIMIT 1").bind(&p.driver).bind(&id).fetch_optional(&mut *tx).await?;
|
||||
let user_id = if let Some(user_id) = existing {
|
||||
user_id
|
||||
} else {
|
||||
let used: bool =
|
||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE lower(email)=lower(?))")
|
||||
.bind(&email)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if used {
|
||||
return Ok(failure("sso_email_already_used"));
|
||||
}
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if count > 0
|
||||
&& settings["disableRegistration"] == true
|
||||
&& settings["keepSsoRegistrationEnabled"] != true
|
||||
{
|
||||
return Ok(failure("sso_no_register"));
|
||||
}
|
||||
sqlx::query("INSERT INTO users(name,email,password,is_admin,oauth_provider,oauth_id,last_seen_at,created_at,updated_at) VALUES(?,?,?,?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)").bind(&name).bind(&email).bind(password).bind(count==0).bind(&p.driver).bind(&id).execute(&mut *tx).await?.last_insert_rowid()
|
||||
};
|
||||
let collision: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE lower(email)=lower(?) AND id!=?)",
|
||||
)
|
||||
.bind(&email)
|
||||
.bind(user_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if collision {
|
||||
return Ok(failure("sso_email_already_used"));
|
||||
}
|
||||
sqlx::query("UPDATE users SET name=?,email=?,last_seen_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=?").bind(name).bind(email).bind(user_id).execute(&mut *tx).await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO rust_sessions(token_hash,user_id,csrf_hash,expires_at) VALUES(?,?,?,?)",
|
||||
)
|
||||
.bind(auth::digest(&sid))
|
||||
.bind(user_id)
|
||||
.bind(auth::digest(&csrf))
|
||||
.bind(auth::now() + auth::TTL)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
crate::auth_audit::login(s, user_id, context, None).await?;
|
||||
let mut response = Redirect::to("/accounts").into_response();
|
||||
auth::cookies(&mut response, &sid, &csrf, auth::TTL);
|
||||
auth::clear_legacy_cookie(&mut response);
|
||||
Ok(response)
|
||||
}
|
||||
fn validate_id_token(
|
||||
token: &str,
|
||||
jwks: &Value,
|
||||
p: &Provider,
|
||||
nonce: &str,
|
||||
access: &str,
|
||||
) -> Result<Value> {
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header, jwk::JwkSet};
|
||||
let header = decode_header(token)?;
|
||||
if matches!(
|
||||
header.alg,
|
||||
Algorithm::HS256 | Algorithm::HS384 | Algorithm::HS512
|
||||
) || !p
|
||||
.algorithms
|
||||
.iter()
|
||||
.any(|a| a == &format!("{:?}", header.alg))
|
||||
{
|
||||
bail!("unsupported ID token algorithm");
|
||||
}
|
||||
let set: JwkSet = serde_json::from_value(jwks.clone())?;
|
||||
let keys: Vec<_> =
|
||||
set.keys
|
||||
.iter()
|
||||
.filter(|key| {
|
||||
header
|
||||
.kid
|
||||
.as_ref()
|
||||
.is_none_or(|kid| key.common.key_id.as_ref() == Some(kid))
|
||||
&& key
|
||||
.common
|
||||
.public_key_use
|
||||
.as_ref()
|
||||
.is_none_or(|usage| *usage == jsonwebtoken::jwk::PublicKeyUse::Signature)
|
||||
&& key
|
||||
.common
|
||||
.key_operations
|
||||
.as_ref()
|
||||
.is_none_or(|ops| ops.contains(&jsonwebtoken::jwk::KeyOperations::Verify))
|
||||
&& key.common.key_algorithm.is_none_or(|algorithm| {
|
||||
algorithm.to_string() == format!("{:?}", header.alg)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if keys.len() != 1 {
|
||||
bail!("ambiguous signing key");
|
||||
}
|
||||
let key = DecodingKey::from_jwk(keys[0])?;
|
||||
let mut validation = Validation::new(header.alg);
|
||||
validation.set_audience(&[&p.client_id]);
|
||||
validation.set_issuer(&[p
|
||||
.issuer
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow!("missing issuer"))?]);
|
||||
validation.set_required_spec_claims(&["exp", "iss", "aud", "sub", "iat"]);
|
||||
validation.validate_nbf = true;
|
||||
let claims = decode::<Value>(token, &key, &validation)?.claims;
|
||||
if claims["nonce"].as_str() != Some(nonce)
|
||||
|| claims["sub"].as_str().is_none_or(|s| s.is_empty())
|
||||
{
|
||||
bail!("invalid nonce or subject");
|
||||
}
|
||||
if claims["iat"].as_i64().is_none_or(|t| t > auth::now() + 60) {
|
||||
bail!("invalid issued time");
|
||||
}
|
||||
let multiple = claims["aud"].as_array().is_some_and(|a| a.len() > 1);
|
||||
if (multiple || claims.get("azp").is_some())
|
||||
&& claims["azp"].as_str() != Some(p.client_id.as_str())
|
||||
{
|
||||
bail!("invalid authorized party");
|
||||
}
|
||||
if let Some(expected) = claims["at_hash"].as_str() {
|
||||
let hash = match header.alg {
|
||||
Algorithm::RS256 | Algorithm::PS256 | Algorithm::ES256 => {
|
||||
Sha256::digest(access.as_bytes()).to_vec()
|
||||
}
|
||||
Algorithm::RS384 | Algorithm::PS384 | Algorithm::ES384 => {
|
||||
sha2::Sha384::digest(access.as_bytes()).to_vec()
|
||||
}
|
||||
Algorithm::RS512 | Algorithm::PS512 => sha2::Sha512::digest(access.as_bytes()).to_vec(),
|
||||
_ => bail!("unsupported access token hash"),
|
||||
};
|
||||
if URL_SAFE_NO_PAD.encode(&hash[..hash.len() / 2]) != expected {
|
||||
bail!("invalid access token hash");
|
||||
}
|
||||
}
|
||||
Ok(claims)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{Json, extract::State};
|
||||
use openssl::{
|
||||
bn::BigNumContext, ec::PointConversionForm, ecdsa::EcdsaSig, hash::MessageDigest,
|
||||
pkey::PKey, sign::Signer,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::sync::{Arc, Mutex};
|
||||
const KEY: &[u8] = include_bytes!("../tests/fixtures/webauthn-private.pem");
|
||||
fn signing_keys() -> (PKey<openssl::pkey::Private>, Value) {
|
||||
let key = PKey::private_key_from_pem(KEY).unwrap();
|
||||
let ec = key.ec_key().unwrap();
|
||||
let mut context = BigNumContext::new().unwrap();
|
||||
let public = ec
|
||||
.public_key()
|
||||
.to_bytes(ec.group(), PointConversionForm::UNCOMPRESSED, &mut context)
|
||||
.unwrap();
|
||||
let jwks = json!({"keys":[{"kty":"EC","crv":"P-256","kid":"test-key","use":"sig","alg":"ES256","x":URL_SAFE_NO_PAD.encode(&public[1..33]),"y":URL_SAFE_NO_PAD.encode(&public[33..65])}]});
|
||||
(key, jwks)
|
||||
}
|
||||
fn signed(claims: Value) -> String {
|
||||
let (key, _) = signing_keys();
|
||||
let data = format!(
|
||||
"{}.{}",
|
||||
URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256","kid":"test-key","typ":"JWT"}"#),
|
||||
URL_SAFE_NO_PAD.encode(claims.to_string())
|
||||
);
|
||||
let mut signer = Signer::new(MessageDigest::sha256(), &key).unwrap();
|
||||
signer.update(data.as_bytes()).unwrap();
|
||||
let signature = EcdsaSig::from_der(&signer.sign_to_vec().unwrap()).unwrap();
|
||||
let mut raw = signature.r().to_vec_padded(32).unwrap();
|
||||
raw.extend(signature.s().to_vec_padded(32).unwrap());
|
||||
format!("{data}.{}", URL_SAFE_NO_PAD.encode(raw))
|
||||
}
|
||||
fn config(base: &str, driver: &str) -> Provider {
|
||||
Provider {
|
||||
driver: driver.into(),
|
||||
client_id: "client".into(),
|
||||
secret: "secret".into(),
|
||||
redirect: "http://localhost/socialite/callback/openid".into(),
|
||||
authorize: format!("{base}/authorize"),
|
||||
token: format!("{base}/token"),
|
||||
userinfo: format!("{base}/userinfo"),
|
||||
emails: None,
|
||||
issuer: Some(base.into()),
|
||||
jwks: Some(format!("{base}/jwks")),
|
||||
algorithms: vec!["ES256".into()],
|
||||
verify_ssl: true,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn id_token_signature_nonce_audience_issuer_and_hash_validation() {
|
||||
let p = config("https://issuer.example", "openid");
|
||||
let (_, jwks) = signing_keys();
|
||||
let now = auth::now();
|
||||
let claims = json!({"iss":"https://issuer.example","aud":"client","sub":"alice","iat":now,"exp":now+300,"nonce":"nonce","at_hash":URL_SAFE_NO_PAD.encode(&Sha256::digest(b"access")[..16])});
|
||||
assert!(validate_id_token(&signed(claims.clone()), &jwks, &p, "nonce", "access").is_ok());
|
||||
for (field, value) in [
|
||||
("nonce", json!("wrong")),
|
||||
("aud", json!("other-client")),
|
||||
("iss", json!("https://attacker.example")),
|
||||
("exp", json!(now - 120)),
|
||||
("at_hash", json!("invalid")),
|
||||
("iat", json!(now + 120)),
|
||||
] {
|
||||
let mut bad = claims.clone();
|
||||
bad[field] = value;
|
||||
assert!(
|
||||
validate_id_token(&signed(bad), &jwks, &p, "nonce", "access").is_err(),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
let mut token = signed(claims);
|
||||
token.push('x');
|
||||
assert!(validate_id_token(&token, &jwks, &p, "nonce", "access").is_err());
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct Fake {
|
||||
base: String,
|
||||
nonce: Arc<Mutex<String>>,
|
||||
github: bool,
|
||||
}
|
||||
async fn fixture(github: bool) -> (Provider, Fake, tokio::task::JoinHandle<()>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let fake = Fake {
|
||||
base: base.clone(),
|
||||
nonce: Arc::new(Mutex::new(String::new())),
|
||||
github,
|
||||
};
|
||||
let app=Router::new().route("/token",axum::routing::post(|State(f):State<Fake>,axum::Form(form):axum::Form<HashMap<String,String>>|async move {
|
||||
assert_eq!(form["grant_type"],"authorization_code");assert_eq!(form["client_id"],"client");assert_eq!(form["client_secret"],"secret");assert!(!form["code_verifier"].is_empty());
|
||||
let claims=json!({"iss":f.base,"aud":"client","sub":"alice","iat":auth::now(),"exp":auth::now()+300,"nonce":f.nonce.lock().unwrap().clone()});
|
||||
Json(json!({"access_token":"access","token_type":"Bearer","id_token":signed(claims)}))
|
||||
})).route("/userinfo",get(|State(f):State<Fake>|async move {Json(if f.github {json!({"id":17,"login":"alice","email":null})} else {json!({"sub":"alice","nickname":"alice","email":"alice@example.com"})})}))
|
||||
.route("/emails",get(||async{Json(json!([{"email":"unverified@example.com","primary":false,"verified":false},{"email":"alice@example.com","primary":true,"verified":true}]))}))
|
||||
.route("/jwks",get(||async{Json(signing_keys().1)})).with_state(fake.clone());
|
||||
let task = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
|
||||
let mut provider = config(&base, if github { "github" } else { "openid" });
|
||||
if github {
|
||||
provider.issuer = None;
|
||||
provider.emails = Some(format!("{base}/emails"));
|
||||
}
|
||||
(provider, fake, task)
|
||||
}
|
||||
async fn setup() -> AppState {
|
||||
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
crate::db::init(&db).await.unwrap();
|
||||
auth::init(&db).await.unwrap();
|
||||
init(&db).await.unwrap();
|
||||
sqlx::query("INSERT INTO options(key,value) VALUES('enableSso','{{1}}')")
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
AppState {
|
||||
db,
|
||||
cipher: None,
|
||||
encrypt_db: std::sync::atomic::AtomicBool::new(false).into(),
|
||||
}
|
||||
}
|
||||
async fn authorization(
|
||||
s: &AppState,
|
||||
p: &Provider,
|
||||
fake: &Fake,
|
||||
) -> (HeaderMap, HashMap<String, String>) {
|
||||
let response = begin(s, &HeaderMap::new(), p).await.unwrap();
|
||||
let url = url::Url::parse(response.headers()[header::LOCATION].to_str().unwrap()).unwrap();
|
||||
let query: HashMap<String, String> = url
|
||||
.query_pairs()
|
||||
.map(|(k, v)| (k.into_owned(), v.into_owned()))
|
||||
.collect();
|
||||
assert_eq!(query["code_challenge_method"], "S256");
|
||||
if let Some(nonce) = query.get("nonce") {
|
||||
*fake.nonce.lock().unwrap() = nonce.clone();
|
||||
}
|
||||
let binding = response
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.find_map(|h| {
|
||||
let h = h.to_str().unwrap();
|
||||
if h.starts_with(FLOW_COOKIE) {
|
||||
assert!(h.contains("SameSite=Lax"));
|
||||
Some(h.split(';').next().unwrap().to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::COOKIE, HeaderValue::from_str(&binding).unwrap());
|
||||
let q = HashMap::from([
|
||||
("state".into(), query["state"].clone()),
|
||||
("code".into(), "authorization-code".into()),
|
||||
]);
|
||||
(headers, q)
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn fake_openid_and_github_login_rotate_sessions_and_consume_state() {
|
||||
for github in [false, true] {
|
||||
let s = setup().await;
|
||||
let (p, fake, task) = fixture(github).await;
|
||||
let (mut headers, q) = authorization(&s, &p, &fake).await;
|
||||
headers.insert("user-agent", HeaderValue::from_static("SSO test browser"));
|
||||
headers.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.99"));
|
||||
let context = crate::auth_audit::Context::from_headers(
|
||||
&headers,
|
||||
Some("127.0.0.1:1234".parse().unwrap()),
|
||||
);
|
||||
let response = finish(&s, &headers, &q, &p, &context).await.unwrap();
|
||||
assert_eq!(response.headers()[header::LOCATION], "/accounts");
|
||||
let audit = sqlx::query(
|
||||
"SELECT ip_address,user_agent,login_method,login_successful FROM auth_logs",
|
||||
)
|
||||
.fetch_one(&s.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(audit.get::<String, _>("ip_address"), "127.0.0.1");
|
||||
assert_eq!(audit.get::<String, _>("user_agent"), "SSO test browser");
|
||||
assert_eq!(audit.get::<Option<String>, _>("login_method"), None);
|
||||
assert!(audit.get::<bool, _>("login_successful"));
|
||||
let user = sqlx::query("SELECT name,email,is_admin,oauth_provider,oauth_id FROM users")
|
||||
.fetch_one(&s.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(user.get::<String, _>("email"), "alice@example.com");
|
||||
assert!(user.get::<bool, _>("is_admin"));
|
||||
assert_eq!(user.get::<String, _>("oauth_provider"), p.driver);
|
||||
assert!(
|
||||
finish(
|
||||
&s,
|
||||
&headers,
|
||||
&q,
|
||||
&p,
|
||||
&crate::auth_audit::Context::from_headers(&headers, None)
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let n: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM rust_sessions WHERE user_id IS NOT NULL")
|
||||
.fetch_one(&s.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(n, 1);
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn identity_is_never_linked_by_email_and_closed_registration_is_respected() {
|
||||
let s = setup().await;
|
||||
let (p, fake, task) = fixture(true).await;
|
||||
sqlx::query(
|
||||
"INSERT INTO users(name,email,password) VALUES('local','alice@example.com','unused')",
|
||||
)
|
||||
.execute(&s.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let (headers, q) = authorization(&s, &p, &fake).await;
|
||||
let response = finish(
|
||||
&s,
|
||||
&headers,
|
||||
&q,
|
||||
&p,
|
||||
&crate::auth_audit::Context::from_headers(&headers, None),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.headers()[header::LOCATION],
|
||||
"/error?err=sso_email_already_used"
|
||||
);
|
||||
sqlx::query("UPDATE users SET email='local@example.com'")
|
||||
.execute(&s.db)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO options(key,value) VALUES('disableRegistration','{{1}}')")
|
||||
.execute(&s.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let (headers, q) = authorization(&s, &p, &fake).await;
|
||||
let response = finish(
|
||||
&s,
|
||||
&headers,
|
||||
&q,
|
||||
&p,
|
||||
&crate::auth_audit::Context::from_headers(&headers, None),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.headers()[header::LOCATION],
|
||||
"/error?err=sso_no_register"
|
||||
);
|
||||
let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users")
|
||||
.fetch_one(&s.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(n, 1);
|
||||
task.abort();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn missing_browser_binding_and_expired_state_fail_before_exchange() {
|
||||
let s = setup().await;
|
||||
let (p, fake, task) = fixture(false).await;
|
||||
let (headers, q) = authorization(&s, &p, &fake).await;
|
||||
assert!(
|
||||
finish(
|
||||
&s,
|
||||
&HeaderMap::new(),
|
||||
&q,
|
||||
&p,
|
||||
&crate::auth_audit::Context::from_headers(&HeaderMap::new(), None)
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
sqlx::query("UPDATE rust_sso_flows SET expires_at=0")
|
||||
.execute(&s.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
finish(
|
||||
&s,
|
||||
&headers,
|
||||
&q,
|
||||
&p,
|
||||
&crate::auth_audit::Context::from_headers(&headers, None)
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Mount the complete application beneath its configured URL path.
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{OriginalUri, Request},
|
||||
http::{HeaderValue, header},
|
||||
middleware::{self, Next},
|
||||
response::{Redirect, Response},
|
||||
routing::any,
|
||||
};
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MountPath(pub String);
|
||||
pub fn normalize(raw: &str) -> Result<String, &'static str> {
|
||||
let raw = raw.trim_matches('/');
|
||||
if raw.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
if raw.split('/').any(|segment| {
|
||||
segment.is_empty()
|
||||
|| segment == "."
|
||||
|| segment == ".."
|
||||
|| !segment
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || b"-._~".contains(&c))
|
||||
}) {
|
||||
return Err(
|
||||
"APP_SUBDIRECTORY must contain URL path segments without dot traversal, query or fragment",
|
||||
);
|
||||
}
|
||||
Ok(format!("/{raw}"))
|
||||
}
|
||||
pub fn configured_prefix() -> String {
|
||||
normalize(&std::env::var("APP_SUBDIRECTORY").unwrap_or_default())
|
||||
.expect("invalid APP_SUBDIRECTORY")
|
||||
}
|
||||
pub fn mount(app: Router, prefix: &str) -> Router {
|
||||
if prefix.is_empty() {
|
||||
return app;
|
||||
}
|
||||
let middleware_prefix = prefix.to_string();
|
||||
let app = app.layer(middleware::from_fn(
|
||||
move |mut request: Request, next: Next| {
|
||||
let prefix = middleware_prefix.clone();
|
||||
async move {
|
||||
request.extensions_mut().insert(MountPath(prefix.clone()));
|
||||
let mut response = next.run(request).await;
|
||||
rewrite_response(&mut response, &prefix);
|
||||
response
|
||||
}
|
||||
},
|
||||
));
|
||||
let destination = format!("{prefix}/");
|
||||
Router::new().nest(&format!("{prefix}/"), app).route(
|
||||
prefix,
|
||||
any(move |OriginalUri(uri): OriginalUri| {
|
||||
let mut destination = destination.clone();
|
||||
async move {
|
||||
if let Some(query) = uri.query() {
|
||||
destination.push('?');
|
||||
destination.push_str(query);
|
||||
}
|
||||
Redirect::permanent(&destination)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
fn rewrite_response(response: &mut Response, prefix: &str) {
|
||||
let values = response
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if !values.is_empty() {
|
||||
response.headers_mut().remove(header::SET_COOKIE);
|
||||
for value in values {
|
||||
let changed = value.to_str().ok().map(|value| {
|
||||
value
|
||||
.split(';')
|
||||
.map(|part| {
|
||||
if part.trim().eq_ignore_ascii_case("Path=/") {
|
||||
format!(" Path={prefix}")
|
||||
} else {
|
||||
part.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(";")
|
||||
});
|
||||
let value = changed
|
||||
.and_then(|value| HeaderValue::from_str(&value).ok())
|
||||
.unwrap_or(value);
|
||||
response.headers_mut().append(header::SET_COOKIE, value);
|
||||
}
|
||||
}
|
||||
if let Some(location) = response
|
||||
.headers()
|
||||
.get(header::LOCATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
&& location.starts_with('/')
|
||||
&& !location.starts_with("//")
|
||||
&& location != prefix
|
||||
&& !location.starts_with(&format!("{prefix}/"))
|
||||
&& let Ok(value) = HeaderValue::from_str(&format!("{prefix}{location}"))
|
||||
{
|
||||
response.headers_mut().insert(header::LOCATION, value);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request as HttpRequest, StatusCode},
|
||||
routing::get,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
#[test]
|
||||
fn validates_path_segments() {
|
||||
assert_eq!(normalize("/tools/2fa/").unwrap(), "/tools/2fa");
|
||||
assert_eq!(normalize("").unwrap(), "");
|
||||
for value in ["../secret", "x/../secret", "foo?query", "a#b", "a//b"] {
|
||||
assert!(normalize(value).is_err());
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn nested_routes_cookie_scope_and_redirects() {
|
||||
let inner = Router::new().route(
|
||||
"/login",
|
||||
get(|| async {
|
||||
(
|
||||
[
|
||||
(header::SET_COOKIE, "session=abc; Path=/; HttpOnly"),
|
||||
(header::LOCATION, "/accounts"),
|
||||
],
|
||||
"ok",
|
||||
)
|
||||
}),
|
||||
);
|
||||
let app = mount(inner, "/tools/2fa");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
HttpRequest::builder()
|
||||
.uri("/tools/2fa/login")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response.headers()[header::SET_COOKIE],
|
||||
"session=abc; Path=/tools/2fa; HttpOnly"
|
||||
);
|
||||
assert_eq!(response.headers()[header::LOCATION], "/tools/2fa/accounts");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
HttpRequest::builder()
|
||||
.uri("/tools/2fa?next=1")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
|
||||
assert_eq!(response.headers()[header::LOCATION], "/tools/2fa/?next=1");
|
||||
assert_eq!(
|
||||
app.oneshot(
|
||||
HttpRequest::builder()
|
||||
.uri("/login")
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.status(),
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
}
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
//! Administrator runtime diagnostics and maintenance for the native uncached runtime.
|
||||
use crate::{ApiError, AppState, auth::AuthUser};
|
||||
use axum::{
|
||||
Extension, Json, Router,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
routing::get,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/system/infos", get(infos))
|
||||
.route("/system/latestRelease", get(latest_release))
|
||||
.route("/system/optimize", get(optimize))
|
||||
.route("/system/clear-cache", get(clear))
|
||||
}
|
||||
fn admin(user: &AuthUser) -> Result<(), ApiError> {
|
||||
if user.is_admin {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError(
|
||||
StatusCode::FORBIDDEN,
|
||||
"This action is unauthorized.",
|
||||
))
|
||||
}
|
||||
}
|
||||
async fn infos(
|
||||
State(state): State<AppState>,
|
||||
Extension(user): Extension<AuthUser>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
admin(&user)?;
|
||||
let settings = crate::settings::all(&state.db).await?;
|
||||
let guard = std::env::var("AUTH_GUARD")
|
||||
.or_else(|_| std::env::var("AUTHENTICATION_GUARD"))
|
||||
.unwrap_or_else(|_| "web-guard".into());
|
||||
let mut common = json!({"Date":chrono::Utc::now().to_rfc2822(),"userAgent":headers.get("user-agent").and_then(|v|v.to_str().ok()),"Version":format!("twofauth-rs {}",env!("CARGO_PKG_VERSION")),"API target":"2FAuth 8.0.3","Environment":std::env::var("APP_ENV").unwrap_or_else(|_|"production".into()),"Install path":format!("/{}",std::env::var("APP_SUBDIRECTORY").unwrap_or_default().trim_matches('/')),"Debug":std::env::var("APP_DEBUG").unwrap_or_else(|_|"false".into()),"Cache driver":"none","Log channel":"tracing","Log level":std::env::var("RUST_LOG").unwrap_or_else(|_|"info".into()),"DB driver":"sqlite","Operating system":std::env::consts::OS,"interface":"native Rust","Auth guard":guard,"webauthn user verification":std::env::var("WEBAUTHN_USER_VERIFICATION").unwrap_or_else(|_|"preferred".into()),"Trusted proxies":std::env::var("TRUSTED_PROXIES").ok().filter(|s|!s.is_empty()).unwrap_or_else(||"none".into()),"lastRadarScan":settings["lastRadarScan"]});
|
||||
if guard == "reverse-proxy-guard" {
|
||||
let config = crate::proxy_auth::ProxyConfig::from_env();
|
||||
common["Auth proxy logout url"] = json!(std::env::var("PROXY_LOGOUT_URL").ok());
|
||||
common["Auth proxy header for user"] = json!(config.user_header);
|
||||
common["Auth proxy header for email"] = json!(config.email_header);
|
||||
}
|
||||
Ok(Json(json!({"common":common})))
|
||||
}
|
||||
fn release_number(tag: &str) -> Option<[u64; 3]> {
|
||||
let pattern = regex::Regex::new(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)")
|
||||
.expect("fixed version regex");
|
||||
let groups = pattern.captures(tag)?;
|
||||
Some([
|
||||
groups[1].parse().ok()?,
|
||||
groups[2].parse().ok()?,
|
||||
groups[3].parse().ok()?,
|
||||
])
|
||||
}
|
||||
async fn record_release(db: &sqlx::SqlitePool, data: &Value) -> Result<Value, ApiError> {
|
||||
let mut tx = db.begin().await?;
|
||||
sqlx::query("INSERT INTO options(key,value) VALUES('lastRadarScan',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(chrono::Utc::now().timestamp().to_string()).execute(&mut *tx).await?;
|
||||
let mut result = json!(false);
|
||||
if let Some(tag) = data["tag_name"].as_str()
|
||||
&& let Some(number) = release_number(tag)
|
||||
{
|
||||
if number > [8, 0, 3] && data["prerelease"] == false && data["draft"] == false {
|
||||
sqlx::query("INSERT INTO options(key,value) VALUES('latestRelease',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(tag).execute(&mut *tx).await?;
|
||||
result = json!(tag);
|
||||
} else {
|
||||
sqlx::query("DELETE FROM options WHERE key='latestRelease'")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(result)
|
||||
}
|
||||
async fn fetch_release() -> Option<Value> {
|
||||
let mut builder = reqwest::Client::builder()
|
||||
.user_agent(concat!("twofauth-rs/", env!("CARGO_PKG_VERSION")))
|
||||
.timeout(std::time::Duration::from_secs(10));
|
||||
if let Ok(proxy) = std::env::var("PROXY_FOR_OUTGOING_REQUESTS")
|
||||
&& !proxy.is_empty()
|
||||
{
|
||||
builder = builder.proxy(reqwest::Proxy::all(proxy).ok()?);
|
||||
}
|
||||
let client = builder.build().ok()?;
|
||||
for attempt in 0..3 {
|
||||
if let Ok(response) = client
|
||||
.get("https://api.github.com/repos/Bubka/2FAuth/releases/latest")
|
||||
.send()
|
||||
.await
|
||||
&& let Ok(mut response) = response.error_for_status()
|
||||
{
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|len| len > 1024 * 1024)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await.ok()? {
|
||||
if bytes.len() + chunk.len() > 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
// A successful non-JSON response still records a completed scan, matching upstream.
|
||||
return Some(serde_json::from_slice(&bytes).unwrap_or(Value::Null));
|
||||
}
|
||||
if attempt < 2 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
async fn latest_release(
|
||||
State(state): State<AppState>,
|
||||
Extension(user): Extension<AuthUser>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
admin(&user)?;
|
||||
let result = match fetch_release().await {
|
||||
Some(data) => record_release(&state.db, &data).await?,
|
||||
None => Value::Null,
|
||||
};
|
||||
Ok(Json(json!({"newRelease":result})))
|
||||
}
|
||||
// Configuration is read per request; routes/events are compiled in the binary and
|
||||
// frontend assets are read directly. There are no generated cache files to stale.
|
||||
async fn optimize(Extension(user): Extension<AuthUser>) -> Result<Json<Value>, ApiError> {
|
||||
admin(&user)?;
|
||||
Ok(Json(
|
||||
json!({"config-exit-code":0,"route-exit-code":0,"event-exit-code":0,"view-exit-code":0}),
|
||||
))
|
||||
}
|
||||
async fn clear(Extension(user): Extension<AuthUser>) -> Result<Json<Value>, ApiError> {
|
||||
admin(&user)?;
|
||||
Ok(Json(json!({"exit-code":0})))
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{body::Body, http::Request};
|
||||
use tower::ServiceExt;
|
||||
#[tokio::test]
|
||||
async fn release_scan_updates_cache_and_clears_old_version() {
|
||||
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
crate::db::init(&db).await.unwrap();
|
||||
assert_eq!(
|
||||
record_release(
|
||||
&db,
|
||||
&json!({"tag_name":"v8.1.0","prerelease":false,"draft":false})
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
json!("v8.1.0")
|
||||
);
|
||||
let value: String =
|
||||
sqlx::query_scalar("SELECT value FROM options WHERE key='latestRelease'")
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(value, "v8.1.0");
|
||||
assert_eq!(
|
||||
record_release(
|
||||
&db,
|
||||
&json!({"tag_name":"v8.0.3","prerelease":false,"draft":false})
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
json!(false)
|
||||
);
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT count(*) FROM options WHERE key='latestRelease'")
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
assert_eq!(
|
||||
record_release(
|
||||
&db,
|
||||
&json!({"tag_name":"v9.0.0-beta","prerelease":true,"draft":false})
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
json!(false)
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn diagnostic_and_cache_routes_require_admin_and_report_actual_runtime() {
|
||||
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
crate::db::init(&db).await.unwrap();
|
||||
let state = AppState {
|
||||
db,
|
||||
cipher: None,
|
||||
encrypt_db: std::sync::atomic::AtomicBool::new(false).into(),
|
||||
};
|
||||
for is_admin in [false, true] {
|
||||
let app = routes()
|
||||
.layer(Extension(AuthUser { id: 1, is_admin }))
|
||||
.with_state(state.clone());
|
||||
for path in ["/system/infos", "/system/optimize", "/system/clear-cache"] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
if !is_admin {
|
||||
StatusCode::FORBIDDEN
|
||||
} else {
|
||||
StatusCode::OK
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+983
@@ -0,0 +1,983 @@
|
||||
//! Administrator user management, including Laravel resource envelopes and cleanup.
|
||||
use crate::{AppState, auth::AuthUser, settings};
|
||||
use axum::{
|
||||
Extension, Json, Router,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, patch},
|
||||
};
|
||||
use chrono::{DateTime, Months, NaiveDateTime, Utc};
|
||||
use rand::{RngCore, rngs::OsRng};
|
||||
use serde_json::{Map, Value, json};
|
||||
use sqlx::{Row, SqlitePool, sqlite::SqliteRow};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub async fn init(db: &SqlitePool) -> std::result::Result<(), sqlx::Error> {
|
||||
sqlx::raw_sql(r#"
|
||||
CREATE TABLE IF NOT EXISTS password_resets (email TEXT NOT NULL,token TEXT NOT NULL,created_at TEXT);
|
||||
CREATE INDEX IF NOT EXISTS password_resets_email ON password_resets(email);
|
||||
CREATE TABLE IF NOT EXISTS webauthn_recoveries (email TEXT NOT NULL,token TEXT NOT NULL,created_at TEXT);
|
||||
CREATE TABLE IF NOT EXISTS webauthn_credentials (id TEXT PRIMARY KEY,authenticatable_type TEXT NOT NULL,authenticatable_id INTEGER NOT NULL,user_id TEXT NOT NULL,alias TEXT,counter INTEGER,rp_id TEXT NOT NULL,origin TEXT NOT NULL,transports TEXT,aaguid TEXT,public_key TEXT NOT NULL,attestation_format TEXT NOT NULL DEFAULT 'none',certificates TEXT,disabled_at TEXT,created_at TEXT,updated_at TEXT);
|
||||
CREATE INDEX IF NOT EXISTS webauthn_user_index ON webauthn_credentials(authenticatable_type,authenticatable_id);
|
||||
CREATE TABLE IF NOT EXISTS auth_logs (id INTEGER PRIMARY KEY AUTOINCREMENT,authenticatable_type TEXT NOT NULL,authenticatable_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,ip_address TEXT,user_agent TEXT,login_at TEXT,login_successful INTEGER NOT NULL DEFAULT 0,logout_at TEXT,cleared_by_user INTEGER NOT NULL DEFAULT 0,guard TEXT,login_method TEXT);
|
||||
"#).execute(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/users", get(index).post(create))
|
||||
.route("/users/{id}", get(show).delete(destroy))
|
||||
.route("/users/{id}/promote", patch(promote))
|
||||
.route("/users/{id}/pats", delete(revoke_pats))
|
||||
.route("/users/{id}/credentials", delete(revoke_credentials))
|
||||
.route("/users/{id}/password/reset", patch(reset_password))
|
||||
.route("/users/{id}/authentications", get(authentications))
|
||||
}
|
||||
|
||||
struct Error(StatusCode, Value);
|
||||
type Result<T> = std::result::Result<T, Error>;
|
||||
impl IntoResponse for Error {
|
||||
fn into_response(self) -> Response {
|
||||
(self.0, Json(self.1)).into_response()
|
||||
}
|
||||
}
|
||||
impl From<sqlx::Error> for Error {
|
||||
fn from(e: sqlx::Error) -> Self {
|
||||
tracing::error!(kind = ?std::mem::discriminant(&e), "user management database operation failed");
|
||||
error(StatusCode::INTERNAL_SERVER_ERROR, "Server Error")
|
||||
}
|
||||
}
|
||||
fn error(status: StatusCode, message: &str) -> Error {
|
||||
Error(
|
||||
status,
|
||||
if status == StatusCode::FORBIDDEN && message == "This action is unauthorized." {
|
||||
json!({"message":"forbidden","reason":message})
|
||||
} else {
|
||||
json!({"message":message})
|
||||
},
|
||||
)
|
||||
}
|
||||
fn invalid(errors: Map<String, Value>) -> Error {
|
||||
Error(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({"message":"The given data was invalid.","errors":errors}),
|
||||
)
|
||||
}
|
||||
fn field(errors: &mut Map<String, Value>, key: &str, message: &str) {
|
||||
errors.insert(key.into(), json!([message]));
|
||||
}
|
||||
fn admin(u: &AuthUser) -> Result<()> {
|
||||
if u.is_admin {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(error(StatusCode::FORBIDDEN, "This action is unauthorized."))
|
||||
}
|
||||
}
|
||||
async fn user(db: &SqlitePool, id: i64) -> Result<SqliteRow> {
|
||||
sqlx::query("SELECT * FROM users WHERE id=?")
|
||||
.bind(id)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.ok_or_else(|| error(StatusCode::NOT_FOUND, "not found"))
|
||||
}
|
||||
fn date(s: &str) -> Option<DateTime<Utc>> {
|
||||
DateTime::parse_from_rfc3339(s)
|
||||
.map(|d| d.with_timezone(&Utc))
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f")
|
||||
.ok()
|
||||
.map(|d| d.and_utc())
|
||||
})
|
||||
}
|
||||
fn duration(seconds: i64) -> String {
|
||||
let seconds = seconds.unsigned_abs();
|
||||
let (n, unit) = if seconds >= 365 * 86400 {
|
||||
(seconds / (365 * 86400), "year")
|
||||
} else if seconds >= 30 * 86400 {
|
||||
(seconds / (30 * 86400), "month")
|
||||
} else if seconds >= 7 * 86400 {
|
||||
(seconds / (7 * 86400), "week")
|
||||
} else if seconds >= 86400 {
|
||||
(seconds / 86400, "day")
|
||||
} else if seconds >= 3600 {
|
||||
(seconds / 3600, "hour")
|
||||
} else if seconds >= 60 {
|
||||
(seconds / 60, "minute")
|
||||
} else {
|
||||
(seconds.max(1), "second")
|
||||
};
|
||||
format!("{n} {unit}{}", if n == 1 { "" } else { "s" })
|
||||
}
|
||||
fn human(raw: Option<String>) -> String {
|
||||
let elapsed = raw
|
||||
.as_deref()
|
||||
.and_then(date)
|
||||
.map(|d| (Utc::now() - d).num_seconds())
|
||||
.unwrap_or(0);
|
||||
format!(
|
||||
"{} {}",
|
||||
duration(elapsed),
|
||||
if elapsed >= 0 { "ago" } else { "from now" }
|
||||
)
|
||||
}
|
||||
async fn info(s: &AppState, id: i64, count: bool) -> Result<Value> {
|
||||
let row = user(&s.db, id).await?;
|
||||
let raw: Option<String> = row.try_get("preferences")?;
|
||||
let n: i64 = if count {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM twofaccounts WHERE user_id=?")
|
||||
.bind(id)
|
||||
.fetch_one(&s.db)
|
||||
.await?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Ok(
|
||||
json!({"id":id,"name":row.try_get::<String,_>("name")?,"email":row.try_get::<String,_>("email")?,"oauth_provider":row.try_get::<Option<String>,_>("oauth_provider")?,"authenticated_by_proxy":crate::proxy_auth::enabled(),"preferences":settings::preferences_from(raw.as_deref()),"appSettings":settings::regular_settings(&s.db).await?,"is_admin":row.try_get::<bool,_>("is_admin")?,"twofaccounts_count":n,"last_seen_at":human(row.try_get("last_seen_at")?),"created_at":human(row.try_get("created_at")?)}),
|
||||
)
|
||||
}
|
||||
async fn resource(s: &AppState, id: i64) -> Result<Value> {
|
||||
let info = info(s, id, false).await?;
|
||||
let email = info["email"].as_str().unwrap_or_default();
|
||||
let reset: Option<String> =
|
||||
sqlx::query_scalar("SELECT created_at FROM password_resets WHERE email=? LIMIT 1")
|
||||
.bind(email)
|
||||
.fetch_optional(&s.db)
|
||||
.await?
|
||||
.flatten();
|
||||
let reset = reset
|
||||
.map(|raw| {
|
||||
if date(&raw).is_some_and(|d| d + chrono::Duration::minutes(60) < Utc::now()) {
|
||||
json!(0)
|
||||
} else {
|
||||
json!(raw)
|
||||
}
|
||||
})
|
||||
.unwrap_or(Value::Null);
|
||||
let pats: i64=sqlx::query_scalar("SELECT COUNT(*) FROM oauth_access_tokens t JOIN oauth_clients c ON c.id=t.client_id WHERE t.user_id=? AND t.revoked=0 AND EXISTS(SELECT 1 FROM json_each(c.grant_types) WHERE value='personal_access')").bind(id).fetch_one(&s.db).await?;
|
||||
let credentials: i64=sqlx::query_scalar("SELECT COUNT(*) FROM webauthn_credentials WHERE authenticatable_id=? AND authenticatable_type=?").bind(id).bind("App\\Models\\User").fetch_one(&s.db).await?;
|
||||
Ok(
|
||||
json!({"info":info,"password_reset":reset,"valid_personal_access_tokens":pats,"webauthn_credentials":credentials}),
|
||||
)
|
||||
}
|
||||
async fn index(
|
||||
State(s): State<AppState>,
|
||||
Extension(u): Extension<AuthUser>,
|
||||
) -> Result<Json<Value>> {
|
||||
admin(&u)?;
|
||||
let ids: Vec<i64> = sqlx::query_scalar("SELECT id FROM users ORDER BY id")
|
||||
.fetch_all(&s.db)
|
||||
.await?;
|
||||
let mut values = Vec::new();
|
||||
for id in ids {
|
||||
values.push(info(&s, id, true).await?);
|
||||
}
|
||||
Ok(Json(json!(values)))
|
||||
}
|
||||
async fn show(
|
||||
State(s): State<AppState>,
|
||||
Extension(u): Extension<AuthUser>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<Value>> {
|
||||
admin(&u)?;
|
||||
Ok(Json(resource(&s, id).await?))
|
||||
}
|
||||
fn boolean(v: Option<&Value>) -> Option<bool> {
|
||||
match v? {
|
||||
Value::Bool(b) => Some(*b),
|
||||
Value::Number(n) if n.as_i64() == Some(0) => Some(false),
|
||||
Value::Number(n) if n.as_i64() == Some(1) => Some(true),
|
||||
Value::String(s) if s == "0" => Some(false),
|
||||
Value::String(s) if s == "1" => Some(true),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
async fn create(
|
||||
State(s): State<AppState>,
|
||||
Extension(u): Extension<AuthUser>,
|
||||
Json(v): Json<Value>,
|
||||
) -> Result<(StatusCode, Json<Value>)> {
|
||||
admin(&u)?;
|
||||
let mut errors = Map::new();
|
||||
let name = v["name"].as_str().unwrap_or("").trim();
|
||||
let email = v["email"].as_str().unwrap_or("").trim().to_lowercase();
|
||||
let password = v["password"].as_str().unwrap_or("");
|
||||
if name.is_empty() || name.chars().count() > 191 {
|
||||
field(
|
||||
&mut errors,
|
||||
"name",
|
||||
"The name field is required and must not exceed 191 characters.",
|
||||
);
|
||||
}
|
||||
if email.chars().count() > 191 || !valid_email(&email) {
|
||||
field(
|
||||
&mut errors,
|
||||
"email",
|
||||
"The email must be a valid email address.",
|
||||
);
|
||||
}
|
||||
if password.chars().count() < 8 {
|
||||
field(
|
||||
&mut errors,
|
||||
"password",
|
||||
"The password must be at least 8 characters.",
|
||||
);
|
||||
}
|
||||
if v["password_confirmation"].as_str() != Some(password) {
|
||||
field(
|
||||
&mut errors,
|
||||
"password",
|
||||
"The password confirmation does not match.",
|
||||
);
|
||||
}
|
||||
let is_admin = boolean(v.get("is_admin"));
|
||||
if is_admin.is_none() {
|
||||
field(
|
||||
&mut errors,
|
||||
"is_admin",
|
||||
"The is admin field must be true or false.",
|
||||
);
|
||||
}
|
||||
for (key, value) in [("name", name), ("email", email.as_str())] {
|
||||
let exists: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM users WHERE {key}=?"))
|
||||
.bind(value)
|
||||
.fetch_one(&s.db)
|
||||
.await?;
|
||||
if exists > 0 {
|
||||
field(
|
||||
&mut errors,
|
||||
key,
|
||||
&format!("The {key} has already been taken."),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !email_allowed(&s.db, &email).await? {
|
||||
field(
|
||||
&mut errors,
|
||||
"email",
|
||||
"This email address is not allowed to register.",
|
||||
);
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
return Err(invalid(errors));
|
||||
}
|
||||
let hash = bcrypt::hash(password, 12)
|
||||
.map_err(|_| error(StatusCode::INTERNAL_SERVER_ERROR, "Server Error"))?;
|
||||
let inserted=sqlx::query("INSERT INTO users(name,email,password,is_admin,created_at,updated_at) VALUES(?,?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)").bind(name).bind(&email).bind(hash).bind(is_admin.unwrap()).execute(&s.db).await;
|
||||
let id = match inserted {
|
||||
Ok(r) => r.last_insert_rowid(),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
field(&mut errors, "email", "The email has already been taken.");
|
||||
return Err(invalid(errors));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
Ok((StatusCode::CREATED, Json(resource(&s, id).await?)))
|
||||
}
|
||||
fn valid_email(email: &str) -> bool {
|
||||
email.rsplit_once('@').is_some_and(|(local, domain)| {
|
||||
!local.is_empty()
|
||||
&& !domain.is_empty()
|
||||
&& !email.chars().any(char::is_whitespace)
|
||||
&& !local.contains('@')
|
||||
&& domain.contains('.')
|
||||
&& !domain.starts_with('.')
|
||||
&& !domain.ends_with('.')
|
||||
})
|
||||
}
|
||||
/// PHP wraps the configured pattern in slash delimiters without extra flags.
|
||||
/// PCRE2 preserves lookarounds, backreferences and byte matching used by preg_match.
|
||||
fn php_regex(pattern: &str) -> Option<pcre2::bytes::Regex> {
|
||||
let mut escaped = false;
|
||||
for byte in pattern.bytes() {
|
||||
if byte == b'/' && !escaped {
|
||||
return None;
|
||||
}
|
||||
escaped = byte == b'\\' && !escaped;
|
||||
}
|
||||
pcre2::bytes::Regex::new(pattern).ok()
|
||||
}
|
||||
pub fn validate_regex(pattern: &str) -> bool {
|
||||
php_regex(pattern).is_some()
|
||||
}
|
||||
pub fn matches_regex(pattern: &str, value: &str) -> bool {
|
||||
php_regex(pattern).is_some_and(|regex| regex.is_match(value.as_bytes()).unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Shared with self-registration: list and regular-expression policies are alternatives.
|
||||
pub async fn email_allowed(db: &SqlitePool, email: &str) -> std::result::Result<bool, sqlx::Error> {
|
||||
let settings = settings::all(db).await?;
|
||||
if settings["restrictRegistration"] != true {
|
||||
return Ok(true);
|
||||
}
|
||||
let list = settings["restrictList"].as_str().filter(|s| !s.is_empty());
|
||||
let rule = settings["restrictRule"].as_str().filter(|s| !s.is_empty());
|
||||
let listed = list.is_some_and(|s| s.split('|').any(|item| item == email));
|
||||
let matched = rule.is_some_and(|s| matches_regex(s, email));
|
||||
Ok(match (list, rule) {
|
||||
(None, None) => true,
|
||||
(Some(_), Some(_)) => listed || matched,
|
||||
(Some(_), None) => listed,
|
||||
(None, Some(_)) => matched,
|
||||
})
|
||||
}
|
||||
async fn promote(
|
||||
State(s): State<AppState>,
|
||||
Extension(u): Extension<AuthUser>,
|
||||
Path(id): Path<i64>,
|
||||
Json(v): Json<Value>,
|
||||
) -> Result<Json<Value>> {
|
||||
admin(&u)?;
|
||||
let Some(promote) = boolean(v.get("is_admin")) else {
|
||||
let mut errors = Map::new();
|
||||
field(
|
||||
&mut errors,
|
||||
"is_admin",
|
||||
"The is admin field must be true or false.",
|
||||
);
|
||||
return Err(invalid(errors));
|
||||
};
|
||||
user(&s.db, id).await?;
|
||||
// The predicate is part of the write, so two simultaneous requests cannot remove the last admin.
|
||||
let updated=sqlx::query("UPDATE users SET is_admin=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND (?=1 OR is_admin=0 OR (SELECT COUNT(*) FROM users WHERE is_admin=1)>1)").bind(promote).bind(id).bind(promote).execute(&s.db).await?;
|
||||
if updated.rows_affected() == 0 {
|
||||
return Err(error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Cannot demote the only admin account",
|
||||
));
|
||||
}
|
||||
Ok(Json(resource(&s, id).await?))
|
||||
}
|
||||
async fn revoke_pats(
|
||||
State(s): State<AppState>,
|
||||
Extension(u): Extension<AuthUser>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<StatusCode> {
|
||||
admin(&u)?;
|
||||
user(&s.db, id).await?;
|
||||
sqlx::query("UPDATE oauth_access_tokens SET revoked=1 WHERE user_id=? AND revoked=0 AND client_id IN (SELECT c.id FROM oauth_clients c WHERE EXISTS(SELECT 1 FROM json_each(c.grant_types) WHERE value='personal_access'))").bind(id).execute(&s.db).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
async fn revoke_credentials(
|
||||
State(s): State<AppState>,
|
||||
Extension(u): Extension<AuthUser>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<StatusCode> {
|
||||
admin(&u)?;
|
||||
user(&s.db, id).await?;
|
||||
let mut tx = s.db.begin().await?;
|
||||
sqlx::query(
|
||||
"DELETE FROM webauthn_credentials WHERE authenticatable_id=? AND authenticatable_type=?",
|
||||
)
|
||||
.bind(id)
|
||||
.bind("App\\Models\\User")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let raw: Option<String> = sqlx::query_scalar("SELECT preferences FROM users WHERE id=?")
|
||||
.bind(id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
let mut prefs: Value = raw
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.filter(Value::is_object)
|
||||
.unwrap_or(json!({}));
|
||||
prefs["useWebauthnOnly"] = json!(false);
|
||||
sqlx::query("UPDATE users SET preferences=?,updated_at=CURRENT_TIMESTAMP WHERE id=?")
|
||||
.bind(prefs.to_string())
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
async fn destroy(
|
||||
State(s): State<AppState>,
|
||||
Extension(u): Extension<AuthUser>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<StatusCode> {
|
||||
admin(&u)?;
|
||||
delete_record(&s, id).await
|
||||
}
|
||||
async fn delete_record(s: &AppState, id: i64) -> Result<StatusCode> {
|
||||
let row = user(&s.db, id).await?;
|
||||
let email: String = row.try_get("email")?;
|
||||
let icon_names: Vec<String> = sqlx::query_scalar("SELECT DISTINCT icon FROM twofaccounts WHERE user_id=? AND icon IS NOT NULL AND icon != ''").bind(id).fetch_all(&s.db).await?;
|
||||
let mut tx = s.db.begin().await?;
|
||||
let deleted=sqlx::query("DELETE FROM users WHERE id=? AND (is_admin=0 OR (SELECT COUNT(*) FROM users WHERE is_admin=1)>1)").bind(id).execute(&mut *tx).await?;
|
||||
if deleted.rows_affected() == 0 {
|
||||
return Err(error(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Cannot delete the only admin account",
|
||||
));
|
||||
}
|
||||
// Explicit cleanup also covers older SQLite installations with foreign keys disabled.
|
||||
sqlx::query("DELETE FROM otp_logs WHERE twofaccount_id IN (SELECT id FROM twofaccounts WHERE user_id=?)").bind(id).execute(&mut *tx).await?;
|
||||
sqlx::query("UPDATE otp_logs SET requester_id=NULL WHERE requester_id=?")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("UPDATE otp_logs SET owner_id=NULL WHERE owner_id=?")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
for query in [
|
||||
"DELETE FROM twofaccount_shares WHERE created_by_user_id=? OR shared_with_user_id=? OR twofaccount_id IN (SELECT id FROM twofaccounts WHERE user_id=?)",
|
||||
"DELETE FROM twofaccount_group_assignments WHERE user_id=? OR twofaccount_id IN (SELECT id FROM twofaccounts WHERE user_id=?) OR group_id IN (SELECT id FROM groups WHERE user_id=?)",
|
||||
"DELETE FROM twofaccount_user_orders WHERE user_id=? OR twofaccount_id IN (SELECT id FROM twofaccounts WHERE user_id=?) OR user_id=?",
|
||||
] {
|
||||
sqlx::query(query)
|
||||
.bind(id)
|
||||
.bind(id)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
for query in [
|
||||
"DELETE FROM twofaccounts WHERE user_id=?",
|
||||
"DELETE FROM groups WHERE user_id=?",
|
||||
"DELETE FROM oauth_access_tokens WHERE user_id=?",
|
||||
"DELETE FROM auth_logs WHERE authenticatable_id=? AND authenticatable_type='App\\Models\\User'",
|
||||
"DELETE FROM rust_sessions WHERE user_id=?",
|
||||
] {
|
||||
sqlx::query(query).bind(id).execute(&mut *tx).await?;
|
||||
}
|
||||
sqlx::query(
|
||||
"DELETE FROM webauthn_credentials WHERE authenticatable_id=? AND authenticatable_type=?",
|
||||
)
|
||||
.bind(id)
|
||||
.bind("App\\Models\\User")
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
for table in ["password_resets", "webauthn_recoveries"] {
|
||||
sqlx::query(&format!("DELETE FROM {table} WHERE email=?"))
|
||||
.bind(&email)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
for name in icon_names {
|
||||
crate::icons::cleanup_orphan(s, &name)
|
||||
.await
|
||||
.map_err(|_| error(StatusCode::INTERNAL_SERVER_ERROR, "Icon cleanup failed"))?;
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
fn random_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
async fn reset_password(
|
||||
State(s): State<AppState>,
|
||||
Extension(u): Extension<AuthUser>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<Value>> {
|
||||
admin(&u)?;
|
||||
let row = user(&s.db, id).await?;
|
||||
if row
|
||||
.try_get::<Option<String>, _>("oauth_provider")?
|
||||
.is_some()
|
||||
{
|
||||
return Err(Error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
json!({"message":"bad request","reason":"Password reset unavailable for this user"}),
|
||||
));
|
||||
}
|
||||
let email: String = row.try_get("email")?;
|
||||
let token = random_token();
|
||||
let app_url = std::env::var("APP_URL").unwrap_or_else(|_| "http://localhost".into());
|
||||
let mut url = url::Url::parse(&format!(
|
||||
"{}/user/password/reset",
|
||||
app_url.trim_end_matches('/')
|
||||
))
|
||||
.map_err(|_| error(StatusCode::INTERNAL_SERVER_ERROR, "Invalid application URL"))?;
|
||||
url.query_pairs_mut()
|
||||
.append_pair("token", &token)
|
||||
.append_pair("email", &email);
|
||||
let hash = bcrypt::hash(&token, 12)
|
||||
.map_err(|_| error(StatusCode::INTERNAL_SERVER_ERROR, "Server Error"))?;
|
||||
let password = bcrypt::hash(random_token(), 12)
|
||||
.map_err(|_| error(StatusCode::INTERNAL_SERVER_ERROR, "Server Error"))?;
|
||||
let mut tx = s.db.begin().await?;
|
||||
sqlx::query("DELETE FROM password_resets WHERE email=?")
|
||||
.bind(&email)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO password_resets(email,token,created_at) VALUES(?,?,CURRENT_TIMESTAMP)",
|
||||
)
|
||||
.bind(&email)
|
||||
.bind(hash)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query("UPDATE users SET password=?,updated_at=CURRENT_TIMESTAMP WHERE id=?")
|
||||
.bind(password)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
crate::mail::password_reset(&email, url.as_str())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
json!({"message":"bad request","reason":e.to_string()}),
|
||||
)
|
||||
})?;
|
||||
tx.commit().await?;
|
||||
Ok(Json(resource(&s, id).await?))
|
||||
}
|
||||
pub(crate) fn agent(ua: &str) -> (Value, Value, &'static str) {
|
||||
let browser = [
|
||||
("Edg/", "Edge"),
|
||||
("OPR/", "Opera"),
|
||||
("Firefox/", "Firefox"),
|
||||
("Chrome/", "Chrome"),
|
||||
("Safari/", "Safari"),
|
||||
("MSIE", "IE"),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|(needle, _)| ua.contains(needle))
|
||||
.map(|(_, name)| json!(name))
|
||||
.unwrap_or(json!(false));
|
||||
let platform = [
|
||||
("Windows", "Windows"),
|
||||
("Android", "Android"),
|
||||
("iPhone", "iOS"),
|
||||
("iPad", "iOS"),
|
||||
("Macintosh", "OS X"),
|
||||
("Linux", "Linux"),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|(needle, _)| ua.contains(needle))
|
||||
.map(|(_, name)| json!(name))
|
||||
.unwrap_or(json!(false));
|
||||
let device = if ua.contains("iPad") || (ua.contains("Android") && !ua.contains("Mobile")) {
|
||||
"tablet"
|
||||
} else if ua.contains("Mobile") || ua.contains("iPhone") {
|
||||
"mobile"
|
||||
} else {
|
||||
"desktop"
|
||||
};
|
||||
(browser, platform, device)
|
||||
}
|
||||
async fn authentications(
|
||||
State(s): State<AppState>,
|
||||
Extension(u): Extension<AuthUser>,
|
||||
Path(id): Path<i64>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
) -> Result<Json<Value>> {
|
||||
admin(&u)?;
|
||||
user(&s.db, id).await?;
|
||||
let retention = std::env::var("AUTHENTICATION_LOG_RETENTION")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(365);
|
||||
let cutoff = Utc::now()
|
||||
.checked_sub_signed(
|
||||
chrono::Duration::try_days(retention).unwrap_or_else(chrono::Duration::zero),
|
||||
)
|
||||
.unwrap_or_else(Utc::now)
|
||||
.format("%Y-%m-%d %H:%M:%S")
|
||||
.to_string();
|
||||
sqlx::query("DELETE FROM auth_logs WHERE login_at<? OR logout_at<?")
|
||||
.bind(&cutoff)
|
||||
.bind(&cutoff)
|
||||
.execute(&s.db)
|
||||
.await?;
|
||||
let mut errors = Map::new();
|
||||
let mut nums = HashMap::new();
|
||||
for key in ["period", "limit"] {
|
||||
if let Some(raw) = q.get(key) {
|
||||
match raw.parse::<f64>() {
|
||||
Ok(n) if n.is_finite() => {
|
||||
nums.insert(key, n as i64);
|
||||
}
|
||||
_ => field(&mut errors, key, &format!("The {key} must be a number.")),
|
||||
}
|
||||
}
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
return Err(invalid(errors));
|
||||
}
|
||||
let from = nums.get("period").and_then(|n| {
|
||||
if *n >= 0 {
|
||||
Utc::now().checked_sub_months(Months::new((*n).min(u32::MAX as i64) as u32))
|
||||
} else {
|
||||
Utc::now().checked_add_months(Months::new(n.unsigned_abs().min(u32::MAX as u64) as u32))
|
||||
}
|
||||
});
|
||||
let request_user = user(&s.db, u.id).await?;
|
||||
let raw: Option<String> = request_user.try_get("preferences")?;
|
||||
let prefs = settings::preferences_from(raw.as_deref());
|
||||
let tz: chrono_tz::Tz = prefs["timezone"]
|
||||
.as_str()
|
||||
.unwrap_or("UTC")
|
||||
.parse()
|
||||
.unwrap_or(chrono_tz::UTC);
|
||||
let rows=sqlx::query("SELECT * FROM auth_logs WHERE authenticatable_id=? AND authenticatable_type=? ORDER BY id DESC").bind(id).bind("App\\Models\\User").fetch_all(&s.db).await?;
|
||||
let mut result = Vec::new();
|
||||
for row in rows {
|
||||
let login: Option<String> = row.try_get("login_at")?;
|
||||
let logout: Option<String> = row.try_get("logout_at")?;
|
||||
let login = login.as_deref().and_then(date);
|
||||
let logout = logout.as_deref().and_then(date);
|
||||
if let Some(from) = from
|
||||
&& !login.is_some_and(|d| d >= from)
|
||||
&& !logout.is_some_and(|d| d >= from)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let ua: Option<String> = row.try_get("user_agent")?;
|
||||
let (browser, platform, device) = agent(ua.as_deref().unwrap_or(""));
|
||||
let formatted = |d: Option<DateTime<Utc>>| {
|
||||
d.map(|d| {
|
||||
d.with_timezone(&tz)
|
||||
.format("%a, %b %-d, %Y %-I:%M %p")
|
||||
.to_string()
|
||||
})
|
||||
};
|
||||
result.push(json!({"id":row.try_get::<i64,_>("id")?,"ip_address":row.try_get::<Option<String>,_>("ip_address")?,"user_agent":ua,"browser":browser,"platform":platform,"device":device,"login_at":formatted(login),"logout_at":formatted(logout),"login_successful":row.try_get::<bool,_>("login_successful")?,"duration":logout.map(|out|duration((out-login.unwrap_or_else(Utc::now)).num_seconds())),"login_method":row.try_get::<Option<String>,_>("login_method")?}));
|
||||
}
|
||||
if let Some(limit) = nums.get("limit") {
|
||||
if *limit >= 0 {
|
||||
result.truncate(*limit as usize);
|
||||
} else {
|
||||
let start = result.len().saturating_sub(limit.unsigned_abs() as usize);
|
||||
result = result.split_off(start);
|
||||
}
|
||||
}
|
||||
Ok(Json(json!(result)))
|
||||
}
|
||||
|
||||
/// Delete a user through the same guarded cleanup path used by administrators.
|
||||
/// Callers must authorize the user before invoking this internal service.
|
||||
pub async fn delete_user(s: &AppState, id: i64) -> std::result::Result<(), crate::ApiError> {
|
||||
delete_record(s, id).await.map(|_| ()).map_err(|e| {
|
||||
crate::ApiError(
|
||||
e.0,
|
||||
match e.0 {
|
||||
StatusCode::FORBIDDEN => "Cannot delete the only admin account",
|
||||
StatusCode::NOT_FOUND => "not found",
|
||||
_ => "Server Error",
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::Request,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
async fn setup(admin: bool) -> (AppState, Router) {
|
||||
let db = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
crate::db::init(&db).await.unwrap();
|
||||
crate::auth::init(&db).await.unwrap();
|
||||
init(&db).await.unwrap();
|
||||
sqlx::query("INSERT INTO users(id,name,email,password,is_admin,created_at) VALUES(1,'Admin','admin@example.com','old-hash',1,CURRENT_TIMESTAMP),(2,'User','user@example.com','old-hash',0,CURRENT_TIMESTAMP)").execute(&db).await.unwrap();
|
||||
let state = AppState {
|
||||
db,
|
||||
cipher: None,
|
||||
encrypt_db: std::sync::atomic::AtomicBool::new(false).into(),
|
||||
};
|
||||
let router = routes()
|
||||
.layer(Extension(AuthUser {
|
||||
id: 1,
|
||||
is_admin: admin,
|
||||
}))
|
||||
.with_state(state.clone());
|
||||
(state, router)
|
||||
}
|
||||
async fn request(app: Router, method: &str, path: &str, body: Value) -> (StatusCode, Value) {
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(body.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
(
|
||||
status,
|
||||
serde_json::from_slice(&bytes).unwrap_or(Value::Null),
|
||||
)
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn every_admin_route_rejects_regular_users() {
|
||||
let (_, app) = setup(false).await;
|
||||
for (method, path) in [
|
||||
("GET", "/users"),
|
||||
("POST", "/users"),
|
||||
("GET", "/users/2"),
|
||||
("DELETE", "/users/2"),
|
||||
("PATCH", "/users/2/promote"),
|
||||
("DELETE", "/users/2/pats"),
|
||||
("DELETE", "/users/2/credentials"),
|
||||
("PATCH", "/users/2/password/reset"),
|
||||
("GET", "/users/2/authentications"),
|
||||
] {
|
||||
assert_eq!(
|
||||
request(app.clone(), method, path, json!({})).await.0,
|
||||
StatusCode::FORBIDDEN,
|
||||
"{method} {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn creates_validates_and_never_returns_passwords() {
|
||||
let (s, app) = setup(true).await;
|
||||
let (status,v)=request(app.clone(),"POST","/users",json!({"name":"New","email":"NEW@example.com","password":"password-123","password_confirmation":"password-123","is_admin":"1"})).await;
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
assert_eq!(v["info"]["email"], "new@example.com");
|
||||
assert_eq!(v["info"]["is_admin"], true);
|
||||
assert!(v["info"].get("password").is_none());
|
||||
let hash: String =
|
||||
sqlx::query_scalar("SELECT password FROM users WHERE email='new@example.com'")
|
||||
.fetch_one(&s.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(bcrypt::verify("password-123", &hash).unwrap());
|
||||
let (status, v) = request(
|
||||
app,
|
||||
"POST",
|
||||
"/users",
|
||||
json!({"name":"New","email":"invalid","password":"short","is_admin":"yes"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
|
||||
for key in ["name", "email", "password", "is_admin"] {
|
||||
assert!(v["errors"].get(key).is_some(), "{key}");
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn protects_last_administrator_and_allows_demotion_with_a_second() {
|
||||
let (s, app) = setup(true).await;
|
||||
assert_eq!(
|
||||
request(app.clone(), "DELETE", "/users/1", json!({}))
|
||||
.await
|
||||
.0,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
assert_eq!(
|
||||
request(
|
||||
app.clone(),
|
||||
"PATCH",
|
||||
"/users/1/promote",
|
||||
json!({"is_admin":false})
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
assert_eq!(
|
||||
request(
|
||||
app.clone(),
|
||||
"PATCH",
|
||||
"/users/2/promote",
|
||||
json!({"is_admin":true})
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
request(app, "PATCH", "/users/1/promote", json!({"is_admin":false}))
|
||||
.await
|
||||
.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users WHERE is_admin=1")
|
||||
.fetch_one(&s.db)
|
||||
.await
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn pats_only_and_credentials_are_actually_revoked() {
|
||||
let (s, app) = setup(true).await;
|
||||
sqlx::raw_sql("INSERT INTO oauth_clients(id,name,redirect_uris,grant_types) VALUES(9001,'PAT','[]','[\"personal_access\"]'),(9002,'OAuth','[]','[\"authorization_code\"]'); INSERT INTO oauth_access_tokens(id,user_id,client_id) VALUES('a',2,9001),('b',2,9002); INSERT INTO webauthn_credentials(id,authenticatable_type,authenticatable_id,user_id,rp_id,origin,public_key) VALUES('key','App\\Models\\User',2,'uuid','localhost','localhost','public'); UPDATE users SET preferences='{\"useWebauthnOnly\":true,\"theme\":\"dark\"}' WHERE id=2;").execute(&s.db).await.unwrap();
|
||||
let (_, v) = request(app.clone(), "GET", "/users/2", json!({})).await;
|
||||
assert_eq!(v["valid_personal_access_tokens"], 1);
|
||||
assert_eq!(v["webauthn_credentials"], 1);
|
||||
assert_eq!(
|
||||
request(app.clone(), "DELETE", "/users/2/pats", json!({}))
|
||||
.await
|
||||
.0,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("SELECT revoked FROM oauth_access_tokens WHERE id='a'")
|
||||
.fetch_one(&s.db)
|
||||
.await
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("SELECT revoked FROM oauth_access_tokens WHERE id='b'")
|
||||
.fetch_one(&s.db)
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
request(app.clone(), "DELETE", "/users/2/credentials", json!({}))
|
||||
.await
|
||||
.0,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
let (_, v) = request(app, "GET", "/users/2", json!({})).await;
|
||||
assert_eq!(v["webauthn_credentials"], 0);
|
||||
assert_eq!(v["info"]["preferences"]["useWebauthnOnly"], false);
|
||||
assert_eq!(v["info"]["preferences"]["theme"], "dark");
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn logs_are_scoped_purged_limited_and_timezoned() {
|
||||
let (s, app) = setup(true).await;
|
||||
sqlx::raw_sql("INSERT INTO auth_logs(authenticatable_type,authenticatable_id,ip_address,user_agent,login_at,login_successful) VALUES('App\\Models\\User',2,'127.0.0.1','Mozilla/5.0 (Windows NT 10.0) Firefox/115.0',CURRENT_TIMESTAMP,1),('App\\Models\\User',1,'private','',CURRENT_TIMESTAMP,1),('App\\Models\\User',2,'old','',datetime('now','-2 years'),1); UPDATE users SET preferences='{\"timezone\":\"Europe/Paris\"}' WHERE id=1;").execute(&s.db).await.unwrap();
|
||||
let (status, v) = request(
|
||||
app.clone(),
|
||||
"GET",
|
||||
"/users/2/authentications?period=1&limit=1",
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(v.as_array().unwrap().len(), 1);
|
||||
assert_eq!(v[0]["ip_address"], "127.0.0.1");
|
||||
assert_eq!(v[0]["browser"], "Firefox");
|
||||
assert_eq!(v[0]["platform"], "Windows");
|
||||
assert_eq!(v[0]["device"], "desktop");
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM auth_logs")
|
||||
.fetch_one(&s.db)
|
||||
.await
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
let (status, v) = request(
|
||||
app,
|
||||
"GET",
|
||||
"/users/2/authentications?period=bad&limit=true",
|
||||
json!({}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert!(v["errors"]["period"].is_array());
|
||||
assert!(v["errors"]["limit"].is_array());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn deletion_removes_credential_and_token_traces() {
|
||||
let (s, app) = setup(true).await;
|
||||
sqlx::raw_sql("INSERT INTO password_resets VALUES('user@example.com','secret',CURRENT_TIMESTAMP); INSERT INTO webauthn_recoveries VALUES('user@example.com','secret',CURRENT_TIMESTAMP); INSERT INTO oauth_access_tokens(id,user_id,client_id) VALUES('token',2,'client'); INSERT INTO rust_sessions VALUES('session',2,'csrf',9999999999);").execute(&s.db).await.unwrap();
|
||||
sqlx::raw_sql("PRAGMA foreign_keys=OFF; INSERT INTO twofaccounts(id,user_id,otp_type,secret,algorithm,digits) VALUES(22,2,'totp','SECRET','sha1',6); INSERT INTO groups(id,user_id,name) VALUES(11,1,'Keep'),(22,2,'Remove'); INSERT INTO twofaccount_shares(twofaccount_id,shared_with_user_id,scope,created_by_user_id) VALUES(22,1,'user',2); INSERT INTO twofaccount_user_orders(user_id,twofaccount_id,position) VALUES(1,22,1); INSERT INTO twofaccount_group_assignments(twofaccount_id,user_id,group_id) VALUES(22,1,11);").execute(&s.db).await.unwrap();
|
||||
assert_eq!(
|
||||
request(app, "DELETE", "/users/2", json!({})).await.0,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
for table in [
|
||||
"password_resets",
|
||||
"webauthn_recoveries",
|
||||
"oauth_access_tokens",
|
||||
"rust_sessions",
|
||||
"twofaccounts",
|
||||
"twofaccount_shares",
|
||||
"twofaccount_user_orders",
|
||||
"twofaccount_group_assignments",
|
||||
] {
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>(&format!("SELECT COUNT(*) FROM {table}"))
|
||||
.fetch_one(&s.db)
|
||||
.await
|
||||
.unwrap(),
|
||||
0,
|
||||
"{table}"
|
||||
);
|
||||
}
|
||||
assert!(user(&s.db, 1).await.is_ok());
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM groups")
|
||||
.fetch_one(&s.db)
|
||||
.await
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn resources_report_expired_password_resets() {
|
||||
let (s, app) = setup(true).await;
|
||||
sqlx::query("INSERT INTO password_resets VALUES('user@example.com','hash',datetime('now','-61 minutes'))").execute(&s.db).await.unwrap();
|
||||
let (_, v) = request(app, "GET", "/users/2", json!({})).await;
|
||||
assert_eq!(v["password_reset"], 0);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn email_restriction_accepts_list_or_regex_and_rejects_others() {
|
||||
let (s, _) = setup(true).await;
|
||||
sqlx::query("INSERT INTO options(key,value) VALUES('restrictRegistration','{{1}}'),('restrictList','listed@example.com'),('restrictRule','@allowed[.]org$')").execute(&s.db).await.unwrap();
|
||||
assert!(email_allowed(&s.db, "listed@example.com").await.unwrap());
|
||||
assert!(email_allowed(&s.db, "somebody@allowed.org").await.unwrap());
|
||||
assert!(!email_allowed(&s.db, "blocked@example.com").await.unwrap());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn oauth_reset_is_rejected_without_password_mutation() {
|
||||
let (s, app) = setup(true).await;
|
||||
sqlx::query("UPDATE users SET oauth_provider='github' WHERE id=2")
|
||||
.execute(&s.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let (status, v) = request(app, "PATCH", "/users/2/password/reset", json!({})).await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(v["reason"], "Password reset unavailable for this user");
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, String>("SELECT password FROM users WHERE id=2")
|
||||
.fetch_one(&s.db)
|
||||
.await
|
||||
.unwrap(),
|
||||
"old-hash"
|
||||
);
|
||||
assert_eq!(
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM password_resets")
|
||||
.fetch_one(&s.db)
|
||||
.await
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn php_pcre_patterns_support_lookarounds_backreferences_and_delimiters() {
|
||||
assert!(validate_regex(r"^(?=.*@allowed[.]org$).+$"));
|
||||
assert!(matches_regex(
|
||||
r"^(?=.*@allowed[.]org$).+$",
|
||||
"alice@allowed.org"
|
||||
));
|
||||
assert!(!matches_regex(
|
||||
r"^(?=.*@allowed[.]org$).+$",
|
||||
"alice@blocked.org"
|
||||
));
|
||||
assert!(matches_regex(r"^(abc)\1$", "abcabc"));
|
||||
assert!(matches_regex(r"a\/b", "a/b"));
|
||||
assert!(!validate_regex("a/b"));
|
||||
assert!(!validate_regex("["));
|
||||
}
|
||||
#[test]
|
||||
fn laravel_boolean_and_duration_rules() {
|
||||
assert_eq!(boolean(Some(&json!("true"))), None);
|
||||
assert_eq!(boolean(Some(&json!("1"))), Some(true));
|
||||
assert_eq!(duration(60), "1 minute");
|
||||
}
|
||||
}
|
||||
+1200
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user