diff --git a/server/src/web/mod.rs b/server/src/web/mod.rs index f6b9a0b..2e36f05 100644 --- a/server/src/web/mod.rs +++ b/server/src/web/mod.rs @@ -4,6 +4,7 @@ mod live; mod path; +mod session; mod static_file; mod view; @@ -24,9 +25,7 @@ use http::method::Method; use http::{status::StatusCode, Request, Response}; use http_serve::dir::FsDir; use hyper::body::Bytes; -use log::{debug, info, warn}; -use memchr::memchr; -use std::convert::TryFrom; +use log::{debug, warn}; use std::net::IpAddr; use std::sync::Arc; use url::form_urlencoded; @@ -565,98 +564,6 @@ impl Service { .unwrap_or(false) } - async fn login(&self, mut req: Request<::hyper::Body>) -> ResponseResult { - let r = extract_json_body(&mut req).await?; - let r: json::LoginRequest = - serde_json::from_slice(&r).map_err(|e| bad_req(e.to_string()))?; - 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(b':', host) { - Some(colon) => &host[0..colon], - None => host, - } - .to_owned(); - let mut l = self.db.lock(); - - // If the request came in over https, tell the browser to only send the cookie on https - // requests also. - let is_secure = self.is_secure(&req); - - // Use SameSite=Lax rather than SameSite=Strict. Safari apparently doesn't send - // SameSite=Strict cookies on WebSocket upgrade requests. There's no real security - // difference for Moonfire NVR anyway. SameSite=Strict exists as CSRF protection for - // sites that (unlike Moonfire NVR) don't follow best practices by (a) - // mutating based on GET requests and (b) not using CSRF tokens. - use auth::SessionFlag; - let flags = (SessionFlag::HttpOnly as i32) - | (SessionFlag::SameSite as i32) - | if is_secure { - SessionFlag::Secure as i32 - } else { - 0 - }; - let (sid, _) = l - .login_by_password(authreq, &r.username, r.password, Some(domain), flags) - .map_err(|e| plain_response(StatusCode::UNAUTHORIZED, e.to_string()))?; - let cookie = encode_sid(sid, flags); - Ok(Response::builder() - .header( - header::SET_COOKIE, - HeaderValue::try_from(cookie).expect("cookie can't have invalid bytes"), - ) - .status(StatusCode::NO_CONTENT) - .body(b""[..].into()) - .unwrap()) - } - - async fn logout(&self, mut req: Request) -> ResponseResult { - let r = extract_json_body(&mut req).await?; - let r: json::LogoutRequest = - serde_json::from_slice(&r).map_err(|e| bad_req(e.to_string()))?; - - 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, _)) => { - if !csrf_matches(r.csrf, s.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) - } - async fn post_signals(&self, mut req: Request, caller: Caller) -> ResponseResult { if !caller.permissions.update_signals { bail_t!(PermissionDenied, "update_signals required"); @@ -769,33 +676,10 @@ impl Service { } } -/// Encodes a session into `Set-Cookie` header value form. -fn encode_sid(sid: db::RawSessionId, flags: i32) -> String { - let mut cookie = String::with_capacity(128); - cookie.push_str("s="); - base64::encode_config_buf(&sid, base64::STANDARD_NO_PAD, &mut cookie); - use auth::SessionFlag; - if (flags & SessionFlag::HttpOnly as i32) != 0 { - cookie.push_str("; HttpOnly"); - } - if (flags & SessionFlag::Secure as i32) != 0 { - cookie.push_str("; Secure"); - } - if (flags & SessionFlag::SameSiteStrict as i32) != 0 { - cookie.push_str("; SameSite=Strict"); - } else if (flags & SessionFlag::SameSite as i32) != 0 { - cookie.push_str("; SameSite=Lax"); - } - cookie.push_str("; Max-Age=2147483648; Path=/"); - cookie -} - #[cfg(test)] mod tests { use db::testutil::{self, TestDb}; use futures::future::FutureExt; - use log::info; - use std::collections::HashMap; use std::sync::Arc; pub(super) struct Server { @@ -864,39 +748,6 @@ mod tests { } } - #[derive(Clone, Debug, Default)] - struct SessionCookie(Option); - - impl SessionCookie { - pub fn new(headers: &reqwest::header::HeaderMap) -> Self { - let mut c = SessionCookie::default(); - c.update(headers); - c - } - - pub fn update(&mut self, headers: &reqwest::header::HeaderMap) { - for set_cookie in headers.get_all(reqwest::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.clone().unwrap() - } - } - #[tokio::test] async fn unauthorized_without_cookie() { testutil::init(); @@ -909,146 +760,6 @@ mod tests { .unwrap(); assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED); } - - #[tokio::test] - async fn login() { - testutil::init(); - let s = Server::new(None); - let cli = reqwest::Client::new(); - let login_url = format!("{}/api/login", &s.base_url); - - let resp = cli.get(&login_url).send().await.unwrap(); - assert_eq!(resp.status(), reqwest::StatusCode::METHOD_NOT_ALLOWED); - - let resp = cli.post(&login_url).send().await.unwrap(); - assert_eq!(resp.status(), reqwest::StatusCode::BAD_REQUEST); - - let mut p = HashMap::new(); - p.insert("username", "slamb"); - p.insert("password", "asdf"); - let resp = cli.post(&login_url).json(&p).send().await.unwrap(); - assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED); - - p.insert("password", "hunter2"); - let resp = cli.post(&login_url).json(&p).send().await.unwrap(); - assert_eq!(resp.status(), reqwest::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(reqwest::header::COOKIE, cookie.header()) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), reqwest::StatusCode::OK); - } - - #[tokio::test] - async fn logout() { - testutil::init(); - let s = Server::new(None); - 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)) - .json(&p) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), reqwest::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(reqwest::header::COOKIE, cookie.header()) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), reqwest::StatusCode::METHOD_NOT_ALLOWED); - - // Neither should a POST without a csrf token. - let resp = cli - .post(&format!("{}/api/logout", &s.base_url)) - .header(reqwest::header::COOKIE, cookie.header()) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), reqwest::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(reqwest::header::COOKIE, cookie.header()) - .send() - .await - .unwrap() - .json() - .await - .unwrap(); - let csrf = toplevel - .get("user") - .unwrap() - .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(reqwest::header::COOKIE, cookie.header()) - .json(&p) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), reqwest::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(reqwest::header::COOKIE, cookie.header()) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED); - } - - #[test] - fn encode_sid() { - use super::encode_sid; - use db::auth::{RawSessionId, SessionFlag}; - let s64 = "3LbrruP5vj/hpE8kvYTz/rNDg4BleRiTCHGA3Ocm91z/YrtxHDxexmrz46biZJxJ"; - let s = RawSessionId::decode_base64(s64.as_bytes()).unwrap(); - assert_eq!( - encode_sid( - s, - (SessionFlag::Secure as i32) - | (SessionFlag::HttpOnly as i32) - | (SessionFlag::SameSite as i32) - | (SessionFlag::SameSiteStrict as i32) - ), - format!( - "s={}; HttpOnly; Secure; SameSite=Strict; Max-Age=2147483648; Path=/", - s64 - ) - ); - assert_eq!( - encode_sid(s, SessionFlag::SameSite as i32), - format!("s={}; SameSite=Lax; Max-Age=2147483648; Path=/", s64) - ); - } } #[cfg(all(test, feature = "nightly"))] diff --git a/server/src/web/session.rs b/server/src/web/session.rs new file mode 100644 index 0000000..33cb38c --- /dev/null +++ b/server/src/web/session.rs @@ -0,0 +1,315 @@ +// This file is part of Moonfire NVR, a security camera network video recorder. +// Copyright (C) 2021 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. +// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception. + +//! Session management: `/api/login` and `/api/logout`. + +use db::auth; +use http::{header, HeaderValue, Request, Response, StatusCode}; +use log::{info, warn}; +use memchr::memchr; + +use crate::json; + +use super::{ + bad_req, csrf_matches, extract_json_body, extract_sid, internal_server_err, plain_response, + ResponseResult, Service, +}; +use std::convert::TryFrom; + +impl Service { + pub(super) async fn login(&self, mut req: Request<::hyper::Body>) -> ResponseResult { + let r = extract_json_body(&mut req).await?; + let r: json::LoginRequest = + serde_json::from_slice(&r).map_err(|e| bad_req(e.to_string()))?; + 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(b':', host) { + Some(colon) => &host[0..colon], + None => host, + } + .to_owned(); + let mut l = self.db.lock(); + + // If the request came in over https, tell the browser to only send the cookie on https + // requests also. + let is_secure = self.is_secure(&req); + + // Use SameSite=Lax rather than SameSite=Strict. Safari apparently doesn't send + // SameSite=Strict cookies on WebSocket upgrade requests. There's no real security + // difference for Moonfire NVR anyway. SameSite=Strict exists as CSRF protection for + // sites that (unlike Moonfire NVR) don't follow best practices by (a) + // mutating based on GET requests and (b) not using CSRF tokens. + use auth::SessionFlag; + let flags = (SessionFlag::HttpOnly as i32) + | (SessionFlag::SameSite as i32) + | if is_secure { + SessionFlag::Secure as i32 + } else { + 0 + }; + let (sid, _) = l + .login_by_password(authreq, &r.username, r.password, Some(domain), flags) + .map_err(|e| plain_response(StatusCode::UNAUTHORIZED, e.to_string()))?; + let cookie = encode_sid(sid, flags); + Ok(Response::builder() + .header( + header::SET_COOKIE, + HeaderValue::try_from(cookie).expect("cookie can't have invalid bytes"), + ) + .status(StatusCode::NO_CONTENT) + .body(b""[..].into()) + .unwrap()) + } + + pub(super) async fn logout(&self, mut req: Request) -> ResponseResult { + let r = extract_json_body(&mut req).await?; + let r: json::LogoutRequest = + serde_json::from_slice(&r).map_err(|e| bad_req(e.to_string()))?; + + 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, _)) => { + if !csrf_matches(r.csrf, s.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) + } +} + +/// Encodes a session into `Set-Cookie` header value form. +fn encode_sid(sid: db::RawSessionId, flags: i32) -> String { + let mut cookie = String::with_capacity(128); + cookie.push_str("s="); + base64::encode_config_buf(&sid, base64::STANDARD_NO_PAD, &mut cookie); + use auth::SessionFlag; + if (flags & SessionFlag::HttpOnly as i32) != 0 { + cookie.push_str("; HttpOnly"); + } + if (flags & SessionFlag::Secure as i32) != 0 { + cookie.push_str("; Secure"); + } + if (flags & SessionFlag::SameSiteStrict as i32) != 0 { + cookie.push_str("; SameSite=Strict"); + } else if (flags & SessionFlag::SameSite as i32) != 0 { + cookie.push_str("; SameSite=Lax"); + } + cookie.push_str("; Max-Age=2147483648; Path=/"); + cookie +} + +#[cfg(test)] +mod tests { + use db::testutil; + use fnv::FnvHashMap; + use log::info; + + use crate::web::tests::Server; + + #[tokio::test] + async fn login() { + testutil::init(); + let s = Server::new(None); + let cli = reqwest::Client::new(); + let login_url = format!("{}/api/login", &s.base_url); + + let resp = cli.get(&login_url).send().await.unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::METHOD_NOT_ALLOWED); + + let resp = cli.post(&login_url).send().await.unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::BAD_REQUEST); + + let mut p = FnvHashMap::default(); + p.insert("username", "slamb"); + p.insert("password", "asdf"); + let resp = cli.post(&login_url).json(&p).send().await.unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED); + + p.insert("password", "hunter2"); + let resp = cli.post(&login_url).json(&p).send().await.unwrap(); + assert_eq!(resp.status(), reqwest::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(reqwest::header::COOKIE, cookie.header()) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::OK); + } + + #[tokio::test] + async fn logout() { + testutil::init(); + let s = Server::new(None); + let cli = reqwest::Client::new(); + let mut p = FnvHashMap::default(); + p.insert("username", "slamb"); + p.insert("password", "hunter2"); + let resp = cli + .post(&format!("{}/api/login", &s.base_url)) + .json(&p) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::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(reqwest::header::COOKIE, cookie.header()) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::METHOD_NOT_ALLOWED); + + // Neither should a POST without a csrf token. + let resp = cli + .post(&format!("{}/api/logout", &s.base_url)) + .header(reqwest::header::COOKIE, cookie.header()) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::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(reqwest::header::COOKIE, cookie.header()) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let csrf = toplevel + .get("user") + .unwrap() + .get("session") + .unwrap() + .get("csrf") + .unwrap() + .as_str(); + let mut p = FnvHashMap::default(); + p.insert("csrf", csrf); + let resp = cli + .post(&format!("{}/api/logout", &s.base_url)) + .header(reqwest::header::COOKIE, cookie.header()) + .json(&p) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::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(reqwest::header::COOKIE, cookie.header()) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED); + } + + #[test] + fn encode_sid() { + use super::encode_sid; + use db::auth::{RawSessionId, SessionFlag}; + let s64 = "3LbrruP5vj/hpE8kvYTz/rNDg4BleRiTCHGA3Ocm91z/YrtxHDxexmrz46biZJxJ"; + let s = RawSessionId::decode_base64(s64.as_bytes()).unwrap(); + assert_eq!( + encode_sid( + s, + (SessionFlag::Secure as i32) + | (SessionFlag::HttpOnly as i32) + | (SessionFlag::SameSite as i32) + | (SessionFlag::SameSiteStrict as i32) + ), + format!( + "s={}; HttpOnly; Secure; SameSite=Strict; Max-Age=2147483648; Path=/", + s64 + ) + ); + assert_eq!( + encode_sid(s, SessionFlag::SameSite as i32), + format!("s={}; SameSite=Lax; Max-Age=2147483648; Path=/", s64) + ); + } + + #[derive(Clone, Debug, Default)] + struct SessionCookie(Option); + + impl SessionCookie { + pub fn new(headers: &reqwest::header::HeaderMap) -> Self { + let mut c = SessionCookie::default(); + c.update(headers); + c + } + + pub fn update(&mut self, headers: &reqwest::header::HeaderMap) { + for set_cookie in headers.get_all(reqwest::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.clone().unwrap() + } + } +}