Spiegel von
https://github.com/dani-garcia/vaultwarden.git
synchronisiert 2024-11-05 02:28:00 +01:00
Add SSO functionality using OpenID Connect
Co-authored-by: Pablo Ovelleiro Corral <mail@pablo.tools> Co-authored-by: Stuart Heap <sheap13@gmail.com> Co-authored-by: Alex Moore <skiepp@my-dockerfarm.cloud> Co-authored-by: Brian Munro <brian.alexander.munro@gmail.com> Co-authored-by: Jacques B. <timshel@github.com>
Dieser Commit ist enthalten in:
Ursprung
16bd7af1ff
Commit
f472af1450
24 geänderte Dateien mit 704 neuen und 15 gelöschten Zeilen
|
@ -409,6 +409,20 @@
|
|||
## KNOW WHAT YOU ARE DOING!
|
||||
# ORG_GROUPS_ENABLED=false
|
||||
|
||||
#####################################
|
||||
### SSO settings (OpenID Connect) ###
|
||||
#####################################
|
||||
|
||||
## Controls whether users can login using an OpenID Connect identity provider
|
||||
# SSO_ENABLED=true
|
||||
## Prevent users from logging in directly without going through SSO
|
||||
# SSO_ONLY=false
|
||||
## Base URL of the OIDC server (auto-discovery is used)
|
||||
# SSO_AUTHORITY=https://auth.example.com
|
||||
## Set your Client ID and Client Key
|
||||
# SSO_CLIENT_ID=11111
|
||||
# SSO_CLIENT_SECRET=AAAAAAAAAAAAAAAAAAAAAAAA
|
||||
|
||||
########################
|
||||
### MFA/2FA settings ###
|
||||
########################
|
||||
|
|
|
@ -149,6 +149,9 @@ pico-args = "0.5.0"
|
|||
paste = "1.0.15"
|
||||
governor = "0.6.3"
|
||||
|
||||
# OIDC for SSO
|
||||
openidconnect = "3.4.0"
|
||||
|
||||
# Check client versions for specific features.
|
||||
semver = "1.0.23"
|
||||
|
||||
|
|
1
migrations/mysql/2023-02-01-133000_add_sso/down.sql
Normale Datei
1
migrations/mysql/2023-02-01-133000_add_sso/down.sql
Normale Datei
|
@ -0,0 +1 @@
|
|||
DROP TABLE sso_nonce;
|
3
migrations/mysql/2023-02-01-133000_add_sso/up.sql
Normale Datei
3
migrations/mysql/2023-02-01-133000_add_sso/up.sql
Normale Datei
|
@ -0,0 +1,3 @@
|
|||
CREATE TABLE sso_nonce (
|
||||
nonce CHAR(36) NOT NULL PRIMARY KEY
|
||||
);
|
1
migrations/postgresql/2023-02-01-133000_add_sso/down.sql
Normale Datei
1
migrations/postgresql/2023-02-01-133000_add_sso/down.sql
Normale Datei
|
@ -0,0 +1 @@
|
|||
DROP TABLE sso_nonce;
|
3
migrations/postgresql/2023-02-01-133000_add_sso/up.sql
Normale Datei
3
migrations/postgresql/2023-02-01-133000_add_sso/up.sql
Normale Datei
|
@ -0,0 +1,3 @@
|
|||
CREATE TABLE sso_nonce (
|
||||
nonce CHAR(36) NOT NULL PRIMARY KEY
|
||||
);
|
1
migrations/sqlite/2023-02-01-133000_add_sso/down.sql
Normale Datei
1
migrations/sqlite/2023-02-01-133000_add_sso/down.sql
Normale Datei
|
@ -0,0 +1 @@
|
|||
DROP TABLE sso_nonce;
|
3
migrations/sqlite/2023-02-01-133000_add_sso/up.sql
Normale Datei
3
migrations/sqlite/2023-02-01-133000_add_sso/up.sql
Normale Datei
|
@ -0,0 +1,3 @@
|
|||
CREATE TABLE sso_nonce (
|
||||
nonce CHAR(36) NOT NULL PRIMARY KEY
|
||||
);
|
|
@ -31,6 +31,7 @@ pub fn routes() -> Vec<rocket::Route> {
|
|||
get_public_keys,
|
||||
post_keys,
|
||||
post_password,
|
||||
post_set_password,
|
||||
post_kdf,
|
||||
post_rotatekey,
|
||||
post_sstamp,
|
||||
|
@ -80,6 +81,21 @@ pub struct RegisterData {
|
|||
organization_user_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SetPasswordData {
|
||||
kdf: Option<i32>,
|
||||
kdf_iterations: Option<i32>,
|
||||
kdf_memory: Option<i32>,
|
||||
kdf_parallelism: Option<i32>,
|
||||
key: String,
|
||||
keys: Option<KeysData>,
|
||||
master_password_hash: String,
|
||||
master_password_hint: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
org_identifier: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct KeysData {
|
||||
|
@ -87,6 +103,13 @@ struct KeysData {
|
|||
public_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct TokenPayload {
|
||||
exp: i64,
|
||||
email: String,
|
||||
nonce: String,
|
||||
}
|
||||
|
||||
/// Trims whitespace from password hints, and converts blank password hints to `None`.
|
||||
fn clean_password_hint(password_hint: &Option<String>) -> Option<String> {
|
||||
match password_hint {
|
||||
|
@ -242,6 +265,50 @@ pub async fn _register(data: Json<RegisterData>, mut conn: DbConn) -> JsonResult
|
|||
})))
|
||||
}
|
||||
|
||||
#[post("/accounts/set-password", data = "<data>")]
|
||||
async fn post_set_password(data: Json<SetPasswordData>, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
let data: SetPasswordData = data.into_inner();
|
||||
let mut user = headers.user;
|
||||
|
||||
// Check against the password hint setting here so if it fails, the user
|
||||
// can retry without losing their invitation below.
|
||||
let password_hint = clean_password_hint(&data.master_password_hash);
|
||||
enforce_password_hint_setting(&password_hint)?;
|
||||
|
||||
if let Some(client_kdf_iter) = data.kdf_iterations {
|
||||
user.client_kdf_iter = client_kdf_iter;
|
||||
}
|
||||
|
||||
if let Some(client_kdf_type) = data.kdf {
|
||||
user.client_kdf_type = client_kdf_type;
|
||||
}
|
||||
|
||||
// We need to allow revision-date to use the old security_timestamp
|
||||
let routes = ["revision_date"];
|
||||
let routes: Option<Vec<String>> = Some(routes.iter().map(ToString::to_string).collect());
|
||||
|
||||
user.client_kdf_memory = data.kdf_memory;
|
||||
user.client_kdf_parallelism = data.kdf_parallelism;
|
||||
|
||||
user.set_password(&data.master_password_hash, Some(data.key), false, routes);
|
||||
user.password_hint = password_hint;
|
||||
|
||||
if let Some(keys) = data.keys {
|
||||
user.private_key = Some(keys.encrypted_private_key);
|
||||
user.public_key = Some(keys.public_key);
|
||||
}
|
||||
|
||||
if CONFIG.mail_enabled() {
|
||||
mail::send_set_password(&user.email.to_lowercase(), &user.name).await?;
|
||||
}
|
||||
|
||||
user.save(&mut conn).await?;
|
||||
Ok(Json(json!({
|
||||
"Object": "set-password",
|
||||
"CaptchaBypassToken": "",
|
||||
})))
|
||||
}
|
||||
|
||||
#[get("/accounts/profile")]
|
||||
async fn profile(headers: Headers, mut conn: DbConn) -> Json<Value> {
|
||||
Json(headers.user.to_json(&mut conn).await)
|
||||
|
@ -922,7 +989,7 @@ struct SecretVerificationRequest {
|
|||
}
|
||||
|
||||
#[post("/accounts/verify-password", data = "<data>")]
|
||||
fn verify_password(data: Json<SecretVerificationRequest>, headers: Headers) -> EmptyResult {
|
||||
fn verify_password(data: Json<SecretVerificationRequest>, headers: Headers) -> JsonResult {
|
||||
let data: SecretVerificationRequest = data.into_inner();
|
||||
let user = headers.user;
|
||||
|
||||
|
@ -930,7 +997,9 @@ fn verify_password(data: Json<SecretVerificationRequest>, headers: Headers) -> E
|
|||
err!("Invalid password")
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(Json(json!({
|
||||
"MasterPasswordPolicy": {}, // Required for SSO login with mobile apps
|
||||
})))
|
||||
}
|
||||
|
||||
async fn _api_key(data: Json<PasswordOrOtpData>, rotate: bool, headers: Headers, mut conn: DbConn) -> JsonResult {
|
||||
|
|
|
@ -40,6 +40,7 @@ pub fn routes() -> Vec<Route> {
|
|||
post_organization_collection_delete,
|
||||
bulk_delete_organization_collections,
|
||||
get_org_details,
|
||||
get_org_domain_sso_details,
|
||||
get_org_users,
|
||||
send_invite,
|
||||
reinvite_user,
|
||||
|
@ -56,6 +57,7 @@ pub fn routes() -> Vec<Route> {
|
|||
post_org_import,
|
||||
list_policies,
|
||||
list_policies_token,
|
||||
list_policies_invited_user,
|
||||
get_policy,
|
||||
put_policy,
|
||||
get_organization_tax,
|
||||
|
@ -96,6 +98,7 @@ pub fn routes() -> Vec<Route> {
|
|||
get_org_export,
|
||||
api_key,
|
||||
rotate_api_key,
|
||||
get_auto_enroll_status,
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -302,6 +305,13 @@ async fn get_user_collections(headers: Headers, mut conn: DbConn) -> Json<Value>
|
|||
}))
|
||||
}
|
||||
|
||||
#[get("/organizations/<_identifier>/auto-enroll-status")]
|
||||
fn get_auto_enroll_status(_identifier: String) -> JsonResult {
|
||||
Ok(Json(json!({
|
||||
"ResetPasswordEnabled": false, // Not implemented
|
||||
})))
|
||||
}
|
||||
|
||||
#[get("/organizations/<org_id>/collections")]
|
||||
async fn get_org_collections(org_id: &str, _headers: ManagerHeadersLoose, mut conn: DbConn) -> Json<Value> {
|
||||
Json(json!({
|
||||
|
@ -769,6 +779,14 @@ async fn _get_org_details(org_id: &str, host: &str, user_uuid: &str, conn: &mut
|
|||
json!(ciphers_json)
|
||||
}
|
||||
|
||||
#[post("/organizations/domain/sso/details")]
|
||||
fn get_org_domain_sso_details() -> JsonResult {
|
||||
Ok(Json(json!({
|
||||
"organizationIdentifier": "vaultwarden",
|
||||
"ssoAvailable": CONFIG.sso_enabled()
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(FromForm)]
|
||||
struct GetOrgUserData {
|
||||
#[field(name = "includeCollections")]
|
||||
|
@ -1664,6 +1682,25 @@ async fn list_policies_token(org_id: &str, token: &str, mut conn: DbConn) -> Jso
|
|||
})))
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
#[get("/organizations/<org_id>/policies/invited-user?<userId>")]
|
||||
async fn list_policies_invited_user(org_id: String, userId: String, mut conn: DbConn) -> JsonResult {
|
||||
// We should confirm the user is part of the organization, but unique domain_hints must be supported first.
|
||||
|
||||
if userId.is_empty() {
|
||||
err!("userId must not be empty");
|
||||
}
|
||||
|
||||
let policies = OrgPolicy::find_by_org(&org_id, &mut conn).await;
|
||||
let policies_json: Vec<Value> = policies.iter().map(OrgPolicy::to_json).collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"Data": policies_json,
|
||||
"Object": "list",
|
||||
"ContinuationToken": null
|
||||
})))
|
||||
}
|
||||
|
||||
#[get("/organizations/<org_id>/policies/<pol_type>")]
|
||||
async fn get_policy(org_id: &str, pol_type: i32, _headers: AdminHeaders, mut conn: DbConn) -> JsonResult {
|
||||
let pol_type_enum = match OrgPolicyType::from_i32(pol_type) {
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
use chrono::Utc;
|
||||
use jsonwebtoken::DecodingKey;
|
||||
use num_traits::FromPrimitive;
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::{
|
||||
form::{Form, FromForm},
|
||||
http::CookieJar,
|
||||
Route,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
@ -17,14 +19,16 @@ use crate::{
|
|||
push::register_push_device,
|
||||
ApiResult, EmptyResult, JsonResult,
|
||||
},
|
||||
auth::{generate_organization_api_key_login_claims, ClientHeaders, ClientIp},
|
||||
auth::{encode_jwt, generate_organization_api_key_login_claims, generate_ssotoken_claims, ClientHeaders, ClientIp},
|
||||
db::{models::*, DbConn},
|
||||
error::MapResult,
|
||||
mail, util, CONFIG,
|
||||
mail, util,
|
||||
util::{CookieManager, CustomRedirect},
|
||||
CONFIG,
|
||||
};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![login, prelogin, identity_register]
|
||||
routes![login, prelogin, identity_register, prevalidate, authorize, oidcsignin]
|
||||
}
|
||||
|
||||
#[post("/connect/token", data = "<data>")]
|
||||
|
@ -61,6 +65,15 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, mut conn:
|
|||
|
||||
_api_key_login(data, &mut user_uuid, &mut conn, &client_header.ip).await
|
||||
}
|
||||
"authorization_code" => {
|
||||
_check_is_some(&data.client_id, "client_id cannot be blank")?;
|
||||
_check_is_some(&data.code, "code cannot be blank")?;
|
||||
|
||||
_check_is_some(&data.device_identifier, "device_identifier cannot be blank")?;
|
||||
_check_is_some(&data.device_name, "device_name cannot be blank")?;
|
||||
_check_is_some(&data.device_type, "device_type cannot be blank")?;
|
||||
_authorization_login(data, &mut user_uuid, &mut conn, &client_header.ip).await
|
||||
}
|
||||
t => err!("Invalid type", t),
|
||||
};
|
||||
|
||||
|
@ -135,6 +148,141 @@ async fn _refresh_login(data: ConnectData, conn: &mut DbConn) -> JsonResult {
|
|||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct TokenPayload {
|
||||
exp: i64,
|
||||
email: Option<String>,
|
||||
nonce: String,
|
||||
}
|
||||
|
||||
async fn _authorization_login(
|
||||
data: ConnectData,
|
||||
user_uuid: &mut Option<String>,
|
||||
conn: &mut DbConn,
|
||||
ip: &ClientIp,
|
||||
) -> JsonResult {
|
||||
let scope = match data.scope.as_ref() {
|
||||
None => err!("Got no scope in OIDC data"),
|
||||
Some(scope) => scope,
|
||||
};
|
||||
if scope != "api offline_access" {
|
||||
err!("Scope not supported")
|
||||
}
|
||||
|
||||
let scope_vec = vec!["api".into(), "offline_access".into()];
|
||||
let code = match data.code.as_ref() {
|
||||
None => err!("Got no code in OIDC data"),
|
||||
Some(code) => code,
|
||||
};
|
||||
|
||||
let (refresh_token, id_token, user_info) = match get_auth_code_access_token(code).await {
|
||||
Ok((refresh_token, id_token, user_info)) => (refresh_token, id_token, user_info),
|
||||
Err(_err) => err!("Could not retrieve access token"),
|
||||
};
|
||||
|
||||
let mut validation = jsonwebtoken::Validation::default();
|
||||
validation.insecure_disable_signature_validation();
|
||||
|
||||
let token =
|
||||
match jsonwebtoken::decode::<TokenPayload>(id_token.as_str(), &DecodingKey::from_secret(&[]), &validation) {
|
||||
Err(_err) => err!("Could not decode id token"),
|
||||
Ok(payload) => payload.claims,
|
||||
};
|
||||
|
||||
// let expiry = token.exp;
|
||||
let nonce = token.nonce;
|
||||
let mut new_user = false;
|
||||
|
||||
match SsoNonce::find(&nonce, conn).await {
|
||||
Some(sso_nonce) => {
|
||||
match sso_nonce.delete(conn).await {
|
||||
Ok(_) => {
|
||||
let user_email = match token.email {
|
||||
Some(email) => email,
|
||||
None => match user_info.email() {
|
||||
None => err!("Neither id token nor userinfo contained an email"),
|
||||
Some(email) => email.to_owned().to_string(),
|
||||
},
|
||||
};
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
let mut user = match User::find_by_mail(&user_email, conn).await {
|
||||
Some(user) => user,
|
||||
None => {
|
||||
new_user = true;
|
||||
User::new(user_email.clone())
|
||||
}
|
||||
};
|
||||
|
||||
if new_user {
|
||||
user.verified_at = Some(Utc::now().naive_utc());
|
||||
user.save(conn).await?;
|
||||
}
|
||||
|
||||
// Set the user_uuid here to be passed back used for event logging.
|
||||
*user_uuid = Some(user.uuid.clone());
|
||||
|
||||
let (mut device, new_device) = get_device(&data, conn, &user).await;
|
||||
|
||||
let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, true, conn).await?;
|
||||
|
||||
if CONFIG.mail_enabled() && new_device {
|
||||
if let Err(e) =
|
||||
mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device.name).await
|
||||
{
|
||||
error!("Error sending new device email: {:#?}", e);
|
||||
|
||||
if CONFIG.require_device_email() {
|
||||
err!("Could not send login notification email. Please contact your administrator.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if CONFIG.sso_acceptall_invites() {
|
||||
for user_org in UserOrganization::find_invited_by_user(&user.uuid, conn).await.iter_mut() {
|
||||
user_org.status = UserOrgStatus::Accepted as i32;
|
||||
user_org.save(conn).await?;
|
||||
}
|
||||
}
|
||||
|
||||
device.refresh_token = refresh_token.clone();
|
||||
device.save(conn).await?;
|
||||
|
||||
let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec);
|
||||
device.save(conn).await?;
|
||||
|
||||
let mut result = json!({
|
||||
"access_token": access_token,
|
||||
"token_type": "Bearer",
|
||||
"refresh_token": device.refresh_token,
|
||||
"expires_in": expires_in,
|
||||
"Key": user.akey,
|
||||
"PrivateKey": user.private_key,
|
||||
"Kdf": user.client_kdf_type,
|
||||
"KdfIterations": user.client_kdf_iter,
|
||||
"KdfMemory": user.client_kdf_memory,
|
||||
"KdfParallelism": user.client_kdf_parallelism,
|
||||
"ResetMasterPassword": user.password_hash.is_empty(),
|
||||
"scope": scope,
|
||||
"unofficialServer": true,
|
||||
});
|
||||
|
||||
if let Some(token) = twofactor_token {
|
||||
result["TwoFactorToken"] = Value::String(token);
|
||||
}
|
||||
|
||||
info!("User {} logged in successfully. IP: {}", user.email, ip.ip);
|
||||
Ok(Json(result))
|
||||
}
|
||||
Err(_) => err!("Failed to delete nonce"),
|
||||
}
|
||||
}
|
||||
None => {
|
||||
err!("Invalid nonce")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn _password_login(
|
||||
data: ConnectData,
|
||||
user_uuid: &mut Option<String>,
|
||||
|
@ -151,6 +299,10 @@ async fn _password_login(
|
|||
// Ratelimit the login
|
||||
crate::ratelimit::check_limit_login(&ip.ip)?;
|
||||
|
||||
if CONFIG.sso_enabled() && CONFIG.sso_only() {
|
||||
err!("SSO sign-in is required");
|
||||
}
|
||||
|
||||
// Get the user
|
||||
let username = data.username.as_ref().unwrap().trim();
|
||||
let mut user = match User::find_by_mail(username, conn).await {
|
||||
|
@ -250,7 +402,7 @@ async fn _password_login(
|
|||
|
||||
let (mut device, new_device) = get_device(&data, conn, &user).await;
|
||||
|
||||
let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, conn).await?;
|
||||
let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, false, conn).await?;
|
||||
|
||||
if CONFIG.mail_enabled() && new_device {
|
||||
if let Err(e) = mail::send_new_device_logged_in(&user.email, &ip.ip.to_string(), &now, &device.name).await {
|
||||
|
@ -485,6 +637,7 @@ async fn twofactor_auth(
|
|||
data: &ConnectData,
|
||||
device: &mut Device,
|
||||
ip: &ClientIp,
|
||||
is_sso: bool,
|
||||
conn: &mut DbConn,
|
||||
) -> ApiResult<Option<String>> {
|
||||
let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await;
|
||||
|
@ -502,7 +655,17 @@ async fn twofactor_auth(
|
|||
|
||||
let twofactor_code = match data.two_factor_token {
|
||||
Some(ref code) => code,
|
||||
None => err_json!(_json_err_twofactor(&twofactor_ids, &user.uuid, conn).await?, "2FA token not provided"),
|
||||
None => {
|
||||
if is_sso {
|
||||
if CONFIG.sso_only() {
|
||||
err!("2FA not supported with SSO login, contact your administrator");
|
||||
} else {
|
||||
err!("2FA not supported with SSO login, log in directly using email and master password");
|
||||
}
|
||||
} else {
|
||||
err_json!(_json_err_twofactor(&twofactor_ids, &user.uuid, conn).await?, "2FA token not provided");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let selected_twofactor = twofactors.into_iter().find(|tf| tf.atype == selected_id && tf.enabled);
|
||||
|
@ -696,11 +859,187 @@ struct ConnectData {
|
|||
two_factor_remember: Option<i32>,
|
||||
#[field(name = uncased("authrequest"))]
|
||||
auth_request: Option<String>,
|
||||
// Needed for authorization code
|
||||
#[form(field = uncased("code"))]
|
||||
code: Option<String>,
|
||||
}
|
||||
|
||||
fn _check_is_some<T>(value: &Option<T>, msg: &str) -> EmptyResult {
|
||||
if value.is_none() {
|
||||
err!(msg)
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[get("/account/prevalidate")]
|
||||
#[allow(non_snake_case)]
|
||||
fn prevalidate() -> JsonResult {
|
||||
let claims = generate_ssotoken_claims();
|
||||
let ssotoken = encode_jwt(&claims);
|
||||
Ok(Json(json!({
|
||||
"token": ssotoken,
|
||||
})))
|
||||
}
|
||||
|
||||
use openidconnect::core::{CoreClient, CoreProviderMetadata, CoreResponseType, CoreUserInfoClaims};
|
||||
use openidconnect::reqwest::async_http_client;
|
||||
use openidconnect::{
|
||||
AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, OAuth2TokenResponse,
|
||||
RedirectUrl, Scope,
|
||||
};
|
||||
|
||||
async fn get_client_from_sso_config() -> ApiResult<CoreClient> {
|
||||
let redirect = CONFIG.sso_callback_path();
|
||||
let client_id = ClientId::new(CONFIG.sso_client_id());
|
||||
let client_secret = ClientSecret::new(CONFIG.sso_client_secret());
|
||||
let issuer_url = match IssuerUrl::new(CONFIG.sso_authority()) {
|
||||
Ok(issuer) => issuer,
|
||||
Err(_err) => err!("invalid issuer URL"),
|
||||
};
|
||||
|
||||
let provider_metadata = match CoreProviderMetadata::discover_async(issuer_url, async_http_client).await {
|
||||
Ok(metadata) => metadata,
|
||||
Err(_err) => {
|
||||
err!("Failed to discover OpenID provider")
|
||||
}
|
||||
};
|
||||
|
||||
let redirect_uri = match RedirectUrl::new(redirect) {
|
||||
Ok(uri) => uri,
|
||||
Err(err) => err!("Invalid redirection url: {}", err.to_string()),
|
||||
};
|
||||
let client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret))
|
||||
.set_redirect_uri(redirect_uri);
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
#[get("/connect/oidc-signin?<code>")]
|
||||
fn oidcsignin(code: String, jar: &CookieJar<'_>, _conn: DbConn) -> ApiResult<CustomRedirect> {
|
||||
let cookiemanager = CookieManager::new(jar);
|
||||
|
||||
let redirect_uri = match cookiemanager.get_cookie("redirect_uri".to_string()) {
|
||||
None => err!("No redirect_uri in cookie"),
|
||||
Some(uri) => uri,
|
||||
};
|
||||
let orig_state = match cookiemanager.get_cookie("state".to_string()) {
|
||||
None => err!("No state in cookie"),
|
||||
Some(state) => state,
|
||||
};
|
||||
|
||||
cookiemanager.delete_cookie("redirect_uri".to_string());
|
||||
cookiemanager.delete_cookie("state".to_string());
|
||||
|
||||
let redirect = CustomRedirect {
|
||||
url: format!("{redirect_uri}?code={code}&state={orig_state}"),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
Ok(redirect)
|
||||
}
|
||||
|
||||
#[derive(FromForm)]
|
||||
#[allow(non_snake_case)]
|
||||
struct AuthorizeData {
|
||||
#[allow(unused)]
|
||||
#[field(name = uncased("client_id"))]
|
||||
#[field(name = uncased("clientid"))]
|
||||
client_id: Option<String>,
|
||||
#[field(name = uncased("redirect_uri"))]
|
||||
#[field(name = uncased("redirecturi"))]
|
||||
redirect_uri: Option<String>,
|
||||
#[allow(unused)]
|
||||
#[field(name = uncased("response_type"))]
|
||||
#[field(name = uncased("responsetype"))]
|
||||
response_type: Option<String>,
|
||||
#[allow(unused)]
|
||||
#[field(name = uncased("scope"))]
|
||||
scope: Option<String>,
|
||||
#[field(name = uncased("state"))]
|
||||
state: Option<String>,
|
||||
#[allow(unused)]
|
||||
#[field(name = uncased("code_challenge"))]
|
||||
code_challenge: Option<String>,
|
||||
#[allow(unused)]
|
||||
#[field(name = uncased("code_challenge_method"))]
|
||||
code_challenge_method: Option<String>,
|
||||
#[allow(unused)]
|
||||
#[field(name = uncased("response_mode"))]
|
||||
response_mode: Option<String>,
|
||||
#[allow(unused)]
|
||||
#[field(name = uncased("domain_hint"))]
|
||||
domain_hint: Option<String>,
|
||||
#[allow(unused)]
|
||||
#[field(name = uncased("ssoToken"))]
|
||||
ssoToken: Option<String>,
|
||||
}
|
||||
|
||||
#[get("/connect/authorize?<data..>")]
|
||||
async fn authorize(data: AuthorizeData, jar: &CookieJar<'_>, mut conn: DbConn) -> ApiResult<CustomRedirect> {
|
||||
let cookiemanager = CookieManager::new(jar);
|
||||
match get_client_from_sso_config().await {
|
||||
Ok(client) => {
|
||||
let (auth_url, _csrf_state, nonce) = client
|
||||
.authorize_url(
|
||||
AuthenticationFlow::<CoreResponseType>::AuthorizationCode,
|
||||
CsrfToken::new_random,
|
||||
Nonce::new_random,
|
||||
)
|
||||
.add_scope(Scope::new("email".to_string()))
|
||||
.add_scope(Scope::new("profile".to_string()))
|
||||
.url();
|
||||
|
||||
let sso_nonce = SsoNonce::new(nonce.secret().to_string());
|
||||
sso_nonce.save(&mut conn).await?;
|
||||
|
||||
let redirect_uri = match data.redirect_uri {
|
||||
None => err!("No redirect_uri in data"),
|
||||
Some(uri) => uri,
|
||||
};
|
||||
cookiemanager.set_cookie("redirect_uri".to_string(), redirect_uri);
|
||||
let state = match data.state {
|
||||
None => err!("No state in data"),
|
||||
Some(state) => state,
|
||||
};
|
||||
cookiemanager.set_cookie("state".to_string(), state);
|
||||
|
||||
let redirect = CustomRedirect {
|
||||
url: format!("{}", auth_url),
|
||||
headers: vec![],
|
||||
};
|
||||
|
||||
Ok(redirect)
|
||||
}
|
||||
Err(_err) => err!("Unable to find client from identifier"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_auth_code_access_token(code: &str) -> ApiResult<(String, String, CoreUserInfoClaims)> {
|
||||
let oidc_code = AuthorizationCode::new(String::from(code));
|
||||
match get_client_from_sso_config().await {
|
||||
Ok(client) => match client.exchange_code(oidc_code).request_async(async_http_client).await {
|
||||
Ok(token_response) => {
|
||||
let refresh_token = match token_response.refresh_token() {
|
||||
Some(token) => token.secret().to_string(),
|
||||
None => String::new(),
|
||||
};
|
||||
let id_token = match token_response.extra_fields().id_token() {
|
||||
None => err!("Token response did not contain an id_token"),
|
||||
Some(token) => token.to_string(),
|
||||
};
|
||||
|
||||
let user_info: CoreUserInfoClaims =
|
||||
match client.user_info(token_response.access_token().to_owned(), None) {
|
||||
Err(_err) => err!("Token response did not contain user_info"),
|
||||
Ok(info) => match info.request_async(async_http_client).await {
|
||||
Err(_err) => err!("Request to user_info endpoint failed"),
|
||||
Ok(claim) => claim,
|
||||
},
|
||||
};
|
||||
|
||||
Ok((refresh_token, id_token, user_info))
|
||||
}
|
||||
Err(err) => err!("Failed to contact token endpoint: {}", err.to_string()),
|
||||
},
|
||||
Err(_err) => err!("Unable to find client"),
|
||||
}
|
||||
}
|
||||
|
|
23
src/auth.rs
23
src/auth.rs
|
@ -20,6 +20,7 @@ pub static JWT_LOGIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|login", CON
|
|||
static JWT_INVITE_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|invite", CONFIG.domain_origin()));
|
||||
static JWT_EMERGENCY_ACCESS_INVITE_ISSUER: Lazy<String> =
|
||||
Lazy::new(|| format!("{}|emergencyaccessinvite", CONFIG.domain_origin()));
|
||||
static JWT_SSOTOKEN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|ssotoken", CONFIG.domain_origin()));
|
||||
static JWT_DELETE_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|delete", CONFIG.domain_origin()));
|
||||
static JWT_VERIFYEMAIL_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|verifyemail", CONFIG.domain_origin()));
|
||||
static JWT_ADMIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|admin", CONFIG.domain_origin()));
|
||||
|
@ -317,6 +318,28 @@ pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SsoTokenJwtClaims {
|
||||
// Not before
|
||||
pub nbf: i64,
|
||||
// Expiration time
|
||||
pub exp: i64,
|
||||
// Issuer
|
||||
pub iss: String,
|
||||
// Subject
|
||||
pub sub: String,
|
||||
}
|
||||
|
||||
pub fn generate_ssotoken_claims() -> SsoTokenJwtClaims {
|
||||
let time_now = Utc::now().naive_utc();
|
||||
SsoTokenJwtClaims {
|
||||
nbf: time_now.timestamp(),
|
||||
exp: (time_now + Duration::minutes(2)).timestamp(),
|
||||
iss: JWT_SSOTOKEN_ISSUER.to_string(),
|
||||
sub: "vaultwarden".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate_verify_email_claims(uuid: String) -> BasicJwtClaims {
|
||||
let time_now = Utc::now();
|
||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||
|
|
|
@ -606,6 +606,24 @@ make_config! {
|
|||
org_groups_enabled: bool, false, def, false;
|
||||
},
|
||||
|
||||
/// OpenID Connect SSO settings
|
||||
sso {
|
||||
/// Enabled
|
||||
sso_enabled: bool, true, def, false;
|
||||
/// Force SSO login
|
||||
sso_only: bool, true, def, false;
|
||||
/// Client ID
|
||||
sso_client_id: String, true, def, String::new();
|
||||
/// Client Key
|
||||
sso_client_secret: Pass, true, def, String::new();
|
||||
/// Authority Server
|
||||
sso_authority: String, true, def, String::new();
|
||||
/// CallBack Path
|
||||
sso_callback_path: String, false, gen, |c| generate_sso_callback_path(&c.domain);
|
||||
/// Allow workaround so SSO logins accept all invites
|
||||
sso_acceptall_invites: bool, true, def, false;
|
||||
},
|
||||
|
||||
/// Yubikey settings
|
||||
yubico: _enable_yubico {
|
||||
/// Enabled
|
||||
|
@ -815,6 +833,12 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
|||
err!("All Duo options need to be set for global Duo support")
|
||||
}
|
||||
|
||||
if cfg.sso_enabled
|
||||
&& (cfg.sso_client_id.is_empty() || cfg.sso_client_secret.is_empty() || cfg.sso_authority.is_empty())
|
||||
{
|
||||
err!("`SSO_CLIENT_ID`, `SSO_CLIENT_SECRET` and `SSO_AUTHORITY` must be set for SSO support")
|
||||
}
|
||||
|
||||
if cfg._enable_yubico {
|
||||
if cfg.yubico_client_id.is_some() != cfg.yubico_secret_key.is_some() {
|
||||
err!("Both `YUBICO_CLIENT_ID` and `YUBICO_SECRET_KEY` must be set for Yubikey OTP support")
|
||||
|
@ -1018,6 +1042,10 @@ fn generate_smtp_img_src(embed_images: bool, domain: &str) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
fn generate_sso_callback_path(domain: &str) -> String {
|
||||
format!("{domain}/identity/connect/oidc-signin")
|
||||
}
|
||||
|
||||
/// Generate the correct URL for the icon service.
|
||||
/// This will be used within icons.rs to call the external icon service.
|
||||
fn generate_icon_service_url(icon_service: &str) -> String {
|
||||
|
@ -1305,6 +1333,7 @@ where
|
|||
reg!("email/send_emergency_access_invite", ".html");
|
||||
reg!("email/send_org_invite", ".html");
|
||||
reg!("email/send_single_org_removed_from_org", ".html");
|
||||
reg!("email/set_password", ".html");
|
||||
reg!("email/smtp_test", ".html");
|
||||
reg!("email/twofactor_email", ".html");
|
||||
reg!("email/verify_email", ".html");
|
||||
|
|
|
@ -11,6 +11,7 @@ mod group;
|
|||
mod org_policy;
|
||||
mod organization;
|
||||
mod send;
|
||||
mod sso_nonce;
|
||||
mod two_factor;
|
||||
mod two_factor_incomplete;
|
||||
mod user;
|
||||
|
@ -28,6 +29,7 @@ pub use self::group::{CollectionGroup, Group, GroupUser};
|
|||
pub use self::org_policy::{OrgPolicy, OrgPolicyErr, OrgPolicyType};
|
||||
pub use self::organization::{Organization, OrganizationApiKey, UserOrgStatus, UserOrgType, UserOrganization};
|
||||
pub use self::send::{Send, SendType};
|
||||
pub use self::sso_nonce::SsoNonce;
|
||||
pub use self::two_factor::{TwoFactor, TwoFactorType};
|
||||
pub use self::two_factor_incomplete::TwoFactorIncomplete;
|
||||
pub use self::user::{Invitation, User, UserKdfType, UserStampException};
|
||||
|
|
|
@ -27,7 +27,7 @@ pub enum OrgPolicyType {
|
|||
MasterPassword = 1,
|
||||
PasswordGenerator = 2,
|
||||
SingleOrg = 3,
|
||||
// RequireSso = 4, // Not supported
|
||||
RequireSso = 4,
|
||||
PersonalOwnership = 5,
|
||||
DisableSend = 6,
|
||||
SendOptions = 7,
|
||||
|
|
|
@ -166,9 +166,9 @@ impl Organization {
|
|||
"useGroups": CONFIG.org_groups_enabled(),
|
||||
"useTotp": true,
|
||||
"usePolicies": true,
|
||||
// "useScim": false, // Not supported (Not AGPLv3 Licensed)
|
||||
"useSso": false, // Not supported
|
||||
// "useKeyConnector": false, // Not supported
|
||||
// "UseScim": false, // Not supported (Not AGPLv3 Licensed)
|
||||
"useSso": CONFIG.sso_enabled(),
|
||||
// "UseKeyConnector": false, // Not supported
|
||||
"selfHost": true,
|
||||
"useApi": true,
|
||||
"hasPublicAndPrivateKeys": self.private_key.is_some() && self.public_key.is_some(),
|
||||
|
@ -385,7 +385,7 @@ impl UserOrganization {
|
|||
"resetPasswordEnrolled": self.reset_password_key.is_some(),
|
||||
"useResetPassword": CONFIG.mail_enabled(),
|
||||
"ssoBound": false, // Not supported
|
||||
"useSso": false, // Not supported
|
||||
"useSso": CONFIG.sso_enabled(),
|
||||
"useKeyConnector": false,
|
||||
"useSecretsManager": false,
|
||||
"usePasswordManager": true,
|
||||
|
|
60
src/db/models/sso_nonce.rs
Normale Datei
60
src/db/models/sso_nonce.rs
Normale Datei
|
@ -0,0 +1,60 @@
|
|||
use crate::api::EmptyResult;
|
||||
use crate::db::DbConn;
|
||||
use crate::error::MapResult;
|
||||
|
||||
db_object! {
|
||||
#[derive(Identifiable, Queryable, Insertable)]
|
||||
#[diesel(table_name = sso_nonce)]
|
||||
#[diesel(primary_key(nonce))]
|
||||
pub struct SsoNonce {
|
||||
pub nonce: String,
|
||||
}
|
||||
}
|
||||
|
||||
/// Local methods
|
||||
impl SsoNonce {
|
||||
pub fn new(nonce: String) -> Self {
|
||||
Self {
|
||||
nonce,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Database methods
|
||||
impl SsoNonce {
|
||||
pub async fn save(&self, conn: &mut DbConn) -> EmptyResult {
|
||||
db_run! { conn:
|
||||
sqlite, mysql {
|
||||
diesel::replace_into(sso_nonce::table)
|
||||
.values(SsoNonceDb::to_db(self))
|
||||
.execute(conn)
|
||||
.map_res("Error saving SSO device")
|
||||
}
|
||||
postgresql {
|
||||
let value = SsoNonceDb::to_db(self);
|
||||
diesel::insert_into(sso_nonce::table)
|
||||
.values(&value)
|
||||
.execute(conn)
|
||||
.map_res("Error saving SSO nonce")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete(self, conn: &mut DbConn) -> EmptyResult {
|
||||
db_run! { conn: {
|
||||
diesel::delete(sso_nonce::table.filter(sso_nonce::nonce.eq(self.nonce)))
|
||||
.execute(conn)
|
||||
.map_res("Error deleting SSO nonce")
|
||||
}}
|
||||
}
|
||||
|
||||
pub async fn find(nonce: &str, conn: &mut DbConn) -> Option<Self> {
|
||||
db_run! { conn: {
|
||||
sso_nonce::table
|
||||
.filter(sso_nonce::nonce.eq(nonce))
|
||||
.first::<SsoNonceDb>(conn)
|
||||
.ok()
|
||||
.from_db()
|
||||
}}
|
||||
}
|
||||
}
|
|
@ -243,6 +243,12 @@ table! {
|
|||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
sso_nonce (nonce) {
|
||||
nonce -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
emergency_access (uuid) {
|
||||
uuid -> Text,
|
||||
|
|
|
@ -243,6 +243,12 @@ table! {
|
|||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
sso_nonce (nonce) {
|
||||
nonce -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
emergency_access (uuid) {
|
||||
uuid -> Text,
|
||||
|
|
|
@ -243,6 +243,12 @@ table! {
|
|||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
sso_nonce (nonce) {
|
||||
nonce -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
emergency_access (uuid) {
|
||||
uuid -> Text,
|
||||
|
|
12
src/mail.rs
12
src/mail.rs
|
@ -492,6 +492,18 @@ pub async fn send_change_email(address: &str, token: &str) -> EmptyResult {
|
|||
send_email(address, &subject, body_html, body_text).await
|
||||
}
|
||||
|
||||
pub async fn send_set_password(address: &str, user_name: &str) -> EmptyResult {
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/set_password",
|
||||
json!({
|
||||
"url": CONFIG.domain(),
|
||||
"img_src": CONFIG._smtp_img_src(),
|
||||
"user_name": user_name,
|
||||
}),
|
||||
)?;
|
||||
send_email(address, &subject, body_html, body_text).await
|
||||
}
|
||||
|
||||
pub async fn send_test(address: &str) -> EmptyResult {
|
||||
let (subject, body_html, body_text) = get_text(
|
||||
"email/smtp_test",
|
||||
|
|
6
src/static/templates/email/set_password.hbs
Normale Datei
6
src/static/templates/email/set_password.hbs
Normale Datei
|
@ -0,0 +1,6 @@
|
|||
Master Password Has Been Changed
|
||||
<!---------------->
|
||||
The master password for {{user_name}} has been changed. If you did not initiate this request, please reach out to your administrator immediately.
|
||||
|
||||
===
|
||||
{{> email/email_footer_text }}
|
11
src/static/templates/email/set_password.html.hbs
Normale Datei
11
src/static/templates/email/set_password.html.hbs
Normale Datei
|
@ -0,0 +1,11 @@
|
|||
Master Password Has Been Changed
|
||||
<!---------------->
|
||||
{{> email/email_header }}
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none;" valign="top">
|
||||
The master password for <b style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">{{user_name}}</b> has been changed. If you did not initiate this request, please reach out to your administrator immediately.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{{> email/email_footer }}
|
58
src/util.rs
58
src/util.rs
|
@ -7,7 +7,7 @@ use num_traits::ToPrimitive;
|
|||
use once_cell::sync::Lazy;
|
||||
use rocket::{
|
||||
fairing::{Fairing, Info, Kind},
|
||||
http::{ContentType, Header, HeaderMap, Method, Status},
|
||||
http::{ContentType, Cookie, CookieJar, Header, HeaderMap, Method, SameSite, Status},
|
||||
request::FromParam,
|
||||
response::{self, Responder},
|
||||
Data, Orbit, Request, Response, Rocket,
|
||||
|
@ -131,8 +131,9 @@ impl Cors {
|
|||
fn get_allowed_origin(headers: &HeaderMap<'_>) -> Option<String> {
|
||||
let origin = Cors::get_header(headers, "Origin");
|
||||
let domain_origin = CONFIG.domain_origin();
|
||||
let sso_origin = CONFIG.sso_authority();
|
||||
let safari_extension_origin = "file://";
|
||||
if origin == domain_origin || origin == safari_extension_origin {
|
||||
if origin == domain_origin || origin == safari_extension_origin || origin == sso_origin {
|
||||
Some(origin)
|
||||
} else {
|
||||
None
|
||||
|
@ -257,6 +258,33 @@ impl<'r> FromParam<'r> for SafeString {
|
|||
}
|
||||
}
|
||||
|
||||
pub struct CustomRedirect {
|
||||
pub url: String,
|
||||
pub headers: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl<'r> rocket::response::Responder<'r, 'static> for CustomRedirect {
|
||||
fn respond_to(self, _: &rocket::request::Request<'_>) -> rocket::response::Result<'static> {
|
||||
let mut response = Response::build()
|
||||
.status(rocket::http::Status {
|
||||
code: 307,
|
||||
})
|
||||
.raw_header("Location", self.url)
|
||||
.header(ContentType::HTML)
|
||||
.finalize();
|
||||
|
||||
// Normal headers
|
||||
response.set_raw_header("Referrer-Policy", "same-origin");
|
||||
response.set_raw_header("X-XSS-Protection", "0");
|
||||
|
||||
for header in &self.headers {
|
||||
response.set_raw_header(header.0.clone(), header.1.clone());
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
// Log all the routes from the main paths list, and the attachments endpoint
|
||||
// Effectively ignores, any static file route, and the alive endpoint
|
||||
const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"];
|
||||
|
@ -994,3 +1022,29 @@ mod tests {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CookieManager<'a> {
|
||||
jar: &'a CookieJar<'a>,
|
||||
}
|
||||
|
||||
impl<'a> CookieManager<'a> {
|
||||
pub fn new(jar: &'a CookieJar<'a>) -> Self {
|
||||
Self {
|
||||
jar,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_cookie(&self, name: String, value: String) {
|
||||
let cookie = Cookie::build((name, value)).same_site(SameSite::Lax);
|
||||
|
||||
self.jar.add(cookie)
|
||||
}
|
||||
|
||||
pub fn get_cookie(&self, name: String) -> Option<String> {
|
||||
self.jar.get(&name).map(|c| c.value().to_string())
|
||||
}
|
||||
|
||||
pub fn delete_cookie(&self, name: String) {
|
||||
self.jar.remove(Cookie::from(name));
|
||||
}
|
||||
}
|
||||
|
|
Laden …
In neuem Issue referenzieren