mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2025-02-19 09:32:31 -05:00
In the process, no longer wait for pending HTTP requests on shutdown. This just extended the time Moonfire was running without streaming.
321 lines
11 KiB
Rust
321 lines
11 KiB
Rust
// 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 base::{bail, ErrorKind, ResultExt};
|
|
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
|
|
use db::auth;
|
|
use http::{header, HeaderValue, Method, Request, Response, StatusCode};
|
|
use memchr::memchr;
|
|
use tracing::{info, warn};
|
|
|
|
use crate::{json, web::parse_json_body};
|
|
|
|
use super::{csrf_matches, extract_sid, into_json_body, plain_response, ResponseResult, Service};
|
|
use std::convert::TryFrom;
|
|
|
|
impl Service {
|
|
pub(super) async fn login(
|
|
&self,
|
|
req: Request<::hyper::body::Incoming>,
|
|
authreq: auth::Request,
|
|
) -> ResponseResult {
|
|
if *req.method() != Method::POST {
|
|
return Ok(plain_response(
|
|
StatusCode::METHOD_NOT_ALLOWED,
|
|
"POST expected",
|
|
));
|
|
}
|
|
let (parts, b) = into_json_body(req).await?;
|
|
let r: json::LoginRequest = parse_json_body(&b)?;
|
|
let Some(host) = parts.headers.get(header::HOST) else {
|
|
bail!(InvalidArgument, msg("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(&parts.headers);
|
|
|
|
// 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)
|
|
.err_kind(ErrorKind::Unauthenticated)?;
|
|
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,
|
|
req: Request<hyper::body::Incoming>,
|
|
authreq: auth::Request,
|
|
) -> ResponseResult {
|
|
if *req.method() != Method::POST {
|
|
return Ok(plain_response(
|
|
StatusCode::METHOD_NOT_ALLOWED,
|
|
"POST expected",
|
|
));
|
|
}
|
|
let (parts, b) = into_json_body(req).await?;
|
|
let r: json::LogoutRequest = parse_json_body(&b)?;
|
|
|
|
let mut res = Response::new(b""[..].into());
|
|
if let Some(sid) = extract_sid(&parts.headers) {
|
|
let mut l = self.db.lock();
|
|
let hash = sid.hash();
|
|
match l.authenticate_session(authreq.clone(), &hash) {
|
|
Ok((s, _)) => {
|
|
if !csrf_matches(r.csrf, s.csrf()) {
|
|
bail!(InvalidArgument, msg("logout with incorrect csrf token"));
|
|
}
|
|
info!("revoking session");
|
|
l.revoke_session(auth::RevocationReason::LoggedOut, None, authreq, &hash)
|
|
.err_kind(ErrorKind::Internal)?;
|
|
}
|
|
Err(err) => {
|
|
// 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!(err = %err.chain(), "logout failed");
|
|
}
|
|
}
|
|
|
|
// 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=");
|
|
STANDARD_NO_PAD.encode_string(sid, &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 base::FastHashMap;
|
|
use db::testutil;
|
|
use tracing::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 = FastHashMap::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 = FastHashMap::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 = FastHashMap::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={s64}; HttpOnly; Secure; SameSite=Strict; Max-Age=2147483648; Path=/")
|
|
);
|
|
assert_eq!(
|
|
encode_sid(s, SessionFlag::SameSite as i32),
|
|
format!("s={s64}; SameSite=Lax; Max-Age=2147483648; Path=/")
|
|
);
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
struct SessionCookie(Option<String>);
|
|
|
|
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()
|
|
}
|
|
}
|
|
}
|