mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2024-11-22 10:52:40 +01:00
Improved two factor auth
This commit is contained in:
parent
dc188211d8
commit
b0ee5f6570
@ -0,0 +1 @@
|
|||||||
|
-- This file should undo anything in `up.sql`
|
@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE devices
|
||||||
|
ADD COLUMN
|
||||||
|
twofactor_remember TEXT;
|
@ -96,22 +96,49 @@ pub fn routes() -> Vec<Route> {
|
|||||||
|
|
||||||
use rocket::Route;
|
use rocket::Route;
|
||||||
|
|
||||||
use rocket_contrib::Json;
|
use rocket_contrib::{Json, Value};
|
||||||
|
|
||||||
use db::DbConn;
|
use db::DbConn;
|
||||||
|
use db::models::*;
|
||||||
|
|
||||||
use api::{JsonResult, EmptyResult, JsonUpcase};
|
use api::{JsonResult, EmptyResult, JsonUpcase};
|
||||||
use auth::Headers;
|
use auth::Headers;
|
||||||
|
|
||||||
#[put("/devices/identifier/<uuid>/clear-token")]
|
#[put("/devices/identifier/<uuid>/clear-token", data = "<data>")]
|
||||||
fn clear_device_token(uuid: String, _conn: DbConn) -> JsonResult {
|
fn clear_device_token(uuid: String, data: Json<Value>, headers: Headers, conn: DbConn) -> EmptyResult {
|
||||||
println!("{}", uuid);
|
println!("UUID: {:#?}", uuid);
|
||||||
err!("Not implemented")
|
println!("DATA: {:#?}", data);
|
||||||
|
|
||||||
|
let device = match Device::find_by_uuid(&uuid, &conn) {
|
||||||
|
Some(device) => device,
|
||||||
|
None => err!("Device not found")
|
||||||
|
};
|
||||||
|
|
||||||
|
if device.user_uuid != headers.user.uuid {
|
||||||
|
err!("Device not owned by user")
|
||||||
|
}
|
||||||
|
|
||||||
|
device.delete(&conn);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[put("/devices/identifier/<uuid>/token")]
|
#[put("/devices/identifier/<uuid>/token", data = "<data>")]
|
||||||
fn put_device_token(uuid: String, _conn: DbConn) -> JsonResult {
|
fn put_device_token(uuid: String, data: Json<Value>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||||
println!("{}", uuid);
|
println!("UUID: {:#?}", uuid);
|
||||||
|
println!("DATA: {:#?}", data);
|
||||||
|
|
||||||
|
let device = match Device::find_by_uuid(&uuid, &conn) {
|
||||||
|
Some(device) => device,
|
||||||
|
None => err!("Device not found")
|
||||||
|
};
|
||||||
|
|
||||||
|
if device.user_uuid != headers.user.uuid {
|
||||||
|
err!("Device not owned by user")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: What does this do?
|
||||||
|
|
||||||
err!("Not implemented")
|
err!("Not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -135,7 +135,7 @@ fn activate_authenticator(data: JsonUpcase<EnableTwoFactorData>, headers: Header
|
|||||||
user.totp_secret = Some(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(Some(token)) {
|
if !user.check_totp_code(token) {
|
||||||
err!("Invalid totp code")
|
err!("Invalid totp code")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@ use std::collections::HashMap;
|
|||||||
use rocket::{Route, Outcome};
|
use rocket::{Route, Outcome};
|
||||||
use rocket::request::{self, Request, FromRequest, Form, FormItems, FromForm};
|
use rocket::request::{self, Request, FromRequest, Form, FormItems, FromForm};
|
||||||
|
|
||||||
use rocket_contrib::Json;
|
use rocket_contrib::{Json, Value};
|
||||||
|
|
||||||
use db::DbConn;
|
use db::DbConn;
|
||||||
use db::models::*;
|
use db::models::*;
|
||||||
@ -19,83 +19,25 @@ 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);
|
||||||
|
|
||||||
let mut device = match data.grant_type {
|
match data.grant_type {
|
||||||
GrantType::RefreshToken => {
|
GrantType::RefreshToken =>_refresh_login(data, device_type, conn),
|
||||||
// Extract token
|
GrantType::Password => _password_login(data, device_type, conn)
|
||||||
let token = data.get("refresh_token").unwrap();
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get device by refresh token
|
fn _refresh_login(data: &ConnectData, _device_type: DeviceType, conn: DbConn) -> JsonResult {
|
||||||
match Device::find_by_refresh_token(token, &conn) {
|
// Extract token
|
||||||
Some(device) => device,
|
let token = data.get("refresh_token").unwrap();
|
||||||
None => err!("Invalid refresh token")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
GrantType::Password => {
|
|
||||||
// Validate scope
|
|
||||||
let scope = data.get("scope").unwrap();
|
|
||||||
if scope != "api offline_access" {
|
|
||||||
err!("Scope not supported")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the user
|
// Get device by refresh token
|
||||||
let username = data.get("username").unwrap();
|
let mut device = match Device::find_by_refresh_token(token, &conn) {
|
||||||
let user = match User::find_by_mail(username, &conn) {
|
Some(device) => device,
|
||||||
Some(user) => user,
|
None => err!("Invalid refresh token")
|
||||||
None => err!("Username or password is incorrect. Try again.")
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check password
|
|
||||||
let password = data.get("password").unwrap();
|
|
||||||
if !user.check_valid_password(password) {
|
|
||||||
err!("Username or password is incorrect. Try again.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if totp code is required and the value is correct
|
|
||||||
let totp_code = util::parse_option_string(data.get("twoFactorToken"));
|
|
||||||
|
|
||||||
if !user.check_totp_code(totp_code) {
|
|
||||||
// Return error 400
|
|
||||||
err_json!(json!({
|
|
||||||
"error" : "invalid_grant",
|
|
||||||
"error_description" : "Two factor required.",
|
|
||||||
"TwoFactorProviders" : [ 0 ],
|
|
||||||
"TwoFactorProviders2" : { "0" : null }
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Let's only use the header and ignore the 'devicetype' parameter
|
|
||||||
let device_type_num = device_type.0;
|
|
||||||
|
|
||||||
let (device_id, device_name) = match data.is_device {
|
|
||||||
false => { (format!("web-{}", user.uuid), String::from("web")) }
|
|
||||||
true => {
|
|
||||||
(
|
|
||||||
data.get("deviceidentifier").unwrap().clone(),
|
|
||||||
data.get("devicename").unwrap().clone(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Find device or create new
|
|
||||||
match Device::find_by_uuid(&device_id, &conn) {
|
|
||||||
Some(device) => {
|
|
||||||
// Check if valid device
|
|
||||||
if device.user_uuid != user.uuid {
|
|
||||||
device.delete(&conn);
|
|
||||||
err!("Device is not owned by user")
|
|
||||||
}
|
|
||||||
|
|
||||||
device
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
// Create new device
|
|
||||||
Device::new(device_id, user.uuid, device_name, device_type_num)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// COMMON
|
||||||
let user = User::find_by_uuid(&device.user_uuid, &conn).unwrap();
|
let user = User::find_by_uuid(&device.user_uuid, &conn).unwrap();
|
||||||
let orgs = UserOrganization::find_by_user(&user.uuid, &conn);
|
let orgs = UserOrganization::find_by_user(&user.uuid, &conn);
|
||||||
|
|
||||||
@ -108,10 +50,162 @@ fn login(connect_data: Form<ConnectData>, device_type: DeviceType, conn: DbConn)
|
|||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
"refresh_token": device.refresh_token,
|
"refresh_token": device.refresh_token,
|
||||||
"Key": user.key,
|
"Key": user.key,
|
||||||
"PrivateKey": user.private_key
|
"PrivateKey": user.private_key,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn _password_login(data: &ConnectData, device_type: DeviceType, conn: DbConn) -> JsonResult {
|
||||||
|
// Validate scope
|
||||||
|
let scope = data.get("scope").unwrap();
|
||||||
|
if scope != "api offline_access" {
|
||||||
|
err!("Scope not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the user
|
||||||
|
let username = data.get("username").unwrap();
|
||||||
|
let user = match User::find_by_mail(username, &conn) {
|
||||||
|
Some(user) => user,
|
||||||
|
None => err!("Username or password is incorrect. Try again.")
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check password
|
||||||
|
let password = data.get("password").unwrap();
|
||||||
|
if !user.check_valid_password(password) {
|
||||||
|
err!("Username or password is incorrect. Try again.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let's only use the header and ignore the 'devicetype' parameter
|
||||||
|
let device_type_num = device_type.0;
|
||||||
|
|
||||||
|
let (device_id, device_name) = match data.is_device {
|
||||||
|
false => { (format!("web-{}", user.uuid), String::from("web")) }
|
||||||
|
true => {
|
||||||
|
(
|
||||||
|
data.get("deviceidentifier").unwrap().clone(),
|
||||||
|
data.get("devicename").unwrap().clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find device or create new
|
||||||
|
let mut device = match Device::find_by_uuid(&device_id, &conn) {
|
||||||
|
Some(device) => {
|
||||||
|
// Check if valid device
|
||||||
|
if device.user_uuid != user.uuid {
|
||||||
|
device.delete(&conn);
|
||||||
|
err!("Device is not owned by user")
|
||||||
|
}
|
||||||
|
|
||||||
|
device
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Create new device
|
||||||
|
Device::new(device_id, user.uuid.clone(), device_name, device_type_num)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let twofactor_token = if user.requires_twofactor() {
|
||||||
|
let twofactor_provider = util::parse_option_string(data.get("twoFactorProvider")).unwrap_or(0);
|
||||||
|
let twofactor_code = match data.get("twoFactorToken") {
|
||||||
|
Some(code) => code,
|
||||||
|
None => err_json!(_json_err_twofactor())
|
||||||
|
};
|
||||||
|
|
||||||
|
match twofactor_provider {
|
||||||
|
0 /* TOTP */ => {
|
||||||
|
let totp_code: u64 = match twofactor_code.parse() {
|
||||||
|
Ok(code) => code,
|
||||||
|
Err(_) => err!("Invalid Totp code")
|
||||||
|
};
|
||||||
|
|
||||||
|
if !user.check_totp_code(totp_code) {
|
||||||
|
err_json!(_json_err_twofactor())
|
||||||
|
}
|
||||||
|
|
||||||
|
if util::parse_option_string(data.get("twoFactorRemember")).unwrap_or(0) == 1 {
|
||||||
|
device.refresh_twofactor_remember();
|
||||||
|
device.twofactor_remember.clone()
|
||||||
|
} else {
|
||||||
|
device.delete_twofactor_remember();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
5 /* Remember */ => {
|
||||||
|
match device.twofactor_remember {
|
||||||
|
Some(ref remember) if remember == twofactor_code => (),
|
||||||
|
_ => err_json!(_json_err_twofactor())
|
||||||
|
};
|
||||||
|
None // No twofactor token needed here
|
||||||
|
},
|
||||||
|
_ => err!("Invalid two factor provider"),
|
||||||
|
}
|
||||||
|
} else { None }; // No twofactor token if twofactor is disabled
|
||||||
|
|
||||||
|
// Common
|
||||||
|
let user = User::find_by_uuid(&device.user_uuid, &conn).unwrap();
|
||||||
|
let orgs = UserOrganization::find_by_user(&user.uuid, &conn);
|
||||||
|
|
||||||
|
let (access_token, expires_in) = device.refresh_tokens(&user, orgs);
|
||||||
|
device.save(&conn);
|
||||||
|
|
||||||
|
let mut result = json!({
|
||||||
|
"access_token": access_token,
|
||||||
|
"expires_in": expires_in,
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"refresh_token": device.refresh_token,
|
||||||
|
"Key": user.key,
|
||||||
|
"PrivateKey": user.private_key,
|
||||||
|
//"TwoFactorToken": "11122233333444555666777888999"
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(token) = twofactor_token {
|
||||||
|
result["TwoFactorToken"] = Value::String(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn _json_err_twofactor() -> Value {
|
||||||
|
json!({
|
||||||
|
"error" : "invalid_grant",
|
||||||
|
"error_description" : "Two factor required.",
|
||||||
|
"TwoFactorProviders" : [ 0 ],
|
||||||
|
"TwoFactorProviders2" : { "0" : null }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
ConnectData {
|
||||||
|
grant_type: Password,
|
||||||
|
is_device: false,
|
||||||
|
data: {
|
||||||
|
"scope": "api offline_access",
|
||||||
|
"client_id": "web",
|
||||||
|
"grant_type": "password",
|
||||||
|
"username": "dani@mail",
|
||||||
|
"password": "8IuV1sJ94tPjyYIK+E+PTjblzjm4W6C4N5wqM0KKsSg="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURNS "TwoFactorToken": "11122233333444555666777888999"
|
||||||
|
|
||||||
|
Next login
|
||||||
|
ConnectData {
|
||||||
|
grant_type: Password,
|
||||||
|
is_device: false,
|
||||||
|
data: {
|
||||||
|
"scope": "api offline_access",
|
||||||
|
"username": "dani@mail",
|
||||||
|
"client_id": "web",
|
||||||
|
"twofactorprovider": "5",
|
||||||
|
"twofactortoken": "11122233333444555666777888999",
|
||||||
|
"grant_type": "password",
|
||||||
|
"twofactorremember": "0",
|
||||||
|
"password": "8IuV1sJ94tPjyYIK+E+PTjblzjm4W6C4N5wqM0KKsSg="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
struct DeviceType(i32);
|
struct DeviceType(i32);
|
||||||
|
|
||||||
|
@ -19,6 +19,8 @@ pub struct Device {
|
|||||||
pub push_token: Option<String>,
|
pub push_token: Option<String>,
|
||||||
|
|
||||||
pub refresh_token: String,
|
pub refresh_token: String,
|
||||||
|
|
||||||
|
pub twofactor_remember: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Local methods
|
/// Local methods
|
||||||
@ -37,9 +39,22 @@ impl Device {
|
|||||||
|
|
||||||
push_token: None,
|
push_token: None,
|
||||||
refresh_token: String::new(),
|
refresh_token: String::new(),
|
||||||
|
twofactor_remember: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn refresh_twofactor_remember(&mut self) {
|
||||||
|
use data_encoding::BASE64;
|
||||||
|
use crypto;
|
||||||
|
|
||||||
|
self.twofactor_remember = Some(BASE64.encode(&crypto::get_random(vec![0u8; 180])));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_twofactor_remember(&mut self) {
|
||||||
|
self.twofactor_remember = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn refresh_tokens(&mut self, user: &super::User, orgs: Vec<super::UserOrganization>) -> (String, i64) {
|
pub fn refresh_tokens(&mut self, user: &super::User, orgs: Vec<super::UserOrganization>) -> (String, i64) {
|
||||||
// If there is no refresh token, we create one
|
// If there is no refresh token, we create one
|
||||||
if self.refresh_token.is_empty() {
|
if self.refresh_token.is_empty() {
|
||||||
|
@ -26,8 +26,10 @@ pub struct User {
|
|||||||
pub key: String,
|
pub key: String,
|
||||||
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>,
|
pub totp_secret: Option<String>,
|
||||||
pub totp_recover: Option<String>,
|
pub totp_recover: Option<String>,
|
||||||
|
|
||||||
pub security_stamp: String,
|
pub security_stamp: String,
|
||||||
|
|
||||||
pub equivalent_domains: String,
|
pub equivalent_domains: String,
|
||||||
@ -61,6 +63,7 @@ impl User {
|
|||||||
password_hint: None,
|
password_hint: None,
|
||||||
private_key: None,
|
private_key: None,
|
||||||
public_key: None,
|
public_key: None,
|
||||||
|
|
||||||
totp_secret: None,
|
totp_secret: None,
|
||||||
totp_recover: None,
|
totp_recover: None,
|
||||||
|
|
||||||
@ -95,23 +98,23 @@ impl User {
|
|||||||
self.security_stamp = Uuid::new_v4().to_string();
|
self.security_stamp = Uuid::new_v4().to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn check_totp_code(&self, totp_code: Option<u64>) -> bool {
|
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 {
|
if let Some(ref totp_secret) = self.totp_secret {
|
||||||
if let Some(code) = totp_code {
|
// Validate totp
|
||||||
// Validate totp
|
use data_encoding::BASE32;
|
||||||
use data_encoding::BASE32;
|
use oath::{totp_raw_now, HashType};
|
||||||
use oath::{totp_raw_now, HashType};
|
|
||||||
|
|
||||||
let decoded_secret = match BASE32.decode(totp_secret.as_bytes()) {
|
let decoded_secret = match BASE32.decode(totp_secret.as_bytes()) {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(_) => return false
|
Err(_) => return false
|
||||||
};
|
};
|
||||||
|
|
||||||
let generated = totp_raw_now(&decoded_secret, 6, 0, 30, &HashType::SHA1);
|
let generated = totp_raw_now(&decoded_secret, 6, 0, 30, &HashType::SHA1);
|
||||||
generated == code
|
generated == totp_code
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
@ -24,6 +24,13 @@ table! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
table! {
|
||||||
|
ciphers_collections (cipher_uuid, collection_uuid) {
|
||||||
|
cipher_uuid -> Text,
|
||||||
|
collection_uuid -> Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
table! {
|
table! {
|
||||||
collections (uuid) {
|
collections (uuid) {
|
||||||
uuid -> Text,
|
uuid -> Text,
|
||||||
@ -43,6 +50,7 @@ table! {
|
|||||||
type_ -> Integer,
|
type_ -> Integer,
|
||||||
push_token -> Nullable<Text>,
|
push_token -> Nullable<Text>,
|
||||||
refresh_token -> Text,
|
refresh_token -> Text,
|
||||||
|
twofactor_remember -> Nullable<Text>,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,13 +109,6 @@ table! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
table! {
|
|
||||||
ciphers_collections (cipher_uuid, collection_uuid) {
|
|
||||||
cipher_uuid -> Text,
|
|
||||||
collection_uuid -> Text,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
table! {
|
table! {
|
||||||
users_organizations (uuid) {
|
users_organizations (uuid) {
|
||||||
uuid -> Text,
|
uuid -> Text,
|
||||||
@ -124,6 +125,8 @@ table! {
|
|||||||
joinable!(attachments -> ciphers (cipher_uuid));
|
joinable!(attachments -> ciphers (cipher_uuid));
|
||||||
joinable!(ciphers -> organizations (organization_uuid));
|
joinable!(ciphers -> organizations (organization_uuid));
|
||||||
joinable!(ciphers -> users (user_uuid));
|
joinable!(ciphers -> users (user_uuid));
|
||||||
|
joinable!(ciphers_collections -> ciphers (cipher_uuid));
|
||||||
|
joinable!(ciphers_collections -> collections (collection_uuid));
|
||||||
joinable!(collections -> organizations (org_uuid));
|
joinable!(collections -> organizations (org_uuid));
|
||||||
joinable!(devices -> users (user_uuid));
|
joinable!(devices -> users (user_uuid));
|
||||||
joinable!(folders -> users (user_uuid));
|
joinable!(folders -> users (user_uuid));
|
||||||
@ -131,14 +134,13 @@ joinable!(folders_ciphers -> ciphers (cipher_uuid));
|
|||||||
joinable!(folders_ciphers -> folders (folder_uuid));
|
joinable!(folders_ciphers -> folders (folder_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!(ciphers_collections -> collections (collection_uuid));
|
|
||||||
joinable!(ciphers_collections -> ciphers (cipher_uuid));
|
|
||||||
joinable!(users_organizations -> organizations (org_uuid));
|
joinable!(users_organizations -> organizations (org_uuid));
|
||||||
joinable!(users_organizations -> users (user_uuid));
|
joinable!(users_organizations -> users (user_uuid));
|
||||||
|
|
||||||
allow_tables_to_appear_in_same_query!(
|
allow_tables_to_appear_in_same_query!(
|
||||||
attachments,
|
attachments,
|
||||||
ciphers,
|
ciphers,
|
||||||
|
ciphers_collections,
|
||||||
collections,
|
collections,
|
||||||
devices,
|
devices,
|
||||||
folders,
|
folders,
|
||||||
@ -146,6 +148,5 @@ allow_tables_to_appear_in_same_query!(
|
|||||||
organizations,
|
organizations,
|
||||||
users,
|
users,
|
||||||
users_collections,
|
users_collections,
|
||||||
ciphers_collections,
|
|
||||||
users_organizations,
|
users_organizations,
|
||||||
);
|
);
|
||||||
|
Loading…
Reference in New Issue
Block a user