2FA logins gatekept #1

merged
opened by baileytownsend.dev targeting main from feature/2faCodeGeneration
Changed files
+52 -49
migrations_bells_and_whistles
src
-3
migrations_bells_and_whistles/.keep
···
-
# This directory holds SQLx migrations for the bells_and_whistles.sqlite database.
-
# It is intentionally empty for now; running `sqlx::migrate!` will still ensure the
-
# migrations table exists and succeed with zero migrations.
···
+1
Cargo.lock
···
"scrypt",
"serde",
"serde_json",
"sqlx",
"tokio",
"tower-http",
···
"scrypt",
"serde",
"serde_json",
+
"sha2",
"sqlx",
"tokio",
"tower-http",
+1
Cargo.toml
···
rand = "0.9.2"
anyhow = "1.0.99"
chrono = "0.4.41"
···
rand = "0.9.2"
anyhow = "1.0.99"
chrono = "0.4.41"
+
sha2 = "0.10"
+27 -32
src/xrpc/helpers.rs
···
use crate::AppState;
use crate::xrpc::helpers::TokenCheckError::InvalidToken;
use axum::body::{Body, to_bytes};
···
//Just going a head and doing uppercase here.
let slice_one = &full_code[0..5].to_ascii_uppercase();
let slice_two = &full_code[5..10].to_ascii_uppercase();
-
format!("{}-{}", slice_one, slice_two)
}
pub enum TokenCheckError {
···
let sha = hasher.finalize();
let salt = hex::encode(&sha[..16]);
let hash_hex = scrypt_hex(password, &salt)?;
-
Ok(format!("{}:{}", salt, hash_hex))
}
-
async fn verify_password(password: &str, password_scrypt: &str) -> Result<bool, StatusCode> {
// Expected format: "salt:hash" where hash is hex of scrypt(password, salt, 64 bytes)
let mut parts = password_scrypt.splitn(2, ':');
let salt = match parts.next() {
···
)
.bind(identifier)
.fetch_optional(&state.account_pool)
-
.await
-
.map_err(|_| StatusCode::BAD_REQUEST)?,
IdentifierType::Handle => sqlx::query_as::<_, (String, String, String, String)>(
"SELECT account.did, account.passwordScrypt, account.email, actor.handle
FROM actor
···
)
.bind(identifier)
.fetch_optional(&state.account_pool)
-
.await
-
.map_err(|_| StatusCode::BAD_REQUEST)?,
IdentifierType::Did => sqlx::query_as::<_, (String, String, String, String)>(
"SELECT account.did, account.passwordScrypt, account.email, actor.handle
FROM actor
···
)
.bind(identifier)
.fetch_optional(&state.account_pool)
-
.await
-
.map_err(|_| StatusCode::BAD_REQUEST)?,
};
if let Some((did, password_scrypt, email, handle)) = account_row {
···
)
.bind(did.clone())
.fetch_optional(&state.pds_gatekeeper_pool)
-
.await
-
.map_err(|_| StatusCode::BAD_REQUEST)?;
let two_factor_required = match required_opt {
Some(row) => row.0 != 0,
···
}
}
Err(err) => {
-
log::error!("Error checking the app password: {}", err);
-
Err(StatusCode::BAD_REQUEST)
}
};
}
···
.await
{
Ok(_) => {
-
let _ = delete_all_email_tokens(&state.account_pool, did.clone()).await;
Ok(AuthResult::ProxyThrough)
}
Err(err) => Ok(AuthResult::TokenCheckFailed(err)),
···
}
return match create_two_factor_token(&state.account_pool, did).await {
-
//TODO replace unwraps with the mythical ?
Ok(code) => {
let mut email_data = Map::new();
email_data.insert("token".to_string(), Value::from(code.clone()));
email_data.insert("handle".to_string(), Value::from(handle.clone()));
-
//TODO bad unwrap
let email_body = state
.template_engine
-
.render("two_factor_code.hbs", email_data)
-
.unwrap();
let email = Message::builder()
//TODO prob get the proper type in the state
-
.from(state.mailer_from.parse().unwrap())
-
.to(email.parse().unwrap())
.subject("Sign in to Bluesky")
.multipart(
MultiPart::alternative() // This is composed of two parts.
.singlepart(
SinglePart::builder()
.header(header::ContentType::TEXT_PLAIN)
-
.body(format!("We received a sign-in request for the account @{}. Use the code: {} to sign in. If this wasn't you, we recommend taking steps to protect your account by changing your password at https://bsky.app/settings.", handle, code)), // Every message should have a plain text fallback.
)
.singlepart(
SinglePart::builder()
.header(header::ContentType::TEXT_HTML)
.body(email_body),
),
-
)
-
//TODO bad
-
.unwrap();
match state.mailer.send(email).await {
Ok(_) => Ok(AuthResult::TwoFactorRequired),
Err(err) => {
-
log::error!("Error sending the 2FA email: {}", err);
-
Err(StatusCode::BAD_REQUEST)
}
}
}
Err(err) => {
-
log::error!("error on creating a 2fa token: {}", err);
-
Err(StatusCode::BAD_REQUEST)
}
};
}
···
match res {
Ok(_) => Ok(token),
-
Err(e) => {
-
log::error!("Error creating a two factor token: {}", e);
-
Err(anyhow::anyhow!(e))
}
}
}
···
.fetch_optional(account_db)
.await
.map_err(|err| {
-
log::error!("Error getting the 2fa token: {}", err);
InvalidToken
})?;
···
+
use anyhow::anyhow;
use crate::AppState;
use crate::xrpc::helpers::TokenCheckError::InvalidToken;
use axum::body::{Body, to_bytes};
···
//Just going a head and doing uppercase here.
let slice_one = &full_code[0..5].to_ascii_uppercase();
let slice_two = &full_code[5..10].to_ascii_uppercase();
+
format!("{slice_one}-{slice_two}")
}
pub enum TokenCheckError {
···
let sha = hasher.finalize();
let salt = hex::encode(&sha[..16]);
let hash_hex = scrypt_hex(password, &salt)?;
+
Ok(format!("{salt}:{hash_hex}"))
}
+
async fn verify_password(password: &str, password_scrypt: &str) -> anyhow::Result<bool> {
// Expected format: "salt:hash" where hash is hex of scrypt(password, salt, 64 bytes)
let mut parts = password_scrypt.splitn(2, ':');
let salt = match parts.next() {
···
)
.bind(identifier)
.fetch_optional(&state.account_pool)
+
.await?,
IdentifierType::Handle => sqlx::query_as::<_, (String, String, String, String)>(
"SELECT account.did, account.passwordScrypt, account.email, actor.handle
FROM actor
···
)
.bind(identifier)
.fetch_optional(&state.account_pool)
+
.await?,
IdentifierType::Did => sqlx::query_as::<_, (String, String, String, String)>(
"SELECT account.did, account.passwordScrypt, account.email, actor.handle
FROM actor
···
)
.bind(identifier)
.fetch_optional(&state.account_pool)
+
.await?,
};
if let Some((did, password_scrypt, email, handle)) = account_row {
···
)
.bind(did.clone())
.fetch_optional(&state.pds_gatekeeper_pool)
+
.await?;
let two_factor_required = match required_opt {
Some(row) => row.0 != 0,
···
}
}
Err(err) => {
+
log::error!("Error checking the app password: {err}");
+
Err(err)
}
};
}
···
.await
{
Ok(_) => {
+
let result_of_cleanup = delete_all_email_tokens(&state.account_pool, did.clone()).await;
+
if result_of_cleanup.is_err(){
+
log::error!("There was an error deleting the email tokens after login: {:?}", result_of_cleanup.err())
+
}
Ok(AuthResult::ProxyThrough)
}
Err(err) => Ok(AuthResult::TokenCheckFailed(err)),
···
}
return match create_two_factor_token(&state.account_pool, did).await {
Ok(code) => {
let mut email_data = Map::new();
email_data.insert("token".to_string(), Value::from(code.clone()));
email_data.insert("handle".to_string(), Value::from(handle.clone()));
let email_body = state
.template_engine
+
.render("two_factor_code.hbs", email_data)?;
let email = Message::builder()
//TODO prob get the proper type in the state
+
.from(state.mailer_from.parse()?)
+
.to(email.parse()?)
.subject("Sign in to Bluesky")
.multipart(
MultiPart::alternative() // This is composed of two parts.
.singlepart(
SinglePart::builder()
.header(header::ContentType::TEXT_PLAIN)
+
.body(format!("We received a sign-in request for the account @{handle}. Use the code: {code} to sign in. If this wasn't you, we recommend taking steps to protect your account by changing your password at https://bsky.app/settings.")), // Every message should have a plain text fallback.
)
.singlepart(
SinglePart::builder()
.header(header::ContentType::TEXT_HTML)
.body(email_body),
),
+
)?;
match state.mailer.send(email).await {
Ok(_) => Ok(AuthResult::TwoFactorRequired),
Err(err) => {
+
log::error!("Error sending the 2FA email: {err}");
+
Err(anyhow!(err))
}
}
}
Err(err) => {
+
log::error!("error on creating a 2fa token: {err}");
+
Err(anyhow!(err))
}
};
}
···
match res {
Ok(_) => Ok(token),
+
Err(err) => {
+
log::error!("Error creating a two factor token: {err}");
+
Err(anyhow::anyhow!(err))
}
}
}
···
.fetch_optional(account_db)
.await
.map_err(|err| {
+
log::error!("Error getting the 2fa token: {err}");
InvalidToken
})?;
+8 -4
src/middleware.rs
···
-
use crate::xrpc::helpers::json_error_response;
use axum::extract::Request;
use axum::http::{HeaderMap, StatusCode};
use axum::middleware::Next;
···
match token {
Ok(token) => {
match token {
-
None => json_error_response(StatusCode::BAD_REQUEST, "TokenRequired", "").expect("Error creating an error response"),
Some(token) => {
let token = UntrustedToken::new(&token);
if token.is_err() {
···
.expect("Error creating an error response");
}
-
let key = Hs256Key::new(env::var("PDS_JWT_SECRET").expect("PDS_JWT_SECRET not set in the pds.env"));
let token: Result<Token<TokenClaims>, ValidationError> =
Hs256.validator(&key).validate(&parsed_token);
if token.is_err() {
···
}
Err(err) => {
log::error!("Error extracting token: {err}");
-
json_error_response(StatusCode::BAD_REQUEST, "InvalidToken", "").expect("Error creating an error response")
}
}
}
···
+
use crate::helpers::json_error_response;
use axum::extract::Request;
use axum::http::{HeaderMap, StatusCode};
use axum::middleware::Next;
···
match token {
Ok(token) => {
match token {
+
None => json_error_response(StatusCode::BAD_REQUEST, "TokenRequired", "")
+
.expect("Error creating an error response"),
Some(token) => {
let token = UntrustedToken::new(&token);
if token.is_err() {
···
.expect("Error creating an error response");
}
+
let key = Hs256Key::new(
+
env::var("PDS_JWT_SECRET").expect("PDS_JWT_SECRET not set in the pds.env"),
+
);
let token: Result<Token<TokenClaims>, ValidationError> =
Hs256.validator(&key).validate(&parsed_token);
if token.is_err() {
···
}
Err(err) => {
log::error!("Error extracting token: {err}");
+
json_error_response(StatusCode::BAD_REQUEST, "InvalidToken", "")
+
.expect("Error creating an error response")
}
}
}
-1
src/xrpc/mod.rs
···
pub mod com_atproto_server;
-
pub mod helpers;
···
pub mod com_atproto_server;
+10 -2
src/main.rs
···
AsyncSmtpTransport::<Tokio1Executor>::from_url(smtp_url.as_str())?.build();
//Email templates setup
let mut hbs = Handlebars::new();
-
//TODO add an override to manually load in the hbs templates
-
let _ = hbs.register_embed_templates::<EmailTemplates>();
let pds_base_url =
env::var("PDS_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
···
AsyncSmtpTransport::<Tokio1Executor>::from_url(smtp_url.as_str())?.build();
//Email templates setup
let mut hbs = Handlebars::new();
+
+
let users_email_directory = env::var("GATEKEEPER_EMAIL_TEMPLATES_DIRECTORY");
+
if let Ok(users_email_directory) = users_email_directory {
+
hbs.register_template_file(
+
"two_factor_code.hbs",
+
format!("{users_email_directory}/two_factor_code.hbs"),
+
)?;
+
} else {
+
let _ = hbs.register_embed_templates::<EmailTemplates>();
+
}
let pds_base_url =
env::var("PDS_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
+5 -6
README.md
···
## 2FA
-
- [x] Ability to turn on/off 2FA
-
- [x] getSession overwrite to set the `emailAuthFactor` flag if the user has 2FA turned on
-
- [x] send an email using the `PDS_EMAIL_SMTP_URL` with a handlebar email template like Bluesky's 2FA sign in email.
-
- [ ] generate a 2FA code
-
- [ ] createSession gatekeeping (It does stop logins, just eh, doesn't actually send a real code or check it yet)
-
- [ ] oauth endpoint gatekeeping
## Captcha on Create Account
···
# Setup
Nothing here yet! If you are brave enough to try before full release, let me know and I'll help you set it up.
But I want to run it locally on my own PDS first to test run it a bit.
···
path /xrpc/com.atproto.server.getSession
path /xrpc/com.atproto.server.updateEmail
path /xrpc/com.atproto.server.createSession
}
handle @gatekeeper {
···
## 2FA
+
- Overrides The login endpoint to add 2FA for both Bluesky client logged in and OAuth logins
+
- Overrides the settings endpoints as well. As long as you have a confirmed email you can turn on 2FA
## Captcha on Create Account
···
# Setup
+
We are getting close! Testing now
+
Nothing here yet! If you are brave enough to try before full release, let me know and I'll help you set it up.
But I want to run it locally on my own PDS first to test run it a bit.
···
path /xrpc/com.atproto.server.getSession
path /xrpc/com.atproto.server.updateEmail
path /xrpc/com.atproto.server.createSession
+
path /@atproto/oauth-provider/~api/sign-in
}
handle @gatekeeper {
-1
src/helpers.rs
···
use lettre::message::{MultiPart, SinglePart, header};
use lettre::{AsyncTransport, Message};
use rand::Rng;
-
use rand::distr::{Alphabetic, Alphanumeric, SampleString};
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
···
use lettre::message::{MultiPart, SinglePart, header};
use lettre::{AsyncTransport, Message};
use rand::Rng;
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};