diff --git a/CHANGELOG.md b/CHANGELOG.md index cd9a0c1..827e7bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Each release is tagged in Git and on the Docker repository and some TP-Link cameras. Fixes [#238](https://github.com/scottlamb/moonfire-nvr/issues/238). * expanded API interface for examining and updating users: * `admin_users` permission for operating on arbitrary users. + * `GET /users/` endpoint * `GET /users/` endpoint * expanded `POST /users/` endpoint, including password and permissions. diff --git a/design/api.md b/design/api.md index c23f794..51ccc13 100644 --- a/design/api.md +++ b/design/api.md @@ -22,8 +22,10 @@ Status: **current**. * [Request 1](#request-1) * [Request 2](#request-2) * [Request 3](#request-3) - * [`GET /api/users/`](#get-apiusersid) - * [`POST /api/users/`](#post-apiusersid) + * [User management](#user-management) + * [`GET /api/users`](#get-apiusers) + * [`GET /api/users/`](#get-apiusersid) + * [`POST /api/users/`](#post-apiusersid) ## Objective @@ -823,7 +825,16 @@ Response: } ``` -### `GET /api/users/` +### User management + +#### `GET /api/users` + +Requires the `admin_users` permission. + +Lists all users. Currently there's no paging. Returns a JSON object with +a `users` key with a map of id to username. + +#### `GET /api/users/` Retrieves the user. Requires the `admin_users` permission if the caller is not authenticated as the user in question. @@ -836,7 +847,7 @@ Returns a HTTP status 200 on success with a JSON dict: be retrieved. * `permissions`. -### `POST /api/users/` +#### `POST /api/users/` Allows updating the given user. Requires the `admin_users` permission if the caller is not authenticated as the user in question. diff --git a/server/src/json.rs b/server/src/json.rs index a695634..719518f 100644 --- a/server/src/json.rs +++ b/server/src/json.rs @@ -9,6 +9,7 @@ use db::auth::SessionHash; use failure::{format_err, Error}; use serde::ser::{Error as _, SerializeMap, SerializeSeq, Serializer}; use serde::{Deserialize, Deserializer, Serialize}; +use std::collections::BTreeMap; use std::ops::Not; use uuid::Uuid; @@ -585,3 +586,9 @@ impl From<&db::schema::Permissions> for Permissions { } } } + +/// Response to `GET /users/`. +#[derive(Serialize)] +pub struct UsersResponse { + pub users: BTreeMap, +} diff --git a/server/src/web/mod.rs b/server/src/web/mod.rs index 30f3f3d..ef0c775 100644 --- a/server/src/web/mod.rs +++ b/server/src/web/mod.rs @@ -8,15 +8,15 @@ mod path; mod session; mod signals; mod static_file; +mod users; mod view; use self::accept::ConnData; use self::path::Path; use crate::body::Body; use crate::json; -use crate::json::UserSubset; use crate::mp4; -use base::{bail_t, clock::Clocks, format_err_t, ErrorKind}; +use base::{bail_t, clock::Clocks, ErrorKind}; use core::borrow::Borrow; use core::str::FromStr; use db::dir::SampleFileDir; @@ -272,6 +272,7 @@ impl Service { self.signals(req, caller).await?, ), Path::Static => (CacheControl::None, self.static_file(req).await?), + Path::Users => (CacheControl::PrivateDynamic, self.users(req, caller).await?), Path::User(id) => ( CacheControl::PrivateDynamic, self.user(req, caller, id).await?, @@ -462,105 +463,6 @@ impl Service { } } - async fn user(&self, req: Request, caller: Caller, id: i32) -> ResponseResult { - if caller.user.as_ref().map(|u| u.id) != Some(id) && !caller.permissions.admin_users { - bail_t!( - Unauthenticated, - "must be authenticated as supplied user or have admin_users permission" - ); - } - match *req.method() { - Method::GET | Method::HEAD => self.get_user(req, id).await, - Method::POST => self.post_user(req, caller, id).await, - _ => Err(plain_response(StatusCode::METHOD_NOT_ALLOWED, "POST expected").into()), - } - } - - async fn get_user(&self, req: Request, id: i32) -> ResponseResult { - let db = self.db.lock(); - let user = db - .users_by_id() - .get(&id) - .ok_or_else(|| format_err_t!(NotFound, "can't find requested user"))?; - let out = UserSubset { - preferences: Some(user.config.preferences.clone()), - password: Some(if user.has_password() { - Some("(censored)") - } else { - None - }), - permissions: Some((&user.permissions).into()), - }; - serve_json(&req, &out) - } - - async fn post_user( - &self, - mut req: Request, - caller: Caller, - id: i32, - ) -> ResponseResult { - let r = extract_json_body(&mut req).await?; - let r: json::PostUser = serde_json::from_slice(&r).map_err(|e| bad_req(e.to_string()))?; - let mut db = self.db.lock(); - let user = db - .get_user_by_id_mut(id) - .ok_or_else(|| format_err_t!(NotFound, "can't find requested user"))?; - if r.update.as_ref().map(|u| u.password).is_some() - && r.precondition.as_ref().map(|p| p.password).is_none() - && !caller.permissions.admin_users - { - bail_t!( - Unauthenticated, - "to change password, must supply previous password or have admin_users permission" - ); - } - if r.update.as_ref().map(|u| &u.permissions).is_some() && !caller.permissions.admin_users { - bail_t!( - Unauthenticated, - "to change permissions, must have admin_users permission" - ); - } - match (r.csrf, caller.user.and_then(|u| u.session)) { - (None, Some(_)) => bail_t!(Unauthenticated, "csrf must be supplied"), - (Some(csrf), Some(session)) if !csrf_matches(csrf, session.csrf) => { - bail_t!(Unauthenticated, "incorrect csrf"); - } - (_, _) => {} - } - if let Some(ref precondition) = r.precondition { - if matches!(precondition.preferences, Some(ref p) if p != &user.config.preferences) { - bail_t!(FailedPrecondition, "preferences mismatch"); - } - if let Some(p) = precondition.password { - if !user.check_password(p)? { - bail_t!(FailedPrecondition, "password mismatch"); // or Unauthenticated? - } - } - if let Some(ref p) = precondition.permissions { - if user.permissions != db::Permissions::from(p) { - bail_t!(FailedPrecondition, "permissions mismatch"); - } - } - } - if let Some(update) = r.update { - let mut change = user.change(); - if let Some(preferences) = update.preferences { - change.config.preferences = preferences; - } - match update.password { - None => {} - Some(None) => change.clear_password(), - Some(Some(p)) => change.set_password(p.to_owned()), - } - if let Some(ref permissions) = update.permissions { - change.permissions = permissions.into(); - } - db.apply_user_change(change).map_err(internal_server_err)?; - } - Ok(plain_response(StatusCode::NO_CONTENT, &b""[..])) - } - fn authreq(&self, req: &Request<::hyper::Body>) -> auth::Request { auth::Request { when_sec: Some(self.db.clocks().realtime().sec), diff --git a/server/src/web/path.rs b/server/src/web/path.rs index d4f8102..4a370af 100644 --- a/server/src/web/path.rs +++ b/server/src/web/path.rs @@ -22,6 +22,7 @@ pub(super) enum Path { Login, // "/api/login" Logout, // "/api/logout" Static, // (anything that doesn't start with "/api/") + Users, // "/api/users" User(i32), // "/api/users/" NotFound, } @@ -93,6 +94,9 @@ impl Path { if let Ok(id) = i32::from_str(path) { return Path::User(id); } + if path.is_empty() { + return Path::Users; + } Path::NotFound } else { Path::NotFound @@ -165,5 +169,6 @@ mod tests { assert_eq!(Path::decode("/api/junk"), Path::NotFound); assert_eq!(Path::decode("/api/users/42"), Path::User(42)); assert_eq!(Path::decode("/api/users/asdf"), Path::NotFound); + assert_eq!(Path::decode("/api/users/"), Path::Users); } } diff --git a/server/src/web/users.rs b/server/src/web/users.rs new file mode 100644 index 0000000..6ac611c --- /dev/null +++ b/server/src/web/users.rs @@ -0,0 +1,135 @@ +// This file is part of Moonfire NVR, a security camera network video recorder. +// Copyright (C) 2022 The Moonfire NVR Authors; see AUTHORS and LICENSE.txt. +// SPDX-License-Identifier: GPL-v3.0-or-later WITH GPL-3.0-linking-exception. + +//! User management: `/api/users/*`. + +use base::{bail_t, format_err_t}; +use http::{Method, Request, StatusCode}; + +use crate::json::{self, UserSubset}; + +use super::{ + bad_req, csrf_matches, extract_json_body, internal_server_err, plain_response, serve_json, + Caller, ResponseResult, Service, +}; + +impl Service { + pub(super) async fn users(&self, req: Request, caller: Caller) -> ResponseResult { + if !caller.permissions.admin_users { + bail_t!(Unauthenticated, "must have admin_users permission"); + } + let users = self + .db + .lock() + .users_by_id() + .iter() + .map(|(&id, user)| (id, user.username.clone())) + .collect(); + serve_json(&req, &json::UsersResponse { users }) + } + + pub(super) async fn user( + &self, + req: Request, + caller: Caller, + id: i32, + ) -> ResponseResult { + if caller.user.as_ref().map(|u| u.id) != Some(id) && !caller.permissions.admin_users { + bail_t!( + Unauthenticated, + "must be authenticated as supplied user or have admin_users permission" + ); + } + match *req.method() { + Method::GET | Method::HEAD => self.get_user(req, id).await, + Method::POST => self.post_user(req, caller, id).await, + _ => Err(plain_response(StatusCode::METHOD_NOT_ALLOWED, "POST expected").into()), + } + } + + async fn get_user(&self, req: Request, id: i32) -> ResponseResult { + let db = self.db.lock(); + let user = db + .users_by_id() + .get(&id) + .ok_or_else(|| format_err_t!(NotFound, "can't find requested user"))?; + let out = UserSubset { + preferences: Some(user.config.preferences.clone()), + password: Some(if user.has_password() { + Some("(censored)") + } else { + None + }), + permissions: Some((&user.permissions).into()), + }; + serve_json(&req, &out) + } + + async fn post_user( + &self, + mut req: Request, + caller: Caller, + id: i32, + ) -> ResponseResult { + let r = extract_json_body(&mut req).await?; + let r: json::PostUser = serde_json::from_slice(&r).map_err(|e| bad_req(e.to_string()))?; + let mut db = self.db.lock(); + let user = db + .get_user_by_id_mut(id) + .ok_or_else(|| format_err_t!(NotFound, "can't find requested user"))?; + if r.update.as_ref().map(|u| u.password).is_some() + && r.precondition.as_ref().map(|p| p.password).is_none() + && !caller.permissions.admin_users + { + bail_t!( + Unauthenticated, + "to change password, must supply previous password or have admin_users permission" + ); + } + if r.update.as_ref().map(|u| &u.permissions).is_some() && !caller.permissions.admin_users { + bail_t!( + Unauthenticated, + "to change permissions, must have admin_users permission" + ); + } + match (r.csrf, caller.user.and_then(|u| u.session)) { + (None, Some(_)) => bail_t!(Unauthenticated, "csrf must be supplied"), + (Some(csrf), Some(session)) if !csrf_matches(csrf, session.csrf) => { + bail_t!(Unauthenticated, "incorrect csrf"); + } + (_, _) => {} + } + if let Some(ref precondition) = r.precondition { + if matches!(precondition.preferences, Some(ref p) if p != &user.config.preferences) { + bail_t!(FailedPrecondition, "preferences mismatch"); + } + if let Some(p) = precondition.password { + if !user.check_password(p)? { + bail_t!(FailedPrecondition, "password mismatch"); // or Unauthenticated? + } + } + if let Some(ref p) = precondition.permissions { + if user.permissions != db::Permissions::from(p) { + bail_t!(FailedPrecondition, "permissions mismatch"); + } + } + } + if let Some(update) = r.update { + let mut change = user.change(); + if let Some(preferences) = update.preferences { + change.config.preferences = preferences; + } + match update.password { + None => {} + Some(None) => change.clear_password(), + Some(Some(p)) => change.set_password(p.to_owned()), + } + if let Some(ref permissions) = update.permissions { + change.permissions = permissions.into(); + } + db.apply_user_change(change).map_err(internal_server_err)?; + } + Ok(plain_response(StatusCode::NO_CONTENT, &b""[..])) + } +}