feat: enhance authentication with security stamp verification

This commit is contained in:
qaz741wsd856 2025-12-30 11:49:57 +00:00
parent 59ddb23a83
commit bcfd15ee91
3 changed files with 53 additions and 13 deletions

4
.gitignore vendored
View file

@ -11,3 +11,7 @@ bw_web_builds/
# Generated seed SQL (global equivalent domains)
sql/global_domains_seed.sql
# Coding assistant
.codex/
.claude/

View file

@ -2,18 +2,21 @@ use axum::{
extract::FromRequestParts,
http::{header, request::Parts},
};
use constant_time_eq::constant_time_eq;
use jsonwebtoken::{decode, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use worker::Env;
use crate::db;
use crate::error::AppError;
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String, // User ID
pub exp: usize, // Expiration time
pub nbf: usize, // Not before time
pub sub: String, // User ID
pub exp: usize, // Expiration time
pub nbf: usize, // Not before time
pub sstamp: String, // Security stamp
pub premium: bool,
pub name: String,
@ -31,7 +34,7 @@ pub struct AuthUser(
impl FromRequestParts<Arc<Env>> for Claims {
type Rejection = AppError;
#[worker::send]
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<Env>,
@ -55,7 +58,22 @@ impl FromRequestParts<Arc<Env>> for Claims {
let token_data = decode::<Claims>(&token, &decoding_key, &Validation::default())
.map_err(|_| AppError::Unauthorized("Invalid token".to_string()))?;
Ok(token_data.claims)
let claims = token_data.claims;
let db = db::get_db(state)?;
let current_sstamp = db
.prepare("SELECT security_stamp FROM users WHERE id = ?1")
.bind(&[claims.sub.clone().into()])?
.first::<String>(Some("security_stamp"))
.await
.map_err(|_| AppError::Database)?
.ok_or_else(|| AppError::Unauthorized("Invalid token".to_string()))?;
if !constant_time_eq(claims.sstamp.as_bytes(), current_sstamp.as_bytes()) {
return Err(AppError::Unauthorized("Invalid token".to_string()));
}
Ok(claims)
}
}

View file

@ -1,5 +1,6 @@
use axum::{extract::State, Form, Json};
use chrono::{Duration, Utc};
use constant_time_eq::constant_time_eq;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
@ -112,6 +113,19 @@ pub struct UserDecryptionOptions {
pub object: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct RefreshClaims {
pub sub: String, // User ID
pub exp: usize, // Expiration time
pub nbf: usize, // Not before time
// NOTE: We intentionally do NOT implement refresh token rotation / replay detection here.
// Vaultwarden's default auth flow also doesn't do rotation/reuse detection; in this project we
// currently avoid device/session management. If we later add minimal device state, we can add
// refresh token rotation (jti/family) + reuse detection on top.
pub sstamp: String,
}
fn generate_tokens_and_response(
user: User,
env: &Arc<Env>,
@ -125,6 +139,7 @@ fn generate_tokens_and_response(
sub: user.id.clone(),
exp,
nbf: now.timestamp() as usize,
sstamp: user.security_stamp.clone(),
premium: true,
name: user.name.clone().unwrap_or_else(|| "User".to_string()),
email: user.email.clone(),
@ -141,15 +156,11 @@ fn generate_tokens_and_response(
let refresh_expires_in = Duration::days(30);
let refresh_exp = (now + refresh_expires_in).timestamp() as usize;
let refresh_claims = Claims {
sub: user.id.clone(),
let refresh_claims = RefreshClaims {
sub: user.id,
exp: refresh_exp,
nbf: now.timestamp() as usize,
premium: true,
name: user.name.unwrap_or_else(|| "User".to_string()),
email: user.email.clone(),
email_verified: true,
amr: vec!["Application".into()],
sstamp: user.security_stamp,
};
let jwt_refresh_secret = env.secret("JWT_REFRESH_SECRET")?.to_string();
let refresh_token = encode(
@ -480,7 +491,7 @@ pub async fn token(
.ok_or_else(|| AppError::BadRequest("Missing refresh_token".to_string()))?;
let jwt_refresh_secret = env.secret("JWT_REFRESH_SECRET")?.to_string();
let token_data = decode::<Claims>(
let token_data = decode::<RefreshClaims>(
&refresh_token,
&DecodingKey::from_secret(jwt_refresh_secret.as_ref()),
&Validation::default(),
@ -497,6 +508,13 @@ pub async fn token(
.ok_or_else(|| AppError::Unauthorized("Invalid user".to_string()))?;
let user: User = serde_json::from_value(user).map_err(|_| AppError::Internal)?;
if !constant_time_eq(
token_data.claims.sstamp.as_bytes(),
user.security_stamp.as_bytes(),
) {
return Err(AppError::Unauthorized("Invalid refresh token".to_string()));
}
generate_tokens_and_response(user, &env, None)
}
_ => Err(AppError::BadRequest("Unsupported grant_type".to_string())),