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

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

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