// 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, Method, 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 { if *req.method() != Method::POST { return Err(plain_response(StatusCode::METHOD_NOT_ALLOWED, "POST expected").into()); } 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 { if *req.method() != Method::POST { return Err(plain_response(StatusCode::METHOD_NOT_ALLOWED, "POST expected").into()); } 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() } } }