Skip to content

Commit

Permalink
Add eth-password interpretation
Browse files Browse the repository at this point in the history
The ETH sends their series credentials as SHA1 encoded
strings including a username and password.
This commit adds a config option that causes these
credentials to be separated and stored in Tobira's DB
when enabled.
To authenticate, users will need to enter these credentials,
which will then be hashed and checked against the ones we
have from the ETH.
  • Loading branch information
owi92 committed Sep 12, 2024
1 parent 68b298f commit a68dd84
Show file tree
Hide file tree
Showing 10 changed files with 134 additions and 38 deletions.
12 changes: 12 additions & 0 deletions backend/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ tracing-subscriber = "0.3.18"
reqwest = "0.12.4"
subtp = "0.2.0"
xmlparser = "0.13.6"
sha1 = "0.10.6"

[target.'cfg(target_os = "linux")'.dependencies]
procfs = "0.16.0"
Expand Down
48 changes: 27 additions & 21 deletions backend/src/api/model/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use postgres_types::ToSql;
use serde::{Serialize, Deserialize};
use tokio_postgres::Row;
use juniper::{GraphQLObject, graphql_object};
use sha1::{Sha1, Digest};

use crate::{
api::{
Expand All @@ -13,7 +14,7 @@ use crate::{
model::{acl::{self, Acl}, realm::Realm, series::Series},
},
db::{
types::{EventCaption, EventSegment, EventState, EventTrack, ExtraMetadata, Key},
types::{EventCaption, EventSegment, EventState, EventTrack, ExtraMetadata, Key, SeriesCredentials},
util::{impl_from_db, select},
},
prelude::*,
Expand All @@ -38,6 +39,7 @@ pub(crate) struct AuthorizedEvent {
pub(crate) read_roles: Vec<String>,
pub(crate) write_roles: Vec<String>,
pub(crate) preview_roles: Vec<String>,
pub(crate) credentials: Option<SeriesCredentials>,

pub(crate) synced_data: Option<SyncedEventData>,
pub(crate) authorized_data: Option<AuthorizedEventData>,
Expand Down Expand Up @@ -70,7 +72,7 @@ impl_from_db!(
title, description, duration, creators, thumbnail, metadata,
created, updated, start_time, end_time,
tracks, captions, segments,
read_roles, write_roles, preview_roles,
read_roles, write_roles, preview_roles, credentials,
tobira_deletion_timestamp,
},
},
Expand All @@ -88,6 +90,7 @@ impl_from_db!(
read_roles: row.read_roles::<Vec<String>>(),
write_roles: row.write_roles::<Vec<String>>(),
preview_roles: row.preview_roles::<Vec<String>>(),
credentials: row.credentials(),
tobira_deletion_timestamp: row.tobira_deletion_timestamp(),
synced_data: match row.state::<EventState>() {
EventState::Ready => Some(SyncedEventData {
Expand Down Expand Up @@ -219,20 +222,32 @@ impl AuthorizedEvent {
&self.preview_roles
}

fn credentials(&self) -> &Option<SeriesCredentials> {
&self.credentials
}

fn synced_data(&self) -> &Option<SyncedEventData> {
&self.synced_data
}

async fn authorized_data(&self, context: &Context, user: Option<String>, password: Option<String>) -> Option<&AuthorizedEventData> {
// TODO: replace with hashed credentials from db, add actual comparison check with hashed user inputs
let expected_user = "plane";
let expected_pw = "bird";
async fn authorized_data(
&self,
context: &Context,
user: Option<String>,
password: Option<String>,
) -> Option<&AuthorizedEventData> {
let sha1_matches = |input: &str, encoded: &str| {
encoded.strip_prefix("sha1:").map_or(false, |encoded_str|
encoded_str == hex::encode(Sha1::digest(input)).to_uppercase()
)
};

let matches = self.has_password(context).await.unwrap_or(false)
&& user.map_or(false, |u| u == expected_user)
&& password.map_or(false, |p| p == expected_pw);
let credentials_match = self.credentials.as_ref().map_or(false, |credentials| {
user.map_or(false, |u| sha1_matches(&u, &credentials.name))
&& password.map_or(false, |p| sha1_matches(&p, &credentials.password))
});

if context.auth.overlaps_roles(&self.read_roles) || matches {
if context.auth.overlaps_roles(&self.read_roles) || credentials_match {
self.authorized_data.as_ref()
} else {
None
Expand Down Expand Up @@ -276,8 +291,8 @@ impl AuthorizedEvent {


/// Whether this event is password protected.
async fn has_password(&self, context: &Context) -> ApiResult<bool> {
self.has_password(context).await
async fn has_password(&self) -> bool {
self.credentials.is_some()
}

async fn acl(&self, context: &Context) -> ApiResult<Acl> {
Expand Down Expand Up @@ -336,15 +351,6 @@ impl AuthorizedEvent {
Self::load_by_any_id_impl("opencast_id", &oc_id, context).await
}

/// Whether this event is password protected.
async fn has_password(&self, context: &Context) -> ApiResult<bool> {
let query = format!("select credentials is not null from all_events where id = $1");
context.db.query_one(&query, &[&self.key])
.await?
.get::<_, bool>(0)
.pipe(Ok)
}

pub(crate) async fn load_by_any_id_impl(
col: &str,
id: &(dyn ToSql + Sync),
Expand Down
14 changes: 14 additions & 0 deletions backend/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use cookie::Cookie;
use deadpool_postgres::Client;
use hyper::{http::HeaderValue, HeaderMap, Request, StatusCode};
use once_cell::sync::Lazy;
use regex::Regex;
use secrecy::ExposeSecret;
use serde::Deserialize;
use tokio_postgres::Error as PgError;
Expand Down Expand Up @@ -37,6 +38,19 @@ pub(crate) use self::{
/// administrator.
pub(crate) const ROLE_ADMIN: &str = "ROLE_ADMIN";

/// Role used to define username and password for series (used in events).
/// In Tobira, these are stored separately during sync and the role isn't used
/// afterwards. Therefore it should be filtered out.
pub(crate) static ROLE_CREDENTIALS_RE: Lazy<Regex> = Lazy::new(|| Regex::new(
r"^ROLE_GROUP_([a-fA-F0-9]{40})_([a-fA-F0-9]{40})$"
).unwrap());

/// Role used in Admin UI to show the above credentials for some series.
/// This is not used in Tobira and should be filtered out.
pub(crate) static ROLE_PASSWORD_RE: Lazy<Regex> = Lazy::new(|| Regex::new(
r"^ROLE_PWD_[a-fA-F0-9+/]*={0,2}$"
).unwrap());

const ROLE_ANONYMOUS: &str = "ROLE_ANONYMOUS";
const ROLE_USER: &str = "ROLE_USER";

Expand Down
9 changes: 8 additions & 1 deletion backend/src/db/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use std::{fmt, collections::HashMap};

use bytes::BytesMut;
use chrono::{DateTime, Utc};
use juniper::GraphQLEnum;
use juniper::{GraphQLEnum, GraphQLObject};
use postgres_types::{FromSql, ToSql};
use serde::{Deserialize, Serialize};

Expand Down Expand Up @@ -240,3 +240,10 @@ impl<'a> FromSql<'a> for CustomActions {
<serde_json::Value as FromSql>::accepts(ty)
}
}

#[derive(Debug, ToSql, FromSql, GraphQLObject)]
#[postgres(name = "credentials")]
pub(crate) struct SeriesCredentials {
pub(crate) name: String,
pub(crate) password: String,
}
46 changes: 40 additions & 6 deletions backend/src/sync/harvest/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ use std::{
use tokio_postgres::types::ToSql;

use crate::{
auth::ROLE_ADMIN,
auth::{ROLE_ADMIN, ROLE_CREDENTIALS_RE, ROLE_PASSWORD_RE},
config::Config,
db::{
self,
DbConnection,
types::{EventCaption, EventSegment, EventState, EventTrack, SeriesState},
types::{EventCaption, EventSegment, EventState, EventTrack, SeriesCredentials, SeriesState},
},
prelude::*,
};
Expand Down Expand Up @@ -88,7 +88,7 @@ pub(crate) async fn run(
// everything worked out alright.
let last_updated = harvest_data.items.last().map(|item| item.updated());
let mut transaction = db.transaction().await?;
store_in_db(harvest_data.items, &sync_status, &mut transaction).await?;
store_in_db(harvest_data.items, &sync_status, &mut transaction, config).await?;
SyncStatus::update_harvested_until(harvest_data.includes_items_until, &*transaction).await?;
transaction.commit().await?;

Expand Down Expand Up @@ -132,6 +132,7 @@ async fn store_in_db(
items: Vec<HarvestItem>,
sync_status: &SyncStatus,
db: &mut deadpool_postgres::Transaction<'_>,
config: &Config,
) -> Result<()> {
let before = Instant::now();
let mut upserted_events = 0;
Expand Down Expand Up @@ -180,11 +181,27 @@ async fn store_in_db(
},
};

let series_credentials = config.sync.interpret_eth_passwords
.then(|| hashed_credentials(&acl.read))
.flatten();

if series_credentials.is_some() {
acl.preview = acl.read.clone();
acl.read = acl.write.clone();
}

let filter_role = |role: &String| -> bool {
!(role == ROLE_ADMIN
|| ROLE_CREDENTIALS_RE.is_match(role)
|| ROLE_PASSWORD_RE.is_match(role)
)
};

// We always handle the admin role in a special way, so no need
// to store it for every single event.
acl.preview.retain(|role| role != ROLE_ADMIN);
acl.read.retain(|role| role != ROLE_ADMIN);
acl.write.retain(|role| role != ROLE_ADMIN);
acl.preview.retain(|role| filter_role(role));
acl.read.retain(|role| filter_role(role));
acl.write.retain(|role| filter_role(role));

for (_, roles) in &mut acl.custom_actions.0 {
roles.retain(|role| role != ROLE_ADMIN);
Expand Down Expand Up @@ -219,6 +236,7 @@ async fn store_in_db(
("captions", &captions),
("segments", &segments),
("slide_text", &slide_text),
("credentials", &series_credentials),
]).await?;

trace!("Inserted or updated event {} ({})", opencast_id, title);
Expand Down Expand Up @@ -381,6 +399,22 @@ fn check_affected_rows_removed(rows_affected: u64, entity: &str, opencast_id: &s
}
}

fn hashed_credentials(read_roles: &Vec<String>) -> Option<SeriesCredentials> {
read_roles.iter().find_map(|role| {
if let Some(captures) = ROLE_CREDENTIALS_RE.captures(role) {
let hashed_user = format!("sha1:{}", &captures[1]);
let hashed_password = format!("sha1:{}", &captures[2]);

Some(SeriesCredentials {
name: hashed_user,
password: hashed_password,
})
} else {
None
}
})
}

/// Inserts a new row or updates an existing one if the value in `unique_col`
/// already exists. Returns the value of the `id` column, which is assumed to
/// be `i64`.
Expand Down
7 changes: 7 additions & 0 deletions backend/src/sync/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ pub(crate) struct SyncConfig {
#[config(default = "30s", deserialize_with = crate::config::deserialize_duration)]
pub(crate) poll_period: Duration,

/// Whether SHA1-hashed series passwords (as assignable by ETH's admin UI build) are interpreted in Tobira.
/// These are initially special roles that are composited of a name and a password.
/// When this option is enabled, encrypted credentials for an event are stored in DB and access to the video
/// will be restricted accordingly.
#[config(default = false)]
pub(crate) interpret_eth_passwords: bool,

/// Number of concurrent tasks with which Tobira downloads assets from
/// Opencast. The default should be a good sweet spot. Decrease to reduce
/// load on Opencast, increase to speed up download a bit.
Expand Down
8 changes: 8 additions & 0 deletions docs/docs/setup/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,14 @@
# Default value: "30s"
#poll_period = "30s"

# Whether SHA1-hashed series passwords (as assignable by ETH's admin UI build) are interpreted in Tobira.
# These are initially special roles that are composited of a name and a password.
# When this option is enabled, encrypted credentials for an event are stored in DB and access to the video
# will be restricted accordingly.
#
# Default value: false
#interpret_eth_passwords = false

# Number of concurrent tasks with which Tobira downloads assets from
# Opencast. The default should be a good sweet spot. Decrease to reduce
# load on Opencast, increase to speed up download a bit.
Expand Down
26 changes: 16 additions & 10 deletions frontend/src/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,9 @@ enum VideoListOrder {
ORIGINAL
}

input Filters {
itemType: ItemType
start: DateTimeUtc
end: DateTimeUtc
type SeriesCredentials {
name: String!
password: String!
}

"A block just showing some text."
Expand All @@ -73,15 +72,17 @@ scalar Cursor
"""
union SearchOutcome = SearchUnavailable | EmptyQuery | SearchResults

input Filters {
itemType: ItemType
start: DateTimeUtc
end: DateTimeUtc
}

type KnownUser {
displayName: String!
userRole: String!
}

type RemovedRealm {
parent: Realm
}

type SearchEvent implements Node {
id: ID!
seriesId: ID
Expand All @@ -106,8 +107,8 @@ input ChildIndex {
index: Int!
}

input NewTextBlock {
content: String!
type RemovedRealm {
parent: Realm
}

"Represents an Opencast series."
Expand Down Expand Up @@ -212,6 +213,10 @@ type SyncedEventData implements Node {
duration: Float!
}

input NewTextBlock {
content: String!
}

input NewVideoBlock {
event: ID!
showTitle: Boolean!
Expand Down Expand Up @@ -307,6 +312,7 @@ type AuthorizedEvent implements Node {
writeRoles: [String!]!
"Implicitly includes all read and write roles."
previewRoles: [String!]!
credentials: SeriesCredentials
syncedData: SyncedEventData
authorizedData(user: String, password: String): AuthorizedEventData
"Whether the current user has write access to this event."
Expand Down
1 change: 1 addition & 0 deletions util/dev-config/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ host = "http://localhost:8081"
user = "admin"
password = "opencast"
preferred_harvest_size = 3
interpret_eth_passwords = true

[theme]
logo.large.path = "logo-large.svg"
Expand Down

0 comments on commit a68dd84

Please sign in to comment.