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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,18 @@ The server checks current settings on requests and while streams remain open.

`thirdshift console log [--json]` and the Console log page show the same
account events from `~/.thirdshift/console.log`. They name the account but
never contain passwords, session tokens or page views. Email account creation
and password reset are a later Ticket; ask the owner to use the password command.
never contain passwords, codes, session tokens or page views.

With a Resend key in `RESEND_API_KEY` or the Credentials, **Set your password**
on the sign-in page creates an account or resets its password by email code.
Only allowed addresses receive mail, from `email.from`, but every address gets
the same answer, without waiting for address-dependent delivery. Codes are
stored as argon2id hashes, last 15 minutes and allow
5 tries. An address may receive one code a minute and 10 in a rolling 24 hours;
these limits survive restarts and password changes. A correct code verifies
the address, sets its password, ends its old sessions and signs it in.
The Console log records codes sent and passwords set. Without a key the flow
is off; ask the owner to use `thirdshift console password` instead.

The header names this machine and links to those addresses, without fetching
them. The Inbox and its `/events` stream pair with `thirdshift status`. One
Expand Down
28 changes: 26 additions & 2 deletions src/console.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,30 @@ enum View {
SignOut,
SignInPage,
SignIn,
SetPasswordPage,
SendCode,
SetPassword,
}

