mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2025-09-10 10:45:57 +03:00
Compare commits
99 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
bd20d8724b | ||
|
df041108f6 | ||
|
b6312340b6 | ||
|
80bad9f66d | ||
|
5a55dd1d4b | ||
|
c0f554311b | ||
|
c6256e1455 | ||
|
0cd3053fcb | ||
|
58c1545707 | ||
|
d3b4b10d18 | ||
|
c031ae9f2f | ||
|
56b3afa77c | ||
|
d335f45e34 | ||
|
34d2648509 | ||
|
f39c4fe2f4 | ||
|
01875c395b | ||
|
2872f40d13 | ||
|
d7df545078 | ||
|
d073f06652 | ||
|
3726da9c14 | ||
|
51450a0df9 | ||
|
98bae4a0a1 | ||
|
48e69cebab | ||
|
798a3b6a43 | ||
|
2dc1427027 | ||
|
233d23a527 | ||
|
06f7bd7c97 | ||
|
458a238c38 | ||
|
de72655bb1 | ||
|
4a2350891a | ||
|
4677ae4ac6 | ||
|
31349a47d3 | ||
|
55b7a3e4d1 | ||
|
692ed81306 | ||
|
03172a6cd7 | ||
|
819622e310 | ||
|
970863ffb1 | ||
|
e876d3077a | ||
|
99d6742fac | ||
|
75615bb5c8 | ||
|
e271b246f3 | ||
|
6378d96d1a | ||
|
c722256cbd | ||
|
8ff50481e5 | ||
|
be4e6c6f0c | ||
|
2f892cb866 | ||
|
4f6f510bd4 | ||
|
dae92b9018 | ||
|
dde7c0d99b | ||
|
79fccccad7 | ||
|
470ad14616 | ||
|
8d13e759fa | ||
|
3bba02b364 | ||
|
251c5c2348 | ||
|
f718827693 | ||
|
869352c361 | ||
|
ca31f117d5 | ||
|
1cb67eee69 | ||
|
e88d8c856d | ||
|
ec37004dfe | ||
|
03ce42e1cf | ||
|
3f56730b8a | ||
|
57701d5213 | ||
|
f920441b28 | ||
|
203fb2e3e7 | ||
|
3c662de4f2 | ||
|
b1d1926249 | ||
|
c5dd1a03be | ||
|
df598d7208 | ||
|
a0ae032ea7 | ||
|
35b4ad69bd | ||
|
dfb348d630 | ||
|
22786c8c9d | ||
|
a1ffa4c28d | ||
|
9f8183deb0 | ||
|
ea600ab2b8 | ||
|
83da757dfb | ||
|
d84d8d756f | ||
|
4fcdf33621 | ||
|
400a17a1ce | ||
|
15833e8d95 | ||
|
7d01947173 | ||
|
6aab2ae6c8 | ||
|
64ac81b9ee | ||
|
7c316fc19a | ||
|
1c45c2ec3a | ||
|
0905355629 | ||
|
f24e754ff7 | ||
|
0260667f7a | ||
|
7983ce4f13 | ||
|
5fc0472d88 | ||
|
410ee9f1f7 | ||
|
538dc00234 | ||
|
515c84d74d | ||
|
f72efa899e | ||
|
483066b9a0 | ||
|
57850a3379 | ||
|
3b09750b76 | ||
|
0da4a8fc8a |
40
.env
40
.env
@@ -1,13 +1,43 @@
|
||||
## Bitwarden_RS Configuration File
|
||||
## Uncomment any of the following lines to change the defaults
|
||||
|
||||
## Main data folder
|
||||
# DATA_FOLDER=data
|
||||
|
||||
## Individual folders, these override %DATA_FOLDER%
|
||||
# DATABASE_URL=data/db.sqlite3
|
||||
# PRIVATE_RSA_KEY=data/private_rsa_key.der
|
||||
# PUBLIC_RSA_KEY=data/public_rsa_key.der
|
||||
# RSA_KEY_FILENAME=data/rsa_key
|
||||
# ICON_CACHE_FOLDER=data/icon_cache
|
||||
# ATTACHMENTS_FOLDER=data/attachments
|
||||
|
||||
# true for yes, anything else for no
|
||||
SIGNUPS_ALLOWED=true
|
||||
## Web vault settings
|
||||
# WEB_VAULT_FOLDER=web-vault/
|
||||
# WEB_VAULT_ENABLED=true
|
||||
|
||||
# ROCKET_ENV=production
|
||||
## Controls if new users can register
|
||||
# SIGNUPS_ALLOWED=true
|
||||
|
||||
## Use a local favicon extractor
|
||||
## Set to false to use bitwarden's official icon servers
|
||||
## Set to true to use the local version, which is not as smart,
|
||||
## but it doesn't send the cipher domains to bitwarden's servers
|
||||
# LOCAL_ICON_EXTRACTOR=false
|
||||
|
||||
## Controls the PBBKDF password iterations to apply on the server
|
||||
## The change only applies when the password is changed
|
||||
# PASSWORD_ITERATIONS=100000
|
||||
|
||||
## Whether password hint should be sent into the error response when the client request it
|
||||
# SHOW_PASSWORD_HINT=true
|
||||
|
||||
## Domain settings
|
||||
## The domain must match the address from where you access the server
|
||||
## Unless you are using U2F, or having problems with attachments not downloading, there is no need to change this
|
||||
## For U2F to work, the server must use HTTPS, you can use Let's Encrypt for free certs
|
||||
# DOMAIN=https://bw.domain.tld:8443
|
||||
|
||||
## Rocket specific settings, check Rocket documentation to learn more
|
||||
# ROCKET_ENV=staging
|
||||
# ROCKET_ADDRESS=0.0.0.0 # Enable this to test mobile app
|
||||
# ROCKET_PORT=8000
|
||||
# ROCKET_TLS={certs="/path/to/certs.pem",key="/path/to/key.pem"}
|
||||
|
52
BUILD.md
52
BUILD.md
@@ -1,25 +1,23 @@
|
||||
## How to compile bitwarden_rs
|
||||
Install `rust nightly`, in Windows the recommended way is through `rustup`.
|
||||
# Build instructions
|
||||
|
||||
Install the `openssl` library, in Windows the best option is Microsoft's `vcpkg`,
|
||||
on other systems use their respective package managers.
|
||||
## Dependencies
|
||||
- `Rust nightly` (strongly recommended to use [rustup](https://rustup.rs/))
|
||||
- `OpenSSL` (should be available in path, install through your system's package manager or use the [prebuilt binaries](https://wiki.openssl.org/index.php/Binaries))
|
||||
- `NodeJS` (required to build the web-vault, (install through your system's package manager or use the [prebuilt binaries](https://nodejs.org/en/download/))
|
||||
|
||||
Then run:
|
||||
|
||||
## Run/Compile
|
||||
```sh
|
||||
# Compile and run
|
||||
cargo run
|
||||
# or
|
||||
cargo build
|
||||
# or just compile (binary located in target/release/bitwarden_rs)
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
## How to install the web-vault locally
|
||||
If you're using docker image, you can just update `VAULT_VERSION` variable in Dockerfile and rebuild the image.
|
||||
When run, the server is accessible in [http://localhost:80](http://localhost:80).
|
||||
|
||||
Install `node.js` and either `yarn` or `npm` (usually included with node)
|
||||
|
||||
Clone the web-vault outside the project:
|
||||
```
|
||||
git clone https://github.com/bitwarden/web.git web-vault
|
||||
```
|
||||
### Install the web-vault
|
||||
Download the latest official release from the [releases page](https://github.com/bitwarden/web/releases) and extract it.
|
||||
|
||||
Modify `web-vault/settings.Production.json` to look like this:
|
||||
```json
|
||||
@@ -34,23 +32,23 @@ Modify `web-vault/settings.Production.json` to look like this:
|
||||
}
|
||||
```
|
||||
|
||||
Then, run the following from the `web-vault` dir:
|
||||
Then, run the following from the `web-vault` directory:
|
||||
```sh
|
||||
# With yarn (recommended)
|
||||
yarn
|
||||
yarn gulp dist:selfHosted
|
||||
|
||||
# With npm
|
||||
npm install
|
||||
npx gulp dist:selfHosted
|
||||
```
|
||||
|
||||
Finally copy the contents of the `web-vault/dist` folder into the `bitwarden_rs/web-vault` folder.
|
||||
|
||||
## How to recreate database schemas
|
||||
# Configuration
|
||||
The available configuration options are documented in the default `.env` file, and they can be modified by uncommenting the desired options in that file or by setting their respective environment variables. Look at the README file for the main configuration options available.
|
||||
|
||||
Note: the environment variables override the values set in the `.env` file.
|
||||
|
||||
## How to recreate database schemas (for developers)
|
||||
Install diesel-cli with cargo:
|
||||
```sh
|
||||
cargo install diesel_cli --no-default-features --features sqlite-bundled # Or use only sqlite to use the system version
|
||||
cargo install diesel_cli --no-default-features --features sqlite-bundled
|
||||
```
|
||||
|
||||
Make sure that the correct path to the database is in the `.env` file.
|
||||
@@ -63,7 +61,9 @@ diesel migration generate <name>
|
||||
Modify the *.sql files, making sure that any changes are reverted in the down.sql file.
|
||||
|
||||
Apply the migrations and save the generated schemas as follows:
|
||||
```
|
||||
```sh
|
||||
diesel migration redo
|
||||
diesel print-schema > src/db/schema.rs
|
||||
```
|
||||
|
||||
# This step should be done automatically when using diesel-cli > 1.3.0
|
||||
# diesel print-schema > src/db/schema.rs
|
||||
```
|
||||
|
651
Cargo.lock
generated
651
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
33
Cargo.toml
33
Cargo.toml
@@ -1,13 +1,13 @@
|
||||
[package]
|
||||
name = "bitwarden_rs"
|
||||
version = "0.9.0"
|
||||
version = "0.13.0"
|
||||
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
|
||||
|
||||
[dependencies]
|
||||
# Web framework for nightly with a focus on ease-of-use, expressibility, and speed.
|
||||
rocket = { version = "0.3.12", features = ["tls"] }
|
||||
rocket_codegen = "0.3.12"
|
||||
rocket_contrib = "0.3.12"
|
||||
rocket = { version = "0.3.14", features = ["tls"] }
|
||||
rocket_codegen = "0.3.14"
|
||||
rocket_contrib = "0.3.14"
|
||||
|
||||
# HTTP client
|
||||
reqwest = "0.8.6"
|
||||
@@ -16,13 +16,13 @@ reqwest = "0.8.6"
|
||||
multipart = "0.14.2"
|
||||
|
||||
# A generic serialization/deserialization framework
|
||||
serde = "1.0.64"
|
||||
serde_derive = "1.0.64"
|
||||
serde_json = "1.0.19"
|
||||
serde = "1.0.70"
|
||||
serde_derive = "1.0.70"
|
||||
serde_json = "1.0.22"
|
||||
|
||||
# A safe, extensible ORM and Query builder
|
||||
diesel = { version = "~1.2.2", features = ["sqlite", "chrono", "r2d2"] }
|
||||
diesel_migrations = { version = "~1.2.0", features = ["sqlite"] }
|
||||
diesel = { version = "1.3.2", features = ["sqlite", "chrono", "r2d2"] }
|
||||
diesel_migrations = { version = "1.3.0", features = ["sqlite"] }
|
||||
|
||||
# Bundled SQLite
|
||||
libsqlite3-sys = { version = "0.9.1", features = ["bundled"] }
|
||||
@@ -34,7 +34,7 @@ ring = { version = "= 0.11.0", features = ["rsa_signing"] }
|
||||
uuid = { version = "0.6.5", features = ["v4"] }
|
||||
|
||||
# Date and time library for Rust
|
||||
chrono = "0.4.2"
|
||||
chrono = "0.4.4"
|
||||
|
||||
# TOTP library
|
||||
oath = "0.10.2"
|
||||
@@ -45,11 +45,22 @@ data-encoding = "2.1.1"
|
||||
# JWT library
|
||||
jsonwebtoken = "= 4.0.1"
|
||||
|
||||
# U2F library
|
||||
u2f = "0.1.2"
|
||||
|
||||
# A `dotenv` implementation for Rust
|
||||
dotenv = { version = "0.13.0", default-features = false }
|
||||
|
||||
# Lazy static macro
|
||||
lazy_static = "1.0.1"
|
||||
|
||||
# Numerical libraries
|
||||
num-traits = "0.2.5"
|
||||
num-derive = "0.2.2"
|
||||
|
||||
[patch.crates-io]
|
||||
jsonwebtoken = { path = "libs/jsonwebtoken" } # Make jwt use ring 0.11, to match rocket
|
||||
# Make jwt use ring 0.11, to match rocket
|
||||
jsonwebtoken = { path = "libs/jsonwebtoken" }
|
||||
|
||||
# Version 0.1.2 from crates.io lacks a commit that fixes a certificate error
|
||||
u2f = { git = 'https://github.com/wisespace-io/u2f-rs', rev = '193de35093a44' }
|
14
Dockerfile
14
Dockerfile
@@ -4,7 +4,7 @@
|
||||
####################### VAULT BUILD IMAGE #######################
|
||||
FROM node:9-alpine as vault
|
||||
|
||||
ENV VAULT_VERSION "1.26.0"
|
||||
ENV VAULT_VERSION "1.27.0"
|
||||
ENV URL "https://github.com/bitwarden/web/archive/v${VAULT_VERSION}.tar.gz"
|
||||
|
||||
RUN apk add --update-cache --upgrade \
|
||||
@@ -31,7 +31,7 @@ RUN git config --global url."https://github.com/".insteadOf ssh://git@github.com
|
||||
########################## BUILD IMAGE ##########################
|
||||
# We need to use the Rust build image, because
|
||||
# we need the Rust compiler and Cargo tooling
|
||||
FROM rustlang/rust:nightly as build
|
||||
FROM rust as build
|
||||
|
||||
# Using bundled SQLite, no need to install it
|
||||
# RUN apt-get update && apt-get install -y\
|
||||
@@ -46,6 +46,7 @@ WORKDIR /app
|
||||
# Copies over *only* your manifests and vendored dependencies
|
||||
COPY ./Cargo.* ./
|
||||
COPY ./libs ./libs
|
||||
COPY ./rust-toolchain ./rust-toolchain
|
||||
|
||||
# Builds your dependencies and removes the
|
||||
# dummy project, except the target folder
|
||||
@@ -66,9 +67,13 @@ RUN cargo build --release
|
||||
# because we already have a binary built
|
||||
FROM debian:stretch-slim
|
||||
|
||||
ENV ROCKET_ENV "staging"
|
||||
ENV ROCKET_WORKERS=10
|
||||
|
||||
# Install needed libraries
|
||||
RUN apt-get update && apt-get install -y\
|
||||
openssl\
|
||||
ca-certificates\
|
||||
--no-install-recommends\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -79,10 +84,9 @@ EXPOSE 80
|
||||
# Copies the files from the context (env file and web-vault)
|
||||
# and the binary from the "build" stage to the current stage
|
||||
COPY .env .
|
||||
COPY Rocket.toml .
|
||||
COPY --from=vault /web-vault ./web-vault
|
||||
COPY --from=build app/target/release/bitwarden_rs .
|
||||
|
||||
# Configures the startup!
|
||||
# Use production to disable Rocket logging
|
||||
#CMD ROCKET_ENV=production ./bitwarden_rs
|
||||
CMD ROCKET_ENV=staging ./bitwarden_rs
|
||||
CMD ./bitwarden_rs
|
||||
|
219
README.md
219
README.md
@@ -4,6 +4,42 @@ Image is based on [Rust implementation of Bitwarden API](https://github.com/dani
|
||||
|
||||
_*Note, that this project is not associated with the [Bitwarden](https://bitwarden.com/) project nor 8bit Solutions LLC._
|
||||
|
||||
**Table of contents**
|
||||
|
||||
- [Features](#features)
|
||||
- [Missing features](#missing-features)
|
||||
- [Docker image usage](#docker-image-usage)
|
||||
- [Starting a container](#starting-a-container)
|
||||
- [Updating the bitwarden image](#updating-the-bitwarden-image)
|
||||
- [Configuring bitwarden service](#configuring-bitwarden-service)
|
||||
- [Disable registration of new users](#disable-registration-of-new-users)
|
||||
- [Enabling HTTPS](#enabling-https)
|
||||
- [Enabling U2F authentication](#enabling-u2f-authentication)
|
||||
- [Changing persistent data location](#changing-persistent-data-location)
|
||||
- [/data prefix:](#data-prefix)
|
||||
- [database name and location](#database-name-and-location)
|
||||
- [attachments location](#attachments-location)
|
||||
- [icons cache](#icons-cache)
|
||||
- [Changing the API request size limit](#changing-the-api-request-size-limit)
|
||||
- [Changing the number of workers](#changing-the-number-of-workers)
|
||||
- [Disabling or overriding the Vault interface hosting](#disabling-or-overriding-the-vault-interface-hosting)
|
||||
- [Other configuration](#other-configuration)
|
||||
- [Building your own image](#building-your-own-image)
|
||||
- [Building binary](#building-binary)
|
||||
- [Available packages](#available-packages)
|
||||
- [Arch Linux](#arch-linux)
|
||||
- [Backing up your vault](#backing-up-your-vault)
|
||||
- [1. the sqlite3 database](#1-the-sqlite3-database)
|
||||
- [2. the attachments folder](#2-the-attachments-folder)
|
||||
- [3. the key files](#3-the-key-files)
|
||||
- [4. Icon Cache](#4-icon-cache)
|
||||
- [Running the server with non-root user](#running-the-server-with-non-root-user)
|
||||
- [Differences from upstream API implementation](#differences-from-upstream-api-implementation)
|
||||
- [Changing user email](#changing-user-email)
|
||||
- [Creating organization](#creating-organization)
|
||||
- [Inviting users into organization](#inviting-users-into-organization)
|
||||
- [Get in touch](#get-in-touch)
|
||||
|
||||
## Features
|
||||
|
||||
Basically full implementation of Bitwarden API is provided including:
|
||||
@@ -14,6 +50,14 @@ Basically full implementation of Bitwarden API is provided including:
|
||||
* Vault API support
|
||||
* Serving the static files for Vault interface
|
||||
* Website icons API
|
||||
* Authenticator and U2F support
|
||||
|
||||
## Missing features
|
||||
* Email confirmation
|
||||
* Other two-factor systems:
|
||||
* YubiKey OTP (if your key supports U2F, you can use that)
|
||||
* Duo
|
||||
* Email codes
|
||||
|
||||
## Docker image usage
|
||||
|
||||
@@ -44,6 +88,7 @@ docker rm bitwarden
|
||||
# Start new container with the data mounted
|
||||
docker run -d --name bitwarden -v /bw-data/:/data/ -p 80:80 mprasil/bitwarden:latest
|
||||
```
|
||||
Then visit [http://localhost:80](http://localhost:80)
|
||||
|
||||
In case you didn't bind mount the volume for persistent data, you need an intermediate step where you preserve the data with an intermediate container:
|
||||
|
||||
@@ -69,6 +114,55 @@ docker rm bitwarden_data
|
||||
|
||||
## Configuring bitwarden service
|
||||
|
||||
### Disable registration of new users
|
||||
|
||||
By default new users can register, if you want to disable that, set the `SIGNUPS_ALLOWED` env variable to `false`:
|
||||
|
||||
```sh
|
||||
docker run -d --name bitwarden \
|
||||
-e SIGNUPS_ALLOWED=false \
|
||||
-v /bw-data/:/data/ \
|
||||
-p 80:80 \
|
||||
mprasil/bitwarden:latest
|
||||
```
|
||||
|
||||
### Enabling HTTPS
|
||||
To enable HTTPS, you need to configure the `ROCKET_TLS`.
|
||||
|
||||
The values to the option must follow the format:
|
||||
```
|
||||
ROCKET_TLS={certs="/path/to/certs.pem",key="/path/to/key.pem"}
|
||||
```
|
||||
Where:
|
||||
- certs: a path to a certificate chain in PEM format
|
||||
- key: a path to a private key file in PEM format for the certificate in certs
|
||||
|
||||
```sh
|
||||
docker run -d --name bitwarden \
|
||||
-e ROCKET_TLS='{certs="/ssl/certs.pem",key="/ssl/key.pem"}' \
|
||||
-v /ssl/keys/:/ssl/ \
|
||||
-v /bw-data/:/data/ \
|
||||
-p 443:80 \
|
||||
mprasil/bitwarden:latest
|
||||
```
|
||||
Note that you need to mount ssl files and you need to forward appropriate port.
|
||||
|
||||
### Enabling U2F authentication
|
||||
To enable U2F authentication, you must be serving bitwarden_rs from an HTTPS domain with a valid certificate (Either using the included
|
||||
HTTPS options or with a reverse proxy). We recommend using a free certificate from Let's Encrypt.
|
||||
|
||||
After that, you need to set the `DOMAIN` environment variable to the same address from where bitwarden_rs is being served:
|
||||
|
||||
```sh
|
||||
docker run -d --name bitwarden \
|
||||
-e DOMAIN=https://bw.domain.tld \
|
||||
-v /bw-data/:/data/ \
|
||||
-p 80:80 \
|
||||
mprasil/bitwarden:latest
|
||||
```
|
||||
|
||||
Note that the value has to include the `https://` and it may include a port at the end (in the format of `https://bw.domain.tld:port`) when not using `443`.
|
||||
|
||||
### Changing persistent data location
|
||||
|
||||
#### /data prefix:
|
||||
@@ -128,7 +222,59 @@ docker run -d --name bitwarden \
|
||||
mprasil/bitwarden:latest
|
||||
```
|
||||
|
||||
Note, that in the above example we don't mount the volume locally, which means it won't be persisted during the upgrade unless you use intermediate data container using `--volumes-from`. This will impact performance as bitwarden will have to re-dowload the icons on restart, but might save you from having stale icons in cache as they are not automatically cleaned.
|
||||
Note, that in the above example we don't mount the volume locally, which means it won't be persisted during the upgrade unless you use intermediate data container using `--volumes-from`. This will impact performance as bitwarden will have to re-download the icons on restart, but might save you from having stale icons in cache as they are not automatically cleaned.
|
||||
|
||||
### Changing the API request size limit
|
||||
|
||||
By default the API calls are limited to 10MB. This should be sufficient for most cases, however if you want to support large imports, this might be limiting you. On the other hand you might want to limit the request size to something smaller than that to prevent API abuse and possible DOS attack, especially if running with limited resources.
|
||||
|
||||
To set the limit, you can use the `ROCKET_LIMITS` variable. Example here shows 10MB limit for posted json in the body (this is the default):
|
||||
|
||||
```sh
|
||||
docker run -d --name bitwarden \
|
||||
-e ROCKET_LIMITS={json=10485760} \
|
||||
-v /bw-data/:/data/ \
|
||||
-p 80:80 \
|
||||
mprasil/bitwarden:latest
|
||||
```
|
||||
|
||||
### Changing the number of workers
|
||||
|
||||
When you run bitwarden_rs, it spawns `2 * <number of cpu cores>` workers to handle requests. On some systems this might lead to low number of workers and hence slow performance, so the default in the docker image is changed to spawn 10 threads. You can override this setting to increase or decrease the number of workers by setting the `ROCKET_WORKERS` variable.
|
||||
|
||||
In the example bellow, we're starting with 20 workers:
|
||||
|
||||
```sh
|
||||
docker run -d --name bitwarden \
|
||||
-e ROCKET_WORKERS=20 \
|
||||
-v /bw-data/:/data/ \
|
||||
-p 80:80 \
|
||||
mprasil/bitwarden:latest
|
||||
```
|
||||
|
||||
### Disabling or overriding the Vault interface hosting
|
||||
|
||||
As a convenience bitwarden_rs image will also host static files for Vault web interface. You can disable this static file hosting completely by setting the WEB_VAULT_ENABLED variable.
|
||||
|
||||
```sh
|
||||
docker run -d --name bitwarden \
|
||||
-e WEB_VAULT_ENABLED=false \
|
||||
-v /bw-data/:/data/ \
|
||||
-p 80:80 \
|
||||
mprasil/bitwarden:latest
|
||||
```
|
||||
|
||||
Alternatively you can override the Vault files and provide your own static files to host. You can do that by mounting a path with your files over the `/web-vault` directory in the container. Just make sure the directory contains at least `index.html` file.
|
||||
|
||||
```sh
|
||||
docker run -d --name bitwarden \
|
||||
-v /path/to/static/files_directory:/web-vault \
|
||||
-v /bw-data/:/data/ \
|
||||
-p 80:80 \
|
||||
mprasil/bitwarden:latest
|
||||
```
|
||||
|
||||
Note that you can also change the path where bitwarden_rs looks for static files by providing the `WEB_VAULT_FOLDER` environment variable with the path.
|
||||
|
||||
### Other configuration
|
||||
|
||||
@@ -145,4 +291,73 @@ docker build -t bitwarden_rs .
|
||||
|
||||
## Building binary
|
||||
|
||||
For building binary outside the Docker environment and running it locally without docker, please see [build instructions](BUILD.md).
|
||||
For building binary outside the Docker environment and running it locally without docker, please see [build instructions](BUILD.md).
|
||||
|
||||
## Available packages
|
||||
|
||||
### Arch Linux
|
||||
|
||||
Bitwarden_rs is already packaged for Archlinux thanks to @mqus. There is an [AUR package](https://aur.archlinux.org/packages/bitwarden_rs) (optionally with the [vault web interface](https://aur.archlinux.org/packages/bitwarden_rs-vault/) ) available.
|
||||
|
||||
## Backing up your vault
|
||||
|
||||
### 1. the sqlite3 database
|
||||
|
||||
The sqlite3 database should be backed up using the proper sqlite3 backup command. This will ensure the database does not become corrupted if the backup happens during a database write.
|
||||
|
||||
```
|
||||
sqlite3 /$DATA_FOLDER/db.sqlite3 ".backup '/$DATA_FOLDER/db-backup/backup.sq3'"
|
||||
```
|
||||
|
||||
This command can be run via a CRON job everyday, however note that it will overwrite the same backup.sq3 file each time. This backup file should therefore be saved via incremental backup either using a CRON job command that appends a timestamp or from another backup app such as Duplicati.
|
||||
|
||||
### 2. the attachments folder
|
||||
|
||||
By default, this is located in `$DATA_FOLDER/attachments`
|
||||
|
||||
### 3. the key files
|
||||
|
||||
This is optional, these are only used to store tokens of users currently logged in, deleting them would simply log each user out forcing them to log in again. By default, these are located in the `$DATA_FOLDER` (by default /data in the docker). There are 3 files: rsa_key.der, rsa_key.pem, rsa_key.pub.der.
|
||||
|
||||
### 4. Icon Cache
|
||||
|
||||
This is optional, the icon cache can re-download itself however if you have a large cache, it may take a long time. By default it is located in `$DATA_FOLDER/icon_cache`
|
||||
|
||||
## Running the server with non-root user
|
||||
|
||||
The root user inside the container is already pretty limited in what it can do, so the default setup should be secure enough. However if you wish to go the extra mile to avoid using root even in container, here's how you can do that:
|
||||
|
||||
1. Create a data folder that's owned by non-root user, so you can use that user to write persistent data. Get the user `id`. In linux you can run `stat <folder_name>` to get/verify the owner ID.
|
||||
2. When you run the container, you need to provide the user ID as one of the parameters. Note that this needs to be in the numeric form and not the user name, because docker would try to find such user defined inside the image, which would likely not be there or it would have different ID than your local user and hence wouldn't be able to write the persistent data. This can be done with the `--user` parameter.
|
||||
3. bitwarden_rs listens on port `80` inside the container by default, this [won't work with non-root user](https://www.w3.org/Daemon/User/Installation/PrivilegedPorts.html), because regular users aren't allowed to open port bellow `1024`. To overcome this, you need to configure server to listen on a different port, you can use `ROCKET_PORT` to do that.
|
||||
|
||||
Here's sample docker run, that uses user with id `1000` and with the port redirection configured, so that inside container the service is listening on port `8080` and docker translates that to external (host) port `80`:
|
||||
|
||||
```sh
|
||||
docker run -d --name bitwarden \
|
||||
--user 1000 \
|
||||
-e ROCKET_PORT=8080 \
|
||||
-v /bw-data/:/data/ \
|
||||
-p 80:8080 \
|
||||
mprasil/bitwarden:latest
|
||||
```
|
||||
|
||||
## Differences from upstream API implementation
|
||||
|
||||
### Changing user email
|
||||
|
||||
Because we don't have any SMTP functionality at the moment, there's no way to deliver the verification token when you try to change the email. User just needs to enter any random token to continue and the change will be applied.
|
||||
|
||||
### Creating organization
|
||||
|
||||
We use upstream Vault interface directly without any (significant) changes, this is why user is presented with paid options when creating organization. To create an organization, just use the free option, none of the limits apply when using bitwarden_rs as back-end API and after the organization is created it should behave like Enterprise organization.
|
||||
|
||||
### Inviting users into organization
|
||||
|
||||
The users must already be registered on your server to invite them, because we can't send the invitation via email. The invited users won't get the invitation email, instead they will appear in the interface as if they already accepted the invitation. Organization admin then just needs to confirm them to be proper Organization members and to give them access to the shared secrets.
|
||||
|
||||
## Get in touch
|
||||
|
||||
To ask an question, [raising an issue](https://github.com/dani-garcia/bitwarden_rs/issues/new) is fine, also please report any bugs spotted here.
|
||||
|
||||
If you prefer to chat, we're usually hanging around at [#bitwarden_rs:matrix.org](https://matrix.to/#/#bitwarden_rs:matrix.org) room on Matrix. Feel free to join us!
|
||||
|
2
Rocket.toml
Normal file
2
Rocket.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[global.limits]
|
||||
json = 10485760 # 10 MiB
|
5
diesel.toml
Normal file
5
diesel.toml
Normal file
@@ -0,0 +1,5 @@
|
||||
# For documentation on how to configure this file,
|
||||
# see diesel.rs/guides/configuring-diesel-cli
|
||||
|
||||
[print_schema]
|
||||
file = "src/db/schema.rs"
|
@@ -0,0 +1,8 @@
|
||||
UPDATE users
|
||||
SET totp_secret = (
|
||||
SELECT twofactor.data FROM twofactor
|
||||
WHERE twofactor.type = 0
|
||||
AND twofactor.user_uuid = users.uuid
|
||||
);
|
||||
|
||||
DROP TABLE twofactor;
|
15
migrations/2018-07-11-181453_create_u2f_twofactor/up.sql
Normal file
15
migrations/2018-07-11-181453_create_u2f_twofactor/up.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE twofactor (
|
||||
uuid TEXT NOT NULL PRIMARY KEY,
|
||||
user_uuid TEXT NOT NULL REFERENCES users (uuid),
|
||||
type INTEGER NOT NULL,
|
||||
enabled BOOLEAN NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
|
||||
UNIQUE (user_uuid, type)
|
||||
);
|
||||
|
||||
|
||||
INSERT INTO twofactor (uuid, user_uuid, type, enabled, data)
|
||||
SELECT lower(hex(randomblob(16))) , uuid, 0, 1, u.totp_secret FROM users u where u.totp_secret IS NOT NULL;
|
||||
|
||||
UPDATE users SET totp_secret = NULL; -- Instead of recreating the table, just leave the columns empty
|
1
rust-toolchain
Normal file
1
rust-toolchain
Normal file
@@ -0,0 +1 @@
|
||||
nightly-2018-06-26
|
@@ -3,11 +3,9 @@ use rocket_contrib::Json;
|
||||
use db::DbConn;
|
||||
use db::models::*;
|
||||
|
||||
use api::{PasswordData, JsonResult, EmptyResult, JsonUpcase};
|
||||
use api::{PasswordData, JsonResult, EmptyResult, JsonUpcase, NumberOrString};
|
||||
use auth::Headers;
|
||||
|
||||
use util;
|
||||
|
||||
use CONFIG;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
@@ -15,7 +13,6 @@ use CONFIG;
|
||||
struct RegisterData {
|
||||
Email: String,
|
||||
Key: String,
|
||||
#[serde(deserialize_with = "util::upcase_deserialize")]
|
||||
Keys: Option<KeysData>,
|
||||
MasterPasswordHash: String,
|
||||
MasterPasswordHint: Option<String>,
|
||||
@@ -34,10 +31,10 @@ fn register(data: JsonUpcase<RegisterData>, conn: DbConn) -> EmptyResult {
|
||||
let data: RegisterData = data.into_inner().data;
|
||||
|
||||
if !CONFIG.signups_allowed {
|
||||
err!(format!("Signups not allowed"))
|
||||
err!("Signups not allowed")
|
||||
}
|
||||
|
||||
if let Some(_) = User::find_by_mail(&data.Email, &conn) {
|
||||
if User::find_by_mail(&data.Email, &conn).is_some() {
|
||||
err!("Email already exists")
|
||||
}
|
||||
|
||||
@@ -67,6 +64,28 @@ fn profile(headers: Headers, conn: DbConn) -> JsonResult {
|
||||
Ok(Json(headers.user.to_json(&conn)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[allow(non_snake_case)]
|
||||
struct ProfileData {
|
||||
#[serde(rename = "Culture")]
|
||||
_Culture: String, // Ignored, always use en-US
|
||||
MasterPasswordHint: Option<String>,
|
||||
Name: String,
|
||||
}
|
||||
|
||||
#[post("/accounts/profile", data = "<data>")]
|
||||
fn post_profile(data: JsonUpcase<ProfileData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
let data: ProfileData = data.into_inner().data;
|
||||
|
||||
let mut user = headers.user;
|
||||
|
||||
user.name = data.Name;
|
||||
user.password_hint = data.MasterPasswordHint;
|
||||
user.save(&conn);
|
||||
|
||||
Ok(Json(user.to_json(&conn)))
|
||||
}
|
||||
|
||||
#[get("/users/<uuid>/public-key")]
|
||||
fn get_public_keys(uuid: String, _headers: Headers, conn: DbConn) -> JsonResult {
|
||||
let user = match User::find_by_uuid(&uuid, &conn) {
|
||||
@@ -136,13 +155,39 @@ fn post_sstamp(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(non_snake_case)]
|
||||
struct ChangeEmailData {
|
||||
struct EmailTokenData {
|
||||
MasterPasswordHash: String,
|
||||
NewEmail: String,
|
||||
}
|
||||
|
||||
|
||||
#[post("/accounts/email-token", data = "<data>")]
|
||||
fn post_email_token(data: JsonUpcase<EmailTokenData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
let data: EmailTokenData = data.into_inner().data;
|
||||
|
||||
if !headers.user.check_valid_password(&data.MasterPasswordHash) {
|
||||
err!("Invalid password")
|
||||
}
|
||||
|
||||
if User::find_by_mail(&data.NewEmail, &conn).is_some() {
|
||||
err!("Email already in use");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(non_snake_case)]
|
||||
struct ChangeEmailData {
|
||||
MasterPasswordHash: String,
|
||||
NewEmail: String,
|
||||
|
||||
Key: String,
|
||||
NewMasterPasswordHash: String,
|
||||
#[serde(rename = "Token")]
|
||||
_Token: NumberOrString,
|
||||
}
|
||||
|
||||
#[post("/accounts/email", data = "<data>")]
|
||||
fn post_email(data: JsonUpcase<ChangeEmailData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
let data: ChangeEmailData = data.into_inner().data;
|
||||
let mut user = headers.user;
|
||||
@@ -156,6 +201,10 @@ fn post_email(data: JsonUpcase<ChangeEmailData>, headers: Headers, conn: DbConn)
|
||||
}
|
||||
|
||||
user.email = data.NewEmail;
|
||||
|
||||
user.set_password(&data.NewMasterPasswordHash);
|
||||
user.key = data.Key;
|
||||
|
||||
user.save(&conn);
|
||||
|
||||
Ok(())
|
||||
@@ -172,17 +221,15 @@ fn delete_account(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn
|
||||
|
||||
// Delete ciphers and their attachments
|
||||
for cipher in Cipher::find_owned_by_user(&user.uuid, &conn) {
|
||||
match cipher.delete(&conn) {
|
||||
Ok(()) => (),
|
||||
Err(_) => err!("Failed deleting cipher")
|
||||
if cipher.delete(&conn).is_err() {
|
||||
err!("Failed deleting cipher")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete folders
|
||||
for f in Folder::find_by_user(&user.uuid, &conn) {
|
||||
match f.delete(&conn) {
|
||||
Ok(()) => (),
|
||||
Err(_) => err!("Failed deleting folder")
|
||||
if f.delete(&conn).is_err() {
|
||||
err!("Failed deleting folder")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +244,29 @@ fn delete_account(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn
|
||||
|
||||
#[get("/accounts/revision-date")]
|
||||
fn revision_date(headers: Headers) -> String {
|
||||
let revision_date = headers.user.updated_at.timestamp();
|
||||
let revision_date = headers.user.updated_at.timestamp_millis();
|
||||
revision_date.to_string()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(non_snake_case)]
|
||||
struct PasswordHintData {
|
||||
Email: String,
|
||||
}
|
||||
|
||||
#[post("/accounts/password-hint", data = "<data>")]
|
||||
fn password_hint(data: JsonUpcase<PasswordHintData>, conn: DbConn) -> EmptyResult {
|
||||
let data: PasswordHintData = data.into_inner().data;
|
||||
|
||||
if !CONFIG.show_password_hint {
|
||||
return Ok(())
|
||||
}
|
||||
|
||||
match User::find_by_mail(&data.Email, &conn) {
|
||||
Some(user) => {
|
||||
let hint = user.password_hint.to_owned().unwrap_or_default();
|
||||
err!(format!("Your password hint is: {}", hint))
|
||||
},
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
@@ -14,7 +14,6 @@ use data_encoding::HEXLOWER;
|
||||
use db::DbConn;
|
||||
use db::models::*;
|
||||
|
||||
use util;
|
||||
use crypto;
|
||||
|
||||
use api::{self, PasswordData, JsonResult, EmptyResult, JsonUpcase};
|
||||
@@ -157,24 +156,6 @@ fn update_cipher_from_data(cipher: &mut Cipher, data: CipherData, headers: &Head
|
||||
}
|
||||
}
|
||||
|
||||
let uppercase_fields = data.Fields.map(|f| {
|
||||
let mut value = json!({});
|
||||
// Copy every field object and change the names to the correct case
|
||||
copy_values(&f, &mut value);
|
||||
value
|
||||
});
|
||||
|
||||
// TODO: ******* Backwards compat start **********
|
||||
// To remove backwards compatibility, just create an empty values object,
|
||||
// and remove the compat code from cipher::to_json
|
||||
let mut values = json!({
|
||||
"Name": data.Name,
|
||||
"Notes": data.Notes
|
||||
});
|
||||
|
||||
values["Fields"] = uppercase_fields.clone().unwrap_or(Value::Null);
|
||||
// TODO: ******* Backwards compat end **********
|
||||
|
||||
let type_data_opt = match data.Type {
|
||||
1 => data.Login,
|
||||
2 => data.SecureNote,
|
||||
@@ -183,19 +164,24 @@ fn update_cipher_from_data(cipher: &mut Cipher, data: CipherData, headers: &Head
|
||||
_ => err!("Invalid type")
|
||||
};
|
||||
|
||||
let type_data = match type_data_opt {
|
||||
let mut type_data = match type_data_opt {
|
||||
Some(data) => data,
|
||||
None => err!("Data missing")
|
||||
};
|
||||
|
||||
// Copy the type data and change the names to the correct case
|
||||
copy_values(&type_data, &mut values);
|
||||
// TODO: ******* Backwards compat start **********
|
||||
// To remove backwards compatibility, just delete this code,
|
||||
// and remove the compat code from cipher::to_json
|
||||
type_data["Name"] = Value::String(data.Name.clone());
|
||||
type_data["Notes"] = data.Notes.clone().map(Value::String).unwrap_or(Value::Null);
|
||||
type_data["Fields"] = data.Fields.clone().unwrap_or(Value::Null);
|
||||
// TODO: ******* Backwards compat end **********
|
||||
|
||||
cipher.favorite = data.Favorite.unwrap_or(false);
|
||||
cipher.name = data.Name;
|
||||
cipher.notes = data.Notes;
|
||||
cipher.fields = uppercase_fields.map(|f| f.to_string());
|
||||
cipher.data = values.to_string();
|
||||
cipher.fields = data.Fields.map(|f| f.to_string());
|
||||
cipher.data = type_data.to_string();
|
||||
|
||||
cipher.save(&conn);
|
||||
|
||||
@@ -206,23 +192,6 @@ fn update_cipher_from_data(cipher: &mut Cipher, data: CipherData, headers: &Head
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_values(from: &Value, to: &mut Value) {
|
||||
if let Some(map) = from.as_object() {
|
||||
for (key, val) in map {
|
||||
copy_values(val, &mut to[util::upcase_first(key)]);
|
||||
}
|
||||
} else if let Some(array) = from.as_array() {
|
||||
// Initialize array with null values
|
||||
*to = json!(vec![Value::Null; array.len()]);
|
||||
|
||||
for (index, val) in array.iter().enumerate() {
|
||||
copy_values(val, &mut to[index]);
|
||||
}
|
||||
} else {
|
||||
*to = from.clone();
|
||||
}
|
||||
}
|
||||
|
||||
use super::folders::FolderData;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -237,9 +206,9 @@ struct ImportData {
|
||||
#[allow(non_snake_case)]
|
||||
struct RelationsData {
|
||||
// Cipher id
|
||||
key: u32,
|
||||
Key: usize,
|
||||
// Folder id
|
||||
value: u32,
|
||||
Value: usize,
|
||||
}
|
||||
|
||||
|
||||
@@ -259,21 +228,18 @@ fn post_ciphers_import(data: JsonUpcase<ImportData>, headers: Headers, conn: DbC
|
||||
let mut relations_map = HashMap::new();
|
||||
|
||||
for relation in data.FolderRelationships {
|
||||
relations_map.insert(relation.key, relation.value);
|
||||
relations_map.insert(relation.Key, relation.Value);
|
||||
}
|
||||
|
||||
// Read and create the ciphers
|
||||
let mut index = 0;
|
||||
for cipher_data in data.Ciphers {
|
||||
for (index, cipher_data) in data.Ciphers.into_iter().enumerate() {
|
||||
let folder_uuid = relations_map.get(&index)
|
||||
.map(|i| folders[*i as usize].uuid.clone());
|
||||
.map(|i| folders[*i].uuid.clone());
|
||||
|
||||
let mut cipher = Cipher::new(cipher_data.Type, cipher_data.Name.clone());
|
||||
update_cipher_from_data(&mut cipher, cipher_data, &headers, true, &conn)?;
|
||||
|
||||
cipher.move_to_folder(folder_uuid, &headers.user.uuid.clone(), &conn).ok();
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -358,7 +324,6 @@ fn post_collections_admin(uuid: String, data: JsonUpcase<CollectionsAdminData>,
|
||||
#[derive(Deserialize)]
|
||||
#[allow(non_snake_case)]
|
||||
struct ShareCipherData {
|
||||
#[serde(deserialize_with = "util::upcase_deserialize")]
|
||||
Cipher: CipherData,
|
||||
CollectionIds: Vec<String>,
|
||||
}
|
||||
@@ -382,8 +347,8 @@ fn post_cipher_share(uuid: String, data: JsonUpcase<ShareCipherData>, headers: H
|
||||
None => err!("Organization id not provided"),
|
||||
Some(_) => {
|
||||
update_cipher_from_data(&mut cipher, data.Cipher, &headers, true, &conn)?;
|
||||
for collection in data.CollectionIds.iter() {
|
||||
match Collection::find_by_uuid(&collection, &conn) {
|
||||
for uuid in &data.CollectionIds {
|
||||
match Collection::find_by_uuid(uuid, &conn) {
|
||||
None => err!("Invalid collection ID provided"),
|
||||
Some(collection) => {
|
||||
if collection.is_writable_by_user(&headers.user.uuid, &conn) {
|
||||
@@ -418,7 +383,8 @@ fn post_attachment(uuid: String, data: Data, content_type: &ContentType, headers
|
||||
let base_path = Path::new(&CONFIG.attachments_folder).join(&cipher.uuid);
|
||||
|
||||
Multipart::with_body(data.open(), boundary).foreach_entry(|mut field| {
|
||||
let name = field.headers.filename.unwrap(); // This is provided by the client, don't trust it
|
||||
// This is provided by the client, don't trust it
|
||||
let name = field.headers.filename.expect("No filename provided");
|
||||
|
||||
let file_name = HEXLOWER.encode(&crypto::get_random(vec![0; 10]));
|
||||
let path = base_path.join(&file_name);
|
||||
@@ -428,17 +394,43 @@ fn post_attachment(uuid: String, data: Data, content_type: &ContentType, headers
|
||||
.size_limit(None)
|
||||
.with_path(path) {
|
||||
SaveResult::Full(SavedData::File(_, size)) => size as i32,
|
||||
_ => return
|
||||
SaveResult::Full(other) => {
|
||||
println!("Attachment is not a file: {:?}", other);
|
||||
return;
|
||||
},
|
||||
SaveResult::Partial(_, reason) => {
|
||||
println!("Partial result: {:?}", reason);
|
||||
return;
|
||||
},
|
||||
SaveResult::Error(e) => {
|
||||
println!("Error: {:?}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let attachment = Attachment::new(file_name, cipher.uuid.clone(), name, size);
|
||||
println!("Attachment: {:#?}", attachment);
|
||||
attachment.save(&conn);
|
||||
}).expect("Error processing multipart data");
|
||||
|
||||
Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, &conn)))
|
||||
}
|
||||
|
||||
#[post("/ciphers/<uuid>/attachment-admin", format = "multipart/form-data", data = "<data>")]
|
||||
fn post_attachment_admin(uuid: String, data: Data, content_type: &ContentType, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
post_attachment(uuid, data, content_type, headers, conn)
|
||||
}
|
||||
|
||||
#[post("/ciphers/<uuid>/attachment/<attachment_id>/share", format = "multipart/form-data", data = "<data>")]
|
||||
fn post_attachment_share(uuid: String, attachment_id: String, data: Data, content_type: &ContentType, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
_delete_cipher_attachment_by_id(&uuid, &attachment_id, &headers, &conn)?;
|
||||
post_attachment(uuid, data, content_type, headers, conn)
|
||||
}
|
||||
|
||||
#[post("/ciphers/<uuid>/attachment/<attachment_id>/delete-admin")]
|
||||
fn delete_attachment_post_admin(uuid: String, attachment_id: String, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
delete_attachment(uuid, attachment_id, headers, conn)
|
||||
}
|
||||
|
||||
#[post("/ciphers/<uuid>/attachment/<attachment_id>/delete")]
|
||||
fn delete_attachment_post(uuid: String, attachment_id: String, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
delete_attachment(uuid, attachment_id, headers, conn)
|
||||
@@ -446,29 +438,7 @@ fn delete_attachment_post(uuid: String, attachment_id: String, headers: Headers,
|
||||
|
||||
#[delete("/ciphers/<uuid>/attachment/<attachment_id>")]
|
||||
fn delete_attachment(uuid: String, attachment_id: String, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
let attachment = match Attachment::find_by_id(&attachment_id, &conn) {
|
||||
Some(attachment) => attachment,
|
||||
None => err!("Attachment doesn't exist")
|
||||
};
|
||||
|
||||
if attachment.cipher_uuid != uuid {
|
||||
err!("Attachment from other cipher")
|
||||
}
|
||||
|
||||
let cipher = match Cipher::find_by_uuid(&uuid, &conn) {
|
||||
Some(cipher) => cipher,
|
||||
None => err!("Cipher doesn't exist")
|
||||
};
|
||||
|
||||
if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn) {
|
||||
err!("Cipher cannot be deleted by user")
|
||||
}
|
||||
|
||||
// Delete attachment
|
||||
match attachment.delete(&conn) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(_) => err!("Deleting attachement failed")
|
||||
}
|
||||
_delete_cipher_attachment_by_id(&uuid, &attachment_id, &headers, &conn)
|
||||
}
|
||||
|
||||
#[post("/ciphers/<uuid>/delete")]
|
||||
@@ -476,6 +446,11 @@ fn delete_cipher_post(uuid: String, headers: Headers, conn: DbConn) -> EmptyResu
|
||||
_delete_cipher_by_uuid(&uuid, &headers, &conn)
|
||||
}
|
||||
|
||||
#[post("/ciphers/<uuid>/delete-admin")]
|
||||
fn delete_cipher_post_admin(uuid: String, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
_delete_cipher_by_uuid(&uuid, &headers, &conn)
|
||||
}
|
||||
|
||||
#[delete("/ciphers/<uuid>")]
|
||||
fn delete_cipher(uuid: String, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
_delete_cipher_by_uuid(&uuid, &headers, &conn)
|
||||
@@ -567,17 +542,15 @@ fn delete_all(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) ->
|
||||
|
||||
// Delete ciphers and their attachments
|
||||
for cipher in Cipher::find_owned_by_user(&user.uuid, &conn) {
|
||||
match cipher.delete(&conn) {
|
||||
Ok(()) => (),
|
||||
Err(_) => err!("Failed deleting cipher")
|
||||
if cipher.delete(&conn).is_err() {
|
||||
err!("Failed deleting cipher")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete folders
|
||||
for f in Folder::find_by_user(&user.uuid, &conn) {
|
||||
match f.delete(&conn) {
|
||||
Ok(()) => (),
|
||||
Err(_) => err!("Failed deleting folder")
|
||||
if f.delete(&conn).is_err() {
|
||||
err!("Failed deleting folder")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,3 +572,29 @@ fn _delete_cipher_by_uuid(uuid: &str, headers: &Headers, conn: &DbConn) -> Empty
|
||||
Err(_) => err!("Failed deleting cipher")
|
||||
}
|
||||
}
|
||||
|
||||
fn _delete_cipher_attachment_by_id(uuid: &str, attachment_id: &str, headers: &Headers, conn: &DbConn) -> EmptyResult {
|
||||
let attachment = match Attachment::find_by_id(&attachment_id, &conn) {
|
||||
Some(attachment) => attachment,
|
||||
None => err!("Attachment doesn't exist")
|
||||
};
|
||||
|
||||
if attachment.cipher_uuid != uuid {
|
||||
err!("Attachment from other cipher")
|
||||
}
|
||||
|
||||
let cipher = match Cipher::find_by_uuid(&uuid, &conn) {
|
||||
Some(cipher) => cipher,
|
||||
None => err!("Cipher doesn't exist")
|
||||
};
|
||||
|
||||
if !cipher.is_write_accessible_to_user(&headers.user.uuid, &conn) {
|
||||
err!("Cipher cannot be deleted by user")
|
||||
}
|
||||
|
||||
// Delete attachment
|
||||
match attachment.delete(&conn) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(_) => err!("Deleting attachement failed")
|
||||
}
|
||||
}
|
||||
|
@@ -646,5 +646,121 @@
|
||||
"wiktionary.org"
|
||||
],
|
||||
"Excluded": false
|
||||
},
|
||||
{
|
||||
"Type": 72,
|
||||
"Domains": [
|
||||
"airbnb.at",
|
||||
"airbnb.be",
|
||||
"airbnb.ca",
|
||||
"airbnb.ch",
|
||||
"airbnb.cl",
|
||||
"airbnb.co.cr",
|
||||
"airbnb.co.id",
|
||||
"airbnb.co.in",
|
||||
"airbnb.co.kr",
|
||||
"airbnb.co.nz",
|
||||
"airbnb.co.uk",
|
||||
"airbnb.co.ve",
|
||||
"airbnb.com",
|
||||
"airbnb.com.ar",
|
||||
"airbnb.com.au",
|
||||
"airbnb.com.bo",
|
||||
"airbnb.com.br",
|
||||
"airbnb.com.bz",
|
||||
"airbnb.com.co",
|
||||
"airbnb.com.ec",
|
||||
"airbnb.com.gt",
|
||||
"airbnb.com.hk",
|
||||
"airbnb.com.hn",
|
||||
"airbnb.com.mt",
|
||||
"airbnb.com.my",
|
||||
"airbnb.com.ni",
|
||||
"airbnb.com.pa",
|
||||
"airbnb.com.pe",
|
||||
"airbnb.com.py",
|
||||
"airbnb.com.sg",
|
||||
"airbnb.com.sv",
|
||||
"airbnb.com.tr",
|
||||
"airbnb.com.tw",
|
||||
"airbnb.cz",
|
||||
"airbnb.de",
|
||||
"airbnb.dk",
|
||||
"airbnb.es",
|
||||
"airbnb.fi",
|
||||
"airbnb.fr",
|
||||
"airbnb.gr",
|
||||
"airbnb.gy",
|
||||
"airbnb.hu",
|
||||
"airbnb.ie",
|
||||
"airbnb.is",
|
||||
"airbnb.it",
|
||||
"airbnb.jp",
|
||||
"airbnb.mx",
|
||||
"airbnb.nl",
|
||||
"airbnb.no",
|
||||
"airbnb.pl",
|
||||
"airbnb.pt",
|
||||
"airbnb.ru",
|
||||
"airbnb.se"
|
||||
],
|
||||
"Excluded": false
|
||||
},
|
||||
{
|
||||
"Type": 73,
|
||||
"Domains": [
|
||||
"eventbrite.at",
|
||||
"eventbrite.be",
|
||||
"eventbrite.ca",
|
||||
"eventbrite.ch",
|
||||
"eventbrite.cl",
|
||||
"eventbrite.co.id",
|
||||
"eventbrite.co.in",
|
||||
"eventbrite.co.kr",
|
||||
"eventbrite.co.nz",
|
||||
"eventbrite.co.uk",
|
||||
"eventbrite.co.ve",
|
||||
"eventbrite.com",
|
||||
"eventbrite.com.au",
|
||||
"eventbrite.com.bo",
|
||||
"eventbrite.com.br",
|
||||
"eventbrite.com.co",
|
||||
"eventbrite.com.hk",
|
||||
"eventbrite.com.hn",
|
||||
"eventbrite.com.pe",
|
||||
"eventbrite.com.sg",
|
||||
"eventbrite.com.tr",
|
||||
"eventbrite.com.tw",
|
||||
"eventbrite.cz",
|
||||
"eventbrite.de",
|
||||
"eventbrite.dk",
|
||||
"eventbrite.fi",
|
||||
"eventbrite.fr",
|
||||
"eventbrite.gy",
|
||||
"eventbrite.hu",
|
||||
"eventbrite.ie",
|
||||
"eventbrite.is",
|
||||
"eventbrite.it",
|
||||
"eventbrite.jp",
|
||||
"eventbrite.mx",
|
||||
"eventbrite.nl",
|
||||
"eventbrite.no",
|
||||
"eventbrite.pl",
|
||||
"eventbrite.pt",
|
||||
"eventbrite.ru",
|
||||
"eventbrite.se"
|
||||
],
|
||||
"Excluded": false
|
||||
},
|
||||
{
|
||||
"Type": 74,
|
||||
"Domains": [
|
||||
"stackexchange.com",
|
||||
"superuser.com",
|
||||
"stackoverflow.com",
|
||||
"serverfault.com",
|
||||
"mathoverflow.net"
|
||||
],
|
||||
"Excluded": false
|
||||
}
|
||||
]
|
@@ -2,7 +2,7 @@ mod accounts;
|
||||
mod ciphers;
|
||||
mod folders;
|
||||
mod organizations;
|
||||
mod two_factor;
|
||||
pub(crate) mod two_factor;
|
||||
|
||||
use self::accounts::*;
|
||||
use self::ciphers::*;
|
||||
@@ -14,13 +14,16 @@ pub fn routes() -> Vec<Route> {
|
||||
routes![
|
||||
register,
|
||||
profile,
|
||||
post_profile,
|
||||
get_public_keys,
|
||||
post_keys,
|
||||
post_password,
|
||||
post_sstamp,
|
||||
post_email_token,
|
||||
post_email,
|
||||
delete_account,
|
||||
revision_date,
|
||||
password_hint,
|
||||
|
||||
sync,
|
||||
|
||||
@@ -32,13 +35,17 @@ pub fn routes() -> Vec<Route> {
|
||||
post_ciphers_admin,
|
||||
post_ciphers_import,
|
||||
post_attachment,
|
||||
post_attachment_admin,
|
||||
post_attachment_share,
|
||||
delete_attachment_post,
|
||||
delete_attachment_post_admin,
|
||||
delete_attachment,
|
||||
post_cipher_admin,
|
||||
post_cipher_share,
|
||||
post_cipher,
|
||||
put_cipher,
|
||||
delete_cipher_post,
|
||||
delete_cipher_post_admin,
|
||||
delete_cipher,
|
||||
delete_cipher_selected,
|
||||
delete_all,
|
||||
@@ -55,13 +62,16 @@ pub fn routes() -> Vec<Route> {
|
||||
get_twofactor,
|
||||
get_recover,
|
||||
recover,
|
||||
disable_twofactor,
|
||||
generate_authenticator,
|
||||
activate_authenticator,
|
||||
disable_authenticator,
|
||||
generate_u2f,
|
||||
activate_u2f,
|
||||
|
||||
get_organization,
|
||||
create_organization,
|
||||
delete_organization,
|
||||
leave_organization,
|
||||
get_user_collections,
|
||||
get_org_collections,
|
||||
get_org_collection_detail,
|
||||
@@ -106,8 +116,7 @@ use auth::Headers;
|
||||
|
||||
#[put("/devices/identifier/<uuid>/clear-token", data = "<data>")]
|
||||
fn clear_device_token(uuid: String, data: Json<Value>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
println!("UUID: {:#?}", uuid);
|
||||
println!("DATA: {:#?}", data);
|
||||
let _data: Value = data.into_inner();
|
||||
|
||||
let device = match Device::find_by_uuid(&uuid, &conn) {
|
||||
Some(device) => device,
|
||||
@@ -125,9 +134,8 @@ fn clear_device_token(uuid: String, data: Json<Value>, headers: Headers, conn: D
|
||||
|
||||
#[put("/devices/identifier/<uuid>/token", data = "<data>")]
|
||||
fn put_device_token(uuid: String, data: Json<Value>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
println!("UUID: {:#?}", uuid);
|
||||
println!("DATA: {:#?}", data);
|
||||
|
||||
let _data: Value = data.into_inner();
|
||||
|
||||
let device = match Device::find_by_uuid(&uuid, &conn) {
|
||||
Some(device) => device,
|
||||
None => err!("Device not found")
|
||||
@@ -150,7 +158,7 @@ struct GlobalDomain {
|
||||
Excluded: bool,
|
||||
}
|
||||
|
||||
const GLOBAL_DOMAINS: &'static str = include_str!("global_domains.json");
|
||||
const GLOBAL_DOMAINS: &str = include_str!("global_domains.json");
|
||||
|
||||
#[get("/settings/domains")]
|
||||
fn get_eq_domains(headers: Headers) -> JsonResult {
|
||||
@@ -185,8 +193,8 @@ struct EquivDomainData {
|
||||
fn post_eq_domains(data: JsonUpcase<EquivDomainData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
let data: EquivDomainData = data.into_inner().data;
|
||||
|
||||
let excluded_globals = data.ExcludedGlobalEquivalentDomains.unwrap_or(Vec::new());
|
||||
let equivalent_domains = data.EquivalentDomains.unwrap_or(Vec::new());
|
||||
let excluded_globals = data.ExcludedGlobalEquivalentDomains.unwrap_or_default();
|
||||
let equivalent_domains = data.EquivalentDomains.unwrap_or_default();
|
||||
|
||||
let mut user = headers.user;
|
||||
use serde_json::to_string;
|
||||
|
@@ -73,6 +73,29 @@ fn delete_organization(org_id: String, data: JsonUpcase<PasswordData>, headers:
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/organizations/<org_id>/leave")]
|
||||
fn leave_organization(org_id: String, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||
match UserOrganization::find_by_user_and_org(&headers.user.uuid, &org_id, &conn) {
|
||||
None => err!("User not part of organization"),
|
||||
Some(user_org) => {
|
||||
if user_org.type_ == UserOrgType::Owner as i32 {
|
||||
let num_owners = UserOrganization::find_by_org_and_type(
|
||||
&org_id, UserOrgType::Owner as i32, &conn)
|
||||
.len();
|
||||
|
||||
if num_owners <= 1 {
|
||||
err!("The last owner can't leave")
|
||||
}
|
||||
}
|
||||
|
||||
match user_org.delete(&conn) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(_) => err!("Failed leaving the organization")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/organizations/<org_id>")]
|
||||
fn get_organization(org_id: String, _headers: OwnerHeaders, conn: DbConn) -> JsonResult {
|
||||
match Organization::find_by_uuid(&org_id, &conn) {
|
||||
@@ -288,8 +311,8 @@ fn get_org_users(org_id: String, headers: AdminHeaders, conn: DbConn) -> JsonRes
|
||||
#[derive(Deserialize)]
|
||||
#[allow(non_snake_case)]
|
||||
struct CollectionData {
|
||||
id: String,
|
||||
readOnly: bool,
|
||||
Id: String,
|
||||
ReadOnly: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -305,7 +328,7 @@ struct InviteData {
|
||||
fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeaders, conn: DbConn) -> EmptyResult {
|
||||
let data: InviteData = data.into_inner().data;
|
||||
|
||||
let new_type = match UserOrgType::from_str(&data.Type.to_string()) {
|
||||
let new_type = match UserOrgType::from_str(&data.Type.into_string()) {
|
||||
Some(new_type) => new_type as i32,
|
||||
None => err!("Invalid type")
|
||||
};
|
||||
@@ -319,9 +342,8 @@ fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeade
|
||||
match user_opt {
|
||||
None => err!("User email does not exist"),
|
||||
Some(user) => {
|
||||
match UserOrganization::find_by_user_and_org(&user.uuid, &org_id, &conn) {
|
||||
Some(_) => err!("User already in organization"),
|
||||
None => ()
|
||||
if UserOrganization::find_by_user_and_org(&user.uuid, &org_id, &conn).is_some() {
|
||||
err!("User already in organization")
|
||||
}
|
||||
|
||||
let mut new_user = UserOrganization::new(user.uuid.clone(), org_id.clone());
|
||||
@@ -331,13 +353,12 @@ fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeade
|
||||
|
||||
// If no accessAll, add the collections received
|
||||
if !access_all {
|
||||
for col in data.Collections.iter() {
|
||||
match Collection::find_by_uuid_and_org(&col.id, &org_id, &conn) {
|
||||
for col in &data.Collections {
|
||||
match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) {
|
||||
None => err!("Collection not found in Organization"),
|
||||
Some(collection) => {
|
||||
match CollectionUser::save(&user.uuid, &collection.uuid, col.readOnly, &conn) {
|
||||
Ok(()) => (),
|
||||
Err(_) => err!("Failed saving collection access for user")
|
||||
if CollectionUser::save(&user.uuid, &collection.uuid, col.ReadOnly, &conn).is_err() {
|
||||
err!("Failed saving collection access for user")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -375,7 +396,7 @@ fn confirm_invite(org_id: String, user_id: String, data: JsonUpcase<Value>, head
|
||||
}
|
||||
|
||||
user_to_confirm.status = UserOrgStatus::Confirmed as i32;
|
||||
user_to_confirm.key = match data["key"].as_str() {
|
||||
user_to_confirm.key = match data["Key"].as_str() {
|
||||
Some(key) => key.to_string(),
|
||||
None => err!("Invalid key provided")
|
||||
};
|
||||
@@ -411,7 +432,7 @@ struct EditUserData {
|
||||
fn edit_user(org_id: String, user_id: String, data: JsonUpcase<EditUserData>, headers: AdminHeaders, conn: DbConn) -> EmptyResult {
|
||||
let data: EditUserData = data.into_inner().data;
|
||||
|
||||
let new_type = match UserOrgType::from_str(&data.Type.to_string()) {
|
||||
let new_type = match UserOrgType::from_str(&data.Type.into_string()) {
|
||||
Some(new_type) => new_type as i32,
|
||||
None => err!("Invalid type")
|
||||
};
|
||||
@@ -449,21 +470,19 @@ fn edit_user(org_id: String, user_id: String, data: JsonUpcase<EditUserData>, he
|
||||
|
||||
// Delete all the odd collections
|
||||
for c in CollectionUser::find_by_organization_and_user_uuid(&org_id, &user_to_edit.user_uuid, &conn) {
|
||||
match c.delete(&conn) {
|
||||
Ok(()) => (),
|
||||
Err(_) => err!("Failed deleting old collection assignment")
|
||||
if c.delete(&conn).is_err() {
|
||||
err!("Failed deleting old collection assignment")
|
||||
}
|
||||
}
|
||||
|
||||
// If no accessAll, add the collections received
|
||||
if !data.AccessAll {
|
||||
for col in data.Collections.iter() {
|
||||
match Collection::find_by_uuid_and_org(&col.id, &org_id, &conn) {
|
||||
for col in &data.Collections {
|
||||
match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) {
|
||||
None => err!("Collection not found in Organization"),
|
||||
Some(collection) => {
|
||||
match CollectionUser::save(&user_to_edit.user_uuid, &collection.uuid, col.readOnly, &conn) {
|
||||
Ok(()) => (),
|
||||
Err(_) => err!("Failed saving collection access for user")
|
||||
if CollectionUser::save(&user_to_edit.user_uuid, &collection.uuid, col.ReadOnly, &conn).is_err() {
|
||||
err!("Failed saving collection access for user")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,28 +1,24 @@
|
||||
use rocket_contrib::{Json, Value};
|
||||
|
||||
use data_encoding::BASE32;
|
||||
use rocket_contrib::{Json, Value};
|
||||
use serde_json;
|
||||
|
||||
use db::DbConn;
|
||||
use db::{
|
||||
models::{TwoFactor, TwoFactorType, User},
|
||||
DbConn,
|
||||
};
|
||||
|
||||
use crypto;
|
||||
|
||||
use api::{PasswordData, JsonResult, NumberOrString, JsonUpcase};
|
||||
use api::{ApiResult, JsonResult, JsonUpcase, NumberOrString, PasswordData};
|
||||
use auth::Headers;
|
||||
|
||||
#[get("/two-factor")]
|
||||
fn get_twofactor(headers: Headers) -> JsonResult {
|
||||
let data = if headers.user.totp_secret.is_none() {
|
||||
Value::Null
|
||||
} else {
|
||||
json!([{
|
||||
"Enabled": true,
|
||||
"Type": 0,
|
||||
"Object": "twoFactorProvider"
|
||||
}])
|
||||
};
|
||||
fn get_twofactor(headers: Headers, conn: DbConn) -> JsonResult {
|
||||
let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn);
|
||||
let twofactors_json: Vec<Value> = twofactors.iter().map(|c| c.to_json_list()).collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"Data": data,
|
||||
"Data": twofactors_json,
|
||||
"Object": "list"
|
||||
})))
|
||||
}
|
||||
@@ -58,7 +54,7 @@ fn recover(data: JsonUpcase<RecoverTwoFactor>, conn: DbConn) -> JsonResult {
|
||||
// Get the user
|
||||
let mut user = match User::find_by_mail(&data.Email, &conn) {
|
||||
Some(user) => user,
|
||||
None => err!("Username or password is incorrect. Try again.")
|
||||
None => err!("Username or password is incorrect. Try again."),
|
||||
};
|
||||
|
||||
// Check password
|
||||
@@ -71,24 +67,69 @@ fn recover(data: JsonUpcase<RecoverTwoFactor>, conn: DbConn) -> JsonResult {
|
||||
err!("Recovery code is incorrect. Try again.")
|
||||
}
|
||||
|
||||
user.totp_secret = None;
|
||||
// Remove all twofactors from the user
|
||||
for twofactor in TwoFactor::find_by_user(&user.uuid, &conn) {
|
||||
twofactor.delete(&conn).expect("Error deleting twofactor");
|
||||
}
|
||||
|
||||
// Remove the recovery code, not needed without twofactors
|
||||
user.totp_recover = None;
|
||||
user.save(&conn);
|
||||
|
||||
Ok(Json(json!({})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(non_snake_case)]
|
||||
struct DisableTwoFactorData {
|
||||
MasterPasswordHash: String,
|
||||
Type: NumberOrString,
|
||||
}
|
||||
|
||||
#[post("/two-factor/disable", data = "<data>")]
|
||||
fn disable_twofactor(
|
||||
data: JsonUpcase<DisableTwoFactorData>,
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
) -> JsonResult {
|
||||
let data: DisableTwoFactorData = data.into_inner().data;
|
||||
let password_hash = data.MasterPasswordHash;
|
||||
|
||||
if !headers.user.check_valid_password(&password_hash) {
|
||||
err!("Invalid password");
|
||||
}
|
||||
|
||||
let type_ = data.Type.into_i32().expect("Invalid type");
|
||||
|
||||
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&headers.user.uuid, type_, &conn) {
|
||||
twofactor.delete(&conn).expect("Error deleting twofactor");
|
||||
}
|
||||
|
||||
Ok(Json(json!({
|
||||
"Enabled": false,
|
||||
"Type": type_,
|
||||
"Object": "twoFactorProvider"
|
||||
})))
|
||||
}
|
||||
|
||||
#[post("/two-factor/get-authenticator", data = "<data>")]
|
||||
fn generate_authenticator(data: JsonUpcase<PasswordData>, headers: Headers) -> JsonResult {
|
||||
fn generate_authenticator(
|
||||
data: JsonUpcase<PasswordData>,
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
) -> JsonResult {
|
||||
let data: PasswordData = data.into_inner().data;
|
||||
|
||||
if !headers.user.check_valid_password(&data.MasterPasswordHash) {
|
||||
err!("Invalid password");
|
||||
}
|
||||
|
||||
let (enabled, key) = match headers.user.totp_secret {
|
||||
Some(secret) => (true, secret),
|
||||
_ => (false, BASE32.encode(&crypto::get_random(vec![0u8; 20])))
|
||||
let type_ = TwoFactorType::Authenticator as i32;
|
||||
let twofactor = TwoFactor::find_by_user_and_type(&headers.user.uuid, type_, &conn);
|
||||
|
||||
let (enabled, key) = match twofactor {
|
||||
Some(tf) => (true, tf.data),
|
||||
_ => (false, BASE32.encode(&crypto::get_random(vec![0u8; 20]))),
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
@@ -100,20 +141,24 @@ fn generate_authenticator(data: JsonUpcase<PasswordData>, headers: Headers) -> J
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[allow(non_snake_case)]
|
||||
struct EnableTwoFactorData {
|
||||
struct EnableAuthenticatorData {
|
||||
MasterPasswordHash: String,
|
||||
Key: String,
|
||||
Token: NumberOrString,
|
||||
}
|
||||
|
||||
#[post("/two-factor/authenticator", data = "<data>")]
|
||||
fn activate_authenticator(data: JsonUpcase<EnableTwoFactorData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
let data: EnableTwoFactorData = data.into_inner().data;
|
||||
fn activate_authenticator(
|
||||
data: JsonUpcase<EnableAuthenticatorData>,
|
||||
headers: Headers,
|
||||
conn: DbConn,
|
||||
) -> JsonResult {
|
||||
let data: EnableAuthenticatorData = data.into_inner().data;
|
||||
let password_hash = data.MasterPasswordHash;
|
||||
let key = data.Key;
|
||||
let token = match data.Token.to_i32() {
|
||||
let token = match data.Token.into_i32() {
|
||||
Some(n) => n as u64,
|
||||
None => err!("Malformed token")
|
||||
None => err!("Malformed token"),
|
||||
};
|
||||
|
||||
if !headers.user.check_valid_password(&password_hash) {
|
||||
@@ -123,27 +168,24 @@ fn activate_authenticator(data: JsonUpcase<EnableTwoFactorData>, headers: Header
|
||||
// Validate key as base32 and 20 bytes length
|
||||
let decoded_key: Vec<u8> = match BASE32.decode(key.as_bytes()) {
|
||||
Ok(decoded) => decoded,
|
||||
_ => err!("Invalid totp secret")
|
||||
_ => err!("Invalid totp secret"),
|
||||
};
|
||||
|
||||
if decoded_key.len() != 20 {
|
||||
err!("Invalid key length")
|
||||
}
|
||||
|
||||
// Set key in user.totp_secret
|
||||
let mut user = headers.user;
|
||||
user.totp_secret = Some(key.to_uppercase());
|
||||
let type_ = TwoFactorType::Authenticator;
|
||||
let twofactor = TwoFactor::new(headers.user.uuid.clone(), type_, key.to_uppercase());
|
||||
|
||||
// Validate the token provided with the key
|
||||
if !user.check_totp_code(token) {
|
||||
if !twofactor.check_totp_code(token) {
|
||||
err!("Invalid totp code")
|
||||
}
|
||||
|
||||
// Generate totp_recover
|
||||
let totp_recover = BASE32.encode(&crypto::get_random(vec![0u8; 20]));
|
||||
user.totp_recover = Some(totp_recover);
|
||||
|
||||
user.save(&conn);
|
||||
let mut user = headers.user;
|
||||
_generate_recover_code(&mut user, &conn);
|
||||
twofactor.save(&conn).expect("Error saving twofactor");
|
||||
|
||||
Ok(Json(json!({
|
||||
"Enabled": true,
|
||||
@@ -152,32 +194,266 @@ fn activate_authenticator(data: JsonUpcase<EnableTwoFactorData>, headers: Header
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[allow(non_snake_case)]
|
||||
struct DisableTwoFactorData {
|
||||
MasterPasswordHash: String,
|
||||
Type: NumberOrString,
|
||||
fn _generate_recover_code(user: &mut User, conn: &DbConn) {
|
||||
if user.totp_recover.is_none() {
|
||||
let totp_recover = BASE32.encode(&crypto::get_random(vec![0u8; 20]));
|
||||
user.totp_recover = Some(totp_recover);
|
||||
user.save(conn);
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/two-factor/disable", data = "<data>")]
|
||||
fn disable_authenticator(data: JsonUpcase<DisableTwoFactorData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
let data: DisableTwoFactorData = data.into_inner().data;
|
||||
let password_hash = data.MasterPasswordHash;
|
||||
let _type = data.Type;
|
||||
use u2f::messages::{RegisterResponse, SignResponse, U2fSignRequest};
|
||||
use u2f::protocol::{Challenge, U2f};
|
||||
use u2f::register::Registration;
|
||||
|
||||
if !headers.user.check_valid_password(&password_hash) {
|
||||
use CONFIG;
|
||||
|
||||
const U2F_VERSION: &str = "U2F_V2";
|
||||
|
||||
lazy_static! {
|
||||
static ref APP_ID: String = format!("{}/app-id.json", &CONFIG.domain);
|
||||
static ref U2F: U2f = U2f::new(APP_ID.clone());
|
||||
}
|
||||
|
||||
#[post("/two-factor/get-u2f", data = "<data>")]
|
||||
fn generate_u2f(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
if !CONFIG.domain_set {
|
||||
err!("`DOMAIN` environment variable is not set. U2F disabled")
|
||||
}
|
||||
|
||||
let data: PasswordData = data.into_inner().data;
|
||||
|
||||
if !headers.user.check_valid_password(&data.MasterPasswordHash) {
|
||||
err!("Invalid password");
|
||||
}
|
||||
|
||||
let mut user = headers.user;
|
||||
user.totp_secret = None;
|
||||
user.totp_recover = None;
|
||||
let user_uuid = &headers.user.uuid;
|
||||
|
||||
user.save(&conn);
|
||||
let u2f_type = TwoFactorType::U2f as i32;
|
||||
let register_type = TwoFactorType::U2fRegisterChallenge;
|
||||
let (enabled, challenge) = match TwoFactor::find_by_user_and_type(user_uuid, u2f_type, &conn) {
|
||||
Some(_) => (true, String::new()),
|
||||
None => {
|
||||
let c = _create_u2f_challenge(user_uuid, register_type, &conn);
|
||||
(false, c.challenge)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
"Enabled": false,
|
||||
"Type": 0,
|
||||
"Object": "twoFactorProvider"
|
||||
"Enabled": enabled,
|
||||
"Challenge": {
|
||||
"UserId": headers.user.uuid,
|
||||
"AppId": APP_ID.to_string(),
|
||||
"Challenge": challenge,
|
||||
"Version": U2F_VERSION,
|
||||
},
|
||||
"Object": "twoFactorU2f"
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[allow(non_snake_case)]
|
||||
struct EnableU2FData {
|
||||
MasterPasswordHash: String,
|
||||
DeviceResponse: String,
|
||||
}
|
||||
|
||||
// This struct is copied from the U2F lib
|
||||
// because challenge is not always sent
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RegisterResponseCopy {
|
||||
pub registration_data: String,
|
||||
pub version: String,
|
||||
pub challenge: Option<String>,
|
||||
pub error_code: Option<NumberOrString>,
|
||||
pub client_data: String,
|
||||
}
|
||||
|
||||
impl RegisterResponseCopy {
|
||||
fn into_response(self, challenge: String) -> RegisterResponse {
|
||||
RegisterResponse {
|
||||
registration_data: self.registration_data,
|
||||
version: self.version,
|
||||
challenge: challenge,
|
||||
client_data: self.client_data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/two-factor/u2f", data = "<data>")]
|
||||
fn activate_u2f(data: JsonUpcase<EnableU2FData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
let data: EnableU2FData = data.into_inner().data;
|
||||
|
||||
if !headers.user.check_valid_password(&data.MasterPasswordHash) {
|
||||
err!("Invalid password");
|
||||
}
|
||||
|
||||
let tf_challenge = TwoFactor::find_by_user_and_type(
|
||||
&headers.user.uuid,
|
||||
TwoFactorType::U2fRegisterChallenge as i32,
|
||||
&conn,
|
||||
);
|
||||
|
||||
if let Some(tf_challenge) = tf_challenge {
|
||||
let challenge: Challenge = serde_json::from_str(&tf_challenge.data)
|
||||
.expect("Can't parse U2fRegisterChallenge data");
|
||||
|
||||
tf_challenge
|
||||
.delete(&conn)
|
||||
.expect("Error deleting U2F register challenge");
|
||||
|
||||
let response_copy: RegisterResponseCopy =
|
||||
serde_json::from_str(&data.DeviceResponse).expect("Can't parse RegisterResponse data");
|
||||
|
||||
let error_code = response_copy
|
||||
.error_code
|
||||
.clone()
|
||||
.map_or("0".into(), NumberOrString::into_string);
|
||||
|
||||
if error_code != "0" {
|
||||
err!("Error registering U2F token")
|
||||
}
|
||||
|
||||
let response = response_copy.into_response(challenge.challenge.clone());
|
||||
|
||||
match U2F.register_response(challenge.clone(), response) {
|
||||
Ok(registration) => {
|
||||
// TODO: Allow more than one U2F device
|
||||
let mut registrations = Vec::new();
|
||||
registrations.push(registration);
|
||||
|
||||
let tf_registration = TwoFactor::new(
|
||||
headers.user.uuid.clone(),
|
||||
TwoFactorType::U2f,
|
||||
serde_json::to_string(®istrations).unwrap(),
|
||||
);
|
||||
tf_registration
|
||||
.save(&conn)
|
||||
.expect("Error saving U2F registration");
|
||||
|
||||
let mut user = headers.user;
|
||||
_generate_recover_code(&mut user, &conn);
|
||||
|
||||
Ok(Json(json!({
|
||||
"Enabled": true,
|
||||
"Challenge": {
|
||||
"UserId": user.uuid,
|
||||
"AppId": APP_ID.to_string(),
|
||||
"Challenge": challenge,
|
||||
"Version": U2F_VERSION,
|
||||
},
|
||||
"Object": "twoFactorU2f"
|
||||
})))
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error: {:#?}", e);
|
||||
err!("Error activating u2f")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
err!("Can't recover challenge")
|
||||
}
|
||||
}
|
||||
|
||||
fn _create_u2f_challenge(user_uuid: &str, type_: TwoFactorType, conn: &DbConn) -> Challenge {
|
||||
let challenge = U2F.generate_challenge().unwrap();
|
||||
|
||||
TwoFactor::new(
|
||||
user_uuid.into(),
|
||||
type_,
|
||||
serde_json::to_string(&challenge).unwrap(),
|
||||
).save(conn)
|
||||
.expect("Error saving challenge");
|
||||
|
||||
challenge
|
||||
}
|
||||
|
||||
// This struct is copied from the U2F lib
|
||||
// because it doesn't implement Deserialize
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RegistrationCopy {
|
||||
pub key_handle: Vec<u8>,
|
||||
pub pub_key: Vec<u8>,
|
||||
pub attestation_cert: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Into<Registration> for RegistrationCopy {
|
||||
fn into(self) -> Registration {
|
||||
Registration {
|
||||
key_handle: self.key_handle,
|
||||
pub_key: self.pub_key,
|
||||
attestation_cert: self.attestation_cert,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn _parse_registrations(registations: &str) -> Vec<Registration> {
|
||||
let registrations_copy: Vec<RegistrationCopy> =
|
||||
serde_json::from_str(registations).expect("Can't parse RegistrationCopy data");
|
||||
|
||||
registrations_copy.into_iter().map(Into::into).collect()
|
||||
}
|
||||
|
||||
pub fn generate_u2f_login(user_uuid: &str, conn: &DbConn) -> ApiResult<U2fSignRequest> {
|
||||
let challenge = _create_u2f_challenge(user_uuid, TwoFactorType::U2fLoginChallenge, conn);
|
||||
|
||||
let type_ = TwoFactorType::U2f as i32;
|
||||
let twofactor = match TwoFactor::find_by_user_and_type(user_uuid, type_, conn) {
|
||||
Some(tf) => tf,
|
||||
None => err!("No U2F devices registered"),
|
||||
};
|
||||
|
||||
let registrations = _parse_registrations(&twofactor.data);
|
||||
let signed_request: U2fSignRequest = U2F.sign_request(challenge, registrations);
|
||||
|
||||
Ok(signed_request)
|
||||
}
|
||||
|
||||
pub fn validate_u2f_login(user_uuid: &str, response: &str, conn: &DbConn) -> ApiResult<()> {
|
||||
let challenge_type = TwoFactorType::U2fLoginChallenge as i32;
|
||||
let u2f_type = TwoFactorType::U2f as i32;
|
||||
|
||||
let tf_challenge = TwoFactor::find_by_user_and_type(user_uuid, challenge_type, &conn);
|
||||
|
||||
let challenge = match tf_challenge {
|
||||
Some(tf_challenge) => {
|
||||
let challenge: Challenge = serde_json::from_str(&tf_challenge.data)
|
||||
.expect("Can't parse U2fLoginChallenge data");
|
||||
tf_challenge
|
||||
.delete(&conn)
|
||||
.expect("Error deleting U2F login challenge");
|
||||
challenge
|
||||
}
|
||||
None => err!("Can't recover login challenge"),
|
||||
};
|
||||
|
||||
let twofactor = match TwoFactor::find_by_user_and_type(user_uuid, u2f_type, conn) {
|
||||
Some(tf) => tf,
|
||||
None => err!("No U2F devices registered"),
|
||||
};
|
||||
|
||||
let registrations = _parse_registrations(&twofactor.data);
|
||||
|
||||
let response: SignResponse =
|
||||
serde_json::from_str(response).expect("Can't parse SignResponse data");
|
||||
|
||||
let mut _counter: u32 = 0;
|
||||
for registration in registrations {
|
||||
let response =
|
||||
U2F.sign_response(challenge.clone(), registration, response.clone(), _counter);
|
||||
match response {
|
||||
Ok(new_counter) => {
|
||||
_counter = new_counter;
|
||||
println!("O {:#}", new_counter);
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => {
|
||||
println!("E {:#}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
err!("error verifying response")
|
||||
}
|
||||
|
112
src/api/icons.rs
112
src/api/icons.rs
@@ -1,4 +1,3 @@
|
||||
use std::io;
|
||||
use std::io::prelude::*;
|
||||
use std::fs::{create_dir_all, File};
|
||||
|
||||
@@ -23,24 +22,59 @@ fn icon(domain: String) -> Content<Vec<u8>> {
|
||||
return Content(icon_type, get_fallback_icon());
|
||||
}
|
||||
|
||||
let url = format!("https://icons.bitwarden.com/{}/icon.png", domain);
|
||||
|
||||
// Get the icon, or fallback in case of error
|
||||
let icon = match get_icon_cached(&domain, &url) {
|
||||
Ok(icon) => icon,
|
||||
Err(_) => return Content(icon_type, get_fallback_icon())
|
||||
};
|
||||
let icon = get_icon(&domain);
|
||||
|
||||
Content(icon_type, icon)
|
||||
}
|
||||
|
||||
fn get_icon(url: &str) -> Result<Vec<u8>, reqwest::Error> {
|
||||
fn get_icon (domain: &str) -> Vec<u8> {
|
||||
let path = format!("{}/{}.png", CONFIG.icon_cache_folder, domain);
|
||||
|
||||
if let Some(icon) = get_cached_icon(&path) {
|
||||
return icon;
|
||||
}
|
||||
|
||||
let url = get_icon_url(&domain);
|
||||
|
||||
// Get the icon, or fallback in case of error
|
||||
match download_icon(&url) {
|
||||
Ok(icon) => {
|
||||
save_icon(&path, &icon);
|
||||
icon
|
||||
},
|
||||
Err(e) => {
|
||||
println!("Error downloading icon: {:?}", e);
|
||||
get_fallback_icon()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
|
||||
// Try to read the cached icon, and return it if it exists
|
||||
if let Ok(mut f) = File::open(path) {
|
||||
let mut buffer = Vec::new();
|
||||
|
||||
if f.read_to_end(&mut buffer).is_ok() {
|
||||
return Some(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn get_icon_url(domain: &str) -> String {
|
||||
if CONFIG.local_icon_extractor {
|
||||
format!("http://{}/favicon.ico", domain)
|
||||
} else {
|
||||
format!("https://icons.bitwarden.com/{}/icon.png", domain)
|
||||
}
|
||||
}
|
||||
|
||||
fn download_icon(url: &str) -> Result<Vec<u8>, reqwest::Error> {
|
||||
println!("Downloading icon for {}...", url);
|
||||
let mut res = reqwest::get(url)?;
|
||||
|
||||
res = match res.error_for_status() {
|
||||
Err(e) => return Err(e),
|
||||
Ok(res) => res
|
||||
};
|
||||
res = res.error_for_status()?;
|
||||
|
||||
let mut buffer: Vec<u8> = vec![];
|
||||
res.copy_to(&mut buffer)?;
|
||||
@@ -48,39 +82,31 @@ fn get_icon(url: &str) -> Result<Vec<u8>, reqwest::Error> {
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
fn get_icon_cached(key: &str, url: &str) -> io::Result<Vec<u8>> {
|
||||
create_dir_all(&CONFIG.icon_cache_folder)?;
|
||||
let path = &format!("{}/{}.png", CONFIG.icon_cache_folder, key);
|
||||
fn save_icon(path: &str, icon: &[u8]) {
|
||||
create_dir_all(&CONFIG.icon_cache_folder).expect("Error creating icon cache");
|
||||
|
||||
// Try to read the cached icon, and return it if it exists
|
||||
match File::open(path) {
|
||||
Ok(mut f) => {
|
||||
let mut buffer = Vec::new();
|
||||
|
||||
if f.read_to_end(&mut buffer).is_ok() {
|
||||
return Ok(buffer);
|
||||
}
|
||||
/* If error reading file continue */
|
||||
}
|
||||
Err(_) => { /* Continue */ }
|
||||
}
|
||||
|
||||
println!("Downloading icon for {}...", key);
|
||||
let icon = match get_icon(url) {
|
||||
Ok(icon) => icon,
|
||||
Err(_) => return Err(io::Error::new(io::ErrorKind::NotFound, ""))
|
||||
if let Ok(mut f) = File::create(path) {
|
||||
f.write_all(icon).expect("Error writing icon file");
|
||||
};
|
||||
|
||||
// Save the currently downloaded icon
|
||||
match File::create(path) {
|
||||
Ok(mut f) => { f.write_all(&icon).expect("Error writing icon file"); }
|
||||
Err(_) => { /* Continue */ }
|
||||
};
|
||||
|
||||
Ok(icon)
|
||||
}
|
||||
|
||||
const FALLBACK_ICON_URL: &str = "https://raw.githubusercontent.com/bitwarden/web/master/src/images/fa-globe.png";
|
||||
|
||||
fn get_fallback_icon() -> Vec<u8> {
|
||||
let fallback_icon = "https://raw.githubusercontent.com/bitwarden/web/master/src/images/fa-globe.png";
|
||||
get_icon_cached("default", fallback_icon).unwrap()
|
||||
let path = format!("{}/default.png", CONFIG.icon_cache_folder);
|
||||
|
||||
if let Some(icon) = get_cached_icon(&path) {
|
||||
return icon;
|
||||
}
|
||||
|
||||
match download_icon(FALLBACK_ICON_URL) {
|
||||
Ok(icon) => {
|
||||
save_icon(&path, &icon);
|
||||
icon
|
||||
},
|
||||
Err(e) => {
|
||||
println!("Error downloading fallback icon: {:?}", e);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,40 +1,43 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rocket::{Route, Outcome};
|
||||
use rocket::request::{self, Request, FromRequest, Form, FormItems, FromForm};
|
||||
use rocket::request::{self, Form, FormItems, FromForm, FromRequest, Request};
|
||||
use rocket::{Outcome, Route};
|
||||
|
||||
use rocket_contrib::{Json, Value};
|
||||
|
||||
use db::DbConn;
|
||||
use num_traits::FromPrimitive;
|
||||
|
||||
use db::models::*;
|
||||
use db::DbConn;
|
||||
|
||||
use util;
|
||||
use util::{self, JsonMap};
|
||||
|
||||
use api::JsonResult;
|
||||
use api::{ApiResult, JsonResult};
|
||||
|
||||
use CONFIG;
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![ login]
|
||||
routes![login]
|
||||
}
|
||||
|
||||
#[post("/connect/token", data = "<connect_data>")]
|
||||
fn login(connect_data: Form<ConnectData>, device_type: DeviceType, conn: DbConn) -> JsonResult {
|
||||
let data = connect_data.get();
|
||||
println!("{:#?}", data);
|
||||
|
||||
match data.grant_type {
|
||||
GrantType::RefreshToken =>_refresh_login(data, device_type, conn),
|
||||
GrantType::Password => _password_login(data, device_type, conn)
|
||||
GrantType::RefreshToken => _refresh_login(data, device_type, conn),
|
||||
GrantType::Password => _password_login(data, device_type, conn),
|
||||
}
|
||||
}
|
||||
|
||||
fn _refresh_login(data: &ConnectData, _device_type: DeviceType, conn: DbConn) -> JsonResult {
|
||||
// Extract token
|
||||
let token = data.get("refresh_token").unwrap();
|
||||
let token = data.get("refresh_token");
|
||||
|
||||
// Get device by refresh token
|
||||
let mut device = match Device::find_by_refresh_token(token, &conn) {
|
||||
Some(device) => device,
|
||||
None => err!("Invalid refresh token")
|
||||
None => err!("Invalid refresh token"),
|
||||
};
|
||||
|
||||
// COMMON
|
||||
@@ -56,35 +59,34 @@ fn _refresh_login(data: &ConnectData, _device_type: DeviceType, conn: DbConn) ->
|
||||
|
||||
fn _password_login(data: &ConnectData, device_type: DeviceType, conn: DbConn) -> JsonResult {
|
||||
// Validate scope
|
||||
let scope = data.get("scope").unwrap();
|
||||
let scope = data.get("scope");
|
||||
if scope != "api offline_access" {
|
||||
err!("Scope not supported")
|
||||
}
|
||||
|
||||
// Get the user
|
||||
let username = data.get("username").unwrap();
|
||||
let username = data.get("username");
|
||||
let user = match User::find_by_mail(username, &conn) {
|
||||
Some(user) => user,
|
||||
None => err!("Username or password is incorrect. Try again.")
|
||||
None => err!("Username or password is incorrect. Try again."),
|
||||
};
|
||||
|
||||
// Check password
|
||||
let password = data.get("password").unwrap();
|
||||
let password = data.get("password");
|
||||
if !user.check_valid_password(password) {
|
||||
err!("Username or password is incorrect. Try again.")
|
||||
}
|
||||
|
||||
|
||||
// Let's only use the header and ignore the 'devicetype' parameter
|
||||
let device_type_num = device_type.0;
|
||||
|
||||
let (device_id, device_name) = match data.is_device {
|
||||
false => { (format!("web-{}", user.uuid), String::from("web")) }
|
||||
true => {
|
||||
(
|
||||
data.get("deviceidentifier").unwrap().clone(),
|
||||
data.get("devicename").unwrap().clone(),
|
||||
)
|
||||
}
|
||||
let (device_id, device_name) = if data.is_device {
|
||||
(
|
||||
data.get("deviceidentifier").clone(),
|
||||
data.get("devicename").clone(),
|
||||
)
|
||||
} else {
|
||||
(format!("web-{}", user.uuid), String::from("web"))
|
||||
};
|
||||
|
||||
// Find device or create new
|
||||
@@ -104,42 +106,7 @@ fn _password_login(data: &ConnectData, device_type: DeviceType, conn: DbConn) ->
|
||||
}
|
||||
};
|
||||
|
||||
let twofactor_token = if user.requires_twofactor() {
|
||||
let twofactor_provider = util::parse_option_string(data.get("twoFactorProvider")).unwrap_or(0);
|
||||
let twofactor_code = match data.get("twoFactorToken") {
|
||||
Some(code) => code,
|
||||
None => err_json!(_json_err_twofactor())
|
||||
};
|
||||
|
||||
match twofactor_provider {
|
||||
0 /* TOTP */ => {
|
||||
let totp_code: u64 = match twofactor_code.parse() {
|
||||
Ok(code) => code,
|
||||
Err(_) => err!("Invalid Totp code")
|
||||
};
|
||||
|
||||
if !user.check_totp_code(totp_code) {
|
||||
err_json!(_json_err_twofactor())
|
||||
}
|
||||
|
||||
if util::parse_option_string(data.get("twoFactorRemember")).unwrap_or(0) == 1 {
|
||||
device.refresh_twofactor_remember();
|
||||
device.twofactor_remember.clone()
|
||||
} else {
|
||||
device.delete_twofactor_remember();
|
||||
None
|
||||
}
|
||||
},
|
||||
5 /* Remember */ => {
|
||||
match device.twofactor_remember {
|
||||
Some(ref remember) if remember == twofactor_code => (),
|
||||
_ => err_json!(_json_err_twofactor())
|
||||
};
|
||||
None // No twofactor token needed here
|
||||
},
|
||||
_ => err!("Invalid two factor provider"),
|
||||
}
|
||||
} else { None }; // No twofactor token if twofactor is disabled
|
||||
let twofactor_token = twofactor_auth(&user.uuid, &data, &mut device, &conn)?;
|
||||
|
||||
// Common
|
||||
let user = User::find_by_uuid(&device.user_uuid, &conn).unwrap();
|
||||
@@ -165,48 +132,127 @@ fn _password_login(data: &ConnectData, device_type: DeviceType, conn: DbConn) ->
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
fn _json_err_twofactor() -> Value {
|
||||
json!({
|
||||
fn twofactor_auth(
|
||||
user_uuid: &str,
|
||||
data: &ConnectData,
|
||||
device: &mut Device,
|
||||
conn: &DbConn,
|
||||
) -> ApiResult<Option<String>> {
|
||||
let twofactors_raw = TwoFactor::find_by_user(user_uuid, conn);
|
||||
// Remove u2f challenge twofactors (impl detail)
|
||||
let twofactors: Vec<_> = twofactors_raw.iter().filter(|tf| tf.type_ < 1000).collect();
|
||||
|
||||
let providers: Vec<_> = twofactors.iter().map(|tf| tf.type_).collect();
|
||||
|
||||
// No twofactor token if twofactor is disabled
|
||||
if twofactors.len() == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let provider = match util::parse_option_string(data.get_opt("twoFactorProvider")) {
|
||||
Some(provider) => provider,
|
||||
None => providers[0], // If we aren't given a two factor provider, asume the first one
|
||||
};
|
||||
|
||||
let twofactor_code = match data.get_opt("twoFactorToken") {
|
||||
Some(code) => code,
|
||||
None => err_json!(_json_err_twofactor(&providers, user_uuid, conn)?),
|
||||
};
|
||||
|
||||
let twofactor = twofactors.iter().filter(|tf| tf.type_ == provider).nth(0);
|
||||
|
||||
match TwoFactorType::from_i32(provider) {
|
||||
Some(TwoFactorType::Remember) => {
|
||||
match &device.twofactor_remember {
|
||||
Some(remember) if remember == twofactor_code => return Ok(None), // No twofactor token needed here
|
||||
_ => err_json!(_json_err_twofactor(&providers, user_uuid, conn)?),
|
||||
}
|
||||
}
|
||||
|
||||
Some(TwoFactorType::Authenticator) => {
|
||||
let twofactor = match twofactor {
|
||||
Some(tf) => tf,
|
||||
None => err!("TOTP not enabled"),
|
||||
};
|
||||
|
||||
let totp_code: u64 = match twofactor_code.parse() {
|
||||
Ok(code) => code,
|
||||
_ => err!("Invalid TOTP code"),
|
||||
};
|
||||
|
||||
if !twofactor.check_totp_code(totp_code) {
|
||||
err_json!(_json_err_twofactor(&providers, user_uuid, conn)?)
|
||||
}
|
||||
}
|
||||
|
||||
Some(TwoFactorType::U2f) => {
|
||||
use api::core::two_factor;
|
||||
|
||||
two_factor::validate_u2f_login(user_uuid, twofactor_code, conn)?;
|
||||
}
|
||||
|
||||
_ => err!("Invalid two factor provider"),
|
||||
}
|
||||
|
||||
if util::parse_option_string(data.get_opt("twoFactorRemember")).unwrap_or(0) == 1 {
|
||||
Ok(Some(device.refresh_twofactor_remember()))
|
||||
} else {
|
||||
device.delete_twofactor_remember();
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &DbConn) -> ApiResult<Value> {
|
||||
use api::core::two_factor;
|
||||
|
||||
let mut result = json!({
|
||||
"error" : "invalid_grant",
|
||||
"error_description" : "Two factor required.",
|
||||
"TwoFactorProviders" : [ 0 ],
|
||||
"TwoFactorProviders2" : { "0" : null }
|
||||
})
|
||||
}
|
||||
"TwoFactorProviders" : providers,
|
||||
"TwoFactorProviders2" : {} // { "0" : null }
|
||||
});
|
||||
|
||||
/*
|
||||
ConnectData {
|
||||
grant_type: Password,
|
||||
is_device: false,
|
||||
data: {
|
||||
"scope": "api offline_access",
|
||||
"client_id": "web",
|
||||
"grant_type": "password",
|
||||
"username": "dani@mail",
|
||||
"password": "8IuV1sJ94tPjyYIK+E+PTjblzjm4W6C4N5wqM0KKsSg="
|
||||
for provider in providers {
|
||||
result["TwoFactorProviders2"][provider.to_string()] = Value::Null;
|
||||
|
||||
match TwoFactorType::from_i32(*provider) {
|
||||
Some(TwoFactorType::Authenticator) => { /* Nothing to do for TOTP */ }
|
||||
|
||||
Some(TwoFactorType::U2f) if CONFIG.domain_set => {
|
||||
let request = two_factor::generate_u2f_login(user_uuid, conn)?;
|
||||
let mut challenge_list = Vec::new();
|
||||
|
||||
for key in request.registered_keys {
|
||||
let mut challenge_map = JsonMap::new();
|
||||
|
||||
challenge_map.insert("appId".into(), Value::String(request.app_id.clone()));
|
||||
challenge_map
|
||||
.insert("challenge".into(), Value::String(request.challenge.clone()));
|
||||
challenge_map.insert("version".into(), Value::String(key.version));
|
||||
challenge_map.insert(
|
||||
"keyHandle".into(),
|
||||
Value::String(key.key_handle.unwrap_or_default()),
|
||||
);
|
||||
|
||||
challenge_list.push(Value::Object(challenge_map));
|
||||
}
|
||||
|
||||
let mut map = JsonMap::new();
|
||||
use serde_json;
|
||||
let challenge_list_str = serde_json::to_string(&challenge_list).unwrap();
|
||||
|
||||
map.insert("Challenges".into(), Value::String(challenge_list_str));
|
||||
result["TwoFactorProviders2"][provider.to_string()] = Value::Object(map);
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
RETURNS "TwoFactorToken": "11122233333444555666777888999"
|
||||
|
||||
Next login
|
||||
ConnectData {
|
||||
grant_type: Password,
|
||||
is_device: false,
|
||||
data: {
|
||||
"scope": "api offline_access",
|
||||
"username": "dani@mail",
|
||||
"client_id": "web",
|
||||
"twofactorprovider": "5",
|
||||
"twofactortoken": "11122233333444555666777888999",
|
||||
"grant_type": "password",
|
||||
"twofactorremember": "0",
|
||||
"password": "8IuV1sJ94tPjyYIK+E+PTjblzjm4W6C4N5wqM0KKsSg="
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct DeviceType(i32);
|
||||
|
||||
impl<'a, 'r> FromRequest<'a, 'r> for DeviceType {
|
||||
@@ -221,7 +267,6 @@ impl<'a, 'r> FromRequest<'a, 'r> for DeviceType {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ConnectData {
|
||||
grant_type: GrantType,
|
||||
@@ -230,10 +275,17 @@ struct ConnectData {
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
enum GrantType { RefreshToken, Password }
|
||||
enum GrantType {
|
||||
RefreshToken,
|
||||
Password,
|
||||
}
|
||||
|
||||
impl ConnectData {
|
||||
fn get(&self, key: &str) -> Option<&String> {
|
||||
fn get(&self, key: &str) -> &String {
|
||||
&self.data[&key.to_lowercase()]
|
||||
}
|
||||
|
||||
fn get_opt(&self, key: &str) -> Option<&String> {
|
||||
self.data.get(&key.to_lowercase())
|
||||
}
|
||||
}
|
||||
@@ -252,30 +304,33 @@ impl<'f> FromForm<'f> for ConnectData {
|
||||
for (key, value) in items {
|
||||
match (key.url_decode(), value.url_decode()) {
|
||||
(Ok(key), Ok(value)) => data.insert(key.to_lowercase(), value),
|
||||
_ => return Err(format!("Error decoding key or value")),
|
||||
_ => return Err("Error decoding key or value".to_string()),
|
||||
};
|
||||
}
|
||||
|
||||
// Validate needed values
|
||||
let (grant_type, is_device) =
|
||||
match data.get("grant_type").map(String::as_ref) {
|
||||
Some("refresh_token") => {
|
||||
check_values(&data, &VALUES_REFRESH)?;
|
||||
(GrantType::RefreshToken, false) // Device doesn't matter here
|
||||
}
|
||||
Some("password") => {
|
||||
check_values(&data, &VALUES_PASSWORD)?;
|
||||
let (grant_type, is_device) = match data.get("grant_type").map(String::as_ref) {
|
||||
Some("refresh_token") => {
|
||||
check_values(&data, &VALUES_REFRESH)?;
|
||||
(GrantType::RefreshToken, false) // Device doesn't matter here
|
||||
}
|
||||
Some("password") => {
|
||||
check_values(&data, &VALUES_PASSWORD)?;
|
||||
|
||||
let is_device = match data.get("client_id").unwrap().as_ref() {
|
||||
"browser" | "mobile" => check_values(&data, &VALUES_DEVICE)?,
|
||||
_ => false
|
||||
};
|
||||
(GrantType::Password, is_device)
|
||||
}
|
||||
_ => return Err(format!("Grant type not supported"))
|
||||
};
|
||||
let is_device = match data["client_id"].as_ref() {
|
||||
"browser" | "mobile" => check_values(&data, &VALUES_DEVICE)?,
|
||||
_ => false,
|
||||
};
|
||||
(GrantType::Password, is_device)
|
||||
}
|
||||
_ => return Err("Grant type not supported".to_string()),
|
||||
};
|
||||
|
||||
Ok(ConnectData { grant_type, is_device, data })
|
||||
Ok(ConnectData {
|
||||
grant_type,
|
||||
is_device,
|
||||
data,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,4 +1,4 @@
|
||||
mod core;
|
||||
pub(crate) mod core;
|
||||
mod icons;
|
||||
mod identity;
|
||||
mod web;
|
||||
@@ -12,8 +12,9 @@ use rocket::response::status::BadRequest;
|
||||
use rocket_contrib::Json;
|
||||
|
||||
// Type aliases for API methods results
|
||||
type JsonResult = Result<Json, BadRequest<Json>>;
|
||||
type EmptyResult = Result<(), BadRequest<Json>>;
|
||||
type ApiResult<T> = Result<T, BadRequest<Json>>;
|
||||
type JsonResult = ApiResult<Json>;
|
||||
type EmptyResult = ApiResult<()>;
|
||||
|
||||
use util;
|
||||
type JsonUpcase<T> = Json<util::UpCase<T>>;
|
||||
@@ -25,7 +26,7 @@ struct PasswordData {
|
||||
MasterPasswordHash: String
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
#[serde(untagged)]
|
||||
enum NumberOrString {
|
||||
Number(i32),
|
||||
@@ -33,14 +34,14 @@ enum NumberOrString {
|
||||
}
|
||||
|
||||
impl NumberOrString {
|
||||
fn to_string(self) -> String {
|
||||
fn into_string(self) -> String {
|
||||
match self {
|
||||
NumberOrString::Number(n) => n.to_string(),
|
||||
NumberOrString::String(s) => s
|
||||
}
|
||||
}
|
||||
|
||||
fn to_i32(self) -> Option<i32> {
|
||||
fn into_i32(self) -> Option<i32> {
|
||||
match self {
|
||||
NumberOrString::Number(n) => Some(n),
|
||||
NumberOrString::String(s) => s.parse().ok()
|
||||
|
@@ -1,39 +1,73 @@
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rocket::request::Request;
|
||||
use rocket::response::{self, NamedFile, Responder};
|
||||
use rocket::response::content::Content;
|
||||
use rocket::http::{ContentType, Status};
|
||||
use rocket::Route;
|
||||
use rocket::response::NamedFile;
|
||||
use rocket_contrib::Json;
|
||||
use rocket_contrib::{Json, Value};
|
||||
|
||||
use CONFIG;
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![index, files, attachments, alive]
|
||||
if CONFIG.web_vault_enabled {
|
||||
routes![web_index, app_id, web_files, attachments, alive]
|
||||
} else {
|
||||
routes![attachments, alive]
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Might want to use in memory cache: https://github.com/hgzimmerman/rocket-file-cache
|
||||
#[get("/")]
|
||||
fn index() -> io::Result<NamedFile> {
|
||||
NamedFile::open(
|
||||
Path::new(&CONFIG.web_vault_folder)
|
||||
.join("index.html"))
|
||||
fn web_index() -> WebHeaders<io::Result<NamedFile>> {
|
||||
web_files("index.html".into())
|
||||
}
|
||||
|
||||
#[get("/app-id.json")]
|
||||
fn app_id() -> WebHeaders<Content<Json<Value>>> {
|
||||
let content_type = ContentType::new("application", "fido.trusted-apps+json");
|
||||
|
||||
WebHeaders(Content(content_type, Json(json!({
|
||||
"trustedFacets": [
|
||||
{
|
||||
"version": { "major": 1, "minor": 0 },
|
||||
"ids": [
|
||||
&CONFIG.domain,
|
||||
"ios:bundle-id:com.8bit.bitwarden",
|
||||
"android:apk-key-hash:dUGFzUzf3lmHSLBDBIv+WaFyZMI" ]
|
||||
}]
|
||||
}))))
|
||||
}
|
||||
|
||||
#[get("/<p..>", rank = 1)] // Only match this if the other routes don't match
|
||||
fn files(p: PathBuf) -> io::Result<NamedFile> {
|
||||
NamedFile::open(
|
||||
Path::new(&CONFIG.web_vault_folder)
|
||||
.join(p))
|
||||
fn web_files(p: PathBuf) -> WebHeaders<io::Result<NamedFile>> {
|
||||
WebHeaders(NamedFile::open(Path::new(&CONFIG.web_vault_folder).join(p)))
|
||||
}
|
||||
|
||||
struct WebHeaders<R>(R);
|
||||
|
||||
impl<'r, R: Responder<'r>> Responder<'r> for WebHeaders<R> {
|
||||
fn respond_to(self, req: &Request) -> response::Result<'r> {
|
||||
match self.0.respond_to(req) {
|
||||
Ok(mut res) => {
|
||||
res.set_raw_header("Referrer-Policy", "same-origin");
|
||||
res.set_raw_header("X-Frame-Options", "SAMEORIGIN");
|
||||
res.set_raw_header("X-Content-Type-Options", "nosniff");
|
||||
res.set_raw_header("X-XSS-Protection", "1; mode=block");
|
||||
|
||||
Ok(res)
|
||||
},
|
||||
Err(_) => {
|
||||
Err(Status::NotFound)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/attachments/<uuid>/<file..>")]
|
||||
fn attachments(uuid: String, file: PathBuf) -> io::Result<NamedFile> {
|
||||
NamedFile::open(
|
||||
Path::new(&CONFIG.attachments_folder)
|
||||
.join(uuid)
|
||||
.join(file)
|
||||
)
|
||||
NamedFile::open(Path::new(&CONFIG.attachments_folder).join(uuid).join(file))
|
||||
}
|
||||
|
||||
|
||||
|
47
src/auth.rs
47
src/auth.rs
@@ -11,10 +11,11 @@ use serde::ser::Serialize;
|
||||
use CONFIG;
|
||||
|
||||
const JWT_ALGORITHM: jwt::Algorithm = jwt::Algorithm::RS256;
|
||||
pub const JWT_ISSUER: &'static str = "localhost:8000/identity";
|
||||
|
||||
lazy_static! {
|
||||
pub static ref DEFAULT_VALIDITY: Duration = Duration::hours(2);
|
||||
pub static ref JWT_ISSUER: String = CONFIG.domain.clone();
|
||||
|
||||
static ref JWT_HEADER: jwt::Header = jwt::Header::new(JWT_ALGORITHM);
|
||||
|
||||
static ref PRIVATE_RSA_KEY: Vec<u8> = match read_file(&CONFIG.private_rsa_key) {
|
||||
@@ -30,9 +31,9 @@ lazy_static! {
|
||||
|
||||
pub fn encode_jwt<T: Serialize>(claims: &T) -> String {
|
||||
match jwt::encode(&JWT_HEADER, claims, &PRIVATE_RSA_KEY) {
|
||||
Ok(token) => return token,
|
||||
Ok(token) => token,
|
||||
Err(e) => panic!("Error encoding jwt {}", e)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_jwt(token: &str) -> Result<JWTClaims, String> {
|
||||
@@ -42,7 +43,7 @@ pub fn decode_jwt(token: &str) -> Result<JWTClaims, String> {
|
||||
validate_iat: true,
|
||||
validate_nbf: true,
|
||||
aud: None,
|
||||
iss: Some(JWT_ISSUER.into()),
|
||||
iss: Some(JWT_ISSUER.clone()),
|
||||
sub: None,
|
||||
algorithms: vec![JWT_ALGORITHM],
|
||||
};
|
||||
@@ -94,7 +95,7 @@ use rocket::Outcome;
|
||||
use rocket::request::{self, Request, FromRequest};
|
||||
|
||||
use db::DbConn;
|
||||
use db::models::{User, UserOrganization, UserOrgType, Device};
|
||||
use db::models::{User, UserOrganization, UserOrgType, UserOrgStatus, Device};
|
||||
|
||||
pub struct Headers {
|
||||
pub host: String,
|
||||
@@ -109,9 +110,31 @@ impl<'a, 'r> FromRequest<'a, 'r> for Headers {
|
||||
let headers = request.headers();
|
||||
|
||||
// Get host
|
||||
let host = match headers.get_one("Host") {
|
||||
Some(host) => format!("http://{}", host), // TODO: Check if HTTPS
|
||||
_ => String::new()
|
||||
let host = if CONFIG.domain_set {
|
||||
CONFIG.domain.clone()
|
||||
} else if let Some(referer) = headers.get_one("Referer") {
|
||||
referer.to_string()
|
||||
} else {
|
||||
// Try to guess from the headers
|
||||
use std::env;
|
||||
|
||||
let protocol = if let Some(proto) = headers.get_one("X-Forwarded-Proto") {
|
||||
proto
|
||||
} else if env::var("ROCKET_TLS").is_ok() {
|
||||
"https"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
|
||||
let host = if let Some(host) = headers.get_one("X-Forwarded-Host") {
|
||||
host
|
||||
} else if let Some(host) = headers.get_one("Host") {
|
||||
host
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
format!("{}://{}", protocol, host)
|
||||
};
|
||||
|
||||
// Get access_token
|
||||
@@ -182,7 +205,13 @@ impl<'a, 'r> FromRequest<'a, 'r> for OrgHeaders {
|
||||
};
|
||||
|
||||
let org_user = match UserOrganization::find_by_user_and_org(&headers.user.uuid, &org_id, &conn) {
|
||||
Some(user) => user,
|
||||
Some(user) => {
|
||||
if user.status == UserOrgStatus::Confirmed as i32 {
|
||||
user
|
||||
} else {
|
||||
err_handler!("The current user isn't confirmed member of the organization")
|
||||
}
|
||||
}
|
||||
None => err_handler!("The current user isn't member of the organization")
|
||||
};
|
||||
|
||||
|
@@ -64,14 +64,33 @@ impl Attachment {
|
||||
|
||||
pub fn delete(self, conn: &DbConn) -> QueryResult<()> {
|
||||
use util;
|
||||
use std::{thread, time};
|
||||
|
||||
let mut retries = 10;
|
||||
|
||||
loop {
|
||||
match diesel::delete(
|
||||
attachments::table.filter(
|
||||
attachments::id.eq(&self.id)
|
||||
)
|
||||
).execute(&**conn) {
|
||||
Ok(_) => break,
|
||||
Err(err) => {
|
||||
if retries < 1 {
|
||||
println!("ERROR: Failed with 10 retries");
|
||||
return Err(err)
|
||||
} else {
|
||||
retries = retries - 1;
|
||||
println!("Had to retry! Retries left: {}", retries);
|
||||
thread::sleep(time::Duration::from_millis(500));
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
util::delete_file(&self.get_file_path());
|
||||
|
||||
diesel::delete(
|
||||
attachments::table.filter(
|
||||
attachments::id.eq(self.id)
|
||||
)
|
||||
).execute(&**conn).and(Ok(()))
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_all_by_cipher(cipher_uuid: &str, conn: &DbConn) -> QueryResult<()> {
|
||||
|
@@ -3,7 +3,7 @@ use serde_json::Value as JsonValue;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{User, Organization, Attachment, FolderCipher, CollectionCipher, UserOrgType};
|
||||
use super::{User, Organization, Attachment, FolderCipher, CollectionCipher, UserOrgType, UserOrgStatus};
|
||||
|
||||
#[derive(Debug, Identifiable, Queryable, Insertable, Associations)]
|
||||
#[table_name = "ciphers"]
|
||||
@@ -84,7 +84,7 @@ impl Cipher {
|
||||
// To remove backwards compatibility, just remove this entire section
|
||||
// and remove the compat code from ciphers::update_cipher_from_data
|
||||
if self.type_ == 1 && data_json["Uris"].is_array() {
|
||||
let uri = data_json["Uris"][0]["uri"].clone();
|
||||
let uri = data_json["Uris"][0]["Uri"].clone();
|
||||
data_json["Uri"] = uri;
|
||||
}
|
||||
// TODO: ******* Backwards compat end **********
|
||||
@@ -97,7 +97,7 @@ impl Cipher {
|
||||
"Favorite": self.favorite,
|
||||
"OrganizationId": self.organization_uuid,
|
||||
"Attachments": attachments_json,
|
||||
"OrganizationUseTotp": false,
|
||||
"OrganizationUseTotp": true,
|
||||
"CollectionIds": self.get_collections(user_uuid, &conn),
|
||||
|
||||
"Name": self.name,
|
||||
@@ -266,7 +266,9 @@ impl Cipher {
|
||||
ciphers::table
|
||||
.left_join(users_organizations::table.on(
|
||||
ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()).and(
|
||||
users_organizations::user_uuid.eq(user_uuid)
|
||||
users_organizations::user_uuid.eq(user_uuid).and(
|
||||
users_organizations::status.eq(UserOrgStatus::Confirmed as i32)
|
||||
)
|
||||
)
|
||||
))
|
||||
.left_join(ciphers_collections::table)
|
||||
|
@@ -2,7 +2,7 @@ use serde_json::Value as JsonValue;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{Organization, UserOrganization, UserOrgType};
|
||||
use super::{Organization, UserOrganization, UserOrgType, UserOrgStatus};
|
||||
|
||||
#[derive(Debug, Identifiable, Queryable, Insertable, Associations)]
|
||||
#[table_name = "collections"]
|
||||
@@ -78,13 +78,18 @@ impl Collection {
|
||||
pub fn find_by_user_uuid(user_uuid: &str, conn: &DbConn) -> Vec<Self> {
|
||||
let mut all_access_collections = users_organizations::table
|
||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||
.filter(users_organizations::status.eq(UserOrgStatus::Confirmed as i32))
|
||||
.filter(users_organizations::access_all.eq(true))
|
||||
.inner_join(collections::table.on(collections::org_uuid.eq(users_organizations::org_uuid)))
|
||||
.select(collections::all_columns)
|
||||
.load::<Self>(&**conn).expect("Error loading collections");
|
||||
|
||||
let mut assigned_collections = users_collections::table.inner_join(collections::table)
|
||||
.left_join(users_organizations::table.on(
|
||||
users_collections::user_uuid.eq(users_organizations::user_uuid)
|
||||
))
|
||||
.filter(users_collections::user_uuid.eq(user_uuid))
|
||||
.filter(users_organizations::status.eq(UserOrgStatus::Confirmed as i32))
|
||||
.select(collections::all_columns)
|
||||
.load::<Self>(&**conn).expect("Error loading collections");
|
||||
|
||||
|
@@ -43,11 +43,14 @@ impl Device {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh_twofactor_remember(&mut self) {
|
||||
pub fn refresh_twofactor_remember(&mut self) -> String {
|
||||
use data_encoding::BASE64;
|
||||
use crypto;
|
||||
|
||||
self.twofactor_remember = Some(BASE64.encode(&crypto::get_random(vec![0u8; 180])));
|
||||
let twofactor_remember = BASE64.encode(&crypto::get_random(vec![0u8; 180]));
|
||||
self.twofactor_remember = Some(twofactor_remember.clone());
|
||||
|
||||
twofactor_remember
|
||||
}
|
||||
|
||||
pub fn delete_twofactor_remember(&mut self) {
|
||||
|
@@ -6,6 +6,7 @@ mod user;
|
||||
|
||||
mod collection;
|
||||
mod organization;
|
||||
mod two_factor;
|
||||
|
||||
pub use self::attachment::Attachment;
|
||||
pub use self::cipher::Cipher;
|
||||
@@ -15,3 +16,4 @@ pub use self::user::User;
|
||||
pub use self::organization::Organization;
|
||||
pub use self::organization::{UserOrganization, UserOrgStatus, UserOrgType};
|
||||
pub use self::collection::{Collection, CollectionUser, CollectionCipher};
|
||||
pub use self::two_factor::{TwoFactor, TwoFactorType};
|
@@ -66,11 +66,11 @@ impl Organization {
|
||||
"Seats": 10,
|
||||
"MaxCollections": 10,
|
||||
|
||||
"Use2fa": false,
|
||||
"Use2fa": true,
|
||||
"UseDirectory": false,
|
||||
"UseEvents": false,
|
||||
"UseGroups": false,
|
||||
"UseTotp": false,
|
||||
"UseTotp": true,
|
||||
|
||||
"BusinessName": null,
|
||||
"BusinessAddress1": null,
|
||||
@@ -80,8 +80,8 @@ impl Organization {
|
||||
"BusinessTaxNumber": null,
|
||||
|
||||
"BillingEmail": self.billing_email,
|
||||
"Plan": "Free",
|
||||
"PlanType": 0, // Free plan
|
||||
"Plan": "TeamsAnnually",
|
||||
"PlanType": 5, // TeamsAnnually plan
|
||||
|
||||
"Object": "organization",
|
||||
})
|
||||
@@ -153,11 +153,11 @@ impl UserOrganization {
|
||||
"Seats": 10,
|
||||
"MaxCollections": 10,
|
||||
|
||||
"Use2fa": false,
|
||||
"Use2fa": true,
|
||||
"UseDirectory": false,
|
||||
"UseEvents": false,
|
||||
"UseGroups": false,
|
||||
"UseTotp": false,
|
||||
"UseTotp": true,
|
||||
|
||||
"MaxStorageGb": 10, // The value doesn't matter, we don't check server-side
|
||||
|
||||
@@ -268,6 +268,7 @@ impl UserOrganization {
|
||||
pub fn find_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Self> {
|
||||
users_organizations::table
|
||||
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||
.filter(users_organizations::status.eq(UserOrgStatus::Confirmed as i32))
|
||||
.load::<Self>(&**conn).unwrap_or(vec![])
|
||||
}
|
||||
|
||||
|
112
src/db/models/two_factor.rs
Normal file
112
src/db/models/two_factor.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::User;
|
||||
|
||||
#[derive(Debug, Identifiable, Queryable, Insertable, Associations)]
|
||||
#[table_name = "twofactor"]
|
||||
#[belongs_to(User, foreign_key = "user_uuid")]
|
||||
#[primary_key(uuid)]
|
||||
pub struct TwoFactor {
|
||||
pub uuid: String,
|
||||
pub user_uuid: String,
|
||||
pub type_: i32,
|
||||
pub enabled: bool,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(FromPrimitive, ToPrimitive)]
|
||||
pub enum TwoFactorType {
|
||||
Authenticator = 0,
|
||||
Email = 1,
|
||||
Duo = 2,
|
||||
YubiKey = 3,
|
||||
U2f = 4,
|
||||
Remember = 5,
|
||||
OrganizationDuo = 6,
|
||||
|
||||
// These are implementation details
|
||||
U2fRegisterChallenge = 1000,
|
||||
U2fLoginChallenge = 1001,
|
||||
}
|
||||
|
||||
/// Local methods
|
||||
impl TwoFactor {
|
||||
pub fn new(user_uuid: String, type_: TwoFactorType, data: String) -> Self {
|
||||
Self {
|
||||
uuid: Uuid::new_v4().to_string(),
|
||||
user_uuid,
|
||||
type_: type_ as i32,
|
||||
enabled: true,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_totp_code(&self, totp_code: u64) -> bool {
|
||||
let totp_secret = self.data.as_bytes();
|
||||
|
||||
use data_encoding::BASE32;
|
||||
use oath::{totp_raw_now, HashType};
|
||||
|
||||
let decoded_secret = match BASE32.decode(totp_secret) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false
|
||||
};
|
||||
|
||||
let generated = totp_raw_now(&decoded_secret, 6, 0, 30, &HashType::SHA1);
|
||||
generated == totp_code
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> JsonValue {
|
||||
json!({
|
||||
"Enabled": self.enabled,
|
||||
"Key": "", // This key and value vary
|
||||
"Object": "twoFactorAuthenticator" // This value varies
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_json_list(&self) -> JsonValue {
|
||||
json!({
|
||||
"Enabled": self.enabled,
|
||||
"Type": self.type_,
|
||||
"Object": "twoFactorProvider"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
use db::DbConn;
|
||||
use db::schema::twofactor;
|
||||
|
||||
/// Database methods
|
||||
impl TwoFactor {
|
||||
pub fn save(&self, conn: &DbConn) -> QueryResult<usize> {
|
||||
diesel::replace_into(twofactor::table)
|
||||
.values(self)
|
||||
.execute(&**conn)
|
||||
}
|
||||
|
||||
pub fn delete(self, conn: &DbConn) -> QueryResult<usize> {
|
||||
diesel::delete(
|
||||
twofactor::table.filter(
|
||||
twofactor::uuid.eq(self.uuid)
|
||||
)
|
||||
).execute(&**conn)
|
||||
}
|
||||
|
||||
pub fn find_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Self> {
|
||||
twofactor::table
|
||||
.filter(twofactor::user_uuid.eq(user_uuid))
|
||||
.load::<Self>(&**conn).expect("Error loading twofactor")
|
||||
}
|
||||
|
||||
pub fn find_by_user_and_type(user_uuid: &str, type_: i32, conn: &DbConn) -> Option<Self> {
|
||||
twofactor::table
|
||||
.filter(twofactor::user_uuid.eq(user_uuid))
|
||||
.filter(twofactor::type_.eq(type_))
|
||||
.first::<Self>(&**conn).ok()
|
||||
}
|
||||
}
|
@@ -27,7 +27,8 @@ pub struct User {
|
||||
pub private_key: Option<String>,
|
||||
pub public_key: Option<String>,
|
||||
|
||||
pub totp_secret: Option<String>,
|
||||
#[column_name = "totp_secret"]
|
||||
_totp_secret: Option<String>,
|
||||
pub totp_recover: Option<String>,
|
||||
|
||||
pub security_stamp: String,
|
||||
@@ -64,7 +65,7 @@ impl User {
|
||||
private_key: None,
|
||||
public_key: None,
|
||||
|
||||
totp_secret: None,
|
||||
_totp_secret: None,
|
||||
totp_recover: None,
|
||||
|
||||
equivalent_domains: "[]".to_string(),
|
||||
@@ -97,28 +98,6 @@ impl User {
|
||||
pub fn reset_security_stamp(&mut self) {
|
||||
self.security_stamp = Uuid::new_v4().to_string();
|
||||
}
|
||||
|
||||
pub fn requires_twofactor(&self) -> bool {
|
||||
self.totp_secret.is_some()
|
||||
}
|
||||
|
||||
pub fn check_totp_code(&self, totp_code: u64) -> bool {
|
||||
if let Some(ref totp_secret) = self.totp_secret {
|
||||
// Validate totp
|
||||
use data_encoding::BASE32;
|
||||
use oath::{totp_raw_now, HashType};
|
||||
|
||||
let decoded_secret = match BASE32.decode(totp_secret.as_bytes()) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return false
|
||||
};
|
||||
|
||||
let generated = totp_raw_now(&decoded_secret, 6, 0, 30, &HashType::SHA1);
|
||||
generated == totp_code
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use diesel;
|
||||
@@ -130,10 +109,13 @@ use db::schema::users;
|
||||
impl User {
|
||||
pub fn to_json(&self, conn: &DbConn) -> JsonValue {
|
||||
use super::UserOrganization;
|
||||
use super::TwoFactor;
|
||||
|
||||
let orgs = UserOrganization::find_by_user(&self.uuid, conn);
|
||||
let orgs_json: Vec<JsonValue> = orgs.iter().map(|c| c.to_json(&conn)).collect();
|
||||
|
||||
let twofactor_enabled = TwoFactor::find_by_user(&self.uuid, conn).len() > 0;
|
||||
|
||||
json!({
|
||||
"Id": self.uuid,
|
||||
"Name": self.name,
|
||||
@@ -142,7 +124,7 @@ impl User {
|
||||
"Premium": true,
|
||||
"MasterPasswordHint": self.password_hint,
|
||||
"Culture": "en-US",
|
||||
"TwoFactorEnabled": self.totp_secret.is_some(),
|
||||
"TwoFactorEnabled": twofactor_enabled,
|
||||
"Key": self.key,
|
||||
"PrivateKey": self.private_key,
|
||||
"SecurityStamp": self.security_stamp,
|
||||
|
@@ -79,6 +79,17 @@ table! {
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
twofactor (uuid) {
|
||||
uuid -> Text,
|
||||
user_uuid -> Text,
|
||||
#[sql_name = "type"]
|
||||
type_ -> Integer,
|
||||
enabled -> Bool,
|
||||
data -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
users (uuid) {
|
||||
uuid -> Text,
|
||||
@@ -132,6 +143,7 @@ joinable!(devices -> users (user_uuid));
|
||||
joinable!(folders -> users (user_uuid));
|
||||
joinable!(folders_ciphers -> ciphers (cipher_uuid));
|
||||
joinable!(folders_ciphers -> folders (folder_uuid));
|
||||
joinable!(twofactor -> users (user_uuid));
|
||||
joinable!(users_collections -> collections (collection_uuid));
|
||||
joinable!(users_collections -> users (user_uuid));
|
||||
joinable!(users_organizations -> organizations (org_uuid));
|
||||
@@ -146,6 +158,7 @@ allow_tables_to_appear_in_same_query!(
|
||||
folders,
|
||||
folders_ciphers,
|
||||
organizations,
|
||||
twofactor,
|
||||
users,
|
||||
users_collections,
|
||||
users_organizations,
|
||||
|
57
src/main.rs
57
src/main.rs
@@ -19,11 +19,15 @@ extern crate chrono;
|
||||
extern crate oath;
|
||||
extern crate data_encoding;
|
||||
extern crate jsonwebtoken as jwt;
|
||||
extern crate u2f;
|
||||
extern crate dotenv;
|
||||
#[macro_use]
|
||||
extern crate lazy_static;
|
||||
#[macro_use]
|
||||
extern crate num_derive;
|
||||
extern crate num_traits;
|
||||
|
||||
use std::{io, env, path::Path, process::{exit, Command}};
|
||||
use std::{env, path::Path, process::{exit, Command}};
|
||||
use rocket::Rocket;
|
||||
|
||||
#[macro_use]
|
||||
@@ -46,16 +50,25 @@ fn init_rocket() -> Rocket {
|
||||
// Embed the migrations from the migrations folder into the application
|
||||
// This way, the program automatically migrates the database to the latest version
|
||||
// https://docs.rs/diesel_migrations/*/diesel_migrations/macro.embed_migrations.html
|
||||
embed_migrations!();
|
||||
#[allow(unused_imports)]
|
||||
mod migrations {
|
||||
embed_migrations!();
|
||||
|
||||
pub fn run_migrations() {
|
||||
// Make sure the database is up to date (create if it doesn't exist, or run the migrations)
|
||||
let connection = ::db::get_connection().expect("Can't conect to DB");
|
||||
|
||||
use std::io::stdout;
|
||||
embedded_migrations::run_with_output(&connection, &mut stdout()).expect("Can't run migrations");
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
check_db();
|
||||
check_rsa_keys();
|
||||
check_web_vault();
|
||||
check_web_vault();
|
||||
migrations::run_migrations();
|
||||
|
||||
// Make sure the database is up to date (create if it doesn't exist, or run the migrations)
|
||||
let connection = db::get_connection().expect("Can't conect to DB");
|
||||
embedded_migrations::run_with_output(&connection, &mut io::stdout()).expect("Can't run migrations");
|
||||
|
||||
init_rocket().launch();
|
||||
}
|
||||
@@ -70,6 +83,11 @@ fn check_db() {
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Turn on WAL in SQLite
|
||||
use diesel::RunQueryDsl;
|
||||
let connection = db::get_connection().expect("Can't conect to DB");
|
||||
diesel::sql_query("PRAGMA journal_mode=wal").execute(&connection).expect("Failed to turn on WAL");
|
||||
}
|
||||
|
||||
fn check_rsa_keys() {
|
||||
@@ -118,6 +136,10 @@ fn check_rsa_keys() {
|
||||
}
|
||||
|
||||
fn check_web_vault() {
|
||||
if !CONFIG.web_vault_enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
let index_path = Path::new(&CONFIG.web_vault_folder).join("index.html");
|
||||
|
||||
if !index_path.exists() {
|
||||
@@ -142,9 +164,14 @@ pub struct Config {
|
||||
public_rsa_key: String,
|
||||
|
||||
web_vault_folder: String,
|
||||
web_vault_enabled: bool,
|
||||
|
||||
local_icon_extractor: bool,
|
||||
signups_allowed: bool,
|
||||
password_iterations: i32,
|
||||
show_password_hint: bool,
|
||||
domain: String,
|
||||
domain_set: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -152,21 +179,29 @@ impl Config {
|
||||
dotenv::dotenv().ok();
|
||||
|
||||
let df = env::var("DATA_FOLDER").unwrap_or("data".into());
|
||||
let key = env::var("RSA_KEY_NAME").unwrap_or("rsa_key".into());
|
||||
let key = env::var("RSA_KEY_FILENAME").unwrap_or(format!("{}/{}", &df, "rsa_key"));
|
||||
|
||||
let domain = env::var("DOMAIN");
|
||||
|
||||
Config {
|
||||
database_url: env::var("DATABASE_URL").unwrap_or(format!("{}/{}", &df, "db.sqlite3")),
|
||||
icon_cache_folder: env::var("ICON_CACHE_FOLDER").unwrap_or(format!("{}/{}", &df, "icon_cache")),
|
||||
attachments_folder: env::var("ATTACHMENTS_FOLDER").unwrap_or(format!("{}/{}", &df, "attachments")),
|
||||
|
||||
private_rsa_key: format!("{}/{}.der", &df, &key),
|
||||
private_rsa_key_pem: format!("{}/{}.pem", &df, &key),
|
||||
public_rsa_key: format!("{}/{}.pub.der", &df, &key),
|
||||
private_rsa_key: format!("{}.der", &key),
|
||||
private_rsa_key_pem: format!("{}.pem", &key),
|
||||
public_rsa_key: format!("{}.pub.der", &key),
|
||||
|
||||
web_vault_folder: env::var("WEB_VAULT_FOLDER").unwrap_or("web-vault/".into()),
|
||||
web_vault_enabled: util::parse_option_string(env::var("WEB_VAULT_ENABLED").ok()).unwrap_or(true),
|
||||
|
||||
signups_allowed: util::parse_option_string(env::var("SIGNUPS_ALLOWED").ok()).unwrap_or(false),
|
||||
local_icon_extractor: util::parse_option_string(env::var("LOCAL_ICON_EXTRACTOR").ok()).unwrap_or(false),
|
||||
signups_allowed: util::parse_option_string(env::var("SIGNUPS_ALLOWED").ok()).unwrap_or(true),
|
||||
password_iterations: util::parse_option_string(env::var("PASSWORD_ITERATIONS").ok()).unwrap_or(100_000),
|
||||
show_password_hint: util::parse_option_string(env::var("SHOW_PASSWORD_HINT").ok()).unwrap_or(true),
|
||||
|
||||
domain_set: domain.is_ok(),
|
||||
domain: domain.unwrap_or("http://localhost".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
99
src/util.rs
99
src/util.rs
@@ -3,7 +3,8 @@
|
||||
///
|
||||
#[macro_export]
|
||||
macro_rules! err {
|
||||
($err:expr, $err_desc:expr, $msg:expr) => {
|
||||
($err:expr, $err_desc:expr, $msg:expr) => {{
|
||||
println!("ERROR: {}", $msg);
|
||||
err_json!(json!({
|
||||
"error": $err,
|
||||
"error_description": $err_desc,
|
||||
@@ -13,14 +14,13 @@ macro_rules! err {
|
||||
"Object": "error"
|
||||
}
|
||||
}))
|
||||
};
|
||||
}};
|
||||
($msg:expr) => { err!("default_error", "default_error_description", $msg) }
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! err_json {
|
||||
($expr:expr) => {{
|
||||
println!("ERROR: {}", $expr);
|
||||
return Err($crate::rocket::response::status::BadRequest(Some($crate::rocket_contrib::Json($expr))));
|
||||
}}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ pub fn delete_file(path: &str) -> bool {
|
||||
}
|
||||
|
||||
|
||||
const UNITS: [&'static str; 6] = ["bytes", "KB", "MB", "GB", "TB", "PB"];
|
||||
const UNITS: [&str; 6] = ["bytes", "KB", "MB", "GB", "TB", "PB"];
|
||||
|
||||
pub fn get_display_size(size: i32) -> String {
|
||||
let mut size = size as f64;
|
||||
@@ -119,7 +119,7 @@ pub fn parse_option_string<S, T>(string: Option<S>) -> Option<T> where S: AsRef<
|
||||
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
const DATETIME_FORMAT: &'static str = "%Y-%m-%dT%H:%M:%S%.6fZ";
|
||||
const DATETIME_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.6fZ";
|
||||
|
||||
pub fn format_date(date: &NaiveDateTime) -> String {
|
||||
date.format(DATETIME_FORMAT).to_string()
|
||||
@@ -129,20 +129,12 @@ pub fn format_date(date: &NaiveDateTime) -> String {
|
||||
/// Deserialization methods
|
||||
///
|
||||
|
||||
use std::collections::BTreeMap as Map;
|
||||
use std::fmt;
|
||||
|
||||
use serde::de::{self, Deserialize, DeserializeOwned, Deserializer};
|
||||
use serde_json::Value;
|
||||
use serde::de::{self, DeserializeOwned, Deserializer, MapAccess, SeqAccess, Visitor};
|
||||
use serde_json::{self, Value};
|
||||
|
||||
/// https://github.com/serde-rs/serde/issues/586
|
||||
pub fn upcase_deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
|
||||
where T: DeserializeOwned,
|
||||
D: Deserializer<'de>
|
||||
{
|
||||
let map = Map::<String, Value>::deserialize(deserializer)?;
|
||||
let lower = map.into_iter().map(|(k, v)| (upcase_first(&k), v)).collect();
|
||||
T::deserialize(Value::Object(lower)).map_err(de::Error::custom)
|
||||
}
|
||||
pub type JsonMap = serde_json::Map<String, Value>;
|
||||
|
||||
#[derive(PartialEq, Serialize, Deserialize)]
|
||||
pub struct UpCase<T: DeserializeOwned> {
|
||||
@@ -150,3 +142,76 @@ pub struct UpCase<T: DeserializeOwned> {
|
||||
#[serde(flatten)]
|
||||
pub data: T,
|
||||
}
|
||||
|
||||
/// https://github.com/serde-rs/serde/issues/586
|
||||
pub fn upcase_deserialize<'de, T, D>(deserializer: D) -> Result<T, D::Error>
|
||||
where T: DeserializeOwned,
|
||||
D: Deserializer<'de>
|
||||
{
|
||||
let d = deserializer.deserialize_any(UpCaseVisitor)?;
|
||||
T::deserialize(d).map_err(de::Error::custom)
|
||||
}
|
||||
|
||||
struct UpCaseVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for UpCaseVisitor {
|
||||
type Value = Value;
|
||||
|
||||
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
formatter.write_str("an object or an array")
|
||||
}
|
||||
|
||||
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
|
||||
where A: MapAccess<'de>
|
||||
{
|
||||
let mut result_map = JsonMap::new();
|
||||
|
||||
while let Some((key, value)) = map.next_entry()? {
|
||||
result_map.insert(upcase_first(key), upcase_value(&value));
|
||||
}
|
||||
|
||||
Ok(Value::Object(result_map))
|
||||
}
|
||||
|
||||
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
|
||||
where A: SeqAccess<'de> {
|
||||
let mut result_seq = Vec::<Value>::new();
|
||||
|
||||
while let Some(value) = seq.next_element()? {
|
||||
result_seq.push(upcase_value(&value));
|
||||
}
|
||||
|
||||
Ok(Value::Array(result_seq))
|
||||
}
|
||||
}
|
||||
|
||||
fn upcase_value(value: &Value) -> Value {
|
||||
if let Some(map) = value.as_object() {
|
||||
let mut new_value = json!({});
|
||||
|
||||
for (key, val) in map {
|
||||
let processed_key = _process_key(key);
|
||||
new_value[processed_key] = upcase_value(val);
|
||||
}
|
||||
new_value
|
||||
|
||||
} else if let Some(array) = value.as_array() {
|
||||
// Initialize array with null values
|
||||
let mut new_value = json!(vec![Value::Null; array.len()]);
|
||||
|
||||
for (index, val) in array.iter().enumerate() {
|
||||
new_value[index] = upcase_value(val);
|
||||
}
|
||||
new_value
|
||||
|
||||
} else {
|
||||
value.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn _process_key(key: &str) -> String {
|
||||
match key.to_lowercase().as_ref() {
|
||||
"ssn" => "SSN".into(),
|
||||
_ => self::upcase_first(key)
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user