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)
|
# Generated seed SQL (global equivalent domains)
|
||||||
sql/global_domains_seed.sql
|
sql/global_domains_seed.sql
|
||||||
|
|
||||||
|
# Coding assistant
|
||||||
|
.codex/
|
||||||
|
.claude/
|
||||||
22
src/auth.rs
22
src/auth.rs
|
|
@ -2,11 +2,13 @@ use axum::{
|
||||||
extract::FromRequestParts,
|
extract::FromRequestParts,
|
||||||
http::{header, request::Parts},
|
http::{header, request::Parts},
|
||||||
};
|
};
|
||||||
|
use constant_time_eq::constant_time_eq;
|
||||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use worker::Env;
|
use worker::Env;
|
||||||
|
|
||||||
|
use crate::db;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
|
@ -14,6 +16,7 @@ pub struct Claims {
|
||||||
pub sub: String, // User ID
|
pub sub: String, // User ID
|
||||||
pub exp: usize, // Expiration time
|
pub exp: usize, // Expiration time
|
||||||
pub nbf: usize, // Not before time
|
pub nbf: usize, // Not before time
|
||||||
|
pub sstamp: String, // Security stamp
|
||||||
|
|
||||||
pub premium: bool,
|
pub premium: bool,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
@ -31,7 +34,7 @@ pub struct AuthUser(
|
||||||
|
|
||||||
impl FromRequestParts<Arc<Env>> for Claims {
|
impl FromRequestParts<Arc<Env>> for Claims {
|
||||||
type Rejection = AppError;
|
type Rejection = AppError;
|
||||||
|
#[worker::send]
|
||||||
async fn from_request_parts(
|
async fn from_request_parts(
|
||||||
parts: &mut Parts,
|
parts: &mut Parts,
|
||||||
state: &Arc<Env>,
|
state: &Arc<Env>,
|
||||||
|
|
@ -55,7 +58,22 @@ impl FromRequestParts<Arc<Env>> for Claims {
|
||||||
let token_data = decode::<Claims>(&token, &decoding_key, &Validation::default())
|
let token_data = decode::<Claims>(&token, &decoding_key, &Validation::default())
|
||||||
.map_err(|_| AppError::Unauthorized("Invalid token".to_string()))?;
|
.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 axum::{extract::State, Form, Json};
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
|
use constant_time_eq::constant_time_eq;
|
||||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||||
use serde::{Deserialize, Deserializer, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
@ -112,6 +113,19 @@ pub struct UserDecryptionOptions {
|
||||||
pub object: String,
|
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(
|
fn generate_tokens_and_response(
|
||||||
user: User,
|
user: User,
|
||||||
env: &Arc<Env>,
|
env: &Arc<Env>,
|
||||||
|
|
@ -125,6 +139,7 @@ fn generate_tokens_and_response(
|
||||||
sub: user.id.clone(),
|
sub: user.id.clone(),
|
||||||
exp,
|
exp,
|
||||||
nbf: now.timestamp() as usize,
|
nbf: now.timestamp() as usize,
|
||||||
|
sstamp: user.security_stamp.clone(),
|
||||||
premium: true,
|
premium: true,
|
||||||
name: user.name.clone().unwrap_or_else(|| "User".to_string()),
|
name: user.name.clone().unwrap_or_else(|| "User".to_string()),
|
||||||
email: user.email.clone(),
|
email: user.email.clone(),
|
||||||
|
|
@ -141,15 +156,11 @@ fn generate_tokens_and_response(
|
||||||
|
|
||||||
let refresh_expires_in = Duration::days(30);
|
let refresh_expires_in = Duration::days(30);
|
||||||
let refresh_exp = (now + refresh_expires_in).timestamp() as usize;
|
let refresh_exp = (now + refresh_expires_in).timestamp() as usize;
|
||||||
let refresh_claims = Claims {
|
let refresh_claims = RefreshClaims {
|
||||||
sub: user.id.clone(),
|
sub: user.id,
|
||||||
exp: refresh_exp,
|
exp: refresh_exp,
|
||||||
nbf: now.timestamp() as usize,
|
nbf: now.timestamp() as usize,
|
||||||
premium: true,
|
sstamp: user.security_stamp,
|
||||||
name: user.name.unwrap_or_else(|| "User".to_string()),
|
|
||||||
email: user.email.clone(),
|
|
||||||
email_verified: true,
|
|
||||||
amr: vec!["Application".into()],
|
|
||||||
};
|
};
|
||||||
let jwt_refresh_secret = env.secret("JWT_REFRESH_SECRET")?.to_string();
|
let jwt_refresh_secret = env.secret("JWT_REFRESH_SECRET")?.to_string();
|
||||||
let refresh_token = encode(
|
let refresh_token = encode(
|
||||||
|
|
@ -480,7 +491,7 @@ pub async fn token(
|
||||||
.ok_or_else(|| AppError::BadRequest("Missing refresh_token".to_string()))?;
|
.ok_or_else(|| AppError::BadRequest("Missing refresh_token".to_string()))?;
|
||||||
|
|
||||||
let jwt_refresh_secret = env.secret("JWT_REFRESH_SECRET")?.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,
|
&refresh_token,
|
||||||
&DecodingKey::from_secret(jwt_refresh_secret.as_ref()),
|
&DecodingKey::from_secret(jwt_refresh_secret.as_ref()),
|
||||||
&Validation::default(),
|
&Validation::default(),
|
||||||
|
|
@ -497,6 +508,13 @@ pub async fn token(
|
||||||
.ok_or_else(|| AppError::Unauthorized("Invalid user".to_string()))?;
|
.ok_or_else(|| AppError::Unauthorized("Invalid user".to_string()))?;
|
||||||
let user: User = serde_json::from_value(user).map_err(|_| AppError::Internal)?;
|
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)
|
generate_tokens_and_response(user, &env, None)
|
||||||
}
|
}
|
||||||
_ => Err(AppError::BadRequest("Unsupported grant_type".to_string())),
|
_ => Err(AppError::BadRequest("Unsupported grant_type".to_string())),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue