feat: enhance authentication with security stamp verification
This commit is contained in:
parent
59ddb23a83
commit
bcfd15ee91
3 changed files with 53 additions and 13 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -11,3 +11,7 @@ bw_web_builds/
|
|||
|
||||
# Generated seed SQL (global equivalent domains)
|
||||
sql/global_domains_seed.sql
|
||||
|
||||
# Coding assistant
|
||||
.codex/
|
||||
.claude/
|
||||
28
src/auth.rs
28
src/auth.rs
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
|
|
|
|||
Loading…
Reference in a new issue