257 lines
8.5 KiB
Rust
257 lines
8.5 KiB
Rust
use base64::Engine;
|
|
use hmac::{Hmac, KeyInit, Mac};
|
|
use models::UserId;
|
|
use session::Session;
|
|
use sha2::Sha256;
|
|
|
|
use crate::AppTransport;
|
|
use crate::error::AppTransportError;
|
|
|
|
type HmacSha256 = Hmac<Sha256>;
|
|
|
|
const VERSION: u8 = 0;
|
|
const TOKEN_TTL_SECS: u64 = 600;
|
|
const SESSION_TTL_SECS: u64 = 1800;
|
|
const MAX_LIFETIME_SECS: i64 = 3000;
|
|
|
|
const TOKEN_PREFIX: &str = "token:access:";
|
|
const SESSION_PREFIX: &str = "transport:session:";
|
|
|
|
/// Stateless access token — 10min TTL, session lookup key only.
|
|
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
|
pub struct AppTransportToken {
|
|
pub access_token: String,
|
|
}
|
|
|
|
/// Device/client binding for multi-device login distinction.
|
|
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
|
pub struct AppTransportTokenApply {
|
|
pub client_id: String,
|
|
pub device_id: String,
|
|
}
|
|
|
|
/// Validated token context — WS/HTTP identity, no session internals exposed.
|
|
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
|
|
pub struct AppTransportTokenContext {
|
|
pub user_id: UserId,
|
|
pub device_id: String,
|
|
pub client_id: String,
|
|
}
|
|
|
|
/// Token binary payload (local decode, no Redis roundtrip):
|
|
/// [0] version (1 byte)
|
|
/// [1..17] user_id (16 bytes, UUID big-endian)
|
|
/// [17..25] created_at (8 bytes, i64 Unix secs big-endian)
|
|
/// [25..57] HMAC-SHA256(bytes[0..25])
|
|
struct TokenPayload {
|
|
user_id: UserId,
|
|
created_at: i64,
|
|
}
|
|
|
|
impl TokenPayload {
|
|
const LEN: usize = 57;
|
|
|
|
fn encode(&self, signing_key: &[u8]) -> Result<Vec<u8>, AppTransportError> {
|
|
let mut buf = Vec::with_capacity(Self::LEN);
|
|
buf.push(VERSION);
|
|
buf.extend_from_slice(self.user_id.as_bytes());
|
|
buf.extend_from_slice(&self.created_at.to_be_bytes());
|
|
|
|
let tag = hmac_sign(signing_key, &buf)?;
|
|
buf.extend_from_slice(&tag);
|
|
|
|
Ok(buf)
|
|
}
|
|
|
|
fn decode(bytes: &[u8], signing_key: &[u8]) -> Result<Self, AppTransportError> {
|
|
if bytes.len() != Self::LEN || bytes[0] != VERSION {
|
|
return Err(AppTransportError::TokenInvalidOrExpired);
|
|
}
|
|
|
|
let expected_tag = hmac_sign(signing_key, &bytes[..25])?;
|
|
if !constant_time_eq(&expected_tag, &bytes[25..]) {
|
|
return Err(AppTransportError::TokenInvalidOrExpired);
|
|
}
|
|
|
|
let user_id_bytes: [u8; 16] = bytes[1..17]
|
|
.try_into()
|
|
.map_err(|_: std::array::TryFromSliceError| AppTransportError::TokenInvalidOrExpired)?;
|
|
let user_id = UserId::from_bytes(user_id_bytes);
|
|
let created_at_bytes: [u8; 8] = bytes[17..25]
|
|
.try_into()
|
|
.map_err(|_: std::array::TryFromSliceError| AppTransportError::TokenInvalidOrExpired)?;
|
|
let created_at = i64::from_be_bytes(created_at_bytes);
|
|
|
|
Ok(TokenPayload {
|
|
user_id,
|
|
created_at,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl AppTransport {
|
|
fn signing_key(&self) -> Result<[u8; 32], AppTransportError> {
|
|
let secret = self
|
|
.config
|
|
.env
|
|
.get("APP_SESSION_SECRET")
|
|
.map(|s| s.as_str())
|
|
.ok_or(AppTransportError::Internal)?;
|
|
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
|
|
.map_err(|_| AppTransportError::Internal)?;
|
|
mac.update(b"transport-access-token-signing-key");
|
|
let result = mac.finalize().into_bytes();
|
|
Ok(result.into())
|
|
}
|
|
|
|
fn session_hash_key(&self, user_id: &UserId, created_at: i64) -> String {
|
|
format!("{}{}:{}", SESSION_PREFIX, user_id, created_at)
|
|
}
|
|
|
|
fn token_redis_key(&self, token_str: &str) -> String {
|
|
format!("{}{}", TOKEN_PREFIX, token_str)
|
|
}
|
|
|
|
/// Apply access token: session→user_id, generate HMAC token, bind in Redis.
|
|
/// token:access:{token} → session_hash_key (TTL=10min)
|
|
/// transport:session:{user_id}:{created_at} → Hash (TTL=30min)
|
|
pub async fn apply_transport_access_token(
|
|
&self,
|
|
session: Session,
|
|
apply: AppTransportTokenApply,
|
|
) -> Result<AppTransportToken, AppTransportError> {
|
|
let user_id = session.user().ok_or(AppTransportError::Unauthorized)?;
|
|
|
|
let created_at = chrono::Utc::now().timestamp();
|
|
let signing_key = self.signing_key()?;
|
|
|
|
let payload = TokenPayload {
|
|
user_id,
|
|
created_at,
|
|
};
|
|
let token_bytes = payload.encode(&signing_key)?;
|
|
let access_token = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&token_bytes);
|
|
|
|
let session_key = self.session_hash_key(&user_id, created_at);
|
|
let token_key = self.token_redis_key(&access_token);
|
|
|
|
let mut pipe = redis::Pipeline::new();
|
|
pipe.hset(&session_key, "device_id", &apply.device_id)
|
|
.hset(&session_key, "client_id", &apply.client_id)
|
|
.expire(&session_key, SESSION_TTL_SECS as i64)
|
|
.set_ex(&token_key, &session_key, TOKEN_TTL_SECS);
|
|
|
|
let mut conn = self
|
|
.cache
|
|
.conn()
|
|
.await
|
|
.map_err(|_| AppTransportError::Internal)?;
|
|
pipe.query_async::<()>(&mut conn)
|
|
.await
|
|
.map_err(|_| AppTransportError::Internal)?;
|
|
|
|
Ok(AppTransportToken { access_token })
|
|
}
|
|
|
|
/// Validate access_token: local decode (HMAC) → Redis HGETALL session → user context.
|
|
/// No online status check, no TTL inference.
|
|
pub async fn check_access_token(
|
|
&self,
|
|
access_token: String,
|
|
) -> Result<AppTransportTokenContext, AppTransportError> {
|
|
let token_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
|
.decode(&access_token)
|
|
.map_err(|_| AppTransportError::TokenInvalidOrExpired)?;
|
|
|
|
let signing_key = self.signing_key()?;
|
|
let payload = TokenPayload::decode(&token_bytes, &signing_key)?;
|
|
|
|
let elapsed = chrono::Utc::now().timestamp() - payload.created_at;
|
|
if elapsed > MAX_LIFETIME_SECS {
|
|
return Err(AppTransportError::TokenInvalidOrExpired);
|
|
}
|
|
|
|
let session_key = self.session_hash_key(&payload.user_id, payload.created_at);
|
|
|
|
let mut conn = self
|
|
.cache
|
|
.conn()
|
|
.await
|
|
.map_err(|_| AppTransportError::Internal)?;
|
|
let hash_data: std::collections::HashMap<String, String> = redis::Cmd::new()
|
|
.arg("HGETALL")
|
|
.arg(&session_key)
|
|
.query_async(&mut conn)
|
|
.await
|
|
.map_err(|_| AppTransportError::Internal)?;
|
|
|
|
let device_id = hash_data
|
|
.get("device_id")
|
|
.cloned()
|
|
.ok_or(AppTransportError::TokenInvalidOrExpired)?;
|
|
let client_id = hash_data
|
|
.get("client_id")
|
|
.cloned()
|
|
.ok_or(AppTransportError::TokenInvalidOrExpired)?;
|
|
|
|
Ok(AppTransportTokenContext {
|
|
user_id: payload.user_id,
|
|
device_id,
|
|
client_id,
|
|
})
|
|
}
|
|
|
|
/// Renew access_token TTL — no token value change, max 10min, renew cap 50min.
|
|
pub async fn renew_access_token(
|
|
&self,
|
|
access_token: String,
|
|
) -> Result<AppTransportToken, AppTransportError> {
|
|
let token_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
|
.decode(&access_token)
|
|
.map_err(|_| AppTransportError::TokenInvalidOrExpired)?;
|
|
|
|
let signing_key = self.signing_key()?;
|
|
let payload = TokenPayload::decode(&token_bytes, &signing_key)?;
|
|
|
|
let elapsed = chrono::Utc::now().timestamp() - payload.created_at;
|
|
if elapsed > MAX_LIFETIME_SECS {
|
|
return Err(AppTransportError::RenewalLimitExceeded);
|
|
}
|
|
|
|
let session_key = self.session_hash_key(&payload.user_id, payload.created_at);
|
|
let token_key = self.token_redis_key(&access_token);
|
|
|
|
let mut pipe = redis::Pipeline::new();
|
|
pipe.expire(&session_key, SESSION_TTL_SECS as i64)
|
|
.expire(&token_key, TOKEN_TTL_SECS as i64);
|
|
|
|
let mut conn = self
|
|
.cache
|
|
.conn()
|
|
.await
|
|
.map_err(|_| AppTransportError::Internal)?;
|
|
pipe.query_async::<()>(&mut conn)
|
|
.await
|
|
.map_err(|_| AppTransportError::Internal)?;
|
|
|
|
Ok(AppTransportToken { access_token })
|
|
}
|
|
}
|
|
|
|
fn hmac_sign(key: &[u8], payload: &[u8]) -> Result<[u8; 32], AppTransportError> {
|
|
let mut mac = HmacSha256::new_from_slice(key).map_err(|_| AppTransportError::Internal)?;
|
|
mac.update(payload);
|
|
Ok(mac.finalize().into_bytes().into())
|
|
}
|
|
|
|
fn constant_time_eq(expected: &[u8; 32], actual: &[u8]) -> bool {
|
|
if actual.len() != 32 {
|
|
return false;
|
|
}
|
|
let mut diff = 0u8;
|
|
for i in 0..32 {
|
|
diff |= expected[i] ^ actual[i];
|
|
}
|
|
diff == 0
|
|
}
|