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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ RUST_LOG=info,octo=debug
# links (e.g. https://app.octo.dev/pay/<slug>). No trailing slash.
PUBLIC_APP_URL=http://localhost:3000

# --- Email (Resend) ---
# API key from https://resend.com — required for OTP/welcome/withdrawal emails.
RESEND_API_KEY=
# Verified sender address, e.g. "Octo <noreply@octohq.org>".
EMAIL_FROM_ADDRESS=

# --- Ingest worker ---
# How often the deposit ingest supervisor polls Horizon for all wallets, and the page size.
INGEST_INTERVAL_SECS=5
Expand Down
17 changes: 17 additions & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ members = [
"crates/wallet-core",
"crates/store",
"crates/webhooks",
"crates/email",
"crates/ingest",
"crates/api",
"crates/resilience",
Expand Down Expand Up @@ -68,6 +69,7 @@ octo-crypto = { path = "crates/crypto" }
octo-wallet-core = { path = "crates/wallet-core" }
octo-store = { path = "crates/store" }
octo-webhooks = { path = "crates/webhooks" }
octo-email = { path = "crates/email" }
octo-ingest = { path = "crates/ingest" }
octo-api = { path = "crates/api" }
octo-resilience = { path = "crates/resilience" }
Expand Down
1 change: 1 addition & 0 deletions bin/server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ octo-api.workspace = true
octo-ingest.workspace = true
octo-store.workspace = true
octo-webhooks.workspace = true
octo-email.workspace = true
octo-wallet-core.workspace = true
octo-resilience.workspace = true
tokio.workspace = true
Expand Down
12 changes: 12 additions & 0 deletions bin/server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

use anyhow::{Context, Result};
use octo_api::{build_router, AppState};
use octo_email::EmailSender;
use octo_ingest::Supervisor;
use octo_resilience::ResilienceConfig;
use octo_store::Store;
Expand Down Expand Up @@ -42,13 +43,15 @@ async fn main() -> Result<()> {
);

// Shared state (includes the API's Horizon client wired with resilience).
let email = EmailSender::new(cfg.resend_api_key.clone(), cfg.email_from_address.clone());
let mut state = AppState::new_with_resilience(
store.clone(),
cfg.master_key,
cfg.network,
cfg.horizon_url.clone(),
cfg.friendbot_url.clone(),
cfg.public_app_url.clone(),
email,
resilience.retry_policy(),
resilience.circuit_breaker(),
)
Expand Down Expand Up @@ -119,6 +122,8 @@ struct Config {
/// Base URL of the hosted checkout frontend (e.g. `https://app.octo.dev`), used to build the
/// `url` field on payment-link responses. Defaults to the local frontend dev server.
public_app_url: String,
resend_api_key: String,
email_from_address: String,
master_key: [u8; 32],
/// Optional next master key for zero-downtime rotation. Present only during the rotation
/// window while `octo-migrate-keys` is backfilling. When set, the server uses this key as
Expand Down Expand Up @@ -159,6 +164,11 @@ impl Config {
.trim_end_matches('/')
.to_string();

let resend_api_key =
std::env::var("RESEND_API_KEY").context("RESEND_API_KEY is required")?;
let email_from_address =
std::env::var("EMAIL_FROM_ADDRESS").context("EMAIL_FROM_ADDRESS is required")?;

let master_key_b64 = std::env::var("MASTER_KEY").context("MASTER_KEY is required")?;
let master_key = AppState::decode_master_key(&master_key_b64)
.map_err(|_| anyhow::anyhow!("MASTER_KEY must be base64-encoded 32 bytes"))?;
Expand Down Expand Up @@ -199,6 +209,8 @@ impl Config {
horizon_url,
friendbot_url,
public_app_url,
resend_api_key,
email_from_address,
master_key,
master_key_next,
jwt_secret,
Expand Down
3 changes: 3 additions & 0 deletions crates/api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ octo-crypto.workspace = true
octo-wallet-core.workspace = true
octo-store.workspace = true
octo-webhooks.workspace = true
octo-email.workspace = true
octo-resilience.workspace = true
axum.workspace = true
tower.workspace = true
Expand Down Expand Up @@ -53,3 +54,5 @@ serde_yaml = "0.9"
jsonschema = "0.26"
# Signing fixtures for validation tests only — never enabled in production builds.
octo-wallet-core = { workspace = true, features = ["test-fixtures"] }
# Captures OTP codes in memory instead of emailing them — never enabled in production builds.
octo-email = { workspace = true, features = ["test-fixtures"] }
181 changes: 169 additions & 12 deletions crates/api/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,28 @@ pub struct AuthResponse {
pub user: UserView,
}

/// Returned by signup, and by login when the account isn't yet email-verified — tells the
/// client to show the OTP-entry step instead of a token.
#[derive(Debug, Serialize)]
pub struct VerificationRequiredResponse {
pub user_id: Uuid,
pub email_verification_required: bool,
}

#[derive(Debug, Deserialize, Default)]
pub struct VerifyEmailRequest {
pub user_id: Option<Uuid>,
pub code: Option<String>,
}

#[derive(Debug, Deserialize, Default)]
pub struct ResendOtpRequest {
pub user_id: Option<Uuid>,
}

/// Signup-OTP TTL. Matches the wallet-ownership challenge's 10-minute window.
const OTP_TTL_MINUTES: i64 = 10;

#[derive(Debug, Serialize)]
pub struct UserView {
pub id: Uuid,
Expand Down Expand Up @@ -113,13 +135,37 @@ fn check_auth_rate_limit(
}
}

/// Generate, store, and email a signup-verification OTP. Shared by `signup`, `resend_otp`, and
/// `login` when the account isn't yet verified.
async fn issue_signup_otp(state: &AppState, user_id: Uuid, email: &str) -> Result<(), ApiError> {
let code = octo_email::generate_otp();
let code_hash = octo_email::hash_otp(&code);
state
.store()
.create_otp(
user_id,
"signup",
&code_hash,
None,
chrono::Duration::minutes(OTP_TTL_MINUTES),
)
.await
.map_err(|_| ApiError::Internal)?;
state
.email()
.send_otp(email, "signup", &code)
.await
.map_err(|_| ApiError::Internal)?;
Ok(())
}

/// `POST /v1/auth/signup`
pub async fn signup(
State(state): State<AppState>,
peer: Option<axum::extract::ConnectInfo<std::net::SocketAddr>>,
headers: HeaderMap,
body: Bytes,
) -> ApiResult<(StatusCode, Json<Envelope<AuthResponse>>)> {
) -> ApiResult<(StatusCode, Json<Envelope<VerificationRequiredResponse>>)> {
check_auth_rate_limit(&state, &headers, peer.map(|c| c.0))?;
let creds: Credentials = parse_optional(&body)?;
let (email, password) = validate(creds)?;
Expand All @@ -146,15 +192,110 @@ pub async fn signup(
)
.await;

issue_signup_otp(&state, user.id, &user.email).await?;

let (code, json) = Envelope::created(VerificationRequiredResponse {
user_id: user.id,
email_verification_required: true,
});
Ok((code, json))
}

/// `POST /v1/auth/verify-email` — consume a signup OTP and issue the first session token.
pub async fn verify_email(
State(state): State<AppState>,
body: Bytes,
) -> ApiResult<Json<Envelope<AuthResponse>>> {
let req: VerifyEmailRequest = parse_optional(&body)?;
let user_id = req
.user_id
.ok_or_else(|| ApiError::BadRequest("user_id is required".into()))?;
let code = req
.code
.filter(|c| !c.is_empty())
.ok_or_else(|| ApiError::BadRequest("code is required".into()))?;

let code_hash = octo_email::hash_otp(&code);
state
.store()
.verify_and_consume_otp(user_id, "signup", &code_hash, None)
.await
.map_err(|_| ApiError::BadRequest("invalid or expired code".into()))?;

let user = state
.store()
.get_user(user_id)
.await
.map_err(|_| ApiError::Internal)?
.ok_or(ApiError::NotFound)?;

state
.store()
.mark_email_verified(user_id)
.await
.map_err(|_| ApiError::Internal)?;

let welcome_html = octo_email::templates::welcome_email(&user.email);
let _ = state
.email()
.send(&user.email, "Welcome to Octo", &welcome_html)
.await;

let token = issue_token(state.jwt_secret(), user.id)?;
let (code, json) = Envelope::created(AuthResponse {
Ok(Envelope::ok(AuthResponse {
token,
user: UserView {
id: user.id,
email: user.email,
},
});
Ok((code, json))
}))
}

/// `POST /v1/auth/resend-otp` — re-send a signup verification code.
pub async fn resend_otp(
State(state): State<AppState>,
peer: Option<axum::extract::ConnectInfo<std::net::SocketAddr>>,
headers: HeaderMap,
body: Bytes,
) -> ApiResult<Json<Envelope<serde_json::Value>>> {
check_auth_rate_limit(&state, &headers, peer.map(|c| c.0))?;
let req: ResendOtpRequest = parse_optional(&body)?;
let user_id = req
.user_id
.ok_or_else(|| ApiError::BadRequest("user_id is required".into()))?;

let ip = crate::rate_limit::client_ip(&headers, peer.map(|c| c.0));
if !state.rate_limiter().check(
&format!("otp:{user_id}"),
"otp_resend",
3,
std::time::Duration::from_secs(60 * 60),
) {
return Err(ApiError::TooManyRequests(
"too many resend attempts — wait a while and try again".into(),
));
}
// Belt and suspenders: also cap per-IP, so one IP can't hammer many user_ids.
if !state.rate_limiter().check(
&ip,
"otp_resend_ip",
10,
std::time::Duration::from_secs(60 * 60),
) {
return Err(ApiError::TooManyRequests(
"too many resend attempts — wait a while and try again".into(),
));
}

let user = state
.store()
.get_user(user_id)
.await
.map_err(|_| ApiError::Internal)?
.ok_or(ApiError::NotFound)?;

issue_signup_otp(&state, user.id, &user.email).await?;
Ok(Envelope::ok(serde_json::json!({ "sent": true })))
}

/// `POST /v1/auth/login`
Expand All @@ -163,7 +304,7 @@ pub async fn login(
peer: Option<axum::extract::ConnectInfo<std::net::SocketAddr>>,
headers: HeaderMap,
body: Bytes,
) -> ApiResult<Json<Envelope<AuthResponse>>> {
) -> ApiResult<Json<Envelope<serde_json::Value>>> {
check_auth_rate_limit(&state, &headers, peer.map(|c| c.0))?;
let creds: Credentials = parse_optional(&body)?;
let (email, password) = validate(creds)?;
Expand All @@ -178,6 +319,19 @@ pub async fn login(
verify_password(&password, &user.password_hash)
.map_err(|_| ApiError::BadRequest("invalid email or password".into()))?;

// A correct password on an unverified account (pre-existing user, or one who abandoned
// signup before verifying) re-triggers the same OTP gate signup uses, instead of a token.
if user.email_verified_at.is_none() {
issue_signup_otp(&state, user.id, &user.email).await?;
return Ok(Envelope::ok(
serde_json::to_value(VerificationRequiredResponse {
user_id: user.id,
email_verification_required: true,
})
.map_err(|_| ApiError::Internal)?,
));
}

crate::audit::record(
&state,
user.id,
Expand All @@ -189,13 +343,16 @@ pub async fn login(
.await;

let token = issue_token(state.jwt_secret(), user.id)?;
Ok(Envelope::ok(AuthResponse {
token,
user: UserView {
id: user.id,
email: user.email,
},
}))
Ok(Envelope::ok(
serde_json::to_value(AuthResponse {
token,
user: UserView {
id: user.id,
email: user.email,
},
})
.map_err(|_| ApiError::Internal)?,
))
}

/// `POST /v1/auth/refresh` — exchange a valid, unexpired token for a freshly-signed one.
Expand Down
Loading
Loading