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:
Scott Lamb 2018-11-25 21:31:50 -08:00
parent 679370c77a
commit 422cd2a75e
21 changed files with 923 additions and 212 deletions

View File

@ -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
View File

@ -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"

View File

@ -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"

View File

@ -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)

View File

@ -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" }

View File

@ -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",
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().0;
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",
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().0;
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");
}
}

View File

@ -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)
}
}

View File

@ -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;

View File

@ -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

View File

@ -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();
});
}

View File

@ -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",
}
}
```

View File

@ -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.

View File

@ -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()))))
}
}

View File

@ -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));

View File

@ -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

View File

@ -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;

View File

@ -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();
::std::thread::spawn(move || {
let addr = "127.0.0.1:0".parse().unwrap();
let service = super::Service::new(db.db.clone(), None, None,
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()));
tx.send(server.local_addr()).unwrap();
let addr = server.local_addr(); // resolve port 0 to a real ephemeral port number.
::std::thread::spawn(move || {
::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,

View File

@ -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) {
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();
}
}

View File

@ -4,6 +4,9 @@ body {
#nav {
float: left;
}
#session {
float: right;
}
#datetime .ui-datepicker {
width: 100%;

View File

@ -5,6 +5,8 @@
<title>Moonfire NVR</title>
</head>
<body>
<div id="session">
</div>
<div id="nav">
<form action="#">
<fieldset>
@ -73,5 +75,26 @@
</form>
</div>
<table id="videos"></table>
</body>
<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>

View File

@ -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',
});
}
}