Spiegel von
https://github.com/dani-garcia/vaultwarden.git
synchronisiert 2024-11-16 04:12:53 +01:00
Improvements and error handling
Dieser Commit ist enthalten in:
Ursprung
20ec2d41e4
Commit
8adf98a5aa
82 geänderte Dateien mit 3007 neuen und 688 gelöschten Zeilen
|
@ -152,6 +152,10 @@
|
||||||
## Cron schedule of the job that cleans old auth requests from the auth request.
|
## Cron schedule of the job that cleans old auth requests from the auth request.
|
||||||
## Defaults to every minute. Set blank to disable this job.
|
## Defaults to every minute. Set blank to disable this job.
|
||||||
# AUTH_REQUEST_PURGE_SCHEDULE="30 * * * * *"
|
# AUTH_REQUEST_PURGE_SCHEDULE="30 * * * * *"
|
||||||
|
##
|
||||||
|
## Cron schedule of the job that cleans sso nonce from incomplete flow
|
||||||
|
## Defaults to daily (20 minutes after midnight). Set blank to disable this job.
|
||||||
|
# PURGE_INCOMPLETE_SSO_NONCE="0 20 0 * * *"
|
||||||
|
|
||||||
########################
|
########################
|
||||||
### General settings ###
|
### General settings ###
|
||||||
|
@ -417,11 +421,32 @@
|
||||||
# SSO_ENABLED=true
|
# SSO_ENABLED=true
|
||||||
## Prevent users from logging in directly without going through SSO
|
## Prevent users from logging in directly without going through SSO
|
||||||
# SSO_ONLY=false
|
# SSO_ONLY=false
|
||||||
|
## On SSO Signup if a user with a matching email already exists make the association
|
||||||
|
# SSO_SIGNUPS_MATCH_EMAIL=true
|
||||||
## Base URL of the OIDC server (auto-discovery is used)
|
## Base URL of the OIDC server (auto-discovery is used)
|
||||||
|
## - Should not include the `/.well-known/openid-configuration` part and no trailing `/`
|
||||||
|
## - ${SSO_AUTHORITY}/.well-known/openid-configuration should return a json document: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse
|
||||||
# SSO_AUTHORITY=https://auth.example.com
|
# SSO_AUTHORITY=https://auth.example.com
|
||||||
|
## Optional SSO scopes, override if email and profile are not enough
|
||||||
|
#SSO_SCOPES="email profile"
|
||||||
|
## Additionnal authorization url parameters (ex: to obtain a `refresh_token` with Google Auth).
|
||||||
|
# SSO_AUTHORIZE_EXTRA_PARAMS="
|
||||||
|
# access_type=offline
|
||||||
|
# prompt=consent
|
||||||
|
# "
|
||||||
|
## Activate PKCE for the Auth Code flow. Recommended but disabled for now waiting for feedback on support.
|
||||||
|
# SSO_PKCE=false
|
||||||
|
## Regex to add additionnal trusted audience to Id Token (by default only the client_id is trusted).
|
||||||
|
# SSO_AUDIENCE_TRUSTED='^$'
|
||||||
## Set your Client ID and Client Key
|
## Set your Client ID and Client Key
|
||||||
# SSO_CLIENT_ID=11111
|
# SSO_CLIENT_ID=11111
|
||||||
# SSO_CLIENT_SECRET=AAAAAAAAAAAAAAAAAAAAAAAA
|
# SSO_CLIENT_SECRET=AAAAAAAAAAAAAAAAAAAAAAAA
|
||||||
|
## Optional Master password policy (minComplexity=[0-4])
|
||||||
|
# SSO_MASTER_PASSWORD_POLICY='{"enforceOnLogin":false,"minComplexity":3,"minLength":12,"requireLower":false,"requireNumbers":false,"requireSpecial":false,"requireUpper":false}'
|
||||||
|
## Use sso only for authentication not the session lifecycle
|
||||||
|
# SSO_AUTH_ONLY_NOT_SESSION=false
|
||||||
|
## Log all the tokens, LOG_LEVEL=debug is required
|
||||||
|
# SSO_DEBUG_TOKENS=false
|
||||||
|
|
||||||
########################
|
########################
|
||||||
### MFA/2FA settings ###
|
### MFA/2FA settings ###
|
||||||
|
|
704
Cargo.lock
generiert
704
Cargo.lock
generiert
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
|
@ -150,7 +150,8 @@ paste = "1.0.15"
|
||||||
governor = "0.6.3"
|
governor = "0.6.3"
|
||||||
|
|
||||||
# OIDC for SSO
|
# OIDC for SSO
|
||||||
openidconnect = "3.4.0"
|
openidconnect = "3.5.0"
|
||||||
|
mini-moka = "0.10.2"
|
||||||
|
|
||||||
# Check client versions for specific features.
|
# Check client versions for specific features.
|
||||||
semver = "1.0.23"
|
semver = "1.0.23"
|
||||||
|
|
260
SSO.md
Normale Datei
260
SSO.md
Normale Datei
|
@ -0,0 +1,260 @@
|
||||||
|
# SSO using OpenId Connect
|
||||||
|
|
||||||
|
To use an external source of authentication your SSO will need to support OpenID Connect :
|
||||||
|
|
||||||
|
- And OpenID Connect Discovery endpoint should be available
|
||||||
|
- Client authentication will be done using Id and Secret.
|
||||||
|
|
||||||
|
A master password will still required and not controlled by the SSO (depending of your point of view this might be a feature ;).
|
||||||
|
This introduce another way to control who can use the vault without having to use invitation or using an LDAP.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The following configurations are available
|
||||||
|
|
||||||
|
- `SSO_ENABLED` : Activate the SSO
|
||||||
|
- `SSO_ONLY` : disable email+Master password authentication
|
||||||
|
- `SSO_SIGNUPS_MATCH_EMAIL`: On SSO Signup if a user with a matching email already exists make the association (default `true`)
|
||||||
|
- `SSO_AUTHORITY` : the OpendID Connect Discovery endpoint of your SSO
|
||||||
|
- Should not include the `/.well-known/openid-configuration` part and no trailing `/`
|
||||||
|
- $SSO_AUTHORITY/.well-known/openid-configuration should return the a json document: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse
|
||||||
|
- `SSO_SCOPES` : Optional, allow to override scopes if needed (default `"email profile"`)
|
||||||
|
- `SSO_AUTHORIZE_EXTRA_PARAMS` : Optional, allow to add extra parameter to the authorize redirection (default `""`)
|
||||||
|
- `SSO_PKCE`: Activate PKCE for the Auth Code flow. Recommended but disabled for now waiting for feedback on support (default `false`).
|
||||||
|
- `SSO_AUDIENCE_TRUSTED`: Optional, Regex to trust additionnal audience for the IdToken (`client_id` is always trusted). Use single quote when writing the regex: `'^$'`.
|
||||||
|
- `SSO_CLIENT_ID` : Client Id
|
||||||
|
- `SSO_CLIENT_SECRET` : Client Secret
|
||||||
|
- `SSO_MASTER_PASSWORD_POLICY`: Optional Master password policy
|
||||||
|
- `SSO_AUTH_ONLY_NOT_SESSION`: Enable to use SSO only for authentication not session lifecycle
|
||||||
|
- `SSO_DEBUG_TOKENS`: Log all tokens (default `false`, `LOG_LEVEL=debug` is required)
|
||||||
|
|
||||||
|
The callback url is : `https://your.domain/identity/connect/oidc-signin`
|
||||||
|
|
||||||
|
## Account and Email handling
|
||||||
|
|
||||||
|
When logging with SSO an identifier (`{iss}/{sub}` claims from the IdToken) is saved in a separate table (`sso_users`).
|
||||||
|
This is used to link to the SSO provider identifier without changing the default Vaultwarden user `uuid`. This is needed because:
|
||||||
|
|
||||||
|
- Storing the SSO identifier is important to prevent account takeover due to email change.
|
||||||
|
- We can't use the identifier as the User uuid since it's way longer (Max 255 chars for the `sub` part, cf [spec](https://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken)).
|
||||||
|
- We want to be able to associate existing account based on `email` but only when the user log for the first time (controlled by `SSO_SIGNUPS_MATCH_EMAIL`).
|
||||||
|
- We need to be able to associate with existing stub account, such as the one created when inviting a user to an org (association is possible only if the user does not have a private key).
|
||||||
|
|
||||||
|
Additionnaly:
|
||||||
|
|
||||||
|
- Signup to Vaultwarden will be blocked if the Provider report the email as `unverified`.
|
||||||
|
- Changing the email need to be done by the user since it require updating the `key`.
|
||||||
|
On login if the email returned by the provider is not the one saved in Vaultwarden an email will be sent to the user to ask him to update it.
|
||||||
|
- If set `SIGNUPS_DOMAINS_WHITELIST` is applied on SSO signup and when attempting to change the email.
|
||||||
|
|
||||||
|
This mean that if you ever need to change the provider url or the provider itself; you'll have to first delete the association
|
||||||
|
then ensure that `SSO_SIGNUPS_MATCH_EMAIL` is activated to allow a new association.
|
||||||
|
|
||||||
|
To delete the association (this has no impact on the `Vaultwarden` user):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
TRUNCATE TABLE sso_users;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client Cache
|
||||||
|
|
||||||
|
By default the client cache is disabled since it can cause issues with the signing keys.
|
||||||
|
\
|
||||||
|
This mean that the discovery endpoint will be called again each time we need to interact with the provider (generating authorize_url, exhange the authorize code, refresh tokens).
|
||||||
|
This is suboptimal so the `SSO_CLIENT_CACHE_EXPIRATION` allow you to configure an expiration that should work for your provider.
|
||||||
|
|
||||||
|
As a protection against a misconfigured expiration if the validation of the `IdToken` fails then the client cache is invalidated (but you'll periodically have an unlucky user ^^).
|
||||||
|
|
||||||
|
### Google example (Rolling keys)
|
||||||
|
|
||||||
|
If we take Google as an example checking the discovery [endpoint](https://accounts.google.com/.well-known/openid-configuration) response headers we can see that the `max-age` of the cache control is set to `3600` seconds. And the [jwk_uri](https://www.googleapis.com/oauth2/v3/certs) response headers usually contain a `max-age` with an even bigger value.
|
||||||
|
/
|
||||||
|
Combined with user [feedback](https://github.com/ramosbugs/openidconnect-rs/issues/152) we can conclude that Google will roll the signing keys each week.
|
||||||
|
|
||||||
|
Setting the cache expiration too high has diminishing return but using something like `600` (10 min) should provide plenty benefits.
|
||||||
|
|
||||||
|
### Rolling keys manually
|
||||||
|
|
||||||
|
If you want to roll the used key, first add a new one but do not immediately start signing with it.
|
||||||
|
Wait for the delay you configured in `SSO_CLIENT_CACHE_EXPIRATION` then you can start signing with it.
|
||||||
|
|
||||||
|
As mentionned in the Google example setting too high of a value has dimishing return even if you do not plan to roll the keys.
|
||||||
|
|
||||||
|
## Keycloak
|
||||||
|
|
||||||
|
Default access token lifetime might be only `5min`, set a longer value otherwise it will collide with `VaultWarden` front-end expiration detection which is also set at `5min`.
|
||||||
|
\
|
||||||
|
At the realm level
|
||||||
|
- `Realm settings / Tokens / Access Token Lifespan` to at least `10min` (`accessTokenLifespan` setting when using `kcadm.sh`).
|
||||||
|
- `Realm settings / Sessions / SSO Session Idle/Max` for the Refresh token lifetime
|
||||||
|
|
||||||
|
Or for a specific client in `Clients / Client details / Advanced / Advanced settings` you can find `Access Token Lifespan` and `Client Session Idle/Max`.
|
||||||
|
|
||||||
|
Server configuration, nothing specific just set:
|
||||||
|
|
||||||
|
- `SSO_AUTHORITY=https://${domain}/realms/${realm_name}`
|
||||||
|
- `SSO_CLIENT_ID`
|
||||||
|
- `SSO_CLIENT_SECRET`
|
||||||
|
- `SSO_PKCE=true`
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
If you want to run a testing instance of Keycloak a [docker-compose](docker/keycloak/docker-compose.yml) is available.
|
||||||
|
|
||||||
|
## Authelia
|
||||||
|
|
||||||
|
To obtain a `refresh_token` to be able to extend session you'll need to add the `offline_access` scope.
|
||||||
|
|
||||||
|
Config will look like:
|
||||||
|
|
||||||
|
- `SSO_SCOPES="email profile offline_access"`
|
||||||
|
|
||||||
|
|
||||||
|
## Authentik
|
||||||
|
|
||||||
|
Default access token lifetime might be only `5min`, set a longer value otherwise it will collide with `VaultWarden` front-end expiration detection which is also set at `5min`.
|
||||||
|
\
|
||||||
|
To change the tokens expiration go to `Applications / Providers / Edit / Advanced protocol settings`.
|
||||||
|
|
||||||
|
Starting with `2024.2` version you will need to add the `offline_access` scope and ensure it's selected in `Applications / Providers / Edit / Advanced protocol settings / Scopes` ([Doc](https://docs.goauthentik.io/docs/providers/oauth2/#authorization_code)).
|
||||||
|
|
||||||
|
Server configuration should look like:
|
||||||
|
|
||||||
|
- `SSO_AUTHORITY=https://${domain}/application/o/${application_name}/` : trailing `/` is important
|
||||||
|
- `SSO_SCOPES="email profile offline_access"`
|
||||||
|
- `SSO_CLIENT_ID`
|
||||||
|
- `SSO_CLIENT_SECRET`
|
||||||
|
- `SSO_PKCE=true`
|
||||||
|
|
||||||
|
## GitLab
|
||||||
|
|
||||||
|
Create an application in your Gitlab Settings with
|
||||||
|
|
||||||
|
- `redirectURI`: https://your.domain/identity/connect/oidc-signin
|
||||||
|
- `Confidential`: `true`
|
||||||
|
- `scopes`: `openid`, `profile`, `email`
|
||||||
|
|
||||||
|
Then configure your server with
|
||||||
|
|
||||||
|
- `SSO_AUTHORITY=https://gitlab.com`
|
||||||
|
- `SSO_CLIENT_ID`
|
||||||
|
- `SSO_CLIENT_SECRET`
|
||||||
|
- `SSO_PKCE=true`
|
||||||
|
|
||||||
|
## Google Auth
|
||||||
|
|
||||||
|
Google [Documentation](https://developers.google.com/identity/openid-connect/openid-connect).
|
||||||
|
\
|
||||||
|
By default without extra [configuration](https://developers.google.com/identity/protocols/oauth2/web-server#creatingclient) you won´t have a `refresh_token` and session will be limited to 1h.
|
||||||
|
|
||||||
|
Configure your server with :
|
||||||
|
|
||||||
|
- `SSO_AUTHORITY=https://accounts.google.com`
|
||||||
|
- ```conf
|
||||||
|
SSO_AUTHORIZE_EXTRA_PARAMS="
|
||||||
|
access_type=offline
|
||||||
|
prompt=consent
|
||||||
|
"
|
||||||
|
```
|
||||||
|
- `SSO_PKCE=true`
|
||||||
|
- `SSO_CLIENT_ID`
|
||||||
|
- `SSO_CLIENT_SECRET`
|
||||||
|
|
||||||
|
## Kanidm
|
||||||
|
|
||||||
|
Kanidm recommend always running with PKCE:
|
||||||
|
|
||||||
|
Config will look like:
|
||||||
|
|
||||||
|
- `SSO_PKCE=true`
|
||||||
|
|
||||||
|
Otherwise you can disable the PKCE requirement with: `kanidm system oauth2 warning-insecure-client-disable-pkce CLIENT_NAME --name admin`.
|
||||||
|
|
||||||
|
## Microsoft Entra ID
|
||||||
|
|
||||||
|
1. Create an "App registration" in [Entra ID](https://entra.microsoft.com/) following [Identity | Applications | App registrations](https://entra.microsoft.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade/quickStartType//sourceType/Microsoft_AAD_IAM).
|
||||||
|
2. From the "Overview" of your "App registration", you'll need the "Directory (tenant) ID" for the `SSO_AUTHORITY` variable and the "Application (client) ID" as the `SSO_CLIENT_ID` value.
|
||||||
|
3. In "Certificates & Secrets" create an "App secret" , you'll need the "Secret Value" for the `SSO_CLIENT_SECRET` variable.
|
||||||
|
4. In "Authentication" add https://vaultwarden.example.org/identity/connect/oidc-signin as "Web Redirect URI".
|
||||||
|
5. In "API Permissions" make sure you have `profile`, `email` and `offline_access` listed under "API / Permission name" (`offline_access` is required, otherwise no refresh_token is returned, see https://github.com/MicrosoftDocs/azure-docs/issues/17134).
|
||||||
|
|
||||||
|
Only the v2 endpooint is compliant with the OpenID spec, see https://github.com/MicrosoftDocs/azure-docs/issues/38427 and https://github.com/ramosbugs/openidconnect-rs/issues/122.
|
||||||
|
|
||||||
|
Your configuration should look like this:
|
||||||
|
|
||||||
|
* `SSO_AUTHORITY=https://login.microsoftonline.com/${Directory (tenant) ID}/v2.0`
|
||||||
|
* `SSO_SCOPES="email profile offline_access"`
|
||||||
|
* `SSO_CLIENT_ID=${Application (client) ID}`
|
||||||
|
* `SSO_CLIENT_SECRET=${Secret Value}`
|
||||||
|
|
||||||
|
## Zitadel
|
||||||
|
|
||||||
|
To obtain a `refresh_token` to be able to extend session you'll need to add the `offline_access` scope.
|
||||||
|
|
||||||
|
Additionnaly Zitadel include the `Project id` and the `Client Id` in the audience of the Id Token.
|
||||||
|
For the validation to work you will need to add the `Resource Id` as a trusted audience (`Client Id` is trusted by default).
|
||||||
|
You can control the trusted audience with the config `SSO_AUDIENCE_TRUSTED`
|
||||||
|
|
||||||
|
It appears it's not possible to use PKCE with confidential client so it needs to be disabled.
|
||||||
|
|
||||||
|
Config will look like:
|
||||||
|
|
||||||
|
- `SSO_AUTHORITY=https://${provider_host}`
|
||||||
|
- `SSO_SCOPES="email profile offline_access"`
|
||||||
|
- `SSO_CLIENT_ID`
|
||||||
|
- `SSO_CLIENT_SECRET`
|
||||||
|
- `SSO_AUDIENCE_TRUSTED='^${Project Id}$'`
|
||||||
|
- `SSO_PKCE=false`
|
||||||
|
|
||||||
|
## Session lifetime
|
||||||
|
|
||||||
|
Session lifetime is dependant on refresh token and access token returned after calling your SSO token endpoint (grant type `authorization_code`).
|
||||||
|
If no refresh token is returned then the session will be limited to the access token lifetime.
|
||||||
|
|
||||||
|
Tokens are not persisted in VaultWarden but wrapped in JWT tokens and returned to the application (The `refresh_token` and `access_token` values returned by VW `identity/connect/token` endpoint).
|
||||||
|
Note that VaultWarden will always return a `refresh_token` for compatibility reasons with the web front and it presence does not indicate that a refresh token was returned by your SSO (But you can decode its value with https://jwt.io and then check if the `token` field contain anything).
|
||||||
|
|
||||||
|
With a refresh token present, activity in the application will trigger a refresh of the access token when it's close to expiration ([5min](https://github.com/bitwarden/clients/blob/0bcb45ed5caa990abaff735553a5046e85250f24/libs/common/src/auth/services/token.service.ts#L126) in web client).
|
||||||
|
|
||||||
|
Additionnaly for certain action a token check is performed, if we have a refresh token we will perform a refresh otherwise we'll call the user information endpoint to check the access token validity.
|
||||||
|
|
||||||
|
### Disabling SSO session handling
|
||||||
|
|
||||||
|
If you are unable to obtain a `refresh_token` or for any other reason you can disable SSO session handling and revert to the default handling.
|
||||||
|
You'll need to enable `SSO_AUTH_ONLY_NOT_SESSION=true` then access token will be valid for 2h and refresh token will allow for an idle time of 7 days (which can be indefinitely extended).
|
||||||
|
|
||||||
|
### Debug information
|
||||||
|
|
||||||
|
Running with `LOG_LEVEL=debug` you'll be able to see information on token expiration.
|
||||||
|
|
||||||
|
## Desktop Client
|
||||||
|
|
||||||
|
There is some issue to handle redirection from your browser (used for sso login) to the application.
|
||||||
|
|
||||||
|
### Chrome
|
||||||
|
|
||||||
|
Probably not much hope, an [issue](https://github.com/bitwarden/clients/issues/2606) is open on the subject and it appears that both Linux and Windows are not working.
|
||||||
|
|
||||||
|
## Firefox
|
||||||
|
|
||||||
|
On Windows you'll be presented with a prompt the first time you log to confirm which application should be launched (But there is a bug at the moment you might end-up with an empty vault after login atm).
|
||||||
|
|
||||||
|
|
||||||
|
On Linux it's a bit more tricky.
|
||||||
|
First you'll need to add some config in `about:config` :
|
||||||
|
|
||||||
|
```
|
||||||
|
network.protocol-handler.expose.bitwarden=false
|
||||||
|
network.protocol-handler.external.bitwarden=true
|
||||||
|
```
|
||||||
|
|
||||||
|
If you have any doubt you can check `mailto` to see how it's configured.
|
||||||
|
|
||||||
|
The redirection will still not work since it appears that the association to an application can only be done on a link/click. You can trigger it with a dummy page such as:
|
||||||
|
|
||||||
|
```html
|
||||||
|
data:text/html,<a href="bitwarden:///dummy">Click me to register Bitwarden</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
From now on the redirection should now work.
|
||||||
|
If you need to change the application launched you can now find it in `Settings` by using the search function and entering `application`.
|
26
docker/keycloak/.env.template
Normale Datei
26
docker/keycloak/.env.template
Normale Datei
|
@ -0,0 +1,26 @@
|
||||||
|
# Keycloak Config
|
||||||
|
KEYCLOAK_ADMIN=admin
|
||||||
|
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
|
||||||
|
KC_HTTP_HOST=127.0.0.1
|
||||||
|
KC_HTTP_PORT=8080
|
||||||
|
|
||||||
|
# Script parameters (use Keycloak and VaultWarden config too)
|
||||||
|
TEST_REALM=test
|
||||||
|
|
||||||
|
TEST_USER=test
|
||||||
|
TEST_USER_PASSWORD=${TEST_USER}
|
||||||
|
TEST_USER_MAIL="${TEST_USER}@yopmail.com"
|
||||||
|
|
||||||
|
TEST_USER_2=test2
|
||||||
|
TEST_USER_2_PASSWORD=${TEST_USER_2}
|
||||||
|
TEST_USER_2_MAIL="${TEST_USER_2}@yopmail.com"
|
||||||
|
|
||||||
|
# VaultWarden Config
|
||||||
|
ROCKET_PORT=8000
|
||||||
|
DOMAIN=http://127.0.0.1:${ROCKET_PORT}
|
||||||
|
I_REALLY_WANT_VOLATILE_STORAGE=true
|
||||||
|
SSO_ENABLED=true
|
||||||
|
SSO_ONLY=false
|
||||||
|
SSO_CLIENT_ID=VaultWarden
|
||||||
|
SSO_CLIENT_SECRET=VaultWarden
|
||||||
|
SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM}
|
67
docker/keycloak/README.md
Normale Datei
67
docker/keycloak/README.md
Normale Datei
|
@ -0,0 +1,67 @@
|
||||||
|
# OpenID Connect test setup
|
||||||
|
|
||||||
|
This `docker-compose` template allow to run locally a `VaultWarden` and [`Keycloak`](https://www.keycloak.org/) instance to test OIDC.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
You'll need `docker` and `docker-compose` ([cf](https://docs.docker.com/engine/install/)).
|
||||||
|
|
||||||
|
First create a copy of `.env.template` as `.env` (This is done to prevent commiting your custom settings, Ex `SMTP_`).
|
||||||
|
|
||||||
|
Then start the stack (the `profile` is required to run the `VaultWarden`) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
> DOCKER_BUILDKIT=1 docker-compose --profile VaultWarden up
|
||||||
|
....
|
||||||
|
keycloakSetup_1 | Logging into http://127.0.0.1:8080 as user admin of realm master
|
||||||
|
keycloakSetup_1 | Created new realm with id 'test'
|
||||||
|
keycloakSetup_1 | 74af4933-e386-4e64-ba15-a7b61212c45e
|
||||||
|
oidc_keycloakSetup_1 exited with code 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait until `oidc_keycloakSetup_1 exited with code 0` which indicate the correct setup of the Keycloak realm, client and user (It's normal for this container to stop once the configuration is done).
|
||||||
|
|
||||||
|
Then you can access :
|
||||||
|
|
||||||
|
- `VaultWarden` on http://127.0.0.1:8000 with the default user `test@yopmail.com/test`.
|
||||||
|
- `Keycloak` on http://127.0.0.1:8080/admin/master/console/ with the default user `admin/admin`
|
||||||
|
|
||||||
|
To proceed with an SSO login after you enter the email, on the screen prompting for `Master Password` the SSO button should be visible.
|
||||||
|
|
||||||
|
## Running only Keycloak
|
||||||
|
|
||||||
|
Since the `VaultWarden` service is defined with a `profile` you can just use the default `docker-compose` command :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
> docker-compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
When running with a local VaultWarden you'll need to make the SSO button visible using :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sed -i 's#a\[routerlink="/sso"\],##' /web-vault/app/main.*.css
|
||||||
|
```
|
||||||
|
|
||||||
|
Otherwise you'll need to reveal the SSO login button using the debug console (F12)
|
||||||
|
|
||||||
|
```js
|
||||||
|
document.querySelector('a[routerlink="/sso"]').style.setProperty("display", "inline-block", "important");
|
||||||
|
```
|
||||||
|
|
||||||
|
## To force rebuilding the VaultWarden image
|
||||||
|
|
||||||
|
Use `DOCKER_BUILDKIT=1 docker-compose --profile VaultWarden up --build VaultWarden`.
|
||||||
|
|
||||||
|
If after building the `Keycloak` configuration is not run, just interrupt and run without `--build`
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All configuration for `keycloak` / `VaultWarden` / `keycloak_setup.sh` can be found in [.env](.env.template).
|
||||||
|
The content of the file will be loaded as environment variables in all containers.
|
||||||
|
|
||||||
|
- `keycloak` [configuration](https://www.keycloak.org/server/all-config) include `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD` and any variable prefixed `KC_` ([more information](https://www.keycloak.org/server/configuration#_example_configuring_the_db_url_host_parameter)).
|
||||||
|
- All `VaultWarden` configuration can be set (EX: `SMTP_*`)
|
||||||
|
|
||||||
|
## Cleanup
|
||||||
|
|
||||||
|
Use `docker-compose --profile VaultWarden down`.
|
33
docker/keycloak/docker-compose.yml
Normale Datei
33
docker/keycloak/docker-compose.yml
Normale Datei
|
@ -0,0 +1,33 @@
|
||||||
|
services:
|
||||||
|
keycloak:
|
||||||
|
container_name: keycloak-${ENV:-dev}
|
||||||
|
image: quay.io/keycloak/keycloak
|
||||||
|
network_mode: "host"
|
||||||
|
command:
|
||||||
|
- start-dev
|
||||||
|
env_file: ${ENV}.env
|
||||||
|
volumes:
|
||||||
|
- ./keycloak_setup.sh:/opt/script/keycloak_setup.sh
|
||||||
|
keycloakSetup:
|
||||||
|
container_name: keycloakSetup-${ENV:-dev}
|
||||||
|
image: quay.io/keycloak/keycloak
|
||||||
|
network_mode: "host"
|
||||||
|
depends_on:
|
||||||
|
- keycloak
|
||||||
|
restart: "no"
|
||||||
|
env_file: ${ENV}.env
|
||||||
|
entrypoint: [ "bash", "-c", "/opt/script/keycloak_setup.sh"]
|
||||||
|
volumes:
|
||||||
|
- ${KC_SETUP_PATH:-.}/keycloak_setup.sh:/opt/script/keycloak_setup.sh
|
||||||
|
VaultWarden:
|
||||||
|
image: vaultwarden
|
||||||
|
profiles: ["VaultWarden"]
|
||||||
|
network_mode: "host"
|
||||||
|
build:
|
||||||
|
context: ../../..
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
depends_on:
|
||||||
|
- keycloak
|
||||||
|
env_file: ${ENV}.env
|
||||||
|
volumes:
|
||||||
|
- ./vaultwarden.sh:/etc/vaultwarden.sh
|
32
docker/keycloak/keycloak_setup.sh
Ausführbare Datei
32
docker/keycloak/keycloak_setup.sh
Ausführbare Datei
|
@ -0,0 +1,32 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
export PATH=$PATH:/opt/keycloak/bin
|
||||||
|
|
||||||
|
CANARY=/tmp/keycloak_setup_done
|
||||||
|
|
||||||
|
if [ -f $CANARY ]
|
||||||
|
then
|
||||||
|
echo "Setup should already be done. Will not run."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
sleep 5
|
||||||
|
kcadm.sh config credentials --server "http://${KC_HTTP_HOST}:${KC_HTTP_PORT}" --realm master --user "$KEYCLOAK_ADMIN" --password "$KEYCLOAK_ADMIN_PASSWORD" --client admin-cli
|
||||||
|
EC=$?
|
||||||
|
if [ $EC -eq 0 ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Will retry in 5 seconds"
|
||||||
|
done
|
||||||
|
|
||||||
|
kcadm.sh create realms -s realm="$TEST_REALM" -s enabled=true -s "accessTokenLifespan=600"
|
||||||
|
kcadm.sh create clients -r test -s "clientId=$SSO_CLIENT_ID" -s "secret=$SSO_CLIENT_SECRET" -s "redirectUris=[\"$DOMAIN/*\"]" -i
|
||||||
|
|
||||||
|
TEST_USER_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER" -s "email=$TEST_USER_MAIL" -s emailVerified=true -s enabled=true -i)
|
||||||
|
kcadm.sh update users/$TEST_USER_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER_PASSWORD" -n
|
||||||
|
|
||||||
|
TEST_USER_2_ID=$(kcadm.sh create users -r "$TEST_REALM" -s "username=$TEST_USER_2" -s "email=$TEST_USER_2_MAIL" -s emailVerified=true -s enabled=true -i)
|
||||||
|
kcadm.sh update users/$TEST_USER_2_ID/reset-password -r "$TEST_REALM" -s type=password -s "value=$TEST_USER_2_PASSWORD" -n
|
||||||
|
|
||||||
|
touch $CANARY
|
4
docker/keycloak/vaultwarden.sh
Ausführbare Datei
4
docker/keycloak/vaultwarden.sh
Ausführbare Datei
|
@ -0,0 +1,4 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
# Remove CSS to hide SSO Link
|
||||||
|
sed -i 's#a\[routerlink="/sso"\],##' /web-vault/app/main.*.css
|
|
@ -1,3 +0,0 @@
|
||||||
CREATE TABLE sso_nonce (
|
|
||||||
nonce CHAR(36) NOT NULL PRIMARY KEY
|
|
||||||
);
|
|
4
migrations/mysql/2023-09-10-133000_add_sso/up.sql
Normale Datei
4
migrations/mysql/2023-09-10-133000_add_sso/up.sql
Normale Datei
|
@ -0,0 +1,4 @@
|
||||||
|
CREATE TABLE sso_nonce (
|
||||||
|
nonce CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE users_organizations DROP COLUMN invited_by_email;
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL;
|
|
@ -0,0 +1,6 @@
|
||||||
|
DROP TABLE IF EXISTS sso_nonce;
|
||||||
|
|
||||||
|
CREATE TABLE sso_nonce (
|
||||||
|
nonce CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
8
migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/up.sql
Normale Datei
8
migrations/mysql/2024-02-14-170000_add_state_to_sso_nonce/up.sql
Normale Datei
|
@ -0,0 +1,8 @@
|
||||||
|
DROP TABLE IF EXISTS sso_nonce;
|
||||||
|
|
||||||
|
CREATE TABLE sso_nonce (
|
||||||
|
state VARCHAR(512) NOT NULL PRIMARY KEY,
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
|
@ -0,0 +1,8 @@
|
||||||
|
DROP TABLE IF EXISTS sso_nonce;
|
||||||
|
|
||||||
|
CREATE TABLE sso_nonce (
|
||||||
|
state VARCHAR(512) NOT NULL PRIMARY KEY,
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
9
migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql
Normale Datei
9
migrations/mysql/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql
Normale Datei
|
@ -0,0 +1,9 @@
|
||||||
|
DROP TABLE IF EXISTS sso_nonce;
|
||||||
|
|
||||||
|
CREATE TABLE sso_nonce (
|
||||||
|
state VARCHAR(512) NOT NULL PRIMARY KEY,
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
verifier TEXT,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
1
migrations/mysql/2024-03-06-170000_add_sso_users/down.sql
Normale Datei
1
migrations/mysql/2024-03-06-170000_add_sso_users/down.sql
Normale Datei
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS sso_users;
|
7
migrations/mysql/2024-03-06-170000_add_sso_users/up.sql
Normale Datei
7
migrations/mysql/2024-03-06-170000_add_sso_users/up.sql
Normale Datei
|
@ -0,0 +1,7 @@
|
||||||
|
CREATE TABLE sso_users (
|
||||||
|
user_uuid CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
identifier VARCHAR(768) NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
FOREIGN KEY(user_uuid) REFERENCES users(uuid)
|
||||||
|
);
|
0
migrations/mysql/2024-03-13-170000_sso_users_cascade/down.sql
Normale Datei
0
migrations/mysql/2024-03-13-170000_sso_users_cascade/down.sql
Normale Datei
2
migrations/mysql/2024-03-13-170000_sso_users_cascade/up.sql
Normale Datei
2
migrations/mysql/2024-03-13-170000_sso_users_cascade/up.sql
Normale Datei
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE sso_users DROP FOREIGN KEY `sso_users_ibfk_1`;
|
||||||
|
ALTER TABLE sso_users ADD FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE;
|
|
@ -1,3 +0,0 @@
|
||||||
CREATE TABLE sso_nonce (
|
|
||||||
nonce CHAR(36) NOT NULL PRIMARY KEY
|
|
||||||
);
|
|
4
migrations/postgresql/2023-09-10-133000_add_sso/up.sql
Normale Datei
4
migrations/postgresql/2023-09-10-133000_add_sso/up.sql
Normale Datei
|
@ -0,0 +1,4 @@
|
||||||
|
CREATE TABLE sso_nonce (
|
||||||
|
nonce CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE users_organizations DROP COLUMN invited_by_email;
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL;
|
|
@ -0,0 +1,6 @@
|
||||||
|
DROP TABLE sso_nonce;
|
||||||
|
|
||||||
|
CREATE TABLE sso_nonce (
|
||||||
|
nonce CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
|
@ -0,0 +1,8 @@
|
||||||
|
DROP TABLE sso_nonce;
|
||||||
|
|
||||||
|
CREATE TABLE sso_nonce (
|
||||||
|
state TEXT NOT NULL PRIMARY KEY,
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
|
@ -0,0 +1,8 @@
|
||||||
|
DROP TABLE IF EXISTS sso_nonce;
|
||||||
|
|
||||||
|
CREATE TABLE sso_nonce (
|
||||||
|
state TEXT NOT NULL PRIMARY KEY,
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
|
@ -0,0 +1,9 @@
|
||||||
|
DROP TABLE IF EXISTS sso_nonce;
|
||||||
|
|
||||||
|
CREATE TABLE sso_nonce (
|
||||||
|
state TEXT NOT NULL PRIMARY KEY,
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
verifier TEXT,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
1
migrations/postgresql/2024-03-06-170000_add_sso_users/down.sql
Normale Datei
1
migrations/postgresql/2024-03-06-170000_add_sso_users/down.sql
Normale Datei
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS sso_users;
|
7
migrations/postgresql/2024-03-06-170000_add_sso_users/up.sql
Normale Datei
7
migrations/postgresql/2024-03-06-170000_add_sso_users/up.sql
Normale Datei
|
@ -0,0 +1,7 @@
|
||||||
|
CREATE TABLE sso_users (
|
||||||
|
user_uuid CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
identifier TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
FOREIGN KEY(user_uuid) REFERENCES users(uuid)
|
||||||
|
);
|
3
migrations/postgresql/2024-03-13-170000_sso_users_cascade/up.sql
Normale Datei
3
migrations/postgresql/2024-03-13-170000_sso_users_cascade/up.sql
Normale Datei
|
@ -0,0 +1,3 @@
|
||||||
|
ALTER TABLE sso_users
|
||||||
|
DROP CONSTRAINT "sso_users_user_uuid_fkey",
|
||||||
|
ADD CONSTRAINT "sso_users_user_uuid_fkey" FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE;
|
|
@ -1,3 +0,0 @@
|
||||||
CREATE TABLE sso_nonce (
|
|
||||||
nonce CHAR(36) NOT NULL PRIMARY KEY
|
|
||||||
);
|
|
4
migrations/sqlite/2023-09-10-133000_add_sso/up.sql
Normale Datei
4
migrations/sqlite/2023-09-10-133000_add_sso/up.sql
Normale Datei
|
@ -0,0 +1,4 @@
|
||||||
|
CREATE TABLE sso_nonce (
|
||||||
|
nonce CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE users_organizations DROP COLUMN invited_by_email;
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE users_organizations ADD COLUMN invited_by_email TEXT DEFAULT NULL;
|
|
@ -0,0 +1,6 @@
|
||||||
|
DROP TABLE sso_nonce;
|
||||||
|
|
||||||
|
CREATE TABLE sso_nonce (
|
||||||
|
nonce CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
|
@ -0,0 +1,8 @@
|
||||||
|
DROP TABLE sso_nonce;
|
||||||
|
|
||||||
|
CREATE TABLE sso_nonce (
|
||||||
|
state TEXT NOT NULL PRIMARY KEY,
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
|
@ -0,0 +1,8 @@
|
||||||
|
DROP TABLE IF EXISTS sso_nonce;
|
||||||
|
|
||||||
|
CREATE TABLE sso_nonce (
|
||||||
|
state TEXT NOT NULL PRIMARY KEY,
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
9
migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql
Normale Datei
9
migrations/sqlite/2024-02-26-170000_add_pkce_to_sso_nonce/up.sql
Normale Datei
|
@ -0,0 +1,9 @@
|
||||||
|
DROP TABLE IF EXISTS sso_nonce;
|
||||||
|
|
||||||
|
CREATE TABLE sso_nonce (
|
||||||
|
state TEXT NOT NULL PRIMARY KEY,
|
||||||
|
nonce TEXT NOT NULL,
|
||||||
|
verifier TEXT,
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
1
migrations/sqlite/2024-03-06-170000_add_sso_users/down.sql
Normale Datei
1
migrations/sqlite/2024-03-06-170000_add_sso_users/down.sql
Normale Datei
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS sso_users;
|
7
migrations/sqlite/2024-03-06-170000_add_sso_users/up.sql
Normale Datei
7
migrations/sqlite/2024-03-06-170000_add_sso_users/up.sql
Normale Datei
|
@ -0,0 +1,7 @@
|
||||||
|
CREATE TABLE sso_users (
|
||||||
|
user_uuid CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
identifier TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
FOREIGN KEY(user_uuid) REFERENCES users(uuid)
|
||||||
|
);
|
0
migrations/sqlite/2024-03-13_170000_sso_userscascade/down.sql
Normale Datei
0
migrations/sqlite/2024-03-13_170000_sso_userscascade/down.sql
Normale Datei
9
migrations/sqlite/2024-03-13_170000_sso_userscascade/up.sql
Normale Datei
9
migrations/sqlite/2024-03-13_170000_sso_userscascade/up.sql
Normale Datei
|
@ -0,0 +1,9 @@
|
||||||
|
DROP TABLE IF EXISTS sso_users;
|
||||||
|
|
||||||
|
CREATE TABLE sso_users (
|
||||||
|
user_uuid CHAR(36) NOT NULL PRIMARY KEY,
|
||||||
|
identifier TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
FOREIGN KEY(user_uuid) REFERENCES users(uuid) ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
|
);
|
|
@ -37,6 +37,20 @@ npx playwright test --project postgres
|
||||||
npx playwright test --project mysql
|
npx playwright test --project mysql
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### SSO
|
||||||
|
|
||||||
|
To run the SSO tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx playwright test --project sso-sqllite
|
||||||
|
```
|
||||||
|
|
||||||
|
Additionnaly if you want you can keep the Keycloak `docker-compose` runnning with (its state is not impacted by the tests):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KC_KEEP_RUNNNING=true npx playwright test --project sso-sqllite
|
||||||
|
```
|
||||||
|
|
||||||
### Running specific tests
|
### Running specific tests
|
||||||
|
|
||||||
To run a whole file you can :
|
To run a whole file you can :
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { firefox, type FullConfig } from '@playwright/test';
|
import { type FullConfig } from '@playwright/test';
|
||||||
import { exec, execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import yaml from 'js-yaml';
|
import yaml from 'js-yaml';
|
||||||
|
|
||||||
|
@ -49,6 +49,9 @@ function retrieveFrontend(){
|
||||||
|
|
||||||
execSync(`cd temp && wget -c https://github.com/dani-garcia/bw_web_builds/releases/download/${vv}/bw_web_${vv}.tar.gz -O - | tar xz`, { stdio: "inherit" });
|
execSync(`cd temp && wget -c https://github.com/dani-garcia/bw_web_builds/releases/download/${vv}/bw_web_${vv}.tar.gz -O - | tar xz`, { stdio: "inherit" });
|
||||||
|
|
||||||
|
// Make the SSO button visible
|
||||||
|
execSync(`bash -c "sed -i 's#a.routerlink=./sso..,##' temp/web-vault/app/main.*.css"`, { stdio: "inherit" });
|
||||||
|
|
||||||
console.log(`Retrieved bw_web_builds-${vv}`);
|
console.log(`Retrieved bw_web_builds-${vv}`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`Using existing bw_web_builds-${vv}`);
|
console.log(`Using existing bw_web_builds-${vv}`);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { type Browser, type TestInfo } from '@playwright/test';
|
import { test, type Browser, type TestInfo, type Page } from '@playwright/test';
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import dotenvExpand from 'dotenv-expand';
|
import dotenvExpand from 'dotenv-expand';
|
||||||
|
@ -50,12 +50,28 @@ function startMariaDB() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function stopMariaDB() {
|
function stopMariaDB() {
|
||||||
console.log("Stopping MariaDB (ensure DB is wiped)");
|
console.log("Stopping MariaDB (ensure DB is wiped)");
|
||||||
execSync(`docker stop ${process.env.MARIADB_CONTAINER} || true`);
|
execSync(`docker stop ${process.env.MARIADB_CONTAINER} || true`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startMysqlDB() {
|
||||||
|
console.log(`Starting Mysql`);
|
||||||
|
execSync(`docker run --rm --name ${process.env.MYSQL_CONTAINER} \
|
||||||
|
-e MYSQL_ROOT_PASSWORD=${process.env.MYSQL_PWD} \
|
||||||
|
-e MYSQL_USER=${process.env.MYSQL_USER} \
|
||||||
|
-e MYSQL_PASSWORD=${process.env.MYSQL_PWD} \
|
||||||
|
-e MYSQL_DATABASE=${process.env.MYSQL_DB} \
|
||||||
|
-p ${process.env.MYSQL_PORT}:3306 \
|
||||||
|
-d mysql:8.3.0`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopMysqlDB() {
|
||||||
|
console.log("Stopping Mysql (ensure DB is wiped)");
|
||||||
|
execSync(`docker stop ${process.env.MYSQL_CONTAINER} || true`);
|
||||||
|
}
|
||||||
|
|
||||||
function startPostgres() {
|
function startPostgres() {
|
||||||
console.log(`Starting Postgres`);
|
console.log(`Starting Postgres`);
|
||||||
execSync(`docker run --rm --name ${process.env.POSTGRES_CONTAINER} \
|
execSync(`docker run --rm --name ${process.env.POSTGRES_CONTAINER} \
|
||||||
|
@ -73,29 +89,36 @@ function stopPostgres() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function dbConfig(testInfo: TestInfo){
|
function dbConfig(testInfo: TestInfo){
|
||||||
switch(testInfo.project.name) {
|
if( testInfo.project.name.includes("postgres") ){
|
||||||
case "postgres": return {
|
return {
|
||||||
DATABASE_URL: `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PWD}@127.0.0.1:${process.env.POSTGRES_PORT}/${process.env.POSTGRES_DB}`
|
DATABASE_URL: `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PWD}@127.0.0.1:${process.env.POSTGRES_PORT}/${process.env.POSTGRES_DB}`
|
||||||
}
|
};
|
||||||
case "mysql": return {
|
} else if( testInfo.project.name.includes("mariadb") ){
|
||||||
|
return {
|
||||||
DATABASE_URL: `mysql://${process.env.MARIADB_USER}:${process.env.MARIADB_PWD}@127.0.0.1:${process.env.MARIADB_PORT}/${process.env.MARIADB_DB}`
|
DATABASE_URL: `mysql://${process.env.MARIADB_USER}:${process.env.MARIADB_PWD}@127.0.0.1:${process.env.MARIADB_PORT}/${process.env.MARIADB_DB}`
|
||||||
}
|
};
|
||||||
default: return { I_REALLY_WANT_VOLATILE_STORAGE: true }
|
} else if( testInfo.project.name.includes("mysql") ){
|
||||||
|
return {
|
||||||
|
DATABASE_URL: `mysql://${process.env.MYSQL_USER}:${process.env.MYSQL_PWD}@127.0.0.1:${process.env.MYSQL_PORT}/${process.env.MYSQL_DB}`
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return { I_REALLY_WANT_VOLATILE_STORAGE: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startVaultwarden(browser: Browser, testInfo: TestInfo, env = {}, resetDB: Boolean = true) {
|
async function startVaultwarden(browser: Browser, testInfo: TestInfo, env = {}, resetDB: Boolean = true) {
|
||||||
if( resetDB ){
|
if( resetDB ){
|
||||||
switch(testInfo.project.name) {
|
test.setTimeout(20000);
|
||||||
case "postgres":
|
if( testInfo.project.name.includes("postgres") ){
|
||||||
stopPostgres();
|
stopPostgres();
|
||||||
startPostgres()
|
startPostgres()
|
||||||
break;
|
} else if( testInfo.project.name.includes("mariadb") ){
|
||||||
case "mysql":
|
|
||||||
stopMariaDB();
|
stopMariaDB();
|
||||||
startMariaDB();
|
startMariaDB();
|
||||||
break;
|
} else if( testInfo.project.name.includes("mysql") ){
|
||||||
default:
|
stopMysqlDB();
|
||||||
|
startMysqlDB();
|
||||||
|
} else {
|
||||||
startStopSqlite();
|
startStopSqlite();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -118,14 +141,13 @@ async function stopVaultwarden(proc, testInfo: TestInfo, resetDB: Boolean = true
|
||||||
proc.kill();
|
proc.kill();
|
||||||
|
|
||||||
if( resetDB ){
|
if( resetDB ){
|
||||||
switch(testInfo.project.name) {
|
if( testInfo.project.name.includes("postgres") ){
|
||||||
case "postgres":
|
|
||||||
stopPostgres();
|
stopPostgres();
|
||||||
break;
|
} else if( testInfo.project.name.includes("mariadb") ){
|
||||||
case "mysql":
|
|
||||||
stopMariaDB();
|
stopMariaDB();
|
||||||
break;
|
} else if( testInfo.project.name.includes("mysql") ){
|
||||||
default:
|
stopMysqlDB();
|
||||||
|
} else {
|
||||||
startStopSqlite();
|
startStopSqlite();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
14
playwright/package-lock.json
generiert
14
playwright/package-lock.json
generiert
|
@ -89,20 +89,6 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
|
||||||
"version": "2.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||||
|
|
|
@ -9,7 +9,7 @@ utils.loadEnv();
|
||||||
* See https://playwright.dev/docs/test-configuration.
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: 'tests',
|
testDir: './.',
|
||||||
/* Run tests in files in parallel */
|
/* Run tests in files in parallel */
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
|
|
||||||
|
@ -38,16 +38,61 @@ export default defineConfig({
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: 'sqllite',
|
name: 'sqllite',
|
||||||
|
testMatch: 'tests/*.spec.ts',
|
||||||
|
testIgnore: 'tests/sso_*.spec.ts',
|
||||||
use: { ...devices['Desktop Firefox'] },
|
use: { ...devices['Desktop Firefox'] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'postgres',
|
name: 'postgres',
|
||||||
|
testMatch: 'tests/*.spec.ts',
|
||||||
|
testIgnore: 'tests/sso_*.spec.ts',
|
||||||
|
use: { ...devices['Desktop Firefox'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mariadb',
|
||||||
|
testMatch: 'tests/*.spec.ts',
|
||||||
|
testIgnore: 'tests/sso_*.spec.ts',
|
||||||
use: { ...devices['Desktop Firefox'] },
|
use: { ...devices['Desktop Firefox'] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'mysql',
|
name: 'mysql',
|
||||||
|
testMatch: 'tests/*.spec.ts',
|
||||||
|
testIgnore: 'tests/sso_*.spec.ts',
|
||||||
use: { ...devices['Desktop Firefox'] },
|
use: { ...devices['Desktop Firefox'] },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'sso-setup',
|
||||||
|
testMatch: 'sso-setup.ts',
|
||||||
|
teardown: 'sso-teardown',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sso-sqllite',
|
||||||
|
testMatch: 'tests/sso_*.spec.ts',
|
||||||
|
dependencies: ['sso-setup'],
|
||||||
|
teardown: 'sso-teardown',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sso-postgres',
|
||||||
|
testMatch: 'tests/sso_*.spec.ts',
|
||||||
|
dependencies: ['sso-setup'],
|
||||||
|
teardown: 'sso-teardown',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sso-mariadb',
|
||||||
|
testMatch: 'tests/sso_*.spec.ts',
|
||||||
|
dependencies: ['sso-setup'],
|
||||||
|
teardown: 'sso-teardown',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sso-mysql',
|
||||||
|
testMatch: 'tests/sso_*.spec.ts',
|
||||||
|
dependencies: ['sso-setup'],
|
||||||
|
teardown: 'sso-teardown',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sso-teardown',
|
||||||
|
testMatch: 'sso-teardown.ts',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
globalSetup: require.resolve('./global-setup'),
|
globalSetup: require.resolve('./global-setup'),
|
||||||
|
|
18
playwright/sso-setup.ts
Normale Datei
18
playwright/sso-setup.ts
Normale Datei
|
@ -0,0 +1,18 @@
|
||||||
|
import { test, expect, type TestInfo } from '@playwright/test';
|
||||||
|
|
||||||
|
const { exec } = require('node:child_process');
|
||||||
|
const utils = require('./global-utils');
|
||||||
|
|
||||||
|
utils.loadEnv();
|
||||||
|
|
||||||
|
test.beforeAll('Setup', async () => {
|
||||||
|
var kcPath = process.env.KC_SETUP_PATH;
|
||||||
|
console.log("Starting Keycloak");
|
||||||
|
exec(`ENV=test KC_SETUP_PATH=${kcPath} docker-compose -f ${kcPath}/docker-compose.yml --project-directory . up >> temp/logs/keycloak.log 2>&1`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Keycloak is up', async ({ page }) => {
|
||||||
|
test.setTimeout(60000);
|
||||||
|
await utils.waitFor(process.env.SSO_AUTHORITY, page.context().browser());
|
||||||
|
console.log(`Keycloak running on: ${process.env.SSO_AUTHORITY}`);
|
||||||
|
});
|
17
playwright/sso-teardown.ts
Normale Datei
17
playwright/sso-teardown.ts
Normale Datei
|
@ -0,0 +1,17 @@
|
||||||
|
import { test, type FullConfig } from '@playwright/test';
|
||||||
|
|
||||||
|
const { execSync } = require('node:child_process');
|
||||||
|
const utils = require('./global-utils');
|
||||||
|
|
||||||
|
utils.loadEnv();
|
||||||
|
|
||||||
|
test('Keycloak teardown', async () => {
|
||||||
|
var kcPath = process.env.KC_SETUP_PATH;
|
||||||
|
|
||||||
|
if( process.env.KC_KEEP_RUNNNING == "true" ) {
|
||||||
|
console.log("Keep Keycloak running");
|
||||||
|
} else {
|
||||||
|
console.log("Keycloak stopping");
|
||||||
|
execSync(`ENV=test KC_SETUP_PATH=${kcPath} docker-compose -f ${kcPath}/docker-compose.yml --project-directory . down`);
|
||||||
|
}
|
||||||
|
});
|
|
@ -2,13 +2,31 @@
|
||||||
### Shared Playwright conf test file Vaultwarden and Databases ###
|
### Shared Playwright conf test file Vaultwarden and Databases ###
|
||||||
##################################################################
|
##################################################################
|
||||||
|
|
||||||
#############
|
########################
|
||||||
# Test user #
|
# Docker-compose Config#
|
||||||
#############
|
########################
|
||||||
|
KC_SETUP_PATH=../docker/keycloak
|
||||||
|
KC_KEEP_RUNNNING=false
|
||||||
|
|
||||||
|
###################
|
||||||
|
# Keycloak Config #
|
||||||
|
###################
|
||||||
|
KEYCLOAK_ADMIN=admin
|
||||||
|
KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN}
|
||||||
|
KC_HTTP_HOST=127.0.0.1
|
||||||
|
KC_HTTP_PORT=8081
|
||||||
|
|
||||||
|
# Script parameters (use Keycloak and VaultWarden config too)
|
||||||
|
TEST_REALM=test
|
||||||
|
|
||||||
TEST_USER=test
|
TEST_USER=test
|
||||||
TEST_USER_PASSWORD=${TEST_USER}
|
TEST_USER_PASSWORD=${TEST_USER}
|
||||||
TEST_USER_MAIL="${TEST_USER}@example.com"
|
TEST_USER_MAIL="${TEST_USER}@example.com"
|
||||||
|
|
||||||
|
TEST_USER_2=test2
|
||||||
|
TEST_USER_2_PASSWORD=${TEST_USER_2}
|
||||||
|
TEST_USER_2_MAIL="${TEST_USER_2}@example.com"
|
||||||
|
|
||||||
######################
|
######################
|
||||||
# Vaultwarden Config #
|
# Vaultwarden Config #
|
||||||
######################
|
######################
|
||||||
|
@ -18,6 +36,11 @@ WEB_VAULT_FOLDER=temp/web-vault/
|
||||||
ROCKET_PORT=8001
|
ROCKET_PORT=8001
|
||||||
DOMAIN=http://127.0.0.1:${ROCKET_PORT}
|
DOMAIN=http://127.0.0.1:${ROCKET_PORT}
|
||||||
|
|
||||||
|
SSO_CLIENT_ID=VaultWarden
|
||||||
|
SSO_CLIENT_SECRET=VaultWarden
|
||||||
|
SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM}
|
||||||
|
SSO_PKCE=true
|
||||||
|
|
||||||
###########################
|
###########################
|
||||||
# Docker MariaDb container#
|
# Docker MariaDb container#
|
||||||
###########################
|
###########################
|
||||||
|
@ -27,6 +50,15 @@ MARIADB_USER=vaultwarden
|
||||||
MARIADB_PWD=vaultwarden
|
MARIADB_PWD=vaultwarden
|
||||||
MARIADB_DB=vaultwarden
|
MARIADB_DB=vaultwarden
|
||||||
|
|
||||||
|
###########################
|
||||||
|
# Docker Mysql container#
|
||||||
|
###########################
|
||||||
|
MYSQL_CONTAINER=vw-mysql-test
|
||||||
|
MYSQL_PORT=3309
|
||||||
|
MYSQL_USER=vaultwarden
|
||||||
|
MYSQL_PWD=vaultwarden
|
||||||
|
MYSQL_DB=vaultwarden
|
||||||
|
|
||||||
############################
|
############################
|
||||||
# Docker Postgres container#
|
# Docker Postgres container#
|
||||||
############################
|
############################
|
||||||
|
|
121
playwright/tests/sso_login.spec.ts
Normale Datei
121
playwright/tests/sso_login.spec.ts
Normale Datei
|
@ -0,0 +1,121 @@
|
||||||
|
import { test, expect, type TestInfo } from '@playwright/test';
|
||||||
|
const utils = require('../global-utils');
|
||||||
|
|
||||||
|
utils.loadEnv();
|
||||||
|
|
||||||
|
var proc;
|
||||||
|
|
||||||
|
test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => {
|
||||||
|
proc = await utils.startVaultwarden(browser, testInfo, {
|
||||||
|
SSO_ENABLED: true,
|
||||||
|
SSO_ONLY: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll('Teardown', async ({}, testInfo: TestInfo) => {
|
||||||
|
utils.stopVaultwarden(proc, testInfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Account creation using SSO', async ({ page }) => {
|
||||||
|
// Landing page
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByLabel(/Email address/).fill(process.env.TEST_USER_MAIL);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
// Unlock page
|
||||||
|
await page.getByRole('link', { name: /Enterprise single sign-on/ }).click();
|
||||||
|
|
||||||
|
// Keycloak Login page
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
|
||||||
|
await page.getByLabel(/Username/).fill(process.env.TEST_USER);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(process.env.TEST_USER_PASSWORD);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
|
||||||
|
// Back to Vault create account
|
||||||
|
await expect(page.getByText('Set master password')).toBeVisible();
|
||||||
|
await page.getByLabel('Master password', { exact: true }).fill('Master password');
|
||||||
|
await page.getByLabel('Re-type master password').fill('Master password');
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
|
||||||
|
// We are now in the default vault page
|
||||||
|
await expect(page).toHaveTitle(/Vaults/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SSO login', async ({ page }) => {
|
||||||
|
// Landing page
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByLabel(/Email address/).fill(process.env.TEST_USER_MAIL);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
// Unlock page
|
||||||
|
await page.getByRole('link', { name: /Enterprise single sign-on/ }).click();
|
||||||
|
|
||||||
|
// Keycloak Login page
|
||||||
|
await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible();
|
||||||
|
await page.getByLabel(/Username/).fill(process.env.TEST_USER);
|
||||||
|
await page.getByLabel('Password', { exact: true }).fill(process.env.TEST_USER_PASSWORD);
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
|
||||||
|
// Back to Vault unlock page
|
||||||
|
await expect(page).toHaveTitle('Vaultwarden Web');
|
||||||
|
await page.getByLabel('Master password').fill('Master password');
|
||||||
|
await page.getByRole('button', { name: 'Unlock' }).click();
|
||||||
|
|
||||||
|
// We are now in the default vault page
|
||||||
|
await expect(page).toHaveTitle(/Vaults/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Non SSO login', async ({ page }) => {
|
||||||
|
// Landing page
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByLabel(/Email address/).fill(process.env.TEST_USER_MAIL);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
// Unlock page
|
||||||
|
await page.getByLabel('Master password').fill('Master password');
|
||||||
|
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
||||||
|
|
||||||
|
// We are now in the default vault page
|
||||||
|
await expect(page).toHaveTitle(/Vaults/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('Non SSO login Failure', async ({ page, browser }, testInfo: TestInfo) => {
|
||||||
|
proc = await utils.restartVaultwarden(proc, page, testInfo, {
|
||||||
|
SSO_ENABLED: true,
|
||||||
|
SSO_ONLY: true
|
||||||
|
}, false);
|
||||||
|
|
||||||
|
// Landing page
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByLabel(/Email address/).fill(process.env.TEST_USER_MAIL);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
// Unlock page
|
||||||
|
await page.getByLabel('Master password').fill('Master password');
|
||||||
|
await page.getByRole('button', { name: 'Log in with master password' }).click();
|
||||||
|
|
||||||
|
// An error should appear
|
||||||
|
await page.getByLabel('SSO sign-in is required')
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SSO login Failure', async ({ page }, testInfo: TestInfo) => {
|
||||||
|
proc = await utils.restartVaultwarden(proc, page, testInfo, {
|
||||||
|
SSO_ENABLED: false
|
||||||
|
}, false);
|
||||||
|
|
||||||
|
// Landing page
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByLabel(/Email address/).fill(process.env.TEST_USER_MAIL);
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
// Unlock page
|
||||||
|
await page.getByRole('link', { name: /Enterprise single sign-on/ }).click();
|
||||||
|
|
||||||
|
// SSO identifier page
|
||||||
|
await page.getByLabel('SSO identifier').fill('Random');
|
||||||
|
await page.getByRole('button', { name: 'Log in' }).click();
|
||||||
|
|
||||||
|
// An error should appear
|
||||||
|
await page.getByLabel('SSO sign-in is not available')
|
||||||
|
});
|
|
@ -286,7 +286,7 @@ async fn invite_user(data: Json<InviteData>, _token: AdminToken, mut conn: DbCon
|
||||||
err_code!("User already exists", Status::Conflict.code)
|
err_code!("User already exists", Status::Conflict.code)
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut user = User::new(data.email);
|
let mut user = User::new(data.email, None);
|
||||||
|
|
||||||
async fn _generate_invite(user: &User, conn: &mut DbConn) -> EmptyResult {
|
async fn _generate_invite(user: &User, conn: &mut DbConn) -> EmptyResult {
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
|
|
|
@ -6,8 +6,8 @@ use serde_json::Value;
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{
|
api::{
|
||||||
core::{log_user_event, two_factor::email},
|
core::{log_user_event, two_factor::email},
|
||||||
register_push_device, unregister_push_device, AnonymousNotify, EmptyResult, JsonResult, JsonUpcase, Notify,
|
register_push_device, unregister_push_device, AnonymousNotify, ApiResult, EmptyResult, JsonResult, JsonUpcase,
|
||||||
PasswordOrOtpData, UpdateType,
|
Notify, PasswordOrOtpData, UpdateType,
|
||||||
},
|
},
|
||||||
auth::{decode_delete, decode_invite, decode_verify_email, ClientHeaders, Headers},
|
auth::{decode_delete, decode_invite, decode_verify_email, ClientHeaders, Headers},
|
||||||
crypto,
|
crypto,
|
||||||
|
@ -94,7 +94,7 @@ pub struct SetPasswordData {
|
||||||
MasterPasswordHash: String,
|
MasterPasswordHash: String,
|
||||||
MasterPasswordHint: Option<String>,
|
MasterPasswordHint: Option<String>,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
orgIdentifier: Option<String>,
|
OrgIdentifier: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
|
@ -104,13 +104,6 @@ struct KeysData {
|
||||||
PublicKey: String,
|
PublicKey: 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`.
|
/// Trims whitespace from password hints, and converts blank password hints to `None`.
|
||||||
fn clean_password_hint(password_hint: &Option<String>) -> Option<String> {
|
fn clean_password_hint(password_hint: &Option<String>) -> Option<String> {
|
||||||
match password_hint {
|
match password_hint {
|
||||||
|
@ -183,10 +176,7 @@ pub async fn _register(data: JsonUpcase<RegisterData>, mut conn: DbConn) -> Json
|
||||||
err!("Registration email does not match invite email")
|
err!("Registration email does not match invite email")
|
||||||
}
|
}
|
||||||
} else if Invitation::take(&email, &mut conn).await {
|
} else if Invitation::take(&email, &mut conn).await {
|
||||||
for user_org in UserOrganization::find_invited_by_user(&user.uuid, &mut conn).await.iter_mut() {
|
UserOrganization::confirm_user_invitations(&user.uuid, &mut conn).await?;
|
||||||
user_org.status = UserOrgStatus::Accepted as i32;
|
|
||||||
user_org.save(&mut conn).await?;
|
|
||||||
}
|
|
||||||
user
|
user
|
||||||
} else if CONFIG.is_signup_allowed(&email)
|
} else if CONFIG.is_signup_allowed(&email)
|
||||||
|| (CONFIG.emergency_access_allowed()
|
|| (CONFIG.emergency_access_allowed()
|
||||||
|
@ -202,7 +192,7 @@ pub async fn _register(data: JsonUpcase<RegisterData>, mut conn: DbConn) -> Json
|
||||||
// because the vaultwarden admin can invite anyone, regardless
|
// because the vaultwarden admin can invite anyone, regardless
|
||||||
// of other signup restrictions.
|
// of other signup restrictions.
|
||||||
if Invitation::take(&email, &mut conn).await || CONFIG.is_signup_allowed(&email) {
|
if Invitation::take(&email, &mut conn).await || CONFIG.is_signup_allowed(&email) {
|
||||||
User::new(email.clone())
|
User::new(email.clone(), None)
|
||||||
} else {
|
} else {
|
||||||
err!("Registration not allowed or user already exists")
|
err!("Registration not allowed or user already exists")
|
||||||
}
|
}
|
||||||
|
@ -1004,15 +994,34 @@ struct SecretVerificationRequest {
|
||||||
MasterPasswordHash: String,
|
MasterPasswordHash: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Change the KDF Iterations if necessary
|
||||||
|
pub async fn kdf_upgrade(user: &mut User, pwd_hash: &str, conn: &mut DbConn) -> ApiResult<()> {
|
||||||
|
if user.password_iterations != CONFIG.password_iterations() {
|
||||||
|
user.password_iterations = CONFIG.password_iterations();
|
||||||
|
user.set_password(pwd_hash, None, false, None);
|
||||||
|
|
||||||
|
if let Err(e) = user.save(conn).await {
|
||||||
|
error!("Error updating user: {:#?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[post("/accounts/verify-password", data = "<data>")]
|
#[post("/accounts/verify-password", data = "<data>")]
|
||||||
fn verify_password(data: JsonUpcase<SecretVerificationRequest>, headers: Headers) -> JsonResult {
|
async fn verify_password(
|
||||||
|
data: JsonUpcase<SecretVerificationRequest>,
|
||||||
|
headers: Headers,
|
||||||
|
mut conn: DbConn,
|
||||||
|
) -> JsonResult {
|
||||||
let data: SecretVerificationRequest = data.into_inner().data;
|
let data: SecretVerificationRequest = data.into_inner().data;
|
||||||
let user = headers.user;
|
let mut user = headers.user;
|
||||||
|
|
||||||
if !user.check_valid_password(&data.MasterPasswordHash) {
|
if !user.check_valid_password(&data.MasterPasswordHash) {
|
||||||
err!("Invalid password")
|
err!("Invalid password")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kdf_upgrade(&mut user, &data.MasterPasswordHash, &mut conn).await?;
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"MasterPasswordPolicy": {}, // Required for SSO login with mobile apps
|
"MasterPasswordPolicy": {}, // Required for SSO login with mobile apps
|
||||||
})))
|
})))
|
||||||
|
|
|
@ -228,7 +228,7 @@ async fn send_invite(data: JsonUpcase<EmergencyAccessInviteData>, headers: Heade
|
||||||
invitation.save(&mut conn).await?;
|
invitation.save(&mut conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut user = User::new(email.clone());
|
let mut user = User::new(email.clone(), None);
|
||||||
user.save(&mut conn).await?;
|
user.save(&mut conn).await?;
|
||||||
(user, true)
|
(user, true)
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,6 +58,7 @@ pub fn routes() -> Vec<Route> {
|
||||||
list_policies,
|
list_policies,
|
||||||
list_policies_token,
|
list_policies_token,
|
||||||
list_policies_invited_user,
|
list_policies_invited_user,
|
||||||
|
get_policy_master_password,
|
||||||
get_policy,
|
get_policy,
|
||||||
put_policy,
|
put_policy,
|
||||||
get_organization_tax,
|
get_organization_tax,
|
||||||
|
@ -171,7 +172,7 @@ async fn create_organization(headers: Headers, data: JsonUpcase<OrgData>, mut co
|
||||||
};
|
};
|
||||||
|
|
||||||
let org = Organization::new(data.Name, data.BillingEmail, private_key, public_key);
|
let org = Organization::new(data.Name, data.BillingEmail, private_key, public_key);
|
||||||
let mut user_org = UserOrganization::new(headers.user.uuid, org.uuid.clone());
|
let mut user_org = UserOrganization::new(headers.user.uuid, org.uuid.clone(), None);
|
||||||
let collection = Collection::new(org.uuid.clone(), data.CollectionName, None);
|
let collection = Collection::new(org.uuid.clone(), data.CollectionName, None);
|
||||||
|
|
||||||
user_org.akey = data.Key;
|
user_org.akey = data.Key;
|
||||||
|
@ -305,9 +306,13 @@ async fn get_user_collections(headers: Headers, mut conn: DbConn) -> Json<Value>
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called during the SSO enrollment
|
||||||
|
// The `_identifier` should be the harcoded value returned by `get_org_domain_sso_details`
|
||||||
|
// The returned `Id` will then be passed to `get_policy_master_password` which will mainly ignore it
|
||||||
#[get("/organizations/<_identifier>/auto-enroll-status")]
|
#[get("/organizations/<_identifier>/auto-enroll-status")]
|
||||||
fn get_auto_enroll_status(_identifier: String) -> JsonResult {
|
fn get_auto_enroll_status(_identifier: &str) -> JsonResult {
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
|
"Id": "_",
|
||||||
"ResetPasswordEnabled": false, // Not implemented
|
"ResetPasswordEnabled": false, // Not implemented
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
@ -777,6 +782,9 @@ async fn _get_org_details(org_id: &str, host: &str, user_uuid: &str, conn: &mut
|
||||||
json!(ciphers_json)
|
json!(ciphers_json)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Endpoint called when the user select SSO login (body: `{ "email": "" }`).
|
||||||
|
// Returning a Domain/Organization here allow to prefill it and prevent prompting the user
|
||||||
|
// VaultWarden sso login is not linked to Org so we set a dummy value.
|
||||||
#[post("/organizations/domain/sso/details")]
|
#[post("/organizations/domain/sso/details")]
|
||||||
fn get_org_domain_sso_details() -> JsonResult {
|
fn get_org_domain_sso_details() -> JsonResult {
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
|
@ -904,7 +912,7 @@ async fn send_invite(
|
||||||
invitation.save(&mut conn).await?;
|
invitation.save(&mut conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut user = User::new(email.clone());
|
let mut user = User::new(email.clone(), None);
|
||||||
user.save(&mut conn).await?;
|
user.save(&mut conn).await?;
|
||||||
user
|
user
|
||||||
}
|
}
|
||||||
|
@ -921,7 +929,8 @@ async fn send_invite(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut new_user = UserOrganization::new(user.uuid.clone(), String::from(org_id));
|
let mut new_user =
|
||||||
|
UserOrganization::new(user.uuid.clone(), String::from(org_id), Some(headers.user.email.clone()));
|
||||||
let access_all = data.AccessAll.unwrap_or(false);
|
let access_all = data.AccessAll.unwrap_or(false);
|
||||||
new_user.access_all = access_all;
|
new_user.access_all = access_all;
|
||||||
new_user.atype = new_type;
|
new_user.atype = new_type;
|
||||||
|
@ -1676,17 +1685,22 @@ async fn list_policies_token(org_id: &str, token: &str, mut conn: DbConn) -> Jso
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called during the SSO enrollment.
|
||||||
|
// Since the VW SSO flow is not linked to an organization it will be called with a dummy or undefined `org_id`
|
||||||
#[allow(non_snake_case)]
|
#[allow(non_snake_case)]
|
||||||
#[get("/organizations/<org_id>/policies/invited-user?<userId>")]
|
#[get("/organizations/<org_id>/policies/invited-user?<userId>")]
|
||||||
async fn list_policies_invited_user(org_id: String, userId: String, mut conn: DbConn) -> JsonResult {
|
async fn list_policies_invited_user(org_id: &str, userId: &str, 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() {
|
if userId.is_empty() {
|
||||||
err!("userId must not be empty");
|
err!("userId must not be empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
let policies = OrgPolicy::find_by_org(&org_id, &mut conn).await;
|
let user_orgs = UserOrganization::find_invited_by_user(userId, &mut conn).await;
|
||||||
let policies_json: Vec<Value> = policies.iter().map(OrgPolicy::to_json).collect();
|
let policies_json: Vec<Value> = if user_orgs.into_iter().any(|user_org| user_org.org_uuid == org_id) {
|
||||||
|
let policies = OrgPolicy::find_by_org(org_id, &mut conn).await;
|
||||||
|
policies.iter().map(OrgPolicy::to_json).collect()
|
||||||
|
} else {
|
||||||
|
Vec::with_capacity(0)
|
||||||
|
};
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"Data": policies_json,
|
"Data": policies_json,
|
||||||
|
@ -1695,7 +1709,26 @@ async fn list_policies_invited_user(org_id: String, userId: String, mut conn: Db
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/organizations/<org_id>/policies/<pol_type>")]
|
// Called during the SSO enrollment.
|
||||||
|
#[get("/organizations/<org_id>/policies/master-password", rank = 1)]
|
||||||
|
fn get_policy_master_password(org_id: &str, _headers: Headers) -> JsonResult {
|
||||||
|
let data = match CONFIG.sso_master_password_policy() {
|
||||||
|
Some(policy) => policy,
|
||||||
|
None => "null".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let policy = OrgPolicy {
|
||||||
|
uuid: String::from(org_id),
|
||||||
|
org_uuid: String::from(org_id),
|
||||||
|
atype: OrgPolicyType::MasterPassword as i32,
|
||||||
|
enabled: CONFIG.sso_master_password_policy().is_some(),
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Json(policy.to_json()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/organizations/<org_id>/policies/<pol_type>", rank = 2)]
|
||||||
async fn get_policy(org_id: &str, pol_type: i32, _headers: AdminHeaders, mut conn: DbConn) -> JsonResult {
|
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) {
|
let pol_type_enum = match OrgPolicyType::from_i32(pol_type) {
|
||||||
Some(pt) => pt,
|
Some(pt) => pt,
|
||||||
|
@ -1928,7 +1961,8 @@ async fn import(org_id: &str, data: JsonUpcase<OrgImportData>, headers: Headers,
|
||||||
UserOrgStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
|
UserOrgStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut new_org_user = UserOrganization::new(user.uuid.clone(), String::from(org_id));
|
let mut new_org_user =
|
||||||
|
UserOrganization::new(user.uuid.clone(), String::from(org_id), Some(headers.user.email.clone()));
|
||||||
new_org_user.access_all = false;
|
new_org_user.access_all = false;
|
||||||
new_org_user.atype = UserOrgType::User as i32;
|
new_org_user.atype = UserOrgType::User as i32;
|
||||||
new_org_user.status = user_org_status;
|
new_org_user.status = user_org_status;
|
||||||
|
|
|
@ -92,7 +92,7 @@ async fn ldap_import(data: JsonUpcase<OrgImportData>, token: PublicToken, mut co
|
||||||
Some(user) => user, // exists in vaultwarden
|
Some(user) => user, // exists in vaultwarden
|
||||||
None => {
|
None => {
|
||||||
// User does not exist yet
|
// User does not exist yet
|
||||||
let mut new_user = User::new(user_data.Email.clone());
|
let mut new_user = User::new(user_data.Email.clone(), None);
|
||||||
new_user.save(&mut conn).await?;
|
new_user.save(&mut conn).await?;
|
||||||
|
|
||||||
if !CONFIG.mail_enabled() {
|
if !CONFIG.mail_enabled() {
|
||||||
|
@ -108,7 +108,12 @@ async fn ldap_import(data: JsonUpcase<OrgImportData>, token: PublicToken, mut co
|
||||||
UserOrgStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
|
UserOrgStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut new_org_user = UserOrganization::new(user.uuid.clone(), org_id.clone());
|
let (org_name, org_email) = match Organization::find_by_uuid(&org_id, &mut conn).await {
|
||||||
|
Some(org) => (org.name, org.billing_email),
|
||||||
|
None => err!("Error looking up organization"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut new_org_user = UserOrganization::new(user.uuid.clone(), org_id.clone(), Some(org_email.clone()));
|
||||||
new_org_user.set_external_id(Some(user_data.ExternalId.clone()));
|
new_org_user.set_external_id(Some(user_data.ExternalId.clone()));
|
||||||
new_org_user.access_all = false;
|
new_org_user.access_all = false;
|
||||||
new_org_user.atype = UserOrgType::User as i32;
|
new_org_user.atype = UserOrgType::User as i32;
|
||||||
|
@ -117,11 +122,6 @@ async fn ldap_import(data: JsonUpcase<OrgImportData>, token: PublicToken, mut co
|
||||||
new_org_user.save(&mut conn).await?;
|
new_org_user.save(&mut conn).await?;
|
||||||
|
|
||||||
if CONFIG.mail_enabled() {
|
if CONFIG.mail_enabled() {
|
||||||
let (org_name, org_email) = match Organization::find_by_uuid(&org_id, &mut conn).await {
|
|
||||||
Some(org) => (org.name, org.billing_email),
|
|
||||||
None => err!("Error looking up organization"),
|
|
||||||
};
|
|
||||||
|
|
||||||
mail::send_invite(
|
mail::send_invite(
|
||||||
&user_data.Email,
|
&user_data.Email,
|
||||||
&user.uuid,
|
&user.uuid,
|
||||||
|
|
|
@ -284,10 +284,6 @@ fn sign_duo_values(key: &str, email: &str, ikey: &str, prefix: &str, expire: i64
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn validate_duo_login(email: &str, response: &str, conn: &mut DbConn) -> EmptyResult {
|
pub async fn validate_duo_login(email: &str, response: &str, conn: &mut DbConn) -> EmptyResult {
|
||||||
// email is as entered by the user, so it needs to be normalized before
|
|
||||||
// comparison with auth_user below.
|
|
||||||
let email = &email.to_lowercase();
|
|
||||||
|
|
||||||
let split: Vec<&str> = response.split(':').collect();
|
let split: Vec<&str> = response.split(':').collect();
|
||||||
if split.len() != 2 {
|
if split.len() != 2 {
|
||||||
err!(
|
err!(
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
use chrono::Utc;
|
use chrono::{NaiveDateTime, Utc};
|
||||||
use jsonwebtoken::DecodingKey;
|
|
||||||
use num_traits::FromPrimitive;
|
use num_traits::FromPrimitive;
|
||||||
use rocket::serde::json::Json;
|
|
||||||
use rocket::{
|
use rocket::{
|
||||||
form::{Form, FromForm},
|
form::{Form, FromForm},
|
||||||
http::CookieJar,
|
http::Status,
|
||||||
|
response::Redirect,
|
||||||
|
serde::json::Json,
|
||||||
Route,
|
Route,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
@ -12,23 +12,22 @@ use serde_json::Value;
|
||||||
use crate::{
|
use crate::{
|
||||||
api::{
|
api::{
|
||||||
core::{
|
core::{
|
||||||
accounts::{PreloginData, RegisterData, _prelogin, _register},
|
accounts::{PreloginData, RegisterData, _prelogin, _register, kdf_upgrade},
|
||||||
log_user_event,
|
log_user_event,
|
||||||
two_factor::{authenticator, duo, email, enforce_2fa_policy, webauthn, yubikey},
|
two_factor::{authenticator, duo, email, enforce_2fa_policy, webauthn, yubikey},
|
||||||
},
|
},
|
||||||
push::register_push_device,
|
push::register_push_device,
|
||||||
ApiResult, EmptyResult, JsonResult, JsonUpcase,
|
ApiResult, EmptyResult, JsonResult, JsonUpcase,
|
||||||
},
|
},
|
||||||
auth::{encode_jwt, generate_organization_api_key_login_claims, generate_ssotoken_claims, ClientHeaders, ClientIp},
|
auth,
|
||||||
|
auth::{AuthMethod, AuthMethodScope, ClientHeaders, ClientIp},
|
||||||
db::{models::*, DbConn},
|
db::{models::*, DbConn},
|
||||||
error::MapResult,
|
error::MapResult,
|
||||||
mail, util,
|
mail, sso, util, CONFIG,
|
||||||
util::{CookieManager, CustomRedirect},
|
|
||||||
CONFIG,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn routes() -> Vec<Route> {
|
pub fn routes() -> Vec<Route> {
|
||||||
routes![login, prelogin, identity_register, prevalidate, authorize, oidcsignin]
|
routes![login, prelogin, identity_register, _prevalidate, prevalidate, authorize, oidcsignin, oidcsignin_error]
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/connect/token", data = "<data>")]
|
#[post("/connect/token", data = "<data>")]
|
||||||
|
@ -42,6 +41,7 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, mut conn:
|
||||||
_check_is_some(&data.refresh_token, "refresh_token cannot be blank")?;
|
_check_is_some(&data.refresh_token, "refresh_token cannot be blank")?;
|
||||||
_refresh_login(data, &mut conn).await
|
_refresh_login(data, &mut conn).await
|
||||||
}
|
}
|
||||||
|
"password" if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO sign-in is required"),
|
||||||
"password" => {
|
"password" => {
|
||||||
_check_is_some(&data.client_id, "client_id cannot be blank")?;
|
_check_is_some(&data.client_id, "client_id cannot be blank")?;
|
||||||
_check_is_some(&data.password, "password cannot be blank")?;
|
_check_is_some(&data.password, "password cannot be blank")?;
|
||||||
|
@ -65,15 +65,17 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, mut conn:
|
||||||
|
|
||||||
_api_key_login(data, &mut user_uuid, &mut conn, &client_header.ip).await
|
_api_key_login(data, &mut user_uuid, &mut conn, &client_header.ip).await
|
||||||
}
|
}
|
||||||
"authorization_code" => {
|
"authorization_code" if CONFIG.sso_enabled() => {
|
||||||
_check_is_some(&data.client_id, "client_id cannot be blank")?;
|
_check_is_some(&data.client_id, "client_id cannot be blank")?;
|
||||||
_check_is_some(&data.code, "code 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_identifier, "device_identifier cannot be blank")?;
|
||||||
_check_is_some(&data.device_name, "device_name 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")?;
|
_check_is_some(&data.device_type, "device_type cannot be blank")?;
|
||||||
_authorization_login(data, &mut user_uuid, &mut conn, &client_header.ip).await
|
|
||||||
|
_sso_login(data, &mut user_uuid, &mut conn, &client_header.ip).await
|
||||||
}
|
}
|
||||||
|
"authorization_code" => err!("SSO sign-in is not available"),
|
||||||
t => err!("Invalid type", t),
|
t => err!("Invalid type", t),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -107,32 +109,31 @@ async fn login(data: Form<ConnectData>, client_header: ClientHeaders, mut conn:
|
||||||
login_result
|
login_result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return Status::Unauthorized to trigger logout
|
||||||
async fn _refresh_login(data: ConnectData, conn: &mut DbConn) -> JsonResult {
|
async fn _refresh_login(data: ConnectData, conn: &mut DbConn) -> JsonResult {
|
||||||
// Extract token
|
// Extract token
|
||||||
let token = data.refresh_token.unwrap();
|
let refresh_token = match data.refresh_token {
|
||||||
|
Some(token) => token,
|
||||||
|
None => err_code!("Missing refresh_token", Status::Unauthorized.code),
|
||||||
|
};
|
||||||
|
|
||||||
// Get device by refresh token
|
|
||||||
let mut device = Device::find_by_refresh_token(&token, conn).await.map_res("Invalid refresh token")?;
|
|
||||||
|
|
||||||
let scope = "api offline_access";
|
|
||||||
let scope_vec = vec!["api".into(), "offline_access".into()];
|
|
||||||
|
|
||||||
// Common
|
|
||||||
let user = User::find_by_uuid(&device.user_uuid, conn).await.unwrap();
|
|
||||||
// ---
|
// ---
|
||||||
// Disabled this variable, it was used to generate the JWT
|
// Disabled this variable, it was used to generate the JWT
|
||||||
// Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out
|
// Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out
|
||||||
// See: https://github.com/dani-garcia/vaultwarden/issues/4156
|
// See: https://github.com/dani-garcia/vaultwarden/issues/4156
|
||||||
// ---
|
// ---
|
||||||
// let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, conn).await;
|
// let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, conn).await;
|
||||||
let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec);
|
match auth::refresh_tokens(&refresh_token, conn).await {
|
||||||
|
Err(err) => err_code!(err.to_string(), Status::Unauthorized.code),
|
||||||
|
Ok((mut device, user, auth_tokens)) => {
|
||||||
|
// Save to update `device.updated_at` to track usage
|
||||||
device.save(conn).await?;
|
device.save(conn).await?;
|
||||||
|
|
||||||
let result = json!({
|
let result = json!({
|
||||||
"access_token": access_token,
|
"refresh_token": auth_tokens.refresh_token(),
|
||||||
"expires_in": expires_in,
|
"access_token": auth_tokens.access_token(),
|
||||||
|
"expires_in": auth_tokens.expires_in(),
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
"refresh_token": device.refresh_token,
|
|
||||||
"Key": user.akey,
|
"Key": user.akey,
|
||||||
"PrivateKey": user.private_key,
|
"PrivateKey": user.private_key,
|
||||||
|
|
||||||
|
@ -141,146 +142,121 @@ async fn _refresh_login(data: ConnectData, conn: &mut DbConn) -> JsonResult {
|
||||||
"KdfMemory": user.client_kdf_memory,
|
"KdfMemory": user.client_kdf_memory,
|
||||||
"KdfParallelism": user.client_kdf_parallelism,
|
"KdfParallelism": user.client_kdf_parallelism,
|
||||||
"ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing
|
"ResetMasterPassword": false, // TODO: according to official server seems something like: user.password_hash.is_empty(), but would need testing
|
||||||
"scope": scope,
|
"scope": auth_tokens.scope(),
|
||||||
"unofficialServer": true,
|
"unofficialServer": true,
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
struct TokenPayload {
|
|
||||||
exp: i64,
|
|
||||||
email: Option<String>,
|
|
||||||
nonce: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn _authorization_login(
|
// After exchanging the code we need to check first if 2FA is needed before continuing
|
||||||
data: ConnectData,
|
async fn _sso_login(data: ConnectData, user_uuid: &mut Option<String>, conn: &mut DbConn, ip: &ClientIp) -> JsonResult {
|
||||||
user_uuid: &mut Option<String>,
|
AuthMethod::Sso.check_scope(data.scope.as_ref())?;
|
||||||
conn: &mut DbConn,
|
|
||||||
ip: &ClientIp,
|
// Ratelimit the login
|
||||||
) -> JsonResult {
|
crate::ratelimit::check_limit_login(&ip.ip)?;
|
||||||
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() {
|
let code = match data.code.as_ref() {
|
||||||
None => err!("Got no code in OIDC data"),
|
None => err!("Got no code in OIDC data"),
|
||||||
Some(code) => code,
|
Some(code) => code,
|
||||||
};
|
};
|
||||||
|
|
||||||
let (refresh_token, id_token, user_info) = match get_auth_code_access_token(code).await {
|
let user_infos = sso::exchange_code(code, conn).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();
|
// Will trigger 2FA flow if needed
|
||||||
validation.insecure_disable_signature_validation();
|
let user_data = match SsoUser::find_by_identifier_or_email(&user_infos.identifier, &user_infos.email, conn).await {
|
||||||
|
None => None,
|
||||||
|
Some((user, None)) if user.private_key.is_some() && !CONFIG.sso_signups_match_email() => {
|
||||||
|
error!(
|
||||||
|
"Login failure ({}), existing non SSO user ({}) with same email ({}) and association is disabled",
|
||||||
|
user_infos.identifier, user.uuid, user.email
|
||||||
|
);
|
||||||
|
err_silent!("Existing non SSO user with same email")
|
||||||
|
}
|
||||||
|
Some((user, Some(sso_user))) if sso_user.identifier != user_infos.identifier => {
|
||||||
|
error!(
|
||||||
|
"Login failure ({}), existing SSO user ({}) with same email ({})",
|
||||||
|
user_infos.identifier, user.uuid, user.email
|
||||||
|
);
|
||||||
|
err_silent!("Existing non SSO user with same email")
|
||||||
|
}
|
||||||
|
Some((user, sso_user)) => {
|
||||||
|
let (mut device, new_device) = get_device(&data, conn, &user).await?;
|
||||||
|
let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, conn).await?;
|
||||||
|
|
||||||
let token =
|
Some((user, device, new_device, twofactor_token, sso_user))
|
||||||
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 {
|
// We passed 2FA get full user informations
|
||||||
user.verified_at = Some(Utc::now().naive_utc());
|
let auth_user = sso::redeem(&user_infos.state, conn).await?;
|
||||||
|
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let (user, mut device, new_device, twofactor_token, sso_user) = match user_data {
|
||||||
|
None => {
|
||||||
|
if !CONFIG.is_email_domain_allowed(&user_infos.email) {
|
||||||
|
err!("Email domain not allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user_infos.email_verified.unwrap_or(true) {
|
||||||
|
err!("Email needs to be verified before you can use VaultWarden");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut user = User::new(user_infos.email, user_infos.user_name);
|
||||||
|
user.verified_at = Some(now);
|
||||||
user.save(conn).await?;
|
user.save(conn).await?;
|
||||||
|
|
||||||
|
let (device, new_device) = get_device(&data, conn, &user).await?;
|
||||||
|
|
||||||
|
(user, device, new_device, None, None)
|
||||||
|
}
|
||||||
|
Some((mut user, device, new_device, twofactor_token, sso_user)) if user.private_key.is_none() => {
|
||||||
|
// User was invited a stub was created
|
||||||
|
user.verified_at = Some(now);
|
||||||
|
if let Some(user_name) = user_infos.user_name {
|
||||||
|
user.name = user_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !CONFIG.mail_enabled() {
|
||||||
|
UserOrganization::confirm_user_invitations(&user.uuid, conn).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.save(conn).await?;
|
||||||
|
(user, device, new_device, twofactor_token, sso_user)
|
||||||
|
}
|
||||||
|
Some((user, device, new_device, twofactor_token, sso_user)) => {
|
||||||
|
if user.email != user_infos.email {
|
||||||
|
if CONFIG.mail_enabled() {
|
||||||
|
mail::send_sso_change_email(&user_infos.email).await?;
|
||||||
|
}
|
||||||
|
info!("User {} email changed in SSO provider from {} to {}", user.uuid, user.email, user_infos.email);
|
||||||
|
}
|
||||||
|
(user, device, new_device, twofactor_token, sso_user)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if sso_user.is_none() {
|
||||||
|
let user_sso = SsoUser {
|
||||||
|
user_uuid: user.uuid.clone(),
|
||||||
|
identifier: user_infos.identifier,
|
||||||
|
};
|
||||||
|
user_sso.save(conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the user_uuid here to be passed back used for event logging.
|
// Set the user_uuid here to be passed back used for event logging.
|
||||||
*user_uuid = Some(user.uuid.clone());
|
*user_uuid = Some(user.uuid.clone());
|
||||||
|
|
||||||
let (mut device, new_device) = get_device(&data, conn, &user).await;
|
let auth_tokens = sso::create_auth_tokens(
|
||||||
|
&device,
|
||||||
|
&user,
|
||||||
|
auth_user.refresh_token,
|
||||||
|
&auth_user.access_token,
|
||||||
|
auth_user.expires_in,
|
||||||
|
)?;
|
||||||
|
|
||||||
let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, true, conn).await?;
|
authenticated_response(&user, &mut device, new_device, auth_tokens, twofactor_token, &now, conn, ip).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(
|
async fn _password_login(
|
||||||
|
@ -290,19 +266,11 @@ async fn _password_login(
|
||||||
ip: &ClientIp,
|
ip: &ClientIp,
|
||||||
) -> JsonResult {
|
) -> JsonResult {
|
||||||
// Validate scope
|
// Validate scope
|
||||||
let scope = data.scope.as_ref().unwrap();
|
AuthMethod::Password.check_scope(data.scope.as_ref())?;
|
||||||
if scope != "api offline_access" {
|
|
||||||
err!("Scope not supported")
|
|
||||||
}
|
|
||||||
let scope_vec = vec!["api".into(), "offline_access".into()];
|
|
||||||
|
|
||||||
// Ratelimit the login
|
// Ratelimit the login
|
||||||
crate::ratelimit::check_limit_login(&ip.ip)?;
|
crate::ratelimit::check_limit_login(&ip.ip)?;
|
||||||
|
|
||||||
if CONFIG.sso_enabled() && CONFIG.sso_only() {
|
|
||||||
err!("SSO sign-in is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the user
|
// Get the user
|
||||||
let username = data.username.as_ref().unwrap().trim();
|
let username = data.username.as_ref().unwrap().trim();
|
||||||
let mut user = match User::find_by_mail(username, conn).await {
|
let mut user = match User::find_by_mail(username, conn).await {
|
||||||
|
@ -345,15 +313,7 @@ async fn _password_login(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Change the KDF Iterations
|
kdf_upgrade(&mut user, password, conn).await?;
|
||||||
if user.password_iterations != CONFIG.password_iterations() {
|
|
||||||
user.password_iterations = CONFIG.password_iterations();
|
|
||||||
user.set_password(password, None, false, None);
|
|
||||||
|
|
||||||
if let Err(e) = user.save(conn).await {
|
|
||||||
error!("Error updating user: {:#?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the user is disabled
|
// Check if the user is disabled
|
||||||
if !user.enabled {
|
if !user.enabled {
|
||||||
|
@ -400,12 +360,28 @@ async fn _password_login(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let (mut device, new_device) = get_device(&data, conn, &user).await;
|
let (mut device, new_device) = get_device(&data, conn, &user).await?;
|
||||||
|
|
||||||
let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, false, conn).await?;
|
let twofactor_token = twofactor_auth(&user, &data, &mut device, ip, conn).await?;
|
||||||
|
|
||||||
|
let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password);
|
||||||
|
|
||||||
|
authenticated_response(&user, &mut device, new_device, auth_tokens, twofactor_token, &now, conn, ip).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
async fn authenticated_response(
|
||||||
|
user: &User,
|
||||||
|
device: &mut Device,
|
||||||
|
new_device: bool,
|
||||||
|
auth_tokens: auth::AuthTokens,
|
||||||
|
twofactor_token: Option<String>,
|
||||||
|
now: &NaiveDateTime,
|
||||||
|
conn: &mut DbConn,
|
||||||
|
ip: &ClientIp,
|
||||||
|
) -> JsonResult {
|
||||||
if CONFIG.mail_enabled() && new_device {
|
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 {
|
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);
|
error!("Error sending new device email: {:#?}", e);
|
||||||
|
|
||||||
if CONFIG.require_device_email() {
|
if CONFIG.require_device_email() {
|
||||||
|
@ -421,28 +397,19 @@ async fn _password_login(
|
||||||
|
|
||||||
// register push device
|
// register push device
|
||||||
if !new_device {
|
if !new_device {
|
||||||
register_push_device(&mut device, conn).await?;
|
register_push_device(device, conn).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Common
|
// Save to update `device.updated_at` to track usage
|
||||||
// ---
|
|
||||||
// Disabled this variable, it was used to generate the JWT
|
|
||||||
// Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out
|
|
||||||
// See: https://github.com/dani-garcia/vaultwarden/issues/4156
|
|
||||||
// ---
|
|
||||||
// let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, conn).await;
|
|
||||||
let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec);
|
|
||||||
device.save(conn).await?;
|
device.save(conn).await?;
|
||||||
|
|
||||||
let mut result = json!({
|
let mut result = json!({
|
||||||
"access_token": access_token,
|
"access_token": auth_tokens.access_token(),
|
||||||
"expires_in": expires_in,
|
"expires_in": auth_tokens.expires_in(),
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
"refresh_token": device.refresh_token,
|
"refresh_token": auth_tokens.refresh_token(),
|
||||||
"Key": user.akey,
|
"Key": user.akey,
|
||||||
"PrivateKey": user.private_key,
|
"PrivateKey": user.private_key,
|
||||||
//"TwoFactorToken": "11122233333444555666777888999"
|
|
||||||
|
|
||||||
"Kdf": user.client_kdf_type,
|
"Kdf": user.client_kdf_type,
|
||||||
"KdfIterations": user.client_kdf_iter,
|
"KdfIterations": user.client_kdf_iter,
|
||||||
"KdfMemory": user.client_kdf_memory,
|
"KdfMemory": user.client_kdf_memory,
|
||||||
|
@ -453,7 +420,7 @@ async fn _password_login(
|
||||||
"object": "masterPasswordPolicy",
|
"object": "masterPasswordPolicy",
|
||||||
},
|
},
|
||||||
|
|
||||||
"scope": scope,
|
"scope": auth_tokens.scope(),
|
||||||
"unofficialServer": true,
|
"unofficialServer": true,
|
||||||
"UserDecryptionOptions": {
|
"UserDecryptionOptions": {
|
||||||
"HasMasterPassword": !user.password_hash.is_empty(),
|
"HasMasterPassword": !user.password_hash.is_empty(),
|
||||||
|
@ -465,7 +432,7 @@ async fn _password_login(
|
||||||
result["TwoFactorToken"] = Value::String(token);
|
result["TwoFactorToken"] = Value::String(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("User {} logged in successfully. IP: {}", username, ip.ip);
|
info!("User {} logged in successfully. IP: {}", user.email, ip.ip);
|
||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -479,9 +446,9 @@ async fn _api_key_login(
|
||||||
crate::ratelimit::check_limit_login(&ip.ip)?;
|
crate::ratelimit::check_limit_login(&ip.ip)?;
|
||||||
|
|
||||||
// Validate scope
|
// Validate scope
|
||||||
match data.scope.as_ref().unwrap().as_ref() {
|
match data.scope.as_ref() {
|
||||||
"api" => _user_api_key_login(data, user_uuid, conn, ip).await,
|
Some(scope) if scope == &AuthMethod::UserApiKey.scope() => _user_api_key_login(data, user_uuid, conn, ip).await,
|
||||||
"api.organization" => _organization_api_key_login(data, conn, ip).await,
|
Some(scope) if scope == &AuthMethod::OrgApiKey.scope() => _organization_api_key_login(data, conn, ip).await,
|
||||||
_ => err!("Scope not supported"),
|
_ => err!("Scope not supported"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -529,7 +496,7 @@ async fn _user_api_key_login(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let (mut device, new_device) = get_device(&data, conn, &user).await;
|
let (mut device, new_device) = get_device(&data, conn, &user).await?;
|
||||||
|
|
||||||
if CONFIG.mail_enabled() && new_device {
|
if CONFIG.mail_enabled() && new_device {
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
|
@ -547,15 +514,15 @@ async fn _user_api_key_login(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Common
|
|
||||||
let scope_vec = vec!["api".into()];
|
|
||||||
// ---
|
// ---
|
||||||
// Disabled this variable, it was used to generate the JWT
|
// Disabled this variable, it was used to generate the JWT
|
||||||
// Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out
|
// Because this might get used in the future, and is add by the Bitwarden Server, lets keep it, but then commented out
|
||||||
// See: https://github.com/dani-garcia/vaultwarden/issues/4156
|
// See: https://github.com/dani-garcia/vaultwarden/issues/4156
|
||||||
// ---
|
// ---
|
||||||
// let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, conn).await;
|
// let orgs = UserOrganization::find_confirmed_by_user(&user.uuid, conn).await;
|
||||||
let (access_token, expires_in) = device.refresh_tokens(&user, scope_vec);
|
let access_claims = auth::LoginJwtClaims::default(&device, &user, &auth::AuthMethod::UserApiKey);
|
||||||
|
|
||||||
|
// Save to update `device.updated_at` to track usage
|
||||||
device.save(conn).await?;
|
device.save(conn).await?;
|
||||||
|
|
||||||
info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip);
|
info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip);
|
||||||
|
@ -563,8 +530,8 @@ async fn _user_api_key_login(
|
||||||
// Note: No refresh_token is returned. The CLI just repeats the
|
// Note: No refresh_token is returned. The CLI just repeats the
|
||||||
// client_credentials login flow when the existing token expires.
|
// client_credentials login flow when the existing token expires.
|
||||||
let result = json!({
|
let result = json!({
|
||||||
"access_token": access_token,
|
"access_token": access_claims.token(),
|
||||||
"expires_in": expires_in,
|
"expires_in": access_claims.expires_in(),
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
"Key": user.akey,
|
"Key": user.akey,
|
||||||
"PrivateKey": user.private_key,
|
"PrivateKey": user.private_key,
|
||||||
|
@ -574,7 +541,7 @@ async fn _user_api_key_login(
|
||||||
"KdfMemory": user.client_kdf_memory,
|
"KdfMemory": user.client_kdf_memory,
|
||||||
"KdfParallelism": user.client_kdf_parallelism,
|
"KdfParallelism": user.client_kdf_parallelism,
|
||||||
"ResetMasterPassword": false, // TODO: Same as above
|
"ResetMasterPassword": false, // TODO: Same as above
|
||||||
"scope": "api",
|
"scope": auth::AuthMethod::UserApiKey.scope(),
|
||||||
"unofficialServer": true,
|
"unofficialServer": true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -599,20 +566,20 @@ async fn _organization_api_key_login(data: ConnectData, conn: &mut DbConn, ip: &
|
||||||
err!("Incorrect client_secret", format!("IP: {}. Organization: {}.", ip.ip, org_api_key.org_uuid))
|
err!("Incorrect client_secret", format!("IP: {}. Organization: {}.", ip.ip, org_api_key.org_uuid))
|
||||||
}
|
}
|
||||||
|
|
||||||
let claim = generate_organization_api_key_login_claims(org_api_key.uuid, org_api_key.org_uuid);
|
let claim = auth::generate_organization_api_key_login_claims(org_api_key.uuid, org_api_key.org_uuid);
|
||||||
let access_token = crate::auth::encode_jwt(&claim);
|
let access_token = auth::encode_jwt(&claim);
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
"expires_in": 3600,
|
"expires_in": 3600,
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
"scope": "api.organization",
|
"scope": auth::AuthMethod::OrgApiKey.scope(),
|
||||||
"unofficialServer": true,
|
"unofficialServer": true,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Retrieves an existing device or creates a new device from ConnectData and the User
|
/// Retrieves an existing device or creates a new device from ConnectData and the User
|
||||||
async fn get_device(data: &ConnectData, conn: &mut DbConn, user: &User) -> (Device, bool) {
|
async fn get_device(data: &ConnectData, conn: &mut DbConn, user: &User) -> ApiResult<(Device, bool)> {
|
||||||
// On iOS, device_type sends "iOS", on others it sends a number
|
// On iOS, device_type sends "iOS", on others it sends a number
|
||||||
// When unknown or unable to parse, return 14, which is 'Unknown Browser'
|
// When unknown or unable to parse, return 14, which is 'Unknown Browser'
|
||||||
let device_type = util::try_parse_string(data.device_type.as_ref()).unwrap_or(14);
|
let device_type = util::try_parse_string(data.device_type.as_ref()).unwrap_or(14);
|
||||||
|
@ -624,12 +591,13 @@ async fn get_device(data: &ConnectData, conn: &mut DbConn, user: &User) -> (Devi
|
||||||
let device = match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await {
|
let device = match Device::find_by_uuid_and_user(&device_id, &user.uuid, conn).await {
|
||||||
Some(device) => device,
|
Some(device) => device,
|
||||||
None => {
|
None => {
|
||||||
|
let device = Device::new(device_id, user.uuid.clone(), device_name, device_type);
|
||||||
new_device = true;
|
new_device = true;
|
||||||
Device::new(device_id, user.uuid.clone(), device_name, device_type)
|
device
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
(device, new_device)
|
Ok((device, new_device))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn twofactor_auth(
|
async fn twofactor_auth(
|
||||||
|
@ -637,7 +605,6 @@ async fn twofactor_auth(
|
||||||
data: &ConnectData,
|
data: &ConnectData,
|
||||||
device: &mut Device,
|
device: &mut Device,
|
||||||
ip: &ClientIp,
|
ip: &ClientIp,
|
||||||
is_sso: bool,
|
|
||||||
conn: &mut DbConn,
|
conn: &mut DbConn,
|
||||||
) -> ApiResult<Option<String>> {
|
) -> ApiResult<Option<String>> {
|
||||||
let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await;
|
let twofactors = TwoFactor::find_by_user(&user.uuid, conn).await;
|
||||||
|
@ -655,17 +622,7 @@ async fn twofactor_auth(
|
||||||
|
|
||||||
let twofactor_code = match data.two_factor_token {
|
let twofactor_code = match data.two_factor_token {
|
||||||
Some(ref code) => code,
|
Some(ref code) => code,
|
||||||
None => {
|
None => err_json!(_json_err_twofactor(&twofactor_ids, &user.uuid, conn).await?, "2FA token not provided"),
|
||||||
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);
|
let selected_twofactor = twofactors.into_iter().find(|tf| tf.atype == selected_id && tf.enabled);
|
||||||
|
@ -681,9 +638,7 @@ async fn twofactor_auth(
|
||||||
}
|
}
|
||||||
Some(TwoFactorType::Webauthn) => webauthn::validate_webauthn_login(&user.uuid, twofactor_code, conn).await?,
|
Some(TwoFactorType::Webauthn) => webauthn::validate_webauthn_login(&user.uuid, twofactor_code, conn).await?,
|
||||||
Some(TwoFactorType::YubiKey) => yubikey::validate_yubikey_login(twofactor_code, &selected_data?).await?,
|
Some(TwoFactorType::YubiKey) => yubikey::validate_yubikey_login(twofactor_code, &selected_data?).await?,
|
||||||
Some(TwoFactorType::Duo) => {
|
Some(TwoFactorType::Duo) => duo::validate_duo_login(&user.email, twofactor_code, conn).await?,
|
||||||
duo::validate_duo_login(data.username.as_ref().unwrap().trim(), twofactor_code, conn).await?
|
|
||||||
}
|
|
||||||
Some(TwoFactorType::Email) => {
|
Some(TwoFactorType::Email) => {
|
||||||
email::validate_email_code_str(&user.uuid, twofactor_code, &selected_data?, conn).await?
|
email::validate_email_code_str(&user.uuid, twofactor_code, &selected_data?, conn).await?
|
||||||
}
|
}
|
||||||
|
@ -711,12 +666,13 @@ async fn twofactor_auth(
|
||||||
|
|
||||||
TwoFactorIncomplete::mark_complete(&user.uuid, &device.uuid, conn).await?;
|
TwoFactorIncomplete::mark_complete(&user.uuid, &device.uuid, conn).await?;
|
||||||
|
|
||||||
if !CONFIG.disable_2fa_remember() && remember == 1 {
|
let two_factor = if !CONFIG.disable_2fa_remember() && remember == 1 {
|
||||||
Ok(Some(device.refresh_twofactor_remember()))
|
Some(device.refresh_twofactor_remember())
|
||||||
} else {
|
} else {
|
||||||
device.delete_twofactor_remember();
|
device.delete_twofactor_remember();
|
||||||
Ok(None)
|
None
|
||||||
}
|
};
|
||||||
|
Ok(two_factor)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn _selected_data(tf: Option<TwoFactor>) -> ApiResult<String> {
|
fn _selected_data(tf: Option<TwoFactor>) -> ApiResult<String> {
|
||||||
|
@ -868,176 +824,120 @@ fn _check_is_some<T>(value: &Option<T>, msg: &str) -> EmptyResult {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deprecated but still needed for Mobile apps
|
||||||
#[get("/account/prevalidate")]
|
#[get("/account/prevalidate")]
|
||||||
#[allow(non_snake_case)]
|
fn _prevalidate() -> JsonResult {
|
||||||
|
prevalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/sso/prevalidate")]
|
||||||
fn prevalidate() -> JsonResult {
|
fn prevalidate() -> JsonResult {
|
||||||
let claims = generate_ssotoken_claims();
|
if CONFIG.sso_enabled() {
|
||||||
let ssotoken = encode_jwt(&claims);
|
let sso_token = sso::encode_ssotoken_claims();
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"token": ssotoken,
|
"token": sso_token,
|
||||||
})))
|
})))
|
||||||
|
} else {
|
||||||
|
err!("SSO sign-in is not available")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use openidconnect::core::{CoreClient, CoreProviderMetadata, CoreResponseType, CoreUserInfoClaims};
|
#[get("/connect/oidc-signin?<code>&<state>", rank = 1)]
|
||||||
use openidconnect::reqwest::async_http_client;
|
async fn oidcsignin(code: String, state: String, conn: DbConn) -> ApiResult<Redirect> {
|
||||||
use openidconnect::{
|
oidcsignin_redirect(
|
||||||
AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, OAuth2TokenResponse,
|
state.clone(),
|
||||||
RedirectUrl, Scope,
|
sso::OIDCCodeWrapper::Ok {
|
||||||
};
|
code,
|
||||||
|
state,
|
||||||
async fn get_client_from_sso_config() -> ApiResult<CoreClient> {
|
},
|
||||||
let redirect = CONFIG.sso_callback_path();
|
&conn,
|
||||||
let client_id = ClientId::new(CONFIG.sso_client_id());
|
)
|
||||||
let client_secret = ClientSecret::new(CONFIG.sso_client_secret());
|
.await
|
||||||
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>")]
|
// Bitwarden client appear to only care for code and state so we pipe it through
|
||||||
fn oidcsignin(code: String, jar: &CookieJar<'_>, _conn: DbConn) -> ApiResult<CustomRedirect> {
|
// cf: https://github.com/bitwarden/clients/blob/8e46ef1ae5be8b62b0d3d0b9d1b1c62088a04638/libs/angular/src/auth/components/sso.component.ts#L68C11-L68C23)
|
||||||
let cookiemanager = CookieManager::new(jar);
|
#[get("/connect/oidc-signin?<state>&<error>&<error_description>", rank = 2)]
|
||||||
|
async fn oidcsignin_error(
|
||||||
let redirect_uri = match cookiemanager.get_cookie("redirect_uri".to_string()) {
|
state: String,
|
||||||
None => err!("No redirect_uri in cookie"),
|
error: String,
|
||||||
Some(uri) => uri,
|
error_description: Option<String>,
|
||||||
};
|
conn: DbConn,
|
||||||
let orig_state = match cookiemanager.get_cookie("state".to_string()) {
|
) -> ApiResult<Redirect> {
|
||||||
None => err!("No state in cookie"),
|
oidcsignin_redirect(
|
||||||
Some(state) => state,
|
state.clone(),
|
||||||
};
|
sso::OIDCCodeWrapper::Error {
|
||||||
|
state,
|
||||||
cookiemanager.delete_cookie("redirect_uri".to_string());
|
error,
|
||||||
cookiemanager.delete_cookie("state".to_string());
|
error_description,
|
||||||
|
},
|
||||||
let redirect = CustomRedirect {
|
&conn,
|
||||||
url: format!("{redirect_uri}?code={code}&state={orig_state}"),
|
)
|
||||||
headers: vec![],
|
.await
|
||||||
};
|
|
||||||
|
|
||||||
Ok(redirect)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(FromForm)]
|
// iss and scope parameters are needed for redirection to work on IOS.
|
||||||
#[allow(non_snake_case)]
|
async fn oidcsignin_redirect(state: String, wrapper: sso::OIDCCodeWrapper, conn: &DbConn) -> ApiResult<Redirect> {
|
||||||
|
let code = sso::encode_code_claims(wrapper);
|
||||||
|
|
||||||
|
let nonce = match SsoNonce::find(&state, conn).await {
|
||||||
|
Some(n) => n,
|
||||||
|
None => err!(format!("Failed to retrive redirect_uri with {state}")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut url = match url::Url::parse(&nonce.redirect_uri) {
|
||||||
|
Ok(url) => url,
|
||||||
|
Err(err) => err!(format!("Failed to parse redirect uri ({}): {err}", nonce.redirect_uri)),
|
||||||
|
};
|
||||||
|
|
||||||
|
url.query_pairs_mut()
|
||||||
|
.append_pair("code", &code)
|
||||||
|
.append_pair("state", &state)
|
||||||
|
.append_pair("scope", &AuthMethod::Sso.scope())
|
||||||
|
.append_pair("iss", &CONFIG.domain());
|
||||||
|
|
||||||
|
debug!("Redirection to {url}");
|
||||||
|
|
||||||
|
Ok(Redirect::temporary(String::from(url)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, FromForm)]
|
||||||
struct AuthorizeData {
|
struct AuthorizeData {
|
||||||
#[allow(unused)]
|
|
||||||
#[field(name = uncased("client_id"))]
|
#[field(name = uncased("client_id"))]
|
||||||
#[field(name = uncased("clientid"))]
|
#[field(name = uncased("clientid"))]
|
||||||
client_id: Option<String>,
|
client_id: String,
|
||||||
#[field(name = uncased("redirect_uri"))]
|
#[field(name = uncased("redirect_uri"))]
|
||||||
#[field(name = uncased("redirecturi"))]
|
#[field(name = uncased("redirecturi"))]
|
||||||
redirect_uri: Option<String>,
|
redirect_uri: String,
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
#[field(name = uncased("response_type"))]
|
|
||||||
#[field(name = uncased("responsetype"))]
|
|
||||||
response_type: Option<String>,
|
response_type: Option<String>,
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
#[field(name = uncased("scope"))]
|
|
||||||
scope: Option<String>,
|
scope: Option<String>,
|
||||||
#[field(name = uncased("state"))]
|
state: String,
|
||||||
state: Option<String>,
|
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
#[field(name = uncased("code_challenge"))]
|
|
||||||
code_challenge: Option<String>,
|
code_challenge: Option<String>,
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
#[field(name = uncased("code_challenge_method"))]
|
|
||||||
code_challenge_method: Option<String>,
|
code_challenge_method: Option<String>,
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
#[field(name = uncased("response_mode"))]
|
|
||||||
response_mode: Option<String>,
|
response_mode: Option<String>,
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
#[field(name = uncased("domain_hint"))]
|
|
||||||
domain_hint: Option<String>,
|
domain_hint: Option<String>,
|
||||||
#[allow(unused)]
|
#[allow(unused)]
|
||||||
#[field(name = uncased("ssoToken"))]
|
#[field(name = uncased("ssoToken"))]
|
||||||
ssoToken: Option<String>,
|
sso_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The `redirect_uri` will change depending of the client (web, android, ios ..)
|
||||||
#[get("/connect/authorize?<data..>")]
|
#[get("/connect/authorize?<data..>")]
|
||||||
async fn authorize(data: AuthorizeData, jar: &CookieJar<'_>, mut conn: DbConn) -> ApiResult<CustomRedirect> {
|
async fn authorize(data: AuthorizeData, conn: DbConn) -> ApiResult<Redirect> {
|
||||||
let cookiemanager = CookieManager::new(jar);
|
let AuthorizeData {
|
||||||
match get_client_from_sso_config().await {
|
client_id,
|
||||||
Ok(client) => {
|
redirect_uri,
|
||||||
let (auth_url, _csrf_state, nonce) = client
|
state,
|
||||||
.authorize_url(
|
..
|
||||||
AuthenticationFlow::<CoreResponseType>::AuthorizationCode,
|
} = data;
|
||||||
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());
|
let auth_url = sso::authorize_url(state, &client_id, &redirect_uri, conn).await?;
|
||||||
sso_nonce.save(&mut conn).await?;
|
|
||||||
|
|
||||||
let redirect_uri = match data.redirect_uri {
|
Ok(Redirect::temporary(String::from(auth_url)))
|
||||||
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"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,7 +36,7 @@ use crate::db::{models::User, DbConn};
|
||||||
use crate::util;
|
use crate::util;
|
||||||
|
|
||||||
// Type aliases for API methods results
|
// Type aliases for API methods results
|
||||||
type ApiResult<T> = Result<T, crate::error::Error>;
|
pub type ApiResult<T> = Result<T, crate::error::Error>;
|
||||||
pub type JsonResult = ApiResult<Json<Value>>;
|
pub type JsonResult = ApiResult<Json<Value>>;
|
||||||
pub type EmptyResult = ApiResult<()>;
|
pub type EmptyResult = ApiResult<()>;
|
||||||
|
|
||||||
|
|
267
src/auth.rs
267
src/auth.rs
|
@ -1,6 +1,5 @@
|
||||||
// JWT Handling
|
// JWT Handling
|
||||||
//
|
use chrono::{DateTime, TimeDelta, Utc};
|
||||||
use chrono::{TimeDelta, Utc};
|
|
||||||
use num_traits::FromPrimitive;
|
use num_traits::FromPrimitive;
|
||||||
use once_cell::sync::{Lazy, OnceCell};
|
use once_cell::sync::{Lazy, OnceCell};
|
||||||
|
|
||||||
|
@ -9,18 +8,29 @@ use openssl::rsa::Rsa;
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
use serde::ser::Serialize;
|
use serde::ser::Serialize;
|
||||||
|
|
||||||
use crate::{error::Error, CONFIG};
|
use crate::{
|
||||||
|
api::ApiResult,
|
||||||
|
db::{
|
||||||
|
models::{Collection, Device, User, UserOrgStatus, UserOrgType, UserOrganization, UserStampException},
|
||||||
|
DbConn,
|
||||||
|
},
|
||||||
|
error::Error,
|
||||||
|
sso, CONFIG,
|
||||||
|
};
|
||||||
|
|
||||||
const JWT_ALGORITHM: Algorithm = Algorithm::RS256;
|
const JWT_ALGORITHM: Algorithm = Algorithm::RS256;
|
||||||
|
|
||||||
pub static DEFAULT_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_hours(2).unwrap());
|
// Limit when BitWarden consider the token as expired
|
||||||
|
pub static BW_EXPIRATION: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_minutes(5).unwrap());
|
||||||
|
|
||||||
|
pub static DEFAULT_REFRESH_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_days(30).unwrap());
|
||||||
|
pub static DEFAULT_ACCESS_VALIDITY: Lazy<TimeDelta> = Lazy::new(|| TimeDelta::try_hours(2).unwrap());
|
||||||
static JWT_HEADER: Lazy<Header> = Lazy::new(|| Header::new(JWT_ALGORITHM));
|
static JWT_HEADER: Lazy<Header> = Lazy::new(|| Header::new(JWT_ALGORITHM));
|
||||||
|
|
||||||
pub static JWT_LOGIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|login", CONFIG.domain_origin()));
|
pub static JWT_LOGIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|login", CONFIG.domain_origin()));
|
||||||
static JWT_INVITE_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|invite", CONFIG.domain_origin()));
|
static JWT_INVITE_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|invite", CONFIG.domain_origin()));
|
||||||
static JWT_EMERGENCY_ACCESS_INVITE_ISSUER: Lazy<String> =
|
static JWT_EMERGENCY_ACCESS_INVITE_ISSUER: Lazy<String> =
|
||||||
Lazy::new(|| format!("{}|emergencyaccessinvite", CONFIG.domain_origin()));
|
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_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_VERIFYEMAIL_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|verifyemail", CONFIG.domain_origin()));
|
||||||
static JWT_ADMIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|admin", CONFIG.domain_origin()));
|
static JWT_ADMIN_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|admin", CONFIG.domain_origin()));
|
||||||
|
@ -73,7 +83,7 @@ pub fn encode_jwt<T: Serialize>(claims: &T) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decode_jwt<T: DeserializeOwned>(token: &str, issuer: String) -> Result<T, Error> {
|
pub fn decode_jwt<T: DeserializeOwned>(token: &str, issuer: String) -> Result<T, Error> {
|
||||||
let mut validation = jsonwebtoken::Validation::new(JWT_ALGORITHM);
|
let mut validation = jsonwebtoken::Validation::new(JWT_ALGORITHM);
|
||||||
validation.leeway = 30; // 30 seconds
|
validation.leeway = 30; // 30 seconds
|
||||||
validation.validate_exp = true;
|
validation.validate_exp = true;
|
||||||
|
@ -92,6 +102,10 @@ fn decode_jwt<T: DeserializeOwned>(token: &str, issuer: String) -> Result<T, Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn decode_refresh(token: &str) -> Result<RefreshJwtClaims, Error> {
|
||||||
|
decode_jwt(token, JWT_LOGIN_ISSUER.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn decode_login(token: &str) -> Result<LoginJwtClaims, Error> {
|
pub fn decode_login(token: &str) -> Result<LoginJwtClaims, Error> {
|
||||||
decode_jwt(token, JWT_LOGIN_ISSUER.to_string())
|
decode_jwt(token, JWT_LOGIN_ISSUER.to_string())
|
||||||
}
|
}
|
||||||
|
@ -165,6 +179,73 @@ pub struct LoginJwtClaims {
|
||||||
pub amr: Vec<String>,
|
pub amr: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl LoginJwtClaims {
|
||||||
|
pub fn new(device: &Device, user: &User, nbf: i64, exp: i64, scope: Vec<String>, now: DateTime<Utc>) -> Self {
|
||||||
|
// ---
|
||||||
|
// Disabled these keys to be added to the JWT since they could cause the JWT to get too large
|
||||||
|
// Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients
|
||||||
|
// Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out
|
||||||
|
// ---
|
||||||
|
// fn arg: orgs: Vec<super::UserOrganization>,
|
||||||
|
// ---
|
||||||
|
// let orgowner: Vec<_> = orgs.iter().filter(|o| o.atype == 0).map(|o| o.org_uuid.clone()).collect();
|
||||||
|
// let orgadmin: Vec<_> = orgs.iter().filter(|o| o.atype == 1).map(|o| o.org_uuid.clone()).collect();
|
||||||
|
// let orguser: Vec<_> = orgs.iter().filter(|o| o.atype == 2).map(|o| o.org_uuid.clone()).collect();
|
||||||
|
// let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect();
|
||||||
|
|
||||||
|
if exp <= (now + *BW_EXPIRATION).timestamp() {
|
||||||
|
warn!("Raise access_token lifetime to more than 5min.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the JWT claims struct, to send to the client
|
||||||
|
Self {
|
||||||
|
nbf,
|
||||||
|
exp,
|
||||||
|
iss: JWT_LOGIN_ISSUER.to_string(),
|
||||||
|
sub: user.uuid.clone(),
|
||||||
|
premium: true,
|
||||||
|
name: user.name.clone(),
|
||||||
|
email: user.email.clone(),
|
||||||
|
email_verified: !CONFIG.mail_enabled() || user.verified_at.is_some(),
|
||||||
|
|
||||||
|
// ---
|
||||||
|
// Disabled these keys to be added to the JWT since they could cause the JWT to get too large
|
||||||
|
// Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients
|
||||||
|
// Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out
|
||||||
|
// See: https://github.com/dani-garcia/vaultwarden/issues/4156
|
||||||
|
// ---
|
||||||
|
// orgowner,
|
||||||
|
// orgadmin,
|
||||||
|
// orguser,
|
||||||
|
// orgmanager,
|
||||||
|
sstamp: user.security_stamp.clone(),
|
||||||
|
device: device.uuid.clone(),
|
||||||
|
scope,
|
||||||
|
amr: vec!["Application".into()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default(device: &Device, user: &User, auth_method: &AuthMethod) -> Self {
|
||||||
|
let time_now = Utc::now();
|
||||||
|
Self::new(
|
||||||
|
device,
|
||||||
|
user,
|
||||||
|
time_now.timestamp(),
|
||||||
|
(time_now + *DEFAULT_ACCESS_VALIDITY).timestamp(),
|
||||||
|
auth_method.scope_vec(),
|
||||||
|
time_now,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn token(&self) -> String {
|
||||||
|
encode_jwt(&self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expires_in(&self) -> i64 {
|
||||||
|
self.exp - Utc::now().timestamp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct InviteJwtClaims {
|
pub struct InviteJwtClaims {
|
||||||
// Not before
|
// Not before
|
||||||
|
@ -318,28 +399,6 @@ 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 {
|
pub fn generate_verify_email_claims(uuid: String) -> BasicJwtClaims {
|
||||||
let time_now = Utc::now();
|
let time_now = Utc::now();
|
||||||
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
let expire_hours = i64::from(CONFIG.invitation_expiration_hours());
|
||||||
|
@ -379,11 +438,6 @@ use rocket::{
|
||||||
request::{FromRequest, Outcome, Request},
|
request::{FromRequest, Outcome, Request},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::db::{
|
|
||||||
models::{Collection, Device, User, UserOrgStatus, UserOrgType, UserOrganization, UserStampException},
|
|
||||||
DbConn,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct Host {
|
pub struct Host {
|
||||||
pub host: String,
|
pub host: String,
|
||||||
}
|
}
|
||||||
|
@ -899,3 +953,150 @@ impl<'r> FromRequest<'r> for WsAccessTokenHeader {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum AuthMethod {
|
||||||
|
OrgApiKey,
|
||||||
|
Password,
|
||||||
|
Sso,
|
||||||
|
UserApiKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait AuthMethodScope {
|
||||||
|
fn scope_vec(&self) -> Vec<String>;
|
||||||
|
fn scope(&self) -> String;
|
||||||
|
fn check_scope(&self, scope: Option<&String>) -> ApiResult<String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthMethodScope for AuthMethod {
|
||||||
|
fn scope(&self) -> String {
|
||||||
|
match self {
|
||||||
|
AuthMethod::OrgApiKey => "api.organization".to_string(),
|
||||||
|
AuthMethod::Password => "api offline_access".to_string(),
|
||||||
|
AuthMethod::Sso => "api offline_access".to_string(),
|
||||||
|
AuthMethod::UserApiKey => "api".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scope_vec(&self) -> Vec<String> {
|
||||||
|
self.scope().split_whitespace().map(str::to_string).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_scope(&self, scope: Option<&String>) -> ApiResult<String> {
|
||||||
|
let method_scope = self.scope();
|
||||||
|
match scope {
|
||||||
|
None => err!("Missing scope"),
|
||||||
|
Some(scope) if scope == &method_scope => Ok(method_scope),
|
||||||
|
Some(scope) => err!(format!("Scope ({scope}) not supported")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub enum TokenWrapper {
|
||||||
|
Access(String),
|
||||||
|
Refresh(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct RefreshJwtClaims {
|
||||||
|
// Not before
|
||||||
|
pub nbf: i64,
|
||||||
|
// Expiration time
|
||||||
|
pub exp: i64,
|
||||||
|
// Issuer
|
||||||
|
pub iss: String,
|
||||||
|
// Subject
|
||||||
|
pub sub: AuthMethod,
|
||||||
|
|
||||||
|
pub device_token: String,
|
||||||
|
|
||||||
|
pub token: Option<TokenWrapper>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct AuthTokens {
|
||||||
|
pub refresh_claims: RefreshJwtClaims,
|
||||||
|
pub access_claims: LoginJwtClaims,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthTokens {
|
||||||
|
pub fn refresh_token(&self) -> String {
|
||||||
|
encode_jwt(&self.refresh_claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn access_token(&self) -> String {
|
||||||
|
self.access_claims.token()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expires_in(&self) -> i64 {
|
||||||
|
self.access_claims.expires_in()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scope(&self) -> String {
|
||||||
|
self.refresh_claims.sub.scope()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create refresh_token and access_token with default validity
|
||||||
|
pub fn new(device: &Device, user: &User, sub: AuthMethod) -> Self {
|
||||||
|
let time_now = Utc::now();
|
||||||
|
|
||||||
|
let access_claims = LoginJwtClaims::default(device, user, &sub);
|
||||||
|
|
||||||
|
let refresh_claims = RefreshJwtClaims {
|
||||||
|
nbf: time_now.timestamp(),
|
||||||
|
exp: (time_now + *DEFAULT_REFRESH_VALIDITY).timestamp(),
|
||||||
|
iss: JWT_LOGIN_ISSUER.to_string(),
|
||||||
|
sub,
|
||||||
|
device_token: device.refresh_token.clone(),
|
||||||
|
token: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
refresh_claims,
|
||||||
|
access_claims,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refresh_tokens(refresh_token: &str, conn: &mut DbConn) -> ApiResult<(Device, User, AuthTokens)> {
|
||||||
|
let time_now = Utc::now();
|
||||||
|
|
||||||
|
let refresh_claims = match decode_refresh(refresh_token) {
|
||||||
|
Err(err) => err!(format!("Impossible to read refresh_token: {err}")),
|
||||||
|
Ok(claims) => claims,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get device by refresh token
|
||||||
|
let mut device = match Device::find_by_refresh_token(&refresh_claims.device_token, conn).await {
|
||||||
|
None => err!("Invalid refresh token"),
|
||||||
|
Some(device) => device,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Roll the Device.refresh_token this way it invalides old JWT refresh_token
|
||||||
|
device.roll_refresh_token();
|
||||||
|
device.save(conn).await?;
|
||||||
|
|
||||||
|
let user = match User::find_by_uuid(&device.user_uuid, conn).await {
|
||||||
|
None => err!("Impossible to find user"),
|
||||||
|
Some(user) => user,
|
||||||
|
};
|
||||||
|
|
||||||
|
if refresh_claims.exp < time_now.timestamp() {
|
||||||
|
err!("Expired refresh token");
|
||||||
|
}
|
||||||
|
|
||||||
|
let auth_tokens = match refresh_claims.sub {
|
||||||
|
AuthMethod::Sso if CONFIG.sso_enabled() && CONFIG.sso_auth_only_not_session() => {
|
||||||
|
AuthTokens::new(&device, &user, refresh_claims.sub)
|
||||||
|
}
|
||||||
|
AuthMethod::Sso if CONFIG.sso_enabled() => sso::exchange_refresh_token(&device, &user, &refresh_claims).await?,
|
||||||
|
AuthMethod::Sso => err!("SSO is now disabled, Login again using email and master password"),
|
||||||
|
AuthMethod::Password if CONFIG.sso_enabled() && CONFIG.sso_only() => err!("SSO is now required, Login again"),
|
||||||
|
AuthMethod::Password => AuthTokens::new(&device, &user, refresh_claims.sub),
|
||||||
|
_ => err!("Invalid auth method cannot refresh token"),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((device, user, auth_tokens))
|
||||||
|
}
|
||||||
|
|
101
src/config.rs
101
src/config.rs
|
@ -409,7 +409,9 @@ make_config! {
|
||||||
/// Auth Request cleanup schedule |> Cron schedule of the job that cleans old auth requests from the auth request.
|
/// Auth Request cleanup schedule |> Cron schedule of the job that cleans old auth requests from the auth request.
|
||||||
/// Defaults to every minute. Set blank to disable this job.
|
/// Defaults to every minute. Set blank to disable this job.
|
||||||
auth_request_purge_schedule: String, false, def, "30 * * * * *".to_string();
|
auth_request_purge_schedule: String, false, def, "30 * * * * *".to_string();
|
||||||
|
/// Purge incomplete sso nonce.
|
||||||
|
/// Defaults to daily. Set blank to disable this job.
|
||||||
|
purge_incomplete_sso_nonce: String, false, def, "0 20 0 * * *".to_string();
|
||||||
},
|
},
|
||||||
|
|
||||||
/// General settings
|
/// General settings
|
||||||
|
@ -610,18 +612,32 @@ make_config! {
|
||||||
sso {
|
sso {
|
||||||
/// Enabled
|
/// Enabled
|
||||||
sso_enabled: bool, true, def, false;
|
sso_enabled: bool, true, def, false;
|
||||||
/// Force SSO login
|
/// Disable Email+Master Password login
|
||||||
sso_only: bool, true, def, false;
|
sso_only: bool, true, def, false;
|
||||||
|
/// Associate existing user based on email
|
||||||
|
sso_signups_match_email: bool, true, def, true;
|
||||||
/// Client ID
|
/// Client ID
|
||||||
sso_client_id: String, true, def, String::new();
|
sso_client_id: String, false, def, String::new();
|
||||||
/// Client Key
|
/// Client Key
|
||||||
sso_client_secret: Pass, true, def, String::new();
|
sso_client_secret: Pass, false, def, String::new();
|
||||||
/// Authority Server
|
/// Authority Server
|
||||||
sso_authority: String, true, def, String::new();
|
sso_authority: String, false, def, String::new();
|
||||||
|
/// Scopes required for authorize
|
||||||
|
sso_scopes: String, false, def, "email profile".to_string();
|
||||||
|
/// Additionnal authorization url parameters
|
||||||
|
sso_authorize_extra_params: String, false, def, String::new();
|
||||||
|
/// Use PKCE during Auth Code flow
|
||||||
|
sso_pkce: bool, false, def, false;
|
||||||
|
/// Regex for additionnal trusted Id token audience
|
||||||
|
sso_audience_trusted: String, false, option;
|
||||||
/// CallBack Path
|
/// CallBack Path
|
||||||
sso_callback_path: String, false, gen, |c| generate_sso_callback_path(&c.domain);
|
sso_callback_path: String, false, gen, |c| generate_sso_callback_path(&c.domain);
|
||||||
/// Allow workaround so SSO logins accept all invites
|
/// Optional sso master password policy
|
||||||
sso_acceptall_invites: bool, true, def, false;
|
sso_master_password_policy: String, true, option;
|
||||||
|
/// Use sso only for auth not the session lifecycle
|
||||||
|
sso_auth_only_not_session: bool, true, def, false;
|
||||||
|
/// Log all tokens, LOG_LEVEL=debug is required
|
||||||
|
sso_debug_tokens: bool, true, def, false;
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Yubikey settings
|
/// Yubikey settings
|
||||||
|
@ -647,7 +663,7 @@ make_config! {
|
||||||
/// Host
|
/// Host
|
||||||
duo_host: String, true, option;
|
duo_host: String, true, option;
|
||||||
/// Application Key (generated automatically)
|
/// Application Key (generated automatically)
|
||||||
_duo_akey: Pass, false, option;
|
_duo_akey: Pass, true, option;
|
||||||
},
|
},
|
||||||
|
|
||||||
/// SMTP Email Settings
|
/// SMTP Email Settings
|
||||||
|
@ -833,12 +849,16 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
||||||
err!("All Duo options need to be set for global Duo support")
|
err!("All Duo options need to be set for global Duo support")
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.sso_enabled
|
if cfg.sso_enabled {
|
||||||
&& (cfg.sso_client_id.is_empty() || cfg.sso_client_secret.is_empty() || cfg.sso_authority.is_empty())
|
if 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")
|
err!("`SSO_CLIENT_ID`, `SSO_CLIENT_SECRET` and `SSO_AUTHORITY` must be set for SSO support")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal_sso_issuer_url(&cfg.sso_authority)?;
|
||||||
|
internal_sso_redirect_url(&cfg.sso_callback_path)?;
|
||||||
|
check_master_password_policy(&cfg.sso_master_password_policy)?;
|
||||||
|
}
|
||||||
|
|
||||||
if cfg._enable_yubico {
|
if cfg._enable_yubico {
|
||||||
if cfg.yubico_client_id.is_some() != cfg.yubico_secret_key.is_some() {
|
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")
|
err!("Both `YUBICO_CLIENT_ID` and `YUBICO_SECRET_KEY` must be set for Yubikey OTP support")
|
||||||
|
@ -1011,6 +1031,28 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn internal_sso_issuer_url(sso_authority: &String) -> Result<openidconnect::IssuerUrl, Error> {
|
||||||
|
match openidconnect::IssuerUrl::new(sso_authority.clone()) {
|
||||||
|
Err(err) => err!(format!("Invalid sso_authority UR ({sso_authority}): {err}")),
|
||||||
|
Ok(issuer_url) => Ok(issuer_url),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn internal_sso_redirect_url(sso_callback_path: &String) -> Result<openidconnect::RedirectUrl, Error> {
|
||||||
|
match openidconnect::RedirectUrl::new(sso_callback_path.clone()) {
|
||||||
|
Err(err) => err!(format!("Invalid sso_callback_path ({sso_callback_path} built using `domain`) URL: {err}")),
|
||||||
|
Ok(redirect_url) => Ok(redirect_url),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check_master_password_policy(sso_master_password_policy: &Option<String>) -> Result<(), Error> {
|
||||||
|
let policy = sso_master_password_policy.as_ref().map(|mpp| serde_json::from_str::<serde_json::Value>(mpp));
|
||||||
|
if let Some(Err(error)) = policy {
|
||||||
|
err!(format!("Invalid sso_master_password_policy ({error}), Ensure that it's correctly escaped with ''"))
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Extracts an RFC 6454 web origin from a URL.
|
/// Extracts an RFC 6454 web origin from a URL.
|
||||||
fn extract_url_origin(url: &str) -> String {
|
fn extract_url_origin(url: &str) -> String {
|
||||||
match Url::parse(url) {
|
match Url::parse(url) {
|
||||||
|
@ -1088,6 +1130,26 @@ fn smtp_convert_deprecated_ssl_options(smtp_ssl: Option<bool>, smtp_explicit_tls
|
||||||
"starttls".to_string()
|
"starttls".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Allow to parse a multiline list of Key/Values (`key=value`)
|
||||||
|
/// Will ignore comment lines (starting with `//`)
|
||||||
|
fn parse_param_list(config: String) -> Vec<(String, String)> {
|
||||||
|
config
|
||||||
|
.lines()
|
||||||
|
.map(|l| l.trim())
|
||||||
|
.filter(|l| !l.is_empty() && !l.starts_with("//"))
|
||||||
|
.filter_map(|l| {
|
||||||
|
let split = l.split('=').collect::<Vec<&str>>();
|
||||||
|
match &split[..] {
|
||||||
|
[key, value] => Some(((*key).to_string(), (*value).to_string())),
|
||||||
|
_ => {
|
||||||
|
println!("[WARNING] Failed to parse ({l}). Expected key=value");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn load() -> Result<Self, Error> {
|
pub fn load() -> Result<Self, Error> {
|
||||||
// Loading from env and file
|
// Loading from env and file
|
||||||
|
@ -1277,6 +1339,22 @@ impl Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn sso_issuer_url(&self) -> Result<openidconnect::IssuerUrl, Error> {
|
||||||
|
internal_sso_issuer_url(&self.sso_authority())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sso_redirect_url(&self) -> Result<openidconnect::RedirectUrl, Error> {
|
||||||
|
internal_sso_redirect_url(&self.sso_callback_path())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sso_scopes_vec(&self) -> Vec<String> {
|
||||||
|
self.sso_scopes().split_whitespace().map(str::to_string).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sso_authorize_extra_params_vec(&self) -> Vec<(String, String)> {
|
||||||
|
parse_param_list(self.sso_authorize_extra_params())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use handlebars::{
|
use handlebars::{
|
||||||
|
@ -1335,6 +1413,7 @@ where
|
||||||
reg!("email/send_single_org_removed_from_org", ".html");
|
reg!("email/send_single_org_removed_from_org", ".html");
|
||||||
reg!("email/set_password", ".html");
|
reg!("email/set_password", ".html");
|
||||||
reg!("email/smtp_test", ".html");
|
reg!("email/smtp_test", ".html");
|
||||||
|
reg!("email/sso_change_email", ".html");
|
||||||
reg!("email/twofactor_email", ".html");
|
reg!("email/twofactor_email", ".html");
|
||||||
reg!("email/verify_email", ".html");
|
reg!("email/verify_email", ".html");
|
||||||
reg!("email/welcome_must_verify", ".html");
|
reg!("email/welcome_must_verify", ".html");
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
|
use data_encoding::{BASE64, BASE64URL};
|
||||||
|
|
||||||
use crate::{crypto, CONFIG};
|
use crate::crypto;
|
||||||
use core::fmt;
|
use core::fmt;
|
||||||
|
|
||||||
db_object! {
|
db_object! {
|
||||||
|
@ -42,13 +43,16 @@ impl Device {
|
||||||
|
|
||||||
push_uuid: None,
|
push_uuid: None,
|
||||||
push_token: None,
|
push_token: None,
|
||||||
refresh_token: String::new(),
|
refresh_token: crypto::encode_random_bytes::<64>(BASE64URL),
|
||||||
twofactor_remember: None,
|
twofactor_remember: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn roll_refresh_token(&mut self) {
|
||||||
|
self.refresh_token = crypto::encode_random_bytes::<64>(BASE64URL)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn refresh_twofactor_remember(&mut self) -> String {
|
pub fn refresh_twofactor_remember(&mut self) -> String {
|
||||||
use data_encoding::BASE64;
|
|
||||||
let twofactor_remember = crypto::encode_random_bytes::<180>(BASE64);
|
let twofactor_remember = crypto::encode_random_bytes::<180>(BASE64);
|
||||||
self.twofactor_remember = Some(twofactor_remember.clone());
|
self.twofactor_remember = Some(twofactor_remember.clone());
|
||||||
|
|
||||||
|
@ -59,61 +63,6 @@ impl Device {
|
||||||
self.twofactor_remember = None;
|
self.twofactor_remember = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh_tokens(&mut self, user: &super::User, scope: Vec<String>) -> (String, i64) {
|
|
||||||
// If there is no refresh token, we create one
|
|
||||||
if self.refresh_token.is_empty() {
|
|
||||||
use data_encoding::BASE64URL;
|
|
||||||
self.refresh_token = crypto::encode_random_bytes::<64>(BASE64URL);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the expiration of the device and the last update date
|
|
||||||
let time_now = Utc::now();
|
|
||||||
self.updated_at = time_now.naive_utc();
|
|
||||||
|
|
||||||
// ---
|
|
||||||
// Disabled these keys to be added to the JWT since they could cause the JWT to get too large
|
|
||||||
// Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients
|
|
||||||
// Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out
|
|
||||||
// ---
|
|
||||||
// fn arg: orgs: Vec<super::UserOrganization>,
|
|
||||||
// ---
|
|
||||||
// let orgowner: Vec<_> = orgs.iter().filter(|o| o.atype == 0).map(|o| o.org_uuid.clone()).collect();
|
|
||||||
// let orgadmin: Vec<_> = orgs.iter().filter(|o| o.atype == 1).map(|o| o.org_uuid.clone()).collect();
|
|
||||||
// let orguser: Vec<_> = orgs.iter().filter(|o| o.atype == 2).map(|o| o.org_uuid.clone()).collect();
|
|
||||||
// let orgmanager: Vec<_> = orgs.iter().filter(|o| o.atype == 3).map(|o| o.org_uuid.clone()).collect();
|
|
||||||
|
|
||||||
// Create the JWT claims struct, to send to the client
|
|
||||||
use crate::auth::{encode_jwt, LoginJwtClaims, DEFAULT_VALIDITY, JWT_LOGIN_ISSUER};
|
|
||||||
let claims = LoginJwtClaims {
|
|
||||||
nbf: time_now.timestamp(),
|
|
||||||
exp: (time_now + *DEFAULT_VALIDITY).timestamp(),
|
|
||||||
iss: JWT_LOGIN_ISSUER.to_string(),
|
|
||||||
sub: user.uuid.clone(),
|
|
||||||
|
|
||||||
premium: true,
|
|
||||||
name: user.name.clone(),
|
|
||||||
email: user.email.clone(),
|
|
||||||
email_verified: !CONFIG.mail_enabled() || user.verified_at.is_some(),
|
|
||||||
|
|
||||||
// ---
|
|
||||||
// Disabled these keys to be added to the JWT since they could cause the JWT to get too large
|
|
||||||
// Also These key/value pairs are not used anywhere by either Vaultwarden or Bitwarden Clients
|
|
||||||
// Because these might get used in the future, and they are added by the Bitwarden Server, lets keep it, but then commented out
|
|
||||||
// See: https://github.com/dani-garcia/vaultwarden/issues/4156
|
|
||||||
// ---
|
|
||||||
// orgowner,
|
|
||||||
// orgadmin,
|
|
||||||
// orguser,
|
|
||||||
// orgmanager,
|
|
||||||
sstamp: user.security_stamp.clone(),
|
|
||||||
device: self.uuid.clone(),
|
|
||||||
scope,
|
|
||||||
amr: vec!["Application".into()],
|
|
||||||
};
|
|
||||||
|
|
||||||
(encode_jwt(&claims), DEFAULT_VALIDITY.num_seconds())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_push_device(&self) -> bool {
|
pub fn is_push_device(&self) -> bool {
|
||||||
matches!(DeviceType::from_i32(self.atype), DeviceType::Android | DeviceType::Ios)
|
matches!(DeviceType::from_i32(self.atype), DeviceType::Android | DeviceType::Ios)
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,4 +32,4 @@ pub use self::send::{Send, SendType};
|
||||||
pub use self::sso_nonce::SsoNonce;
|
pub use self::sso_nonce::SsoNonce;
|
||||||
pub use self::two_factor::{TwoFactor, TwoFactorType};
|
pub use self::two_factor::{TwoFactor, TwoFactorType};
|
||||||
pub use self::two_factor_incomplete::TwoFactorIncomplete;
|
pub use self::two_factor_incomplete::TwoFactorIncomplete;
|
||||||
pub use self::user::{Invitation, User, UserKdfType, UserStampException};
|
pub use self::user::{Invitation, SsoUser, User, UserKdfType, UserStampException};
|
||||||
|
|
|
@ -28,7 +28,7 @@ pub enum OrgPolicyType {
|
||||||
MasterPassword = 1,
|
MasterPassword = 1,
|
||||||
PasswordGenerator = 2,
|
PasswordGenerator = 2,
|
||||||
SingleOrg = 3,
|
SingleOrg = 3,
|
||||||
RequireSso = 4,
|
// RequireSso = 4, // Not supported
|
||||||
PersonalOwnership = 5,
|
PersonalOwnership = 5,
|
||||||
DisableSend = 6,
|
DisableSend = 6,
|
||||||
SendOptions = 7,
|
SendOptions = 7,
|
||||||
|
@ -76,12 +76,11 @@ impl OrgPolicy {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn to_json(&self) -> Value {
|
pub fn to_json(&self) -> Value {
|
||||||
let data_json: Value = serde_json::from_str(&self.data).unwrap_or(Value::Null);
|
|
||||||
json!({
|
json!({
|
||||||
"Id": self.uuid,
|
"Id": self.uuid,
|
||||||
"OrganizationId": self.org_uuid,
|
"OrganizationId": self.org_uuid,
|
||||||
"Type": self.atype,
|
"Type": self.atype,
|
||||||
"Data": data_json,
|
"Data": serde_json::from_str(&self.data).unwrap_or(Value::Null),
|
||||||
"Enabled": self.enabled,
|
"Enabled": self.enabled,
|
||||||
"Object": "policy",
|
"Object": "policy",
|
||||||
})
|
})
|
||||||
|
|
|
@ -25,6 +25,7 @@ db_object! {
|
||||||
pub uuid: String,
|
pub uuid: String,
|
||||||
pub user_uuid: String,
|
pub user_uuid: String,
|
||||||
pub org_uuid: String,
|
pub org_uuid: String,
|
||||||
|
pub invited_by_email: Option<String>,
|
||||||
|
|
||||||
pub access_all: bool,
|
pub access_all: bool,
|
||||||
pub akey: String,
|
pub akey: String,
|
||||||
|
@ -167,7 +168,7 @@ impl Organization {
|
||||||
"UseTotp": true,
|
"UseTotp": true,
|
||||||
"UsePolicies": true,
|
"UsePolicies": true,
|
||||||
// "UseScim": false, // Not supported (Not AGPLv3 Licensed)
|
// "UseScim": false, // Not supported (Not AGPLv3 Licensed)
|
||||||
"UseSso": CONFIG.sso_enabled(),
|
"UseSso": false, // Not supported
|
||||||
// "UseKeyConnector": false, // Not supported
|
// "UseKeyConnector": false, // Not supported
|
||||||
"SelfHost": true,
|
"SelfHost": true,
|
||||||
"UseApi": true,
|
"UseApi": true,
|
||||||
|
@ -197,12 +198,13 @@ impl Organization {
|
||||||
static ACTIVATE_REVOKE_DIFF: i32 = 128;
|
static ACTIVATE_REVOKE_DIFF: i32 = 128;
|
||||||
|
|
||||||
impl UserOrganization {
|
impl UserOrganization {
|
||||||
pub fn new(user_uuid: String, org_uuid: String) -> Self {
|
pub fn new(user_uuid: String, org_uuid: String, invited_by_email: Option<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
uuid: crate::util::get_uuid(),
|
uuid: crate::util::get_uuid(),
|
||||||
|
|
||||||
user_uuid,
|
user_uuid,
|
||||||
org_uuid,
|
org_uuid,
|
||||||
|
invited_by_email,
|
||||||
|
|
||||||
access_all: false,
|
access_all: false,
|
||||||
akey: String::new(),
|
akey: String::new(),
|
||||||
|
@ -385,7 +387,7 @@ impl UserOrganization {
|
||||||
"ResetPasswordEnrolled": self.reset_password_key.is_some(),
|
"ResetPasswordEnrolled": self.reset_password_key.is_some(),
|
||||||
"UseResetPassword": CONFIG.mail_enabled(),
|
"UseResetPassword": CONFIG.mail_enabled(),
|
||||||
"SsoBound": false, // Not supported
|
"SsoBound": false, // Not supported
|
||||||
"UseSso": CONFIG.sso_enabled(),
|
"UseSso": false, // Not supported
|
||||||
"ProviderId": null,
|
"ProviderId": null,
|
||||||
"ProviderName": null,
|
"ProviderName": null,
|
||||||
// "KeyConnectorEnabled": false,
|
// "KeyConnectorEnabled": false,
|
||||||
|
@ -635,6 +637,17 @@ impl UserOrganization {
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn confirm_user_invitations(user_uuid: &str, conn: &mut DbConn) -> EmptyResult {
|
||||||
|
db_run! { conn: {
|
||||||
|
diesel::update(users_organizations::table)
|
||||||
|
.filter(users_organizations::user_uuid.eq(user_uuid))
|
||||||
|
.filter(users_organizations::status.eq(UserOrgStatus::Invited as i32))
|
||||||
|
.set(users_organizations::status.eq(UserOrgStatus::Accepted as i32))
|
||||||
|
.execute(conn)
|
||||||
|
.map_res("Error confirming invitations")
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn find_any_state_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
pub async fn find_any_state_by_user(user_uuid: &str, conn: &mut DbConn) -> Vec<Self> {
|
||||||
db_run! { conn: {
|
db_run! { conn: {
|
||||||
users_organizations::table
|
users_organizations::table
|
||||||
|
|
|
@ -1,21 +1,34 @@
|
||||||
|
use chrono::{NaiveDateTime, Utc};
|
||||||
|
|
||||||
use crate::api::EmptyResult;
|
use crate::api::EmptyResult;
|
||||||
use crate::db::DbConn;
|
use crate::db::{DbConn, DbPool};
|
||||||
use crate::error::MapResult;
|
use crate::error::MapResult;
|
||||||
|
use crate::sso::NONCE_EXPIRATION;
|
||||||
|
|
||||||
db_object! {
|
db_object! {
|
||||||
#[derive(Identifiable, Queryable, Insertable)]
|
#[derive(Identifiable, Queryable, Insertable)]
|
||||||
#[diesel(table_name = sso_nonce)]
|
#[diesel(table_name = sso_nonce)]
|
||||||
#[diesel(primary_key(nonce))]
|
#[diesel(primary_key(state))]
|
||||||
pub struct SsoNonce {
|
pub struct SsoNonce {
|
||||||
|
pub state: String,
|
||||||
pub nonce: String,
|
pub nonce: String,
|
||||||
|
pub verifier: Option<String>,
|
||||||
|
pub redirect_uri: String,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Local methods
|
/// Local methods
|
||||||
impl SsoNonce {
|
impl SsoNonce {
|
||||||
pub fn new(nonce: String) -> Self {
|
pub fn new(state: String, nonce: String, verifier: Option<String>, redirect_uri: String) -> Self {
|
||||||
Self {
|
let now = Utc::now().naive_utc();
|
||||||
|
|
||||||
|
SsoNonce {
|
||||||
|
state,
|
||||||
nonce,
|
nonce,
|
||||||
|
verifier,
|
||||||
|
redirect_uri,
|
||||||
|
created_at: now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -28,7 +41,7 @@ impl SsoNonce {
|
||||||
diesel::replace_into(sso_nonce::table)
|
diesel::replace_into(sso_nonce::table)
|
||||||
.values(SsoNonceDb::to_db(self))
|
.values(SsoNonceDb::to_db(self))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error saving SSO device")
|
.map_res("Error saving SSO nonce")
|
||||||
}
|
}
|
||||||
postgresql {
|
postgresql {
|
||||||
let value = SsoNonceDb::to_db(self);
|
let value = SsoNonceDb::to_db(self);
|
||||||
|
@ -40,21 +53,37 @@ impl SsoNonce {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(self, conn: &mut DbConn) -> EmptyResult {
|
pub async fn delete(state: &str, conn: &mut DbConn) -> EmptyResult {
|
||||||
db_run! { conn: {
|
db_run! { conn: {
|
||||||
diesel::delete(sso_nonce::table.filter(sso_nonce::nonce.eq(self.nonce)))
|
diesel::delete(sso_nonce::table.filter(sso_nonce::state.eq(state)))
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.map_res("Error deleting SSO nonce")
|
.map_res("Error deleting SSO nonce")
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find(nonce: &str, conn: &mut DbConn) -> Option<Self> {
|
pub async fn find(state: &str, conn: &DbConn) -> Option<Self> {
|
||||||
|
let oldest = Utc::now().naive_utc() - *NONCE_EXPIRATION;
|
||||||
db_run! { conn: {
|
db_run! { conn: {
|
||||||
sso_nonce::table
|
sso_nonce::table
|
||||||
.filter(sso_nonce::nonce.eq(nonce))
|
.filter(sso_nonce::state.eq(state))
|
||||||
|
.filter(sso_nonce::created_at.ge(oldest))
|
||||||
.first::<SsoNonceDb>(conn)
|
.first::<SsoNonceDb>(conn)
|
||||||
.ok()
|
.ok()
|
||||||
.from_db()
|
.from_db()
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete_expired(pool: DbPool) -> EmptyResult {
|
||||||
|
debug!("Purging expired sso_nonce");
|
||||||
|
if let Ok(conn) = pool.get().await {
|
||||||
|
let oldest = Utc::now().naive_utc() - *NONCE_EXPIRATION;
|
||||||
|
db_run! { conn: {
|
||||||
|
diesel::delete(sso_nonce::table.filter(sso_nonce::created_at.lt(oldest)))
|
||||||
|
.execute(conn)
|
||||||
|
.map_res("Error deleting expired SSO nonce")
|
||||||
|
}}
|
||||||
|
} else {
|
||||||
|
err!("Failed to get DB connection while purging expired sso_nonce")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ use crate::crypto;
|
||||||
use crate::CONFIG;
|
use crate::CONFIG;
|
||||||
|
|
||||||
db_object! {
|
db_object! {
|
||||||
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
|
#[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)]
|
||||||
#[diesel(table_name = users)]
|
#[diesel(table_name = users)]
|
||||||
#[diesel(treat_none_as_null = true)]
|
#[diesel(treat_none_as_null = true)]
|
||||||
#[diesel(primary_key(uuid))]
|
#[diesel(primary_key(uuid))]
|
||||||
|
@ -60,6 +60,14 @@ db_object! {
|
||||||
pub struct Invitation {
|
pub struct Invitation {
|
||||||
pub email: String,
|
pub email: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Identifiable, Queryable, Insertable, Selectable)]
|
||||||
|
#[diesel(table_name = sso_users)]
|
||||||
|
#[diesel(primary_key(user_uuid))]
|
||||||
|
pub struct SsoUser {
|
||||||
|
pub user_uuid: String,
|
||||||
|
pub identifier: String,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum UserKdfType {
|
pub enum UserKdfType {
|
||||||
|
@ -85,7 +93,7 @@ impl User {
|
||||||
pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32;
|
pub const CLIENT_KDF_TYPE_DEFAULT: i32 = UserKdfType::Pbkdf2 as i32;
|
||||||
pub const CLIENT_KDF_ITER_DEFAULT: i32 = 600_000;
|
pub const CLIENT_KDF_ITER_DEFAULT: i32 = 600_000;
|
||||||
|
|
||||||
pub fn new(email: String) -> Self {
|
pub fn new(email: String, name: Option<String>) -> Self {
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
let email = email.to_lowercase();
|
let email = email.to_lowercase();
|
||||||
|
|
||||||
|
@ -97,7 +105,7 @@ impl User {
|
||||||
verified_at: None,
|
verified_at: None,
|
||||||
last_verifying_at: None,
|
last_verifying_at: None,
|
||||||
login_verify_count: 0,
|
login_verify_count: 0,
|
||||||
name: email.clone(),
|
name: name.unwrap_or(email.clone()),
|
||||||
email,
|
email,
|
||||||
akey: String::new(),
|
akey: String::new(),
|
||||||
email_new: None,
|
email_new: None,
|
||||||
|
@ -456,3 +464,51 @@ impl Invitation {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SsoUser {
|
||||||
|
pub async fn save(&self, conn: &mut DbConn) -> EmptyResult {
|
||||||
|
db_run! { conn:
|
||||||
|
sqlite, mysql {
|
||||||
|
diesel::replace_into(sso_users::table)
|
||||||
|
.values(SsoUserDb::to_db(self))
|
||||||
|
.execute(conn)
|
||||||
|
.map_res("Error saving SSO user")
|
||||||
|
}
|
||||||
|
postgresql {
|
||||||
|
let value = SsoUserDb::to_db(self);
|
||||||
|
diesel::insert_into(sso_users::table)
|
||||||
|
.values(&value)
|
||||||
|
.execute(conn)
|
||||||
|
.map_res("Error saving SSO user")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Written as an union to make the query more lisible than using an `or_filter`.
|
||||||
|
// But `first()` does not appear to work with `union()` so we use `load()`.
|
||||||
|
pub async fn find_by_identifier_or_email(
|
||||||
|
identifier: &str,
|
||||||
|
mail: &str,
|
||||||
|
conn: &DbConn,
|
||||||
|
) -> Option<(User, Option<SsoUser>)> {
|
||||||
|
let lower_mail = mail.to_lowercase();
|
||||||
|
|
||||||
|
db_run! {conn: {
|
||||||
|
users::table
|
||||||
|
.inner_join(sso_users::table)
|
||||||
|
.select(<(UserDb, Option<SsoUserDb>)>::as_select())
|
||||||
|
.filter(sso_users::identifier.eq(identifier))
|
||||||
|
.union(
|
||||||
|
users::table
|
||||||
|
.left_join(sso_users::table)
|
||||||
|
.select(<(UserDb, Option<SsoUserDb>)>::as_select())
|
||||||
|
.filter(users::email.eq(lower_mail))
|
||||||
|
)
|
||||||
|
.load(conn)
|
||||||
|
.expect("Error searching user by SSO identifier and email")
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.map(|(user, sso_user)| { (user.from_db(), sso_user.from_db()) })
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -224,6 +224,7 @@ table! {
|
||||||
uuid -> Text,
|
uuid -> Text,
|
||||||
user_uuid -> Text,
|
user_uuid -> Text,
|
||||||
org_uuid -> Text,
|
org_uuid -> Text,
|
||||||
|
invited_by_email -> Nullable<Text>,
|
||||||
access_all -> Bool,
|
access_all -> Bool,
|
||||||
akey -> Text,
|
akey -> Text,
|
||||||
status -> Integer,
|
status -> Integer,
|
||||||
|
@ -244,8 +245,19 @@ table! {
|
||||||
}
|
}
|
||||||
|
|
||||||
table! {
|
table! {
|
||||||
sso_nonce (nonce) {
|
sso_nonce (state) {
|
||||||
|
state -> Text,
|
||||||
nonce -> Text,
|
nonce -> Text,
|
||||||
|
verifier -> Nullable<Text>,
|
||||||
|
redirect_uri -> Text,
|
||||||
|
created_at -> Timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table! {
|
||||||
|
sso_users (user_uuid) {
|
||||||
|
user_uuid -> Text,
|
||||||
|
identifier -> Text,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -342,6 +354,7 @@ joinable!(collections_groups -> collections (collections_uuid));
|
||||||
joinable!(collections_groups -> groups (groups_uuid));
|
joinable!(collections_groups -> groups (groups_uuid));
|
||||||
joinable!(event -> users_organizations (uuid));
|
joinable!(event -> users_organizations (uuid));
|
||||||
joinable!(auth_requests -> users (user_uuid));
|
joinable!(auth_requests -> users (user_uuid));
|
||||||
|
joinable!(sso_users -> users (user_uuid));
|
||||||
|
|
||||||
allow_tables_to_appear_in_same_query!(
|
allow_tables_to_appear_in_same_query!(
|
||||||
attachments,
|
attachments,
|
||||||
|
@ -355,6 +368,7 @@ allow_tables_to_appear_in_same_query!(
|
||||||
org_policies,
|
org_policies,
|
||||||
organizations,
|
organizations,
|
||||||
sends,
|
sends,
|
||||||
|
sso_users,
|
||||||
twofactor,
|
twofactor,
|
||||||
users,
|
users,
|
||||||
users_collections,
|
users_collections,
|
||||||
|
|
|
@ -224,6 +224,7 @@ table! {
|
||||||
uuid -> Text,
|
uuid -> Text,
|
||||||
user_uuid -> Text,
|
user_uuid -> Text,
|
||||||
org_uuid -> Text,
|
org_uuid -> Text,
|
||||||
|
invited_by_email -> Nullable<Text>,
|
||||||
access_all -> Bool,
|
access_all -> Bool,
|
||||||
akey -> Text,
|
akey -> Text,
|
||||||
status -> Integer,
|
status -> Integer,
|
||||||
|
@ -244,8 +245,19 @@ table! {
|
||||||
}
|
}
|
||||||
|
|
||||||
table! {
|
table! {
|
||||||
sso_nonce (nonce) {
|
sso_nonce (state) {
|
||||||
|
state -> Text,
|
||||||
nonce -> Text,
|
nonce -> Text,
|
||||||
|
verifier -> Nullable<Text>,
|
||||||
|
redirect_uri -> Text,
|
||||||
|
created_at -> Timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table! {
|
||||||
|
sso_users (user_uuid) {
|
||||||
|
user_uuid -> Text,
|
||||||
|
identifier -> Text,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -342,6 +354,7 @@ joinable!(collections_groups -> collections (collections_uuid));
|
||||||
joinable!(collections_groups -> groups (groups_uuid));
|
joinable!(collections_groups -> groups (groups_uuid));
|
||||||
joinable!(event -> users_organizations (uuid));
|
joinable!(event -> users_organizations (uuid));
|
||||||
joinable!(auth_requests -> users (user_uuid));
|
joinable!(auth_requests -> users (user_uuid));
|
||||||
|
joinable!(sso_users -> users (user_uuid));
|
||||||
|
|
||||||
allow_tables_to_appear_in_same_query!(
|
allow_tables_to_appear_in_same_query!(
|
||||||
attachments,
|
attachments,
|
||||||
|
@ -355,6 +368,7 @@ allow_tables_to_appear_in_same_query!(
|
||||||
org_policies,
|
org_policies,
|
||||||
organizations,
|
organizations,
|
||||||
sends,
|
sends,
|
||||||
|
sso_users,
|
||||||
twofactor,
|
twofactor,
|
||||||
users,
|
users,
|
||||||
users_collections,
|
users_collections,
|
||||||
|
|
|
@ -224,6 +224,7 @@ table! {
|
||||||
uuid -> Text,
|
uuid -> Text,
|
||||||
user_uuid -> Text,
|
user_uuid -> Text,
|
||||||
org_uuid -> Text,
|
org_uuid -> Text,
|
||||||
|
invited_by_email -> Nullable<Text>,
|
||||||
access_all -> Bool,
|
access_all -> Bool,
|
||||||
akey -> Text,
|
akey -> Text,
|
||||||
status -> Integer,
|
status -> Integer,
|
||||||
|
@ -244,8 +245,19 @@ table! {
|
||||||
}
|
}
|
||||||
|
|
||||||
table! {
|
table! {
|
||||||
sso_nonce (nonce) {
|
sso_nonce (state) {
|
||||||
|
state -> Text,
|
||||||
nonce -> Text,
|
nonce -> Text,
|
||||||
|
verifier -> Nullable<Text>,
|
||||||
|
redirect_uri -> Text,
|
||||||
|
created_at -> Timestamp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table! {
|
||||||
|
sso_users (user_uuid) {
|
||||||
|
user_uuid -> Text,
|
||||||
|
identifier -> Text,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -342,6 +354,7 @@ joinable!(collections_groups -> collections (collections_uuid));
|
||||||
joinable!(collections_groups -> groups (groups_uuid));
|
joinable!(collections_groups -> groups (groups_uuid));
|
||||||
joinable!(event -> users_organizations (uuid));
|
joinable!(event -> users_organizations (uuid));
|
||||||
joinable!(auth_requests -> users (user_uuid));
|
joinable!(auth_requests -> users (user_uuid));
|
||||||
|
joinable!(sso_users -> users (user_uuid));
|
||||||
|
|
||||||
allow_tables_to_appear_in_same_query!(
|
allow_tables_to_appear_in_same_query!(
|
||||||
attachments,
|
attachments,
|
||||||
|
@ -355,6 +368,7 @@ allow_tables_to_appear_in_same_query!(
|
||||||
org_policies,
|
org_policies,
|
||||||
organizations,
|
organizations,
|
||||||
sends,
|
sends,
|
||||||
|
sso_users,
|
||||||
twofactor,
|
twofactor,
|
||||||
users,
|
users,
|
||||||
users_collections,
|
users_collections,
|
||||||
|
|
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
|
send_email(address, &subject, body_html, body_text).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn send_sso_change_email(address: &str) -> EmptyResult {
|
||||||
|
let (subject, body_html, body_text) = get_text(
|
||||||
|
"email/sso_change_email",
|
||||||
|
json!({
|
||||||
|
"url": format!("{}/#/settings/account", CONFIG.domain()),
|
||||||
|
"img_src": CONFIG._smtp_img_src(),
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
send_email(address, &subject, body_html, body_text).await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn send_set_password(address: &str, user_name: &str) -> EmptyResult {
|
pub async fn send_set_password(address: &str, user_name: &str) -> EmptyResult {
|
||||||
let (subject, body_html, body_text) = get_text(
|
let (subject, body_html, body_text) = get_text(
|
||||||
"email/set_password",
|
"email/set_password",
|
||||||
|
|
|
@ -49,6 +49,7 @@ mod crypto;
|
||||||
mod db;
|
mod db;
|
||||||
mod mail;
|
mod mail;
|
||||||
mod ratelimit;
|
mod ratelimit;
|
||||||
|
mod sso;
|
||||||
mod util;
|
mod util;
|
||||||
|
|
||||||
use crate::api::purge_auth_requests;
|
use crate::api::purge_auth_requests;
|
||||||
|
@ -594,6 +595,13 @@ fn schedule_jobs(pool: db::DbPool) {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Purge sso nonce from incomplete flow (default to daily at 00h20).
|
||||||
|
if !CONFIG.purge_incomplete_sso_nonce().is_empty() {
|
||||||
|
sched.add(Job::new(CONFIG.purge_incomplete_sso_nonce().parse().unwrap(), || {
|
||||||
|
runtime.spawn(db::models::SsoNonce::delete_expired(pool.clone()));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// Periodically check for jobs to run. We probably won't need any
|
// Periodically check for jobs to run. We probably won't need any
|
||||||
// jobs that run more often than once a minute, so a default poll
|
// jobs that run more often than once a minute, so a default poll
|
||||||
// interval of 30 seconds should be sufficient. Users who want to
|
// interval of 30 seconds should be sufficient. Users who want to
|
||||||
|
|
538
src/sso.rs
Normale Datei
538
src/sso.rs
Normale Datei
|
@ -0,0 +1,538 @@
|
||||||
|
use chrono::Utc;
|
||||||
|
use regex::Regex;
|
||||||
|
use std::borrow::Cow;
|
||||||
|
use std::sync::RwLock;
|
||||||
|
use std::time::Duration;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use mini_moka::sync::Cache;
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use openidconnect::core::{
|
||||||
|
CoreClient, CoreIdTokenVerifier, CoreProviderMetadata, CoreResponseType, CoreUserInfoClaims,
|
||||||
|
};
|
||||||
|
use openidconnect::reqwest::async_http_client;
|
||||||
|
use openidconnect::{
|
||||||
|
AccessToken, AuthDisplay, AuthPrompt, AuthenticationFlow, AuthorizationCode, AuthorizationRequest, ClientId,
|
||||||
|
ClientSecret, CsrfToken, Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RefreshToken,
|
||||||
|
ResponseType, Scope,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::ApiResult,
|
||||||
|
auth,
|
||||||
|
auth::{AuthMethod, AuthMethodScope, AuthTokens, TokenWrapper, BW_EXPIRATION, DEFAULT_REFRESH_VALIDITY},
|
||||||
|
db::{
|
||||||
|
models::{Device, SsoNonce, User},
|
||||||
|
DbConn,
|
||||||
|
},
|
||||||
|
CONFIG,
|
||||||
|
};
|
||||||
|
|
||||||
|
static AC_CACHE: Lazy<Cache<String, AuthenticatedUser>> =
|
||||||
|
Lazy::new(|| Cache::builder().max_capacity(1000).time_to_live(Duration::from_secs(10 * 60)).build());
|
||||||
|
|
||||||
|
static CLIENT_CACHE: RwLock<Option<CoreClient>> = RwLock::new(None);
|
||||||
|
|
||||||
|
static SSO_JWT_ISSUER: Lazy<String> = Lazy::new(|| format!("{}|sso", CONFIG.domain_origin()));
|
||||||
|
|
||||||
|
pub static NONCE_EXPIRATION: Lazy<chrono::Duration> = Lazy::new(|| chrono::TimeDelta::try_minutes(10).unwrap());
|
||||||
|
|
||||||
|
trait AuthorizationRequestExt<'a> {
|
||||||
|
fn add_extra_params<N: Into<Cow<'a, str>>, V: Into<Cow<'a, str>>>(self, params: Vec<(N, V)>) -> Self;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, AD: AuthDisplay, P: AuthPrompt, RT: ResponseType> AuthorizationRequestExt<'a>
|
||||||
|
for AuthorizationRequest<'a, AD, P, RT>
|
||||||
|
{
|
||||||
|
fn add_extra_params<N: Into<Cow<'a, str>>, V: Into<Cow<'a, str>>>(mut self, params: Vec<(N, V)>) -> Self {
|
||||||
|
for (key, value) in params {
|
||||||
|
self = self.add_extra_param(key, value);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct SsoTokenJwtClaims {
|
||||||
|
// Not before
|
||||||
|
pub nbf: i64,
|
||||||
|
// Expiration time
|
||||||
|
pub exp: i64,
|
||||||
|
// Issuer
|
||||||
|
pub iss: String,
|
||||||
|
// Subject
|
||||||
|
pub sub: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_ssotoken_claims() -> String {
|
||||||
|
let time_now = Utc::now();
|
||||||
|
let claims = SsoTokenJwtClaims {
|
||||||
|
nbf: time_now.timestamp(),
|
||||||
|
exp: (time_now + chrono::TimeDelta::try_minutes(2).unwrap()).timestamp(),
|
||||||
|
iss: SSO_JWT_ISSUER.to_string(),
|
||||||
|
sub: "vaultwarden".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
auth::encode_jwt(&claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub enum OIDCCodeWrapper {
|
||||||
|
Ok {
|
||||||
|
code: String,
|
||||||
|
state: String,
|
||||||
|
},
|
||||||
|
Error {
|
||||||
|
state: String,
|
||||||
|
error: String,
|
||||||
|
error_description: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct OIDCCodeClaims {
|
||||||
|
// Expiration time
|
||||||
|
pub exp: i64,
|
||||||
|
// Issuer
|
||||||
|
pub iss: String,
|
||||||
|
|
||||||
|
pub code: OIDCCodeWrapper,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_code_claims(code: OIDCCodeWrapper) -> String {
|
||||||
|
let time_now = Utc::now();
|
||||||
|
let claims = OIDCCodeClaims {
|
||||||
|
exp: (time_now + chrono::TimeDelta::try_minutes(5).unwrap()).timestamp(),
|
||||||
|
iss: SSO_JWT_ISSUER.to_string(),
|
||||||
|
code,
|
||||||
|
};
|
||||||
|
|
||||||
|
auth::encode_jwt(&claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
struct BasicTokenClaims {
|
||||||
|
iat: Option<i64>,
|
||||||
|
nbf: Option<i64>,
|
||||||
|
exp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BasicTokenClaims {
|
||||||
|
fn nbf(&self) -> i64 {
|
||||||
|
self.nbf.or(self.iat).unwrap_or_else(|| Utc::now().timestamp())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_token_claims(token_name: &str, token: &str) -> ApiResult<BasicTokenClaims> {
|
||||||
|
let mut validation = jsonwebtoken::Validation::default();
|
||||||
|
validation.set_issuer(&[CONFIG.sso_authority()]);
|
||||||
|
validation.insecure_disable_signature_validation();
|
||||||
|
validation.validate_aud = false;
|
||||||
|
|
||||||
|
match jsonwebtoken::decode(token, &jsonwebtoken::DecodingKey::from_secret(&[]), &validation) {
|
||||||
|
Ok(btc) => Ok(btc.claims),
|
||||||
|
Err(err) => err_silent!(format!("Failed to decode basic token claims from {token_name}: {err}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::async_trait]
|
||||||
|
trait CoreClientExt {
|
||||||
|
async fn _get_client() -> ApiResult<CoreClient>;
|
||||||
|
async fn cached() -> ApiResult<CoreClient>;
|
||||||
|
|
||||||
|
async fn user_info_async(&self, access_token: AccessToken) -> ApiResult<CoreUserInfoClaims>;
|
||||||
|
|
||||||
|
fn vw_id_token_verifier(&self) -> CoreIdTokenVerifier<'_>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[rocket::async_trait]
|
||||||
|
impl CoreClientExt for CoreClient {
|
||||||
|
// Call the OpenId discovery endpoint to retrieve configuration
|
||||||
|
async fn _get_client() -> ApiResult<CoreClient> {
|
||||||
|
let client_id = ClientId::new(CONFIG.sso_client_id());
|
||||||
|
let client_secret = ClientSecret::new(CONFIG.sso_client_secret());
|
||||||
|
|
||||||
|
let issuer_url = CONFIG.sso_issuer_url()?;
|
||||||
|
|
||||||
|
let provider_metadata = match CoreProviderMetadata::discover_async(issuer_url, async_http_client).await {
|
||||||
|
Err(err) => err!(format!("Failed to discover OpenID provider: {err}")),
|
||||||
|
Ok(metadata) => metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret))
|
||||||
|
.set_redirect_uri(CONFIG.sso_redirect_url()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple cache to prevent recalling the discovery endpoint each time
|
||||||
|
async fn cached() -> ApiResult<CoreClient> {
|
||||||
|
let cc_client = CLIENT_CACHE.read().ok().and_then(|rw_lock| rw_lock.clone());
|
||||||
|
match cc_client {
|
||||||
|
Some(client) => Ok(client),
|
||||||
|
None => Self::_get_client().await.map(|client| {
|
||||||
|
let mut cached_client = CLIENT_CACHE.write().unwrap();
|
||||||
|
*cached_client = Some(client.clone());
|
||||||
|
client
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn user_info_async(&self, access_token: AccessToken) -> ApiResult<CoreUserInfoClaims> {
|
||||||
|
let endpoint = match self.user_info(access_token, None) {
|
||||||
|
Err(err) => err!(format!("No user_info endpoint: {err}")),
|
||||||
|
Ok(endpoint) => endpoint,
|
||||||
|
};
|
||||||
|
|
||||||
|
match endpoint.request_async(async_http_client).await {
|
||||||
|
Err(err) => err!(format!("Request to user_info endpoint failed: {err}")),
|
||||||
|
Ok(user_info) => Ok(user_info),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vw_id_token_verifier(&self) -> CoreIdTokenVerifier<'_> {
|
||||||
|
let mut verifier = self.id_token_verifier();
|
||||||
|
if let Some(regex_str) = CONFIG.sso_audience_trusted() {
|
||||||
|
match Regex::new(®ex_str) {
|
||||||
|
Ok(regex) => {
|
||||||
|
verifier = verifier.set_other_audience_verifier_fn(move |aud| regex.is_match(aud));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("Failed to parse SSO_AUDIENCE_TRUSTED={regex_str} regex: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
verifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The `nonce` allow to protect against replay attacks
|
||||||
|
// redirect_uri from: https://github.com/bitwarden/server/blob/main/src/Identity/IdentityServer/ApiClient.cs
|
||||||
|
pub async fn authorize_url(state: String, client_id: &str, raw_redirect_uri: &str, mut conn: DbConn) -> ApiResult<Url> {
|
||||||
|
let scopes = CONFIG.sso_scopes_vec().into_iter().map(Scope::new);
|
||||||
|
|
||||||
|
let redirect_uri = match client_id {
|
||||||
|
"web" | "browser" => format!("{}/sso-connector.html", CONFIG.domain()),
|
||||||
|
"desktop" | "mobile" => "bitwarden://sso-callback".to_string(),
|
||||||
|
"cli" => {
|
||||||
|
let port_regex = Regex::new(r"^http://localhost:([0-9]{4})$").unwrap();
|
||||||
|
match port_regex.captures(raw_redirect_uri).and_then(|captures| captures.get(1).map(|c| c.as_str())) {
|
||||||
|
Some(port) => format!("http://localhost:{}", port),
|
||||||
|
None => err!("Failed to extract port number"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => err!(format!("Unsupported client {client_id}")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = CoreClient::cached().await?;
|
||||||
|
let mut auth_req = client
|
||||||
|
.authorize_url(
|
||||||
|
AuthenticationFlow::<CoreResponseType>::AuthorizationCode,
|
||||||
|
|| CsrfToken::new(state),
|
||||||
|
Nonce::new_random,
|
||||||
|
)
|
||||||
|
.add_scopes(scopes)
|
||||||
|
.add_extra_params(CONFIG.sso_authorize_extra_params_vec());
|
||||||
|
|
||||||
|
let verifier = if CONFIG.sso_pkce() {
|
||||||
|
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||||
|
auth_req = auth_req.set_pkce_challenge(pkce_challenge);
|
||||||
|
Some(pkce_verifier.secret().to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let (auth_url, csrf_state, nonce) = auth_req.url();
|
||||||
|
|
||||||
|
let sso_nonce = SsoNonce::new(csrf_state.secret().to_string(), nonce.secret().to_string(), verifier, redirect_uri);
|
||||||
|
sso_nonce.save(&mut conn).await?;
|
||||||
|
|
||||||
|
Ok(auth_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct AuthenticatedUser {
|
||||||
|
pub state: String,
|
||||||
|
pub refresh_token: Option<String>,
|
||||||
|
pub access_token: String,
|
||||||
|
pub expires_in: Option<Duration>,
|
||||||
|
pub identifier: String,
|
||||||
|
pub email: String,
|
||||||
|
pub email_verified: Option<bool>,
|
||||||
|
pub user_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct UserInformation {
|
||||||
|
pub state: String,
|
||||||
|
pub identifier: String,
|
||||||
|
pub email: String,
|
||||||
|
pub email_verified: Option<bool>,
|
||||||
|
pub user_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn decode_code_claims(code: &str, conn: &mut DbConn) -> ApiResult<(String, String)> {
|
||||||
|
match auth::decode_jwt::<OIDCCodeClaims>(code, SSO_JWT_ISSUER.to_string()) {
|
||||||
|
Ok(code_claims) => match code_claims.code {
|
||||||
|
OIDCCodeWrapper::Ok {
|
||||||
|
code,
|
||||||
|
state,
|
||||||
|
} => Ok((code, state)),
|
||||||
|
OIDCCodeWrapper::Error {
|
||||||
|
state,
|
||||||
|
error,
|
||||||
|
error_description,
|
||||||
|
} => {
|
||||||
|
if let Err(err) = SsoNonce::delete(&state, conn).await {
|
||||||
|
error!("Failed to delete database sso_nonce using {state}: {err}")
|
||||||
|
}
|
||||||
|
err!(format!(
|
||||||
|
"SSO authorization failed: {error}, {}",
|
||||||
|
error_description.as_ref().unwrap_or(&String::new())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) => err!(format!("Failed to decode code wrapper: {err}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// During the 2FA flow we will
|
||||||
|
// - retrieve the user information and then only discover he needs 2FA.
|
||||||
|
// - second time we will rely on the `AC_CACHE` since the `code` has already been exchanged.
|
||||||
|
// The `nonce` will ensure that the user is authorized only once.
|
||||||
|
// We return only the `UserInformation` to force calling `redeem` to obtain the `refresh_token`.
|
||||||
|
pub async fn exchange_code(wrapped_code: &str, conn: &mut DbConn) -> ApiResult<UserInformation> {
|
||||||
|
let (code, state) = decode_code_claims(wrapped_code, conn).await?;
|
||||||
|
|
||||||
|
if let Some(authenticated_user) = AC_CACHE.get(&state) {
|
||||||
|
return Ok(UserInformation {
|
||||||
|
state,
|
||||||
|
identifier: authenticated_user.identifier,
|
||||||
|
email: authenticated_user.email,
|
||||||
|
email_verified: authenticated_user.email_verified,
|
||||||
|
user_name: authenticated_user.user_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let oidc_code = AuthorizationCode::new(code.clone());
|
||||||
|
let client = CoreClient::cached().await?;
|
||||||
|
|
||||||
|
let nonce = match SsoNonce::find(&state, conn).await {
|
||||||
|
None => err!(format!("Invalid state cannot retrieve nonce")),
|
||||||
|
Some(nonce) => nonce,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut exchange = client.exchange_code(oidc_code);
|
||||||
|
|
||||||
|
if CONFIG.sso_pkce() {
|
||||||
|
match nonce.verifier {
|
||||||
|
None => err!(format!("Missing verifier in the DB nonce table")),
|
||||||
|
Some(secret) => exchange = exchange.set_pkce_verifier(PkceCodeVerifier::new(secret)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match exchange.request_async(async_http_client).await {
|
||||||
|
Ok(token_response) => {
|
||||||
|
let user_info = client.user_info_async(token_response.access_token().to_owned()).await?;
|
||||||
|
let oidc_nonce = Nonce::new(nonce.nonce.clone());
|
||||||
|
|
||||||
|
let id_token = match token_response.extra_fields().id_token() {
|
||||||
|
None => err!("Token response did not contain an id_token"),
|
||||||
|
Some(token) => token,
|
||||||
|
};
|
||||||
|
|
||||||
|
if CONFIG.sso_debug_tokens() {
|
||||||
|
debug!("Id token: {}", id_token.to_string());
|
||||||
|
debug!("Access token: {}", token_response.access_token().secret().to_string());
|
||||||
|
debug!("Refresh token: {:?}", token_response.refresh_token().map(|t| t.secret().to_string()));
|
||||||
|
debug!("Expiration time: {:?}", token_response.expires_in());
|
||||||
|
}
|
||||||
|
|
||||||
|
let id_claims = match id_token.claims(&client.vw_id_token_verifier(), &oidc_nonce) {
|
||||||
|
Err(err) => err!(format!("Could not read id_token claims, {err}")),
|
||||||
|
Ok(claims) => claims,
|
||||||
|
};
|
||||||
|
|
||||||
|
let email = match id_claims.email() {
|
||||||
|
Some(email) => email.to_string(),
|
||||||
|
None => match user_info.email() {
|
||||||
|
None => err!("Neither id token nor userinfo contained an email"),
|
||||||
|
Some(email) => email.to_owned().to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
.to_lowercase();
|
||||||
|
|
||||||
|
let user_name = user_info.preferred_username().map(|un| un.to_string());
|
||||||
|
|
||||||
|
let refresh_token = token_response.refresh_token().map(|t| t.secret().to_string());
|
||||||
|
if refresh_token.is_none() && CONFIG.sso_scopes_vec().contains(&"offline_access".to_string()) {
|
||||||
|
error!("Scope offline_access is present but response contain no refresh_token");
|
||||||
|
}
|
||||||
|
|
||||||
|
let identifier = format!("{}/{}", **id_claims.issuer(), **id_claims.subject());
|
||||||
|
|
||||||
|
let authenticated_user = AuthenticatedUser {
|
||||||
|
state: nonce.state,
|
||||||
|
refresh_token,
|
||||||
|
access_token: token_response.access_token().secret().to_string(),
|
||||||
|
expires_in: token_response.expires_in(),
|
||||||
|
identifier: identifier.clone(),
|
||||||
|
email: email.clone(),
|
||||||
|
email_verified: id_claims.email_verified(),
|
||||||
|
user_name: user_name.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
AC_CACHE.insert(state.clone(), authenticated_user.clone());
|
||||||
|
|
||||||
|
Ok(UserInformation {
|
||||||
|
state,
|
||||||
|
identifier,
|
||||||
|
email,
|
||||||
|
email_verified: id_claims.email_verified(),
|
||||||
|
user_name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(err) => err!(format!("Failed to contact token endpoint: {err}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User has passed 2FA flow we can delete `nonce` and clear the cache.
|
||||||
|
pub async fn redeem(state: &String, conn: &mut DbConn) -> ApiResult<AuthenticatedUser> {
|
||||||
|
if let Err(err) = SsoNonce::delete(state, conn).await {
|
||||||
|
error!("Failed to delete database sso_nonce using {state}: {err}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(au) = AC_CACHE.get(state) {
|
||||||
|
AC_CACHE.invalidate(state);
|
||||||
|
Ok(au)
|
||||||
|
} else {
|
||||||
|
err!("Failed to retrieve user info from sso cache")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We always return a refresh_token (with no refresh_token some secrets are not displayed in the web front).
|
||||||
|
// If there is no SSO refresh_token, we keep the access_token to be able to call user_info to check for validity
|
||||||
|
pub fn create_auth_tokens(
|
||||||
|
device: &Device,
|
||||||
|
user: &User,
|
||||||
|
refresh_token: Option<String>,
|
||||||
|
access_token: &str,
|
||||||
|
expires_in: Option<Duration>,
|
||||||
|
) -> ApiResult<auth::AuthTokens> {
|
||||||
|
if !CONFIG.sso_auth_only_not_session() {
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
let (ap_nbf, ap_exp) = match (decode_token_claims("access_token", access_token), expires_in) {
|
||||||
|
(Ok(ap), _) => (ap.nbf(), ap.exp),
|
||||||
|
(Err(_), Some(exp)) => (now.timestamp(), (now + exp).timestamp()),
|
||||||
|
_ => err!("Non jwt access_token and empty expires_in"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let access_claims =
|
||||||
|
auth::LoginJwtClaims::new(device, user, ap_nbf, ap_exp, auth::AuthMethod::Sso.scope_vec(), now);
|
||||||
|
|
||||||
|
_create_auth_tokens(device, refresh_token, access_claims, access_token)
|
||||||
|
} else {
|
||||||
|
Ok(AuthTokens::new(device, user, AuthMethod::Sso))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn _create_auth_tokens(
|
||||||
|
device: &Device,
|
||||||
|
refresh_token: Option<String>,
|
||||||
|
access_claims: auth::LoginJwtClaims,
|
||||||
|
access_token: &str,
|
||||||
|
) -> ApiResult<auth::AuthTokens> {
|
||||||
|
let (nbf, exp, token) = if let Some(rt) = refresh_token.as_ref() {
|
||||||
|
match decode_token_claims("refresh_token", rt) {
|
||||||
|
Err(_) => {
|
||||||
|
let time_now = Utc::now();
|
||||||
|
let exp = (time_now + *DEFAULT_REFRESH_VALIDITY).timestamp();
|
||||||
|
debug!("Non jwt refresh_token (expiration set to {})", exp);
|
||||||
|
(time_now.timestamp(), exp, TokenWrapper::Refresh(rt.to_string()))
|
||||||
|
}
|
||||||
|
Ok(refresh_payload) => {
|
||||||
|
debug!("Refresh_payload: {:?}", refresh_payload);
|
||||||
|
(refresh_payload.nbf(), refresh_payload.exp, TokenWrapper::Refresh(rt.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debug!("No refresh_token present");
|
||||||
|
(access_claims.nbf, access_claims.exp, TokenWrapper::Access(access_token.to_string()))
|
||||||
|
};
|
||||||
|
|
||||||
|
let refresh_claims = auth::RefreshJwtClaims {
|
||||||
|
nbf,
|
||||||
|
exp,
|
||||||
|
iss: auth::JWT_LOGIN_ISSUER.to_string(),
|
||||||
|
sub: auth::AuthMethod::Sso,
|
||||||
|
device_token: device.refresh_token.clone(),
|
||||||
|
token: Some(token),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(auth::AuthTokens {
|
||||||
|
refresh_claims,
|
||||||
|
access_claims,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// This endpoint is called in two case
|
||||||
|
// - the session is close to expiration we will try to extend it
|
||||||
|
// - the user is going to make an action and we check that the session is still valid
|
||||||
|
pub async fn exchange_refresh_token(
|
||||||
|
device: &Device,
|
||||||
|
user: &User,
|
||||||
|
refresh_claims: &auth::RefreshJwtClaims,
|
||||||
|
) -> ApiResult<auth::AuthTokens> {
|
||||||
|
match &refresh_claims.token {
|
||||||
|
Some(TokenWrapper::Refresh(refresh_token)) => {
|
||||||
|
let rt = RefreshToken::new(refresh_token.to_string());
|
||||||
|
|
||||||
|
let client = CoreClient::cached().await?;
|
||||||
|
|
||||||
|
let token_response = match client.exchange_refresh_token(&rt).request_async(async_http_client).await {
|
||||||
|
Err(err) => err!(format!("Request to exchange_refresh_token endpoint failed: {:?}", err)),
|
||||||
|
Ok(token_response) => token_response,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use new refresh_token if returned
|
||||||
|
let rolled_refresh_token = token_response
|
||||||
|
.refresh_token()
|
||||||
|
.map(|token| token.secret().to_string())
|
||||||
|
.unwrap_or(refresh_token.to_string());
|
||||||
|
|
||||||
|
create_auth_tokens(
|
||||||
|
device,
|
||||||
|
user,
|
||||||
|
Some(rolled_refresh_token),
|
||||||
|
token_response.access_token().secret(),
|
||||||
|
token_response.expires_in(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Some(TokenWrapper::Access(access_token)) => {
|
||||||
|
let now = Utc::now();
|
||||||
|
let exp_limit = (now + *BW_EXPIRATION).timestamp();
|
||||||
|
|
||||||
|
if refresh_claims.exp < exp_limit {
|
||||||
|
err_silent!("Access token is close to expiration but we have no refresh token")
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = CoreClient::cached().await?;
|
||||||
|
match client.user_info_async(AccessToken::new(access_token.to_string())).await {
|
||||||
|
Err(err) => {
|
||||||
|
err_silent!(format!("Failed to retrieve user info, token has probably been invalidated: {err}"))
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
let access_claims = auth::LoginJwtClaims::new(
|
||||||
|
device,
|
||||||
|
user,
|
||||||
|
now.timestamp(),
|
||||||
|
refresh_claims.exp,
|
||||||
|
auth::AuthMethod::Sso.scope_vec(),
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
_create_auth_tokens(device, None, access_claims, access_token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => err!("No token present while in SSO"),
|
||||||
|
}
|
||||||
|
}
|
4
src/static/templates/email/sso_change_email.hbs
Normale Datei
4
src/static/templates/email/sso_change_email.hbs
Normale Datei
|
@ -0,0 +1,4 @@
|
||||||
|
Your Email Changed
|
||||||
|
<!---------------->
|
||||||
|
Your email was changed in your SSO Provider. Please update your email in Account Settings ({{url}}).
|
||||||
|
{{> email/email_footer_text }}
|
11
src/static/templates/email/sso_change_email.html.hbs
Normale Datei
11
src/static/templates/email/sso_change_email.html.hbs
Normale Datei
|
@ -0,0 +1,11 @@
|
||||||
|
Your Email 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; text-align: center;" valign="top" align="center">
|
||||||
|
Your email was changed in your SSO Provider. Please update your email in <a href="{{url}}/">Account Settings</a>.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{{> email/email_footer }}
|
63
src/util.rs
63
src/util.rs
|
@ -7,7 +7,7 @@ use num_traits::ToPrimitive;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use rocket::{
|
use rocket::{
|
||||||
fairing::{Fairing, Info, Kind},
|
fairing::{Fairing, Info, Kind},
|
||||||
http::{ContentType, Cookie, CookieJar, Header, HeaderMap, Method, SameSite, Status},
|
http::{ContentType, Header, HeaderMap, Method, Status},
|
||||||
request::FromParam,
|
request::FromParam,
|
||||||
response::{self, Responder},
|
response::{self, Responder},
|
||||||
Data, Orbit, Request, Response, Rocket,
|
Data, Orbit, Request, Response, Rocket,
|
||||||
|
@ -130,10 +130,12 @@ impl Cors {
|
||||||
// If a match exists, return it. Otherwise, return None.
|
// If a match exists, return it. Otherwise, return None.
|
||||||
fn get_allowed_origin(headers: &HeaderMap<'_>) -> Option<String> {
|
fn get_allowed_origin(headers: &HeaderMap<'_>) -> Option<String> {
|
||||||
let origin = Cors::get_header(headers, "Origin");
|
let origin = Cors::get_header(headers, "Origin");
|
||||||
let domain_origin = CONFIG.domain_origin();
|
|
||||||
let sso_origin = CONFIG.sso_authority();
|
|
||||||
let safari_extension_origin = "file://";
|
let safari_extension_origin = "file://";
|
||||||
if origin == domain_origin || origin == safari_extension_origin || origin == sso_origin {
|
|
||||||
|
if origin == CONFIG.domain_origin()
|
||||||
|
|| origin == safari_extension_origin
|
||||||
|
|| (CONFIG.sso_enabled() && origin == CONFIG.sso_authority())
|
||||||
|
{
|
||||||
Some(origin)
|
Some(origin)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
@ -258,33 +260,6 @@ 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
|
// Log all the routes from the main paths list, and the attachments endpoint
|
||||||
// Effectively ignores, any static file route, and the alive endpoint
|
// Effectively ignores, any static file route, and the alive endpoint
|
||||||
const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"];
|
const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"];
|
||||||
|
@ -1036,29 +1011,3 @@ 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