mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2025-02-05 10:48:08 -05: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 live;
|
||||||
mod path;
|
mod path;
|
||||||
|
mod session;
|
||||||
mod static_file;
|
mod static_file;
|
||||||
mod view;
|
mod view;
|
||||||
|
|
||||||
@ -24,9 +25,7 @@ use http::method::Method;
|
|||||||
use http::{status::StatusCode, Request, Response};
|
use http::{status::StatusCode, Request, Response};
|
||||||
use http_serve::dir::FsDir;
|
use http_serve::dir::FsDir;
|
||||||
use hyper::body::Bytes;
|
use hyper::body::Bytes;
|
||||||
use log::{debug, info, warn};
|
use log::{debug, warn};
|
||||||
use memchr::memchr;
|
|
||||||
use std::convert::TryFrom;
|
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use url::form_urlencoded;
|
use url::form_urlencoded;
|
||||||
@ -565,98 +564,6 @@ impl Service {
|
|||||||
.unwrap_or(false)
|
.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 {
|
async fn post_signals(&self, mut req: Request<hyper::Body>, caller: Caller) -> ResponseResult {
|
||||||
if !caller.permissions.update_signals {
|
if !caller.permissions.update_signals {
|
||||||
bail_t!(PermissionDenied, "update_signals required");
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use db::testutil::{self, TestDb};
|
use db::testutil::{self, TestDb};
|
||||||
use futures::future::FutureExt;
|
use futures::future::FutureExt;
|
||||||
use log::info;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub(super) struct Server {
|
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]
|
#[tokio::test]
|
||||||
async fn unauthorized_without_cookie() {
|
async fn unauthorized_without_cookie() {
|
||||||
testutil::init();
|
testutil::init();
|
||||||
@ -909,146 +760,6 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED);
|
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"))]
|
#[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