2021-10-28 13:57:32 -07:00
|
|
|
// 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
|
2022-09-28 09:29:16 -07:00
|
|
|
.login_by_password(authreq, r.username, r.password, Some(domain), flags)
|
2021-10-28 13:57:32 -07:00
|
|
|
.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<hyper::Body>) -> 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<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()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|