mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
feat(admin): admin session JWTs + admin_sessions queries [operator §0.3]
Admin tokens are cryptographically distinct from user tokens: a dedicated ADMIN_JWT_SECRET, a distinct audience (smirk-admin) plus an explicit scope, and the centralized strict Validation (HS256 pinned, zero leeway, aud required) — Validation::default() is never used on this path. Access TTL 15 min (carries a jti recorded in the session row, so a soft-revoked admin's still-valid token is rejected at the next call), refresh TTL 8 h (binds a session id). - core::admin_session: AdminSessionManager (mint/verify access+refresh) with the four required regressions + scope check: user token fails admin decode; wrong/absent aud fails; HS512 (alg confusion) fails; expired fails at zero leeway; non-admin scope fails. - core::session::strict_validation made pub(crate) and shared (no drift). - db::admin_sessions: create, find-active-by-jti (guard), find-active-by-id + rotate-jti (refresh), revoke (logout), revoke-all-for-key (key cascade), and a dead-session sweep. Lookups return only live (unrevoked, unexpired) rows. - NewAdminSession model; 2 L1 tests (lookup/rotate/logout, key->session cascade).
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
//! Admin session JWTs — cryptographically distinct from user sessions.
|
||||
//!
|
||||
//! Admin tokens are signed with a dedicated `ADMIN_JWT_SECRET` and carry a
|
||||
//! distinct audience (`smirk-admin`) plus an explicit `scope`, so a user token
|
||||
//! can never be presented on the admin plane and vice versa. Verification reuses
|
||||
//! the centralized strict [`crate::core::session::strict_validation`] (HS256
|
||||
//! pinned, zero leeway, `aud` required) — `Validation::default()` is never used.
|
||||
//! Access tokens are short-lived (15 min) and carry a `jti` recorded in the
|
||||
//! `admin_sessions` row, so a soft-revoked admin's still-valid token is rejected
|
||||
//! at the next call; refresh tokens (8 h) bind a session id.
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::core::session::strict_validation;
|
||||
use crate::error::AppError;
|
||||
|
||||
const ADMIN_ACCESS_AUD: &str = "smirk-admin";
|
||||
const ADMIN_REFRESH_AUD: &str = "smirk-admin-refresh";
|
||||
const ADMIN_SCOPE: &str = "admin";
|
||||
const ACCESS_TTL_MINUTES: i64 = 15;
|
||||
const REFRESH_TTL_HOURS: i64 = 8;
|
||||
|
||||
/// Admin access-token claims. `sub` is the admin **pubkey** (not a UUID) so the
|
||||
/// live allowlist re-check in the guard is meaningful.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AdminAccessClaims {
|
||||
pub sub: String,
|
||||
pub aud: String,
|
||||
pub scope: String,
|
||||
pub jti: String,
|
||||
pub iat: i64,
|
||||
pub exp: i64,
|
||||
}
|
||||
|
||||
/// Admin refresh-token claims. `sid` is the `admin_sessions` row id.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AdminRefreshClaims {
|
||||
pub sub: String,
|
||||
pub aud: String,
|
||||
pub sid: String,
|
||||
pub iat: i64,
|
||||
pub exp: i64,
|
||||
}
|
||||
|
||||
/// Decoded admin access token: the pubkey to re-authorize and the `jti` to look
|
||||
/// up the live session row.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AdminAccessInfo {
|
||||
pub pubkey: String,
|
||||
pub jti: String,
|
||||
}
|
||||
|
||||
/// A freshly minted admin token pair. `access_jti` must be recorded in the
|
||||
/// session row so the guard can match (and revocation can invalidate) it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AdminTokenPair {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub access_jti: String,
|
||||
pub expires_in: i64,
|
||||
}
|
||||
|
||||
/// Mints and verifies admin JWTs under a dedicated secret.
|
||||
#[derive(Clone)]
|
||||
pub struct AdminSessionManager {
|
||||
encoding_key: EncodingKey,
|
||||
decoding_key: DecodingKey,
|
||||
access_validation: jsonwebtoken::Validation,
|
||||
refresh_validation: jsonwebtoken::Validation,
|
||||
}
|
||||
|
||||
impl AdminSessionManager {
|
||||
/// `admin_jwt_secret` must be >= 32 bytes (enforced by config validation when
|
||||
/// the admin surface is enabled).
|
||||
pub fn new(admin_jwt_secret: &str) -> Self {
|
||||
Self {
|
||||
encoding_key: EncodingKey::from_secret(admin_jwt_secret.as_bytes()),
|
||||
decoding_key: DecodingKey::from_secret(admin_jwt_secret.as_bytes()),
|
||||
access_validation: strict_validation(ADMIN_ACCESS_AUD),
|
||||
refresh_validation: strict_validation(ADMIN_REFRESH_AUD),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mint an access + refresh pair for `admin_pubkey`, binding the refresh
|
||||
/// token to `session_id`. Returns the pair plus the access `jti` to persist.
|
||||
pub fn create_token_pair(
|
||||
&self,
|
||||
admin_pubkey: &str,
|
||||
session_id: Uuid,
|
||||
) -> Result<AdminTokenPair, AppError> {
|
||||
let now = Utc::now();
|
||||
let jti = Uuid::new_v4().to_string();
|
||||
let header = Header::new(Algorithm::HS256);
|
||||
|
||||
let access = AdminAccessClaims {
|
||||
sub: admin_pubkey.to_string(),
|
||||
aud: ADMIN_ACCESS_AUD.to_string(),
|
||||
scope: ADMIN_SCOPE.to_string(),
|
||||
jti: jti.clone(),
|
||||
iat: now.timestamp(),
|
||||
exp: (now + Duration::minutes(ACCESS_TTL_MINUTES)).timestamp(),
|
||||
};
|
||||
let access_token = encode(&header, &access, &self.encoding_key)
|
||||
.map_err(|e| AppError::Internal(format!("admin access token: {e}")))?;
|
||||
|
||||
let refresh = AdminRefreshClaims {
|
||||
sub: admin_pubkey.to_string(),
|
||||
aud: ADMIN_REFRESH_AUD.to_string(),
|
||||
sid: session_id.to_string(),
|
||||
iat: now.timestamp(),
|
||||
exp: (now + Duration::hours(REFRESH_TTL_HOURS)).timestamp(),
|
||||
};
|
||||
let refresh_token = encode(&header, &refresh, &self.encoding_key)
|
||||
.map_err(|e| AppError::Internal(format!("admin refresh token: {e}")))?;
|
||||
|
||||
Ok(AdminTokenPair {
|
||||
access_token,
|
||||
refresh_token,
|
||||
access_jti: jti,
|
||||
expires_in: ACCESS_TTL_MINUTES * 60,
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify an admin access token. Beyond the strict `aud` check, the `scope`
|
||||
/// is re-asserted. The error is a literal (never the inner Display) so the
|
||||
/// token cannot become an oracle.
|
||||
pub fn verify_access(&self, token: &str) -> Result<AdminAccessInfo, AppError> {
|
||||
let data = decode::<AdminAccessClaims>(token, &self.decoding_key, &self.access_validation)
|
||||
.map_err(|_| AppError::AuthError("Invalid or expired admin token".into()))?;
|
||||
if data.claims.scope != ADMIN_SCOPE {
|
||||
return Err(AppError::AuthError("Invalid or expired admin token".into()));
|
||||
}
|
||||
Ok(AdminAccessInfo {
|
||||
pubkey: data.claims.sub,
|
||||
jti: data.claims.jti,
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify an admin refresh token, returning `(admin_pubkey, session_id)`.
|
||||
pub fn verify_refresh(&self, token: &str) -> Result<(String, Uuid), AppError> {
|
||||
let data =
|
||||
decode::<AdminRefreshClaims>(token, &self.decoding_key, &self.refresh_validation)
|
||||
.map_err(|_| AppError::AuthError("Invalid or expired admin token".into()))?;
|
||||
let sid = Uuid::parse_str(&data.claims.sid)
|
||||
.map_err(|_| AppError::AuthError("Invalid or expired admin token".into()))?;
|
||||
Ok((data.claims.sub, sid))
|
||||
}
|
||||
|
||||
pub fn access_ttl_secs(&self) -> i64 {
|
||||
ACCESS_TTL_MINUTES * 60
|
||||
}
|
||||
|
||||
pub fn refresh_ttl(&self) -> Duration {
|
||||
Duration::hours(REFRESH_TTL_HOURS)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::core::session::{Platform, SessionManager};
|
||||
|
||||
const ADMIN_SECRET: &str = "admin-jwt-secret-at-least-32-bytes-long!!";
|
||||
const PK: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
|
||||
#[test]
|
||||
fn mint_and_verify_roundtrip() {
|
||||
let m = AdminSessionManager::new(ADMIN_SECRET);
|
||||
let sid = Uuid::new_v4();
|
||||
let pair = m.create_token_pair(PK, sid).unwrap();
|
||||
|
||||
let info = m.verify_access(&pair.access_token).unwrap();
|
||||
assert_eq!(info.pubkey, PK);
|
||||
assert_eq!(info.jti, pair.access_jti);
|
||||
|
||||
let (pk, got_sid) = m.verify_refresh(&pair.refresh_token).unwrap();
|
||||
assert_eq!(pk, PK);
|
||||
assert_eq!(got_sid, sid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_access_token_fails_admin_decode() {
|
||||
// A user token (distinct secret + audience) must not verify as admin.
|
||||
let user = SessionManager::new("user-jwt-secret-at-least-32-bytes-long!", 24);
|
||||
let pair = user
|
||||
.create_token_pair(Uuid::new_v4(), Platform::Web, Uuid::new_v4())
|
||||
.unwrap();
|
||||
let admin = AdminSessionManager::new(ADMIN_SECRET);
|
||||
assert!(admin.verify_access(&pair.access_token).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_access_not_accepted_as_refresh() {
|
||||
let m = AdminSessionManager::new(ADMIN_SECRET);
|
||||
let pair = m.create_token_pair(PK, Uuid::new_v4()).unwrap();
|
||||
// Distinct audiences within the admin secret too.
|
||||
assert!(m.verify_refresh(&pair.access_token).is_err());
|
||||
assert!(m.verify_access(&pair.refresh_token).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_audience_fails() {
|
||||
// A token with the right secret/alg but a foreign audience must fail.
|
||||
let claims = AdminAccessClaims {
|
||||
sub: PK.into(),
|
||||
aud: "some-other-aud".into(),
|
||||
scope: ADMIN_SCOPE.into(),
|
||||
jti: Uuid::new_v4().to_string(),
|
||||
iat: Utc::now().timestamp(),
|
||||
exp: (Utc::now() + Duration::minutes(5)).timestamp(),
|
||||
};
|
||||
let token = encode(
|
||||
&Header::new(Algorithm::HS256),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(ADMIN_SECRET.as_bytes()),
|
||||
)
|
||||
.unwrap();
|
||||
let m = AdminSessionManager::new(ADMIN_SECRET);
|
||||
assert!(m.verify_access(&token).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_algorithm_fails() {
|
||||
// HS512 token must be rejected (HS256 pinned, no alg confusion).
|
||||
let claims = AdminAccessClaims {
|
||||
sub: PK.into(),
|
||||
aud: ADMIN_ACCESS_AUD.into(),
|
||||
scope: ADMIN_SCOPE.into(),
|
||||
jti: Uuid::new_v4().to_string(),
|
||||
iat: Utc::now().timestamp(),
|
||||
exp: (Utc::now() + Duration::minutes(5)).timestamp(),
|
||||
};
|
||||
let token = encode(
|
||||
&Header::new(Algorithm::HS512),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(ADMIN_SECRET.as_bytes()),
|
||||
)
|
||||
.unwrap();
|
||||
let m = AdminSessionManager::new(ADMIN_SECRET);
|
||||
assert!(m.verify_access(&token).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_token_fails_with_zero_leeway() {
|
||||
let claims = AdminAccessClaims {
|
||||
sub: PK.into(),
|
||||
aud: ADMIN_ACCESS_AUD.into(),
|
||||
scope: ADMIN_SCOPE.into(),
|
||||
jti: Uuid::new_v4().to_string(),
|
||||
iat: (Utc::now() - Duration::hours(1)).timestamp(),
|
||||
exp: (Utc::now() - Duration::minutes(1)).timestamp(),
|
||||
};
|
||||
let token = encode(
|
||||
&Header::new(Algorithm::HS256),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(ADMIN_SECRET.as_bytes()),
|
||||
)
|
||||
.unwrap();
|
||||
let m = AdminSessionManager::new(ADMIN_SECRET);
|
||||
assert!(m.verify_access(&token).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_must_be_admin() {
|
||||
// Right aud/alg/secret but a non-admin scope is rejected.
|
||||
let claims = AdminAccessClaims {
|
||||
sub: PK.into(),
|
||||
aud: ADMIN_ACCESS_AUD.into(),
|
||||
scope: "user".into(),
|
||||
jti: Uuid::new_v4().to_string(),
|
||||
iat: Utc::now().timestamp(),
|
||||
exp: (Utc::now() + Duration::minutes(5)).timestamp(),
|
||||
};
|
||||
let token = encode(
|
||||
&Header::new(Algorithm::HS256),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(ADMIN_SECRET.as_bytes()),
|
||||
)
|
||||
.unwrap();
|
||||
let m = AdminSessionManager::new(ADMIN_SECRET);
|
||||
assert!(m.verify_access(&token).is_err());
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Functions take their inputs (config, decoded bytes) as parameters, keeping
|
||||
//! this layer straightforward to test in isolation.
|
||||
|
||||
pub mod admin_session;
|
||||
pub mod crypto;
|
||||
pub mod pow;
|
||||
pub mod secret;
|
||||
|
||||
+4
-1
@@ -148,7 +148,10 @@ pub struct SessionManager {
|
||||
refresh_token_expiry_days: i64,
|
||||
}
|
||||
|
||||
fn strict_validation(audience: &str) -> Validation {
|
||||
/// Centralized strict JWT validation: HS256 pinned (no alg-confusion), zero
|
||||
/// leeway, `exp`/`sub`/`aud` required, and the audience fixed. Shared by the user
|
||||
/// and admin session managers so neither can drift to `Validation::default()`.
|
||||
pub(crate) fn strict_validation(audience: &str) -> Validation {
|
||||
let mut v = Validation::new(Algorithm::HS256);
|
||||
v.leeway = 0;
|
||||
v.validate_exp = true;
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Admin session queries.
|
||||
//!
|
||||
//! Admin sessions live in their own table (never user `sessions`). The guard
|
||||
//! looks a session up by the access token's `jti`; refresh and logout act by
|
||||
//! session id; revoking a key cascades to every session it owns. A lookup only
|
||||
//! returns a session that is neither revoked nor expired.
|
||||
|
||||
use tracing::instrument;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::db::{AdminSession, NewAdminSession};
|
||||
|
||||
use super::Database;
|
||||
|
||||
const COLS: &str = "id, admin_key_id, pubkey, refresh_token_hash, access_jti, \
|
||||
device_info, ip_address, created_at, expires_at, revoked_at";
|
||||
|
||||
impl Database {
|
||||
/// Persist a new admin session.
|
||||
#[instrument(skip(self, input), fields(admin_key_id = %input.admin_key_id))]
|
||||
pub async fn create_admin_session(
|
||||
&self,
|
||||
input: NewAdminSession,
|
||||
) -> Result<AdminSession, AppError> {
|
||||
let sql = format!(
|
||||
"INSERT INTO admin_sessions \
|
||||
(id, admin_key_id, pubkey, refresh_token_hash, access_jti, device_info, ip_address, expires_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING {COLS}"
|
||||
);
|
||||
let s = sqlx::query_as::<_, AdminSession>(&sql)
|
||||
.bind(input.id)
|
||||
.bind(input.admin_key_id)
|
||||
.bind(&input.pubkey)
|
||||
.bind(&input.refresh_token_hash)
|
||||
.bind(&input.access_jti)
|
||||
.bind(&input.device_info)
|
||||
.bind(input.ip_address)
|
||||
.bind(input.expires_at)
|
||||
.fetch_one(self.pool())
|
||||
.await?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// The live session whose access token carries `jti` (not revoked/expired).
|
||||
#[instrument(skip(self, jti))]
|
||||
pub async fn find_active_admin_session_by_jti(
|
||||
&self,
|
||||
jti: &str,
|
||||
) -> Result<Option<AdminSession>, AppError> {
|
||||
let sql = format!(
|
||||
"SELECT {COLS} FROM admin_sessions \
|
||||
WHERE access_jti = $1 AND revoked_at IS NULL AND expires_at > NOW()"
|
||||
);
|
||||
let s = sqlx::query_as::<_, AdminSession>(&sql)
|
||||
.bind(jti)
|
||||
.fetch_optional(self.pool())
|
||||
.await?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// The live session with this id (used by the refresh path).
|
||||
#[instrument(skip(self))]
|
||||
pub async fn find_active_admin_session_by_id(
|
||||
&self,
|
||||
id: Uuid,
|
||||
) -> Result<Option<AdminSession>, AppError> {
|
||||
let sql = format!(
|
||||
"SELECT {COLS} FROM admin_sessions \
|
||||
WHERE id = $1 AND revoked_at IS NULL AND expires_at > NOW()"
|
||||
);
|
||||
let s = sqlx::query_as::<_, AdminSession>(&sql)
|
||||
.bind(id)
|
||||
.fetch_optional(self.pool())
|
||||
.await?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Rotate the access `jti` on refresh (the old access token stops matching).
|
||||
/// Returns the updated row, or `None` if the session is no longer live.
|
||||
#[instrument(skip(self, new_jti))]
|
||||
pub async fn rotate_admin_session_jti(
|
||||
&self,
|
||||
id: Uuid,
|
||||
new_jti: &str,
|
||||
) -> Result<Option<AdminSession>, AppError> {
|
||||
let sql = format!(
|
||||
"UPDATE admin_sessions SET access_jti = $2 \
|
||||
WHERE id = $1 AND revoked_at IS NULL AND expires_at > NOW() RETURNING {COLS}"
|
||||
);
|
||||
let s = sqlx::query_as::<_, AdminSession>(&sql)
|
||||
.bind(id)
|
||||
.bind(new_jti)
|
||||
.fetch_optional(self.pool())
|
||||
.await?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Revoke a single session (logout). Returns true if a live row was revoked.
|
||||
#[instrument(skip(self))]
|
||||
pub async fn revoke_admin_session(&self, id: Uuid) -> Result<bool, AppError> {
|
||||
let res = sqlx::query(
|
||||
"UPDATE admin_sessions SET revoked_at = NOW() \
|
||||
WHERE id = $1 AND revoked_at IS NULL",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
Ok(res.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Revoke every live session owned by an admin key (key-revocation cascade).
|
||||
/// Returns the number revoked.
|
||||
#[instrument(skip(self))]
|
||||
pub async fn revoke_admin_sessions_for_key(&self, admin_key_id: Uuid) -> Result<u64, AppError> {
|
||||
let res = sqlx::query(
|
||||
"UPDATE admin_sessions SET revoked_at = NOW() \
|
||||
WHERE admin_key_id = $1 AND revoked_at IS NULL",
|
||||
)
|
||||
.bind(admin_key_id)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
|
||||
/// Delete expired/revoked admin sessions older than now (hygiene sweep).
|
||||
#[instrument(skip(self))]
|
||||
pub async fn delete_dead_admin_sessions(&self) -> Result<u64, AppError> {
|
||||
let res = sqlx::query(
|
||||
"DELETE FROM admin_sessions WHERE expires_at <= NOW() OR revoked_at IS NOT NULL",
|
||||
)
|
||||
.execute(self.pool())
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
//! reaches a column and the peppering cannot drift between call sites.
|
||||
|
||||
mod admin_keys;
|
||||
mod admin_sessions;
|
||||
mod audit;
|
||||
mod challenges;
|
||||
mod grin_slatepacks;
|
||||
|
||||
@@ -280,6 +280,20 @@ pub struct NewAdminKey {
|
||||
pub activation_deadline: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Input for an admin session row. The id and access_jti are generated by the
|
||||
/// caller (the refresh token embeds the id, the access token embeds the jti).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewAdminSession {
|
||||
pub id: Uuid,
|
||||
pub admin_key_id: Uuid,
|
||||
pub pubkey: String,
|
||||
pub refresh_token_hash: String,
|
||||
pub access_jti: String,
|
||||
pub device_info: Option<String>,
|
||||
pub ip_address: Option<IpNetwork>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewAuditLog {
|
||||
pub user_id: Option<Uuid>,
|
||||
|
||||
Reference in New Issue
Block a user