mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2024-12-24 22:25:55 -05:00
preliminary web support for auth (#26)
Some caveats: * it doesn't record the peer IP yet, which makes it harder to verify sessions are valid. This is a little annoying to do in hyper now (see hyperium/hyper#1410). The direct peer might not be what we want right now anyway because there's no TLS support yet (see #27). In the meantime, the sane way to expose Moonfire NVR to the Internet is via a proxy server, and recording the proxy's IP is not useful. Maybe better to interpret a RFC 7239 Forwarded header (and/or the older X-Forwarded-{For,Proto} headers). * it doesn't ever use Secure (https-only) cookies, for a similar reason. It's not safe to use even with a tls proxy until this is fixed. * there's no "moonfire-nvr config" support for inspecting/invalidating sessions yet. * in debug builds, logging in is crazy slow. See libpasta/libpasta#9. Some notes: * I removed the Javascript "no-use-before-defined" lint, as some of the functions form a cycle. * Fixed #20 along the way. I needed to add support for properly returning non-OK HTTP statuses to signal unauthorized and such. * I removed the Access-Control-Allow-Origin header support, which was at odds with the "SameSite=lax" in the cookie header. The "yarn start" method for running a local proxy server accomplishes the same thing as the Access-Control-Allow-Origin support in a more secure manner.
This commit is contained in:
parent
679370c77a
commit
422cd2a75e
@ -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 }]
|
||||
}
|
||||
}
|
||||
|
22
Cargo.lock
generated
22
Cargo.lock
generated
@ -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)" = "<none>"
|
||||
"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"
|
||||
|
@ -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"
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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" }
|
||||
|
200
db/auth.rs
200
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<Arc<libpasta::Config>> =
|
||||
Mutex::new(Arc::new(libpasta::Config::default()));
|
||||
}
|
||||
|
||||
/// For testing only: use a fast but insecure libpasta config.
|
||||
/// See also <https://github.com/libpasta/libpasta/issues/9>.
|
||||
/// 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<u8>,
|
||||
description: Option<String>,
|
||||
seed: Seed,
|
||||
|
||||
creation_password_id: Option<i32>,
|
||||
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<Self, Error> {
|
||||
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<Self, Error> {
|
||||
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<Self> {
|
||||
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<String>, req: Request, hash: SessionHash)
|
||||
detail: Option<String>, 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<Session, Error> {
|
||||
fn lookup_session(conn: &Connection, hash: &SessionHash) -> Result<Session, Error> {
|
||||
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<Session, Error
|
||||
Some(Err(e)) => 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");
|
||||
}
|
||||
}
|
||||
|
6
db/db.rs
6
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<String>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -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.
|
||||
|
12
src/body.rs
12
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<String> for Chunk {
|
||||
fn from(r: String) -> Self { Chunk(ARefs::new(r.into_bytes()).map(|v| &v[..])) }
|
||||
}
|
||||
|
||||
impl From<Vec<u8>> for Chunk {
|
||||
fn from(r: Vec<u8>) -> Self { Chunk(ARefs::new(r).map(|v| &v[..])) }
|
||||
}
|
||||
@ -81,8 +89,8 @@ impl From<BodyStream> for Body {
|
||||
fn from(b: BodyStream) -> Self { Body(b) }
|
||||
}
|
||||
|
||||
impl From<&'static [u8]> for Body {
|
||||
fn from(c: &'static [u8]) -> Self {
|
||||
impl<C: Into<Chunk>> From<C> for Body {
|
||||
fn from(c: C) -> Self {
|
||||
Body(Box::new(stream::once(Ok(c.into()))))
|
||||
}
|
||||
}
|
||||
|
@ -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<String>,
|
||||
flag_require_auth: bool,
|
||||
}
|
||||
|
||||
fn setup_shutdown() -> impl Future<Item = (), Error = ()> + 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));
|
||||
|
22
src/json.rs
22
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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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<Session>,
|
||||
}
|
||||
|
||||
#[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<S>(csrf: &SessionHash, serializer: S) -> Result<S::Ok, S::Error>
|
||||
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
|
||||
|
@ -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;
|
||||
|
592
src/web.rs
592
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/<sha1>.mp4"
|
||||
@ -71,7 +75,9 @@ enum Path {
|
||||
StreamRecordings(Uuid, db::StreamType), // "/api/cameras/<uuid>/<type>/recordings"
|
||||
StreamViewMp4(Uuid, db::StreamType), // "/api/cameras/<uuid>/<type>/view.mp4"
|
||||
StreamViewMp4Segment(Uuid, db::StreamType), // "/api/cameras/<uuid>/<type>/view.m4s"
|
||||
Static, // "<other path>"
|
||||
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<B: Into<Body>>(status: http::StatusCode, body: B) -> Response<Body> {
|
||||
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<B: Into<Body>>(body: B) -> Response<Body> {
|
||||
plain_response(StatusCode::NOT_FOUND, body)
|
||||
}
|
||||
|
||||
fn bad_req<B: Into<Body>>(body: B) -> Response<Body> {
|
||||
plain_response(StatusCode::BAD_REQUEST, body)
|
||||
}
|
||||
|
||||
fn internal_server_err<E: Into<Error>>(err: E) -> Response<Body> {
|
||||
plain_response(StatusCode::INTERNAL_SERVER_ERROR, err.into().to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
struct Segments {
|
||||
ids: Range<i32>,
|
||||
@ -190,25 +220,20 @@ struct ServiceInner {
|
||||
db: Arc<db::Database>,
|
||||
dirs_by_stream_id: Arc<FnvHashMap<i32, Arc<SampleFileDir>>>,
|
||||
ui_files: HashMap<String, UiFile>,
|
||||
allow_origin: Option<HeaderValue>,
|
||||
pool: futures_cpupool::CpuPool,
|
||||
time_zone_name: String,
|
||||
require_auth: bool,
|
||||
}
|
||||
|
||||
impl ServiceInner {
|
||||
fn not_found(&self) -> Result<Response<Body>, 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<Body>, Response<Body>>;
|
||||
|
||||
fn top_level(&self, req: &Request<::hyper::Body>) -> Result<Response<Body>, Error> {
|
||||
impl ServiceInner {
|
||||
fn top_level(&self, req: &Request<::hyper::Body>, session: Option<json::Session>)
|
||||
-> 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<Response<Body>, 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<Response<Body>, 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<Response<Body>, 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<Response<Body>, 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<Response<Body>, 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<hyper::Body>, 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<hyper::Body>) -> Result<Option<json::Session>, 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<hyper::Body>) -> Option<auth::RawSessionId> {
|
||||
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<ServiceInner>);
|
||||
|
||||
impl Service {
|
||||
pub fn new(db: Arc<db::Database>, ui_dir: Option<&str>, allow_origin: Option<String>,
|
||||
zone: String) -> Result<Self, Error> {
|
||||
pub fn new(db: Arc<db::Database>, ui_dir: Option<&str>, require_auth: bool,
|
||||
time_zone_name: String) -> Result<Self, Error> {
|
||||
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<hyper::Body>)
|
||||
-> Box<Future<Item = (Request<hyper::Body>, hyper::Chunk),
|
||||
Error = Response<Body>> +
|
||||
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<Response<Self::ResBody>, Self::Error>;
|
||||
type Future = Box<Future<Item = Response<Self::ResBody>, 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<R: Future<Item = Response<Body>, Error = Response<Body>> + Send + 'static>(r: R)
|
||||
-> Box<Future<Item = Response<Body>, Error = BoxedError> + Send + 'static> {
|
||||
return Box::new(r.or_else(|e| Ok(e)))
|
||||
}
|
||||
|
||||
fn wrap_r(r: ResponseResult)
|
||||
-> Box<Future<Item = Response<Body>, 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<futures::sync::oneshot::Sender<()>>,
|
||||
}
|
||||
|
||||
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<StdError + Send + Sync>>(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<String>);
|
||||
|
||||
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<StdError + Send + Sync>>(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<StdError + Send + Sync>>(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,
|
||||
|
@ -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($('<span id="session-username" />').text(session.username));
|
||||
let logout = $('<a>logout</a>');
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,9 @@ body {
|
||||
#nav {
|
||||
float: left;
|
||||
}
|
||||
#session {
|
||||
float: right;
|
||||
}
|
||||
|
||||
#datetime .ui-datepicker {
|
||||
width: 100%;
|
||||
|
@ -5,6 +5,8 @@
|
||||
<title>Moonfire NVR</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="session">
|
||||
</div>
|
||||
<div id="nav">
|
||||
<form action="#">
|
||||
<fieldset>
|
||||
@ -72,6 +74,27 @@
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
<table id="videos"></table>
|
||||
</body>
|
||||
<table id="videos"></table>
|
||||
<div id="login">
|
||||
<form>
|
||||
<fieldset>
|
||||
<table>
|
||||
<tr>
|
||||
<td><label for="login-username">Username:</label></td>
|
||||
<td><input type="text" id="login-username" name="username"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="login-password">Password:</label></td>
|
||||
<td><input type="password" id="login-password" name="password"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td><input type="submit" tabindex="-1" style="position:absolute; top:-1000px"></td>
|
||||
</tr>
|
||||
</table>
|
||||
<p id="login-error"></p>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -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
|
||||
* <tt>/api/</tt> response JSON.
|
||||
* @return {Request}
|
||||
*/
|
||||
logout(csrf) {
|
||||
return $.ajax(this._builder.makeUrl('logout'), {
|
||||
data: {
|
||||
csrf: csrf,
|
||||
},
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user