mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2025-09-10 18:55:57 +03:00
Compare commits
65 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
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 |
37
.env
37
.env
@@ -1,13 +1,40 @@
|
|||||||
|
## 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
|
# DATABASE_URL=data/db.sqlite3
|
||||||
# PRIVATE_RSA_KEY=data/private_rsa_key.der
|
# RSA_KEY_FILENAME=data/rsa_key
|
||||||
# PUBLIC_RSA_KEY=data/public_rsa_key.der
|
|
||||||
# ICON_CACHE_FOLDER=data/icon_cache
|
# ICON_CACHE_FOLDER=data/icon_cache
|
||||||
# ATTACHMENTS_FOLDER=data/attachments
|
# ATTACHMENTS_FOLDER=data/attachments
|
||||||
|
|
||||||
# true for yes, anything else for no
|
## Web vault settings
|
||||||
SIGNUPS_ALLOWED=true
|
# 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
|
||||||
|
|
||||||
|
## 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_ADDRESS=0.0.0.0 # Enable this to test mobile app
|
||||||
# ROCKET_PORT=8000
|
# ROCKET_PORT=8000
|
||||||
# ROCKET_TLS={certs="/path/to/certs.pem",key="/path/to/key.pem"}
|
# ROCKET_TLS={certs="/path/to/certs.pem",key="/path/to/key.pem"}
|
||||||
|
69
BUILD.md
Normal file
69
BUILD.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Build instructions
|
||||||
|
|
||||||
|
## 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/))
|
||||||
|
|
||||||
|
|
||||||
|
## Run/Compile
|
||||||
|
```sh
|
||||||
|
# Compile and run
|
||||||
|
cargo run
|
||||||
|
# or just compile (binary located in target/release/bitwarden_rs)
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
When run, the server is accessible in [http://localhost:80](http://localhost:80).
|
||||||
|
|
||||||
|
### 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
|
||||||
|
{
|
||||||
|
"appSettings": {
|
||||||
|
"apiUri": "/api",
|
||||||
|
"identityUri": "/identity",
|
||||||
|
"iconsUri": "/icons",
|
||||||
|
"stripeKey": "",
|
||||||
|
"braintreeKey": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, run the following from the `web-vault` directory:
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npx gulp dist:selfHosted
|
||||||
|
```
|
||||||
|
|
||||||
|
Finally copy the contents of the `web-vault/dist` folder into the `bitwarden_rs/web-vault` folder.
|
||||||
|
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure that the correct path to the database is in the `.env` file.
|
||||||
|
|
||||||
|
If you want to modify the schemas, create a new migration with:
|
||||||
|
```
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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]
|
[package]
|
||||||
name = "bitwarden_rs"
|
name = "bitwarden_rs"
|
||||||
version = "0.9.0"
|
version = "0.10.0"
|
||||||
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
|
authors = ["Daniel García <dani-garcia@users.noreply.github.com>"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Web framework for nightly with a focus on ease-of-use, expressibility, and speed.
|
# Web framework for nightly with a focus on ease-of-use, expressibility, and speed.
|
||||||
rocket = { version = "0.3.12", features = ["tls"] }
|
rocket = { version = "0.3.14", features = ["tls"] }
|
||||||
rocket_codegen = "0.3.12"
|
rocket_codegen = "0.3.14"
|
||||||
rocket_contrib = "0.3.12"
|
rocket_contrib = "0.3.14"
|
||||||
|
|
||||||
# HTTP client
|
# HTTP client
|
||||||
reqwest = "0.8.6"
|
reqwest = "0.8.6"
|
||||||
@@ -16,13 +16,13 @@ reqwest = "0.8.6"
|
|||||||
multipart = "0.14.2"
|
multipart = "0.14.2"
|
||||||
|
|
||||||
# A generic serialization/deserialization framework
|
# A generic serialization/deserialization framework
|
||||||
serde = "1.0.64"
|
serde = "1.0.70"
|
||||||
serde_derive = "1.0.64"
|
serde_derive = "1.0.70"
|
||||||
serde_json = "1.0.19"
|
serde_json = "1.0.22"
|
||||||
|
|
||||||
# A safe, extensible ORM and Query builder
|
# A safe, extensible ORM and Query builder
|
||||||
diesel = { version = "~1.2.2", features = ["sqlite", "chrono", "r2d2"] }
|
diesel = { version = "1.3.2", features = ["sqlite", "chrono", "r2d2"] }
|
||||||
diesel_migrations = { version = "~1.2.0", features = ["sqlite"] }
|
diesel_migrations = { version = "1.3.0", features = ["sqlite"] }
|
||||||
|
|
||||||
# Bundled SQLite
|
# Bundled SQLite
|
||||||
libsqlite3-sys = { version = "0.9.1", features = ["bundled"] }
|
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"] }
|
uuid = { version = "0.6.5", features = ["v4"] }
|
||||||
|
|
||||||
# Date and time library for Rust
|
# Date and time library for Rust
|
||||||
chrono = "0.4.2"
|
chrono = "0.4.4"
|
||||||
|
|
||||||
# TOTP library
|
# TOTP library
|
||||||
oath = "0.10.2"
|
oath = "0.10.2"
|
||||||
@@ -45,11 +45,22 @@ data-encoding = "2.1.1"
|
|||||||
# JWT library
|
# JWT library
|
||||||
jsonwebtoken = "= 4.0.1"
|
jsonwebtoken = "= 4.0.1"
|
||||||
|
|
||||||
|
# U2F library
|
||||||
|
u2f = "0.1.2"
|
||||||
|
|
||||||
# A `dotenv` implementation for Rust
|
# A `dotenv` implementation for Rust
|
||||||
dotenv = { version = "0.13.0", default-features = false }
|
dotenv = { version = "0.13.0", default-features = false }
|
||||||
|
|
||||||
# Lazy static macro
|
# Lazy static macro
|
||||||
lazy_static = "1.0.1"
|
lazy_static = "1.0.1"
|
||||||
|
|
||||||
|
# Numerical libraries
|
||||||
|
num-traits = "0.2.5"
|
||||||
|
num-derive = "0.2.2"
|
||||||
|
|
||||||
[patch.crates-io]
|
[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' }
|
13
Dockerfile
13
Dockerfile
@@ -4,7 +4,7 @@
|
|||||||
####################### VAULT BUILD IMAGE #######################
|
####################### VAULT BUILD IMAGE #######################
|
||||||
FROM node:9-alpine as vault
|
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"
|
ENV URL "https://github.com/bitwarden/web/archive/v${VAULT_VERSION}.tar.gz"
|
||||||
|
|
||||||
RUN apk add --update-cache --upgrade \
|
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 ##########################
|
########################## BUILD IMAGE ##########################
|
||||||
# We need to use the Rust build image, because
|
# We need to use the Rust build image, because
|
||||||
# we need the Rust compiler and Cargo tooling
|
# 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
|
# Using bundled SQLite, no need to install it
|
||||||
# RUN apt-get update && apt-get install -y\
|
# RUN apt-get update && apt-get install -y\
|
||||||
@@ -46,6 +46,7 @@ WORKDIR /app
|
|||||||
# Copies over *only* your manifests and vendored dependencies
|
# Copies over *only* your manifests and vendored dependencies
|
||||||
COPY ./Cargo.* ./
|
COPY ./Cargo.* ./
|
||||||
COPY ./libs ./libs
|
COPY ./libs ./libs
|
||||||
|
COPY ./rust-toolchain ./rust-toolchain
|
||||||
|
|
||||||
# Builds your dependencies and removes the
|
# Builds your dependencies and removes the
|
||||||
# dummy project, except the target folder
|
# dummy project, except the target folder
|
||||||
@@ -66,9 +67,12 @@ RUN cargo build --release
|
|||||||
# because we already have a binary built
|
# because we already have a binary built
|
||||||
FROM debian:stretch-slim
|
FROM debian:stretch-slim
|
||||||
|
|
||||||
|
ENV ROCKET_ENV "staging"
|
||||||
|
|
||||||
# Install needed libraries
|
# Install needed libraries
|
||||||
RUN apt-get update && apt-get install -y\
|
RUN apt-get update && apt-get install -y\
|
||||||
openssl\
|
openssl\
|
||||||
|
ca-certificates\
|
||||||
--no-install-recommends\
|
--no-install-recommends\
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
@@ -79,10 +83,9 @@ EXPOSE 80
|
|||||||
# Copies the files from the context (env file and web-vault)
|
# Copies the files from the context (env file and web-vault)
|
||||||
# and the binary from the "build" stage to the current stage
|
# and the binary from the "build" stage to the current stage
|
||||||
COPY .env .
|
COPY .env .
|
||||||
|
COPY Rocket.toml .
|
||||||
COPY --from=vault /web-vault ./web-vault
|
COPY --from=vault /web-vault ./web-vault
|
||||||
COPY --from=build app/target/release/bitwarden_rs .
|
COPY --from=build app/target/release/bitwarden_rs .
|
||||||
|
|
||||||
# Configures the startup!
|
# Configures the startup!
|
||||||
# Use production to disable Rocket logging
|
CMD ./bitwarden_rs
|
||||||
#CMD ROCKET_ENV=production ./bitwarden_rs
|
|
||||||
CMD ROCKET_ENV=staging ./bitwarden_rs
|
|
||||||
|
363
README.md
363
README.md
@@ -1,97 +1,304 @@
|
|||||||
## Easy setup (Docker)
|
This is Bitwarden server API implementation written in rust compatible with [upstream Bitwarden clients](https://bitwarden.com/#download)*, ideal for self-hosted deployment where running official resource-heavy service might not be ideal.
|
||||||
Install Docker to your system and then, from the project root, run:
|
|
||||||
|
Image is based on [Rust implementation of Bitwarden API](https://github.com/dani-garcia/bitwarden_rs).
|
||||||
|
|
||||||
|
_*Note, that this project is not associated with the [Bitwarden](https://bitwarden.com/) project nor 8bit Solutions LLC._
|
||||||
|
|
||||||
|
## Table of contents <!-- omit in toc -->
|
||||||
|
- [Features](#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)
|
||||||
|
- [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)
|
||||||
|
- [Get in touch](#get-in-touch)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
Basically full implementation of Bitwarden API is provided including:
|
||||||
|
|
||||||
|
* Basic single user functionality
|
||||||
|
* Organizations support
|
||||||
|
* Attachments
|
||||||
|
* 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
|
||||||
|
|
||||||
|
### Starting a container
|
||||||
|
|
||||||
|
The persistent data is stored under /data inside the container, so the only requirement for persistent deployment using Docker is to mount persistent volume at the path:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker run -d --name bitwarden -v /bw-data/:/data/ -p 80:80 mprasil/bitwarden:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
This will preserve any persistent data under `/bw-data/`, you can adapt the path to whatever suits you.
|
||||||
|
|
||||||
|
The service will be exposed on port 80.
|
||||||
|
|
||||||
|
### Updating the bitwarden image
|
||||||
|
|
||||||
|
Updating is straightforward, you just make sure to preserve the mounted volume. If you used the bind-mounted path as in the example above, you just need to `pull` the latest image, `stop` and `rm` the current container and then start a new one the same way as before:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Pull the latest version
|
||||||
|
docker pull mprasil/bitwarden:latest
|
||||||
|
|
||||||
|
# Stop and remove the old container
|
||||||
|
docker stop bitwarden
|
||||||
|
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:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Pull the latest version
|
||||||
|
docker pull mprasil/bitwarden:latest
|
||||||
|
|
||||||
|
# Create intermediate container to preserve data
|
||||||
|
docker run --volumes-from bitwarden --name bitwarden_data busybox true
|
||||||
|
|
||||||
|
# Stop and remove the old container
|
||||||
|
docker stop bitwarden
|
||||||
|
docker rm bitwarden
|
||||||
|
|
||||||
|
# Start new container with the data mounted
|
||||||
|
docker run -d --volumes-from bitwarden_data --name bitwarden -p 80:80 mprasil/bitwarden:latest
|
||||||
|
|
||||||
|
# Optionally remove the intermediate container
|
||||||
|
docker rm bitwarden_data
|
||||||
|
|
||||||
|
# Alternatively you can keep data container around for future updates in which case you can skip last step.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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/ \
|
||||||
|
-v /icon_cache/ \
|
||||||
|
-p 443:443 \
|
||||||
|
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:
|
||||||
|
|
||||||
|
By default all persistent data is saved under `/data`, you can override this path by setting the `DATA_FOLDER` env variable:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run -d --name bitwarden \
|
||||||
|
-e DATA_FOLDER=/persistent \
|
||||||
|
-v /bw-data/:/persistent/ \
|
||||||
|
-p 80:80 \
|
||||||
|
mprasil/bitwarden:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Notice, that you need to adapt your volume mount accordingly.
|
||||||
|
|
||||||
|
#### database name and location
|
||||||
|
|
||||||
|
Default is `$DATA_FOLDER/db.sqlite3`, you can change the path specifically for database using `DATABASE_URL` variable:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run -d --name bitwarden \
|
||||||
|
-e DATABASE_URL=/database/bitwarden.sqlite3 \
|
||||||
|
-v /bw-data/:/data/ \
|
||||||
|
-v /bw-database/:/database/ \
|
||||||
|
-p 80:80 \
|
||||||
|
mprasil/bitwarden:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Note, that you need to remember to mount the volume for both database and other persistent data if they are different.
|
||||||
|
|
||||||
|
#### attachments location
|
||||||
|
|
||||||
|
Default is `$DATA_FOLDER/attachments`, you can change the path using `ATTACHMENTS_FOLDER` variable:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run -d --name bitwarden \
|
||||||
|
-e ATTACHMENTS_FOLDER=/attachments \
|
||||||
|
-v /bw-data/:/data/ \
|
||||||
|
-v /bw-attachments/:/attachments/ \
|
||||||
|
-p 80:80 \
|
||||||
|
mprasil/bitwarden:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Note, that you need to remember to mount the volume for both attachments and other persistent data if they are different.
|
||||||
|
|
||||||
|
#### icons cache
|
||||||
|
|
||||||
|
Default is `$DATA_FOLDER/icon_cache`, you can change the path using `ICON_CACHE_FOLDER` variable:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run -d --name bitwarden \
|
||||||
|
-e ICON_CACHE_FOLDER=/icon_cache \
|
||||||
|
-v /bw-data/:/data/ \
|
||||||
|
-v /icon_cache/ \
|
||||||
|
-p 80:80 \
|
||||||
|
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-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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Other configuration
|
||||||
|
|
||||||
|
Though this is unlikely to be required in small deployment, you can fine-tune some other settings like number of workers using environment variables that are processed by [Rocket](https://rocket.rs), please see details in [documentation](https://rocket.rs/guide/configuration/#environment-variables).
|
||||||
|
|
||||||
|
## Building your own image
|
||||||
|
|
||||||
|
Clone the repository, then from the root of the repository run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Build the docker image:
|
# Build the docker image:
|
||||||
docker build -t dani/bitwarden_rs .
|
docker build -t bitwarden_rs .
|
||||||
|
|
||||||
# Run the docker image with a docker volume:
|
|
||||||
docker volume create bw_data
|
|
||||||
docker run --name bitwarden_rs -t --init --rm --mount source=bw_data,target=/data -p 8000:80 dani/bitwarden_rs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Other possible Docker options
|
## Building binary
|
||||||
|
|
||||||
To run the container in the background, add the `-d` parameter.
|
For building binary outside the Docker environment and running it locally without docker, please see [build instructions](BUILD.md).
|
||||||
|
|
||||||
To check the logs when in background, run `docker logs bitwarden_rs`
|
## Available packages
|
||||||
|
|
||||||
To stop the container in background, run `docker stop bitwarden_rs`
|
### Arch Linux
|
||||||
|
|
||||||
To make sure the container is restarted automatically, add the `--restart unless-stopped` parameter
|
Bitwarden_rs is already packaged for Archlinux thanks to @mqus. There is an AUR package [with](https://aur.archlinux.org/packages/bitwarden_rs-vault-git/) and
|
||||||
|
[without](https://aur.archlinux.org/packages/bitwarden_rs-git/) the vault web interface 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.
|
||||||
|
|
||||||
To run the image with a host bind, change the `--mount` parameter to:
|
|
||||||
```
|
```
|
||||||
--mount type=bind,source=<absolute_path>,target=/data
|
sqlite3 /$DATA_FOLDER/db.sqlite3 ".backup '/$DATA_FOLDER/db-backup/backup.sq3'"
|
||||||
```
|
```
|
||||||
Where <absolute_path> is an absolute path in the hosts file system (e.g. C:\bitwarden\data)
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## How to compile bitwarden_rs
|
### 2. the attachments folder
|
||||||
Install `rust nightly`, in Windows the recommended way is through `rustup`.
|
|
||||||
|
|
||||||
Install the `openssl` library, in Windows the best option is Microsoft's `vcpkg`,
|
By default, this is located in `$DATA_FOLDER/attachments`
|
||||||
on other systems use their respective package managers.
|
|
||||||
|
### 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`:
|
||||||
|
|
||||||
Then run:
|
|
||||||
```sh
|
```sh
|
||||||
cargo run
|
docker run -d --name bitwarden \
|
||||||
# or
|
--user 1000 \
|
||||||
cargo build
|
-e ROCKET_PORT=8080 \
|
||||||
|
-v /bw-data/:/data/ \
|
||||||
|
-p 80:8080 \
|
||||||
|
mprasil/bitwarden:latest
|
||||||
```
|
```
|
||||||
|
## Get in touch
|
||||||
|
|
||||||
## How to install the web-vault locally
|
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're using docker image, you can just update `VAULT_VERSION` variable in Dockerfile and rebuild the image.
|
|
||||||
|
|
||||||
Install `node.js` and either `yarn` or `npm` (usually included with node)
|
If you prefer to chat, we're usually hanging around at [#bitwarden_rs:matrix.org](https://matrix.to/#/!cASGtOHlSftdScFNMs:matrix.org) room on Matrix. Feel free to join us!
|
||||||
|
|
||||||
Clone the web-vault outside the project:
|
|
||||||
```
|
|
||||||
git clone https://github.com/bitwarden/web.git web-vault
|
|
||||||
```
|
|
||||||
|
|
||||||
Modify `web-vault/settings.Production.json` to look like this:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"appSettings": {
|
|
||||||
"apiUri": "/api",
|
|
||||||
"identityUri": "/identity",
|
|
||||||
"iconsUri": "/icons",
|
|
||||||
"stripeKey": "",
|
|
||||||
"braintreeKey": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Then, run the following from the `web-vault` dir:
|
|
||||||
```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
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
Make sure that the correct path to the database is in the `.env` file.
|
|
||||||
|
|
||||||
If you want to modify the schemas, create a new migration with:
|
|
||||||
```
|
|
||||||
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:
|
|
||||||
```
|
|
||||||
diesel migration redo
|
|
||||||
diesel print-schema > src/db/schema.rs
|
|
||||||
```
|
|
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::DbConn;
|
||||||
use db::models::*;
|
use db::models::*;
|
||||||
|
|
||||||
use api::{PasswordData, JsonResult, EmptyResult, JsonUpcase};
|
use api::{PasswordData, JsonResult, EmptyResult, JsonUpcase, NumberOrString};
|
||||||
use auth::Headers;
|
use auth::Headers;
|
||||||
|
|
||||||
use util;
|
|
||||||
|
|
||||||
use CONFIG;
|
use CONFIG;
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
@@ -15,7 +13,6 @@ use CONFIG;
|
|||||||
struct RegisterData {
|
struct RegisterData {
|
||||||
Email: String,
|
Email: String,
|
||||||
Key: String,
|
Key: String,
|
||||||
#[serde(deserialize_with = "util::upcase_deserialize")]
|
|
||||||
Keys: Option<KeysData>,
|
Keys: Option<KeysData>,
|
||||||
MasterPasswordHash: String,
|
MasterPasswordHash: String,
|
||||||
MasterPasswordHint: Option<String>,
|
MasterPasswordHint: Option<String>,
|
||||||
@@ -34,10 +31,10 @@ fn register(data: JsonUpcase<RegisterData>, conn: DbConn) -> EmptyResult {
|
|||||||
let data: RegisterData = data.into_inner().data;
|
let data: RegisterData = data.into_inner().data;
|
||||||
|
|
||||||
if !CONFIG.signups_allowed {
|
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")
|
err!("Email already exists")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +64,28 @@ fn profile(headers: Headers, conn: DbConn) -> JsonResult {
|
|||||||
Ok(Json(headers.user.to_json(&conn)))
|
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")]
|
#[get("/users/<uuid>/public-key")]
|
||||||
fn get_public_keys(uuid: String, _headers: Headers, conn: DbConn) -> JsonResult {
|
fn get_public_keys(uuid: String, _headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
let user = match User::find_by_uuid(&uuid, &conn) {
|
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)]
|
#[derive(Deserialize)]
|
||||||
#[allow(non_snake_case)]
|
#[allow(non_snake_case)]
|
||||||
struct ChangeEmailData {
|
struct EmailTokenData {
|
||||||
MasterPasswordHash: String,
|
MasterPasswordHash: String,
|
||||||
NewEmail: String,
|
NewEmail: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[post("/accounts/email-token", data = "<data>")]
|
#[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 {
|
fn post_email(data: JsonUpcase<ChangeEmailData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
let data: ChangeEmailData = data.into_inner().data;
|
let data: ChangeEmailData = data.into_inner().data;
|
||||||
let mut user = headers.user;
|
let mut user = headers.user;
|
||||||
@@ -156,6 +201,10 @@ fn post_email(data: JsonUpcase<ChangeEmailData>, headers: Headers, conn: DbConn)
|
|||||||
}
|
}
|
||||||
|
|
||||||
user.email = data.NewEmail;
|
user.email = data.NewEmail;
|
||||||
|
|
||||||
|
user.set_password(&data.NewMasterPasswordHash);
|
||||||
|
user.key = data.Key;
|
||||||
|
|
||||||
user.save(&conn);
|
user.save(&conn);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -172,17 +221,15 @@ fn delete_account(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn
|
|||||||
|
|
||||||
// Delete ciphers and their attachments
|
// Delete ciphers and their attachments
|
||||||
for cipher in Cipher::find_owned_by_user(&user.uuid, &conn) {
|
for cipher in Cipher::find_owned_by_user(&user.uuid, &conn) {
|
||||||
match cipher.delete(&conn) {
|
if cipher.delete(&conn).is_err() {
|
||||||
Ok(()) => (),
|
err!("Failed deleting cipher")
|
||||||
Err(_) => err!("Failed deleting cipher")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete folders
|
// Delete folders
|
||||||
for f in Folder::find_by_user(&user.uuid, &conn) {
|
for f in Folder::find_by_user(&user.uuid, &conn) {
|
||||||
match f.delete(&conn) {
|
if f.delete(&conn).is_err() {
|
||||||
Ok(()) => (),
|
err!("Failed deleting folder")
|
||||||
Err(_) => err!("Failed deleting folder")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -14,7 +14,6 @@ use data_encoding::HEXLOWER;
|
|||||||
use db::DbConn;
|
use db::DbConn;
|
||||||
use db::models::*;
|
use db::models::*;
|
||||||
|
|
||||||
use util;
|
|
||||||
use crypto;
|
use crypto;
|
||||||
|
|
||||||
use api::{self, PasswordData, JsonResult, EmptyResult, JsonUpcase};
|
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 {
|
let type_data_opt = match data.Type {
|
||||||
1 => data.Login,
|
1 => data.Login,
|
||||||
2 => data.SecureNote,
|
2 => data.SecureNote,
|
||||||
@@ -183,19 +164,24 @@ fn update_cipher_from_data(cipher: &mut Cipher, data: CipherData, headers: &Head
|
|||||||
_ => err!("Invalid type")
|
_ => err!("Invalid type")
|
||||||
};
|
};
|
||||||
|
|
||||||
let type_data = match type_data_opt {
|
let mut type_data = match type_data_opt {
|
||||||
Some(data) => data,
|
Some(data) => data,
|
||||||
None => err!("Data missing")
|
None => err!("Data missing")
|
||||||
};
|
};
|
||||||
|
|
||||||
// Copy the type data and change the names to the correct case
|
// TODO: ******* Backwards compat start **********
|
||||||
copy_values(&type_data, &mut values);
|
// 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.favorite = data.Favorite.unwrap_or(false);
|
||||||
cipher.name = data.Name;
|
cipher.name = data.Name;
|
||||||
cipher.notes = data.Notes;
|
cipher.notes = data.Notes;
|
||||||
cipher.fields = uppercase_fields.map(|f| f.to_string());
|
cipher.fields = data.Fields.map(|f| f.to_string());
|
||||||
cipher.data = values.to_string();
|
cipher.data = type_data.to_string();
|
||||||
|
|
||||||
cipher.save(&conn);
|
cipher.save(&conn);
|
||||||
|
|
||||||
@@ -206,23 +192,6 @@ fn update_cipher_from_data(cipher: &mut Cipher, data: CipherData, headers: &Head
|
|||||||
Ok(())
|
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;
|
use super::folders::FolderData;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -237,9 +206,9 @@ struct ImportData {
|
|||||||
#[allow(non_snake_case)]
|
#[allow(non_snake_case)]
|
||||||
struct RelationsData {
|
struct RelationsData {
|
||||||
// Cipher id
|
// Cipher id
|
||||||
key: u32,
|
Key: usize,
|
||||||
// Folder id
|
// 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();
|
let mut relations_map = HashMap::new();
|
||||||
|
|
||||||
for relation in data.FolderRelationships {
|
for relation in data.FolderRelationships {
|
||||||
relations_map.insert(relation.key, relation.value);
|
relations_map.insert(relation.Key, relation.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read and create the ciphers
|
// Read and create the ciphers
|
||||||
let mut index = 0;
|
for (index, cipher_data) in data.Ciphers.into_iter().enumerate() {
|
||||||
for cipher_data in data.Ciphers {
|
|
||||||
let folder_uuid = relations_map.get(&index)
|
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());
|
let mut cipher = Cipher::new(cipher_data.Type, cipher_data.Name.clone());
|
||||||
update_cipher_from_data(&mut cipher, cipher_data, &headers, true, &conn)?;
|
update_cipher_from_data(&mut cipher, cipher_data, &headers, true, &conn)?;
|
||||||
|
|
||||||
cipher.move_to_folder(folder_uuid, &headers.user.uuid.clone(), &conn).ok();
|
cipher.move_to_folder(folder_uuid, &headers.user.uuid.clone(), &conn).ok();
|
||||||
|
|
||||||
index += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -358,7 +324,6 @@ fn post_collections_admin(uuid: String, data: JsonUpcase<CollectionsAdminData>,
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[allow(non_snake_case)]
|
#[allow(non_snake_case)]
|
||||||
struct ShareCipherData {
|
struct ShareCipherData {
|
||||||
#[serde(deserialize_with = "util::upcase_deserialize")]
|
|
||||||
Cipher: CipherData,
|
Cipher: CipherData,
|
||||||
CollectionIds: Vec<String>,
|
CollectionIds: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -382,8 +347,8 @@ fn post_cipher_share(uuid: String, data: JsonUpcase<ShareCipherData>, headers: H
|
|||||||
None => err!("Organization id not provided"),
|
None => err!("Organization id not provided"),
|
||||||
Some(_) => {
|
Some(_) => {
|
||||||
update_cipher_from_data(&mut cipher, data.Cipher, &headers, true, &conn)?;
|
update_cipher_from_data(&mut cipher, data.Cipher, &headers, true, &conn)?;
|
||||||
for collection in data.CollectionIds.iter() {
|
for uuid in &data.CollectionIds {
|
||||||
match Collection::find_by_uuid(&collection, &conn) {
|
match Collection::find_by_uuid(uuid, &conn) {
|
||||||
None => err!("Invalid collection ID provided"),
|
None => err!("Invalid collection ID provided"),
|
||||||
Some(collection) => {
|
Some(collection) => {
|
||||||
if collection.is_writable_by_user(&headers.user.uuid, &conn) {
|
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);
|
let base_path = Path::new(&CONFIG.attachments_folder).join(&cipher.uuid);
|
||||||
|
|
||||||
Multipart::with_body(data.open(), boundary).foreach_entry(|mut field| {
|
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 file_name = HEXLOWER.encode(&crypto::get_random(vec![0; 10]));
|
||||||
let path = base_path.join(&file_name);
|
let path = base_path.join(&file_name);
|
||||||
@@ -428,11 +394,21 @@ fn post_attachment(uuid: String, data: Data, content_type: &ContentType, headers
|
|||||||
.size_limit(None)
|
.size_limit(None)
|
||||||
.with_path(path) {
|
.with_path(path) {
|
||||||
SaveResult::Full(SavedData::File(_, size)) => size as i32,
|
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);
|
let attachment = Attachment::new(file_name, cipher.uuid.clone(), name, size);
|
||||||
println!("Attachment: {:#?}", attachment);
|
|
||||||
attachment.save(&conn);
|
attachment.save(&conn);
|
||||||
}).expect("Error processing multipart data");
|
}).expect("Error processing multipart data");
|
||||||
|
|
||||||
@@ -476,6 +452,11 @@ fn delete_cipher_post(uuid: String, headers: Headers, conn: DbConn) -> EmptyResu
|
|||||||
_delete_cipher_by_uuid(&uuid, &headers, &conn)
|
_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>")]
|
#[delete("/ciphers/<uuid>")]
|
||||||
fn delete_cipher(uuid: String, headers: Headers, conn: DbConn) -> EmptyResult {
|
fn delete_cipher(uuid: String, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
_delete_cipher_by_uuid(&uuid, &headers, &conn)
|
_delete_cipher_by_uuid(&uuid, &headers, &conn)
|
||||||
@@ -567,17 +548,15 @@ fn delete_all(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) ->
|
|||||||
|
|
||||||
// Delete ciphers and their attachments
|
// Delete ciphers and their attachments
|
||||||
for cipher in Cipher::find_owned_by_user(&user.uuid, &conn) {
|
for cipher in Cipher::find_owned_by_user(&user.uuid, &conn) {
|
||||||
match cipher.delete(&conn) {
|
if cipher.delete(&conn).is_err() {
|
||||||
Ok(()) => (),
|
err!("Failed deleting cipher")
|
||||||
Err(_) => err!("Failed deleting cipher")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete folders
|
// Delete folders
|
||||||
for f in Folder::find_by_user(&user.uuid, &conn) {
|
for f in Folder::find_by_user(&user.uuid, &conn) {
|
||||||
match f.delete(&conn) {
|
if f.delete(&conn).is_err() {
|
||||||
Ok(()) => (),
|
err!("Failed deleting folder")
|
||||||
Err(_) => err!("Failed deleting folder")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -646,5 +646,121 @@
|
|||||||
"wiktionary.org"
|
"wiktionary.org"
|
||||||
],
|
],
|
||||||
"Excluded": false
|
"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 ciphers;
|
||||||
mod folders;
|
mod folders;
|
||||||
mod organizations;
|
mod organizations;
|
||||||
mod two_factor;
|
pub(crate) mod two_factor;
|
||||||
|
|
||||||
use self::accounts::*;
|
use self::accounts::*;
|
||||||
use self::ciphers::*;
|
use self::ciphers::*;
|
||||||
@@ -14,10 +14,12 @@ pub fn routes() -> Vec<Route> {
|
|||||||
routes![
|
routes![
|
||||||
register,
|
register,
|
||||||
profile,
|
profile,
|
||||||
|
post_profile,
|
||||||
get_public_keys,
|
get_public_keys,
|
||||||
post_keys,
|
post_keys,
|
||||||
post_password,
|
post_password,
|
||||||
post_sstamp,
|
post_sstamp,
|
||||||
|
post_email_token,
|
||||||
post_email,
|
post_email,
|
||||||
delete_account,
|
delete_account,
|
||||||
revision_date,
|
revision_date,
|
||||||
@@ -39,6 +41,7 @@ pub fn routes() -> Vec<Route> {
|
|||||||
post_cipher,
|
post_cipher,
|
||||||
put_cipher,
|
put_cipher,
|
||||||
delete_cipher_post,
|
delete_cipher_post,
|
||||||
|
delete_cipher_post_admin,
|
||||||
delete_cipher,
|
delete_cipher,
|
||||||
delete_cipher_selected,
|
delete_cipher_selected,
|
||||||
delete_all,
|
delete_all,
|
||||||
@@ -55,13 +58,16 @@ pub fn routes() -> Vec<Route> {
|
|||||||
get_twofactor,
|
get_twofactor,
|
||||||
get_recover,
|
get_recover,
|
||||||
recover,
|
recover,
|
||||||
|
disable_twofactor,
|
||||||
generate_authenticator,
|
generate_authenticator,
|
||||||
activate_authenticator,
|
activate_authenticator,
|
||||||
disable_authenticator,
|
generate_u2f,
|
||||||
|
activate_u2f,
|
||||||
|
|
||||||
get_organization,
|
get_organization,
|
||||||
create_organization,
|
create_organization,
|
||||||
delete_organization,
|
delete_organization,
|
||||||
|
leave_organization,
|
||||||
get_user_collections,
|
get_user_collections,
|
||||||
get_org_collections,
|
get_org_collections,
|
||||||
get_org_collection_detail,
|
get_org_collection_detail,
|
||||||
@@ -106,8 +112,7 @@ use auth::Headers;
|
|||||||
|
|
||||||
#[put("/devices/identifier/<uuid>/clear-token", data = "<data>")]
|
#[put("/devices/identifier/<uuid>/clear-token", data = "<data>")]
|
||||||
fn clear_device_token(uuid: String, data: Json<Value>, headers: Headers, conn: DbConn) -> EmptyResult {
|
fn clear_device_token(uuid: String, data: Json<Value>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
println!("UUID: {:#?}", uuid);
|
let _data: Value = data.into_inner();
|
||||||
println!("DATA: {:#?}", data);
|
|
||||||
|
|
||||||
let device = match Device::find_by_uuid(&uuid, &conn) {
|
let device = match Device::find_by_uuid(&uuid, &conn) {
|
||||||
Some(device) => device,
|
Some(device) => device,
|
||||||
@@ -125,8 +130,7 @@ fn clear_device_token(uuid: String, data: Json<Value>, headers: Headers, conn: D
|
|||||||
|
|
||||||
#[put("/devices/identifier/<uuid>/token", data = "<data>")]
|
#[put("/devices/identifier/<uuid>/token", data = "<data>")]
|
||||||
fn put_device_token(uuid: String, data: Json<Value>, headers: Headers, conn: DbConn) -> JsonResult {
|
fn put_device_token(uuid: String, data: Json<Value>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
println!("UUID: {:#?}", uuid);
|
let _data: Value = data.into_inner();
|
||||||
println!("DATA: {:#?}", data);
|
|
||||||
|
|
||||||
let device = match Device::find_by_uuid(&uuid, &conn) {
|
let device = match Device::find_by_uuid(&uuid, &conn) {
|
||||||
Some(device) => device,
|
Some(device) => device,
|
||||||
@@ -150,7 +154,7 @@ struct GlobalDomain {
|
|||||||
Excluded: bool,
|
Excluded: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
const GLOBAL_DOMAINS: &'static str = include_str!("global_domains.json");
|
const GLOBAL_DOMAINS: &str = include_str!("global_domains.json");
|
||||||
|
|
||||||
#[get("/settings/domains")]
|
#[get("/settings/domains")]
|
||||||
fn get_eq_domains(headers: Headers) -> JsonResult {
|
fn get_eq_domains(headers: Headers) -> JsonResult {
|
||||||
@@ -185,8 +189,8 @@ struct EquivDomainData {
|
|||||||
fn post_eq_domains(data: JsonUpcase<EquivDomainData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
fn post_eq_domains(data: JsonUpcase<EquivDomainData>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
let data: EquivDomainData = data.into_inner().data;
|
let data: EquivDomainData = data.into_inner().data;
|
||||||
|
|
||||||
let excluded_globals = data.ExcludedGlobalEquivalentDomains.unwrap_or(Vec::new());
|
let excluded_globals = data.ExcludedGlobalEquivalentDomains.unwrap_or_default();
|
||||||
let equivalent_domains = data.EquivalentDomains.unwrap_or(Vec::new());
|
let equivalent_domains = data.EquivalentDomains.unwrap_or_default();
|
||||||
|
|
||||||
let mut user = headers.user;
|
let mut user = headers.user;
|
||||||
use serde_json::to_string;
|
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>")]
|
#[get("/organizations/<org_id>")]
|
||||||
fn get_organization(org_id: String, _headers: OwnerHeaders, conn: DbConn) -> JsonResult {
|
fn get_organization(org_id: String, _headers: OwnerHeaders, conn: DbConn) -> JsonResult {
|
||||||
match Organization::find_by_uuid(&org_id, &conn) {
|
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)]
|
#[derive(Deserialize)]
|
||||||
#[allow(non_snake_case)]
|
#[allow(non_snake_case)]
|
||||||
struct CollectionData {
|
struct CollectionData {
|
||||||
id: String,
|
Id: String,
|
||||||
readOnly: bool,
|
ReadOnly: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -305,7 +328,7 @@ struct InviteData {
|
|||||||
fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeaders, conn: DbConn) -> EmptyResult {
|
fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeaders, conn: DbConn) -> EmptyResult {
|
||||||
let data: InviteData = data.into_inner().data;
|
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,
|
Some(new_type) => new_type as i32,
|
||||||
None => err!("Invalid type")
|
None => err!("Invalid type")
|
||||||
};
|
};
|
||||||
@@ -319,9 +342,8 @@ fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeade
|
|||||||
match user_opt {
|
match user_opt {
|
||||||
None => err!("User email does not exist"),
|
None => err!("User email does not exist"),
|
||||||
Some(user) => {
|
Some(user) => {
|
||||||
match UserOrganization::find_by_user_and_org(&user.uuid, &org_id, &conn) {
|
if UserOrganization::find_by_user_and_org(&user.uuid, &org_id, &conn).is_some() {
|
||||||
Some(_) => err!("User already in organization"),
|
err!("User already in organization")
|
||||||
None => ()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut new_user = UserOrganization::new(user.uuid.clone(), org_id.clone());
|
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 no accessAll, add the collections received
|
||||||
if !access_all {
|
if !access_all {
|
||||||
for col in data.Collections.iter() {
|
for col in &data.Collections {
|
||||||
match Collection::find_by_uuid_and_org(&col.id, &org_id, &conn) {
|
match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) {
|
||||||
None => err!("Collection not found in Organization"),
|
None => err!("Collection not found in Organization"),
|
||||||
Some(collection) => {
|
Some(collection) => {
|
||||||
match CollectionUser::save(&user.uuid, &collection.uuid, col.readOnly, &conn) {
|
if CollectionUser::save(&user.uuid, &collection.uuid, col.ReadOnly, &conn).is_err() {
|
||||||
Ok(()) => (),
|
err!("Failed saving collection access for user")
|
||||||
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.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(),
|
Some(key) => key.to_string(),
|
||||||
None => err!("Invalid key provided")
|
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 {
|
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 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,
|
Some(new_type) => new_type as i32,
|
||||||
None => err!("Invalid type")
|
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
|
// Delete all the odd collections
|
||||||
for c in CollectionUser::find_by_organization_and_user_uuid(&org_id, &user_to_edit.user_uuid, &conn) {
|
for c in CollectionUser::find_by_organization_and_user_uuid(&org_id, &user_to_edit.user_uuid, &conn) {
|
||||||
match c.delete(&conn) {
|
if c.delete(&conn).is_err() {
|
||||||
Ok(()) => (),
|
err!("Failed deleting old collection assignment")
|
||||||
Err(_) => err!("Failed deleting old collection assignment")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no accessAll, add the collections received
|
// If no accessAll, add the collections received
|
||||||
if !data.AccessAll {
|
if !data.AccessAll {
|
||||||
for col in data.Collections.iter() {
|
for col in &data.Collections {
|
||||||
match Collection::find_by_uuid_and_org(&col.id, &org_id, &conn) {
|
match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) {
|
||||||
None => err!("Collection not found in Organization"),
|
None => err!("Collection not found in Organization"),
|
||||||
Some(collection) => {
|
Some(collection) => {
|
||||||
match CollectionUser::save(&user_to_edit.user_uuid, &collection.uuid, col.readOnly, &conn) {
|
if CollectionUser::save(&user_to_edit.user_uuid, &collection.uuid, col.ReadOnly, &conn).is_err() {
|
||||||
Ok(()) => (),
|
err!("Failed saving collection access for user")
|
||||||
Err(_) => err!("Failed saving collection access for user")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,28 +1,24 @@
|
|||||||
use rocket_contrib::{Json, Value};
|
|
||||||
|
|
||||||
use data_encoding::BASE32;
|
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 crypto;
|
||||||
|
|
||||||
use api::{PasswordData, JsonResult, NumberOrString, JsonUpcase};
|
use api::{ApiResult, JsonResult, JsonUpcase, NumberOrString, PasswordData};
|
||||||
use auth::Headers;
|
use auth::Headers;
|
||||||
|
|
||||||
#[get("/two-factor")]
|
#[get("/two-factor")]
|
||||||
fn get_twofactor(headers: Headers) -> JsonResult {
|
fn get_twofactor(headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
let data = if headers.user.totp_secret.is_none() {
|
let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn);
|
||||||
Value::Null
|
let twofactors_json: Vec<Value> = twofactors.iter().map(|c| c.to_json_list()).collect();
|
||||||
} else {
|
|
||||||
json!([{
|
|
||||||
"Enabled": true,
|
|
||||||
"Type": 0,
|
|
||||||
"Object": "twoFactorProvider"
|
|
||||||
}])
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"Data": data,
|
"Data": twofactors_json,
|
||||||
"Object": "list"
|
"Object": "list"
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
@@ -58,7 +54,7 @@ fn recover(data: JsonUpcase<RecoverTwoFactor>, conn: DbConn) -> JsonResult {
|
|||||||
// Get the user
|
// Get the user
|
||||||
let mut user = match User::find_by_mail(&data.Email, &conn) {
|
let mut user = match User::find_by_mail(&data.Email, &conn) {
|
||||||
Some(user) => user,
|
Some(user) => user,
|
||||||
None => err!("Username or password is incorrect. Try again.")
|
None => err!("Username or password is incorrect. Try again."),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check password
|
// Check password
|
||||||
@@ -71,24 +67,69 @@ fn recover(data: JsonUpcase<RecoverTwoFactor>, conn: DbConn) -> JsonResult {
|
|||||||
err!("Recovery code is incorrect. Try again.")
|
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.totp_recover = None;
|
||||||
user.save(&conn);
|
user.save(&conn);
|
||||||
|
|
||||||
Ok(Json(json!({})))
|
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>")]
|
#[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;
|
let data: PasswordData = data.into_inner().data;
|
||||||
|
|
||||||
if !headers.user.check_valid_password(&data.MasterPasswordHash) {
|
if !headers.user.check_valid_password(&data.MasterPasswordHash) {
|
||||||
err!("Invalid password");
|
err!("Invalid password");
|
||||||
}
|
}
|
||||||
|
|
||||||
let (enabled, key) = match headers.user.totp_secret {
|
let type_ = TwoFactorType::Authenticator as i32;
|
||||||
Some(secret) => (true, secret),
|
let twofactor = TwoFactor::find_by_user_and_type(&headers.user.uuid, type_, &conn);
|
||||||
_ => (false, BASE32.encode(&crypto::get_random(vec![0u8; 20])))
|
|
||||||
|
let (enabled, key) = match twofactor {
|
||||||
|
Some(tf) => (true, tf.data),
|
||||||
|
_ => (false, BASE32.encode(&crypto::get_random(vec![0u8; 20]))),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
@@ -100,20 +141,24 @@ fn generate_authenticator(data: JsonUpcase<PasswordData>, headers: Headers) -> J
|
|||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
#[allow(non_snake_case)]
|
#[allow(non_snake_case)]
|
||||||
struct EnableTwoFactorData {
|
struct EnableAuthenticatorData {
|
||||||
MasterPasswordHash: String,
|
MasterPasswordHash: String,
|
||||||
Key: String,
|
Key: String,
|
||||||
Token: NumberOrString,
|
Token: NumberOrString,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/two-factor/authenticator", data = "<data>")]
|
#[post("/two-factor/authenticator", data = "<data>")]
|
||||||
fn activate_authenticator(data: JsonUpcase<EnableTwoFactorData>, headers: Headers, conn: DbConn) -> JsonResult {
|
fn activate_authenticator(
|
||||||
let data: EnableTwoFactorData = data.into_inner().data;
|
data: JsonUpcase<EnableAuthenticatorData>,
|
||||||
|
headers: Headers,
|
||||||
|
conn: DbConn,
|
||||||
|
) -> JsonResult {
|
||||||
|
let data: EnableAuthenticatorData = data.into_inner().data;
|
||||||
let password_hash = data.MasterPasswordHash;
|
let password_hash = data.MasterPasswordHash;
|
||||||
let key = data.Key;
|
let key = data.Key;
|
||||||
let token = match data.Token.to_i32() {
|
let token = match data.Token.into_i32() {
|
||||||
Some(n) => n as u64,
|
Some(n) => n as u64,
|
||||||
None => err!("Malformed token")
|
None => err!("Malformed token"),
|
||||||
};
|
};
|
||||||
|
|
||||||
if !headers.user.check_valid_password(&password_hash) {
|
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
|
// Validate key as base32 and 20 bytes length
|
||||||
let decoded_key: Vec<u8> = match BASE32.decode(key.as_bytes()) {
|
let decoded_key: Vec<u8> = match BASE32.decode(key.as_bytes()) {
|
||||||
Ok(decoded) => decoded,
|
Ok(decoded) => decoded,
|
||||||
_ => err!("Invalid totp secret")
|
_ => err!("Invalid totp secret"),
|
||||||
};
|
};
|
||||||
|
|
||||||
if decoded_key.len() != 20 {
|
if decoded_key.len() != 20 {
|
||||||
err!("Invalid key length")
|
err!("Invalid key length")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set key in user.totp_secret
|
let type_ = TwoFactorType::Authenticator;
|
||||||
let mut user = headers.user;
|
let twofactor = TwoFactor::new(headers.user.uuid.clone(), type_, key.to_uppercase());
|
||||||
user.totp_secret = Some(key.to_uppercase());
|
|
||||||
|
|
||||||
// Validate the token provided with the key
|
// Validate the token provided with the key
|
||||||
if !user.check_totp_code(token) {
|
if !twofactor.check_totp_code(token) {
|
||||||
err!("Invalid totp code")
|
err!("Invalid totp code")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate totp_recover
|
let mut user = headers.user;
|
||||||
let totp_recover = BASE32.encode(&crypto::get_random(vec![0u8; 20]));
|
_generate_recover_code(&mut user, &conn);
|
||||||
user.totp_recover = Some(totp_recover);
|
twofactor.save(&conn).expect("Error saving twofactor");
|
||||||
|
|
||||||
user.save(&conn);
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"Enabled": true,
|
"Enabled": true,
|
||||||
@@ -152,32 +194,266 @@ fn activate_authenticator(data: JsonUpcase<EnableTwoFactorData>, headers: Header
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
fn _generate_recover_code(user: &mut User, conn: &DbConn) {
|
||||||
#[allow(non_snake_case)]
|
if user.totp_recover.is_none() {
|
||||||
struct DisableTwoFactorData {
|
let totp_recover = BASE32.encode(&crypto::get_random(vec![0u8; 20]));
|
||||||
MasterPasswordHash: String,
|
user.totp_recover = Some(totp_recover);
|
||||||
Type: NumberOrString,
|
user.save(conn);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/two-factor/disable", data = "<data>")]
|
use u2f::messages::{RegisterResponse, SignResponse, U2fSignRequest};
|
||||||
fn disable_authenticator(data: JsonUpcase<DisableTwoFactorData>, headers: Headers, conn: DbConn) -> JsonResult {
|
use u2f::protocol::{Challenge, U2f};
|
||||||
let data: DisableTwoFactorData = data.into_inner().data;
|
use u2f::register::Registration;
|
||||||
let password_hash = data.MasterPasswordHash;
|
|
||||||
let _type = data.Type;
|
|
||||||
|
|
||||||
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");
|
err!("Invalid password");
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut user = headers.user;
|
let user_uuid = &headers.user.uuid;
|
||||||
user.totp_secret = None;
|
|
||||||
user.totp_recover = None;
|
|
||||||
|
|
||||||
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!({
|
Ok(Json(json!({
|
||||||
"Enabled": false,
|
"Enabled": enabled,
|
||||||
"Type": 0,
|
"Challenge": {
|
||||||
"Object": "twoFactorProvider"
|
"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::io::prelude::*;
|
||||||
use std::fs::{create_dir_all, File};
|
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());
|
return Content(icon_type, get_fallback_icon());
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = format!("https://icons.bitwarden.com/{}/icon.png", domain);
|
let icon = get_icon(&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())
|
|
||||||
};
|
|
||||||
|
|
||||||
Content(icon_type, icon)
|
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)?;
|
let mut res = reqwest::get(url)?;
|
||||||
|
|
||||||
res = match res.error_for_status() {
|
res = res.error_for_status()?;
|
||||||
Err(e) => return Err(e),
|
|
||||||
Ok(res) => res
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut buffer: Vec<u8> = vec![];
|
let mut buffer: Vec<u8> = vec![];
|
||||||
res.copy_to(&mut buffer)?;
|
res.copy_to(&mut buffer)?;
|
||||||
@@ -48,39 +82,31 @@ fn get_icon(url: &str) -> Result<Vec<u8>, reqwest::Error> {
|
|||||||
Ok(buffer)
|
Ok(buffer)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_icon_cached(key: &str, url: &str) -> io::Result<Vec<u8>> {
|
fn save_icon(path: &str, icon: &[u8]) {
|
||||||
create_dir_all(&CONFIG.icon_cache_folder)?;
|
create_dir_all(&CONFIG.icon_cache_folder).expect("Error creating icon cache");
|
||||||
let path = &format!("{}/{}.png", CONFIG.icon_cache_folder, key);
|
|
||||||
|
|
||||||
// Try to read the cached icon, and return it if it exists
|
if let Ok(mut f) = File::create(path) {
|
||||||
match File::open(path) {
|
f.write_all(icon).expect("Error writing icon file");
|
||||||
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, ""))
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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> {
|
fn get_fallback_icon() -> Vec<u8> {
|
||||||
let fallback_icon = "https://raw.githubusercontent.com/bitwarden/web/master/src/images/fa-globe.png";
|
let path = format!("{}/default.png", CONFIG.icon_cache_folder);
|
||||||
get_icon_cached("default", fallback_icon).unwrap()
|
|
||||||
|
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,16 +1,20 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use rocket::{Route, Outcome};
|
use rocket::request::{self, Form, FormItems, FromForm, FromRequest, Request};
|
||||||
use rocket::request::{self, Request, FromRequest, Form, FormItems, FromForm};
|
use rocket::{Outcome, Route};
|
||||||
|
|
||||||
use rocket_contrib::{Json, Value};
|
use rocket_contrib::{Json, Value};
|
||||||
|
|
||||||
use db::DbConn;
|
use num_traits::FromPrimitive;
|
||||||
|
|
||||||
use db::models::*;
|
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> {
|
pub fn routes() -> Vec<Route> {
|
||||||
routes![login]
|
routes![login]
|
||||||
@@ -19,22 +23,21 @@ pub fn routes() -> Vec<Route> {
|
|||||||
#[post("/connect/token", data = "<connect_data>")]
|
#[post("/connect/token", data = "<connect_data>")]
|
||||||
fn login(connect_data: Form<ConnectData>, device_type: DeviceType, conn: DbConn) -> JsonResult {
|
fn login(connect_data: Form<ConnectData>, device_type: DeviceType, conn: DbConn) -> JsonResult {
|
||||||
let data = connect_data.get();
|
let data = connect_data.get();
|
||||||
println!("{:#?}", data);
|
|
||||||
|
|
||||||
match data.grant_type {
|
match data.grant_type {
|
||||||
GrantType::RefreshToken => _refresh_login(data, device_type, conn),
|
GrantType::RefreshToken => _refresh_login(data, device_type, conn),
|
||||||
GrantType::Password => _password_login(data, device_type, conn)
|
GrantType::Password => _password_login(data, device_type, conn),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _refresh_login(data: &ConnectData, _device_type: DeviceType, conn: DbConn) -> JsonResult {
|
fn _refresh_login(data: &ConnectData, _device_type: DeviceType, conn: DbConn) -> JsonResult {
|
||||||
// Extract token
|
// Extract token
|
||||||
let token = data.get("refresh_token").unwrap();
|
let token = data.get("refresh_token");
|
||||||
|
|
||||||
// Get device by refresh token
|
// Get device by refresh token
|
||||||
let mut device = match Device::find_by_refresh_token(token, &conn) {
|
let mut device = match Device::find_by_refresh_token(token, &conn) {
|
||||||
Some(device) => device,
|
Some(device) => device,
|
||||||
None => err!("Invalid refresh token")
|
None => err!("Invalid refresh token"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// COMMON
|
// COMMON
|
||||||
@@ -56,20 +59,20 @@ fn _refresh_login(data: &ConnectData, _device_type: DeviceType, conn: DbConn) ->
|
|||||||
|
|
||||||
fn _password_login(data: &ConnectData, device_type: DeviceType, conn: DbConn) -> JsonResult {
|
fn _password_login(data: &ConnectData, device_type: DeviceType, conn: DbConn) -> JsonResult {
|
||||||
// Validate scope
|
// Validate scope
|
||||||
let scope = data.get("scope").unwrap();
|
let scope = data.get("scope");
|
||||||
if scope != "api offline_access" {
|
if scope != "api offline_access" {
|
||||||
err!("Scope not supported")
|
err!("Scope not supported")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the user
|
// Get the user
|
||||||
let username = data.get("username").unwrap();
|
let username = data.get("username");
|
||||||
let user = match User::find_by_mail(username, &conn) {
|
let user = match User::find_by_mail(username, &conn) {
|
||||||
Some(user) => user,
|
Some(user) => user,
|
||||||
None => err!("Username or password is incorrect. Try again.")
|
None => err!("Username or password is incorrect. Try again."),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check password
|
// Check password
|
||||||
let password = data.get("password").unwrap();
|
let password = data.get("password");
|
||||||
if !user.check_valid_password(password) {
|
if !user.check_valid_password(password) {
|
||||||
err!("Username or password is incorrect. Try again.")
|
err!("Username or password is incorrect. Try again.")
|
||||||
}
|
}
|
||||||
@@ -77,14 +80,13 @@ fn _password_login(data: &ConnectData, device_type: DeviceType, conn: DbConn) ->
|
|||||||
// Let's only use the header and ignore the 'devicetype' parameter
|
// Let's only use the header and ignore the 'devicetype' parameter
|
||||||
let device_type_num = device_type.0;
|
let device_type_num = device_type.0;
|
||||||
|
|
||||||
let (device_id, device_name) = match data.is_device {
|
let (device_id, device_name) = if data.is_device {
|
||||||
false => { (format!("web-{}", user.uuid), String::from("web")) }
|
|
||||||
true => {
|
|
||||||
(
|
(
|
||||||
data.get("deviceidentifier").unwrap().clone(),
|
data.get("deviceidentifier").clone(),
|
||||||
data.get("devicename").unwrap().clone(),
|
data.get("devicename").clone(),
|
||||||
)
|
)
|
||||||
}
|
} else {
|
||||||
|
(format!("web-{}", user.uuid), String::from("web"))
|
||||||
};
|
};
|
||||||
|
|
||||||
// Find device or create new
|
// 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_token = twofactor_auth(&user.uuid, &data, &mut device, &conn)?;
|
||||||
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
|
|
||||||
|
|
||||||
// Common
|
// Common
|
||||||
let user = User::find_by_uuid(&device.user_uuid, &conn).unwrap();
|
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))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _json_err_twofactor() -> Value {
|
fn twofactor_auth(
|
||||||
json!({
|
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" : "invalid_grant",
|
||||||
"error_description" : "Two factor required.",
|
"error_description" : "Two factor required.",
|
||||||
"TwoFactorProviders" : [ 0 ],
|
"TwoFactorProviders" : providers,
|
||||||
"TwoFactorProviders2" : { "0" : null }
|
"TwoFactorProviders2" : {} // { "0" : null }
|
||||||
})
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
ConnectData {
|
use serde_json;
|
||||||
grant_type: Password,
|
let challenge_list_str = serde_json::to_string(&challenge_list).unwrap();
|
||||||
is_device: false,
|
|
||||||
data: {
|
map.insert("Challenges".into(), Value::String(challenge_list_str));
|
||||||
"scope": "api offline_access",
|
result["TwoFactorProviders2"][provider.to_string()] = Value::Object(map);
|
||||||
"client_id": "web",
|
}
|
||||||
"grant_type": "password",
|
|
||||||
"username": "dani@mail",
|
_ => {}
|
||||||
"password": "8IuV1sJ94tPjyYIK+E+PTjblzjm4W6C4N5wqM0KKsSg="
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RETURNS "TwoFactorToken": "11122233333444555666777888999"
|
Ok(result)
|
||||||
|
|
||||||
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);
|
struct DeviceType(i32);
|
||||||
|
|
||||||
impl<'a, 'r> FromRequest<'a, 'r> for DeviceType {
|
impl<'a, 'r> FromRequest<'a, 'r> for DeviceType {
|
||||||
@@ -221,7 +267,6 @@ impl<'a, 'r> FromRequest<'a, 'r> for DeviceType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct ConnectData {
|
struct ConnectData {
|
||||||
grant_type: GrantType,
|
grant_type: GrantType,
|
||||||
@@ -230,10 +275,17 @@ struct ConnectData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Copy, Clone)]
|
#[derive(Debug, Copy, Clone)]
|
||||||
enum GrantType { RefreshToken, Password }
|
enum GrantType {
|
||||||
|
RefreshToken,
|
||||||
|
Password,
|
||||||
|
}
|
||||||
|
|
||||||
impl ConnectData {
|
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())
|
self.data.get(&key.to_lowercase())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,13 +304,12 @@ impl<'f> FromForm<'f> for ConnectData {
|
|||||||
for (key, value) in items {
|
for (key, value) in items {
|
||||||
match (key.url_decode(), value.url_decode()) {
|
match (key.url_decode(), value.url_decode()) {
|
||||||
(Ok(key), Ok(value)) => data.insert(key.to_lowercase(), value),
|
(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
|
// Validate needed values
|
||||||
let (grant_type, is_device) =
|
let (grant_type, is_device) = match data.get("grant_type").map(String::as_ref) {
|
||||||
match data.get("grant_type").map(String::as_ref) {
|
|
||||||
Some("refresh_token") => {
|
Some("refresh_token") => {
|
||||||
check_values(&data, &VALUES_REFRESH)?;
|
check_values(&data, &VALUES_REFRESH)?;
|
||||||
(GrantType::RefreshToken, false) // Device doesn't matter here
|
(GrantType::RefreshToken, false) // Device doesn't matter here
|
||||||
@@ -266,16 +317,20 @@ impl<'f> FromForm<'f> for ConnectData {
|
|||||||
Some("password") => {
|
Some("password") => {
|
||||||
check_values(&data, &VALUES_PASSWORD)?;
|
check_values(&data, &VALUES_PASSWORD)?;
|
||||||
|
|
||||||
let is_device = match data.get("client_id").unwrap().as_ref() {
|
let is_device = match data["client_id"].as_ref() {
|
||||||
"browser" | "mobile" => check_values(&data, &VALUES_DEVICE)?,
|
"browser" | "mobile" => check_values(&data, &VALUES_DEVICE)?,
|
||||||
_ => false
|
_ => false,
|
||||||
};
|
};
|
||||||
(GrantType::Password, is_device)
|
(GrantType::Password, is_device)
|
||||||
}
|
}
|
||||||
_ => return Err(format!("Grant type not supported"))
|
_ => 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 icons;
|
||||||
mod identity;
|
mod identity;
|
||||||
mod web;
|
mod web;
|
||||||
@@ -12,8 +12,9 @@ use rocket::response::status::BadRequest;
|
|||||||
use rocket_contrib::Json;
|
use rocket_contrib::Json;
|
||||||
|
|
||||||
// Type aliases for API methods results
|
// Type aliases for API methods results
|
||||||
type JsonResult = Result<Json, BadRequest<Json>>;
|
type ApiResult<T> = Result<T, BadRequest<Json>>;
|
||||||
type EmptyResult = Result<(), BadRequest<Json>>;
|
type JsonResult = ApiResult<Json>;
|
||||||
|
type EmptyResult = ApiResult<()>;
|
||||||
|
|
||||||
use util;
|
use util;
|
||||||
type JsonUpcase<T> = Json<util::UpCase<T>>;
|
type JsonUpcase<T> = Json<util::UpCase<T>>;
|
||||||
@@ -25,7 +26,7 @@ struct PasswordData {
|
|||||||
MasterPasswordHash: String
|
MasterPasswordHash: String
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
#[serde(untagged)]
|
#[serde(untagged)]
|
||||||
enum NumberOrString {
|
enum NumberOrString {
|
||||||
Number(i32),
|
Number(i32),
|
||||||
@@ -33,14 +34,14 @@ enum NumberOrString {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl NumberOrString {
|
impl NumberOrString {
|
||||||
fn to_string(self) -> String {
|
fn into_string(self) -> String {
|
||||||
match self {
|
match self {
|
||||||
NumberOrString::Number(n) => n.to_string(),
|
NumberOrString::Number(n) => n.to_string(),
|
||||||
NumberOrString::String(s) => s
|
NumberOrString::String(s) => s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn to_i32(self) -> Option<i32> {
|
fn into_i32(self) -> Option<i32> {
|
||||||
match self {
|
match self {
|
||||||
NumberOrString::Number(n) => Some(n),
|
NumberOrString::Number(n) => Some(n),
|
||||||
NumberOrString::String(s) => s.parse().ok()
|
NumberOrString::String(s) => s.parse().ok()
|
||||||
|
@@ -1,39 +1,68 @@
|
|||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use rocket::request::Request;
|
||||||
|
use rocket::response::{self, NamedFile, Responder};
|
||||||
|
use rocket::response::content::Content;
|
||||||
|
use rocket::http::ContentType;
|
||||||
use rocket::Route;
|
use rocket::Route;
|
||||||
use rocket::response::NamedFile;
|
use rocket_contrib::{Json, Value};
|
||||||
use rocket_contrib::Json;
|
|
||||||
|
|
||||||
use CONFIG;
|
use CONFIG;
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
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
|
// TODO: Might want to use in memory cache: https://github.com/hgzimmerman/rocket-file-cache
|
||||||
#[get("/")]
|
#[get("/")]
|
||||||
fn index() -> io::Result<NamedFile> {
|
fn web_index() -> WebHeaders<io::Result<NamedFile>> {
|
||||||
NamedFile::open(
|
web_files("index.html".into())
|
||||||
Path::new(&CONFIG.web_vault_folder)
|
}
|
||||||
.join("index.html"))
|
|
||||||
|
#[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
|
#[get("/<p..>", rank = 1)] // Only match this if the other routes don't match
|
||||||
fn files(p: PathBuf) -> io::Result<NamedFile> {
|
fn web_files(p: PathBuf) -> WebHeaders<io::Result<NamedFile>> {
|
||||||
NamedFile::open(
|
WebHeaders(NamedFile::open(Path::new(&CONFIG.web_vault_folder).join(p)))
|
||||||
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> {
|
||||||
|
let mut res = self.0.respond_to(req)?;
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/attachments/<uuid>/<file..>")]
|
#[get("/attachments/<uuid>/<file..>")]
|
||||||
fn attachments(uuid: String, file: PathBuf) -> io::Result<NamedFile> {
|
fn attachments(uuid: String, file: PathBuf) -> io::Result<NamedFile> {
|
||||||
NamedFile::open(
|
NamedFile::open(Path::new(&CONFIG.attachments_folder).join(uuid).join(file))
|
||||||
Path::new(&CONFIG.attachments_folder)
|
|
||||||
.join(uuid)
|
|
||||||
.join(file)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
37
src/auth.rs
37
src/auth.rs
@@ -11,10 +11,11 @@ use serde::ser::Serialize;
|
|||||||
use CONFIG;
|
use CONFIG;
|
||||||
|
|
||||||
const JWT_ALGORITHM: jwt::Algorithm = jwt::Algorithm::RS256;
|
const JWT_ALGORITHM: jwt::Algorithm = jwt::Algorithm::RS256;
|
||||||
pub const JWT_ISSUER: &'static str = "localhost:8000/identity";
|
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
pub static ref DEFAULT_VALIDITY: Duration = Duration::hours(2);
|
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 JWT_HEADER: jwt::Header = jwt::Header::new(JWT_ALGORITHM);
|
||||||
|
|
||||||
static ref PRIVATE_RSA_KEY: Vec<u8> = match read_file(&CONFIG.private_rsa_key) {
|
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 {
|
pub fn encode_jwt<T: Serialize>(claims: &T) -> String {
|
||||||
match jwt::encode(&JWT_HEADER, claims, &PRIVATE_RSA_KEY) {
|
match jwt::encode(&JWT_HEADER, claims, &PRIVATE_RSA_KEY) {
|
||||||
Ok(token) => return token,
|
Ok(token) => token,
|
||||||
Err(e) => panic!("Error encoding jwt {}", e)
|
Err(e) => panic!("Error encoding jwt {}", e)
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decode_jwt(token: &str) -> Result<JWTClaims, String> {
|
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_iat: true,
|
||||||
validate_nbf: true,
|
validate_nbf: true,
|
||||||
aud: None,
|
aud: None,
|
||||||
iss: Some(JWT_ISSUER.into()),
|
iss: Some(JWT_ISSUER.clone()),
|
||||||
sub: None,
|
sub: None,
|
||||||
algorithms: vec![JWT_ALGORITHM],
|
algorithms: vec![JWT_ALGORITHM],
|
||||||
};
|
};
|
||||||
@@ -109,9 +110,31 @@ impl<'a, 'r> FromRequest<'a, 'r> for Headers {
|
|||||||
let headers = request.headers();
|
let headers = request.headers();
|
||||||
|
|
||||||
// Get host
|
// Get host
|
||||||
let host = match headers.get_one("Host") {
|
let host = if CONFIG.domain_set {
|
||||||
Some(host) => format!("http://{}", host), // TODO: Check if HTTPS
|
CONFIG.domain.clone()
|
||||||
_ => String::new()
|
} 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
|
// Get access_token
|
||||||
|
@@ -3,7 +3,7 @@ use serde_json::Value as JsonValue;
|
|||||||
|
|
||||||
use uuid::Uuid;
|
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)]
|
#[derive(Debug, Identifiable, Queryable, Insertable, Associations)]
|
||||||
#[table_name = "ciphers"]
|
#[table_name = "ciphers"]
|
||||||
@@ -84,7 +84,7 @@ impl Cipher {
|
|||||||
// To remove backwards compatibility, just remove this entire section
|
// To remove backwards compatibility, just remove this entire section
|
||||||
// and remove the compat code from ciphers::update_cipher_from_data
|
// and remove the compat code from ciphers::update_cipher_from_data
|
||||||
if self.type_ == 1 && data_json["Uris"].is_array() {
|
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;
|
data_json["Uri"] = uri;
|
||||||
}
|
}
|
||||||
// TODO: ******* Backwards compat end **********
|
// TODO: ******* Backwards compat end **********
|
||||||
@@ -97,7 +97,7 @@ impl Cipher {
|
|||||||
"Favorite": self.favorite,
|
"Favorite": self.favorite,
|
||||||
"OrganizationId": self.organization_uuid,
|
"OrganizationId": self.organization_uuid,
|
||||||
"Attachments": attachments_json,
|
"Attachments": attachments_json,
|
||||||
"OrganizationUseTotp": false,
|
"OrganizationUseTotp": true,
|
||||||
"CollectionIds": self.get_collections(user_uuid, &conn),
|
"CollectionIds": self.get_collections(user_uuid, &conn),
|
||||||
|
|
||||||
"Name": self.name,
|
"Name": self.name,
|
||||||
@@ -266,7 +266,9 @@ impl Cipher {
|
|||||||
ciphers::table
|
ciphers::table
|
||||||
.left_join(users_organizations::table.on(
|
.left_join(users_organizations::table.on(
|
||||||
ciphers::organization_uuid.eq(users_organizations::org_uuid.nullable()).and(
|
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)
|
.left_join(ciphers_collections::table)
|
||||||
|
@@ -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 data_encoding::BASE64;
|
||||||
use crypto;
|
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) {
|
pub fn delete_twofactor_remember(&mut self) {
|
||||||
|
@@ -6,6 +6,7 @@ mod user;
|
|||||||
|
|
||||||
mod collection;
|
mod collection;
|
||||||
mod organization;
|
mod organization;
|
||||||
|
mod two_factor;
|
||||||
|
|
||||||
pub use self::attachment::Attachment;
|
pub use self::attachment::Attachment;
|
||||||
pub use self::cipher::Cipher;
|
pub use self::cipher::Cipher;
|
||||||
@@ -15,3 +16,4 @@ pub use self::user::User;
|
|||||||
pub use self::organization::Organization;
|
pub use self::organization::Organization;
|
||||||
pub use self::organization::{UserOrganization, UserOrgStatus, UserOrgType};
|
pub use self::organization::{UserOrganization, UserOrgStatus, UserOrgType};
|
||||||
pub use self::collection::{Collection, CollectionUser, CollectionCipher};
|
pub use self::collection::{Collection, CollectionUser, CollectionCipher};
|
||||||
|
pub use self::two_factor::{TwoFactor, TwoFactorType};
|
@@ -66,11 +66,11 @@ impl Organization {
|
|||||||
"Seats": 10,
|
"Seats": 10,
|
||||||
"MaxCollections": 10,
|
"MaxCollections": 10,
|
||||||
|
|
||||||
"Use2fa": false,
|
"Use2fa": true,
|
||||||
"UseDirectory": false,
|
"UseDirectory": false,
|
||||||
"UseEvents": false,
|
"UseEvents": false,
|
||||||
"UseGroups": false,
|
"UseGroups": false,
|
||||||
"UseTotp": false,
|
"UseTotp": true,
|
||||||
|
|
||||||
"BusinessName": null,
|
"BusinessName": null,
|
||||||
"BusinessAddress1": null,
|
"BusinessAddress1": null,
|
||||||
@@ -80,8 +80,8 @@ impl Organization {
|
|||||||
"BusinessTaxNumber": null,
|
"BusinessTaxNumber": null,
|
||||||
|
|
||||||
"BillingEmail": self.billing_email,
|
"BillingEmail": self.billing_email,
|
||||||
"Plan": "Free",
|
"Plan": "TeamsAnnually",
|
||||||
"PlanType": 0, // Free plan
|
"PlanType": 5, // TeamsAnnually plan
|
||||||
|
|
||||||
"Object": "organization",
|
"Object": "organization",
|
||||||
})
|
})
|
||||||
@@ -153,11 +153,11 @@ impl UserOrganization {
|
|||||||
"Seats": 10,
|
"Seats": 10,
|
||||||
"MaxCollections": 10,
|
"MaxCollections": 10,
|
||||||
|
|
||||||
"Use2fa": false,
|
"Use2fa": true,
|
||||||
"UseDirectory": false,
|
"UseDirectory": false,
|
||||||
"UseEvents": false,
|
"UseEvents": false,
|
||||||
"UseGroups": false,
|
"UseGroups": false,
|
||||||
"UseTotp": false,
|
"UseTotp": true,
|
||||||
|
|
||||||
"MaxStorageGb": 10, // The value doesn't matter, we don't check server-side
|
"MaxStorageGb": 10, // The value doesn't matter, we don't check server-side
|
||||||
|
|
||||||
|
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 private_key: Option<String>,
|
||||||
pub public_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 totp_recover: Option<String>,
|
||||||
|
|
||||||
pub security_stamp: String,
|
pub security_stamp: String,
|
||||||
@@ -64,7 +65,7 @@ impl User {
|
|||||||
private_key: None,
|
private_key: None,
|
||||||
public_key: None,
|
public_key: None,
|
||||||
|
|
||||||
totp_secret: None,
|
_totp_secret: None,
|
||||||
totp_recover: None,
|
totp_recover: None,
|
||||||
|
|
||||||
equivalent_domains: "[]".to_string(),
|
equivalent_domains: "[]".to_string(),
|
||||||
@@ -97,28 +98,6 @@ impl User {
|
|||||||
pub fn reset_security_stamp(&mut self) {
|
pub fn reset_security_stamp(&mut self) {
|
||||||
self.security_stamp = Uuid::new_v4().to_string();
|
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;
|
use diesel;
|
||||||
@@ -130,10 +109,13 @@ use db::schema::users;
|
|||||||
impl User {
|
impl User {
|
||||||
pub fn to_json(&self, conn: &DbConn) -> JsonValue {
|
pub fn to_json(&self, conn: &DbConn) -> JsonValue {
|
||||||
use super::UserOrganization;
|
use super::UserOrganization;
|
||||||
|
use super::TwoFactor;
|
||||||
|
|
||||||
let orgs = UserOrganization::find_by_user(&self.uuid, conn);
|
let orgs = UserOrganization::find_by_user(&self.uuid, conn);
|
||||||
let orgs_json: Vec<JsonValue> = orgs.iter().map(|c| c.to_json(&conn)).collect();
|
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!({
|
json!({
|
||||||
"Id": self.uuid,
|
"Id": self.uuid,
|
||||||
"Name": self.name,
|
"Name": self.name,
|
||||||
@@ -142,7 +124,7 @@ impl User {
|
|||||||
"Premium": true,
|
"Premium": true,
|
||||||
"MasterPasswordHint": self.password_hint,
|
"MasterPasswordHint": self.password_hint,
|
||||||
"Culture": "en-US",
|
"Culture": "en-US",
|
||||||
"TwoFactorEnabled": self.totp_secret.is_some(),
|
"TwoFactorEnabled": twofactor_enabled,
|
||||||
"Key": self.key,
|
"Key": self.key,
|
||||||
"PrivateKey": self.private_key,
|
"PrivateKey": self.private_key,
|
||||||
"SecurityStamp": self.security_stamp,
|
"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! {
|
table! {
|
||||||
users (uuid) {
|
users (uuid) {
|
||||||
uuid -> Text,
|
uuid -> Text,
|
||||||
@@ -132,6 +143,7 @@ joinable!(devices -> users (user_uuid));
|
|||||||
joinable!(folders -> users (user_uuid));
|
joinable!(folders -> users (user_uuid));
|
||||||
joinable!(folders_ciphers -> ciphers (cipher_uuid));
|
joinable!(folders_ciphers -> ciphers (cipher_uuid));
|
||||||
joinable!(folders_ciphers -> folders (folder_uuid));
|
joinable!(folders_ciphers -> folders (folder_uuid));
|
||||||
|
joinable!(twofactor -> users (user_uuid));
|
||||||
joinable!(users_collections -> collections (collection_uuid));
|
joinable!(users_collections -> collections (collection_uuid));
|
||||||
joinable!(users_collections -> users (user_uuid));
|
joinable!(users_collections -> users (user_uuid));
|
||||||
joinable!(users_organizations -> organizations (org_uuid));
|
joinable!(users_organizations -> organizations (org_uuid));
|
||||||
@@ -146,6 +158,7 @@ allow_tables_to_appear_in_same_query!(
|
|||||||
folders,
|
folders,
|
||||||
folders_ciphers,
|
folders_ciphers,
|
||||||
organizations,
|
organizations,
|
||||||
|
twofactor,
|
||||||
users,
|
users,
|
||||||
users_collections,
|
users_collections,
|
||||||
users_organizations,
|
users_organizations,
|
||||||
|
45
src/main.rs
45
src/main.rs
@@ -19,11 +19,15 @@ extern crate chrono;
|
|||||||
extern crate oath;
|
extern crate oath;
|
||||||
extern crate data_encoding;
|
extern crate data_encoding;
|
||||||
extern crate jsonwebtoken as jwt;
|
extern crate jsonwebtoken as jwt;
|
||||||
|
extern crate u2f;
|
||||||
extern crate dotenv;
|
extern crate dotenv;
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate lazy_static;
|
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;
|
use rocket::Rocket;
|
||||||
|
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
@@ -46,16 +50,25 @@ fn init_rocket() -> Rocket {
|
|||||||
// Embed the migrations from the migrations folder into the application
|
// Embed the migrations from the migrations folder into the application
|
||||||
// This way, the program automatically migrates the database to the latest version
|
// This way, the program automatically migrates the database to the latest version
|
||||||
// https://docs.rs/diesel_migrations/*/diesel_migrations/macro.embed_migrations.html
|
// https://docs.rs/diesel_migrations/*/diesel_migrations/macro.embed_migrations.html
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
mod migrations {
|
||||||
embed_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() {
|
fn main() {
|
||||||
check_db();
|
check_db();
|
||||||
check_rsa_keys();
|
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();
|
init_rocket().launch();
|
||||||
}
|
}
|
||||||
@@ -118,6 +131,10 @@ fn check_rsa_keys() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn check_web_vault() {
|
fn check_web_vault() {
|
||||||
|
if !CONFIG.web_vault_enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let index_path = Path::new(&CONFIG.web_vault_folder).join("index.html");
|
let index_path = Path::new(&CONFIG.web_vault_folder).join("index.html");
|
||||||
|
|
||||||
if !index_path.exists() {
|
if !index_path.exists() {
|
||||||
@@ -142,9 +159,13 @@ pub struct Config {
|
|||||||
public_rsa_key: String,
|
public_rsa_key: String,
|
||||||
|
|
||||||
web_vault_folder: String,
|
web_vault_folder: String,
|
||||||
|
web_vault_enabled: bool,
|
||||||
|
|
||||||
|
local_icon_extractor: bool,
|
||||||
signups_allowed: bool,
|
signups_allowed: bool,
|
||||||
password_iterations: i32,
|
password_iterations: i32,
|
||||||
|
domain: String,
|
||||||
|
domain_set: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -152,21 +173,27 @@ impl Config {
|
|||||||
dotenv::dotenv().ok();
|
dotenv::dotenv().ok();
|
||||||
|
|
||||||
let df = env::var("DATA_FOLDER").unwrap_or("data".into());
|
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 {
|
Config {
|
||||||
database_url: env::var("DATABASE_URL").unwrap_or(format!("{}/{}", &df, "db.sqlite3")),
|
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")),
|
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")),
|
attachments_folder: env::var("ATTACHMENTS_FOLDER").unwrap_or(format!("{}/{}", &df, "attachments")),
|
||||||
|
|
||||||
private_rsa_key: format!("{}/{}.der", &df, &key),
|
private_rsa_key: format!("{}.der", &key),
|
||||||
private_rsa_key_pem: format!("{}/{}.pem", &df, &key),
|
private_rsa_key_pem: format!("{}.pem", &key),
|
||||||
public_rsa_key: format!("{}/{}.pub.der", &df, &key),
|
public_rsa_key: format!("{}.pub.der", &key),
|
||||||
|
|
||||||
web_vault_folder: env::var("WEB_VAULT_FOLDER").unwrap_or("web-vault/".into()),
|
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),
|
password_iterations: util::parse_option_string(env::var("PASSWORD_ITERATIONS").ok()).unwrap_or(100_000),
|
||||||
|
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_export]
|
||||||
macro_rules! err {
|
macro_rules! err {
|
||||||
($err:expr, $err_desc:expr, $msg:expr) => {
|
($err:expr, $err_desc:expr, $msg:expr) => {{
|
||||||
|
println!("ERROR: {}", $msg);
|
||||||
err_json!(json!({
|
err_json!(json!({
|
||||||
"error": $err,
|
"error": $err,
|
||||||
"error_description": $err_desc,
|
"error_description": $err_desc,
|
||||||
@@ -13,14 +14,13 @@ macro_rules! err {
|
|||||||
"Object": "error"
|
"Object": "error"
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
};
|
}};
|
||||||
($msg:expr) => { err!("default_error", "default_error_description", $msg) }
|
($msg:expr) => { err!("default_error", "default_error_description", $msg) }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! err_json {
|
macro_rules! err_json {
|
||||||
($expr:expr) => {{
|
($expr:expr) => {{
|
||||||
println!("ERROR: {}", $expr);
|
|
||||||
return Err($crate::rocket::response::status::BadRequest(Some($crate::rocket_contrib::Json($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 {
|
pub fn get_display_size(size: i32) -> String {
|
||||||
let mut size = size as f64;
|
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;
|
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 {
|
pub fn format_date(date: &NaiveDateTime) -> String {
|
||||||
date.format(DATETIME_FORMAT).to_string()
|
date.format(DATETIME_FORMAT).to_string()
|
||||||
@@ -129,20 +129,12 @@ pub fn format_date(date: &NaiveDateTime) -> String {
|
|||||||
/// Deserialization methods
|
/// Deserialization methods
|
||||||
///
|
///
|
||||||
|
|
||||||
use std::collections::BTreeMap as Map;
|
use std::fmt;
|
||||||
|
|
||||||
use serde::de::{self, Deserialize, DeserializeOwned, Deserializer};
|
use serde::de::{self, DeserializeOwned, Deserializer, MapAccess, SeqAccess, Visitor};
|
||||||
use serde_json::Value;
|
use serde_json::{self, Value};
|
||||||
|
|
||||||
/// https://github.com/serde-rs/serde/issues/586
|
pub type JsonMap = serde_json::Map<String, Value>;
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(PartialEq, Serialize, Deserialize)]
|
#[derive(PartialEq, Serialize, Deserialize)]
|
||||||
pub struct UpCase<T: DeserializeOwned> {
|
pub struct UpCase<T: DeserializeOwned> {
|
||||||
@@ -150,3 +142,76 @@ pub struct UpCase<T: DeserializeOwned> {
|
|||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub data: T,
|
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