mirror of
https://github.com/scottlamb/moonfire-nvr.git
synced 2025-01-27 06:33:20 -05:00
add GET /users/ endpoint
This commit is contained in:
parent
dffec68b2f
commit
3ab30a318f
@ -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).
|
and some TP-Link cameras. Fixes [#238](https://github.com/scottlamb/moonfire-nvr/issues/238).
|
||||||
* expanded API interface for examining and updating users:
|
* expanded API interface for examining and updating users:
|
||||||
* `admin_users` permission for operating on arbitrary users.
|
* `admin_users` permission for operating on arbitrary users.
|
||||||
|
* `GET /users/` endpoint
|
||||||
* `GET /users/<id>` endpoint
|
* `GET /users/<id>` endpoint
|
||||||
* expanded `POST /users/<id>` endpoint, including password and
|
* expanded `POST /users/<id>` endpoint, including password and
|
||||||
permissions.
|
permissions.
|
||||||
|
@ -22,8 +22,10 @@ Status: **current**.
|
|||||||
* [Request 1](#request-1)
|
* [Request 1](#request-1)
|
||||||
* [Request 2](#request-2)
|
* [Request 2](#request-2)
|
||||||
* [Request 3](#request-3)
|
* [Request 3](#request-3)
|
||||||
* [`GET /api/users/<id>`](#get-apiusersid)
|
* [User management](#user-management)
|
||||||
* [`POST /api/users/<id>`](#post-apiusersid)
|
* [`GET /api/users`](#get-apiusers)
|
||||||
|
* [`GET /api/users/<id>`](#get-apiusersid)
|
||||||
|
* [`POST /api/users/<id>`](#post-apiusersid)
|
||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
|
|
||||||
@ -823,7 +825,16 @@ Response:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### `GET /api/users/<id>`
|
### 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/<id>`
|
||||||
|
|
||||||
Retrieves the user. Requires the `admin_users` permission if the caller is
|
Retrieves the user. Requires the `admin_users` permission if the caller is
|
||||||
not authenticated as the user in question.
|
not authenticated as the user in question.
|
||||||
@ -836,7 +847,7 @@ Returns a HTTP status 200 on success with a JSON dict:
|
|||||||
be retrieved.
|
be retrieved.
|
||||||
* `permissions`.
|
* `permissions`.
|
||||||
|
|
||||||
### `POST /api/users/<id>`
|
#### `POST /api/users/<id>`
|
||||||
|
|
||||||
Allows updating the given user. Requires the `admin_users` permission if the
|
Allows updating the given user. Requires the `admin_users` permission if the
|
||||||
caller is not authenticated as the user in question.
|
caller is not authenticated as the user in question.
|
||||||
|
@ -9,6 +9,7 @@ use db::auth::SessionHash;
|
|||||||
use failure::{format_err, Error};
|
use failure::{format_err, Error};
|
||||||
use serde::ser::{Error as _, SerializeMap, SerializeSeq, Serializer};
|
use serde::ser::{Error as _, SerializeMap, SerializeSeq, Serializer};
|
||||||
use serde::{Deserialize, Deserializer, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use std::ops::Not;
|
use std::ops::Not;
|
||||||
use uuid::Uuid;
|
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<i32, String>,
|
||||||
|
}
|
||||||
|
@ -8,15 +8,15 @@ mod path;
|
|||||||
mod session;
|
mod session;
|
||||||
mod signals;
|
mod signals;
|
||||||
mod static_file;
|
mod static_file;
|
||||||
|
mod users;
|
||||||
mod view;
|
mod view;
|
||||||
|
|
||||||
use self::accept::ConnData;
|
use self::accept::ConnData;
|
||||||
use self::path::Path;
|
use self::path::Path;
|
||||||
use crate::body::Body;
|
use crate::body::Body;
|
||||||
use crate::json;
|
use crate::json;
|
||||||
use crate::json::UserSubset;
|
|
||||||
use crate::mp4;
|
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::borrow::Borrow;
|
||||||
use core::str::FromStr;
|
use core::str::FromStr;
|
||||||
use db::dir::SampleFileDir;
|
use db::dir::SampleFileDir;
|
||||||
@ -272,6 +272,7 @@ impl Service {
|
|||||||
self.signals(req, caller).await?,
|
self.signals(req, caller).await?,
|
||||||
),
|
),
|
||||||
Path::Static => (CacheControl::None, self.static_file(req).await?),
|
Path::Static => (CacheControl::None, self.static_file(req).await?),
|
||||||
|
Path::Users => (CacheControl::PrivateDynamic, self.users(req, caller).await?),
|
||||||
Path::User(id) => (
|
Path::User(id) => (
|
||||||
CacheControl::PrivateDynamic,
|
CacheControl::PrivateDynamic,
|
||||||
self.user(req, caller, id).await?,
|
self.user(req, caller, id).await?,
|
||||||
@ -462,105 +463,6 @@ impl Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn user(&self, req: Request<hyper::Body>, 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<hyper::Body>, 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<hyper::Body>,
|
|
||||||
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 {
|
fn authreq(&self, req: &Request<::hyper::Body>) -> auth::Request {
|
||||||
auth::Request {
|
auth::Request {
|
||||||
when_sec: Some(self.db.clocks().realtime().sec),
|
when_sec: Some(self.db.clocks().realtime().sec),
|
||||||
|
@ -22,6 +22,7 @@ pub(super) enum Path {
|
|||||||
Login, // "/api/login"
|
Login, // "/api/login"
|
||||||
Logout, // "/api/logout"
|
Logout, // "/api/logout"
|
||||||
Static, // (anything that doesn't start with "/api/")
|
Static, // (anything that doesn't start with "/api/")
|
||||||
|
Users, // "/api/users"
|
||||||
User(i32), // "/api/users/<id>"
|
User(i32), // "/api/users/<id>"
|
||||||
NotFound,
|
NotFound,
|
||||||
}
|
}
|
||||||
@ -93,6 +94,9 @@ impl Path {
|
|||||||
if let Ok(id) = i32::from_str(path) {
|
if let Ok(id) = i32::from_str(path) {
|
||||||
return Path::User(id);
|
return Path::User(id);
|
||||||
}
|
}
|
||||||
|
if path.is_empty() {
|
||||||
|
return Path::Users;
|
||||||
|
}
|
||||||
Path::NotFound
|
Path::NotFound
|
||||||
} else {
|
} else {
|
||||||
Path::NotFound
|
Path::NotFound
|
||||||
@ -165,5 +169,6 @@ mod tests {
|
|||||||
assert_eq!(Path::decode("/api/junk"), Path::NotFound);
|
assert_eq!(Path::decode("/api/junk"), Path::NotFound);
|
||||||
assert_eq!(Path::decode("/api/users/42"), Path::User(42));
|
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/asdf"), Path::NotFound);
|
||||||
|
assert_eq!(Path::decode("/api/users/"), Path::Users);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
135
server/src/web/users.rs
Normal file
135
server/src/web/users.rs
Normal file
@ -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<hyper::Body>, 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<hyper::Body>,
|
||||||
|
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<hyper::Body>, 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<hyper::Body>,
|
||||||
|
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""[..]))
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user