Files
tuliprox/backend/auth/src/authenticator.rs
T
euzuandGitHub 91f6c57159 Fix/merge live and vod recording (#876)
feat(dvr)!: unify live and VOD/series recording into one recoverable DVR

Merge fix/merge_live_and_vod_recording into develop.

- One recording queue for scheduled live captures and VOD/series transfers,
  with a shared transition graph, provider-slot scheduling, resumable HTTP
  transfers (strong ETag / Last-Modified validation) and ffmpeg live capture.
- Crash-recoverable persistence: B+Tree repository with a journal-first
  recovery history; one file can be shared by several library entries.
- Admission checks quota and disk on request, at transfer start (real size)
  and before an entry attaches to a finished file; idempotent requests.
- REST is command-only; state reaches clients via owner-filtered WebSocket
  snapshots.
- Deleting a recording removes the file once no entry holds it; removing an
  entry keeps a finished file and cleans up orphaned partials.
- User ids derive from the configured username (web:/api:); the identity
  registry is gone.
- Spanish locale added; ru/ar completed; locale parity test.

BREAKING CHANGE: `video.download` is now `video.recording` (nested
`recording` keys move up, `download_priority`/`recording_priority` become
`priority`); old configs fail to load with a migration hint.
BREAKING CHANGE: `/api/v1/file/download*` and `/file/record` are removed in
favour of `/api/v1/recording/*`.
BREAKING CHANGE: `download.*` and `recording.write` permissions are replaced
by `recording.read|create|manage|delete`; tokens from before the change must
re-authenticate.
BREAKING CHANGE: the old recording queue does not migrate; existing
`identity_registry.json` is ignored.
2026-10-04 16:02:55 +02:00

418 lines
18 KiB
Rust

use chrono::{Duration, Local};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, TokenData, Validation};
use log::warn;
use shared::{
error::to_io_error,
model::{
permission::{PermissionSet, PERM_ALL},
Claims, Role, RoleSet, UserId, CURRENT_PERMISSION_SCHEMA_VERSION,
},
};
use tuliprox_core::model::WebAuthConfig;
/// Hard ceiling on a minted token's lifetime.
///
/// A token this server issues is a bearer credential with no revocation list
/// behind it, so its lifetime is the entire blast radius of a leak.
pub const MAX_TOKEN_TTL_MINS: u32 = 60 * 24 * 30; // 30 days
/// Lifetime used when the config asks for an unbounded token.
///
/// `token_ttl_mins: 0` used to mean "expire in 100 years", which is a
/// permanent credential written as if it were a configuration convenience.
pub const DEFAULT_TOKEN_TTL_MINS: u32 = 60 * 24; // 24 hours
/// Clamp a configured TTL into something a bearer token may actually carry.
fn effective_ttl_mins(configured: u32) -> u32 {
if configured == 0 {
warn!(
"web_ui.auth.token_ttl_mins is 0; issuing {DEFAULT_TOKEN_TTL_MINS}-minute tokens instead of \
non-expiring ones. Set an explicit value to silence this."
);
DEFAULT_TOKEN_TTL_MINS
} else if configured > MAX_TOKEN_TTL_MINS {
warn!("web_ui.auth.token_ttl_mins of {configured} exceeds the {MAX_TOKEN_TTL_MINS} minute ceiling; clamped.");
MAX_TOKEN_TTL_MINS
} else {
configured
}
}
pub fn create_jwt_admin(
web_auth_config: &WebAuthConfig,
username: &str,
pwd_version: u32,
) -> Result<String, std::io::Error> {
create_jwt(web_auth_config, username, RoleSet::ADMIN, PERM_ALL, pwd_version, Some(UserId::builtin_admin()))
}
/// `subject_id` is [`UserId::api`] of the configured username.
pub fn create_jwt_api_user(
web_auth_config: &WebAuthConfig,
username: &str,
subject_id: UserId,
) -> Result<String, std::io::Error> {
create_jwt(web_auth_config, username, RoleSet::API_USER, PermissionSet::new(), 0, Some(subject_id))
}
/// `subject_id` is [`UserId::web`] of the configured username.
pub fn create_jwt_web_user(
web_auth_config: &WebAuthConfig,
username: &str,
permissions: PermissionSet,
pwd_version: u32,
subject_id: UserId,
) -> Result<String, std::io::Error> {
create_jwt(web_auth_config, username, RoleSet::new(), permissions, pwd_version, Some(subject_id))
}
fn create_jwt(
web_auth_config: &WebAuthConfig,
username: &str,
roles: RoleSet,
permissions: PermissionSet,
pwd_version: u32,
subject_id: Option<UserId>,
) -> Result<String, std::io::Error> {
let mut header = Header::new(Algorithm::HS256);
header.typ = Some("JWT".to_string());
let now = Local::now();
let iat = now.timestamp();
let exp = (now + Duration::minutes(i64::from(effective_ttl_mins(web_auth_config.token_ttl_mins)))).timestamp();
let claims = Claims {
username: username.to_string(),
iss: web_auth_config.issuer.clone(),
iat,
exp,
roles,
permissions,
pwd_version,
subject_id,
permission_schema_version: CURRENT_PERMISSION_SCHEMA_VERSION,
};
match encode(&header, &claims, &EncodingKey::from_secret(web_auth_config.secret.as_bytes())) {
Ok(jwt) => Ok(jwt),
Err(err) => Err(to_io_error(err)),
}
}
/// Verify a token's signature, expiry **and issuer**.
///
/// `iss` was written into every token and then never looked at:
/// `Validation::new` checks `exp` and nothing else. A token minted by a
/// different deployment that happened to share the secret verified cleanly.
pub fn verify_token(token: &str, secret_key: &[u8], issuer: &str) -> Option<TokenData<Claims>> {
let mut validation = Validation::new(Algorithm::HS256);
validation.set_issuer(&[issuer]);
decode::<Claims>(token, &DecodingKey::from_secret(secret_key), &validation).ok()
}
/// The material needed to verify a token, for call sites that hold it across
/// an await or a task boundary and so cannot borrow the live config.
///
/// The WebSocket paths used to carry a bare `Vec<u8>` secret, which is exactly
/// why they could not check the issuer.
#[derive(Debug, Clone)]
pub struct TokenVerifier {
secret: Vec<u8>,
issuer: String,
}
impl TokenVerifier {
pub fn new(secret: impl Into<Vec<u8>>, issuer: impl Into<String>) -> Self {
Self { secret: secret.into(), issuer: issuer.into() }
}
pub fn from_config(config: &WebAuthConfig) -> Self { Self::new(config.secret.as_bytes(), config.issuer.clone()) }
pub fn verify(&self, token: &str) -> Option<TokenData<Claims>> { verify_token(token, &self.secret, &self.issuer) }
}
fn has_role(token_data: Option<TokenData<Claims>>, role: Role) -> bool {
token_data.is_some_and(|data| data.claims.roles.contains(role))
}
pub fn is_admin(token_data: Option<TokenData<Claims>>) -> bool { has_role(token_data, Role::Admin) }
pub fn is_api_user(token_data: Option<TokenData<Claims>>) -> bool { has_role(token_data, Role::ApiUser) }
/// Stable error type for the validators. A stable
/// "token-refresh-required" response lets the frontend sign out
/// without guessing. The HTTP layer returns 401 with an
/// `X-Token-Refresh: required` header for refresh-required cases.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum AuthError {
/// Token signature/issuer/exp invalid, malformed, or otherwise
/// unverifiable. The frontend should sign the user out.
#[error("token is invalid or expired")]
InvalidToken,
/// Token signature is valid but it carries an old or absent
/// `permission_schema_version`. The frontend must refresh
/// credentials to receive a token at the current schema.
#[error("token was issued for an older permission schema; refresh required")]
StaleSchema,
/// Token signature is valid but it lacks a `subject_id`. The
/// identity-registry-bound principal cannot be resolved.
#[error("token is missing a subject_id; refresh required")]
MissingSubject,
/// Token signature is valid but it was minted against a password that has
/// since changed - or it carries no password version at all. Either way
/// the principal must authenticate again; a refresh cannot help, because
/// the refresh endpoint applies the same check.
#[error("token was issued for a different password; sign in again")]
PasswordChanged,
/// Token signature is valid but it was issued before a revocation that
/// covers it - the principal was signed out, or every session was.
#[error("token has been revoked; sign in again")]
Revoked,
/// Token signature is valid but the principal has the wrong
/// role/permission for the requested endpoint.
#[error("principal does not have the required role")]
Forbidden,
}
impl AuthError {
/// `true` when the frontend should request a fresh token before
/// retrying the request. Stale-schema and missing-subject both
/// qualify; a re-auth round-trip is required.
pub fn is_token_refresh_required(self) -> bool { matches!(self, Self::StaleSchema | Self::MissingSubject) }
}
/// Validate a verified token's `permission_schema_version` and
/// `subject_id`. Both must be present and current. Returns
/// [`AuthError::StaleSchema`] when the schema is below the current
/// version, and [`AuthError::MissingSubject`] when the
/// `subject_id` is `None`. A token that passes both checks is fit
/// for downstream permission and authorization checks.
pub fn validate_token_claims(claims: &Claims) -> Result<(), AuthError> {
if claims.permission_schema_version < CURRENT_PERMISSION_SCHEMA_VERSION {
return Err(AuthError::StaleSchema);
}
if claims.subject_id.is_none() {
return Err(AuthError::MissingSubject);
}
Ok(())
}
/// Validate a token's `pwd_version` against the principal's current one.
///
/// `pwd_version` was minted into every web token and then checked in exactly
/// one place - the refresh endpoint - which meant changing a password did not
/// invalidate any live token on any guarded route. Combined with the old
/// "0 means non-expiring" TTL, a leaked token was a permanent credential.
///
/// A `pwd_version` of `0` is rejected rather than waved through. The refresh
/// endpoint used to treat `0` as "skip the check", which made the check
/// bypassable by anything that could mint or replay a zero-versioned token.
///
/// Call this only for principals that actually have a password on file - the
/// web users in `web_ui.auth`. Proxy API users authenticate against
/// `api_proxy.yml` credentials and carry no password version.
pub fn validate_password_version(claims: &Claims, current_pwd_version: u32) -> Result<(), AuthError> {
if claims.pwd_version == 0 || claims.pwd_version != current_pwd_version {
return Err(AuthError::PasswordChanged);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use shared::model::{permission::Permission, Claims, Role, RoleSet, UserId, CURRENT_PERMISSION_SCHEMA_VERSION};
use tuliprox_core::model::WebAuthConfig;
fn test_web_auth_config() -> WebAuthConfig {
WebAuthConfig {
enabled: true,
issuer: "tuliprox-test".to_string(),
secret: "test-secret".to_string(),
token_ttl_mins: 60,
userfile: None,
groupfile: None,
t_users: None,
t_groups: None,
}
}
#[test]
fn admin_jwt_carries_builtin_admin_subject_id_and_current_schema_version() {
let cfg = test_web_auth_config();
let jwt = create_jwt_admin(&cfg, "any", 1).expect("admin jwt");
let secret = cfg.secret.as_bytes();
let data = verify_token(&jwt, secret, &cfg.issuer).expect("verify");
assert_eq!(data.claims.username, "any");
assert_eq!(data.claims.subject_id, Some(UserId::builtin_admin()));
assert!(data.claims.is_admin());
assert!(data.claims.permissions.contains(Permission::ConfigRead));
assert!(data.claims.permissions.contains(Permission::RecordingRead));
assert!(data.claims.permissions.contains(Permission::RecordingManage));
assert_eq!(data.claims.permission_schema_version, CURRENT_PERMISSION_SCHEMA_VERSION);
}
#[test]
fn web_user_jwt_carries_web_namespaced_subject_id() {
let cfg = test_web_auth_config();
let jwt = create_jwt_web_user(
&cfg,
"alice",
Permission::ConfigRead | Permission::RecordingRead,
0,
UserId::from("web:alice-uuid"),
)
.expect("web jwt");
let data = verify_token(&jwt, cfg.secret.as_bytes(), &cfg.issuer).expect("verify");
assert_eq!(data.claims.username, "alice");
assert_eq!(data.claims.subject_id, Some(UserId::from("web:alice-uuid")));
assert!(!data.claims.is_admin());
assert!(data.claims.permissions.contains(Permission::RecordingRead));
assert!(!data.claims.permissions.contains(Permission::RecordingManage));
}
#[test]
fn api_user_jwt_carries_api_namespaced_subject_id() {
let cfg = test_web_auth_config();
let jwt = create_jwt_api_user(&cfg, "bob", UserId::from("api:bob-uuid")).expect("api jwt");
let data = verify_token(&jwt, cfg.secret.as_bytes(), &cfg.issuer).expect("verify");
assert_eq!(data.claims.subject_id, Some(UserId::from("api:bob-uuid")));
assert!(data.claims.is_api_user());
assert!(data.claims.permissions.is_empty());
}
#[test]
fn stale_schema_token_is_rejected() {
// Manually craft a Claims payload that simulates a token issued
// before the schema bump. The validator must mark it
// refresh-required.
let cfg = test_web_auth_config();
let jwt = create_jwt_admin(&cfg, "any", 0).expect("admin jwt");
let mut data = verify_token(&jwt, cfg.secret.as_bytes(), &cfg.issuer).expect("verify");
data.claims.permission_schema_version = 0; // stale
let err = validate_token_claims(&data.claims).unwrap_err();
assert!(matches!(err, AuthError::StaleSchema));
assert!(err.is_token_refresh_required());
}
#[test]
fn missing_subject_token_is_rejected() {
// A token that survives signature verification but has no
// `subject_id` must be rejected. The validator flags it as
// refresh-required.
let cfg = test_web_auth_config();
let jwt = create_jwt_admin(&cfg, "any", 0).expect("admin jwt");
let mut data = verify_token(&jwt, cfg.secret.as_bytes(), &cfg.issuer).expect("verify");
data.claims.subject_id = None;
let err = validate_token_claims(&data.claims).unwrap_err();
assert!(matches!(err, AuthError::MissingSubject));
assert!(err.is_token_refresh_required());
}
#[test]
fn forged_signature_is_invalid_token() {
let cfg = test_web_auth_config();
let jwt = create_jwt_admin(&cfg, "any", 0).expect("admin jwt");
let mut tampered = jwt.clone();
// Flip a character in the signature segment.
let last = tampered.pop().unwrap();
tampered.push(if last == 'A' { 'B' } else { 'A' });
assert!(verify_token(&tampered, cfg.secret.as_bytes(), &cfg.issuer).is_none());
}
#[test]
fn owner_id_never_serialized_into_token_payloads() {
// No request body may carry an `owner_id`. This test asserts
// the inverse: a JWT payload never exposes an `owner_id`
// field directly. The owner
// identity is captured only via `subject_id` and the
// permission set.
let cfg = test_web_auth_config();
let jwt = create_jwt_admin(&cfg, "any", 0).expect("admin jwt");
let data = verify_token(&jwt, cfg.secret.as_bytes(), &cfg.issuer).expect("verify");
let json = serde_json::to_string(&data.claims).expect("serialize");
assert!(!json.contains("owner_id"), "JWT must not carry an owner_id field; got: {json}");
assert!(json.contains("subject_id"), "JWT must carry subject_id: {json}");
}
#[test]
fn token_from_another_issuer_does_not_verify() {
// `iss` was minted and never checked. It is checked now.
let cfg = test_web_auth_config();
let jwt = create_jwt_admin(&cfg, "any", 1).expect("admin jwt");
assert!(verify_token(&jwt, cfg.secret.as_bytes(), "tuliprox-test").is_some());
assert!(verify_token(&jwt, cfg.secret.as_bytes(), "someone-else").is_none());
}
#[test]
fn zero_ttl_config_no_longer_mints_a_century_long_token() {
let mut cfg = test_web_auth_config();
cfg.token_ttl_mins = 0;
let jwt = create_jwt_admin(&cfg, "any", 1).expect("admin jwt");
let data = verify_token(&jwt, cfg.secret.as_bytes(), &cfg.issuer).expect("verify");
let lifetime_mins = (data.claims.exp - data.claims.iat) / 60;
assert_eq!(lifetime_mins, i64::from(DEFAULT_TOKEN_TTL_MINS));
}
#[test]
fn oversized_ttl_is_clamped_to_the_ceiling() {
let mut cfg = test_web_auth_config();
cfg.token_ttl_mins = MAX_TOKEN_TTL_MINS * 10;
let jwt = create_jwt_admin(&cfg, "any", 1).expect("admin jwt");
let data = verify_token(&jwt, cfg.secret.as_bytes(), &cfg.issuer).expect("verify");
let lifetime_mins = (data.claims.exp - data.claims.iat) / 60;
assert_eq!(lifetime_mins, i64::from(MAX_TOKEN_TTL_MINS));
}
#[test]
fn password_version_mismatch_is_rejected() {
let cfg = test_web_auth_config();
let jwt = create_jwt_admin(&cfg, "any", 7).expect("admin jwt");
let data = verify_token(&jwt, cfg.secret.as_bytes(), &cfg.issuer).expect("verify");
assert!(validate_password_version(&data.claims, 7).is_ok());
assert_eq!(validate_password_version(&data.claims, 8), Err(AuthError::PasswordChanged));
}
#[test]
fn zero_password_version_is_rejected_rather_than_waved_through() {
// The refresh endpoint used to read `pwd_version == 0` as "skip the
// check", which made the check bypassable.
let cfg = test_web_auth_config();
let jwt = create_jwt_admin(&cfg, "any", 0).expect("admin jwt");
let data = verify_token(&jwt, cfg.secret.as_bytes(), &cfg.issuer).expect("verify");
assert_eq!(validate_password_version(&data.claims, 0), Err(AuthError::PasswordChanged));
assert_eq!(validate_password_version(&data.claims, 5), Err(AuthError::PasswordChanged));
}
#[test]
fn is_token_refresh_required_classifier() {
assert!(AuthError::StaleSchema.is_token_refresh_required());
assert!(AuthError::MissingSubject.is_token_refresh_required());
assert!(!AuthError::InvalidToken.is_token_refresh_required());
assert!(!AuthError::Forbidden.is_token_refresh_required());
// A changed password cannot be fixed by a refresh - the refresh
// endpoint applies the same check. The client must sign in again.
assert!(!AuthError::PasswordChanged.is_token_refresh_required());
}
#[test]
fn claims_pwd_version_and_subject_id_round_trip() {
// The round-trip preserves the new fields through the
// shared `Claims` type, so a token issued today can be
// validated and the schema info surfaced to the validator.
let claims = Claims {
username: "alice".to_string(),
iss: "tuliprox".to_string(),
iat: 100,
exp: 200,
roles: RoleSet::from(Role::Admin),
permissions: Permission::ConfigRead.into(),
pwd_version: 7,
subject_id: Some(UserId::from("web:alice")),
permission_schema_version: CURRENT_PERMISSION_SCHEMA_VERSION,
};
let json = serde_json::to_string(&claims).expect("serialize");
let restored: Claims = serde_json::from_str(&json).expect("deserialize");
assert_eq!(restored.subject_id, claims.subject_id);
assert_eq!(restored.permission_schema_version, claims.permission_schema_version);
assert_eq!(restored.pwd_version, 7);
}
}