mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
backup: the Proton login runs from the workspace
:backup login <username> shows Proton's third-party disclosure (and a warning when no session key file seals the stored session), asks for the password, then for a two-factor code when the account has one. If Proton answers with a human check, its own verify.proton.me page opens in a sheet; the token it posts back resumes the login. :backup logout forgets the session and ends it on Proton's side where Proton answers. The mirror picks up a new session within a minute, without a restart. The login is split into steps in backup::proton (start, second_factor, verified), and a half-done login waits in memory for five minutes at most. Every step goes through its own throttle, so the workspace cannot hammer Proton into locking the account. FIDO2-only accounts are told to add an authenticator app. The CLI runs over the same steps and points to :backup login when Proton wants a human check. The CSP lets verify.proton.me into a frame and nothing else.
This commit is contained in:
1 parent
3387db1c4d
commit
e0a4c01654
15 files changed
+638
-39
No files matched your search
@@ -29,6 +29,7 @@ use proton_sdk::ids::NodeUid;
|
||||
use serde::Serialize;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use tokio::sync::Notify;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::config::Config;
|
||||
use session::SessionStore;
|
||||
@@ -66,6 +67,21 @@ pub struct Backup {
|
||||
/// but unreadable — when that is so. The server still starts; the mirror
|
||||
/// says why it is off, every time it is asked.
|
||||
disabled: Option<String>,
|
||||
/// A login from the workspace, waiting for its second factor or its
|
||||
/// human check; see [`Backup::login`].
|
||||
pending: Mutex<Option<proton::Pending>>,
|
||||
}
|
||||
|
||||
/// What a login from the workspace needs next.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "next", rename_all = "lowercase")]
|
||||
pub enum LoginStep {
|
||||
/// Logged in; the mirror starts on its own.
|
||||
Done,
|
||||
/// A two-factor code.
|
||||
Totp,
|
||||
/// Proton's human check, on the page at `url`.
|
||||
Captcha { url: String },
|
||||
}
|
||||
|
||||
impl Backup {
|
||||
@@ -104,9 +120,100 @@ impl Backup {
|
||||
..Status::default()
|
||||
}),
|
||||
running: tokio::sync::Mutex::new(()),
|
||||
pending: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the stored session is sealed under a key kept off the data
|
||||
/// volume, or lies there readable to whoever has a copy of it.
|
||||
pub fn session_sealed(&self) -> bool {
|
||||
self.store.sealed()
|
||||
}
|
||||
|
||||
/// Log in to Proton from the workspace. Any login still waiting is
|
||||
/// dropped: there is only ever one.
|
||||
pub async fn login(&self, username: &str, password: Zeroizing<String>) -> Result<LoginStep> {
|
||||
self.usable()?;
|
||||
self.take_pending();
|
||||
let outcome = proton::start(&self.store, username.trim(), password, None).await?;
|
||||
Ok(self.step(outcome))
|
||||
}
|
||||
|
||||
/// The two-factor code a waiting login asked for.
|
||||
pub async fn login_totp(&self, code: &str) -> Result<LoginStep> {
|
||||
self.usable()?;
|
||||
let pending = self.waiting()?;
|
||||
match proton::second_factor(&self.store, pending, code).await {
|
||||
Ok(()) => Ok(self.step(None)),
|
||||
Err((e, pending)) => {
|
||||
*self.pending.lock().unwrap_or_else(|e| e.into_inner()) =
|
||||
pending.map(|pending| *pending);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The token Proton's human check handed back to a waiting login.
|
||||
pub async fn login_verified(&self, token: &str) -> Result<LoginStep> {
|
||||
self.usable()?;
|
||||
let pending = self.waiting()?;
|
||||
let outcome = proton::verified(&self.store, pending, token).await?;
|
||||
Ok(self.step(outcome))
|
||||
}
|
||||
|
||||
/// Forget the Proton session, ending it on Proton's side too where
|
||||
/// Proton answers. The mirror on Drive stays where it is.
|
||||
pub async fn logout(&self) -> Result<()> {
|
||||
self.take_pending();
|
||||
if let Err(e) = proton::end(&self.store).await {
|
||||
tracing::warn!("{e:#}; the session is forgotten here all the same");
|
||||
}
|
||||
self.store.clear()?;
|
||||
self.set(|s| s.configured = false);
|
||||
tracing::info!("Proton Drive session forgotten");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn usable(&self) -> Result<()> {
|
||||
match &self.disabled {
|
||||
Some(reason) => anyhow::bail!("backups are off: {reason} — fix it and restart"),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn take_pending(&self) -> Option<proton::Pending> {
|
||||
self.pending
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.take()
|
||||
}
|
||||
|
||||
fn waiting(&self) -> Result<proton::Pending> {
|
||||
match self.take_pending() {
|
||||
Some(pending) if !pending.expired() => Ok(pending),
|
||||
Some(_) => anyhow::bail!("that login took too long — start again with :backup login"),
|
||||
None => anyhow::bail!("no Proton login is waiting — start one with :backup login"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep a login that still needs something, or note one that is done.
|
||||
fn step(&self, outcome: Option<(proton::Next, proton::Pending)>) -> LoginStep {
|
||||
let Some((next, pending)) = outcome else {
|
||||
tracing::info!("logged in to Proton Drive from the workspace");
|
||||
self.set(|s| {
|
||||
s.configured = true;
|
||||
s.last_error = None;
|
||||
});
|
||||
self.signal();
|
||||
return LoginStep::Done;
|
||||
};
|
||||
*self.pending.lock().unwrap_or_else(|e| e.into_inner()) = Some(pending);
|
||||
match next {
|
||||
proton::Next::Totp => LoginStep::Totp,
|
||||
proton::Next::Captcha(url) => LoginStep::Captcha { url },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self) -> Status {
|
||||
let mut status = self
|
||||
.status
|
||||
|
||||
+135
-18
@@ -3,14 +3,17 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use proton_drive_rs::{Device, DeviceType, ProtonDriveClient};
|
||||
use proton_sdk::account::AccountClient;
|
||||
use proton_sdk::api::{HumanVerificationCredential, ResponseCode};
|
||||
use proton_sdk::config::ProtonClientConfiguration;
|
||||
use proton_sdk::error::ProtonError;
|
||||
use proton_sdk::session::ProtonApiSession;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use super::session::{SessionStore, StoredSession};
|
||||
|
||||
/// What Proton asks a third-party client to tell anyone it takes credentials
|
||||
/// from. Shown by `narl-workspace proton-login` before it asks for anything.
|
||||
/// from. Shown by `narl-workspace proton-login` and `:backup login` before
|
||||
/// either asks for anything.
|
||||
pub const DISCLOSURE: &str =
|
||||
"This is a third-party application not officially supported by Proton.";
|
||||
|
||||
@@ -45,33 +48,132 @@ fn client_config() -> ProtonClientConfiguration {
|
||||
ProtonClientConfiguration::new(version).with_user_agent(agent)
|
||||
}
|
||||
|
||||
/// Run an SRP login, complete 2FA if the account asks for one, and persist the
|
||||
/// result. `get_totp` is only called when a second factor is actually required,
|
||||
/// so accounts without one are never prompted.
|
||||
pub async fn login(
|
||||
/// How long a login may wait for its second factor or its human check. The
|
||||
/// password is held in memory for that long and no longer.
|
||||
const PENDING_FOR: std::time::Duration = std::time::Duration::from_secs(5 * 60);
|
||||
|
||||
/// A login half done, waiting on the person at the keyboard: for a
|
||||
/// two-factor code, with the session Proton opened for it, or for a solved
|
||||
/// human check, after which the login starts over with the proof attached.
|
||||
pub struct Pending {
|
||||
username: String,
|
||||
password: Zeroizing<String>,
|
||||
session: Option<ProtonApiSession>,
|
||||
started: std::time::Instant,
|
||||
}
|
||||
|
||||
impl Pending {
|
||||
pub fn expired(&self) -> bool {
|
||||
self.started.elapsed() > PENDING_FOR
|
||||
}
|
||||
}
|
||||
|
||||
/// What a login needs next.
|
||||
pub enum Next {
|
||||
/// A code from the account's authenticator app.
|
||||
Totp,
|
||||
/// Proton's human check, solved on the page at this address.
|
||||
Captcha(String),
|
||||
}
|
||||
|
||||
/// Start a login: SRP with the password, and with a solved human check when
|
||||
/// there is one. Stored and done when the account asks for nothing more;
|
||||
/// otherwise what it asks for, and the login waiting on it.
|
||||
pub async fn start(
|
||||
store: &SessionStore,
|
||||
username: &str,
|
||||
password: &str,
|
||||
get_totp: impl FnOnce() -> Result<String>,
|
||||
) -> Result<()> {
|
||||
let mut session = ProtonApiSession::begin(client_config(), username, password.as_bytes())
|
||||
.await
|
||||
.context("Proton rejected the login")?;
|
||||
password: Zeroizing<String>,
|
||||
verified: Option<&HumanVerificationCredential>,
|
||||
) -> Result<Option<(Next, Pending)>> {
|
||||
let begun =
|
||||
ProtonApiSession::begin_verified(client_config(), username, password.as_bytes(), verified)
|
||||
.await;
|
||||
let pending = |session| Pending {
|
||||
username: username.to_string(),
|
||||
password: password.clone(),
|
||||
session,
|
||||
started: std::time::Instant::now(),
|
||||
};
|
||||
let session = match begun {
|
||||
Ok(session) => session,
|
||||
Err(ProtonError::Api(e)) if e.is_human_verification_required() => {
|
||||
let Some(check) = e
|
||||
.human_verification()
|
||||
.filter(|check| check.supports_captcha())
|
||||
else {
|
||||
return Err(anyhow!(
|
||||
"Proton wants a human check this server cannot show — log in once at \
|
||||
proton.me from this network, then try again"
|
||||
));
|
||||
};
|
||||
return Ok(Some((
|
||||
Next::Captcha(check.verification_url()),
|
||||
pending(None),
|
||||
)));
|
||||
}
|
||||
Err(ProtonError::Api(e)) if e.code == ResponseCode::IncorrectLoginCredentials => {
|
||||
return Err(anyhow!("Proton rejected the username or password"));
|
||||
}
|
||||
Err(e @ ProtonError::Transport(_)) => {
|
||||
return Err(anyhow::Error::from(e).context("could not reach Proton"))
|
||||
}
|
||||
Err(e) => return Err(anyhow::Error::from(e).context("Proton rejected the login")),
|
||||
};
|
||||
|
||||
if session.is_waiting_for_second_factor() {
|
||||
let code = get_totp()?;
|
||||
session
|
||||
.apply_second_factor_code(code.trim())
|
||||
.await
|
||||
.context("Proton rejected the second-factor code")?;
|
||||
if !session.second_factor_methods().totp {
|
||||
return Err(anyhow!(
|
||||
"this account's second factor is a security key only — add an authenticator \
|
||||
app to it in Proton's settings, which this server can use"
|
||||
));
|
||||
}
|
||||
return Ok(Some((Next::Totp, pending(Some(session)))));
|
||||
}
|
||||
finish(store, &session, &password).await?;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Answer a login's two-factor question. A wrong code gives the login back,
|
||||
/// to be asked again.
|
||||
pub async fn second_factor(
|
||||
store: &SessionStore,
|
||||
mut pending: Pending,
|
||||
code: &str,
|
||||
) -> std::result::Result<(), (anyhow::Error, Option<Box<Pending>>)> {
|
||||
let Some(session) = pending.session.as_mut() else {
|
||||
return Err((
|
||||
anyhow!("this login is waiting for a human check, not a code"),
|
||||
Some(Box::new(pending)),
|
||||
));
|
||||
};
|
||||
if let Err(e) = session.apply_second_factor_code(code.trim()).await {
|
||||
let e = anyhow::Error::from(e).context("Proton rejected the two-factor code");
|
||||
return Err((e, Some(Box::new(pending))));
|
||||
}
|
||||
let session = pending.session.take().expect("checked above");
|
||||
finish(store, &session, &pending.password)
|
||||
.await
|
||||
.map_err(|e| (e, None))
|
||||
}
|
||||
|
||||
/// The login again from the start, now with a solved human check.
|
||||
pub async fn verified(
|
||||
store: &SessionStore,
|
||||
pending: Pending,
|
||||
token: &str,
|
||||
) -> Result<Option<(Next, Pending)>> {
|
||||
let proof = HumanVerificationCredential::captcha(token);
|
||||
start(store, &pending.username, pending.password, Some(&proof)).await
|
||||
}
|
||||
|
||||
/// Store the session of a finished login.
|
||||
async fn finish(store: &SessionStore, session: &ProtonApiSession, password: &str) -> Result<()> {
|
||||
// The passphrases are derived here and only here: this access token still
|
||||
// carries the `locked` scope the key salts need, and no refreshed one ever
|
||||
// will. Unlocking checks them against the account's keys before anything
|
||||
// is stored, so a wrong mailbox password fails now and not at the first
|
||||
// backup.
|
||||
let client = ProtonDriveClient::new(&session, password.as_bytes().to_vec());
|
||||
let client = ProtonDriveClient::new(session, password.as_bytes().to_vec());
|
||||
let account = client.account();
|
||||
let key_passphrases = account
|
||||
.key_passphrases()
|
||||
@@ -84,13 +186,28 @@ pub async fn login(
|
||||
|
||||
let tokens = session.current_tokens().await;
|
||||
store.save(&StoredSession::from_session(
|
||||
&session,
|
||||
session,
|
||||
tokens,
|
||||
key_passphrases,
|
||||
))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sign the stored session out on Proton's side as well, as far as Proton
|
||||
/// answers; the caller forgets it locally whatever comes of this.
|
||||
pub async fn end(store: &SessionStore) -> Result<()> {
|
||||
let Some(stored) = store.load()? else {
|
||||
return Ok(());
|
||||
};
|
||||
let session = ProtonApiSession::resume(client_config(), stored.resume_parameters())
|
||||
.context("the stored Proton session could not be resumed")?;
|
||||
session
|
||||
.end()
|
||||
.await
|
||||
.context("Proton did not end the session")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rebuild a Drive client from the stored session, or `None` when nothing has
|
||||
/// been stored yet — that is the ordinary "backups are not set up" state, not
|
||||
/// an error.
|
||||
|
||||
@@ -178,6 +178,11 @@ impl SessionStore {
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
/// Whether what is stored is sealed under a key file.
|
||||
pub fn sealed(&self) -> bool {
|
||||
self.key.is_some()
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
+18
-7
@@ -55,19 +55,30 @@ pub async fn proton_login(config: &Config) -> Result<()> {
|
||||
}
|
||||
};
|
||||
|
||||
backup::proton::login(&store, username.trim(), &password, || {
|
||||
match env("PROTON_TOTP") {
|
||||
Some(code) => Ok(code),
|
||||
None => prompt("Two-factor code: "),
|
||||
let password = zeroize::Zeroizing::new(password);
|
||||
use backup::proton::Next;
|
||||
match backup::proton::start(&store, username.trim(), password, None).await? {
|
||||
None => {}
|
||||
Some((Next::Totp, pending)) => {
|
||||
let code = match env("PROTON_TOTP") {
|
||||
Some(code) => code,
|
||||
None => prompt("Two-factor code: ")?,
|
||||
};
|
||||
backup::proton::second_factor(&store, pending, &code)
|
||||
.await
|
||||
.map_err(|(e, _)| e)?;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
Some((Next::Captcha(_), _)) => bail!(
|
||||
"Proton wants a human check before this login, which a terminal cannot show — \
|
||||
log in from the workspace instead, with `:backup login <username>`"
|
||||
),
|
||||
}
|
||||
|
||||
println!(
|
||||
"Logged in. The session is stored at {}.",
|
||||
store.path().display()
|
||||
);
|
||||
println!("Backups start with the next server start, or run `narl-workspace backup-now`.");
|
||||
println!("A running server starts backing up within a minute; `narl-workspace backup-now` runs one at once.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -132,6 +132,7 @@ async fn main() -> Result<()> {
|
||||
config: Arc::clone(&config),
|
||||
login_throttle: Arc::default(),
|
||||
vault_throttle: Arc::default(),
|
||||
proton_throttle: Arc::default(),
|
||||
backup,
|
||||
notify,
|
||||
epoch: Arc::new(std::sync::atomic::AtomicI64::new(epoch)),
|
||||
@@ -304,6 +305,8 @@ fn is_slow(method: &axum::http::Method, path: &str) -> bool {
|
||||
path == "/api/media"
|
||||
|| path == "/api/import"
|
||||
|| path == "/api/backup"
|
||||
// A login waits on Proton's SRP and key unlocking.
|
||||
|| path.starts_with("/api/backup/login")
|
||||
|| path.ends_with("/import")
|
||||
|| path.ends_with("/files")
|
||||
|| path.ends_with("/encrypt")
|
||||
@@ -383,7 +386,7 @@ fn compressible(
|
||||
/// runtime; scripts stay confined to the bundled files.
|
||||
const CSP: &str = "default-src 'self'; img-src 'self' data:; media-src 'self'; \
|
||||
style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; \
|
||||
frame-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'";
|
||||
frame-src 'self' https://verify.proton.me; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'";
|
||||
|
||||
type HeaderLayer = SetResponseHeaderLayer<HeaderValue>;
|
||||
|
||||
|
||||
+168
-4
@@ -1,11 +1,33 @@
|
||||
//! Looking at the Proton Drive mirror, and asking it to run now.
|
||||
//! Looking at the Proton Drive mirror, asking it to run now, and logging it
|
||||
//! in to Proton or out again.
|
||||
|
||||
use axum::{extract::State, Json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::{auth::Session, backup::Status, error::AppResult, state::AppState};
|
||||
use crate::{
|
||||
auth::Session,
|
||||
backup::{proton::DISCLOSURE, LoginStep, Status},
|
||||
error::{AppError, AppResult},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub async fn status(_: Session, State(state): State<AppState>) -> AppResult<Json<Status>> {
|
||||
Ok(Json(state.backup.status()))
|
||||
#[derive(Serialize)]
|
||||
pub struct Shown {
|
||||
#[serde(flatten)]
|
||||
pub status: Status,
|
||||
/// What Proton asks a third-party client to say before it takes a login.
|
||||
pub disclosure: &'static str,
|
||||
/// Whether a stored session is sealed under a key file off the data volume.
|
||||
pub sealed_session: bool,
|
||||
}
|
||||
|
||||
pub async fn status(_: Session, State(state): State<AppState>) -> AppResult<Json<Shown>> {
|
||||
Ok(Json(Shown {
|
||||
status: state.backup.status(),
|
||||
disclosure: DISCLOSURE,
|
||||
sealed_session: state.backup.session_sealed(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Mirror now rather than when the workspace next falls quiet. Answers with the
|
||||
@@ -14,3 +36,145 @@ pub async fn run(_: Session, State(state): State<AppState>) -> AppResult<Json<St
|
||||
state.backup.run_now().await?;
|
||||
Ok(Json(state.backup.status()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginInput {
|
||||
pub username: String,
|
||||
pub password: Zeroizing<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CodeInput {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TokenInput {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Answer {
|
||||
#[serde(flatten)]
|
||||
pub step: LoginStep,
|
||||
pub status: Status,
|
||||
}
|
||||
|
||||
/// Every step of a login goes through its own throttle, so the workspace
|
||||
/// cannot be made to hammer Proton into locking the account. Proton's reasons
|
||||
/// for a refusal are passed on as they are; none of them holds a secret.
|
||||
async fn step(
|
||||
state: &AppState,
|
||||
run: impl std::future::Future<Output = anyhow::Result<LoginStep>>,
|
||||
) -> AppResult<Json<Answer>> {
|
||||
let attempt = state.proton_throttle.begin().map_err(|wait| {
|
||||
tracing::warn!("Proton login refused, {wait}s of backoff remaining");
|
||||
AppError::TooManyRequests(wait)
|
||||
})?;
|
||||
match run.await {
|
||||
Ok(step) => {
|
||||
attempt.succeeded();
|
||||
Ok(Json(Answer {
|
||||
step,
|
||||
status: state.backup.status(),
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
attempt.failed();
|
||||
tracing::warn!("Proton login: {e:#}");
|
||||
Err(AppError::BadRequest(format!("{e:#}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/backup/login` — start a login with the account's password.
|
||||
pub async fn login(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<LoginInput>,
|
||||
) -> AppResult<Json<Answer>> {
|
||||
if input.username.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("a Proton username is needed".into()));
|
||||
}
|
||||
step(&state, state.backup.login(&input.username, input.password)).await
|
||||
}
|
||||
|
||||
/// `POST /api/backup/login/totp` — the code a waiting login asked for.
|
||||
pub async fn totp(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<CodeInput>,
|
||||
) -> AppResult<Json<Answer>> {
|
||||
step(&state, state.backup.login_totp(&input.code)).await
|
||||
}
|
||||
|
||||
/// `POST /api/backup/login/captcha` — the token Proton's human check gave.
|
||||
pub async fn captcha(
|
||||
_: Session,
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<TokenInput>,
|
||||
) -> AppResult<Json<Answer>> {
|
||||
step(&state, state.backup.login_verified(&input.token)).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/backup/login` — forget the Proton session.
|
||||
pub async fn logout(_: Session, State(state): State<AppState>) -> AppResult<Json<Status>> {
|
||||
state.backup.logout().await?;
|
||||
Ok(Json(state.backup.status()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{header, Request, StatusCode},
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_code_with_no_login_waiting_is_refused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let state = crate::routes::tests::state_in(dir.path().into()).await;
|
||||
crate::auth::Epoch::load(&state.db, &state.config)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = crate::routes::api_router(1024).with_state(state);
|
||||
let login = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::post("/login")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"username":"narl","password":"hunter2"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let cookie = login.headers()[header::SET_COOKIE]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let answer = app
|
||||
.oneshot(
|
||||
Request::post("/backup/login/totp")
|
||||
.header("content-type", "application/json")
|
||||
.header(header::COOKIE, cookie)
|
||||
.body(Body::from(r#"{"code":"123456"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(answer.status(), StatusCode::BAD_REQUEST);
|
||||
let body = axum::body::to_bytes(answer.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
// A server built for tests has backups off; that is said first.
|
||||
let text = String::from_utf8_lossy(&body);
|
||||
assert!(
|
||||
text.contains("backups are off") || text.contains("no Proton login is waiting"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,9 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
|
||||
.route("/sessions/{id}", delete(session::revoke))
|
||||
.route("/export", get(export::export))
|
||||
.route("/backup", get(backup::status).post(backup::run))
|
||||
.route("/backup/login", post(backup::login).delete(backup::logout))
|
||||
.route("/backup/login/totp", post(backup::totp))
|
||||
.route("/backup/login/captcha", post(backup::captcha))
|
||||
.route("/spaces", get(nodes::spaces).post(nodes::create_space))
|
||||
.route("/spaces/overview", get(nodes::overview))
|
||||
.route("/agenda", get(boards::agenda))
|
||||
@@ -141,6 +144,7 @@ pub(crate) mod tests {
|
||||
notify: Notifier::new(db.clone(), Arc::clone(&config)).unwrap(),
|
||||
login_throttle: Arc::default(),
|
||||
vault_throttle: Arc::default(),
|
||||
proton_throttle: Arc::default(),
|
||||
epoch: Arc::new(AtomicI64::new(0)),
|
||||
sessions: Arc::default(),
|
||||
config,
|
||||
|
||||
@@ -19,6 +19,8 @@ pub struct AppState {
|
||||
/// The vault password gets the same slowing-down as the login, kept apart
|
||||
/// so guessing at one does not slow the other.
|
||||
pub vault_throttle: Arc<LoginThrottle>,
|
||||
/// Logins to Proton from the workspace, slowed apart from the rest.
|
||||
pub proton_throttle: Arc<LoginThrottle>,
|
||||
/// Sealed files. Always present; holds no key until it is unlocked.
|
||||
pub vault: Arc<Vault>,
|
||||
/// The Proton Drive mirror. Always present; dormant until it has a session.
|
||||
|
||||
Reference in new issue
Block a user