const PAGES: &[PageRoute] = &[
PageRoute {
path: "/set-password",
cli: &["thirdshift console password"],
method: Method::GET,
view: View::SetPasswordPage,
},
PageRoute {
path: "/set-password/code",
cli: &["thirdshift console password"],
method: Method::POST,
view: View::SendCode,
},
PageRoute {
path: "/set-password",
cli: &["thirdshift console password"],
method: Method::POST,
view: View::SetPassword,
},
PageRoute {
path: "/waterfall",
cli: &[
Expand Down Expand Up @@ -246,6 +267,9 @@ fn router(no_sign_in: bool, changes: tokio::sync::watch::Receiver<u64>) -> Route
(View::SignOut, Method::POST) => post(auth::sign_out),
(View::SignInPage, Method::GET) => get(auth::sign_in_page),
(View::SignIn, Method::POST) => post(auth::sign_in),
(View::SetPasswordPage, Method::GET) => get(auth::set_password_page),
(View::SendCode, Method::POST) => post(auth::code),
(View::SetPassword, Method::POST) => post(auth::set_password),
_ => unreachable!("unknown Console page method"),
};
router = router.route(page.path, handler);
Expand Down Expand Up @@ -502,7 +526,7 @@ mod tests {
.with_state(changes),
);
// Each unique path has one fallback; merging GET and POST at sign-in
// adds only the POST service, not another fallback service.
// and set-password adds only the POST services, not more fallbacks.
let fallback = 1;
let expected = super::PAGES
.iter()
Expand All @@ -515,7 +539,7 @@ mod tests {
})
.sum::<usize>()
+ get_methods
- fallback;
- 2 * fallback;
assert_eq!(
registered_methods(router),
expected,
Expand Down
60 changes: 41 additions & 19 deletions src/console/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,19 @@ use rand_core::{OsRng, RngCore};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

mod codes;
use codes::Codes;

pub(super) const LIFETIME: i64 = 30 * 24 * 60 * 60;

#[derive(Default, Deserialize, Serialize)]
struct Account {
password_hash: String,
#[serde(default)]
verified: bool,
#[serde(default)]
codes: Codes,
#[serde(default)]
sessions: BTreeMap<String, i64>,
#[serde(default)]
failed_passwords: u32,
Expand All @@ -30,6 +37,7 @@ pub(super) enum EventKind {
SignIn,
FailedSignIn,
PasswordSet,
CodeSent,
SignOut,
BlockedAddress,
}
Expand All @@ -40,6 +48,7 @@ impl std::fmt::Display for EventKind {
Self::SignIn => "sign-in",
Self::FailedSignIn => "failed-sign-in",
Self::PasswordSet => "password-set",
Self::CodeSent => "code-sent",
Self::SignOut => "sign-out",
Self::BlockedAddress => "blocked-address",
})
Expand Down Expand Up @@ -100,6 +109,7 @@ impl Accounts {
for (email, account) in &mut store.accounts {
let before = account.sessions.len();
if !allowed.contains(email) {
changed |= account.codes.clear();
account.sessions.clear();
if before != 0 {
blocked.push(email.clone());
Expand All @@ -126,18 +136,13 @@ impl Accounts {
"address is not allowed in console.allowed_emails"
);
ensure!(!password.is_empty(), "password must not be empty");
let salt = SaltString::generate(&mut OsRng);
let password_hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|error| anyhow::anyhow!("can't hash password: {error}"))?
.to_string();
self.accounts.insert(
email.into(),
Account {
password_hash,
..Account::default()
},
);
let password_hash = hash_secret(password)?;
let account = self.accounts.entry(email.into()).or_default();
account.password_hash = password_hash;
account.sessions.clear();
account.codes.clear();
account.failed_passwords = 0;
account.next_attempt = 0;
self.save()?;
self.record(email, EventKind::PasswordSet)
}
Expand All @@ -155,13 +160,10 @@ impl Accounts {
self.record(email, EventKind::FailedSignIn)?;
return Ok(SignIn::Slowdown);
}
let valid = self.accounts.get(email).is_some_and(|account| {
PasswordHash::new(&account.password_hash).is_ok_and(|hash| {
Argon2::default()
.verify_password(password.as_bytes(), &hash)
.is_ok()
})
});
let valid = self
.accounts
.get(email)
.is_some_and(|account| matches_secret(password, &account.password_hash));
if !valid {
if let Some(account) = self.accounts.get_mut(email) {
account.failed_passwords = account.failed_passwords.saturating_add(1);
Expand All @@ -173,6 +175,10 @@ impl Accounts {
self.record(email, EventKind::FailedSignIn)?;
return Ok(SignIn::Refused);
}
self.start_session(email)
}

fn start_session(&mut self, email: &str) -> Result<SignIn> {
let token = random_token();
let account = self.accounts.get_mut(email).expect("verified account");
account.failed_passwords = 0;
Expand Down Expand Up @@ -249,6 +255,22 @@ impl Accounts {
}
}

fn hash_secret(secret: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(secret.as_bytes(), &salt)
.map(|hash| hash.to_string())
.map_err(|error| anyhow::anyhow!("can't hash secret: {error}"))
}

fn matches_secret(secret: &str, hash: &str) -> bool {
PasswordHash::new(hash).is_ok_and(|hash| {
Argon2::default()
.verify_password(secret.as_bytes(), &hash)
.is_ok()
})
}

fn private_file(path: &Path) -> Result<File> {
let mut options = OpenOptions::new();
options.read(true).append(true).create(true);
Expand Down
92 changes: 92 additions & 0 deletions src/console/accounts/codes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//! Email codes and their durable limits share the account store's lock.
use super::*;

#[derive(Default, Deserialize, Serialize)]
pub(super) struct Codes {
pending: Option<Code>,
sent: Vec<i64>,
}

#[derive(Deserialize, Serialize)]
struct Code {
hash: String,
sent_at: i64,
attempts: u8,
}

impl Codes {
pub(super) fn clear(&mut self) -> bool {
self.pending.take().is_some()
}
}

impl Accounts {
/// The sender crosses the external email seam. Reserve the limits before
/// calling it so even a failed send cannot be retried without a bound.
pub fn send_code(&mut self, email: &str, send: impl FnOnce(&str) -> Result<()>) -> Result<()> {
if !self.allowed.iter().any(|allowed| allowed == email) {
return Ok(());
}
let account = self.accounts.entry(email.into()).or_default();
account
.codes
.sent
.retain(|sent| self.now.saturating_sub(*sent) < 24 * 60 * 60);
if account.codes.sent.len() >= 10
|| account
.codes
.sent
.last()
.is_some_and(|sent| self.now.saturating_sub(*sent) < 60)
{
return Ok(());
}
// Rejection sampling keeps all six-digit strings equally likely.
let number = loop {
let number = OsRng.next_u32();
if number < u32::MAX - u32::MAX % 1_000_000 {
break number % 1_000_000;
}
};
let code = format!("{number:06}");
account.codes.pending = Some(Code {
hash: hash_secret(&code)?,
sent_at: self.now,
attempts: 0,
});
account.codes.sent.push(self.now);
self.save()?;
send(&code)?;
self.record(email, EventKind::CodeSent)
}

pub fn set_password_by_code(
&mut self,
email: &str,
code: &str,
password: &str,
) -> Result<SignIn> {
if password.is_empty() || !self.allowed.iter().any(|allowed| allowed == email) {
return Ok(SignIn::Refused);
}
let Some(pending) = self
.accounts
.get_mut(email)
.and_then(|account| account.codes.pending.as_mut())
else {
return Ok(SignIn::Refused);
};
if self.now.saturating_sub(pending.sent_at) >= 15 * 60 || pending.attempts >= 5 {
return Ok(SignIn::Refused);
}
pending.attempts += 1;
let valid = matches_secret(code, &pending.hash);
self.save()?;
if !valid {
return Ok(SignIn::Refused);
}
self.set_password(email, password)?;
self.accounts.get_mut(email).expect("code account").verified = true;
self.start_session(email)
}
}
Loading
Loading