diff --git a/.eslintrc.json b/.eslintrc.json index ad1adfc6..5877fefd 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -17,6 +17,5 @@ "no-shadow-restricted-names": ["error"], "no-undef": ["error", {"typeof": true}], "no-unused-vars": ["error", { "vars": "all", "args": "after-used", "ignoreRestSiblings": false }], - "no-use-before-define": ["error", { "functions": true, "classes": true }] } } diff --git a/Cargo.lock b/Cargo.lock index 29a559f4..4af106da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,14 +101,6 @@ dependencies = [ "iovec 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", ] -[[package]] -name = "cargon" -version = "0.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -dependencies = [ - "gcc 0.3.55 (registry+https://github.com/rust-lang/crates.io-index)", -] - [[package]] name = "cc" version = "1.0.25" @@ -609,11 +601,10 @@ dependencies = [ [[package]] name = "libpasta" -version = "0.1.0-rc0" -source = "git+https://github.com/scottlamb/libpasta?branch=pr-default-ring#074ce9d815b1d5e9639b3a8b4b1be5051fe5f074" +version = "0.1.0-rc2" +source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ "argon2rs 0.2.5 (registry+https://github.com/rust-lang/crates.io-index)", - "cargon 0.0.1 (registry+https://github.com/rust-lang/crates.io-index)", "data-encoding 2.1.1 (registry+https://github.com/rust-lang/crates.io-index)", "error-chain 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)", "itertools 0.7.9 (registry+https://github.com/rust-lang/crates.io-index)", @@ -810,12 +801,13 @@ dependencies = [ name = "moonfire-db" version = "0.0.1" dependencies = [ + "base64 0.9.3 (registry+https://github.com/rust-lang/crates.io-index)", "blake2-rfc 0.2.18 (registry+https://github.com/rust-lang/crates.io-index)", "failure 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)", "fnv 1.0.6 (registry+https://github.com/rust-lang/crates.io-index)", "lazy_static 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)", "libc 0.2.43 (registry+https://github.com/rust-lang/crates.io-index)", - "libpasta 0.1.0-rc0 (git+https://github.com/scottlamb/libpasta?branch=pr-default-ring)", + "libpasta 0.1.0-rc2 (registry+https://github.com/rust-lang/crates.io-index)", "log 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)", "lru-cache 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", "moonfire-base 0.0.1", @@ -844,6 +836,7 @@ dependencies = [ name = "moonfire-nvr" version = "0.1.0" dependencies = [ + "base64 0.9.3 (registry+https://github.com/rust-lang/crates.io-index)", "byteorder 1.2.7 (registry+https://github.com/rust-lang/crates.io-index)", "bytes 0.4.11 (registry+https://github.com/rust-lang/crates.io-index)", "cursive 0.9.2 (registry+https://github.com/rust-lang/crates.io-index)", @@ -858,6 +851,7 @@ dependencies = [ "lazy_static 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)", "libc 0.2.43 (registry+https://github.com/rust-lang/crates.io-index)", "log 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)", + "memchr 2.1.1 (registry+https://github.com/rust-lang/crates.io-index)", "memmap 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)", "moonfire-base 0.0.1", "moonfire-db 0.0.1", @@ -868,6 +862,7 @@ dependencies = [ "reffers 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)", "regex 1.0.6 (registry+https://github.com/rust-lang/crates.io-index)", "reqwest 0.9.5 (registry+https://github.com/rust-lang/crates.io-index)", + "ring 0.12.1 (registry+https://github.com/rust-lang/crates.io-index)", "rusqlite 0.14.0 (registry+https://github.com/rust-lang/crates.io-index)", "serde 1.0.80 (registry+https://github.com/rust-lang/crates.io-index)", "serde_derive 1.0.80 (registry+https://github.com/rust-lang/crates.io-index)", @@ -1978,7 +1973,6 @@ dependencies = [ "checksum build_const 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "39092a32794787acd8525ee150305ff051b0aa6cc2abaf193924f5ab05425f39" "checksum byteorder 1.2.7 (registry+https://github.com/rust-lang/crates.io-index)" = "94f88df23a25417badc922ab0f5716cc1330e87f71ddd9203b3a3ccd9cedf75d" "checksum bytes 0.4.11 (registry+https://github.com/rust-lang/crates.io-index)" = "40ade3d27603c2cb345eb0912aec461a6dec7e06a4ae48589904e808335c7afa" -"checksum cargon 0.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "828332c08f2453409faf99af40e4a9e26c9b28790a9445c135e34c7996a25fb3" "checksum cc 1.0.25 (registry+https://github.com/rust-lang/crates.io-index)" = "f159dfd43363c4d08055a07703eb7a3406b0dac4d0584d96965a3262db3c9d16" "checksum cfg-if 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)" = "082bb9b28e00d3c9d39cc03e64ce4cea0f1bb9b3fde493f0cbc008472d22bdf4" "checksum chrono 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)" = "45912881121cb26fad7c38c17ba7daa18764771836b34fab7d3fbd93ed633878" @@ -2036,7 +2030,7 @@ dependencies = [ "checksum lazycell 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ddba4c30a78328befecec92fc94970e53b3ae385827d28620f0f5bb2493081e0" "checksum libc 0.2.43 (registry+https://github.com/rust-lang/crates.io-index)" = "76e3a3ef172f1a0b9a9ff0dd1491ae5e6c948b94479a3021819ba7d860c8645d" "checksum libflate 0.1.18 (registry+https://github.com/rust-lang/crates.io-index)" = "21138fc6669f438ed7ae3559d5789a5f0ba32f28c1f0608d1e452b0bb06ee936" -"checksum libpasta 0.1.0-rc0 (git+https://github.com/scottlamb/libpasta?branch=pr-default-ring)" = "" +"checksum libpasta 0.1.0-rc2 (registry+https://github.com/rust-lang/crates.io-index)" = "00ffb4244832c95894e55a27ae9565195a49faa3ec7b3e5a57dc5cee6e2e9fcc" "checksum libsqlite3-sys 0.9.3 (registry+https://github.com/rust-lang/crates.io-index)" = "d3711dfd91a1081d2458ad2d06ea30a8755256e74038be2ad927d94e1c955ca8" "checksum linked-hash-map 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)" = "7860ec297f7008ff7a1e3382d7f7e1dcd69efc94751a2284bafc3d013c2aa939" "checksum linked-hash-map 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)" = "70fb39025bc7cdd76305867c4eccf2f2dcf6e9a57f5b21a93e1c2d86cd03ec9e" diff --git a/Cargo.toml b/Cargo.toml index e19f188c..af6ada2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ bundled = ["rusqlite/bundled"] members = ["base", "db", "ffmpeg"] [dependencies] +base64 = "0.9.0" bytes = "0.4.6" byteorder = "1.0" docopt = "1.0" @@ -30,6 +31,7 @@ hyper = "0.12.9" lazy_static = "1.0" libc = "0.2" log = { version = "0.4", features = ["release_max_level_info"] } +memchr = "2.0.2" memmap = "0.7" moonfire-base = { path = "base" } moonfire-db = { path = "db" } @@ -39,6 +41,7 @@ openssl = "0.10" parking_lot = { version = "0.6", features = [] } reffers = "0.5.1" regex = "1.0" +ring = "0.12.1" rusqlite = "0.14" serde = "1.0" serde_derive = "1.0" diff --git a/README.md b/README.md index 19a599d4..f497acfb 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,8 @@ less than 10% of the machine's total CPU. So far, the web interface is basic: a filterable list of video segments, with support for trimming them to arbitrary time ranges. No scrub bar yet. -There's also no support for motion detection, no authentication, and no config -UI. +There's also no support for motion detection, no https/SSL/TLS support (you'll +need a proxy server), and no config UI. ![screenshot](screenshot.png) diff --git a/db/Cargo.toml b/db/Cargo.toml index b6960eb5..e93d64a4 100644 --- a/db/Cargo.toml +++ b/db/Cargo.toml @@ -11,12 +11,13 @@ nightly = [] path = "lib.rs" [dependencies] +base64 = "0.9.0" blake2-rfc = "0.2.18" failure = "0.1.1" fnv = "1.0" lazy_static = "1.0" libc = "0.2" -libpasta = { git = "https://github.com/scottlamb/libpasta", branch = "pr-default-ring" } +libpasta = "0.1.0-rc2" log = "0.4" lru-cache = "0.1" moonfire-base = { path = "../base" } diff --git a/db/auth.rs b/db/auth.rs index c621c1f6..aa14fc25 100644 --- a/db/auth.rs +++ b/db/auth.rs @@ -33,20 +33,24 @@ use blake2_rfc::blake2b::blake2b; use failure::Error; use fnv::FnvHashMap; use libpasta; +use parking_lot::Mutex; use rusqlite::{self, Connection, Transaction}; use std::collections::BTreeMap; use std::fmt; use std::net::IpAddr; +use std::sync::Arc; lazy_static! { - static ref PASTA_CONFIG: libpasta::Config = if cfg!(test) { - // In test builds, use bcrypt with the cost turned down. Password hash functions are - // designed to be slow; when run un-optimized, they're unpleasantly slow with default - // settings. Security doesn't matter for cfg(test). - libpasta::Config::with_primitive(libpasta::primitives::Bcrypt::new(2)) - } else { - libpasta::Config::default() - }; + static ref PASTA_CONFIG: Mutex> = + Mutex::new(Arc::new(libpasta::Config::default())); +} + +/// For testing only: use a fast but insecure libpasta config. +/// See also . +/// Call via `testutil::init()`. +pub(crate) fn set_test_config() { + *PASTA_CONFIG.lock() = + Arc::new(libpasta::Config::with_primitive(libpasta::primitives::Bcrypt::new(2))); } enum UserFlags { @@ -111,7 +115,8 @@ impl UserChange { } pub fn set_password(&mut self, pwd: String) { - self.set_password_hash = Some(Some(PASTA_CONFIG.hash_password(&pwd))); + let c = Arc::clone(&PASTA_CONFIG.lock()); + self.set_password_hash = Some(Some(c.hash_password(&pwd))); } pub fn clear_password(&mut self) { @@ -198,6 +203,7 @@ pub struct Session { flags: i32, // bitmask of SessionFlags enum values domain: Vec, description: Option, + seed: Seed, creation_password_id: Option, creation: Request, @@ -211,15 +217,33 @@ pub struct Session { dirty: bool, } +impl Session { + pub fn csrf(&self) -> SessionHash { + let r = blake2b(24, b"csrf", &self.seed.0[..]); + let mut h = SessionHash([0u8; 24]); + h.0.copy_from_slice(r.as_bytes()); + h + } +} + /// A raw session id (not base64-encoded). Sensitive. Never stored in the database. pub struct RawSessionId([u8; 48]); impl RawSessionId { pub fn new() -> Self { RawSessionId([0u8; 48]) } - fn hash(&self) -> SessionHash { - let r = blake2b(32, &[], &self.0[..]); - let mut h = SessionHash([0u8; 32]); + pub fn decode_base64(input: &[u8]) -> Result { + let mut s = RawSessionId::new(); + let l = ::base64::decode_config_slice(input, ::base64::STANDARD_NO_PAD, &mut s.0[..])?; + if l != 48 { + bail!("session id must be 48 bytes"); + } + Ok(s) + } + + pub fn hash(&self) -> SessionHash { + let r = blake2b(24, &[], &self.0[..]); + let mut h = SessionHash([0u8; 24]); h.0.copy_from_slice(r.as_bytes()); h } @@ -239,13 +263,50 @@ impl fmt::Debug for RawSessionId { } } -/// Blake2b-256 of the raw (not base64-encoded) 48-byte session id. -#[derive(Copy, Clone, PartialEq, Eq, Hash)] -pub struct SessionHash([u8; 32]); +/// A Blake2b-256 (48 bytes) of data associated with the session. +/// This is currently used in two ways: +/// * the csrf token is a blake2b drived from the session's seed. This is put into the `sc` +/// cookie. +/// * the 48-byte session id is hashed to be used as a database key. +#[derive(Copy, Clone, Default, PartialEq, Eq, Hash)] +pub struct SessionHash(pub [u8; 24]); + +impl SessionHash { + pub fn encode_base64(&self, output: &mut [u8; 32]) { + ::base64::encode_config_slice(&self.0, ::base64::STANDARD_NO_PAD, output); + } + + pub fn decode_base64(input: &[u8]) -> Result { + let mut h = SessionHash([0u8; 24]); + let l = ::base64::decode_config_slice(input, ::base64::STANDARD_NO_PAD, &mut h.0[..])?; + if l != 24 { + bail!("session hash must be 24 bytes"); + } + Ok(h) + } +} impl fmt::Debug for SessionHash { fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { - write!(f, "SessionHash(\"{}\")", &strutil::hex(&self.0[..])) + let mut buf = [0; 32]; + self.encode_base64(&mut buf); + write!(f, "SessionHash(\"{}\")", ::std::str::from_utf8(&buf[..]).expect("base64 is UTF-8")) + } +} + +#[derive(Copy, Clone, Debug, Default)] +struct Seed([u8; 32]); + +impl rusqlite::types::FromSql for Seed { + fn column_result(value: rusqlite::types::ValueRef) -> rusqlite::types::FromSqlResult { + let b = value.as_blob()?; + if b.len() != 32 { + return Err(rusqlite::types::FromSqlError::Other( + Box::new(format_err!("expected a 32-byte seed").compat()))); + } + let mut s = Seed::default(); + s.0.copy_from_slice(b); + Ok(s) } } @@ -424,7 +485,8 @@ impl State { None => bail!("no password set for user {:?}", username), Some(h) => h, }; - match PASTA_CONFIG.verify_password_update_hash(hash, &password) { + let c = Arc::clone(&PASTA_CONFIG.lock()); + match c.verify_password_update_hash(hash, &password) { libpasta::HashUpdate::Failed => { u.dirty = true; u.password_failure_count += 1; @@ -482,15 +544,15 @@ impl State { domain, creation_password_id, creation, + seed: Seed(seed), ..Default::default() }); Ok((session_id, session)) } - pub fn authenticate_session(&mut self, conn: &Connection, req: Request, - session: &RawSessionId) -> Result<(SessionHash, &User), Error> { - let hash = session.hash(); - let s = match self.sessions.entry(hash) { + pub fn authenticate_session(&mut self, conn: &Connection, req: Request, hash: &SessionHash) + -> Result<(&Session, &User), Error> { + let s = match self.sessions.entry(*hash) { ::std::collections::hash_map::Entry::Occupied(e) => e.into_mut(), ::std::collections::hash_map::Entry::Vacant(e) => e.insert(lookup_session(conn, hash)?), }; @@ -507,13 +569,13 @@ impl State { if u.disabled() { bail!("user {:?} is disabled", &u.username); } - Ok((hash, u)) + Ok((s, u)) } pub fn revoke_session(&mut self, conn: &Connection, reason: RevocationReason, - detail: Option, req: Request, hash: SessionHash) + detail: Option, req: Request, hash: &SessionHash) -> Result<(), Error> { - let s = match self.sessions.entry(hash) { + let s = match self.sessions.entry(*hash) { ::std::collections::hash_map::Entry::Occupied(e) => e.into_mut(), ::std::collections::hash_map::Entry::Vacant(e) => e.insert(lookup_session(conn, hash)?), }; @@ -601,10 +663,11 @@ impl State { } } -fn lookup_session(conn: &Connection, hash: SessionHash) -> Result { +fn lookup_session(conn: &Connection, hash: &SessionHash) -> Result { let mut stmt = conn.prepare_cached(r#" select user_id, + seed, flags, domain, description, @@ -632,33 +695,34 @@ fn lookup_session(conn: &Connection, hash: SessionHash) -> Result return Err(e.into()), Some(Ok(r)) => r, }; - let creation_addr: FromSqlIpAddr = row.get_checked(7)?; - let revocation_addr: FromSqlIpAddr = row.get_checked(10)?; - let last_use_addr: FromSqlIpAddr = row.get_checked(15)?; + let creation_addr: FromSqlIpAddr = row.get_checked(8)?; + let revocation_addr: FromSqlIpAddr = row.get_checked(11)?; + let last_use_addr: FromSqlIpAddr = row.get_checked(16)?; Ok(Session { user_id: row.get_checked(0)?, - flags: row.get_checked(1)?, - domain: row.get_checked(2)?, - description: row.get_checked(3)?, - creation_password_id: row.get_checked(4)?, + seed: row.get_checked(1)?, + flags: row.get_checked(2)?, + domain: row.get_checked(3)?, + description: row.get_checked(4)?, + creation_password_id: row.get_checked(5)?, creation: Request { - when_sec: row.get_checked(5)?, - user_agent: row.get_checked(6)?, + when_sec: row.get_checked(6)?, + user_agent: row.get_checked(7)?, addr: creation_addr.0, }, revocation: Request { - when_sec: row.get_checked(8)?, - user_agent: row.get_checked(9)?, + when_sec: row.get_checked(9)?, + user_agent: row.get_checked(10)?, addr: revocation_addr.0, }, - revocation_reason: row.get_checked(11)?, - revocation_reason_detail: row.get_checked(12)?, + revocation_reason: row.get_checked(12)?, + revocation_reason_detail: row.get_checked(13)?, last_use: Request { - when_sec: row.get_checked(13)?, - user_agent: row.get_checked(14)?, + when_sec: row.get_checked(14)?, + user_agent: row.get_checked(15)?, addr: last_use_addr.0, }, - use_count: row.get_checked(16)?, + use_count: row.get_checked(17)?, dirty: false, }) } @@ -673,6 +737,7 @@ mod tests { #[test] fn open_empty_db() { testutil::init(); + set_test_config(); let mut conn = Connection::open_in_memory().unwrap(); db::init(&mut conn).unwrap(); State::init(&conn).unwrap(); @@ -702,27 +767,32 @@ mod tests { "hunter3".to_owned(), b"nvr.example.com".to_vec(), 0).unwrap_err(); assert_eq!(format!("{}", e), "incorrect password for user \"slamb\""); - let sid = { + let (sid, csrf) = { let (sid, s) = state.login_by_password(&conn, req.clone(), "slamb", "hunter2".to_owned(), b"nvr.example.com".to_vec(), 0).unwrap(); assert_eq!(s.user_id, uid); - sid + (sid, s.csrf()) }; + + let e = state.authenticate_session(&conn, req.clone(), &sid, + &SessionHash::default()).unwrap_err(); + assert_eq!(format!("{}", e), "s and sc cookies are inconsistent"); + let sid_hash = { - let (sid_hash, u) = state.authenticate_session(&conn, req.clone(), &sid).unwrap(); + let (hash, u) = state.authenticate_session(&conn, req.clone(), &sid, &csrf).unwrap(); assert_eq!(u.id, uid); - sid_hash + hash }; state.revoke_session(&conn, RevocationReason::LoggedOut, None, req.clone(), sid_hash).unwrap(); - let e = state.authenticate_session(&conn, req.clone(), &sid).unwrap_err(); + let e = state.authenticate_session(&conn, req.clone(), &sid, &csrf).unwrap_err(); assert_eq!(format!("{}", e), "session is no longer valid (reason=1)"); // Everything should persist across reload. drop(state); let mut state = State::init(&conn).unwrap(); - let e = state.authenticate_session(&conn, req, &sid).unwrap_err(); + let e = state.authenticate_session(&conn, req, &sid, &csrf).unwrap_err(); assert_eq!(format!("{}", e), "session is no longer valid (reason=1)"); } @@ -742,20 +812,20 @@ mod tests { c.set_password("hunter2".to_owned()); state.apply(&conn, c).unwrap(); }; - let sid = { - let (sid, _s) = state.login_by_password(&conn, req.clone(), "slamb", + let (sid, csrf) = { + let (sid, s) = state.login_by_password(&conn, req.clone(), "slamb", "hunter2".to_owned(), b"nvr.example.com".to_vec(), 0).unwrap(); - sid + (sid, s.csrf()) }; - state.authenticate_session(&conn, req.clone(), &sid).unwrap(); + state.authenticate_session(&conn, req.clone(), &sid, &csrf).unwrap(); // Reload. drop(state); let mut state = State::init(&conn).unwrap(); state.revoke_session(&conn, RevocationReason::LoggedOut, None, req.clone(), sid.hash()).unwrap(); - let e = state.authenticate_session(&conn, req, &sid).unwrap_err(); + let e = state.authenticate_session(&conn, req, &sid, &csrf).unwrap_err(); assert_eq!(format!("{}", e), "session is no longer valid (reason=1)"); } @@ -832,9 +902,12 @@ mod tests { }; // Get a session for later. - let sid = state.login_by_password(&conn, req.clone(), "slamb", - "hunter2".to_owned(), - b"nvr.example.com".to_vec(), 0).unwrap().0; + let (sid, csrf) = { + let (sid, s) = state.login_by_password(&conn, req.clone(), "slamb", + "hunter2".to_owned(), + b"nvr.example.com".to_vec(), 0).unwrap(); + (sid, s.csrf()) + }; // Disable the user. { @@ -850,13 +923,13 @@ mod tests { assert_eq!(format!("{}", e), "user \"slamb\" is disabled"); // Authenticating existing sessions shouldn't work either. - let e = state.authenticate_session(&conn, req.clone(), &sid).unwrap_err(); + let e = state.authenticate_session(&conn, req.clone(), &sid, &csrf).unwrap_err(); assert_eq!(format!("{}", e), "user \"slamb\" is disabled"); // The user should still be disabled after reload. drop(state); let mut state = State::init(&conn).unwrap(); - let e = state.authenticate_session(&conn, req, &sid).unwrap_err(); + let e = state.authenticate_session(&conn, req, &sid, &csrf).unwrap_err(); assert_eq!(format!("{}", e), "user \"slamb\" is disabled"); } @@ -878,20 +951,23 @@ mod tests { }; // Get a session for later. - let sid = state.login_by_password(&conn, req.clone(), "slamb", - "hunter2".to_owned(), - b"nvr.example.com".to_vec(), 0).unwrap().0; + let (sid, csrf) = { + let (sid, s) = state.login_by_password(&conn, req.clone(), "slamb", + "hunter2".to_owned(), + b"nvr.example.com".to_vec(), 0).unwrap(); + (sid, s.csrf()) + }; state.delete_user(&mut conn, uid).unwrap(); assert!(state.users_by_id().get(&uid).is_none()); - let e = state.authenticate_session(&conn, req.clone(), &sid).unwrap_err(); + let e = state.authenticate_session(&conn, req.clone(), &sid, &csrf).unwrap_err(); assert_eq!(format!("{}", e), "no such session"); // The user should still be deleted after reload. drop(state); let mut state = State::init(&conn).unwrap(); assert!(state.users_by_id().get(&uid).is_none()); - let e = state.authenticate_session(&conn, req.clone(), &sid).unwrap_err(); + let e = state.authenticate_session(&conn, req.clone(), &sid, &csrf).unwrap_err(); assert_eq!(format!("{}", e), "no such session"); } } diff --git a/db/db.rs b/db/db.rs index d981c00a..91af08e1 100644 --- a/db/db.rs +++ b/db/db.rs @@ -1693,13 +1693,13 @@ impl LockedDatabase { self.auth.login_by_password(&self.conn, req, username, password, domain, session_flags) } - pub fn authenticate_session(&mut self, req: auth::Request, sid: &auth::RawSessionId) - -> Result<(auth::SessionHash, &User), Error> { + pub fn authenticate_session(&mut self, req: auth::Request, sid: &auth::SessionHash) + -> Result<(&auth::Session, &User), Error> { self.auth.authenticate_session(&self.conn, req, sid) } pub fn revoke_session(&mut self, reason: auth::RevocationReason, detail: Option, - req: auth::Request, hash: auth::SessionHash) -> Result<(), Error> { + req: auth::Request, hash: &auth::SessionHash) -> Result<(), Error> { self.auth.revoke_session(&self.conn, reason, detail, req, hash) } } diff --git a/db/lib.rs b/db/lib.rs index 4cec9679..4b3e9f9b 100644 --- a/db/lib.rs +++ b/db/lib.rs @@ -30,6 +30,7 @@ #![cfg_attr(all(feature="nightly", test), feature(test))] +extern crate base64; extern crate blake2_rfc; #[macro_use] extern crate failure; extern crate fnv; diff --git a/db/schema.sql b/db/schema.sql index efc077d7..6038c414 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -332,35 +332,28 @@ create table user ( ); -- A single session, whether for browser or robot use. --- These map at the HTTP layer to two cookies (exact format described --- elsewhere): --- --- * "s" holds the session id and an encrypted sequence number for replay --- protection. To decrease chance of leaks, it's normally marked as --- HttpOnly, preventing client-side Javascript from accessing it. --- --- * "sc" holds state needed by client Javascript, such as a CSRF token (which --- should be copied into POST request bodies) and username (which should be --- presented in the UI). It should never be marked HttpOnly. +-- These map at the HTTP layer to an "s" cookie (exact format described +-- elsewhere), which holds the session id and an encrypted sequence number for +-- replay protection. create table user_session ( - -- The session id is a 20-byte blob. This is the unencoded, unsalted Blake2b-160 - -- (also 20 bytes) of the unencoded session id. Much like `password_hash`, a + -- The session id is a 48-byte blob. This is the unencoded, unsalted Blake2b-192 + -- (24 bytes) of the unencoded session id. Much like `password_hash`, a -- hash is used here so that a leaked database backup can't be trivially used -- to steal credentials. session_id_hash blob primary key not null, user_id integer references user (id) not null, - -- A TBD-byte random number. Used to derive keys for the replay protection + -- A 32-byte random number. Used to derive keys for the replay protection -- and CSRF tokens. seed blob not null, - -- A bitwise mask of flags, currently all properties of the HTTP cookies + -- A bitwise mask of flags, currently all properties of the HTTP cookie -- used to hold the session: - -- 1: HttpOnly ("s" cookie only) - -- 2: Secure (both cookies) - -- 4: SameSite=Lax (both cookies) - -- 8: SameSite=Strict ("s" cookie only) - 4 must also be set. + -- 1: HttpOnly + -- 2: Secure + -- 4: SameSite=Lax + -- 8: SameSite=Strict - 4 must also be set. flags integer not null, -- The domain of the HTTP cookie used to store this session. The outbound diff --git a/db/testutil.rs b/db/testutil.rs index d7cfa99b..70683524 100644 --- a/db/testutil.rs +++ b/db/testutil.rs @@ -53,6 +53,7 @@ pub const TEST_STREAM_ID: i32 = 1; /// the program's environment prior to running.) /// * set `TZ=America/Los_Angeles` so that tests that care about calendar time get the expected /// results regardless of machine setup.) +/// * use a fast but insecure password hashing format. pub fn init() { INIT.call_once(|| { let h = mylog::Builder::new() @@ -61,6 +62,7 @@ pub fn init() { h.install().unwrap(); env::set_var("TZ", "America/Los_Angeles"); time::tzset(); + ::auth::set_test_config(); }); } diff --git a/design/api.md b/design/api.md index 3f6732df..87a4dcd9 100644 --- a/design/api.md +++ b/design/api.md @@ -15,11 +15,30 @@ In the future, this is likely to be expanded: ## Detailed design -All requests for JSON data should be sent with the header `Accept: -application/json` (exactly). Without this header, replies will generally be in -HTML rather than JSON. +All requests for JSON data should be sent with the header +`Accept: application/json` (exactly). -TODO(slamb): authentication. +### `/api/login` + +A `POST` request on this URL should have an `application/x-www-form-urlencoded` +body containing `username` and `password` parameters. + +On successful authentication, the server will return an HTTP 204 (no content) +with a `Set-Cookie` header for the `s` cookie, which is an opaque, HttpOnly +(unavailable to Javascript) session identifier. + +If authentication or authorization fails, the server will return a HTTP 403 +(forbidden) response. Currently the body will be a `text/plain` error message; +future versions will likely be more sophisticated. + +### `/api/logout` + +A `POST` request on this URL should have an `application/x-www-form-urlencoded` +body containing a `csrf` parameter copied from the `session.csrf` of the +top-level API request. + +On success, returns an HTTP 204 (no content) responses. On failure, returns a +4xx response with `text/plain` error message. ### `/api/` @@ -69,6 +88,9 @@ The `application/json` response will have a dict as follows: time zone. It is usually 24 hours after the start time. It might be 23 hours or 25 hours during spring forward or fall back, respectively. +* `session`: if logged in, a dict with the following properties: + * `username` + * `csrf`: a cross-site request forgery token for use in `POST` requests. Example response: @@ -104,6 +126,10 @@ Example response: }, ... ], + "session": { + "username": "slamb", + "csrf": "2DivvlnKUQ9JD4ao6YACBJm8XK4bFmOc", + } } ``` diff --git a/guide/troubleshooting.md b/guide/troubleshooting.md index 1538a681..5af63fd1 100644 --- a/guide/troubleshooting.md +++ b/guide/troubleshooting.md @@ -68,3 +68,9 @@ In the short term, you can use either of two workarounds: This happens if your machine is configured to a non-UTF-8 locale, due to gyscos/Cursive#13. As a workaround, type `export LC_ALL=en_US.UTF-8` prior to running `moonfire-nvr config`. + +### Logging in is very very slow + +Ensure you're using a build compiled with the `--release` flag. See +[libpasta/libpasta#9](https://github.com/libpasta/libpasta/issues/9) for more +background. diff --git a/src/body.rs b/src/body.rs index d623c1cd..5d541954 100644 --- a/src/body.rs +++ b/src/body.rs @@ -54,6 +54,14 @@ impl From<&'static [u8]> for Chunk { fn from(r: &'static [u8]) -> Self { Chunk(ARefs::new(r)) } } +impl From<&'static str> for Chunk { + fn from(r: &'static str) -> Self { Chunk(ARefs::new(r.as_bytes())) } +} + +impl From for Chunk { + fn from(r: String) -> Self { Chunk(ARefs::new(r.into_bytes()).map(|v| &v[..])) } +} + impl From> for Chunk { fn from(r: Vec) -> Self { Chunk(ARefs::new(r).map(|v| &v[..])) } } @@ -81,8 +89,8 @@ impl From for Body { fn from(b: BodyStream) -> Self { Body(b) } } -impl From<&'static [u8]> for Body { - fn from(c: &'static [u8]) -> Self { +impl> From for Body { + fn from(c: C) -> Self { Body(Box::new(stream::once(Ok(c.into())))) } } diff --git a/src/cmds/run.rs b/src/cmds/run.rs index a12436f8..c4a06bac 100644 --- a/src/cmds/run.rs +++ b/src/cmds/run.rs @@ -66,9 +66,8 @@ Options: --http-addr=ADDR Set the bind address for the unencrypted HTTP server. [default: 0.0.0.0:8080] --read-only Forces read-only mode / disables recording. - --allow-origin=ORIGIN If present, adds a Access-Control-Allow-Origin: - header to HTTP responses. This may be useful for - Javascript development. + --require-auth=BOOL Requires authentication to access the web interface. + [default: true] "#; #[derive(Debug, Deserialize)] @@ -77,7 +76,7 @@ struct Args { flag_http_addr: String, flag_ui_dir: String, flag_read_only: bool, - flag_allow_origin: Option, + flag_require_auth: bool, } fn setup_shutdown() -> impl Future + Send { @@ -180,7 +179,7 @@ pub fn run() -> Result<(), Error> { let zone = resolve_zone()?; info!("Resolved timezone: {}", &zone); - let s = web::Service::new(db.clone(), Some(&args.flag_ui_dir), args.flag_allow_origin, zone)?; + let s = web::Service::new(db.clone(), Some(&args.flag_ui_dir), args.flag_require_auth, zone)?; // Start a streamer for each stream. let shutdown_streamers = Arc::new(AtomicBool::new(false)); diff --git a/src/json.rs b/src/json.rs index 00f27ddf..5c76ecb4 100644 --- a/src/json.rs +++ b/src/json.rs @@ -28,7 +28,7 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use db; +use db::{self, auth::SessionHash}; use failure::Error; use serde::ser::{SerializeMap, SerializeSeq, Serializer}; use std::collections::BTreeMap; @@ -44,6 +44,26 @@ pub struct TopLevel<'a> { // "days" attribute or not, according to the bool in the tuple. #[serde(serialize_with = "TopLevel::serialize_cameras")] pub cameras: (&'a db::LockedDatabase, bool), + + pub session: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all="camelCase")] +pub struct Session { + pub username: String, + + #[serde(serialize_with = "Session::serialize_csrf")] + pub csrf: SessionHash, +} + +impl Session { + fn serialize_csrf(csrf: &SessionHash, serializer: S) -> Result + where S: Serializer { + let mut tmp = [0u8; 32]; + csrf.encode_base64(&mut tmp); + serializer.serialize_str(::std::str::from_utf8(&tmp[..]).expect("base64 is UTF-8")) + } } /// JSON serialization wrapper for a single camera when processing `/api/` and diff --git a/src/main.rs b/src/main.rs index 68548316..c036a7ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,7 @@ #![cfg_attr(all(feature="nightly", test), feature(test))] +extern crate base64; extern crate bytes; extern crate byteorder; extern crate core; @@ -46,6 +47,7 @@ extern crate libc; #[macro_use] extern crate log; extern crate reffers; extern crate rusqlite; +extern crate memchr; extern crate memmap; extern crate moonfire_base as base; extern crate moonfire_db as db; @@ -54,6 +56,7 @@ extern crate mylog; extern crate openssl; extern crate parking_lot; extern crate regex; +extern crate ring; extern crate serde; #[macro_use] extern crate serde_derive; extern crate serde_json; diff --git a/src/web.rs b/src/web.rs index e5f3059f..2fc7082d 100644 --- a/src/web.rs +++ b/src/web.rs @@ -30,15 +30,18 @@ extern crate hyper; +use base::clock::Clocks; use base::strutil; -use body::{Body, BoxedError, wrap_error}; +use body::{Body, BoxedError}; +use base64; +use bytes::{BufMut, BytesMut}; use core::borrow::Borrow; use core::str::FromStr; -use db::{self, recording}; +use db::{self, auth, recording}; use db::dir::SampleFileDir; use failure::Error; use fnv::FnvHashMap; -use futures::future; +use futures::{Future, Stream, future}; use futures_cpupool; use json; use http::{self, Request, Response, status::StatusCode}; @@ -64,6 +67,7 @@ lazy_static! { Regex::new(r"^(\d+)(-\d+)?(@\d+)?(?:\.(\d+)?-(\d+)?)?$").unwrap(); } +#[derive(Debug)] enum Path { TopLevel, // "/api/" InitSegment([u8; 20]), // "/api/init/.mp4" @@ -71,7 +75,9 @@ enum Path { StreamRecordings(Uuid, db::StreamType), // "/api/cameras///recordings" StreamViewMp4(Uuid, db::StreamType), // "/api/cameras///view.mp4" StreamViewMp4Segment(Uuid, db::StreamType), // "/api/cameras///view.m4s" - Static, // "" + Login, // "/api/login" + Logout, // "/api/logout" + Static, // (anything that doesn't start with "/api/") NotFound, } @@ -83,6 +89,11 @@ fn decode_path(path: &str) -> Path { if path == "/" { return Path::TopLevel; } + if path == "/login" { + return Path::Login; + } else if path == "/logout" { + return Path::Logout; + } if path.starts_with("/init/") { if path.len() != 50 || !path.ends_with(".mp4") { return Path::NotFound; @@ -131,6 +142,25 @@ fn decode_path(path: &str) -> Path { } } +fn plain_response>(status: http::StatusCode, body: B) -> Response { + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, HeaderValue::from_static("text/plain")) + .body(body.into()).expect("hardcoded head should be valid") +} + +fn not_found>(body: B) -> Response { + plain_response(StatusCode::NOT_FOUND, body) +} + +fn bad_req>(body: B) -> Response { + plain_response(StatusCode::BAD_REQUEST, body) +} + +fn internal_server_err>(err: E) -> Response { + plain_response(StatusCode::INTERNAL_SERVER_ERROR, err.into().to_string()) +} + #[derive(Debug, Eq, PartialEq)] struct Segments { ids: Range, @@ -190,25 +220,20 @@ struct ServiceInner { db: Arc, dirs_by_stream_id: Arc>>, ui_files: HashMap, - allow_origin: Option, pool: futures_cpupool::CpuPool, time_zone_name: String, + require_auth: bool, } -impl ServiceInner { - fn not_found(&self) -> Result, Error> { - let body: Body = (&b"not found"[..]).into(); - Ok(Response::builder() - .status(StatusCode::NOT_FOUND) - .header(header::CONTENT_TYPE, HeaderValue::from_static("text/plain")) - .body(body)?) - } +type ResponseResult = Result, Response>; - fn top_level(&self, req: &Request<::hyper::Body>) -> Result, Error> { +impl ServiceInner { + fn top_level(&self, req: &Request<::hyper::Body>, session: Option) + -> ResponseResult { let mut days = false; if let Some(q) = req.uri().query() { for (key, value) in form_urlencoded::parse(q.as_bytes()) { - let (key, value) : (_, &str) = (key.borrow(), value.borrow()); + let (key, value): (_, &str) = (key.borrow(), value.borrow()); match key { "days" => days = value == "true", _ => {}, @@ -224,26 +249,30 @@ impl ServiceInner { serde_json::to_writer(&mut w, &json::TopLevel { time_zone_name: &self.time_zone_name, cameras: (&db, days), - })?; + session, + }).map_err(internal_server_err)?; } Ok(resp) } - fn camera(&self, req: &Request<::hyper::Body>, uuid: Uuid) -> Result, Error> { + fn camera(&self, req: &Request<::hyper::Body>, uuid: Uuid) -> ResponseResult { let (mut resp, writer) = http_serve::streaming_body(&req).build(); resp.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("application/json")); if let Some(mut w) = writer { let db = self.db.lock(); let camera = db.get_camera(uuid) - .ok_or_else(|| format_err!("no such camera {}", uuid))?; - serde_json::to_writer(&mut w, &json::Camera::wrap(camera, &db, true)?)? + .ok_or_else(|| not_found(format!("no such camera {}", uuid)))?; + serde_json::to_writer( + &mut w, + &json::Camera::wrap(camera, &db, true).map_err(internal_server_err)? + ).map_err(internal_server_err)? }; Ok(resp) } fn stream_recordings(&self, req: &Request<::hyper::Body>, uuid: Uuid, type_: db::StreamType) - -> Result, Error> { + -> ResponseResult { let (r, split) = { let mut time = recording::Time(i64::min_value()) .. recording::Time(i64::max_value()); let mut split = recording::Duration(i64::max_value()); @@ -251,9 +280,18 @@ impl ServiceInner { for (key, value) in form_urlencoded::parse(q.as_bytes()) { let (key, value) = (key.borrow(), value.borrow()); match key { - "startTime90k" => time.start = recording::Time::parse(value)?, - "endTime90k" => time.end = recording::Time::parse(value)?, - "split90k" => split = recording::Duration(i64::from_str(value)?), + "startTime90k" => { + time.start = recording::Time::parse(value) + .map_err(|_| bad_req("unparseable startTime90k"))? + }, + "endTime90k" => { + time.end = recording::Time::parse(value) + .map_err(|_| bad_req("unparseable endTime90k"))? + }, + "split90k" => { + split = recording::Duration(i64::from_str(value) + .map_err(|_| bad_req("unparseable split90k"))?) + }, _ => {}, } }; @@ -264,9 +302,11 @@ impl ServiceInner { { let db = self.db.lock(); let camera = db.get_camera(uuid) - .ok_or_else(|| format_err!("no such camera {}", uuid))?; + .ok_or_else(|| plain_response(StatusCode::NOT_FOUND, + format!("no such camera {}", uuid)))?; let stream_id = camera.streams[type_.index()] - .ok_or_else(|| format_err!("no such stream {}/{}", uuid, type_))?; + .ok_or_else(|| plain_response(StatusCode::NOT_FOUND, + format!("no such stream {}/{}", uuid, type_)))?; db.list_aggregated_recordings(stream_id, r, split, &mut |row| { let end = row.ids.end - 1; // in api, ids are inclusive. let vse = db.video_sample_entries_by_id().get(&row.video_sample_entry_id).unwrap(); @@ -285,40 +325,43 @@ impl ServiceInner { growing: row.growing, }); Ok(()) - })?; + }).map_err(internal_server_err)?; } let (mut resp, writer) = http_serve::streaming_body(&req).build(); resp.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("application/json")); if let Some(mut w) = writer { - serde_json::to_writer(&mut w, &out)? + serde_json::to_writer(&mut w, &out).map_err(internal_server_err)? }; Ok(resp) } - fn init_segment(&self, sha1: [u8; 20], req: &Request<::hyper::Body>) - -> Result, Error> { + fn init_segment(&self, sha1: [u8; 20], req: &Request<::hyper::Body>) -> ResponseResult { let mut builder = mp4::FileBuilder::new(mp4::Type::InitSegment); let db = self.db.lock(); for ent in db.video_sample_entries_by_id().values() { if ent.sha1 == sha1 { builder.append_video_sample_entry(ent.clone()); - let mp4 = builder.build(self.db.clone(), self.dirs_by_stream_id.clone())?; + let mp4 = builder.build(self.db.clone(), self.dirs_by_stream_id.clone()) + .map_err(internal_server_err)?; return Ok(http_serve::serve(mp4, req)); } } - self.not_found() + Err(not_found("no such init segment")) } fn stream_view_mp4(&self, req: &Request<::hyper::Body>, uuid: Uuid, stream_type_: db::StreamType, mp4_type_: mp4::Type) - -> Result, Error> { + -> ResponseResult { let stream_id = { let db = self.db.lock(); let camera = db.get_camera(uuid) - .ok_or_else(|| format_err!("no such camera {}", uuid))?; + .ok_or_else(|| plain_response(StatusCode::NOT_FOUND, + format!("no such camera {}", uuid)))?; camera.streams[stream_type_.index()] - .ok_or_else(|| format_err!("no such stream {}/{}", uuid, stream_type_))? + .ok_or_else(|| plain_response(StatusCode::NOT_FOUND, + format!("no such stream {}/{}", uuid, + stream_type_)))? }; let mut builder = mp4::FileBuilder::new(mp4_type_); if let Some(q) = req.uri().query() { @@ -327,7 +370,8 @@ impl ServiceInner { match key { "s" => { let s = Segments::parse(value).map_err( - |_| format_err!("invalid s parameter: {}", value))?; + |()| plain_response(StatusCode::BAD_REQUEST, + format!("invalid s parameter: {}", value)))?; debug!("stream_view_mp4: appending s={:?}", s); let mut est_segments = (s.ids.end - s.ids.start) as usize; if let Some(end) = s.end_time { @@ -381,52 +425,199 @@ impl ServiceInner { } cur_off += d; Ok(()) - })?; + }).map_err(internal_server_err)?; // Check for missing recordings. match prev { Some(id) if s.ids.end != id + 1 => { - bail!("no such recording {}/{}", stream_id, s.ids.end - 1); + return Err(not_found(format!("no such recording {}/{}", + stream_id, s.ids.end - 1))); }, None => { - bail!("no such recording {}/{}", stream_id, s.ids.start); + return Err(not_found(format!("no such recording {}/{}", + stream_id, s.ids.start))); }, _ => {}, }; if let Some(end) = s.end_time { if end > cur_off { - bail!("end time {} is beyond specified recordings", end); + return Err(plain_response( + StatusCode::BAD_REQUEST, + format!("end time {} is beyond specified recordings", + end))); } } }, "ts" => builder.include_timestamp_subtitle_track(value == "true"), - _ => bail!("parameter {} not understood", key), + _ => return Err(bad_req(format!("parameter {} not understood", key))), } }; } - let mp4 = builder.build(self.db.clone(), self.dirs_by_stream_id.clone())?; + let mp4 = builder.build(self.db.clone(), self.dirs_by_stream_id.clone()) + .map_err(internal_server_err)?; Ok(http_serve::serve(mp4, req)) } - fn static_file(&self, req: &Request<::hyper::Body>) -> Result, Error> { - let s = match self.ui_files.get(req.uri().path()) { - None => { return self.not_found() }, - Some(s) => s, - }; - let f = fs::File::open(&s.path)?; + fn static_file(&self, req: &Request<::hyper::Body>, path: &str) -> ResponseResult { + let s = self.ui_files.get(path).ok_or_else(|| not_found("no such static file"))?; + let f = fs::File::open(&s.path).map_err(internal_server_err)?; let mut hdrs = http::HeaderMap::new(); hdrs.insert(header::CONTENT_TYPE, s.mime.clone()); - let e = http_serve::ChunkedReadFile::new(f, Some(self.pool.clone()), hdrs)?; + let e = http_serve::ChunkedReadFile::new(f, Some(self.pool.clone()), hdrs) + .map_err(internal_server_err)?; Ok(http_serve::serve(e, &req)) } + + fn authreq(&self, req: &Request<::hyper::Body>) -> auth::Request { + auth::Request { + when_sec: Some(self.db.clocks().realtime().sec), + addr: None, // TODO: req.remote_addr().map(|a| a.ip()), + user_agent: req.headers().get(header::USER_AGENT).map(|ua| ua.as_bytes().to_vec()), + } + } + + fn login(&self, req: &Request<::hyper::Body>, body: hyper::Chunk) -> ResponseResult { + let mut username = None; + let mut password = None; + for (key, value) in form_urlencoded::parse(&body) { + match &*key { + "username" => username = Some(value), + "password" => password = Some(value), + _ => {}, + }; + } + let (username, password) = match (username, password) { + (Some(u), Some(p)) => (u, p), + _ => return Err(bad_req("expected username + password")), + }; + let authreq = self.authreq(req); + let host = req.headers().get(header::HOST).ok_or_else(|| bad_req("missing Host header!"))?; + let host = host.as_bytes(); + let domain = match ::memchr::memchr(b':', host) { + Some(colon) => &host[0..colon], + None => host, + }.to_owned(); + let mut l = self.db.lock(); + let flags = (auth::SessionFlags::HttpOnly as i32) | (auth::SessionFlags::SameSite as i32); + let (sid, _) = l.login_by_password(authreq, &username, password.into_owned(), domain, + flags) + .map_err(|e| plain_response(StatusCode::UNAUTHORIZED, e.to_string()))?; + let s_suffix = "; HttpOnly; SameSite=Lax; Max-Age=2147483648; Path=/"; + let mut encoded = [0u8; 64]; + base64::encode_config_slice(&sid, base64::STANDARD_NO_PAD, &mut encoded); + let mut cookie = BytesMut::with_capacity("s=".len() + encoded.len() + s_suffix.len()); + cookie.put("s="); + cookie.put(&encoded[..]); + cookie.put(s_suffix); + Ok(Response::builder() + .header(header::SET_COOKIE, cookie.freeze()) + .status(StatusCode::NO_CONTENT) + .body(b""[..].into()).unwrap()) + } + + fn logout(&self, req: &Request, body: hyper::Chunk) -> ResponseResult { + // Parse parameters. + let mut csrf = None; + for (key, value) in form_urlencoded::parse(&body) { + match &*key { + "csrf" => csrf = Some(value), + _ => {}, + }; + } + + let mut res = Response::new(b""[..].into()); + if let Some(sid) = extract_sid(req) { + let authreq = self.authreq(req); + let mut l = self.db.lock(); + let hash = sid.hash(); + let need_revoke = match l.authenticate_session(authreq.clone(), &hash) { + Ok((s, _)) => { + let correct_csrf = if let Some(c) = csrf { + csrf_matches(&*c, s.csrf()) + } else { false }; + if !correct_csrf { + warn!("logout request with missing/incorrect csrf"); + return Err(bad_req("logout with incorrect csrf token")); + } + info!("revoking session"); + true + }, + Err(e) => { + // TODO: distinguish "no such session", "session is no longer valid", and + // "user ... is disabled" (which are all client error / bad state) from database + // errors. + warn!("logout failed: {}", e); + false + }, + }; + if need_revoke { + // TODO: inline this above with non-lexical lifetimes. + l.revoke_session(auth::RevocationReason::LoggedOut, None, authreq, &hash) + .map_err(internal_server_err)?; + } + + // By now the session is invalid (whether it was valid to start with or not). + // Clear useless cookie. + res.headers_mut().append(header::SET_COOKIE, + HeaderValue::from_str("s=; Max-Age=0; Path=/").unwrap()); + } + *res.status_mut() = StatusCode::NO_CONTENT; + Ok(res) + } + + fn authenticated(&self, req: &Request) -> Result, Error> { + if let Some(sid) = extract_sid(req) { + let authreq = self.authreq(req); + match self.db.lock().authenticate_session(authreq.clone(), &sid.hash()) { + Ok((s, u)) => { + return Ok(Some(json::Session { + username: u.username.clone(), + csrf: s.csrf(), + })) + }, + Err(_) => { + // TODO: real error handling! this assumes all errors are due to lack of + // authentication, when they could be logic errors in SQL or such. + return Ok(None); + } + } + } + Ok(None) + } +} + +fn csrf_matches(csrf: &str, session: auth::SessionHash) -> bool { + let mut b64 = [0u8; 32]; + session.encode_base64(&mut b64); + ::ring::constant_time::verify_slices_are_equal(&b64[..], csrf.as_bytes()).is_ok() +} + +/// Extracts `s` cookie from the HTTP request. Does not authenticate. +fn extract_sid(req: &Request) -> Option { + let hdr = match req.headers().get(header::COOKIE) { + None => return None, + Some(c) => c, + }; + for mut cookie in hdr.as_bytes().split(|&b| b == b';') { + if cookie.starts_with(b" ") { + cookie = &cookie[1..]; + } + if cookie.starts_with(b"s=") { + let s = &cookie[2..]; + if let Ok(s) = auth::RawSessionId::decode_base64(s) { + return Some(s); + } + } + } + None } #[derive(Clone)] pub struct Service(Arc); impl Service { - pub fn new(db: Arc, ui_dir: Option<&str>, allow_origin: Option, - zone: String) -> Result { + pub fn new(db: Arc, ui_dir: Option<&str>, require_auth: bool, + time_zone_name: String) -> Result { let mut ui_files = HashMap::new(); if let Some(d) = ui_dir { Service::fill_ui_files(d, &mut ui_files); @@ -448,17 +639,14 @@ impl Service { } Arc::new(d) }; - let allow_origin = match allow_origin { - None => None, - Some(o) => Some(HeaderValue::from_str(&o)?), - }; + Ok(Service(Arc::new(ServiceInner { db, dirs_by_stream_id, ui_files, - allow_origin, pool: futures_cpupool::Builder::new().pool_size(1).name_prefix("static").create(), - time_zone_name: zone, + require_auth, + time_zone_name, }))) } @@ -502,44 +690,186 @@ impl Service { }); } } + + /// Returns a future separating the request from its form body. + /// + /// If this is not a `POST` or the body's `Content-Type` is not + /// `application/x-www-form-urlencoded`, returns an appropriate error response instead. + /// + /// Use with `and_then` to chain logic which consumes the form body. + fn with_form_body(&self, mut req: Request) + -> Box, hyper::Chunk), + Error = Response> + + Send + 'static> { + if *req.method() != http::method::Method::POST { + return Box::new(future::err(plain_response(StatusCode::METHOD_NOT_ALLOWED, + "POST expected"))); + } + let correct_mime_type = match req.headers().get(header::CONTENT_TYPE) { + Some(t) if t == "application/x-www-form-urlencoded" => true, + Some(t) if t == "application/x-www-form-urlencoded; charset=UTF-8" => true, + _ => false, + }; + if !correct_mime_type { + return Box::new(future::err(bad_req( + "expected application/x-www-form-urlencoded request body"))); + } + let b = ::std::mem::replace(req.body_mut(), hyper::Body::empty()); + Box::new(b.concat2() + .map(|b| (req, b)) + .map_err(|e| internal_server_err(format_err!("unable to read request body: {}", + e)))) + } } impl ::hyper::service::Service for Service { type ReqBody = ::hyper::Body; type ResBody = Body; type Error = BoxedError; - type Future = future::FutureResult, Self::Error>; + type Future = Box, Error = Self::Error> + Send + 'static>; fn call(&mut self, req: Request<::hyper::Body>) -> Self::Future { - debug!("request on: {}", req.uri()); - let mut res = match decode_path(req.uri().path()) { - Path::InitSegment(sha1) => self.0.init_segment(sha1, &req), - Path::TopLevel => self.0.top_level(&req), - Path::Camera(uuid) => self.0.camera(&req, uuid), - Path::StreamRecordings(uuid, type_) => self.0.stream_recordings(&req, uuid, type_), + fn wrap, Error = Response> + Send + 'static>(r: R) + -> Box, Error = BoxedError> + Send + 'static> { + return Box::new(r.or_else(|e| Ok(e))) + } + + fn wrap_r(r: ResponseResult) + -> Box, Error = BoxedError> + Send + 'static> { + return wrap(future::result(r)) + } + + let p = decode_path(req.uri().path()); + let require_auth = self.0.require_auth && match p { + Path::NotFound | Path::Login | Path::Logout | Path::Static => false, + _ => true, + }; + debug!("request on: {}: {:?}, require_auth={}", req.uri(), p, require_auth); + let session = match self.0.authenticated(&req) { + Ok(s) => s, + Err(e) => return Box::new(future::ok(internal_server_err(e))), + }; + if require_auth && session.is_none() { + return Box::new(future::ok( + plain_response(StatusCode::UNAUTHORIZED, "unauthorized"))); + } + match decode_path(req.uri().path()) { + Path::InitSegment(sha1) => wrap_r(self.0.init_segment(sha1, &req)), + Path::TopLevel => wrap_r(self.0.top_level(&req, session)), + Path::Camera(uuid) => wrap_r(self.0.camera(&req, uuid)), + Path::StreamRecordings(uuid, type_) => { + wrap_r(self.0.stream_recordings(&req, uuid, type_)) + }, Path::StreamViewMp4(uuid, type_) => { - self.0.stream_view_mp4(&req, uuid, type_, mp4::Type::Normal) + wrap_r(self.0.stream_view_mp4(&req, uuid, type_, mp4::Type::Normal)) }, Path::StreamViewMp4Segment(uuid, type_) => { - self.0.stream_view_mp4(&req, uuid, type_, mp4::Type::MediaSegment) + wrap_r(self.0.stream_view_mp4(&req, uuid, type_, mp4::Type::MediaSegment)) }, - Path::NotFound => self.0.not_found(), - Path::Static => self.0.static_file(&req), - }; - if let Ok(ref mut resp) = res { - if let Some(ref o) = self.0.allow_origin { - resp.headers_mut().insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, o.clone()); - } + Path::NotFound => wrap(future::err(not_found("path not understood"))), + Path::Login => wrap(self.with_form_body(req).and_then({ + let s = self.clone(); + move |(req, b)| { s.0.login(&req, b) } + })), + Path::Logout => wrap(self.with_form_body(req).and_then({ + let s = self.clone(); + move |(req, b)| { s.0.logout(&req, b) } + })), + Path::Static => wrap_r(self.0.static_file(&req, req.uri().path())), } - future::result(res.map_err(|e| wrap_error(e))) } } #[cfg(test)] mod tests { - use db::testutil; + extern crate reqwest; + + use db; + use db::testutil::{self, TestDb}; + use futures::Future; + use http::{self, header}; + use std::collections::HashMap; + use std::error::Error as StdError; use super::Segments; + struct Server { + db: TestDb<::base::clock::RealClocks>, + base_url: String, + //test_camera_uuid: Uuid, + handle: Option<::std::thread::JoinHandle<()>>, + shutdown_tx: Option>, + } + + impl Server { + fn new() -> Server { + let db = TestDb::new(::base::clock::RealClocks {}); + let (shutdown_tx, shutdown_rx) = futures::sync::oneshot::channel::<()>(); + let addr = "127.0.0.1:0".parse().unwrap(); + let require_auth = true; + let service = super::Service::new(db.db.clone(), None, require_auth, + "".to_owned()).unwrap(); + let server = hyper::server::Server::bind(&addr) + .tcp_nodelay(true) + .serve(move || Ok::<_, Box>(service.clone())); + let addr = server.local_addr(); // resolve port 0 to a real ephemeral port number. + let handle = ::std::thread::spawn(move || { + ::tokio::run(server.with_graceful_shutdown(shutdown_rx).map_err(|e| panic!(e))); + }); + + // Create a user. + let mut c = db::UserChange::add_user("slamb".to_owned()); + c.set_password("hunter2".to_owned()); + db.db.lock().apply_user_change(c).unwrap(); + + Server { + db, + base_url: format!("http://{}:{}", addr.ip(), addr.port()), + handle: Some(handle), + shutdown_tx: Some(shutdown_tx), + } + } + } + + impl Drop for Server { + fn drop(&mut self) { + self.shutdown_tx.take().unwrap().send(()).unwrap(); + self.handle.take().unwrap().join().unwrap() + } + } + + #[derive(Clone, Debug, Default)] + struct SessionCookie(Option); + + impl SessionCookie { + pub fn new(headers: &http::HeaderMap) -> Self { + let mut c = SessionCookie::default(); + c.update(headers); + c + } + + pub fn update(&mut self, headers: &http::HeaderMap) { + for set_cookie in headers.get_all(header::SET_COOKIE) { + let mut set_cookie = set_cookie.to_str().unwrap().split("; "); + let c = set_cookie.next().unwrap(); + let mut clear = false; + for attr in set_cookie { + if attr == "Max-Age=0" { + clear = true; + } + } + if !c.starts_with("s=") { + panic!("unrecognized cookie"); + } + self.0 = if clear { None } else { Some(c.to_owned()) }; + } + } + + /// Produces a `Cookie` header value. + pub fn header(&self) -> String { + self.0.as_ref().map(|s| s.as_str()).unwrap_or("").to_owned() + } + } + #[test] fn test_segments() { testutil::init(); @@ -564,6 +894,103 @@ mod tests { assert_eq!(Segments{ids: 1..6, open_id: None, start_time: 26, end_time: Some(42)}, Segments::parse("1-5.26-42").unwrap()); } + + #[test] + fn unauthorized_without_cookie() { + testutil::init(); + let s = Server::new(); + let cli = reqwest::Client::new(); + let resp = cli.get(&format!("{}/api/", &s.base_url)).send().unwrap(); + assert_eq!(resp.status(), http::StatusCode::UNAUTHORIZED); + } + + #[test] + fn login() { + testutil::init(); + let s = Server::new(); + let cli = reqwest::Client::new(); + let login_url = format!("{}/api/login", &s.base_url); + + let resp = cli.get(&login_url).send().unwrap(); + assert_eq!(resp.status(), http::StatusCode::METHOD_NOT_ALLOWED); + + let resp = cli.post(&login_url).send().unwrap(); + assert_eq!(resp.status(), http::StatusCode::BAD_REQUEST); + + let mut p = HashMap::new(); + p.insert("username", "slamb"); + p.insert("password", "asdf"); + let resp = cli.post(&login_url).form(&p).send().unwrap(); + assert_eq!(resp.status(), http::StatusCode::UNAUTHORIZED); + + p.insert("password", "hunter2"); + let resp = cli.post(&login_url).form(&p).send().unwrap(); + assert_eq!(resp.status(), http::StatusCode::NO_CONTENT); + let cookie = SessionCookie::new(resp.headers()); + info!("cookie: {:?}", cookie); + info!("header: {}", cookie.header()); + + let resp = cli.get(&format!("{}/api/", &s.base_url)) + .header(header::COOKIE, cookie.header()) + .send() + .unwrap(); + assert_eq!(resp.status(), http::StatusCode::OK); + } + + #[test] + fn logout() { + testutil::init(); + let s = Server::new(); + let cli = reqwest::Client::new(); + let mut p = HashMap::new(); + p.insert("username", "slamb"); + p.insert("password", "hunter2"); + let resp = cli.post(&format!("{}/api/login", &s.base_url)).form(&p).send().unwrap(); + assert_eq!(resp.status(), http::StatusCode::NO_CONTENT); + let cookie = SessionCookie::new(resp.headers()); + + // A GET shouldn't work. + let resp = cli.get(&format!("{}/api/logout", &s.base_url)) + .header(header::COOKIE, cookie.header()) + .send() + .unwrap(); + assert_eq!(resp.status(), http::StatusCode::METHOD_NOT_ALLOWED); + + // Neither should a POST without a csrf token. + let resp = cli.post(&format!("{}/api/logout", &s.base_url)) + .header(header::COOKIE, cookie.header()) + .send() + .unwrap(); + assert_eq!(resp.status(), http::StatusCode::BAD_REQUEST); + + // But it should work with the csrf token. + // Retrieve that from the toplevel API request. + let toplevel: serde_json::Value = cli.post(&format!("{}/api/", &s.base_url)) + .header(header::COOKIE, cookie.header()) + .send().unwrap() + .json().unwrap(); + let csrf = toplevel.get("session").unwrap().get("csrf").unwrap().as_str(); + let mut p = HashMap::new(); + p.insert("csrf", csrf); + let resp = cli.post(&format!("{}/api/logout", &s.base_url)) + .header(header::COOKIE, cookie.header()) + .form(&p) + .send() + .unwrap(); + assert_eq!(resp.status(), http::StatusCode::NO_CONTENT); + let mut updated_cookie = cookie.clone(); + updated_cookie.update(resp.headers()); + + // The cookie should be cleared client-side. + assert!(updated_cookie.0.is_none()); + + // It should also be invalidated server-side. + let resp = cli.get(&format!("{}/api/", &s.base_url)) + .header(header::COOKIE, cookie.header()) + .send() + .unwrap(); + assert_eq!(resp.status(), http::StatusCode::UNAUTHORIZED); + } } #[cfg(all(test, feature="nightly"))] @@ -588,18 +1015,17 @@ mod bench { let db = TestDb::new(::base::clock::RealClocks {}); let test_camera_uuid = db.test_camera_uuid; testutil::add_dummy_recordings_to_db(&db.db, 1440); - let (tx, rx) = ::std::sync::mpsc::channel(); + let addr = "127.0.0.1:0".parse().unwrap(); + let require_auth = false; + let service = super::Service::new(db.db.clone(), None, require_auth, + "".to_owned()).unwrap(); + let server = hyper::server::Server::bind(&addr) + .tcp_nodelay(true) + .serve(move || Ok::<_, Box>(service.clone())); + let addr = server.local_addr(); // resolve port 0 to a real ephemeral port number. ::std::thread::spawn(move || { - let addr = "127.0.0.1:0".parse().unwrap(); - let service = super::Service::new(db.db.clone(), None, None, - "".to_owned()).unwrap(); - let server = hyper::server::Server::bind(&addr) - .tcp_nodelay(true) - .serve(move || Ok::<_, Box>(service.clone())); - tx.send(server.local_addr()).unwrap(); ::tokio::run(server.map_err(|e| panic!(e))); }); - let addr = rx.recv().unwrap(); Server { base_url: format!("http://{}:{}", addr.ip(), addr.port()), test_camera_uuid, diff --git a/ui-src/NVRApplication.js b/ui-src/NVRApplication.js index 2b559a40..3ae2e0c3 100644 --- a/ui-src/NVRApplication.js +++ b/ui-src/NVRApplication.js @@ -65,6 +65,7 @@ import MoonfireAPI from './lib/MoonfireAPI'; const api = new MoonfireAPI(); let streamViews = null; // StreamView objects let calendarView = null; // CalendarView object +let loginDialog = null; /** * Currently selected time format specification. @@ -206,7 +207,34 @@ function fetch(selectedRange, videoLength) { } /** - * Initialize the page after receiving camera data. + * Updates the session bar at the top of the page. + * + * @param {Object} session the "session" key of the main API request's JSON, + * or null. + */ +function updateSession(session) { + let sessionBar = $('#session'); + sessionBar.empty(); + if (session === null) { + sessionBar.hide(); + return; + } + sessionBar.append($('').text(session.username)); + let logout = $('logout'); + logout.click(() => { + api + .logout(session.csrf) + .done(() => { + onReceivedTopLevel(null); + loginDialog.dialog('open'); + }); + }); + sessionBar.append(' | ', logout); + sessionBar.show(); +} + +/** + * Initialize the page after receiving top-level data. * * Sets the following globals: * zone - timezone from data received @@ -215,9 +243,16 @@ function fetch(selectedRange, videoLength) { * Builds the dom for the left side controllers * * @param {Object} data JSON resulting from the main API request /api/?days= + * or null if the request failed. */ -function onReceivedCameras(data) { - newTimeZone(data.timeZoneName); +function onReceivedTopLevel(data) { + if (data === null) { + data = {cameras: [], session: null}; + } else { + newTimeZone(data.timeZoneName); + } + + updateSession(data.session); // Set up controls and values const nvrSettingsView = new NVRSettingsView(); @@ -236,6 +271,9 @@ function onReceivedCameras(data) { const streamsParent = $('#streams'); const videos = $('#videos'); + streamsParent.empty(); + videos.empty(); + streamViews = []; let streamSelectorCameras = []; for (const cameraJson of data.cameras) { @@ -276,6 +314,57 @@ function onReceivedCameras(data) { console.log('Loaded: ' + streamViews.length + ' stream views'); } +/** + * Handles the submit action on the login form. + */ +function sendLoginRequest() { + if (loginDialog.pending) { + return; + } + + let username = $('#login-username').val(); + let password = $('#login-password').val(); + let submit = $('#login-submit'); + let error = $('#login-error'); + + error.empty(); + error.removeClass('ui-state-highlight'); + submit.button('option', 'disabled', true); + loginDialog.pending = true; + console.info('logging in as', username); + api + .login(username, password) + .done(() => { + console.info('login successful'); + loginDialog.dialog('close'); + sendTopLevelRequest(); + }) + .catch((e) => { + console.info('login failed:', e); + error.show(); + error.addClass('ui-state-highlight'); + error.text(e.responseText); + }) + .always(() => { + submit.button('option', 'disabled', false); + loginDialog.pending = false; + }); +} + +/** Sends the top-level api request. */ +function sendTopLevelRequest() { + api + .request(api.nvrUrl(true)) + .done((data) => onReceivedTopLevel(data)) + .catch((e) => { + console.error('NVR load exception: ', e); + onReceivedTopLevel(null); + if (e.status == 401) { + loginDialog.dialog('open'); + } + }); +} + /** * Class representing the entire application. */ @@ -289,16 +378,22 @@ export default class NVRApplication { * Start the application. */ start() { - api - .request(api.nvrUrl(true)) - .done((data) => onReceivedCameras(data)) - .fail((req, status, err) => { - console.error('NVR load error: ', status, err); - onReceivedCameras({cameras: []}); - }) - .catch((e) => { - console.error('NVR load exception: ', e); - onReceivedCameras({cameras: []}); - }); + loginDialog = $('#login').dialog({ + autoOpen: false, + modal: true, + buttons: [ + { + id: 'login-submit', + text: 'Login', + click: sendLoginRequest, + }, + ], + }); + loginDialog.pending = false; + loginDialog.find('form').on('submit', function(event) { + event.preventDefault(); + sendLoginRequest(); + }); + sendTopLevelRequest(); } } diff --git a/ui-src/assets/index.css b/ui-src/assets/index.css index f8ac7f97..e18d65b0 100644 --- a/ui-src/assets/index.css +++ b/ui-src/assets/index.css @@ -4,6 +4,9 @@ body { #nav { float: left; } +#session { + float: right; +} #datetime .ui-datepicker { width: 100%; diff --git a/ui-src/assets/index.html b/ui-src/assets/index.html index 6687d8ba..d6b764a9 100644 --- a/ui-src/assets/index.html +++ b/ui-src/assets/index.html @@ -5,6 +5,8 @@ Moonfire NVR +
+
-
- +
+
+
+
+ + + + + + + + + + + + + +
+

+
+
+
+ diff --git a/ui-src/lib/MoonfireAPI.js b/ui-src/lib/MoonfireAPI.js index 3ce8cf8e..987b6366 100644 --- a/ui-src/lib/MoonfireAPI.js +++ b/ui-src/lib/MoonfireAPI.js @@ -158,4 +158,37 @@ export default class MoonfireAPI { cache: cacheOk, }); } + + /** + * Start a new AJAX request to log in. + * + * @param {String} username + * @param {String} password + * @return {Request} + */ + login(username, password) { + return $.ajax(this._builder.makeUrl('login'), { + data: { + username: username, + password: password, + }, + method: 'POST', + }); + } + + /** + * Start a new AJAX request to log out. + * + * @param {String} csrf: the csrf request token as returned in + * /api/ response JSON. + * @return {Request} + */ + logout(csrf) { + return $.ajax(this._builder.makeUrl('logout'), { + data: { + csrf: csrf, + }, + method: 'POST', + }); + } }