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