Skip to content

Commit 76283cd

Browse files
committed
feat: Limit number of check code attempts to 5
1 parent 6a0bd9d commit 76283cd

File tree

10 files changed

+322
-2
lines changed

10 files changed

+322
-2
lines changed

src/infrastructure/config.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ pub struct EnvConfig {
2525
pub max_sms_verifications_per_year: u32,
2626
#[serde(default)]
2727
pub sms_verifications_limit_whitelist: Vec<PhoneNumber>,
28+
/// Maximum number of code validation attempts per verification session.
29+
/// Prelude seems to fail silently after 5 failures regardless of how its configured.
30+
/// We count attempts here to guard against this.
31+
#[serde(default = "default_max_sms_validation_attempts")]
32+
pub max_sms_validation_attempts: u32,
2833
#[serde(default = "default_lightning_verification_price_sat")]
2934
pub lightning_invoice_price_sat: u64,
3035
#[serde(default = "default_lightning_verification_expiry_seconds")]
@@ -49,6 +54,10 @@ fn default_max_sms_verifications_per_year() -> u32 {
4954
4
5055
}
5156

57+
fn default_max_sms_validation_attempts() -> u32 {
58+
5
59+
}
60+
5261
fn default_lightning_verification_expiry_seconds() -> u64 {
5362
60 * 10
5463
}
@@ -93,6 +102,7 @@ impl EnvConfig {
93102
max_sms_verifications_per_week: 2,
94103
max_sms_verifications_per_year: 4,
95104
sms_verifications_limit_whitelist: vec![],
105+
max_sms_validation_attempts: 3,
96106
allow_cors: true,
97107
lightning_invoice_price_sat: 1000,
98108
lightning_invoice_expiry_seconds: 60 * 10,
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
use async_trait::async_trait;
2+
use sea_query::{ColumnDef, PostgresQueryBuilder, Table};
3+
use sqlx::Transaction;
4+
5+
use crate::infrastructure::sql::MigrationTrait;
6+
7+
pub struct M20260120AddValidationAttempts;
8+
9+
#[async_trait]
10+
impl MigrationTrait for M20260120AddValidationAttempts {
11+
async fn up(&self, tx: &mut Transaction<'static, sqlx::Postgres>) -> anyhow::Result<()> {
12+
let statement = Table::alter()
13+
.table("sms_verifications")
14+
.add_column(ColumnDef::new("attempts").integer().not_null().default(0))
15+
.to_owned();
16+
17+
let query = statement.build(PostgresQueryBuilder);
18+
sqlx::query(&query).execute(&mut **tx).await?;
19+
20+
Ok(())
21+
}
22+
23+
fn name(&self) -> &str {
24+
"m20260120_add_validation_attempts"
25+
}
26+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
pub mod m20251201_create_sms_verifications;
22
pub mod m20251216_create_ln_verification;
3+
pub mod m20260120_add_validation_attempts;

src/infrastructure/sql/migrator.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use crate::infrastructure::sql::{
77
migrations::{
88
m20251201_create_sms_verifications::M20251201CreateSmsVerifications,
99
m20251216_create_ln_verification::M20251216CreateLnVerification,
10+
m20260120_add_validation_attempts::M20260120AddValidationAttempts,
1011
},
1112
};
1213

@@ -31,6 +32,7 @@ impl<'a> Migrator<'a> {
3132
vec![
3233
Box::new(M20251201CreateSmsVerifications),
3334
Box::new(M20251216CreateLnVerification),
35+
Box::new(M20260120AddValidationAttempts),
3436
]
3537
}
3638

src/sms_verification/app_state.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ impl AppState {
2323
homeserver_admin_api.clone(),
2424
config.max_sms_verifications_per_week,
2525
config.max_sms_verifications_per_year,
26+
config.max_sms_validation_attempts,
2627
config.sms_verifications_limit_whitelist.clone(),
2728
);
2829
Self {

src/sms_verification/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ pub enum SmsVerificationError {
1818
#[error("No active verification session for phone number")]
1919
NoActiveVerification,
2020

21+
#[error("Too many incorrect code attempts. Please request a new verification code.")]
22+
MaxValidationAttemptsExceeded,
23+
2124
#[error("Invalid phone number format. Must be in E.164 format (e.g., +30123456789)")]
2225
InvalidPhoneNumber,
2326

src/sms_verification/http.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ impl IntoResponse for SmsVerificationError {
7878
SmsVerificationError::Blocked => StatusCode::FORBIDDEN,
7979
SmsVerificationError::WeeklyLimitExceeded => StatusCode::TOO_MANY_REQUESTS,
8080
SmsVerificationError::AnnualLimitExceeded => StatusCode::TOO_MANY_REQUESTS,
81+
SmsVerificationError::MaxValidationAttemptsExceeded => StatusCode::TOO_MANY_REQUESTS,
8182
SmsVerificationError::NoActiveVerification => StatusCode::UNPROCESSABLE_ENTITY,
8283
SmsVerificationError::InvalidPhoneNumber => StatusCode::UNPROCESSABLE_ENTITY,
8384
SmsVerificationError::RateLimited { retry_after } => {

src/sms_verification/repository.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ pub struct SmsVerificationEntity {
3838
pub signup_code: Option<String>,
3939
pub status: VerificationStatus,
4040
pub failure_reason: Option<String>,
41+
pub attempts: i32,
4142
}
4243

4344
#[derive(Clone, Debug)]
@@ -211,6 +212,29 @@ impl SmsVerificationRepository {
211212
}
212213
}
213214

215+
/// Increment attempts for the active session and return the new count.
216+
pub async fn increment_attempts(
217+
executor: &mut UnifiedExecutor<'_>,
218+
phone_number_hash: &str,
219+
) -> Result<i32, DbError> {
220+
let update_statement = Query::update()
221+
.table("sms_verifications")
222+
.value("attempts", Expr::col("attempts").add(1))
223+
.and_where(Expr::col("phone_number_hash").eq(phone_number_hash))
224+
.and_where(Expr::col("status").eq(VerificationStatus::Pending.as_str()))
225+
.returning(Query::returning().column("attempts"))
226+
.to_owned();
227+
228+
let (query, values) = update_statement.build_sqlx(PostgresQueryBuilder);
229+
let row = sqlx::query_with(&query, values)
230+
.fetch_one(executor.get_con().await?)
231+
.await
232+
.map_err(DbError::from)?;
233+
234+
let attempts: i32 = row.try_get("attempts").map_err(DbError::from)?;
235+
Ok(attempts)
236+
}
237+
214238
/// Verify an SMS by setting finalised_at, status, and signup_code
215239
pub async fn mark_verified(
216240
executor: &mut UnifiedExecutor<'_>,
@@ -317,6 +341,7 @@ impl SmsVerificationRepository {
317341
"signup_code",
318342
"status",
319343
"failure_reason",
344+
"attempts",
320345
])
321346
.from("sms_verifications")
322347
.and_where(Expr::col("phone_number_hash").eq(hashed_phone.as_str()))
@@ -345,6 +370,7 @@ impl SmsVerificationRepository {
345370
"signup_code",
346371
"status",
347372
"failure_reason",
373+
"attempts",
348374
])
349375
.from("sms_verifications")
350376
.and_where(Expr::col("prelude_id").eq(prelude_id))

src/sms_verification/service.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pub struct SmsVerificationService {
1818
hasher_argon2id: HasherArgon2id,
1919
max_verifications_per_week: u32,
2020
max_verifications_per_year: u32,
21+
max_validation_attempts: u32,
2122
limit_whitelist: Vec<PhoneNumber>,
2223
}
2324

@@ -27,6 +28,7 @@ impl SmsVerificationService {
2728
homeserver_admin_api: HomeserverAdminAPI,
2829
max_verifications_per_week: u32,
2930
max_verifications_per_year: u32,
31+
max_validation_attempts: u32,
3032
limit_whitelist: Vec<PhoneNumber>,
3133
) -> Self {
3234
if !limit_whitelist.is_empty() {
@@ -39,6 +41,7 @@ impl SmsVerificationService {
3941
hasher_argon2id: HasherArgon2id::new(),
4042
max_verifications_per_week,
4143
max_verifications_per_year,
44+
max_validation_attempts,
4245
limit_whitelist,
4346
}
4447
}
@@ -166,6 +169,23 @@ impl SmsVerificationService {
166169
.await
167170
.map_err(|_| SmsVerificationError::NoActiveVerification)?;
168171

172+
let attempts =
173+
SmsVerificationRepository::increment_attempts(&mut executor, &phone_number_hash)
174+
.await?;
175+
if attempts > self.max_validation_attempts as i32 {
176+
// Mark as failed since they've exceeded the limit
177+
if let Err(e) = SmsVerificationRepository::mark_all_pending_verification_as_failed(
178+
&mut executor,
179+
&phone_number_hash,
180+
"max_validation_attempts_exceeded",
181+
)
182+
.await
183+
{
184+
tracing::error!("{}", e);
185+
}
186+
return Err(SmsVerificationError::MaxValidationAttemptsExceeded);
187+
}
188+
169189
let prelude_response = self
170190
.prelude_api
171191
.check_code(&request.phone_number, &request.code)
@@ -197,7 +217,7 @@ impl SmsVerificationService {
197217
})
198218
}
199219
PreludeCheckCodeResponse::Failure { .. } => {
200-
// Wrong code - don't mark as failed, allow retries
220+
// Wrong code - don't mark as failed, allow retries (up to max_validation_attempts)
201221
Ok(ValidateCodeResponse::Invalid)
202222
}
203223
PreludeCheckCodeResponse::ExpiredOrNotFound { .. } => {

0 commit comments

Comments
 (0)