mirror of
				https://github.com/scottlamb/moonfire-nvr.git
				synced 2025-10-30 00:05:03 -04:00 
			
		
		
		
	extract /api/{login,logout} to their own file
This commit is contained in:
		
							parent
							
								
									bae45a0855
								
							
						
					
					
						commit
						1f41a27cc3
					
				| @ -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<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) | ||||
|     } | ||||
| 
 | ||||
|     async fn post_signals(&self, mut req: Request<hyper::Body>, 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<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() | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     #[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"))] | ||||
|  | ||||
							
								
								
									
										315
									
								
								server/src/web/session.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										315
									
								
								server/src/web/session.rs
									
									
									
									
									
										Normal file
									
								
							| @ -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<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() | ||||
|         } | ||||
|     } | ||||
| } | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user