Compare commits
66 commits
Author | SHA1 | Date | |
---|---|---|---|
|
7ce4af79ba | ||
|
3f47d7f008 | ||
|
3ce4e0ff87 | ||
|
855e45bbb7 | ||
|
013661bbf4 | ||
|
51a976fed5 | ||
|
fee2d3b0d6 | ||
|
a810bc43c0 | ||
|
095df1b216 | ||
|
a7fabdedef | ||
|
4803710026 | ||
|
2f7279d8db | ||
|
46ad459a56 | ||
|
beb937f303 | ||
|
ab8dd18e4f | ||
|
e0bc19d033 | ||
|
f481e033ef | ||
|
1909d1a15a | ||
|
6b0c8be718 | ||
|
caa8b2d7a6 | ||
|
ab8ef8d977 | ||
|
d4a373365e | ||
|
0d967b8dbe | ||
|
c3dbf83312 | ||
|
bc61225600 | ||
|
2ee5f0ccc4 | ||
|
7c82c951f5 | ||
|
dafc98b1db | ||
|
c5f8196666 | ||
|
24c95ff5ff | ||
|
a2119c54c5 | ||
|
c464f0bd9e | ||
|
2882967f54 | ||
|
d6f6a2671d | ||
|
078625cbf9 | ||
|
fb001765ae | ||
|
e04d0680a4 | ||
|
d058b8c053 | ||
|
2dd8891d51 | ||
|
abbce9edf3 | ||
|
9bd1fe1481 | ||
|
f6d16ff08a | ||
|
e4c6ca767e | ||
|
6458660a24 | ||
|
a6dcb960d7 | ||
|
a6ecff0caa | ||
|
54cea7a9b7 | ||
|
ed20725817 | ||
|
69ceb6c4f7 | ||
|
ee5c382d8e | ||
|
4f55b1cc33 | ||
|
c3e42ba257 | ||
|
ad93202992 | ||
|
99573f2b94 | ||
|
78ced241eb | ||
|
770efa80f0 | ||
|
c297c3f5d9 | ||
|
26321bc6ed | ||
|
d72b551d2f | ||
|
e50e967880 | ||
|
daba216803 | ||
|
8afdc065bb | ||
|
b8811c9eaf | ||
|
750932b322 | ||
|
6232206d43 | ||
|
b7ce4350e3 |
101 changed files with 2748 additions and 5072 deletions
1274
Cargo.lock
generated
1274
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
30
Cargo.toml
30
Cargo.toml
|
@ -1,5 +1,5 @@
|
|||
[workspace.package]
|
||||
version = "1.4.0-dev"
|
||||
version = "1.4.6"
|
||||
authors = [
|
||||
"William Brown <william@blackhats.net.au>",
|
||||
"James Hodgkinson <james@terminaloutcomes.com>",
|
||||
|
@ -121,20 +121,20 @@ codegen-units = 256
|
|||
# kanidm-hsm-crypto = { path = "../hsm-crypto" }
|
||||
|
||||
[workspace.dependencies]
|
||||
kanidmd_core = { path = "./server/core", version = "=1.4.0-dev" }
|
||||
kanidmd_lib = { path = "./server/lib", version = "=1.4.0-dev" }
|
||||
kanidmd_lib_macros = { path = "./server/lib-macros", version = "=1.4.0-dev" }
|
||||
kanidmd_testkit = { path = "./server/testkit", version = "=1.4.0-dev" }
|
||||
kanidm_build_profiles = { path = "./libs/profiles", version = "=1.4.0-dev" }
|
||||
kanidm_client = { path = "./libs/client", version = "=1.4.0-dev" }
|
||||
kanidmd_core = { path = "./server/core", version = "=1.4.6" }
|
||||
kanidmd_lib = { path = "./server/lib", version = "=1.4.6" }
|
||||
kanidmd_lib_macros = { path = "./server/lib-macros", version = "=1.4.6" }
|
||||
kanidmd_testkit = { path = "./server/testkit", version = "=1.4.6" }
|
||||
kanidm_build_profiles = { path = "./libs/profiles", version = "=1.4.6" }
|
||||
kanidm_client = { path = "./libs/client", version = "=1.4.6" }
|
||||
kanidm-hsm-crypto = "^0.2.0"
|
||||
kanidm_lib_crypto = { path = "./libs/crypto", version = "=1.4.0-dev" }
|
||||
kanidm_lib_file_permissions = { path = "./libs/file_permissions", version = "=1.4.0-dev" }
|
||||
kanidm_proto = { path = "./proto", version = "=1.4.0-dev" }
|
||||
kanidm_unix_common = { path = "./unix_integration/common", version = "=1.4.0-dev" }
|
||||
kanidm_utils_users = { path = "./libs/users", version = "=1.4.0-dev" }
|
||||
scim_proto = { path = "./libs/scim_proto", version = "=1.4.0-dev" }
|
||||
sketching = { path = "./libs/sketching", version = "=1.4.0-dev" }
|
||||
kanidm_lib_crypto = { path = "./libs/crypto", version = "=1.4.6" }
|
||||
kanidm_lib_file_permissions = { path = "./libs/file_permissions", version = "=1.4.6" }
|
||||
kanidm_proto = { path = "./proto", version = "=1.4.6" }
|
||||
kanidm_unix_common = { path = "./unix_integration/common", version = "=1.4.6" }
|
||||
kanidm_utils_users = { path = "./libs/users", version = "=1.4.6" }
|
||||
scim_proto = { path = "./libs/scim_proto", version = "=1.4.6" }
|
||||
sketching = { path = "./libs/sketching", version = "=1.4.6" }
|
||||
|
||||
anyhow = { version = "1.0.90" }
|
||||
argon2 = { version = "0.5.3", features = ["alloc"] }
|
||||
|
@ -261,7 +261,7 @@ svg = "0.13.1"
|
|||
syn = { version = "2.0.82", features = ["full"] }
|
||||
tempfile = "3.13.0"
|
||||
testkit-macros = { path = "./server/testkit-macros" }
|
||||
time = { version = "^0.3.34", features = ["formatting", "local-offset"] }
|
||||
time = { version = "=0.3.36", features = ["formatting", "local-offset"] }
|
||||
|
||||
tokio = "^1.40.0"
|
||||
tokio-openssl = "^0.6.5"
|
||||
|
|
10
Makefile
10
Makefile
|
@ -1,5 +1,5 @@
|
|||
IMAGE_BASE ?= kanidm
|
||||
IMAGE_VERSION ?= devel
|
||||
IMAGE_VERSION ?= latest
|
||||
IMAGE_EXT_VERSION ?= $(shell cargo metadata --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "daemon") | .version')
|
||||
CONTAINER_TOOL_ARGS ?=
|
||||
IMAGE_ARCH ?= "linux/amd64,linux/arm64"
|
||||
|
@ -178,11 +178,9 @@ codespell:
|
|||
--skip='*.svg' \
|
||||
--skip='*.br' \
|
||||
--skip='./rlm_python/mods-available/eap' \
|
||||
--skip='./server/web_ui/static/external' \
|
||||
--skip='./server/web_ui/pkg/external' \
|
||||
--skip='./server/web_ui/shared/static/external' \
|
||||
--skip='./server/web_ui/admin/static/external,./server/web_ui/admin/pkg/external' \
|
||||
--skip='./server/lib/src/constants/system_config.rs,./pykanidm/site,./server/lib/src/constants/*.json'
|
||||
--skip='./server/lib/src/constants/system_config.rs'
|
||||
--skip='./pykanidm/site' \
|
||||
--skip='./server/lib/src/constants/*.json'
|
||||
|
||||
.PHONY: test/pykanidm/pytest
|
||||
test/pykanidm/pytest: ## python library testing
|
||||
|
|
|
@ -14,7 +14,7 @@ report it to our [issue tracker].
|
|||
|
||||
# Release Notes
|
||||
|
||||
## 2024-08-07 - Kanidm 1.4.0
|
||||
## 2024-11-01 - Kanidm 1.4.0
|
||||
|
||||
This is the latest stable release of the Kanidm Identity Management project. Every release is the
|
||||
combined effort of our community and we appreciate their invaluable contributions, comments,
|
||||
|
@ -63,7 +63,7 @@ and taken the needed steps before upgrading.
|
|||
- SCIM foundations for getting and modifying entries, reference handling, and complex attribute
|
||||
display. Much more to come in this space!
|
||||
- Rewrite the entire web frontend to be simpler and faster, allowing more features to be added
|
||||
in future. Greatly improves user expirence as the pages are now very fast to load!
|
||||
in the future. Greatly improves user experience as the pages are now very fast to load!
|
||||
|
||||
## 2024-08-07 - Kanidm 1.3.0
|
||||
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
- [Domain Rename](domain_rename.md)
|
||||
- [Monitoring the platform](monitoring_the_platform.md)
|
||||
- [Recycle Bin](recycle_bin.md)
|
||||
- [Customising](customising.md)
|
||||
|
||||
- [Accounts and Groups](accounts/intro.md)
|
||||
- [People Accounts](accounts/people_accounts.md)
|
||||
|
|
|
@ -110,9 +110,9 @@ These validity settings impact all authentication functions of the account (kani
|
|||
|
||||
By default, Kanidm allows an account to change some attributes, but not their mail address.
|
||||
|
||||
Adding the user to the `idm_people_self_write_mail` group, as shown below, allows the user to edit
|
||||
Adding the user to the `idm_people_self_mail_write` group, as shown below, allows the user to edit
|
||||
their own mail.
|
||||
|
||||
```bash
|
||||
kanidm group add-members idm_people_self_write_mail demo_user --name idm_admin
|
||||
kanidm group add-members idm_people_self_mail_write demo_user --name idm_admin
|
||||
```
|
||||
|
|
|
@ -11,14 +11,15 @@ when choosing a domain.
|
|||
> **Bad choices** of domain name may have security impacts on your Kanidm
|
||||
> instance, not limited to credential phishing, theft, session leaks and more.
|
||||
>
|
||||
> **Changing** domain name is hard to do – it not only means reconfiguring all
|
||||
> LDAP and OAuth clients, but will also break all registered WebAuthn
|
||||
> credentials for all users (which are bound to an `Origin`).
|
||||
> [**Changing** domain name is hard to do](./domain_rename.md) – it not only
|
||||
> means reconfiguring all LDAP and OAuth clients, but will also break all
|
||||
> registered WebAuthn credentials for all users (which are bound to an
|
||||
> `Origin`).
|
||||
>
|
||||
> It's critical that you consider and follow the advice in this chapter, and
|
||||
> aim to get it right the first time.
|
||||
>
|
||||
> You'll save yourself a lot of work later!
|
||||
> You'll save yourself (and your users) a lot of work later!
|
||||
|
||||
<!-- -->
|
||||
|
||||
|
@ -89,7 +90,7 @@ country cease to exist (eg: [as for `.io`][dot-io]).
|
|||
### Top-level domains containing "kanidm"
|
||||
|
||||
We ask that you **do not** use the word `kanidm` as part of your instance's
|
||||
*top-level* (or [public suffix][ps]) domain, eg: `contosokanidm.example`.
|
||||
*top-level* (or [public-suffix-level][ps]) domain, eg: `contoso-kanidm.example`.
|
||||
|
||||
Use something like `auth`, `idm`, `login` or `sso` instead – they're shorter,
|
||||
too!
|
||||
|
@ -98,10 +99,10 @@ We're OK with you using `kanidm` in a *subdomain* to point to your Kanidm
|
|||
instance, eg: `kanidm.example.com`.
|
||||
|
||||
We've worked hard to build this project, and using its name in conjunction with
|
||||
an organisation *not* associated with the project dilutes the name's branding
|
||||
an organisation *not* associated with the project dilutes the name's brand
|
||||
value.
|
||||
|
||||
### Subdomains and Origin policy
|
||||
### Subdomains and Cross-Origin policy
|
||||
|
||||
Browsers allow a server on a subdomain to use intra-domain resources, and access
|
||||
and set credentials and cookies from all of its parents until a
|
||||
|
@ -154,9 +155,80 @@ This can be an issue if Kanidm shares a domain with:
|
|||
* third-party servers *outside* of your organisation's control (eg: SaaS apps)
|
||||
* anything which can be deployed to with minimal oversight (eg: a web host that
|
||||
allows uploading content via unencrypted FTP)
|
||||
* DNS entries that resolve to arbitrary IP addresses (eg:
|
||||
`192-0-2-1.ipv4.example.com` to `192.0.2.1`, and `192.0.2.1` is not under
|
||||
the authority of `example.com`)
|
||||
|
||||
[matrix-csp]: https://github.com/element-hq/synapse/blob/develop/README.rst#security-note
|
||||
|
||||
In most cases, hosting Kanidm on a subdomain of a separate top-level (or
|
||||
*existing* [public-suffix level][ps]) domain (eg: `idm.contoso-auth.example`) is
|
||||
sufficient to isolate your Kanidm deployment's `Origin` from other applications
|
||||
and services.
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> There is generally **no need** to request additions to
|
||||
> [the public suffix list][ps] to deploy Kanidm securely,
|
||||
> *even for multi-environment deployments*.
|
||||
>
|
||||
> The **only** exception is to *remove* an *existing* opt-out that affects your
|
||||
> domain where it must operate under a particular suffix (eg: a NSW government
|
||||
> agency using `example.nsw.gov.au`).
|
||||
>
|
||||
> Such requests are a
|
||||
> [major burden for the *volunteer-operated* list][ps-diffusion], can take
|
||||
> [months to roll out to clients][ps-rollout], and changes may have unintended
|
||||
> side-effects.
|
||||
>
|
||||
> By comparison, registering a separate domain is easy, and takes minutes.
|
||||
|
||||
[ps-diffusion]: https://github.com/publicsuffix/list/wiki/Third-Party-Diffusion
|
||||
[ps-rollout]: https://github.com/publicsuffix/list/wiki/Guidelines#appropriate-expectations-on-derivative-propagation-use-or-inclusion
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> Web apps (and APIs) that authenticate with
|
||||
> [OAuth 2.0/OpenID Connect](./integrations/oauth2.md) **never** need to share
|
||||
> cookies or Origin with Kanidm, so they **do not** need to be on the same
|
||||
> top-level (or [public-suffix-level][ps]) domain.
|
||||
>
|
||||
> Large public auth providers (eg: Google, Meta, Microsoft) work the same way
|
||||
> with both first and third-party web apps.
|
||||
|
||||
### Kanidm requires its own hostname
|
||||
|
||||
Kanidm must be the *only* thing running on a hostname, served from `/`, with all
|
||||
its paths served as-is.
|
||||
|
||||
It cannot:
|
||||
|
||||
* be run from a subdirectory (eg: `https://example.com/kanidm/`)
|
||||
* have *other* services accessible on the hostname in subdirectories (eg:
|
||||
`https://idm.example.com/wiki/`)
|
||||
* have *other* services accessible over HTTP or HTTPS at the same hostname on a
|
||||
different port (eg: `https://idm.example.com:8080/`)
|
||||
|
||||
These introduce similar security risks to the
|
||||
[subdomain issues described above](#subdomains-and-cross-origin-policy).
|
||||
|
||||
One reasonable exception is to serve [ACME HTTP-01 challenges][acme-http] (for
|
||||
Let's Encrypt) at `http://${hostname}/.well-known/acme-challenge/`. You'll need
|
||||
a *separate* HTTP server to respond to these challenges, and ensure that only
|
||||
authorised processes can request a certificate for Kanidm's hostname.
|
||||
|
||||
[acme-http]: https://letsencrypt.org/docs/challenge-types/#http-01-challenge
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> The `/.well-known/` path ([RFC 8615][]) can be assigned security-sensitive
|
||||
> meaning in other protocols, similar to [ACME HTTP-01][acme-http].
|
||||
>
|
||||
> Kanidm currently uses this path for OpenID Connect Discovery, and may use it
|
||||
> for other integrations in the future.
|
||||
|
||||
[RFC 8615]: https://datatracker.ietf.org/doc/html/rfc8615
|
||||
|
||||
### Avoid wildcard and widely-scoped certificates
|
||||
|
||||
CAs can issue wildcard TLS certificates, which apply to all subdomains in the
|
||||
|
@ -212,18 +284,12 @@ For **maximum** security, your Kanidm domain name should be a subdomain of a
|
|||
top-level domain (or domain under a [public suffix][ps]) that has no other
|
||||
services assigned it, eg:
|
||||
|
||||
* Origin: `https://idm.exampleauth.example`
|
||||
* Domain name: `idm.exampleauth.example`
|
||||
* Origin: `https://idm.example-auth.example`
|
||||
* Domain name: `idm.example-auth.example`
|
||||
|
||||
When using [OAuth 2.0/OpenID Connect](./integrations/oauth2.md), there is no
|
||||
need for a client app to share a top-level domain with Kanidm, because the app
|
||||
does not need to share cookies.
|
||||
|
||||
Large public auth providers (eg: Google, Meta, Microsoft) work the same way with
|
||||
both first and third-party apps.
|
||||
|
||||
If you have strict security controls for all apps on your top-level domain, you
|
||||
could run Kanidm on a subdomain of your main domain, eg:
|
||||
If you have
|
||||
[strict security controls for all apps on your top-level domain](#subdomains-and-cross-origin-policy),
|
||||
you could run Kanidm on a subdomain of your main domain, eg:
|
||||
|
||||
* Origin: `https://idm.example.com`
|
||||
* Domain name: `idm.example.com`
|
||||
|
@ -233,8 +299,9 @@ restrict changes that *could* affect your IDM infrastructure.
|
|||
|
||||
> [!NOTE]
|
||||
>
|
||||
> This is the **inverse** of the common Active Directory practice of using the
|
||||
> organisation's primary top-level domain directly, eg: `example.com`.
|
||||
> Using a subdomain is the **inverse** of the common Active Directory practice
|
||||
> of using the organisation's primary top-level domain directly, eg:
|
||||
> `example.com`.
|
||||
|
||||
### Multi-environment and regional deployments
|
||||
|
||||
|
|
55
book/src/customising.md
Normal file
55
book/src/customising.md
Normal file
|
@ -0,0 +1,55 @@
|
|||
# Customising
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> Currently theming options such as updating the CSS requires modifying the style.css file. This
|
||||
> may be changed in the future to make it easier to modify.
|
||||
|
||||
Kanidm supports customising various aspects such as the site display name, site image, and display
|
||||
names and images for each application.
|
||||
|
||||
## Changing the site
|
||||
|
||||
### Updating the display Name
|
||||
|
||||
By default, the display name is 'Kanidm <hostname>' which is visible when logged in. To modify the
|
||||
display name, run the following
|
||||
|
||||
```bash
|
||||
kanidm system domain set-displayname <new-display-name> -D admin
|
||||
```
|
||||
|
||||
### Updating the site image
|
||||
|
||||
Similarly instead of the default Ferris the crab logo, the image on the signin page can be updated
|
||||
or reset with the below commands. The image must satisfy the following conditions:
|
||||
1. Maximum 1024 x 1024 pixels
|
||||
2. Less than 256 KB
|
||||
3. Is a supported image file type: png, jpg, gif, svg, webp
|
||||
|
||||
```bash
|
||||
kanidm system domain set-image <file-path> [image-type] -D admin
|
||||
|
||||
kanidm system domain remove-image -D admin
|
||||
```
|
||||
|
||||
## Changing a resource server
|
||||
|
||||
### Updating the display name
|
||||
|
||||
Each application can have its display name updated with the following
|
||||
|
||||
```bash
|
||||
kanidm system oauth2 set-displayname <NAME> <displayname> -D idm_admin
|
||||
```
|
||||
|
||||
### Updating the image
|
||||
|
||||
Each application can have its image updated or reset with the following commands. The image is
|
||||
subject to the same restrictions as the site image above.
|
||||
|
||||
```bash
|
||||
kanidm system oauth2 set-image <NAME> <file-path> [image-type] -D idm_admin
|
||||
|
||||
kanidm system oauth2 remove-image <NAME> -D idm_admin
|
||||
```
|
|
@ -7,10 +7,10 @@ support machine accounts also know as domain joining.
|
|||
|
||||
### Limiting Unix Password Auth
|
||||
|
||||
Currently unix password authentication is targetted as the method for sudo. Initial access to the
|
||||
Currently unix password authentication is targeted as the method for sudo. Initial access to the
|
||||
machine should come from ssh keys (and in future, ctap2).
|
||||
|
||||
In order to maintain compatability with LDAP style authentication, we allow "anonymous hosts" to
|
||||
In order to maintain compatibility with LDAP style authentication, we allow "anonymous hosts" to
|
||||
retrieve ssh public keys, and then perform sudo authentication.
|
||||
|
||||
This has the obvious caveat that anyone can stand up a machine that trusts a Kanidm instance. This
|
||||
|
|
|
@ -38,7 +38,7 @@ There are different ways we can scope a trust out, each with pros-cons. Here are
|
|||
may implement some controls around which subject DN's to allow/deny, but this is pretty fraught
|
||||
with landminds. You don't know who exists until they login!
|
||||
* Azure AD individual account trusting. Instead of trusting a whole domain you allow a user from
|
||||
a remote tennant to access your resources. You don't trust everyone in their tennant, just that
|
||||
a remote tenant to access your resources. You don't trust everyone in their tenant, just that
|
||||
one account that you can invite. You can then revoke them as needed.
|
||||
* Group-trust - FreeIPA does this with AD. It's still like kerberos, but you only trust a subset
|
||||
of the users determined by "groups" from the trusted site.
|
||||
|
|
|
@ -65,12 +65,12 @@ same. // TODO: Should a user be allowed to relabel their kanidm ssh keys ?
|
|||
|
||||
Due to their long length they should be line-wrapped into a text field so the entirety is visible
|
||||
when shown. To reduce visible clutter and inconsistent spacing we will put the values into
|
||||
collapsable elements.
|
||||
collapsible elements.
|
||||
|
||||
These collapsed elements must include:
|
||||
|
||||
- label
|
||||
- value's key type (ECDSA, rsa, ect..) and may include:
|
||||
- value's key type (ECDSA, rsa, etc..) and may include:
|
||||
- value's comment, truncated to some max length
|
||||
|
||||
#### Editing keys
|
||||
|
|
|
@ -54,7 +54,7 @@ to improve it as a result. This will necesitate a major rework of the project.
|
|||
|
||||
The current design treated the client as a trivial communication layer. The daemon/event loop
|
||||
contained all state including if the resolver was online or offline. Additionally the TPM and
|
||||
password caching operations primarily occured in the daemon layer, which limited the access of these
|
||||
password caching operations primarily occurred in the daemon layer, which limited the access of these
|
||||
features to the client backend itself.
|
||||
|
||||
### Future Features
|
||||
|
@ -130,7 +130,7 @@ future.
|
|||
#### CTAP2 / TPM-PIN
|
||||
|
||||
We want to allow local authentication with CTAP2 or a TPM with PIN. Both provide stronger assurances
|
||||
of both who the user is, and that they are in posession of a specific cryptographic device. The nice
|
||||
of both who the user is, and that they are in possession of a specific cryptographic device. The nice
|
||||
part of this is that they both implement hardware bruteforce protections. For soft-tpm we can
|
||||
emulate this with a strict bruteforce lockout prevention mechanism.
|
||||
|
||||
|
@ -384,7 +384,7 @@ and rely on sqlite heavily.
|
|||
We should migrate to a primarily in-memory cache, where sqlite is used only for persistence. The
|
||||
sqlite content should be optionally able to be encrypted by a TPM bound key.
|
||||
|
||||
To obsfucate details, the sqlite db should be a single table of key:value where keys are uuids
|
||||
To obfuscate details, the sqlite db should be a single table of key:value where keys are uuids
|
||||
associated to the item. The uuid is a local detail, not related to the provider.
|
||||
|
||||
The cache should move to a concread based concurrent tree which will also allow us to multi-thread
|
||||
|
|
|
@ -75,7 +75,7 @@ administrator. While they may not have direct access to the client/application s
|
|||
still use this `client_id+secret` to then carry out the authorisation code interception attack
|
||||
listed.
|
||||
|
||||
For confidential clients (refered to as a `basic` client in Kanidm due to the use of HTTP Basic for
|
||||
For confidential clients (referred to as a `basic` client in Kanidm due to the use of HTTP Basic for
|
||||
`client_id+secret` presentation) PKCE may optionally be disabled. This can allow authorisation code
|
||||
attacks to be carried out - however _if_ TLS is used and the `client_secret` never leaks, then these
|
||||
attacks will not be possible. Since there are many public references to system administrators
|
||||
|
|
|
@ -45,6 +45,7 @@ introspection.
|
|||
|
||||
Kanidm will expose its OAuth2 APIs at the following URLs, substituting
|
||||
`:client_id:` with an OAuth2 client ID.
|
||||
<!-- markdownlint-disable MD033 -->
|
||||
|
||||
<dl>
|
||||
|
||||
|
@ -59,7 +60,7 @@ URL **(recommended)**
|
|||
`https://idm.example.com/oauth2/openid/:client_id:/.well-known/openid-configuration`
|
||||
|
||||
This document includes all the URLs and attributes an app needs to be able to
|
||||
authenticate using OIDC with Kanidm, *except* for the `client_id` and
|
||||
authenticate using OIDC with Kanidm, _except_ for the `client_id` and
|
||||
`client_secret`.
|
||||
|
||||
Use this document wherever possible, as it will allow you to easily build and/or
|
||||
|
@ -183,6 +184,8 @@ Token signing public key
|
|||
|
||||
</dl>
|
||||
|
||||
<!-- markdownlint-enable MD037 -->
|
||||
|
||||
## Configuration
|
||||
|
||||
### Create the Kanidm Configuration
|
||||
|
@ -210,7 +213,7 @@ You can create a scope map with:
|
|||
|
||||
```bash
|
||||
kanidm system oauth2 update-scope-map <name> <kanidm_group_name> [scopes]...
|
||||
kanidm system oauth2 update-scope-map nextcloud nextcloud_admins admin
|
||||
kanidm system oauth2 update-scope-map nextcloud nextcloud_users email profile openid
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
|
@ -220,18 +223,23 @@ kanidm system oauth2 update-scope-map nextcloud nextcloud_admins admin
|
|||
> claims may be added to the authorisation token. It is not guaranteed that all of the associated
|
||||
> claims will be added.
|
||||
>
|
||||
> - **profile** - name, family_name, given_name, middle_name, nickname, preferred_username, profile,
|
||||
> * **profile** - name, family_name, given_name, middle_name, nickname, preferred_username, profile,
|
||||
> picture, website, gender, birthdate, zoneinfo, locale, and updated_at
|
||||
> - **email** - email, email_verified
|
||||
> - **address** - address
|
||||
> - **phone** - phone_number, phone_number_verified
|
||||
> * **email** - email, email_verified
|
||||
> * **address** - address
|
||||
> * **phone** - phone_number, phone_number_verified
|
||||
> * **groups** - groups
|
||||
|
||||
<!-- this is just to split the templates up -->
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> If you are creating an OpenID Connect (OIDC) client you **MUST** provide a scope map named
|
||||
> If you are creating an OpenID Connect (OIDC) client you **MUST** provide a scope map containing
|
||||
> `openid`. Without this, OpenID Connect clients **WILL NOT WORK**!
|
||||
>
|
||||
> ```bash
|
||||
> kanidm system oauth2 update-scope-map nextcloud nextcloud_users openid
|
||||
> ```
|
||||
|
||||
You can create a supplemental scope map with:
|
||||
|
||||
|
@ -311,10 +319,7 @@ applications that act as the OAuth2 client and its corresponding webserver is th
|
|||
In this case, the SPA is unable to act as a confidential client since the basic secret would need to
|
||||
be embedded in every client.
|
||||
|
||||
Another common example is native applications that use a redirect to localhost. These can't have a
|
||||
client secret embedded, so must act as public clients.
|
||||
|
||||
Public clients for this reason require PKCE to bind a specific browser session to its OAuth2
|
||||
For this reason, public clients require PKCE to bind a specific browser session to its OAuth2
|
||||
exchange. PKCE can not be disabled for public clients for this reason.
|
||||
|
||||
To create an OAuth2 public client:
|
||||
|
@ -324,7 +329,13 @@ kanidm system oauth2 create-public <name> <displayname> <origin>
|
|||
kanidm system oauth2 create-public mywebapp "My Web App" https://webapp.example.com
|
||||
```
|
||||
|
||||
To allow localhost redirection
|
||||
## Native Applications
|
||||
|
||||
Some applications will run a local web server on the user's device which directs users to the IDP for
|
||||
authentication, then back to the local server. [BCP212](https://www.rfc-editor.org/info/bcp212)
|
||||
"OAuth 2.0 for Native Apps" specifies the rules for this.
|
||||
|
||||
First allow localhost redirects:
|
||||
|
||||
```bash
|
||||
kanidm system oauth2 enable-localhost-redirects <name>
|
||||
|
@ -332,6 +343,10 @@ kanidm system oauth2 disable-localhost-redirects <name>
|
|||
kanidm system oauth2 enable-localhost-redirects mywebapp
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> Kanidm only allows these to be enabled on public clients where PKCE is enforced.
|
||||
|
||||
## Alternate Redirect URLs
|
||||
|
||||
> [!WARNING]
|
||||
|
@ -374,8 +389,10 @@ To indicate your readiness for this transition, all OAuth2 clients must have the
|
|||
`strict-redirect-url` enabled. Once enabled, the client will begin to enforce the 1.4.0 strict
|
||||
validation behaviour.
|
||||
|
||||
If you have not enabled `strict-redirect-url` on all OAuth2 clients the upgrade to 1.4.0 will refuse
|
||||
to proceed.
|
||||
> [!WARNING]
|
||||
>
|
||||
> If you have not enabled `strict-redirect-url` on all OAuth2 clients the upgrade to 1.4.0 will refuse
|
||||
> to proceed.
|
||||
|
||||
To enable or disable strict validation:
|
||||
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
# Example OAuth2 Configurations
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> Web applications that authenticate with Kanidm **must** be served over HTTPS.
|
||||
|
||||
## Apache `mod_auth_openidc`
|
||||
|
||||
Add the following to a `mod_auth_openidc.conf`. It should be included in a `mods_enabled` folder or
|
||||
|
@ -261,7 +265,7 @@ using OAuth2:
|
|||
<dd>
|
||||
|
||||
Upload a Kanidm or other organisational logo.
|
||||
|
||||
|
||||
This will appear on the login form (with no text) to prompt users to sign
|
||||
in.
|
||||
|
||||
|
@ -480,7 +484,7 @@ with some limitations:
|
|||
|
||||
It will set the user's preferred name on *first* log in *only*.
|
||||
|
||||
To set up a *new* self-hosted Outline instance to authenicate with Kanidm:
|
||||
To set up a *new* self-hosted Outline instance to authenticate with Kanidm:
|
||||
|
||||
1. Add an email address to your regular Kanidm account, if it doesn't have one
|
||||
already:
|
||||
|
@ -651,7 +655,22 @@ To set up an ownCloud instance to authenticate with Kanidm:
|
|||
kanidm system oauth2 show-basic-secret owncloud
|
||||
```
|
||||
|
||||
7. Create a JSON configuration file (`oidc-config.json`) for ownCloud's OIDC
|
||||
7. Set [ownCloud's session cookie `SameSite` value to `Lax`][owncloud-samesite]:
|
||||
|
||||
* For manual installations, add the option
|
||||
`'http.cookie.samesite' => 'Lax',` to `config.php`.
|
||||
* For Docker installations, set the `OWNCLOUD_HTTP_COOKIE_SAMESITE`
|
||||
environment variable to `Lax`, then stop and start the container.
|
||||
|
||||
When ownCloud and Kanidm are on different top-level domains
|
||||
([as we recommend](../../choosing_a_domain_name.md#subdomains-and-cross-origin-policy)),
|
||||
ownCloud's default `SameSite=Strict` session cookie policy causes browsers
|
||||
to drop the session cookie when Kanidm redirects back to ownCloud, which
|
||||
then causes their OIDC library to
|
||||
[send an invalid token request to Kanidm][owncloud-session-bug], which
|
||||
Kanidm (correctly) rejects.
|
||||
|
||||
8. Create a JSON configuration file (`oidc-config.json`) for ownCloud's OIDC
|
||||
App.
|
||||
|
||||
To key users by UID (most secure configuration, but not suitable if you have
|
||||
|
@ -687,7 +706,7 @@ To set up an ownCloud instance to authenticate with Kanidm:
|
|||
}
|
||||
```
|
||||
|
||||
8. Deploy the config file you created with [`occ`][occ].
|
||||
9. Deploy the config file you created with [`occ`][occ].
|
||||
|
||||
[The exact command varies][occ] depending on how you've deployed ownCloud.
|
||||
|
||||
|
@ -726,7 +745,9 @@ login form, which you can use to sign in.
|
|||
|
||||
[owncloud-branding]: https://doc.owncloud.com/server/next/admin_manual/enterprise/clients/creating_branded_apps.html
|
||||
[owncloud-oidcsd]: https://doc.owncloud.com/server/next/admin_manual/configuration/user/oidc/oidc.html#set-up-service-discovery
|
||||
[owncloud-samesite]: https://doc.owncloud.com/server/next/admin_manual/configuration/server/config_sample_php_parameters.html#define-how-to-relax-same-site-cookie-settings
|
||||
[owncloud-secrets]: https://doc.owncloud.com/server/next/admin_manual/configuration/user/oidc/oidc.html#client-ids-secrets-and-redirect-uris
|
||||
[owncloud-session-bug]: https://github.com/jumbojett/OpenID-Connect-PHP/issues/453
|
||||
[owncloud-oauth2-app]: https://marketplace.owncloud.com/apps/oauth2
|
||||
[owncloud-ios-mdm]: https://doc.owncloud.com/ios-app/12.2/appendices/mdm.html#oauth2-based-authentication
|
||||
[occ]: https://doc.owncloud.com/server/next/admin_manual/configuration/server/occ_command.html
|
||||
|
|
BIN
build_rs_cov.profraw
Normal file
BIN
build_rs_cov.profraw
Normal file
Binary file not shown.
|
@ -23,7 +23,7 @@ bindaddress = "[::]:443"
|
|||
# The path to the kanidm database.
|
||||
db_path = "/var/lib/private/kanidm/kanidm.db"
|
||||
#
|
||||
# If you have a known filesystem, kanidm can tune the
|
||||
# If you have a known filesystem, kanidm can tune the
|
||||
# database page size to match. Valid choices are:
|
||||
# [zfs, other]
|
||||
# If you are unsure about this leave it as the default
|
||||
|
@ -45,7 +45,7 @@ db_path = "/var/lib/private/kanidm/kanidm.db"
|
|||
# db_arc_size = 2048
|
||||
#
|
||||
# TLS chain and key in pem format. Both must be present.
|
||||
# If the server recieves a SIGHUP, these files will be
|
||||
# If the server receives a SIGHUP, these files will be
|
||||
# re-read and reloaded if their content is valid.
|
||||
tls_chain = "/var/lib/private/kanidm/chain.pem"
|
||||
tls_key = "/var/lib/private/kanidm/key.pem"
|
||||
|
@ -72,7 +72,7 @@ tls_key = "/var/lib/private/kanidm/key.pem"
|
|||
# credentials for accounts including but not limited to
|
||||
# webauthn, oauth tokens, and more.
|
||||
# If you change this value you *must* run
|
||||
# `kanidmd domain_name_change` immediately after.
|
||||
# `kanidmd domain rename` immediately after.
|
||||
domain = "idm.example.com"
|
||||
#
|
||||
# The origin for webauthn. This is the url to the server,
|
||||
|
|
|
@ -23,7 +23,7 @@ bindaddress = "[::]:8443"
|
|||
# The path to the kanidm database.
|
||||
db_path = "/data/kanidm.db"
|
||||
#
|
||||
# If you have a known filesystem, kanidm can tune the
|
||||
# If you have a known filesystem, kanidm can tune the
|
||||
# database page size to match. Valid choices are:
|
||||
# [zfs, other]
|
||||
# If you are unsure about this leave it as the default
|
||||
|
@ -44,7 +44,9 @@ db_path = "/data/kanidm.db"
|
|||
# memory pressure on your system.
|
||||
# db_arc_size = 2048
|
||||
#
|
||||
# TLS chain and key in pem format. Both must be present
|
||||
# TLS chain and key in pem format. Both must be present.
|
||||
# If the server receives a SIGHUP, these files will be
|
||||
# re-read and reloaded if their content is valid.
|
||||
tls_chain = "/data/chain.pem"
|
||||
tls_key = "/data/key.pem"
|
||||
#
|
||||
|
|
|
@ -1,17 +1,19 @@
|
|||
## Kanidm Unixd minimal Service Configuration - /etc/kanidm/unixd
|
||||
# Kanidm Unixd minimal Service Configuration - /etc/kanidm/unixd
|
||||
# For a full example and documentation, see /usr/share/kanidm-unixd/unixd
|
||||
# or `example/unixd` in the source repository.
|
||||
# or `example/unixd` in the source repository
|
||||
|
||||
version = '2'
|
||||
|
||||
[kanidm]
|
||||
# default_shell = "/bin/sh"
|
||||
|
||||
# home_attr = "uuid"
|
||||
# home_alias = "spn"
|
||||
# use_etc_skel = false
|
||||
|
||||
|
||||
# Defines a set of POSIX groups where membership of any of these groups
|
||||
# will be allowed to login via PAM.
|
||||
# Replace your group below and uncomment this line:
|
||||
#pam_allowed_login_groups = ["your_posix_login_group"]
|
||||
# will be allowed to login via PAM
|
||||
#
|
||||
# WITHOUT THIS SET, NOBODY WILL BE ABLE TO LOG IN VIA PAM
|
||||
#
|
||||
# Replace your group below and uncomment this line
|
||||
# pam_allowed_login_groups = ["your_posix_login_group"]
|
||||
|
|
|
@ -37,6 +37,14 @@ impl KanidmClient {
|
|||
.await
|
||||
}
|
||||
|
||||
pub async fn group_account_policy_authsession_expiry_reset(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<(), ClientError> {
|
||||
self.perform_delete_request(&format!("/v1/group/{}/_attr/authsession_expiry", id))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn group_account_policy_credential_type_minimum_set(
|
||||
&self,
|
||||
id: &str,
|
||||
|
@ -61,6 +69,17 @@ impl KanidmClient {
|
|||
.await
|
||||
}
|
||||
|
||||
pub async fn group_account_policy_password_minimum_length_reset(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<(), ClientError> {
|
||||
self.perform_delete_request(&format!(
|
||||
"/v1/group/{}/_attr/auth_password_minimum_length",
|
||||
id
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn group_account_policy_privilege_expiry_set(
|
||||
&self,
|
||||
id: &str,
|
||||
|
@ -73,6 +92,14 @@ impl KanidmClient {
|
|||
.await
|
||||
}
|
||||
|
||||
pub async fn group_account_policy_privilege_expiry_reset(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<(), ClientError> {
|
||||
self.perform_delete_request(&format!("/v1/group/{}/_attr/privilege_expiry", id))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn group_account_policy_webauthn_attestation_set(
|
||||
&self,
|
||||
id: &str,
|
||||
|
@ -85,6 +112,17 @@ impl KanidmClient {
|
|||
.await
|
||||
}
|
||||
|
||||
pub async fn group_account_policy_webauthn_attestation_reset(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<(), ClientError> {
|
||||
self.perform_delete_request(&format!(
|
||||
"/v1/group/{}/_attr/webauthn_attestation_ca_list",
|
||||
id
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn group_account_policy_limit_search_max_results(
|
||||
&self,
|
||||
id: &str,
|
||||
|
@ -97,6 +135,14 @@ impl KanidmClient {
|
|||
.await
|
||||
}
|
||||
|
||||
pub async fn group_account_policy_limit_search_max_results_reset(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<(), ClientError> {
|
||||
self.perform_delete_request(&format!("/v1/group/{}/_attr/limit_search_max_results", id))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn group_account_policy_limit_search_max_filter_test(
|
||||
&self,
|
||||
id: &str,
|
||||
|
@ -109,6 +155,17 @@ impl KanidmClient {
|
|||
.await
|
||||
}
|
||||
|
||||
pub async fn group_account_policy_limit_search_max_filter_test_reset(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<(), ClientError> {
|
||||
self.perform_delete_request(&format!(
|
||||
"/v1/group/{}/_attr/limit_search_max_filter_test",
|
||||
id
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn group_account_policy_allow_primary_cred_fallback(
|
||||
&self,
|
||||
id: &str,
|
||||
|
|
|
@ -434,6 +434,8 @@ impl KanidmClient {
|
|||
id: &str,
|
||||
origin: &Url,
|
||||
) -> Result<(), ClientError> {
|
||||
// TODO: should we normalise loopback origins, so when a user specifies `http://localhost/foo` we store it as `http://[::1]/foo`?
|
||||
|
||||
let url_to_add = &[origin.as_str()];
|
||||
self.perform_post_request(
|
||||
format!("/v1/oauth2/{}/_attr/{}", id, ATTR_OAUTH2_RS_ORIGIN).as_str(),
|
||||
|
|
|
@ -142,6 +142,13 @@ pub enum OperationError {
|
|||
DatabaseLockAcquisitionTimeout,
|
||||
|
||||
// Specific internal errors.
|
||||
AU0001InvalidState,
|
||||
AU0002JwsSerialisation,
|
||||
AU0003JwsSignature,
|
||||
AU0004UserAuthTokenInvalid,
|
||||
AU0005DelayedProcessFailure,
|
||||
AU0006CredentialMayNotReauthenticate,
|
||||
AU0007UserAuthTokenInvalid,
|
||||
|
||||
// Kanidm Generic Errors
|
||||
KG001TaskTimeout,
|
||||
|
@ -153,6 +160,15 @@ pub enum OperationError {
|
|||
CU0001WebauthnAttestationNotTrusted,
|
||||
CU0002WebauthnRegistrationError,
|
||||
CU0003WebauthnUserNotVerified,
|
||||
|
||||
// The session is inconsistent and can't be committed, but the errors
|
||||
// can be resolved.
|
||||
CU0004SessionInconsistent,
|
||||
// Another session used this intent token, and so it can't be committed.
|
||||
CU0005IntentTokenConflict,
|
||||
// The intent token was invalidated before we could commit.
|
||||
CU0006IntentTokenInvalidated,
|
||||
|
||||
// ValueSet errors
|
||||
VS0001IncomingReplSshPublicKey,
|
||||
VS0002CertificatePublicKeyDigest,
|
||||
|
@ -235,6 +251,7 @@ pub enum OperationError {
|
|||
// Web UI
|
||||
UI0001ChallengeSerialisation,
|
||||
UI0002InvalidState,
|
||||
UI0003InvalidOauth2Resume,
|
||||
|
||||
// Unixd Things
|
||||
KU001InitWhileSessionActive,
|
||||
|
@ -271,7 +288,7 @@ impl Display for OperationError {
|
|||
|
||||
impl OperationError {
|
||||
/// Return the message associated with the error if there is one.
|
||||
fn message(&self) -> Option<String> {
|
||||
pub fn message(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::SessionExpired => None,
|
||||
Self::EmptyRequest => None,
|
||||
|
@ -337,9 +354,23 @@ impl OperationError {
|
|||
Self::TransactionAlreadyCommitted => None,
|
||||
Self::ValueDenyName => None,
|
||||
Self::DatabaseLockAcquisitionTimeout => Some("Unable to acquire a database lock - the current server may be too busy. Try again later.".into()),
|
||||
|
||||
Self::AU0001InvalidState => Some("Invalid authentication session state for request".into()),
|
||||
Self::AU0002JwsSerialisation => Some("JWS serialisation failed".into()),
|
||||
Self::AU0003JwsSignature => Some("JWS signature failed".into()),
|
||||
Self::AU0004UserAuthTokenInvalid => Some("User auth token was unable to be generated".into()),
|
||||
Self::AU0005DelayedProcessFailure => Some("Delaying processing failure, unable to proceed".into()),
|
||||
Self::AU0006CredentialMayNotReauthenticate => Some("Credential may not reauthenticate".into()),
|
||||
Self::AU0007UserAuthTokenInvalid => Some("User auth token was unable to be generated".into()),
|
||||
|
||||
Self::CU0001WebauthnAttestationNotTrusted => None,
|
||||
Self::CU0002WebauthnRegistrationError => None,
|
||||
Self::CU0003WebauthnUserNotVerified => Some("User Verification bit not set while registering credential, you may need to configure a PIN on this device.".into()),
|
||||
|
||||
Self::CU0004SessionInconsistent => Some("The session is unable to be committed due to unresolved warnings.".into()),
|
||||
Self::CU0005IntentTokenConflict => Some("The intent token used to create this session has been reused in another browser/tab and may not proceed.".into()),
|
||||
Self::CU0006IntentTokenInvalidated => Some("The intent token has been invalidated/revoked before the commit could be accepted. Has it been used in another browser or tab?".into()),
|
||||
|
||||
Self::DB0001MismatchedRestoreVersion => None,
|
||||
Self::DB0002MismatchedRestoreVersion => None,
|
||||
Self::DB0003FilterResolveCacheBuild => None,
|
||||
|
@ -408,9 +439,12 @@ impl OperationError {
|
|||
Self::MG0007Oauth2StrictConstraintsNotMet => Some("Migration Constraints Not Met - All OAuth2 clients must have strict-redirect-uri mode enabled.".into()),
|
||||
Self::MG0008SkipUpgradeAttempted => Some("Skip Upgrade Attempted.".into()),
|
||||
Self::PL0001GidOverlapsSystemRange => None,
|
||||
|
||||
Self::SC0001IncomingSshPublicKey => None,
|
||||
|
||||
Self::UI0001ChallengeSerialisation => Some("The WebAuthn challenge was unable to be serialised.".into()),
|
||||
Self::UI0002InvalidState => Some("The credential update process returned an invalid state transition.".into()),
|
||||
Self::UI0003InvalidOauth2Resume => Some("The server attemped to resume OAuth2, but no OAuth2 session is in progress.".into()),
|
||||
Self::VL0001ValueSshPublicKeyString => None,
|
||||
Self::VS0001IncomingReplSshPublicKey => None,
|
||||
Self::VS0002CertificatePublicKeyDigest |
|
||||
|
|
|
@ -49,6 +49,8 @@ pub struct AuthorisationRequest {
|
|||
// OIDC also allows other optional params
|
||||
#[serde(flatten)]
|
||||
pub oidc_ext: AuthorisationRequestOidc,
|
||||
// Needs to be hoisted here due to serde flatten bug #3185
|
||||
pub max_age: Option<i64>,
|
||||
#[serde(flatten)]
|
||||
pub unknown_keys: BTreeMap<String, serde_json::value::Value>,
|
||||
}
|
||||
|
@ -60,7 +62,6 @@ pub struct AuthorisationRequest {
|
|||
pub struct AuthorisationRequestOidc {
|
||||
pub display: Option<String>,
|
||||
pub prompt: Option<String>,
|
||||
pub max_age: Option<i64>,
|
||||
pub ui_locales: Option<()>,
|
||||
pub claims_locales: Option<()>,
|
||||
pub id_token_hint: Option<String>,
|
||||
|
|
|
@ -11,10 +11,10 @@
|
|||
//! The [scim_proto] library, which is generic over all scim implementations.
|
||||
//!
|
||||
//! The client module, which describes how a client should transmit entries, and
|
||||
//! how it should parse them when it recieves them.
|
||||
//! how it should parse them when it receives them.
|
||||
//!
|
||||
//! The server module, which describes how a server should transmit entries and
|
||||
//! how it should recieve them.
|
||||
//! how it should receive them.
|
||||
|
||||
use crate::attribute::Attribute;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
@ -79,12 +79,15 @@ mod tests {
|
|||
// Group
|
||||
let group_uuid = uuid::uuid!("2d0a9e7c-cc08-4ca2-8d7f-114f9abcfc8a");
|
||||
|
||||
let group = ScimSyncGroup::builder("testgroup".to_string(), group_uuid)
|
||||
.set_description(Some("test desc".to_string()))
|
||||
.set_gidnumber(Some(12345))
|
||||
.set_members(vec!["member_a".to_string(), "member_a".to_string()].into_iter())
|
||||
.set_external_id(Some("cn=testgroup".to_string()))
|
||||
.build();
|
||||
let group = ScimSyncGroup::builder(
|
||||
group_uuid,
|
||||
"cn=testgroup".to_string(),
|
||||
"testgroup".to_string(),
|
||||
)
|
||||
.set_description(Some("test desc".to_string()))
|
||||
.set_gidnumber(Some(12345))
|
||||
.set_members(vec!["member_a".to_string(), "member_a".to_string()].into_iter())
|
||||
.build();
|
||||
|
||||
let entry: Result<ScimEntry, _> = group.try_into();
|
||||
|
||||
|
@ -95,32 +98,35 @@ mod tests {
|
|||
|
||||
let user_sshkey = "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBENubZikrb8hu+HeVRdZ0pp/VAk2qv4JDbuJhvD0yNdWDL2e3cBbERiDeNPkWx58Q4rVnxkbV1fa8E2waRtT91wAAAAEc3NoOg== testuser@fidokey";
|
||||
|
||||
let person =
|
||||
ScimSyncPerson::builder(user_uuid, "testuser".to_string(), "Test User".to_string())
|
||||
.set_password_import(Some("new_password".to_string()))
|
||||
.set_unix_password_import(Some("new_password".to_string()))
|
||||
.set_totp_import(vec![ScimTotp {
|
||||
external_id: "Totp".to_string(),
|
||||
secret: "abcd".to_string(),
|
||||
algo: "SHA3".to_string(),
|
||||
step: 60,
|
||||
digits: 8,
|
||||
}])
|
||||
.set_mail(vec![MultiValueAttr {
|
||||
primary: Some(true),
|
||||
value: "testuser@example.com".to_string(),
|
||||
..Default::default()
|
||||
}])
|
||||
.set_ssh_publickey(vec![ScimSshPubKey {
|
||||
label: "Key McKeyface".to_string(),
|
||||
value: user_sshkey.to_string(),
|
||||
}])
|
||||
.set_login_shell(Some("/bin/false".to_string()))
|
||||
.set_account_valid_from(Some("2023-11-28T04:57:55Z".to_string()))
|
||||
.set_account_expire(Some("2023-11-28T04:57:55Z".to_string()))
|
||||
.set_gidnumber(Some(54321))
|
||||
.set_external_id(Some("cn=testuser".to_string()))
|
||||
.build();
|
||||
let person = ScimSyncPerson::builder(
|
||||
user_uuid,
|
||||
"cn=testuser".to_string(),
|
||||
"testuser".to_string(),
|
||||
"Test User".to_string(),
|
||||
)
|
||||
.set_password_import(Some("new_password".to_string()))
|
||||
.set_unix_password_import(Some("new_password".to_string()))
|
||||
.set_totp_import(vec![ScimTotp {
|
||||
external_id: "Totp".to_string(),
|
||||
secret: "abcd".to_string(),
|
||||
algo: "SHA3".to_string(),
|
||||
step: 60,
|
||||
digits: 8,
|
||||
}])
|
||||
.set_mail(vec![MultiValueAttr {
|
||||
primary: Some(true),
|
||||
value: "testuser@example.com".to_string(),
|
||||
..Default::default()
|
||||
}])
|
||||
.set_ssh_publickey(vec![ScimSshPubKey {
|
||||
label: "Key McKeyface".to_string(),
|
||||
value: user_sshkey.to_string(),
|
||||
}])
|
||||
.set_login_shell(Some("/bin/false".to_string()))
|
||||
.set_account_valid_from(Some("2023-11-28T04:57:55Z".to_string()))
|
||||
.set_account_expire(Some("2023-11-28T04:57:55Z".to_string()))
|
||||
.set_gidnumber(Some(54321))
|
||||
.build();
|
||||
|
||||
let entry: Result<ScimEntry, _> = person.try_into();
|
||||
|
||||
|
|
|
@ -85,19 +85,19 @@ pub struct ScimSshPubKey {
|
|||
|
||||
#[skip_serializing_none]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct ScimSyncPerson {
|
||||
#[serde(flatten)]
|
||||
pub entry: ScimEntryHeader,
|
||||
|
||||
pub user_name: String,
|
||||
pub display_name: String,
|
||||
pub name: String,
|
||||
pub displayname: String,
|
||||
pub gidnumber: Option<u32>,
|
||||
pub password_import: Option<String>,
|
||||
pub unix_password_import: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub totp_import: Vec<ScimTotp>,
|
||||
pub login_shell: Option<String>,
|
||||
pub loginshell: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub mail: Vec<MultiValueAttr>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
|
@ -119,7 +119,12 @@ pub struct ScimSyncPersonBuilder {
|
|||
}
|
||||
|
||||
impl ScimSyncPerson {
|
||||
pub fn builder(id: Uuid, user_name: String, display_name: String) -> ScimSyncPersonBuilder {
|
||||
pub fn builder(
|
||||
id: Uuid,
|
||||
external_id: String,
|
||||
name: String,
|
||||
displayname: String,
|
||||
) -> ScimSyncPersonBuilder {
|
||||
ScimSyncPersonBuilder {
|
||||
inner: ScimSyncPerson {
|
||||
entry: ScimEntryHeader {
|
||||
|
@ -128,16 +133,16 @@ impl ScimSyncPerson {
|
|||
SCIM_SCHEMA_SYNC_PERSON.to_string(),
|
||||
],
|
||||
id,
|
||||
external_id: None,
|
||||
external_id: Some(external_id),
|
||||
meta: None,
|
||||
},
|
||||
user_name,
|
||||
display_name,
|
||||
name,
|
||||
displayname,
|
||||
gidnumber: None,
|
||||
password_import: None,
|
||||
unix_password_import: None,
|
||||
totp_import: Vec::with_capacity(0),
|
||||
login_shell: None,
|
||||
loginshell: None,
|
||||
mail: Vec::with_capacity(0),
|
||||
ssh_publickey: Vec::with_capacity(0),
|
||||
account_valid_from: None,
|
||||
|
@ -173,8 +178,8 @@ impl ScimSyncPersonBuilder {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn set_login_shell(mut self, login_shell: Option<String>) -> Self {
|
||||
self.inner.login_shell = login_shell;
|
||||
pub fn set_login_shell(mut self, loginshell: Option<String>) -> Self {
|
||||
self.inner.loginshell = loginshell;
|
||||
self
|
||||
}
|
||||
|
||||
|
@ -205,11 +210,6 @@ impl ScimSyncPersonBuilder {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn set_external_id(mut self, external_id: Option<String>) -> Self {
|
||||
self.inner.entry.external_id = external_id;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> ScimSyncPerson {
|
||||
self.inner
|
||||
}
|
||||
|
@ -220,8 +220,9 @@ pub struct ScimExternalMember {
|
|||
pub external_id: String,
|
||||
}
|
||||
|
||||
#[skip_serializing_none]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct ScimSyncGroup {
|
||||
#[serde(flatten)]
|
||||
pub entry: ScimEntryHeader,
|
||||
|
@ -229,7 +230,8 @@ pub struct ScimSyncGroup {
|
|||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub gidnumber: Option<u32>,
|
||||
pub members: Vec<ScimExternalMember>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub member: Vec<ScimExternalMember>,
|
||||
}
|
||||
|
||||
impl TryInto<ScimEntry> for ScimSyncGroup {
|
||||
|
@ -247,19 +249,19 @@ pub struct ScimSyncGroupBuilder {
|
|||
}
|
||||
|
||||
impl ScimSyncGroup {
|
||||
pub fn builder(name: String, id: Uuid) -> ScimSyncGroupBuilder {
|
||||
pub fn builder(id: Uuid, external_id: String, name: String) -> ScimSyncGroupBuilder {
|
||||
ScimSyncGroupBuilder {
|
||||
inner: ScimSyncGroup {
|
||||
entry: ScimEntryHeader {
|
||||
schemas: vec![SCIM_SCHEMA_SYNC_GROUP.to_string()],
|
||||
id,
|
||||
external_id: None,
|
||||
external_id: Some(external_id),
|
||||
meta: None,
|
||||
},
|
||||
name,
|
||||
description: None,
|
||||
gidnumber: None,
|
||||
members: Vec::with_capacity(0),
|
||||
member: Vec::with_capacity(0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -288,17 +290,12 @@ impl ScimSyncGroupBuilder {
|
|||
where
|
||||
I: Iterator<Item = String>,
|
||||
{
|
||||
self.inner.members = member_iter
|
||||
self.inner.member = member_iter
|
||||
.map(|external_id| ScimExternalMember { external_id })
|
||||
.collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_external_id(mut self, external_id: Option<String>) -> Self {
|
||||
self.inner.entry.external_id = external_id;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> ScimSyncGroup {
|
||||
self.inner
|
||||
}
|
||||
|
|
|
@ -85,10 +85,10 @@ impl fmt::Debug for AuthCredential {
|
|||
pub enum AuthMech {
|
||||
Anonymous,
|
||||
Password,
|
||||
PasswordBackupCode,
|
||||
// Now represents TOTP.
|
||||
#[serde(rename = "passwordmfa")]
|
||||
PasswordTotp,
|
||||
PasswordBackupCode,
|
||||
PasswordSecurityKey,
|
||||
Passkey,
|
||||
}
|
||||
|
|
|
@ -1422,13 +1422,13 @@ impl QueryServerReadV1 {
|
|||
.map_err(Oauth2Error::ServerError)?;
|
||||
let ident = idms_prox_read
|
||||
.validate_client_auth_info_to_ident(client_auth_info, ct)
|
||||
.map_err(|e| {
|
||||
.inspect_err(|e| {
|
||||
error!("Invalid identity: {:?}", e);
|
||||
Oauth2Error::AuthenticationRequired
|
||||
})?;
|
||||
})
|
||||
.ok();
|
||||
|
||||
// Now we can send to the idm server for authorisation checking.
|
||||
idms_prox_read.check_oauth2_authorisation(&ident, &auth_req, ct)
|
||||
idms_prox_read.check_oauth2_authorisation(ident.as_ref(), &auth_req, ct)
|
||||
}
|
||||
|
||||
#[instrument(
|
||||
|
|
|
@ -31,7 +31,7 @@ use axum::{
|
|||
};
|
||||
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use compact_jwt::{JwsCompact, JwsHs256Signer, JwsVerifier};
|
||||
use compact_jwt::{error::JwtError, JwsCompact, JwsHs256Signer, JwsVerifier};
|
||||
use futures::pin_mut;
|
||||
use hyper::body::Incoming;
|
||||
use hyper_util::rt::{TokioExecutor, TokioIo};
|
||||
|
@ -53,6 +53,7 @@ use tokio::{
|
|||
use tokio_openssl::SslStream;
|
||||
use tower::Service;
|
||||
use tower_http::{services::ServeDir, trace::TraceLayer};
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
use std::io::ErrorKind;
|
||||
|
@ -62,16 +63,17 @@ use std::{net::SocketAddr, str::FromStr};
|
|||
|
||||
#[derive(Clone)]
|
||||
pub struct ServerState {
|
||||
pub status_ref: &'static StatusActor,
|
||||
pub qe_w_ref: &'static QueryServerWriteV1,
|
||||
pub qe_r_ref: &'static QueryServerReadV1,
|
||||
pub(crate) status_ref: &'static StatusActor,
|
||||
pub(crate) qe_w_ref: &'static QueryServerWriteV1,
|
||||
pub(crate) qe_r_ref: &'static QueryServerReadV1,
|
||||
// Store the token management parts.
|
||||
pub jws_signer: JwsHs256Signer,
|
||||
pub(crate) jws_signer: JwsHs256Signer,
|
||||
pub(crate) trust_x_forward_for: bool,
|
||||
pub csp_header: HeaderValue,
|
||||
pub domain: String,
|
||||
pub(crate) csp_header: HeaderValue,
|
||||
pub(crate) origin: Url,
|
||||
pub(crate) domain: String,
|
||||
// This is set to true by default, and is only false on integration tests.
|
||||
pub secure_cookies: bool,
|
||||
pub(crate) secure_cookies: bool,
|
||||
}
|
||||
|
||||
impl ServerState {
|
||||
|
@ -83,7 +85,19 @@ impl ServerState {
|
|||
Ok(val) => match self.jws_signer.verify(&val) {
|
||||
Ok(val) => val.from_json::<T>().ok(),
|
||||
Err(err) => {
|
||||
error!("Failed to unmarshal JWT from headers: {:?}", err);
|
||||
error!(?err, "Failed to deserialise JWT from request");
|
||||
if matches!(err, JwtError::InvalidSignature) {
|
||||
// The server has an ephemeral in memory HMAC signer. This is important as
|
||||
// auth (login) sessions on one node shouldn't validate on another. Sessions
|
||||
// that are shared beween nodes use the internal ECDSA signer.
|
||||
//
|
||||
// But because of this if the server restarts it rolls the key. Additionally
|
||||
// it can occur if the load balancer isn't sticking sessions to the correct
|
||||
// node. That can cause this error. So we want to specifically call it out
|
||||
// to admins so they can investigate that the fault is occurring *outside*
|
||||
// of kanidm.
|
||||
warn!("Invalid Signature errors can occur if your instance restarted recently, if a load balancer is not configured for sticky sessions, or a session was tampered with.");
|
||||
}
|
||||
None
|
||||
}
|
||||
},
|
||||
|
@ -186,7 +200,7 @@ pub async fn create_https_server(
|
|||
"frame-ancestors 'none'; ",
|
||||
"img-src 'self' data:; ",
|
||||
"worker-src 'none'; ",
|
||||
"script-src 'self'{};",
|
||||
"script-src 'self' 'unsafe-eval'{};",
|
||||
),
|
||||
js_checksums
|
||||
);
|
||||
|
@ -197,6 +211,12 @@ pub async fn create_https_server(
|
|||
|
||||
let trust_x_forward_for = config.trust_x_forward_for;
|
||||
|
||||
let origin = Url::parse(&config.origin)
|
||||
// Should be impossible!
|
||||
.map_err(|err| {
|
||||
error!(?err, "Unable to parse origin URL - refusing to start. You must correct the value for origin. {:?}", config.origin);
|
||||
})?;
|
||||
|
||||
let state = ServerState {
|
||||
status_ref,
|
||||
qe_w_ref,
|
||||
|
@ -204,6 +224,7 @@ pub async fn create_https_server(
|
|||
jws_signer,
|
||||
trust_x_forward_for,
|
||||
csp_header,
|
||||
origin,
|
||||
domain: config.domain.clone(),
|
||||
secure_cookies: config.integration_test_config.is_none(),
|
||||
};
|
||||
|
|
|
@ -20,7 +20,6 @@ use axum::{
|
|||
Extension, Form, Json, Router,
|
||||
};
|
||||
use axum_macros::debug_handler;
|
||||
use compact_jwt::{JwkKeySet, OidcToken};
|
||||
use kanidm_proto::constants::uri::{
|
||||
OAUTH2_AUTHORISE, OAUTH2_AUTHORISE_PERMIT, OAUTH2_AUTHORISE_REJECT,
|
||||
};
|
||||
|
@ -289,7 +288,8 @@ async fn oauth2_authorise(
|
|||
.body(body)
|
||||
.unwrap()
|
||||
}
|
||||
Err(Oauth2Error::AuthenticationRequired) => {
|
||||
Ok(AuthoriseResponse::AuthenticationRequired { .. })
|
||||
| Err(Oauth2Error::AuthenticationRequired) => {
|
||||
// This will trigger our ui to auth and retry.
|
||||
#[allow(clippy::unwrap_used)]
|
||||
Response::builder()
|
||||
|
@ -586,13 +586,13 @@ pub async fn oauth2_openid_userinfo_get(
|
|||
Path(client_id): Path<String>,
|
||||
Extension(kopid): Extension<KOpId>,
|
||||
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
|
||||
) -> Result<Json<OidcToken>, HTTPOauth2Error> {
|
||||
) -> Response {
|
||||
// The token we want to inspect is in the authorisation header.
|
||||
let client_token = match client_auth_info.bearer_token {
|
||||
Some(val) => val,
|
||||
None => {
|
||||
error!("Bearer Authentication Not Provided");
|
||||
return Err(HTTPOauth2Error(Oauth2Error::AuthenticationRequired));
|
||||
return HTTPOauth2Error(Oauth2Error::AuthenticationRequired).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -602,8 +602,13 @@ pub async fn oauth2_openid_userinfo_get(
|
|||
.await;
|
||||
|
||||
match res {
|
||||
Ok(uir) => Ok(Json(uir)),
|
||||
Err(e) => Err(HTTPOauth2Error(e)),
|
||||
Ok(uir) => (
|
||||
StatusCode::OK,
|
||||
[(ACCESS_CONTROL_ALLOW_ORIGIN, "*")],
|
||||
Json(uir),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => HTTPOauth2Error(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -611,13 +616,18 @@ pub async fn oauth2_openid_publickey_get(
|
|||
State(state): State<ServerState>,
|
||||
Path(client_id): Path<String>,
|
||||
Extension(kopid): Extension<KOpId>,
|
||||
) -> Result<Json<JwkKeySet>, WebError> {
|
||||
state
|
||||
) -> Response {
|
||||
let res = state
|
||||
.qe_r_ref
|
||||
.handle_oauth2_openid_publickey(client_id, kopid.eventid)
|
||||
.await
|
||||
.map(Json::from)
|
||||
.map_err(WebError::from)
|
||||
.map_err(WebError::from);
|
||||
|
||||
match res {
|
||||
Ok(jsn) => (StatusCode::OK, [(ACCESS_CONTROL_ALLOW_ORIGIN, "*")], jsn).into_response(),
|
||||
Err(web_err) => web_err.response_with_access_control_origin_header(),
|
||||
}
|
||||
}
|
||||
|
||||
/// This is called directly by the resource server, where we then issue
|
||||
|
@ -788,7 +798,7 @@ pub fn route_setup(state: ServerState) -> Router<ServerState> {
|
|||
// // IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
|
||||
.route(
|
||||
"/oauth2/openid/:client_id/public_key.jwk",
|
||||
get(oauth2_openid_publickey_get),
|
||||
get(oauth2_openid_publickey_get).options(oauth2_preflight_options),
|
||||
)
|
||||
// // ⚠️ ⚠️ WARNING ⚠️ ⚠️
|
||||
// // IF YOU CHANGE THESE VALUES YOU MUST UPDATE OAUTH2 DISCOVERY URLS
|
||||
|
|
|
@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
|
|||
pub(crate) enum ProfileMenuItems {
|
||||
UserProfile,
|
||||
Credentials,
|
||||
EnrolDevice,
|
||||
UnixPassword,
|
||||
}
|
||||
|
||||
|
@ -24,10 +25,13 @@ impl std::fmt::Display for UiMessage {
|
|||
pub(crate) enum Urls {
|
||||
Apps,
|
||||
CredReset,
|
||||
CredResetError,
|
||||
EnrolDevice,
|
||||
Profile,
|
||||
UpdateCredentials,
|
||||
Oauth2Resume,
|
||||
Login,
|
||||
Ui,
|
||||
}
|
||||
|
||||
impl AsRef<str> for Urls {
|
||||
|
@ -35,10 +39,13 @@ impl AsRef<str> for Urls {
|
|||
match self {
|
||||
Self::Apps => "/ui/apps",
|
||||
Self::CredReset => "/ui/reset",
|
||||
Self::CredResetError => "/ui/reset/err",
|
||||
Self::EnrolDevice => "/ui/enrol",
|
||||
Self::Profile => "/ui/profile",
|
||||
Self::UpdateCredentials => "/ui/update_credentials",
|
||||
Self::Oauth2Resume => "/ui/oauth2/resume",
|
||||
Self::Login => "/ui/login",
|
||||
Self::Ui => "/ui",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,27 +6,7 @@ use compact_jwt::{Jws, JwsSigner};
|
|||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
|
||||
pub fn destroy(jar: CookieJar, ck_id: &str) -> CookieJar {
|
||||
if let Some(ck) = jar.get(ck_id) {
|
||||
let mut ck = ck.clone();
|
||||
ck.make_removal();
|
||||
/*
|
||||
if let Some(path) = ck.path().cloned() {
|
||||
ck.set_path(&path);
|
||||
}
|
||||
*/
|
||||
jar.add(ck)
|
||||
} else {
|
||||
jar
|
||||
}
|
||||
}
|
||||
|
||||
pub fn make_unsigned<'a>(
|
||||
state: &'_ ServerState,
|
||||
ck_id: &'a str,
|
||||
value: String,
|
||||
path: &'a str,
|
||||
) -> Cookie<'a> {
|
||||
fn new_cookie<'a>(state: &'_ ServerState, ck_id: &'a str, value: String) -> Cookie<'a> {
|
||||
let mut token_cookie = Cookie::new(ck_id, value);
|
||||
token_cookie.set_secure(state.secure_cookies);
|
||||
token_cookie.set_same_site(SameSite::Lax);
|
||||
|
@ -36,15 +16,37 @@ pub fn make_unsigned<'a>(
|
|||
// of the idm to share the cookie. If domain was incorrect
|
||||
// then webauthn won't work anyway!
|
||||
token_cookie.set_domain(state.domain.clone());
|
||||
token_cookie.set_path(path);
|
||||
token_cookie.set_path("/");
|
||||
token_cookie
|
||||
}
|
||||
|
||||
#[instrument(name = "views::cookies::destroy", level = "debug", skip(jar, state))]
|
||||
pub fn destroy(jar: CookieJar, ck_id: &str, state: &ServerState) -> CookieJar {
|
||||
if let Some(ck) = jar.get(ck_id) {
|
||||
let mut removal_cookie = ck.clone();
|
||||
removal_cookie.make_removal();
|
||||
|
||||
// Need to be set to domain else the cookie isn't removed!
|
||||
removal_cookie.set_domain(state.domain.clone());
|
||||
|
||||
// Need to be set to / to remove on all parent paths.
|
||||
// If you don't set a path, NOTHING IS REMOVED!!!
|
||||
removal_cookie.set_path("/");
|
||||
|
||||
jar.add(removal_cookie)
|
||||
} else {
|
||||
jar
|
||||
}
|
||||
}
|
||||
|
||||
pub fn make_unsigned<'a>(state: &'_ ServerState, ck_id: &'a str, value: String) -> Cookie<'a> {
|
||||
new_cookie(state, ck_id, value)
|
||||
}
|
||||
|
||||
pub fn make_signed<'a, T: Serialize>(
|
||||
state: &'_ ServerState,
|
||||
ck_id: &'a str,
|
||||
value: &'_ T,
|
||||
path: &'a str,
|
||||
) -> Option<Cookie<'a>> {
|
||||
let kref = &state.jws_signer;
|
||||
|
||||
|
@ -63,13 +65,7 @@ pub fn make_signed<'a, T: Serialize>(
|
|||
})
|
||||
.ok()?;
|
||||
|
||||
let mut token_cookie = Cookie::new(ck_id, token);
|
||||
token_cookie.set_secure(state.secure_cookies);
|
||||
token_cookie.set_same_site(SameSite::Lax);
|
||||
token_cookie.set_http_only(true);
|
||||
token_cookie.set_path(path);
|
||||
token_cookie.set_domain(state.domain.clone());
|
||||
Some(token_cookie)
|
||||
Some(new_cookie(state, ck_id, token))
|
||||
}
|
||||
|
||||
pub fn get_signed<T: DeserializeOwned>(
|
||||
|
|
116
server/core/src/https/views/enrol.rs
Normal file
116
server/core/src/https/views/enrol.rs
Normal file
|
@ -0,0 +1,116 @@
|
|||
use askama::Template;
|
||||
use askama_axum::IntoResponse;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::response::Response;
|
||||
use axum::Extension;
|
||||
|
||||
use axum_extra::extract::CookieJar;
|
||||
use kanidm_proto::internal::UserAuthToken;
|
||||
|
||||
use qrcode::render::svg;
|
||||
use qrcode::QrCode;
|
||||
use url::Url;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use super::constants::Urls;
|
||||
use super::navbar::NavbarCtx;
|
||||
use crate::https::extractors::{DomainInfo, VerifiedClientInformation};
|
||||
use crate::https::middleware::KOpId;
|
||||
use crate::https::views::constants::ProfileMenuItems;
|
||||
use crate::https::views::errors::HtmxError;
|
||||
use crate::https::views::login::{LoginDisplayCtx, Reauth, ReauthPurpose};
|
||||
use crate::https::ServerState;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user_settings.html")]
|
||||
struct ProfileView {
|
||||
navbar_ctx: NavbarCtx,
|
||||
profile_partial: EnrolDeviceView,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "enrol_device.html")]
|
||||
pub(crate) struct EnrolDeviceView {
|
||||
menu_active_item: ProfileMenuItems,
|
||||
secret: String,
|
||||
qr_code_svg: String,
|
||||
uri: Url,
|
||||
}
|
||||
|
||||
pub(crate) async fn view_enrol_get(
|
||||
State(state): State<ServerState>,
|
||||
Extension(kopid): Extension<KOpId>,
|
||||
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
|
||||
DomainInfo(domain_info): DomainInfo,
|
||||
jar: CookieJar,
|
||||
) -> axum::response::Result<Response> {
|
||||
let uat: UserAuthToken = state
|
||||
.qe_r_ref
|
||||
.handle_whoami_uat(client_auth_info.clone(), kopid.eventid)
|
||||
.await
|
||||
.map_err(|op_err| HtmxError::new(&kopid, op_err))?;
|
||||
|
||||
let time = time::OffsetDateTime::now_utc() + time::Duration::new(60, 0);
|
||||
let can_rw = uat.purpose_readwrite_active(time);
|
||||
|
||||
// The user lacks an elevated session, request a re-auth.
|
||||
if !can_rw {
|
||||
let display_ctx = LoginDisplayCtx {
|
||||
domain_info,
|
||||
oauth2: None,
|
||||
reauth: Some(Reauth {
|
||||
username: uat.spn,
|
||||
purpose: ReauthPurpose::ProfileSettings,
|
||||
}),
|
||||
error: None,
|
||||
};
|
||||
|
||||
return Ok(super::login::view_reauth_get(
|
||||
state,
|
||||
client_auth_info,
|
||||
kopid,
|
||||
jar,
|
||||
Urls::EnrolDevice.as_ref(),
|
||||
display_ctx,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
|
||||
let cu_intent = state
|
||||
.qe_w_ref
|
||||
.handle_idmcredentialupdateintent(
|
||||
client_auth_info,
|
||||
uat.spn,
|
||||
Some(Duration::from_secs(900)),
|
||||
kopid.eventid,
|
||||
)
|
||||
.await
|
||||
.map_err(|op_err| HtmxError::new(&kopid, op_err))?;
|
||||
|
||||
let secret = cu_intent.token;
|
||||
|
||||
let mut uri = state.origin.clone();
|
||||
uri.set_path(Urls::CredReset.as_ref());
|
||||
uri.set_query(Some(format!("token={secret}").as_str()));
|
||||
|
||||
let qr_code_svg = match QrCode::new(uri.as_str()) {
|
||||
Ok(qr) => qr.render::<svg::Color>().build(),
|
||||
Err(qr_err) => {
|
||||
error!("Failed to create TOTP QR code: {qr_err}");
|
||||
"QR Code Generation Failed".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
Ok(ProfileView {
|
||||
navbar_ctx: NavbarCtx { domain_info },
|
||||
profile_partial: EnrolDeviceView {
|
||||
menu_active_item: ProfileMenuItems::EnrolDevice,
|
||||
qr_code_svg,
|
||||
secret,
|
||||
uri,
|
||||
},
|
||||
}
|
||||
.into_response())
|
||||
}
|
|
@ -12,9 +12,10 @@ use axum::{
|
|||
response::{IntoResponse, Redirect, Response},
|
||||
Extension, Form, Json,
|
||||
};
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||
use axum_extra::extract::cookie::{CookieJar, SameSite};
|
||||
use kanidm_proto::internal::{
|
||||
COOKIE_AUTH_SESSION_ID, COOKIE_BEARER_TOKEN, COOKIE_OAUTH2_REQ, COOKIE_USERNAME,
|
||||
COOKIE_AUTH_SESSION_ID, COOKIE_BEARER_TOKEN, COOKIE_CU_SESSION_TOKEN, COOKIE_OAUTH2_REQ,
|
||||
COOKIE_USERNAME,
|
||||
};
|
||||
use kanidm_proto::v1::{
|
||||
AuthAllowed, AuthCredential, AuthIssueSession, AuthMech, AuthRequest, AuthStep,
|
||||
|
@ -76,10 +77,15 @@ pub struct Reauth {
|
|||
pub purpose: ReauthPurpose,
|
||||
}
|
||||
|
||||
pub struct Oauth2Ctx {
|
||||
pub client_name: String,
|
||||
}
|
||||
|
||||
pub struct LoginDisplayCtx {
|
||||
pub domain_info: DomainInfoRead,
|
||||
// We only need this on the first re-auth screen to indicate what we are doing
|
||||
pub reauth: Option<Reauth>,
|
||||
pub oauth2: Option<Oauth2Ctx>,
|
||||
pub error: Option<LoginError>,
|
||||
}
|
||||
|
||||
|
@ -94,6 +100,7 @@ struct LoginView {
|
|||
pub struct Mech<'a> {
|
||||
name: AuthMech,
|
||||
value: &'a str,
|
||||
autofocus: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
|
@ -155,7 +162,7 @@ pub async fn view_logout_get(
|
|||
Extension(kopid): Extension<KOpId>,
|
||||
mut jar: CookieJar,
|
||||
) -> Response {
|
||||
if let Err(err_code) = state
|
||||
let response = if let Err(err_code) = state
|
||||
.qe_w_ref
|
||||
.handle_logout(client_auth_info, kopid.eventid)
|
||||
.await
|
||||
|
@ -166,12 +173,16 @@ pub async fn view_logout_get(
|
|||
}
|
||||
.into_response()
|
||||
} else {
|
||||
let response = Redirect::to(Urls::Login.as_ref()).into_response();
|
||||
Redirect::to(Urls::Login.as_ref()).into_response()
|
||||
};
|
||||
|
||||
jar = cookies::destroy(jar, COOKIE_BEARER_TOKEN);
|
||||
// Always clear cookies even on an error.
|
||||
jar = cookies::destroy(jar, COOKIE_BEARER_TOKEN, &state);
|
||||
jar = cookies::destroy(jar, COOKIE_OAUTH2_REQ, &state);
|
||||
jar = cookies::destroy(jar, COOKIE_AUTH_SESSION_ID, &state);
|
||||
jar = cookies::destroy(jar, COOKIE_CU_SESSION_TOKEN, &state);
|
||||
|
||||
(jar, response).into_response()
|
||||
}
|
||||
(jar, response).into_response()
|
||||
}
|
||||
|
||||
pub async fn view_reauth_get(
|
||||
|
@ -182,6 +193,10 @@ pub async fn view_reauth_get(
|
|||
return_location: &str,
|
||||
display_ctx: LoginDisplayCtx,
|
||||
) -> Response {
|
||||
// No matter what, we always clear the stored oauth2 cookie to prevent
|
||||
// ui loops
|
||||
let jar = cookies::destroy(jar, COOKIE_OAUTH2_REQ, &state);
|
||||
|
||||
let session_valid_result = state
|
||||
.qe_r_ref
|
||||
.handle_auth_valid(client_auth_info.clone(), kopid.eventid)
|
||||
|
@ -247,12 +262,15 @@ pub async fn view_reauth_get(
|
|||
|
||||
let remember_me = !username.is_empty();
|
||||
|
||||
LoginView {
|
||||
display_ctx,
|
||||
username,
|
||||
remember_me,
|
||||
}
|
||||
.into_response()
|
||||
(
|
||||
jar,
|
||||
LoginView {
|
||||
display_ctx,
|
||||
username,
|
||||
remember_me,
|
||||
},
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(err_code) => UnrecoverableErrorView {
|
||||
err_code,
|
||||
|
@ -262,6 +280,33 @@ pub async fn view_reauth_get(
|
|||
}
|
||||
}
|
||||
|
||||
pub fn view_oauth2_get(
|
||||
jar: CookieJar,
|
||||
display_ctx: LoginDisplayCtx,
|
||||
login_hint: Option<String>,
|
||||
) -> Response {
|
||||
let (username, remember_me) = if let Some(login_hint) = login_hint {
|
||||
(login_hint, false)
|
||||
} else if let Some(cookie_username) =
|
||||
// cookie jar with remember me.
|
||||
jar.get(COOKIE_USERNAME).map(|c| c.value().to_string())
|
||||
{
|
||||
(cookie_username, true)
|
||||
} else {
|
||||
(String::default(), false)
|
||||
};
|
||||
|
||||
(
|
||||
jar,
|
||||
LoginView {
|
||||
display_ctx,
|
||||
username,
|
||||
remember_me,
|
||||
},
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn view_index_get(
|
||||
State(state): State<ServerState>,
|
||||
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
|
||||
|
@ -275,10 +320,14 @@ pub async fn view_index_get(
|
|||
.handle_auth_valid(client_auth_info, kopid.eventid)
|
||||
.await;
|
||||
|
||||
// No matter what, we always clear the stored oauth2 cookie to prevent
|
||||
// ui loops
|
||||
let jar = cookies::destroy(jar, COOKIE_OAUTH2_REQ, &state);
|
||||
|
||||
match session_valid_result {
|
||||
Ok(()) => {
|
||||
// Send the user to the landing.
|
||||
Redirect::to(Urls::Apps.as_ref()).into_response()
|
||||
(jar, Redirect::to(Urls::Apps.as_ref())).into_response()
|
||||
}
|
||||
Err(OperationError::NotAuthenticated) | Err(OperationError::SessionExpired) => {
|
||||
// cookie jar with remember me.
|
||||
|
@ -291,16 +340,20 @@ pub async fn view_index_get(
|
|||
|
||||
let display_ctx = LoginDisplayCtx {
|
||||
domain_info,
|
||||
oauth2: None,
|
||||
reauth: None,
|
||||
error: None,
|
||||
};
|
||||
|
||||
LoginView {
|
||||
display_ctx,
|
||||
username,
|
||||
remember_me,
|
||||
}
|
||||
.into_response()
|
||||
(
|
||||
jar,
|
||||
LoginView {
|
||||
display_ctx,
|
||||
username,
|
||||
remember_me,
|
||||
},
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(err_code) => UnrecoverableErrorView {
|
||||
err_code,
|
||||
|
@ -368,6 +421,7 @@ pub async fn view_login_begin_post(
|
|||
|
||||
let mut display_ctx = LoginDisplayCtx {
|
||||
domain_info,
|
||||
oauth2: None,
|
||||
reauth: None,
|
||||
error: None,
|
||||
};
|
||||
|
@ -450,6 +504,7 @@ pub async fn view_login_mech_choose_post(
|
|||
|
||||
let display_ctx = LoginDisplayCtx {
|
||||
domain_info,
|
||||
oauth2: None,
|
||||
reauth: None,
|
||||
error: None,
|
||||
};
|
||||
|
@ -488,6 +543,8 @@ pub async fn view_login_mech_choose_post(
|
|||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct LoginTotpForm {
|
||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
||||
password: Option<String>,
|
||||
totp: String,
|
||||
}
|
||||
|
||||
|
@ -496,7 +553,7 @@ pub async fn view_login_totp_post(
|
|||
Extension(kopid): Extension<KOpId>,
|
||||
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
|
||||
DomainInfo(domain_info): DomainInfo,
|
||||
jar: CookieJar,
|
||||
mut jar: CookieJar,
|
||||
Form(login_totp_form): Form<LoginTotpForm>,
|
||||
) -> Response {
|
||||
// trim leading and trailing white space.
|
||||
|
@ -505,6 +562,7 @@ pub async fn view_login_totp_post(
|
|||
Err(_) => {
|
||||
let display_ctx = LoginDisplayCtx {
|
||||
domain_info,
|
||||
oauth2: None,
|
||||
reauth: None,
|
||||
error: None,
|
||||
};
|
||||
|
@ -518,6 +576,31 @@ pub async fn view_login_totp_post(
|
|||
}
|
||||
};
|
||||
|
||||
// In some flows the PW manager may not have autocompleted the pw until
|
||||
// this point. This could be due to a re-auth flow which skips the username
|
||||
// prompt, the use of remember-me+return which then skips the autocomplete.
|
||||
//
|
||||
// In the case the pw *is* bg filled, we need to add it to the session context
|
||||
// here.
|
||||
//
|
||||
// It's probably not "optimal" to be getting the context out and signing it
|
||||
// here to re-add it, but it also helps keep the flow neater in general.
|
||||
|
||||
if let Some(password_autofill) = login_totp_form.password {
|
||||
let mut session_context =
|
||||
cookies::get_signed::<SessionContext>(&state, &jar, COOKIE_AUTH_SESSION_ID)
|
||||
.unwrap_or_default();
|
||||
|
||||
session_context.password = Some(password_autofill);
|
||||
|
||||
// If we can't write this back to the jar, we warn and move on.
|
||||
if let Ok(update_jar) = add_session_cookie(&state, jar.clone(), &session_context) {
|
||||
jar = update_jar;
|
||||
} else {
|
||||
warn!("Unable to update session_context, ignoring...");
|
||||
}
|
||||
}
|
||||
|
||||
let auth_cred = AuthCredential::Totp(totp);
|
||||
credential_step(state, kopid, jar, client_auth_info, auth_cred, domain_info).await
|
||||
}
|
||||
|
@ -610,6 +693,7 @@ async fn credential_step(
|
|||
|
||||
let display_ctx = LoginDisplayCtx {
|
||||
domain_info,
|
||||
oauth2: None,
|
||||
reauth: None,
|
||||
error: None,
|
||||
};
|
||||
|
@ -689,7 +773,7 @@ async fn view_login_step(
|
|||
safety -= 1;
|
||||
|
||||
match auth_state {
|
||||
AuthState::Choose(allowed) => {
|
||||
AuthState::Choose(mut allowed) => {
|
||||
debug!("🧩 -> AuthState::Choose");
|
||||
|
||||
jar = add_session_cookie(&state, jar, &session_context)?;
|
||||
|
@ -728,13 +812,22 @@ async fn view_login_step(
|
|||
|
||||
// Render the list of options.
|
||||
_ => {
|
||||
let mechs = allowed
|
||||
allowed.sort_unstable();
|
||||
// Put strongest first.
|
||||
allowed.reverse();
|
||||
|
||||
let mechs: Vec<_> = allowed
|
||||
.into_iter()
|
||||
.map(|m| Mech {
|
||||
.enumerate()
|
||||
.map(|(i, m)| Mech {
|
||||
value: m.to_value(),
|
||||
name: m,
|
||||
// Auto focus the first item, it's the strongest
|
||||
// mechanism and the one we should optimise for.
|
||||
autofocus: i == 0,
|
||||
})
|
||||
.collect();
|
||||
|
||||
LoginMechView { display_ctx, mechs }.into_response()
|
||||
}
|
||||
};
|
||||
|
@ -742,10 +835,8 @@ async fn view_login_step(
|
|||
break res;
|
||||
}
|
||||
AuthState::Continue(allowed) => {
|
||||
// Reauth inits its session here so we need to be able to add cookie here ig.
|
||||
if jar.get(COOKIE_AUTH_SESSION_ID).is_none() {
|
||||
jar = add_session_cookie(&state, jar, &session_context)?;
|
||||
}
|
||||
// Reauth inits its session here so we need to be able to add it's cookie here.
|
||||
jar = add_session_cookie(&state, jar, &session_context)?;
|
||||
|
||||
let res = match allowed.len() {
|
||||
// Shouldn't be possible.
|
||||
|
@ -822,32 +913,30 @@ async fn view_login_step(
|
|||
// Update jar
|
||||
let token_str = token.to_string();
|
||||
|
||||
// Important - this can be make unsigned as token_str has it's own
|
||||
// Important - this can be make unsigned as token_str has its own
|
||||
// signatures.
|
||||
let bearer_cookie = cookies::make_unsigned(
|
||||
&state,
|
||||
COOKIE_BEARER_TOKEN,
|
||||
token_str.clone(),
|
||||
"/",
|
||||
);
|
||||
let mut bearer_cookie =
|
||||
cookies::make_unsigned(&state, COOKIE_BEARER_TOKEN, token_str.clone());
|
||||
// Important - can be permanent as the token has its own expiration time internally
|
||||
bearer_cookie.make_permanent();
|
||||
|
||||
jar = if session_context.remember_me {
|
||||
// Important - can be unsigned as username is just for remember
|
||||
// me and no other purpose.
|
||||
let username_cookie = cookies::make_unsigned(
|
||||
let mut username_cookie = cookies::make_unsigned(
|
||||
&state,
|
||||
COOKIE_USERNAME,
|
||||
session_context.username.clone(),
|
||||
Urls::Login.as_ref(),
|
||||
);
|
||||
username_cookie.make_permanent();
|
||||
jar.add(username_cookie)
|
||||
} else {
|
||||
jar
|
||||
};
|
||||
|
||||
jar = jar
|
||||
.add(bearer_cookie)
|
||||
.remove(Cookie::from(COOKIE_AUTH_SESSION_ID));
|
||||
jar = jar.add(bearer_cookie);
|
||||
|
||||
jar = cookies::destroy(jar, COOKIE_AUTH_SESSION_ID, &state);
|
||||
|
||||
// Now, we need to decided where to go.
|
||||
let res = if jar.get(COOKIE_OAUTH2_REQ).is_some() {
|
||||
|
@ -864,7 +953,7 @@ async fn view_login_step(
|
|||
}
|
||||
AuthState::Denied(reason) => {
|
||||
debug!("🧩 -> AuthState::Denied");
|
||||
jar = jar.remove(Cookie::from(COOKIE_AUTH_SESSION_ID));
|
||||
jar = cookies::destroy(jar, COOKIE_AUTH_SESSION_ID, &state);
|
||||
|
||||
break LoginDeniedView {
|
||||
display_ctx,
|
||||
|
@ -884,16 +973,11 @@ fn add_session_cookie(
|
|||
jar: CookieJar,
|
||||
session_context: &SessionContext,
|
||||
) -> Result<CookieJar, OperationError> {
|
||||
cookies::make_signed(
|
||||
state,
|
||||
COOKIE_AUTH_SESSION_ID,
|
||||
session_context,
|
||||
Urls::Login.as_ref(),
|
||||
)
|
||||
.map(|mut cookie| {
|
||||
// Not needed when redirecting into this site
|
||||
cookie.set_same_site(SameSite::Strict);
|
||||
jar.add(cookie)
|
||||
})
|
||||
.ok_or(OperationError::InvalidSessionState)
|
||||
cookies::make_signed(state, COOKIE_AUTH_SESSION_ID, session_context)
|
||||
.map(|mut cookie| {
|
||||
// Not needed when redirecting into this site
|
||||
cookie.set_same_site(SameSite::Strict);
|
||||
jar.add(cookie)
|
||||
})
|
||||
.ok_or(OperationError::InvalidSessionState)
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ use crate::https::ServerState;
|
|||
mod apps;
|
||||
mod constants;
|
||||
mod cookies;
|
||||
mod enrol;
|
||||
mod errors;
|
||||
mod login;
|
||||
mod navbar;
|
||||
|
@ -37,6 +38,7 @@ pub fn view_router() -> Router<ServerState> {
|
|||
get(|| async { Redirect::permanent(Urls::Login.as_ref()) }),
|
||||
)
|
||||
.route("/apps", get(apps::view_apps_get))
|
||||
.route("/enrol", get(enrol::view_enrol_get))
|
||||
.route("/reset", get(reset::view_reset_get))
|
||||
.route("/update_credentials", get(reset::view_self_reset_get))
|
||||
.route("/profile", get(profile::view_profile_get))
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
use crate::https::{extractors::VerifiedClientInformation, middleware::KOpId, ServerState};
|
||||
use crate::https::{
|
||||
extractors::{DomainInfo, DomainInfoRead, VerifiedClientInformation},
|
||||
middleware::KOpId,
|
||||
ServerState,
|
||||
};
|
||||
use kanidmd_lib::idm::oauth2::{
|
||||
AuthorisationRequest, AuthorisePermitSuccess, AuthoriseResponse, Oauth2Error,
|
||||
};
|
||||
|
@ -22,7 +26,7 @@ use axum_extra::extract::cookie::{CookieJar, SameSite};
|
|||
use axum_htmx::HX_REDIRECT;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::constants::Urls;
|
||||
use super::login::{LoginDisplayCtx, Oauth2Ctx};
|
||||
use super::{cookies, UnrecoverableErrorView};
|
||||
|
||||
#[derive(Template)]
|
||||
|
@ -45,39 +49,68 @@ pub async fn view_index_get(
|
|||
State(state): State<ServerState>,
|
||||
Extension(kopid): Extension<KOpId>,
|
||||
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
|
||||
DomainInfo(domain_info): DomainInfo,
|
||||
jar: CookieJar,
|
||||
Query(auth_req): Query<AuthorisationRequest>,
|
||||
) -> Response {
|
||||
oauth2_auth_req(state, kopid, client_auth_info, jar, auth_req).await
|
||||
oauth2_auth_req(
|
||||
state,
|
||||
kopid,
|
||||
client_auth_info,
|
||||
domain_info,
|
||||
jar,
|
||||
Some(auth_req),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn view_resume_get(
|
||||
State(state): State<ServerState>,
|
||||
Extension(kopid): Extension<KOpId>,
|
||||
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
|
||||
DomainInfo(domain_info): DomainInfo,
|
||||
jar: CookieJar,
|
||||
) -> Result<Response, UnrecoverableErrorView> {
|
||||
) -> Response {
|
||||
let maybe_auth_req =
|
||||
cookies::get_signed::<AuthorisationRequest>(&state, &jar, COOKIE_OAUTH2_REQ);
|
||||
|
||||
if let Some(auth_req) = maybe_auth_req {
|
||||
Ok(oauth2_auth_req(state, kopid, client_auth_info, jar, auth_req).await)
|
||||
} else {
|
||||
error!("unable to resume session, no auth_req was found in the cookie");
|
||||
Err(UnrecoverableErrorView {
|
||||
err_code: OperationError::InvalidState,
|
||||
operation_id: kopid.eventid,
|
||||
})
|
||||
}
|
||||
oauth2_auth_req(
|
||||
state,
|
||||
kopid,
|
||||
client_auth_info,
|
||||
domain_info,
|
||||
jar,
|
||||
maybe_auth_req,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn oauth2_auth_req(
|
||||
state: ServerState,
|
||||
kopid: KOpId,
|
||||
client_auth_info: ClientAuthInfo,
|
||||
domain_info: DomainInfoRead,
|
||||
jar: CookieJar,
|
||||
auth_req: AuthorisationRequest,
|
||||
maybe_auth_req: Option<AuthorisationRequest>,
|
||||
) -> Response {
|
||||
// No matter what, we always clear the stored oauth2 cookie to prevent
|
||||
// ui loops
|
||||
let jar = cookies::destroy(jar, COOKIE_OAUTH2_REQ, &state);
|
||||
|
||||
// If the auth_req was cross-signed, old, or just bad, error. But we have *cleared* it
|
||||
// from the cookie which means we won't see it again.
|
||||
let Some(auth_req) = maybe_auth_req else {
|
||||
error!("unable to resume session, no valid auth_req was found in the cookie. This cookie has been removed.");
|
||||
return (
|
||||
jar,
|
||||
UnrecoverableErrorView {
|
||||
err_code: OperationError::UI0003InvalidOauth2Resume,
|
||||
operation_id: kopid.eventid,
|
||||
},
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let res: Result<AuthoriseResponse, Oauth2Error> = state
|
||||
.qe_r_ref
|
||||
.handle_oauth2_authorise(client_auth_info, auth_req.clone(), kopid.eventid)
|
||||
|
@ -89,15 +122,6 @@ async fn oauth2_auth_req(
|
|||
state,
|
||||
code,
|
||||
})) => {
|
||||
let jar = if let Some(authreq_cookie) = jar.get(COOKIE_OAUTH2_REQ) {
|
||||
let mut authreq_cookie = authreq_cookie.clone();
|
||||
authreq_cookie.make_removal();
|
||||
authreq_cookie.set_path("/ui");
|
||||
jar.add(authreq_cookie)
|
||||
} else {
|
||||
jar
|
||||
};
|
||||
|
||||
redirect_uri
|
||||
.query_pairs_mut()
|
||||
.clear()
|
||||
|
@ -124,39 +148,66 @@ async fn oauth2_auth_req(
|
|||
consent_token,
|
||||
}) => {
|
||||
// We can just render the form now, the consent token has everything we need.
|
||||
ConsentRequestView {
|
||||
client_name,
|
||||
// scopes,
|
||||
pii_scopes,
|
||||
consent_token,
|
||||
redirect: None,
|
||||
}
|
||||
.into_response()
|
||||
(
|
||||
jar,
|
||||
ConsentRequestView {
|
||||
client_name,
|
||||
// scopes,
|
||||
pii_scopes,
|
||||
consent_token,
|
||||
redirect: None,
|
||||
},
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(Oauth2Error::AuthenticationRequired) => {
|
||||
// Sign the auth req and hide it in our cookie.
|
||||
let maybe_jar = cookies::make_signed(&state, COOKIE_OAUTH2_REQ, &auth_req, "/ui")
|
||||
|
||||
Ok(AuthoriseResponse::AuthenticationRequired {
|
||||
client_name,
|
||||
login_hint,
|
||||
}) => {
|
||||
// Sign the auth req and hide it in our cookie - we'll come back for
|
||||
// you later.
|
||||
let maybe_jar = cookies::make_signed(&state, COOKIE_OAUTH2_REQ, &auth_req)
|
||||
.map(|mut cookie| {
|
||||
cookie.set_same_site(SameSite::Strict);
|
||||
jar.add(cookie)
|
||||
// Expire at the end of the session.
|
||||
cookie.set_expires(None);
|
||||
// Could experiment with this to a shorter value, but session should be enough.
|
||||
cookie.set_max_age(time::Duration::minutes(15));
|
||||
jar.clone().add(cookie)
|
||||
})
|
||||
.ok_or(OperationError::InvalidSessionState);
|
||||
|
||||
match maybe_jar {
|
||||
Ok(jar) => (jar, Redirect::to(Urls::Login.as_ref())).into_response(),
|
||||
Err(err_code) => UnrecoverableErrorView {
|
||||
err_code,
|
||||
operation_id: kopid.eventid,
|
||||
Ok(new_jar) => {
|
||||
let display_ctx = LoginDisplayCtx {
|
||||
domain_info,
|
||||
oauth2: Some(Oauth2Ctx { client_name }),
|
||||
reauth: None,
|
||||
error: None,
|
||||
};
|
||||
|
||||
super::login::view_oauth2_get(new_jar, display_ctx, login_hint)
|
||||
}
|
||||
.into_response(),
|
||||
Err(err_code) => (
|
||||
jar,
|
||||
UnrecoverableErrorView {
|
||||
err_code,
|
||||
operation_id: kopid.eventid,
|
||||
},
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
Err(Oauth2Error::AccessDenied) => {
|
||||
// If scopes are not available for this account.
|
||||
AccessDeniedView {
|
||||
operation_id: kopid.eventid,
|
||||
}
|
||||
.into_response()
|
||||
(
|
||||
jar,
|
||||
AccessDeniedView {
|
||||
operation_id: kopid.eventid,
|
||||
},
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
/*
|
||||
RFC - If the request fails due to a missing, invalid, or mismatching
|
||||
|
@ -175,11 +226,14 @@ async fn oauth2_auth_req(
|
|||
&err_code.to_string()
|
||||
);
|
||||
|
||||
UnrecoverableErrorView {
|
||||
err_code: OperationError::InvalidState,
|
||||
operation_id: kopid.eventid,
|
||||
}
|
||||
.into_response()
|
||||
(
|
||||
jar,
|
||||
UnrecoverableErrorView {
|
||||
err_code: OperationError::InvalidState,
|
||||
operation_id: kopid.eventid,
|
||||
},
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -193,13 +247,13 @@ pub struct ConsentForm {
|
|||
}
|
||||
|
||||
pub async fn view_consent_post(
|
||||
State(state): State<ServerState>,
|
||||
State(server_state): State<ServerState>,
|
||||
Extension(kopid): Extension<KOpId>,
|
||||
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
|
||||
jar: CookieJar,
|
||||
Form(consent_form): Form<ConsentForm>,
|
||||
) -> Result<Response, UnrecoverableErrorView> {
|
||||
let res = state
|
||||
let res = server_state
|
||||
.qe_w_ref
|
||||
.handle_oauth2_authorise_permit(client_auth_info, consent_form.consent_token, kopid.eventid)
|
||||
.await;
|
||||
|
@ -210,7 +264,7 @@ pub async fn view_consent_post(
|
|||
state,
|
||||
code,
|
||||
}) => {
|
||||
let jar = cookies::destroy(jar, COOKIE_OAUTH2_REQ);
|
||||
let jar = cookies::destroy(jar, COOKIE_OAUTH2_REQ, &server_state);
|
||||
|
||||
if let Some(redirect) = consent_form.redirect {
|
||||
Ok((
|
||||
|
|
|
@ -73,6 +73,7 @@ pub(crate) async fn view_profile_unlock_get(
|
|||
|
||||
let display_ctx = LoginDisplayCtx {
|
||||
domain_info,
|
||||
oauth2: None,
|
||||
reauth: Some(Reauth {
|
||||
username: uat.spn,
|
||||
purpose: ReauthPurpose::ProfileSettings,
|
||||
|
|
|
@ -3,7 +3,7 @@ use axum::extract::{Query, State};
|
|||
use axum::http::{StatusCode, Uri};
|
||||
use axum::response::{ErrorResponse, IntoResponse, Redirect, Response};
|
||||
use axum::{Extension, Form};
|
||||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||
use axum_extra::extract::cookie::SameSite;
|
||||
use axum_extra::extract::CookieJar;
|
||||
use axum_htmx::{
|
||||
HxEvent, HxLocation, HxPushUrl, HxRequest, HxReselect, HxResponseTrigger, HxReswap, HxRetarget,
|
||||
|
@ -30,6 +30,7 @@ use super::navbar::NavbarCtx;
|
|||
use crate::https::extractors::{DomainInfo, DomainInfoRead, VerifiedClientInformation};
|
||||
use crate::https::middleware::KOpId;
|
||||
use crate::https::views::constants::ProfileMenuItems;
|
||||
use crate::https::views::cookies;
|
||||
use crate::https::views::errors::HtmxError;
|
||||
use crate::https::views::login::{LoginDisplayCtx, Reauth, ReauthPurpose};
|
||||
use crate::https::ServerState;
|
||||
|
@ -210,7 +211,7 @@ pub(crate) async fn commit(
|
|||
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
|
||||
jar: CookieJar,
|
||||
) -> axum::response::Result<Response> {
|
||||
let cu_session_token: CUSessionToken = get_cu_session(jar).await?;
|
||||
let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
|
||||
|
||||
state
|
||||
.qe_w_ref
|
||||
|
@ -218,7 +219,10 @@ pub(crate) async fn commit(
|
|||
.map_err(|op_err| HtmxError::new(&kopid, op_err))
|
||||
.await?;
|
||||
|
||||
Ok((HxLocation::from(Uri::from_static("/ui")), "").into_response())
|
||||
// No longer need the cookie jar.
|
||||
let jar = cookies::destroy(jar, COOKIE_CU_SESSION_TOKEN, &state);
|
||||
|
||||
Ok((jar, HxLocation::from(Uri::from_static("/ui")), "").into_response())
|
||||
}
|
||||
|
||||
pub(crate) async fn cancel_cred_update(
|
||||
|
@ -228,7 +232,7 @@ pub(crate) async fn cancel_cred_update(
|
|||
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
|
||||
jar: CookieJar,
|
||||
) -> axum::response::Result<Response> {
|
||||
let cu_session_token: CUSessionToken = get_cu_session(jar).await?;
|
||||
let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
|
||||
|
||||
state
|
||||
.qe_w_ref
|
||||
|
@ -236,7 +240,11 @@ pub(crate) async fn cancel_cred_update(
|
|||
.map_err(|op_err| HtmxError::new(&kopid, op_err))
|
||||
.await?;
|
||||
|
||||
// No longer need the cookie jar.
|
||||
let jar = cookies::destroy(jar, COOKIE_CU_SESSION_TOKEN, &state);
|
||||
|
||||
Ok((
|
||||
jar,
|
||||
HxLocation::from(Uri::from_static(Urls::Profile.as_ref())),
|
||||
"",
|
||||
)
|
||||
|
@ -250,7 +258,7 @@ pub(crate) async fn cancel_mfareg(
|
|||
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
|
||||
jar: CookieJar,
|
||||
) -> axum::response::Result<Response> {
|
||||
let cu_session_token: CUSessionToken = get_cu_session(jar).await?;
|
||||
let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
|
||||
|
||||
let cu_status = state
|
||||
.qe_r_ref
|
||||
|
@ -268,7 +276,7 @@ pub(crate) async fn remove_alt_creds(
|
|||
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
|
||||
jar: CookieJar,
|
||||
) -> axum::response::Result<Response> {
|
||||
let cu_session_token: CUSessionToken = get_cu_session(jar).await?;
|
||||
let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
|
||||
|
||||
let cu_status = state
|
||||
.qe_r_ref
|
||||
|
@ -286,7 +294,7 @@ pub(crate) async fn remove_unixcred(
|
|||
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
|
||||
jar: CookieJar,
|
||||
) -> axum::response::Result<Response> {
|
||||
let cu_session_token: CUSessionToken = get_cu_session(jar).await?;
|
||||
let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
|
||||
|
||||
let cu_status = state
|
||||
.qe_r_ref
|
||||
|
@ -309,7 +317,7 @@ pub(crate) async fn remove_totp(
|
|||
jar: CookieJar,
|
||||
Form(totp): Form<TOTPRemoveData>,
|
||||
) -> axum::response::Result<Response> {
|
||||
let cu_session_token: CUSessionToken = get_cu_session(jar).await?;
|
||||
let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
|
||||
|
||||
let cu_status = state
|
||||
.qe_r_ref
|
||||
|
@ -332,7 +340,7 @@ pub(crate) async fn remove_passkey(
|
|||
jar: CookieJar,
|
||||
Form(passkey): Form<PasskeyRemoveData>,
|
||||
) -> axum::response::Result<Response> {
|
||||
let cu_session_token: CUSessionToken = get_cu_session(jar).await?;
|
||||
let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
|
||||
|
||||
let cu_status = state
|
||||
.qe_r_ref
|
||||
|
@ -355,7 +363,7 @@ pub(crate) async fn finish_passkey(
|
|||
jar: CookieJar,
|
||||
Form(passkey_create): Form<PasskeyCreateForm>,
|
||||
) -> axum::response::Result<Response> {
|
||||
let cu_session_token = get_cu_session(jar).await?;
|
||||
let cu_session_token = get_cu_session(&jar).await?;
|
||||
|
||||
match serde_json::from_str(passkey_create.creation_data.as_str()) {
|
||||
Ok(creation_data) => {
|
||||
|
@ -393,7 +401,7 @@ pub(crate) async fn view_new_passkey(
|
|||
jar: CookieJar,
|
||||
Form(init_form): Form<PasskeyInitForm>,
|
||||
) -> axum::response::Result<Response> {
|
||||
let cu_session_token = get_cu_session(jar).await?;
|
||||
let cu_session_token = get_cu_session(&jar).await?;
|
||||
let cu_req = match init_form.class {
|
||||
PasskeyClass::Any => CURequest::PasskeyInit,
|
||||
PasskeyClass::Attested => CURequest::AttestedPasskeyInit,
|
||||
|
@ -445,7 +453,7 @@ pub(crate) async fn view_new_totp(
|
|||
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
|
||||
jar: CookieJar,
|
||||
) -> axum::response::Result<Response> {
|
||||
let cu_session_token = get_cu_session(jar).await?;
|
||||
let cu_session_token = get_cu_session(&jar).await?;
|
||||
let push_url = HxPushUrl(Uri::from_static("/ui/reset/add_totp"));
|
||||
|
||||
let cu_status = state
|
||||
|
@ -497,7 +505,7 @@ pub(crate) async fn add_totp(
|
|||
jar: CookieJar,
|
||||
new_totp_form: Form<NewTotp>,
|
||||
) -> axum::response::Result<Response> {
|
||||
let cu_session_token = get_cu_session(jar).await?;
|
||||
let cu_session_token = get_cu_session(&jar).await?;
|
||||
|
||||
let check_totpcode = u32::from_str(&new_totp_form.check_totpcode).unwrap_or_default();
|
||||
|
||||
|
@ -569,7 +577,7 @@ pub(crate) async fn view_new_pwd(
|
|||
jar: CookieJar,
|
||||
opt_form: Option<Form<NewPassword>>,
|
||||
) -> axum::response::Result<Response> {
|
||||
let cu_session_token: CUSessionToken = get_cu_session(jar).await?;
|
||||
let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
|
||||
let swapped_handler_trigger =
|
||||
HxResponseTrigger::after_swap([HxEvent::new("addPasswordSwapped".to_string())]);
|
||||
|
||||
|
@ -653,6 +661,7 @@ pub(crate) async fn view_self_reset_get(
|
|||
} else {
|
||||
let display_ctx = LoginDisplayCtx {
|
||||
domain_info,
|
||||
oauth2: None,
|
||||
reauth: Some(Reauth {
|
||||
username: uat.spn,
|
||||
purpose: ReauthPurpose::ProfileSettings,
|
||||
|
@ -678,10 +687,9 @@ fn add_cu_cookie(
|
|||
state: &ServerState,
|
||||
cu_session_token: CUSessionToken,
|
||||
) -> CookieJar {
|
||||
let mut token_cookie = Cookie::new(COOKIE_CU_SESSION_TOKEN, cu_session_token.token);
|
||||
token_cookie.set_secure(state.secure_cookies);
|
||||
let mut token_cookie =
|
||||
cookies::make_unsigned(state, COOKIE_CU_SESSION_TOKEN, cu_session_token.token);
|
||||
token_cookie.set_same_site(SameSite::Strict);
|
||||
token_cookie.set_http_only(true);
|
||||
jar.add(token_cookie)
|
||||
}
|
||||
|
||||
|
@ -693,7 +701,7 @@ pub(crate) async fn view_set_unixcred(
|
|||
jar: CookieJar,
|
||||
opt_form: Option<Form<NewPassword>>,
|
||||
) -> axum::response::Result<Response> {
|
||||
let cu_session_token: CUSessionToken = get_cu_session(jar).await?;
|
||||
let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
|
||||
let swapped_handler_trigger =
|
||||
HxResponseTrigger::after_swap([HxEvent::new("addPasswordSwapped".to_string())]);
|
||||
|
||||
|
@ -780,7 +788,7 @@ pub(crate) async fn view_reset_get(
|
|||
| OperationError::InvalidState,
|
||||
) => {
|
||||
// If our previous credential update session expired we want to see the reset form again.
|
||||
jar = jar.remove(Cookie::from(COOKIE_CU_SESSION_TOKEN));
|
||||
jar = cookies::destroy(jar, COOKIE_CU_SESSION_TOKEN, &state);
|
||||
|
||||
if let Some(token) = params.token {
|
||||
let token_uri_string = format!("{}?token={}", Urls::CredReset, token);
|
||||
|
@ -916,7 +924,7 @@ fn get_cu_response(
|
|||
}
|
||||
}
|
||||
|
||||
async fn get_cu_session(jar: CookieJar) -> Result<CUSessionToken, Response> {
|
||||
async fn get_cu_session(jar: &CookieJar) -> Result<CUSessionToken, Response> {
|
||||
let cookie = jar.get(COOKIE_CU_SESSION_TOKEN);
|
||||
if let Some(cookie) = cookie {
|
||||
let cu_session_token = cookie.value();
|
||||
|
|
|
@ -400,14 +400,6 @@ async fn repl_task(
|
|||
}
|
||||
};
|
||||
|
||||
let socket_addrs = match origin.socket_addrs(|| Some(443)) {
|
||||
Ok(sa) => sa,
|
||||
Err(err) => {
|
||||
error!(?err, "Replica origin could not resolve to ip:port");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Setup our tls connector.
|
||||
let mut ssl_builder = match SslConnector::builder(SslMethod::tls_client()) {
|
||||
Ok(sb) => sb,
|
||||
|
@ -465,20 +457,57 @@ async fn repl_task(
|
|||
// we keep track of the "last known good" socketaddr so we can try that first next time.
|
||||
let mut last_working_address: Option<SocketAddr> = None;
|
||||
|
||||
// Okay, all the parameters are setup. Now we wait on our interval.
|
||||
// Okay, all the parameters are set up. Now we replicate on our interval.
|
||||
loop {
|
||||
// if the target address worked last time, then let's use it this time!
|
||||
// we resolve the DNS entry to the ip:port each time we attempt a connection to avoid stale
|
||||
// DNS issues, ref #3188. If we are unable to resolve the address, we backoff and try again
|
||||
// as in something like docker the address may change frequently.
|
||||
//
|
||||
// Note, if DNS isn't available, we can proceed with the last used working address too. This
|
||||
// prevents DNS (or lack thereof) from causing a replication outage.
|
||||
let mut sorted_socket_addrs = vec![];
|
||||
|
||||
// If the target address worked last time, then let's use it this time!
|
||||
if let Some(addr) = last_working_address {
|
||||
debug!(?last_working_address);
|
||||
sorted_socket_addrs.push(addr);
|
||||
};
|
||||
// this is O(2^n) but we *should* be talking about a small number of addresses for a given hostname
|
||||
socket_addrs.iter().for_each(|addr| {
|
||||
if !sorted_socket_addrs.contains(addr) {
|
||||
sorted_socket_addrs.push(addr.to_owned());
|
||||
|
||||
// Default to port 443 if not set in the origin
|
||||
match origin.socket_addrs(|| Some(443)) {
|
||||
Ok(mut socket_addrs) => {
|
||||
// Make every address unique.
|
||||
socket_addrs.sort_unstable();
|
||||
socket_addrs.dedup();
|
||||
|
||||
// The only possible conflict is with the last working address,
|
||||
// so lets just check that.
|
||||
socket_addrs.into_iter().for_each(|addr| {
|
||||
if Some(&addr) != last_working_address.as_ref() {
|
||||
// Not already present, append
|
||||
sorted_socket_addrs.push(addr);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Err(err) => {
|
||||
if let Some(addr) = last_working_address {
|
||||
warn!(
|
||||
?err,
|
||||
"Unable to resolve '{origin}' to ip:port, using last known working address '{addr}'"
|
||||
);
|
||||
} else {
|
||||
warn!(?err, "Unable to resolve '{origin}' to ip:port.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if sorted_socket_addrs.is_empty() {
|
||||
warn!(
|
||||
"No replication addresses available, delaying replication operation for '{origin}'"
|
||||
);
|
||||
repl_interval.tick().await;
|
||||
continue;
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
Ok(task) = task_rx.recv() => {
|
||||
|
|
3925
server/core/static/external/htmx.1.9.12.js
vendored
3925
server/core/static/external/htmx.1.9.12.js
vendored
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
3
server/core/static/img/icons/phone-flip.svg
Normal file
3
server/core/static/img/icons/phone-flip.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-phone-flip" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M11 1H5a1 1 0 0 0-1 1v6a.5.5 0 0 1-1 0V2a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v6a.5.5 0 0 1-1 0V2a1 1 0 0 0-1-1m1 13a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-2a.5.5 0 0 0-1 0v2a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2v-2a.5.5 0 0 0-1 0zM1.713 7.954a.5.5 0 1 0-.419-.908c-.347.16-.654.348-.882.57C.184 7.842 0 8.139 0 8.5c0 .546.408.94.823 1.201.44.278 1.043.51 1.745.696C3.978 10.773 5.898 11 8 11q.148 0 .294-.002l-1.148 1.148a.5.5 0 0 0 .708.708l2-2a.5.5 0 0 0 0-.708l-2-2a.5.5 0 1 0-.708.708l1.145 1.144L8 10c-2.04 0-3.87-.221-5.174-.569-.656-.175-1.151-.374-1.47-.575C1.012 8.639 1 8.506 1 8.5c0-.003 0-.059.112-.17.115-.112.31-.242.6-.376Zm12.993-.908a.5.5 0 0 0-.419.908c.292.134.486.264.6.377.113.11.113.166.113.169s0 .065-.13.187c-.132.122-.352.26-.677.4-.645.28-1.596.523-2.763.687a.5.5 0 0 0 .14.99c1.212-.17 2.26-.43 3.02-.758.38-.164.713-.357.96-.587.246-.229.45-.537.45-.919 0-.362-.184-.66-.412-.883s-.535-.411-.882-.571M7.5 2a.5.5 0 0 0 0 1h1a.5.5 0 0 0 0-1z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
|
@ -40,3 +40,8 @@ try {
|
|||
});
|
||||
} catch (_error) {};
|
||||
|
||||
try {
|
||||
window.addEventListener("load", (event) => {
|
||||
asskey_login()
|
||||
});
|
||||
} catch (_error) {};
|
||||
|
|
|
@ -36,7 +36,7 @@ body {
|
|||
&.active {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
&:hover, &.active {
|
||||
background-color: var(--bs-gray-300);
|
||||
}
|
||||
|
@ -138,10 +138,15 @@ body {
|
|||
transition: none !important;
|
||||
}
|
||||
|
||||
.card > a {
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.oauth2-img {
|
||||
max-width: 150px;
|
||||
max-height: 150px;
|
||||
min-height: 150px;
|
||||
max-width: 100%;
|
||||
max-height: 90%;
|
||||
padding: 10px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.btn-tiny {
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
(% extends "base_htmx_with_nav.html" %)
|
||||
(% block title %)Apps(% endblock %)
|
||||
(% block title %)Applications(% endblock %)
|
||||
|
||||
(% block head %)
|
||||
(% endblock %)
|
||||
|
||||
(% block main %)
|
||||
(( apps_partial|safe ))
|
||||
(% endblock %)
|
||||
(% endblock %)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<main class="container-lg">
|
||||
<div>
|
||||
<h2>Applications list</h2>
|
||||
<h2>Applications</h2>
|
||||
</div>
|
||||
<hr />
|
||||
(% if apps.is_empty() %)
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
<div>
|
||||
<div id="totpInfo">
|
||||
(% if let Some(TotpInit with { secret, qr_code_svg, steps, digits, algo, uri }) = totp_init %)
|
||||
<div>((qr_code_svg|safe))
|
||||
</div>
|
||||
<div>((qr_code_svg|safe))</div>
|
||||
<code>((uri|safe))</code>
|
||||
|
||||
<h3>TOTP details</h3>
|
||||
|
@ -35,7 +34,8 @@
|
|||
name="checkTOTPCode"
|
||||
id="new-totp-check"
|
||||
value="(( totp_value ))"
|
||||
type="number"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
required
|
||||
/>
|
||||
|
||||
|
|
|
@ -33,9 +33,10 @@
|
|||
|
||||
(% match warning %)
|
||||
(% when CURegWarning::MfaRequired %)
|
||||
Multi-Factor Authentication is required for your account. Either
|
||||
add TOTP or remove your password in favour of passkeys to
|
||||
submit.
|
||||
Multi-Factor Authentication is required for your account. Delete
|
||||
the generated password and set up either a passkey (recommended)
|
||||
or password and two-factor authentication (TOTP) to save
|
||||
changes.
|
||||
(% when CURegWarning::PasskeyRequired %)
|
||||
Passkeys are required for your account.
|
||||
(% when CURegWarning::AttestedPasskeyRequired %)
|
||||
|
@ -44,10 +45,10 @@
|
|||
Attested Resident Keys are required for your account.
|
||||
(% when CURegWarning::WebauthnAttestationUnsatisfiable %)
|
||||
A webauthn attestation policy conflict has occurred and you will
|
||||
not be able to save your credentials
|
||||
not be able to save your credentials.
|
||||
(% when CURegWarning::Unsatisfiable %)
|
||||
An account policy conflict has occurred and you will not be able
|
||||
to save your credentials
|
||||
to save your credentials.
|
||||
(% endmatch %)
|
||||
|
||||
(% if is_danger %)
|
||||
|
@ -159,7 +160,8 @@
|
|||
<div class="mt-2 pt-2 border-top">
|
||||
<button class="btn btn-danger"
|
||||
hx-post="/ui/api/cu_cancel"
|
||||
hx-target="#main">Discard Changes</button>
|
||||
hx-boost="false"
|
||||
>Discard Changes</button>
|
||||
<span class="d-inline-block" tabindex="0"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="Unresolved warnings">
|
||||
|
|
19
server/core/templates/enrol_device.html
Normal file
19
server/core/templates/enrol_device.html
Normal file
|
@ -0,0 +1,19 @@
|
|||
(% extends "user_settings_partial_base.html" %)
|
||||
|
||||
(% block selected_setting_group %)
|
||||
Enrol Another Device
|
||||
(% endblock %)
|
||||
|
||||
(% block settings_window %)
|
||||
<p>You can enrol another device to your account by scanning the QR code or following the link below.</p>
|
||||
|
||||
<div id="intentInfo">
|
||||
<div>((qr_code_svg|safe))</div>
|
||||
<dl>
|
||||
<dt>URL</dt>
|
||||
<dd><code>((uri|safe))</code></dd>
|
||||
<dt>Secret</dt>
|
||||
<dd><code>(( secret ))</code></dd>
|
||||
</dl>
|
||||
</div>
|
||||
(% endblock %)
|
|
@ -23,7 +23,7 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<!-- BEGIN: to work better with password managers -->
|
||||
<!-- BEGIN: allows a password manager to autocomplete these fields in the BG. -->
|
||||
<input
|
||||
class="d-none"
|
||||
id="password"
|
||||
|
@ -36,7 +36,8 @@
|
|||
class="d-none"
|
||||
id="totp"
|
||||
name="totp"
|
||||
type="number"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
value=""
|
||||
/>
|
||||
|
|
|
@ -15,11 +15,15 @@
|
|||
src="/pkg/img/logo-square.svg?v=((crate::https::cache_buster::get_cache_buster_key()))"
|
||||
alt="(( display_ctx.domain_info.display_name() ))" class="kanidm_logo" />
|
||||
(% endif %)
|
||||
<h3>Kanidm</h3>
|
||||
<h3>(( display_ctx.domain_info.display_name() ))</h3>
|
||||
(% if let Some(reauth) = display_ctx.reauth %)
|
||||
<div class="alert alert-info" role="alert">
|
||||
Reauthenticating as (( reauth.username )) to access (( reauth.purpose ))
|
||||
</div>
|
||||
(% else if let Some(oauth2) = display_ctx.oauth2 %)
|
||||
<div class="alert alert-info" role="alert">
|
||||
Authenticate to access (( oauth2.client_name ))
|
||||
</div>
|
||||
(% endif %)
|
||||
<div>
|
||||
(% block logincontainer %)
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
<form id="login" action="/ui/login/mech_choose" method="post">
|
||||
<input type="hidden" id="mech" name="mech" value="(( mech.value ))" />
|
||||
<button
|
||||
(% if mech.autofocus %)autofocus(% endif %)
|
||||
type="submit"
|
||||
class="btn btn-dark"
|
||||
>(( mech.name ))</button>
|
||||
|
|
|
@ -1,21 +1,35 @@
|
|||
(% extends "login_base.html" %)
|
||||
|
||||
(% block logincontainer %)
|
||||
<label for="totp" class="form-label">TOTP</label>
|
||||
<label for="totp" class="form-label">Two-factor authentication code</label>
|
||||
(% match errors %)
|
||||
(% when LoginTotpError::Syntax %)
|
||||
<span class="error">Invalid Value</span>
|
||||
<span class="error">TOTP must only consist of numbers</span>
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<p>Invalid Value</p>
|
||||
<p>Code must only consist of numbers, please try again.</p>
|
||||
</div>
|
||||
(% when LoginTotpError::None %)
|
||||
(% endmatch %)
|
||||
<form id="login" action="/ui/login/totp" method="post">
|
||||
<div class="input-group mb-3">
|
||||
<!-- BEGIN: allows a password manager to autocomplete these fields in the BG. -->
|
||||
<input
|
||||
class="d-none"
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
value=""
|
||||
/>
|
||||
<!-- END -->
|
||||
|
||||
<input
|
||||
autofocus=true
|
||||
class="autofocus form-control"
|
||||
id="totp"
|
||||
name="totp"
|
||||
type="number"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
value="(( totp ))"
|
||||
required=true
|
||||
|
|
|
@ -16,16 +16,14 @@
|
|||
(% if passkey %)
|
||||
<form id="cred-form" action="/ui/login/passkey" method="POST">
|
||||
<input hidden="hidden" name="cred" id="cred">
|
||||
|
||||
<button hx-disable type="button" class="btn btn-dark"
|
||||
<button hx-disable type="button" autofocus class="btn btn-dark"
|
||||
id="start-passkey-button">Use Passkey</button>
|
||||
</form>
|
||||
(% else %)
|
||||
<form id="cred-form" action="/ui/login/seckey" method="POST">
|
||||
<input hidden="hidden" name="cred" id="cred">
|
||||
|
||||
<button type="button" class="btn btn-dark" id="start-seckey-button"
|
||||
>Use Security Key</button>
|
||||
<button hx-disable type="button" autofocus class="btn btn-dark"
|
||||
id="start-seckey-button">Use Security Key</button>
|
||||
</form>
|
||||
(% endif %)
|
||||
</div>
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
<ul class="navbar-nav me-auto mb-2 mb-md-0">
|
||||
<li>
|
||||
<a class="nav-link" href=((Urls::Apps))>
|
||||
<span data-feather="file"></span>Apps</a>
|
||||
<span data-feather="file"></span>Applications</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="nav-link" href=((Urls::Profile))>
|
||||
|
|
|
@ -8,9 +8,10 @@
|
|||
(% block body %)
|
||||
<h2>Error</h2>
|
||||
<main id="main">
|
||||
<p>An unrecoverable error occured. Please contact your administrator with the details below.</p>
|
||||
<p>Error Code: (( err_code ))</p>
|
||||
<p>An unrecoverable error occurred. Please contact your administrator with the details below.</p>
|
||||
<p>Operation ID: (( operation_id ))</p>
|
||||
<p>Error Code: (( err_code ))</p>
|
||||
<a href=((Urls::Ui))>Return</a>
|
||||
</main>
|
||||
(% endblock %)
|
||||
|
||||
|
|
|
@ -20,6 +20,8 @@
|
|||
ProfileMenuItems::UserProfile, "person") %)
|
||||
(% call side_menu_item("Credentials", (Urls::UpdateCredentials),
|
||||
ProfileMenuItems::Credentials, "shield-lock") %)
|
||||
(% call side_menu_item("Enrol Device", (Urls::EnrolDevice),
|
||||
ProfileMenuItems::EnrolDevice, "phone-flip") %)
|
||||
</ul>
|
||||
<div id="settings-window" class="flex-grow-1 ps-sm-4 ps-md-5 pt-sm-0 pt-4">
|
||||
<div>
|
||||
|
|
|
@ -787,8 +787,14 @@ async fn kanidm_main(
|
|||
|
||||
sctx.tls_acceptor_reload().await;
|
||||
|
||||
// Systemd freaks out if you send the ready state too fast after the
|
||||
// reload state and can kill Kanidmd as a result.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let _ = sd_notify::notify(true, &[sd_notify::NotifyState::Ready]);
|
||||
|
||||
info!("Reload complete");
|
||||
}
|
||||
Some(()) = async move {
|
||||
let sigterm = tokio::signal::unix::SignalKind::user_defined1();
|
||||
|
|
|
@ -470,7 +470,7 @@ lazy_static! {
|
|||
EntryClass::AccessControlModify,
|
||||
EntryClass::AccessControlSearch
|
||||
],
|
||||
name: "idm_acp_group_entry_managed_by",
|
||||
name: "idm_acp_group_entry_managed_by_modify",
|
||||
uuid: UUID_IDM_ACP_GROUP_ENTRY_MANAGED_BY_MODIFY,
|
||||
description: "Builtin IDM Control for allowing entry_managed_by to be set on group entries",
|
||||
receiver: BuiltinAcpReceiver::Group(vec![UUID_IDM_ACCESS_CONTROL_ADMINS]),
|
||||
|
@ -918,7 +918,7 @@ lazy_static! {
|
|||
EntryClass::AccessControlModify,
|
||||
EntryClass::AccessControlSearch
|
||||
],
|
||||
name: "idm_acp_hp_oauth2_manage_priv",
|
||||
name: "idm_acp_oauth2_manage",
|
||||
uuid: UUID_IDM_ACP_OAUTH2_MANAGE_V1,
|
||||
description: "Builtin IDM Control for managing OAuth2 resource server integrations.",
|
||||
receiver: BuiltinAcpReceiver::Group(vec![UUID_IDM_OAUTH2_ADMINS]),
|
||||
|
@ -1315,7 +1315,7 @@ lazy_static! {
|
|||
EntryClass::AccessControlProfile,
|
||||
EntryClass::AccessControlModify,
|
||||
],
|
||||
name: "idm_people_self_acp_write_mail",
|
||||
name: "idm_acp_people_self_write_mail",
|
||||
uuid: UUID_IDM_ACP_PEOPLE_SELF_WRITE_MAIL,
|
||||
description: "Builtin IDM Control for self write of mail for people accounts.",
|
||||
receiver: BuiltinAcpReceiver::Group(vec![UUID_IDM_PEOPLE_SELF_MAIL_WRITE]),
|
||||
|
@ -1570,7 +1570,7 @@ lazy_static! {
|
|||
|
||||
lazy_static! {
|
||||
pub static ref IDM_ACP_ACCOUNT_SELF_WRITE_V1: BuiltinAcp = BuiltinAcp {
|
||||
name: "idm_acp_self_account_write",
|
||||
name: "idm_acp_account_self_write",
|
||||
uuid: UUID_IDM_ACP_ACCOUNT_SELF_WRITE_V1,
|
||||
description: "Builtin IDM Control for self write - required for accounts to update their own session state.",
|
||||
classes: vec![
|
||||
|
@ -1974,7 +1974,7 @@ lazy_static! {
|
|||
EntryClass::AccessControlProfile,
|
||||
EntryClass::AccessControlModify,
|
||||
],
|
||||
name: "idm_acp_people_account_policy_manage",
|
||||
name: "idm_acp_people_manage",
|
||||
uuid: UUID_IDM_ACP_PEOPLE_MANAGE_V1,
|
||||
description: "Builtin IDM Control for management of peoples non sensitive attributes.",
|
||||
receiver: BuiltinAcpReceiver::Group(vec![UUID_IDM_PEOPLE_ADMINS]),
|
||||
|
@ -2301,7 +2301,7 @@ lazy_static! {
|
|||
EntryClass::AccessControlModify,
|
||||
EntryClass::AccessControlSearch
|
||||
],
|
||||
name: "idm_acp_service_account_entry_managed_by",
|
||||
name: "idm_acp_service_account_entry_managed_by_modify",
|
||||
uuid: UUID_IDM_ACP_SERVICE_ACCOUNT_ENTRY_MANAGED_BY_MODIFY,
|
||||
description:
|
||||
"Builtin IDM Control for allowing entry_managed_by to be set on service account entries",
|
||||
|
|
|
@ -73,6 +73,7 @@ pub const DOMAIN_LEVEL_8: DomainVersion = 8;
|
|||
/// Domain Level introduced with 1.5.0.
|
||||
/// Deprecated as of 1.7.0
|
||||
pub const DOMAIN_LEVEL_9: DomainVersion = 9;
|
||||
pub const PATCH_LEVEL_2: u32 = 2;
|
||||
|
||||
// The minimum level that we can re-migrate from.
|
||||
// This should be DOMAIN_TGT_LEVEL minus 2
|
||||
|
@ -85,7 +86,7 @@ pub const DOMAIN_PREVIOUS_TGT_LEVEL: DomainVersion = DOMAIN_LEVEL_7;
|
|||
// the NEXT level that users will upgrade too.
|
||||
pub const DOMAIN_TGT_LEVEL: DomainVersion = DOMAIN_LEVEL_8;
|
||||
// The current patch level if any out of band fixes are required.
|
||||
pub const DOMAIN_TGT_PATCH_LEVEL: u32 = PATCH_LEVEL_1;
|
||||
pub const DOMAIN_TGT_PATCH_LEVEL: u32 = PATCH_LEVEL_2;
|
||||
// The target domain functional level for the SUBSEQUENT release/dev cycle.
|
||||
pub const DOMAIN_TGT_NEXT_LEVEL: DomainVersion = DOMAIN_LEVEL_9;
|
||||
// The maximum supported domain functional level
|
||||
|
|
|
@ -1119,6 +1119,8 @@ pub static ref SCHEMA_CLASS_DOMAIN_INFO_DL6: SchemaClass = SchemaClass {
|
|||
Attribute::PrivateCookieKey,
|
||||
Attribute::FernetPrivateKeyStr,
|
||||
Attribute::Es256PrivateKeyDer,
|
||||
Attribute::PatchLevel,
|
||||
Attribute::DomainDevelopmentTaint,
|
||||
],
|
||||
systemmust: vec![
|
||||
Attribute::Name,
|
||||
|
|
|
@ -2944,10 +2944,19 @@ impl<VALID, STATE> Entry<VALID, STATE> {
|
|||
match schema.is_multivalue(k) {
|
||||
Ok(r) => {
|
||||
// As this is single value, purge then present to maintain this
|
||||
// invariant. The other situation we purge is within schema with
|
||||
// the system types where we need to be able to express REMOVAL
|
||||
// of attributes, thus we need the purge.
|
||||
if !r || *k == Attribute::SystemMust || *k == Attribute::SystemMay {
|
||||
// invariant.
|
||||
if !r ||
|
||||
// we need to be able to express REMOVAL of attributes, so we
|
||||
// purge here for migrations of certain system attributes.
|
||||
*k == Attribute::AcpReceiverGroup ||
|
||||
*k == Attribute::AcpCreateAttr ||
|
||||
*k == Attribute::AcpCreateClass ||
|
||||
*k == Attribute::AcpModifyPresentAttr ||
|
||||
*k == Attribute::AcpModifyRemovedAttr ||
|
||||
*k == Attribute::AcpModifyClass ||
|
||||
*k == Attribute::SystemMust ||
|
||||
*k == Attribute::SystemMay
|
||||
{
|
||||
mods.push_mod(Modify::Purged(k.clone()));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -388,7 +388,7 @@ mod tests {
|
|||
assert!(cr.is_ok());
|
||||
}
|
||||
|
||||
// Tests it is not possible to create an applicatin without the linked group attribute
|
||||
// Tests it is not possible to create an application without the linked group attribute
|
||||
#[idm_test]
|
||||
async fn test_idm_application_no_linked_group(
|
||||
idms: &IdmServer,
|
||||
|
@ -418,7 +418,7 @@ mod tests {
|
|||
assert!(cr.is_err());
|
||||
}
|
||||
|
||||
// Tests creating an applicatin with a real linked group attribute
|
||||
// Tests creating an application with a real linked group attribute
|
||||
#[idm_test]
|
||||
async fn test_idm_application_linked_group(
|
||||
idms: &IdmServer,
|
||||
|
|
|
@ -1383,7 +1383,17 @@ impl AuthSession {
|
|||
| AuthSessionState::InProgress(CredHandler::PasswordSecurityKey { .. })
|
||||
| AuthSessionState::InProgress(CredHandler::Passkey { .. })
|
||||
| AuthSessionState::InProgress(CredHandler::AttestedPasskey { .. }) => Ok(None),
|
||||
_ => Err(OperationError::InvalidState),
|
||||
|
||||
AuthSessionState::Init(_) => {
|
||||
debug!(
|
||||
"Request for credential uuid invalid as auth session state not yet initialised"
|
||||
);
|
||||
Err(OperationError::AU0001InvalidState)
|
||||
}
|
||||
AuthSessionState::Success | AuthSessionState::Denied(_) => {
|
||||
debug!("Request for credential uuid invalid as auth session state has progressed");
|
||||
Err(OperationError::AU0001InvalidState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1485,13 +1495,13 @@ impl AuthSession {
|
|||
|
||||
let jwt = Jws::into_json(&uat).map_err(|e| {
|
||||
admin_error!(?e, "Failed to serialise into Jws");
|
||||
OperationError::InvalidState
|
||||
OperationError::AU0002JwsSerialisation
|
||||
})?;
|
||||
|
||||
// Now encrypt and prepare the token for return to the client.
|
||||
let token = self.key_object.jws_es256_sign(&jwt, time).map_err(|e| {
|
||||
admin_error!(?e, "Failed to sign UserAuthToken to Jwt");
|
||||
OperationError::InvalidState
|
||||
OperationError::AU0003JwsSignature
|
||||
})?;
|
||||
|
||||
(
|
||||
|
@ -1586,7 +1596,7 @@ impl AuthSession {
|
|||
let uat = self
|
||||
.account
|
||||
.to_userauthtoken(session_id, scope, time, &self.account_policy)
|
||||
.ok_or(OperationError::InvalidState)?;
|
||||
.ok_or(OperationError::AU0004UserAuthTokenInvalid)?;
|
||||
|
||||
// Queue the session info write.
|
||||
// This is dependent on the type of authentication factors
|
||||
|
@ -1619,7 +1629,7 @@ impl AuthSession {
|
|||
.map_err(|e| {
|
||||
debug!(?e, "queue failure");
|
||||
admin_error!("unable to queue failing authentication as the session will not validate ... ");
|
||||
OperationError::InvalidState
|
||||
OperationError::AU0005DelayedProcessFailure
|
||||
})?;
|
||||
}
|
||||
};
|
||||
|
@ -1635,7 +1645,7 @@ impl AuthSession {
|
|||
let scope = match auth_type {
|
||||
AuthType::Anonymous | AuthType::GeneratedPassword => {
|
||||
error!("AuthType used in Reauth is not valid for session re-issuance. Rejecting");
|
||||
return Err(OperationError::InvalidState);
|
||||
return Err(OperationError::AU0006CredentialMayNotReauthenticate);
|
||||
}
|
||||
AuthType::Password
|
||||
| AuthType::PasswordTotp
|
||||
|
@ -1654,7 +1664,7 @@ impl AuthSession {
|
|||
time,
|
||||
&self.account_policy,
|
||||
)
|
||||
.ok_or(OperationError::InvalidState)?;
|
||||
.ok_or(OperationError::AU0007UserAuthTokenInvalid)?;
|
||||
|
||||
Ok(uat)
|
||||
}
|
||||
|
|
|
@ -31,6 +31,8 @@ use compact_jwt::jwe::JweBuilder;
|
|||
|
||||
use super::accountpolicy::ResolvedAccountPolicy;
|
||||
|
||||
// A user can take up to 15 minutes to update their credentials before we automatically
|
||||
// cancel on them.
|
||||
const MAXIMUM_CRED_UPDATE_TTL: Duration = Duration::from_secs(900);
|
||||
// Minimum 5 minutes.
|
||||
const MINIMUM_INTENT_TTL: Duration = Duration::from_secs(300);
|
||||
|
@ -1126,27 +1128,24 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
|
|||
};
|
||||
(*max_ttl, *perms)
|
||||
}
|
||||
Some(IntentTokenState::Valid { max_ttl, perms }) => {
|
||||
// Check the TTL
|
||||
if current_time >= *max_ttl {
|
||||
trace!(?current_time, ?max_ttl);
|
||||
security_info!(%account.uuid, "intent has expired");
|
||||
return Err(OperationError::SessionExpired);
|
||||
} else {
|
||||
security_info!(
|
||||
%entry,
|
||||
%account.uuid,
|
||||
"Initiating Credential Update Session",
|
||||
);
|
||||
(*max_ttl, *perms)
|
||||
}
|
||||
}
|
||||
Some(IntentTokenState::Valid { max_ttl, perms }) => (*max_ttl, *perms),
|
||||
None => {
|
||||
admin_error!("Corruption may have occurred - index yielded an entry for intent_id, but the entry does not contain that intent_id");
|
||||
return Err(OperationError::InvalidState);
|
||||
}
|
||||
};
|
||||
|
||||
if current_time >= max_ttl {
|
||||
security_info!(?current_time, ?max_ttl, %account.uuid, "intent has expired");
|
||||
return Err(OperationError::SessionExpired);
|
||||
}
|
||||
|
||||
security_info!(
|
||||
%entry,
|
||||
%account.uuid,
|
||||
"Initiating Credential Update Session",
|
||||
);
|
||||
|
||||
// To prevent issues with repl, we need to associate this cred update session id, with
|
||||
// this intent token id.
|
||||
|
||||
|
@ -1312,8 +1311,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
|
|||
"Session is unable to commit due to: {}",
|
||||
commit_failure_reasons
|
||||
);
|
||||
// TODO: perhaps it would be more helpful to add a new operation error that describes what the issue is
|
||||
return Err(OperationError::InvalidState);
|
||||
return Err(OperationError::CU0004SessionInconsistent);
|
||||
}
|
||||
|
||||
// Setup mods for the various bits. We always assert an *exact* state.
|
||||
|
@ -1340,7 +1338,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
|
|||
}) => {
|
||||
if *session_id != session_token.sessionid {
|
||||
security_info!("Session originated from an intent token, but the intent token has initiated a conflicting second update session. Refusing to commit changes.");
|
||||
return Err(OperationError::InvalidState);
|
||||
return Err(OperationError::CU0005IntentTokenConflict);
|
||||
} else {
|
||||
*max_ttl
|
||||
}
|
||||
|
@ -1352,7 +1350,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
|
|||
})
|
||||
| None => {
|
||||
security_info!("Session originated from an intent token, but the intent token has transitioned to an invalid state. Refusing to commit changes.");
|
||||
return Err(OperationError::InvalidState);
|
||||
return Err(OperationError::CU0006IntentTokenInvalidated);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -1848,11 +1846,9 @@ impl<'a> IdmServerCredUpdateTransaction<'a> {
|
|||
return Err(OperationError::AccessDenied);
|
||||
};
|
||||
|
||||
// Is there something else in progress?
|
||||
// Or should this just cancel it ....
|
||||
// Is there something else in progress? Cancel it if so.
|
||||
if !matches!(session.mfaregstate, MfaRegState::None) {
|
||||
admin_info!("Invalid TOTP state, another update is in progress");
|
||||
return Err(OperationError::InvalidState);
|
||||
debug!("Clearing incomplete mfareg");
|
||||
}
|
||||
|
||||
// Generate the TOTP.
|
||||
|
@ -2017,7 +2013,7 @@ impl<'a> IdmServerCredUpdateTransaction<'a> {
|
|||
) -> Result<CredentialUpdateSessionStatus, OperationError> {
|
||||
let session_handle = self.get_current_session(cust, ct)?;
|
||||
let mut session = session_handle.try_lock().map_err(|_| {
|
||||
admin_error!("Session already locked, unable to proceed.");
|
||||
error!("Session already locked, unable to proceed.");
|
||||
OperationError::InvalidState
|
||||
})?;
|
||||
trace!(?session);
|
||||
|
@ -2035,13 +2031,13 @@ impl<'a> IdmServerCredUpdateTransaction<'a> {
|
|||
.primary
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
admin_error!("Tried to add backup codes, but no primary credential stub exists");
|
||||
error!("Tried to add backup codes, but no primary credential stub exists");
|
||||
OperationError::InvalidState
|
||||
})
|
||||
.and_then(|cred|
|
||||
cred.update_backup_code(BackupCodes::new(codes.clone()))
|
||||
.map_err(|_| {
|
||||
admin_error!("Tried to add backup codes, but MFA is not enabled on this credential yet");
|
||||
error!("Tried to add backup codes, but MFA is not enabled on this credential yet");
|
||||
OperationError::InvalidState
|
||||
})
|
||||
)
|
||||
|
@ -2134,8 +2130,7 @@ impl<'a> IdmServerCredUpdateTransaction<'a> {
|
|||
};
|
||||
|
||||
if !matches!(session.mfaregstate, MfaRegState::None) {
|
||||
admin_info!("Invalid Passkey Init state, another update is in progress");
|
||||
return Err(OperationError::InvalidState);
|
||||
debug!("Clearing incomplete mfareg");
|
||||
}
|
||||
|
||||
let (ccr, pk_reg) = self
|
||||
|
@ -2248,8 +2243,7 @@ impl<'a> IdmServerCredUpdateTransaction<'a> {
|
|||
};
|
||||
|
||||
if !matches!(session.mfaregstate, MfaRegState::None) {
|
||||
info!("Invalid Attested Passkey Init state, another update is in progress");
|
||||
return Err(OperationError::InvalidState);
|
||||
debug!("Cancelling abandoned mfareg");
|
||||
}
|
||||
|
||||
let att_ca_list = session
|
||||
|
@ -2460,17 +2454,17 @@ impl<'a> IdmServerCredUpdateTransaction<'a> {
|
|||
|
||||
// Check the label.
|
||||
if !LABEL_RE.is_match(&label) {
|
||||
error!("SSH Pubilc Key label invalid");
|
||||
error!("SSH Public Key label invalid");
|
||||
return Err(OperationError::InvalidLabel);
|
||||
}
|
||||
|
||||
if session.sshkeys.contains_key(&label) {
|
||||
error!("SSH Pubilc Key label duplicate");
|
||||
error!("SSH Public Key label duplicate");
|
||||
return Err(OperationError::DuplicateLabel);
|
||||
}
|
||||
|
||||
if session.sshkeys.values().any(|sk| *sk == sshpubkey) {
|
||||
error!("SSH Pubilc Key duplicate");
|
||||
error!("SSH Public Key duplicate");
|
||||
return Err(OperationError::DuplicateKey);
|
||||
}
|
||||
|
||||
|
@ -2692,7 +2686,7 @@ mod tests {
|
|||
) -> (CredentialUpdateSessionToken, CredentialUpdateSessionStatus) {
|
||||
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
|
||||
|
||||
// Remove the default all persons policy, it interfers with our test.
|
||||
// Remove the default all persons policy, it interferes with our test.
|
||||
let modlist = ModifyList::new_purge(Attribute::CredentialTypeMinimum);
|
||||
idms_prox_write
|
||||
.qs_write
|
||||
|
@ -3067,7 +3061,7 @@ mod tests {
|
|||
|
||||
let cutxn = idms.cred_update_transaction().await.unwrap();
|
||||
|
||||
// Now fake going back in time .... allows the tokne to decrypt, but the session
|
||||
// Now fake going back in time .... allows the token to decrypt, but the session
|
||||
// is gone anyway!
|
||||
let c_status = cutxn
|
||||
.credential_update_status(&cust, ct)
|
||||
|
|
|
@ -46,7 +46,7 @@ use serde_with::{formats, serde_as};
|
|||
use time::OffsetDateTime;
|
||||
use tracing::trace;
|
||||
use uri::{OAUTH2_TOKEN_INTROSPECT_ENDPOINT, OAUTH2_TOKEN_REVOKE_ENDPOINT};
|
||||
use url::{Origin, Url};
|
||||
use url::{Host, Origin, Url};
|
||||
|
||||
use crate::idm::account::Account;
|
||||
use crate::idm::server::{
|
||||
|
@ -208,6 +208,12 @@ impl fmt::Display for Oauth2TokenType {
|
|||
|
||||
#[derive(Debug)]
|
||||
pub enum AuthoriseResponse {
|
||||
AuthenticationRequired {
|
||||
// A pretty-name of the client
|
||||
client_name: String,
|
||||
// A username hint, if any
|
||||
login_hint: Option<String>,
|
||||
},
|
||||
ConsentRequested {
|
||||
// A pretty-name of the client
|
||||
client_name: String,
|
||||
|
@ -245,9 +251,21 @@ enum OauthRSType {
|
|||
},
|
||||
}
|
||||
|
||||
impl OauthRSType {
|
||||
/// We only allow localhost redirects if PKCE is enabled/required
|
||||
fn allow_localhost_redirect(&self) -> bool {
|
||||
match self {
|
||||
OauthRSType::Basic { .. } => false,
|
||||
OauthRSType::Public {
|
||||
allow_localhost_redirect,
|
||||
} => *allow_localhost_redirect,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for OauthRSType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
let mut ds = f.debug_struct("Oauth2RSType");
|
||||
let mut ds = f.debug_struct("OauthRSType");
|
||||
match self {
|
||||
OauthRSType::Basic { enable_pkce, .. } => {
|
||||
ds.field("type", &"basic").field("pkce", enable_pkce)
|
||||
|
@ -1775,7 +1793,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
|
|||
#[instrument(level = "debug", skip_all)]
|
||||
pub fn check_oauth2_authorisation(
|
||||
&self,
|
||||
ident: &Identity,
|
||||
maybe_ident: Option<&Identity>,
|
||||
auth_req: &AuthorisationRequest,
|
||||
ct: Duration,
|
||||
) -> Result<AuthoriseResponse, Oauth2Error> {
|
||||
|
@ -1813,67 +1831,51 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
|
|||
Oauth2Error::InvalidClientId
|
||||
})?;
|
||||
|
||||
let allow_localhost_redirect = match &o2rs.type_ {
|
||||
OauthRSType::Basic { .. } => false,
|
||||
OauthRSType::Public {
|
||||
allow_localhost_redirect,
|
||||
} => *allow_localhost_redirect,
|
||||
};
|
||||
// redirect_uri must be part of the client_id origins, unless the client is public and then it MAY
|
||||
// be a loopback address exempting it from this check and enforcement and we can carry on safely.
|
||||
if o2rs.type_.allow_localhost_redirect() && check_is_loopback(&auth_req.redirect_uri) {
|
||||
debug!("Loopback redirect_uri detected, allowing for localhost");
|
||||
} else {
|
||||
// The legacy origin match is in use.
|
||||
let origin_uri_matched =
|
||||
!o2rs.strict_redirect_uri && o2rs.origins.contains(&auth_req.redirect_uri.origin());
|
||||
// Strict uri validation is in use.
|
||||
let strict_redirect_uri_matched =
|
||||
o2rs.strict_redirect_uri && o2rs.redirect_uris.contains(&auth_req.redirect_uri);
|
||||
// Allow opaque origins such as app uris.
|
||||
let opaque_origin_matched = o2rs.opaque_origins.contains(&auth_req.redirect_uri);
|
||||
|
||||
let localhost_redirect = auth_req
|
||||
.redirect_uri
|
||||
.domain()
|
||||
.map(|domain| domain == "localhost")
|
||||
.unwrap_or_default();
|
||||
|
||||
// Strict uri validation is in use.
|
||||
let strict_redirect_uri_matched =
|
||||
o2rs.strict_redirect_uri && o2rs.redirect_uris.contains(&auth_req.redirect_uri);
|
||||
// The legacy origin match is in use.
|
||||
let origin_uri_matched =
|
||||
!o2rs.strict_redirect_uri && o2rs.origins.contains(&auth_req.redirect_uri.origin());
|
||||
// Allow opaque origins such as app uris.
|
||||
let opaque_origin_matched = o2rs.opaque_origins.contains(&auth_req.redirect_uri);
|
||||
// redirect_uri must be part of the client_id origin, unless the client is public and then it MAY
|
||||
// be localhost exempting it from this check and enforcement.
|
||||
let localhost_redirect_matched = allow_localhost_redirect && localhost_redirect;
|
||||
|
||||
// At least one of these conditions must hold true to proceed.
|
||||
if !(strict_redirect_uri_matched
|
||||
|| origin_uri_matched
|
||||
|| opaque_origin_matched
|
||||
|| localhost_redirect_matched)
|
||||
{
|
||||
if o2rs.strict_redirect_uri {
|
||||
warn!(
|
||||
"Invalid OAuth2 redirect_uri (must be an exact match to a redirect-url) - got {}",
|
||||
auth_req.redirect_uri.as_str()
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"Invalid OAuth2 redirect_uri (must be related to origin) - got {:?}",
|
||||
auth_req.redirect_uri.origin()
|
||||
);
|
||||
// At least one of these conditions must hold true to proceed.
|
||||
if !(strict_redirect_uri_matched || origin_uri_matched || opaque_origin_matched) {
|
||||
if o2rs.strict_redirect_uri {
|
||||
warn!(
|
||||
"Invalid OAuth2 redirect_uri (must be an exact match to a redirect-url) - got {}",
|
||||
auth_req.redirect_uri.as_str()
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"Invalid OAuth2 redirect_uri (must be related to origin) - got {:?}",
|
||||
auth_req.redirect_uri.origin()
|
||||
);
|
||||
}
|
||||
return Err(Oauth2Error::InvalidOrigin);
|
||||
}
|
||||
// We have to specifically match on http here because non-http origins may be exempt from this
|
||||
// enforcement.
|
||||
if (o2rs.origin_https_required && auth_req.redirect_uri.scheme() != "https")
|
||||
&& !opaque_origin_matched
|
||||
{
|
||||
admin_warn!(
|
||||
"Invalid OAuth2 redirect_uri scheme (must be https for secure origin) - got {}",
|
||||
auth_req.redirect_uri.to_string()
|
||||
);
|
||||
return Err(Oauth2Error::InvalidOrigin);
|
||||
}
|
||||
return Err(Oauth2Error::InvalidOrigin);
|
||||
}
|
||||
|
||||
// We have to specifically match on http here because non-http origins may be exempt from this
|
||||
// enforcement.
|
||||
if !localhost_redirect
|
||||
&& o2rs.origin_https_required
|
||||
&& auth_req.redirect_uri.scheme() == "http"
|
||||
{
|
||||
admin_warn!(
|
||||
"Invalid OAuth2 redirect_uri (must be https for secure origin) - got {:?}",
|
||||
auth_req.redirect_uri.scheme()
|
||||
);
|
||||
return Err(Oauth2Error::InvalidOrigin);
|
||||
}
|
||||
|
||||
let code_challenge = if let Some(pkce_request) = &auth_req.pkce_request {
|
||||
if !o2rs.require_pkce() {
|
||||
security_info!(?o2rs.name, "Insecure rs configuration - pkce is not enforced, but rs is requesting it!");
|
||||
security_info!(?o2rs.name, "Insecure OAuth2 client configuration - PKCE is not enforced, but client is requesting it!");
|
||||
}
|
||||
// CodeChallengeMethod must be S256
|
||||
if pkce_request.code_challenge_method != CodeChallengeMethod::S256 {
|
||||
|
@ -1885,10 +1887,15 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
|
|||
security_error!(?o2rs.name, "No PKCE code challenge was provided with client in enforced PKCE mode.");
|
||||
return Err(Oauth2Error::InvalidRequest);
|
||||
} else {
|
||||
security_info!(?o2rs.name, "Insecure client configuration - pkce is not enforced.");
|
||||
security_info!(?o2rs.name, "Insecure client configuration - PKCE is not enforced.");
|
||||
None
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// By this point, we have validated the majority of the security related
|
||||
// parameters of the request. We can now inspect the identity and decide
|
||||
// if we should ask the user to re-authenticate and proceed.
|
||||
|
||||
// TODO: https://openid.net/specs/openid-connect-basic-1_0.html#RequestParameters
|
||||
// Are we going to provide the functions for these? Most of these can be "later".
|
||||
// IF CHANGED: Update OidcDiscoveryResponse!!!
|
||||
|
@ -1908,10 +1915,16 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
|
|||
|
||||
// TODO: id_token_hint - a past token which can be used as a hint.
|
||||
|
||||
// NOTE: login_hint is handled in the UI code, not here.
|
||||
let Some(ident) = maybe_ident else {
|
||||
debug!("No identity available, assume authentication required");
|
||||
return Ok(AuthoriseResponse::AuthenticationRequired {
|
||||
client_name: o2rs.displayname.clone(),
|
||||
login_hint: auth_req.oidc_ext.login_hint.clone(),
|
||||
});
|
||||
};
|
||||
|
||||
let Some(account_uuid) = ident.get_uuid() else {
|
||||
error!("consent request ident does not have a valid uuid, unable to proceed");
|
||||
error!("Consent request ident does not have a valid UUID, unable to proceed");
|
||||
return Err(Oauth2Error::InvalidRequest);
|
||||
};
|
||||
|
||||
|
@ -2871,6 +2884,23 @@ fn parse_user_code(val: &str) -> Result<u32, Oauth2Error> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Check if a host is local (loopback or localhost)
|
||||
fn host_is_local(host: &Host<&str>) -> bool {
|
||||
match host {
|
||||
Host::Ipv4(ip) => ip.is_loopback(),
|
||||
Host::Ipv6(ip) => ip.is_loopback(),
|
||||
Host::Domain(domain) => *domain == "localhost",
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure that the redirect URI is a loopback/localhost address
|
||||
fn check_is_loopback(redirect_uri: &Url) -> bool {
|
||||
redirect_uri.host().map_or(false, |host| {
|
||||
// Check if the host is a loopback/localhost address.
|
||||
host_is_local(&host)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
|
@ -2889,7 +2919,7 @@ mod tests {
|
|||
use openssl::sha;
|
||||
|
||||
use crate::idm::accountpolicy::ResolvedAccountPolicy;
|
||||
use crate::idm::oauth2::{AuthoriseResponse, Oauth2Error};
|
||||
use crate::idm::oauth2::{host_is_local, AuthoriseResponse, Oauth2Error, OauthRSType};
|
||||
use crate::idm::server::{IdmServer, IdmServerTransaction};
|
||||
use crate::prelude::*;
|
||||
use crate::value::{AuthType, OauthClaimMapJoin, SessionState};
|
||||
|
@ -2935,11 +2965,12 @@ mod tests {
|
|||
scope: $scope,
|
||||
nonce: Some("abcdef".to_string()),
|
||||
oidc_ext: Default::default(),
|
||||
max_age: None,
|
||||
unknown_keys: Default::default(),
|
||||
};
|
||||
|
||||
$idms_prox_read
|
||||
.check_oauth2_authorisation($ident, &auth_req, $ct)
|
||||
.check_oauth2_authorisation(Some($ident), &auth_req, $ct)
|
||||
.expect("OAuth2 authorisation failed")
|
||||
}};
|
||||
}
|
||||
|
@ -3426,12 +3457,13 @@ mod tests {
|
|||
scope: OAUTH2_SCOPE_OPENID.to_string(),
|
||||
nonce: None,
|
||||
oidc_ext: Default::default(),
|
||||
max_age: None,
|
||||
unknown_keys: Default::default(),
|
||||
};
|
||||
|
||||
assert!(
|
||||
idms_prox_read
|
||||
.check_oauth2_authorisation(&ident, &auth_req, ct)
|
||||
.check_oauth2_authorisation(Some(&ident), &auth_req, ct)
|
||||
.unwrap_err()
|
||||
== Oauth2Error::UnsupportedResponseType
|
||||
);
|
||||
|
@ -3446,12 +3478,13 @@ mod tests {
|
|||
scope: OAUTH2_SCOPE_OPENID.to_string(),
|
||||
nonce: None,
|
||||
oidc_ext: Default::default(),
|
||||
max_age: None,
|
||||
unknown_keys: Default::default(),
|
||||
};
|
||||
|
||||
assert!(
|
||||
idms_prox_read
|
||||
.check_oauth2_authorisation(&ident, &auth_req, ct)
|
||||
.check_oauth2_authorisation(Some(&ident), &auth_req, ct)
|
||||
.unwrap_err()
|
||||
== Oauth2Error::InvalidRequest
|
||||
);
|
||||
|
@ -3466,12 +3499,13 @@ mod tests {
|
|||
scope: OAUTH2_SCOPE_OPENID.to_string(),
|
||||
nonce: None,
|
||||
oidc_ext: Default::default(),
|
||||
max_age: None,
|
||||
unknown_keys: Default::default(),
|
||||
};
|
||||
|
||||
assert!(
|
||||
idms_prox_read
|
||||
.check_oauth2_authorisation(&ident, &auth_req, ct)
|
||||
.check_oauth2_authorisation(Some(&ident), &auth_req, ct)
|
||||
.unwrap_err()
|
||||
== Oauth2Error::InvalidClientId
|
||||
);
|
||||
|
@ -3486,12 +3520,13 @@ mod tests {
|
|||
scope: OAUTH2_SCOPE_OPENID.to_string(),
|
||||
nonce: None,
|
||||
oidc_ext: Default::default(),
|
||||
max_age: None,
|
||||
unknown_keys: Default::default(),
|
||||
};
|
||||
|
||||
assert!(
|
||||
idms_prox_read
|
||||
.check_oauth2_authorisation(&ident, &auth_req, ct)
|
||||
.check_oauth2_authorisation(Some(&ident), &auth_req, ct)
|
||||
.unwrap_err()
|
||||
== Oauth2Error::InvalidOrigin
|
||||
);
|
||||
|
@ -3506,16 +3541,40 @@ mod tests {
|
|||
scope: OAUTH2_SCOPE_OPENID.to_string(),
|
||||
nonce: None,
|
||||
oidc_ext: Default::default(),
|
||||
max_age: None,
|
||||
unknown_keys: Default::default(),
|
||||
};
|
||||
|
||||
assert!(
|
||||
idms_prox_read
|
||||
.check_oauth2_authorisation(&ident, &auth_req, ct)
|
||||
.check_oauth2_authorisation(Some(&ident), &auth_req, ct)
|
||||
.unwrap_err()
|
||||
== Oauth2Error::InvalidOrigin
|
||||
);
|
||||
|
||||
// Not Authenticated
|
||||
let auth_req = AuthorisationRequest {
|
||||
response_type: "code".to_string(),
|
||||
client_id: "test_resource_server".to_string(),
|
||||
state: "123".to_string(),
|
||||
pkce_request: pkce_request.clone(),
|
||||
redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(),
|
||||
scope: OAUTH2_SCOPE_OPENID.to_string(),
|
||||
nonce: None,
|
||||
oidc_ext: Default::default(),
|
||||
max_age: None,
|
||||
unknown_keys: Default::default(),
|
||||
};
|
||||
|
||||
let req = idms_prox_read
|
||||
.check_oauth2_authorisation(None, &auth_req, ct)
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
req,
|
||||
AuthoriseResponse::AuthenticationRequired { .. }
|
||||
));
|
||||
|
||||
// Requested scope is not available
|
||||
let auth_req = AuthorisationRequest {
|
||||
response_type: "code".to_string(),
|
||||
|
@ -3526,12 +3585,13 @@ mod tests {
|
|||
scope: "invalid_scope read".to_string(),
|
||||
nonce: None,
|
||||
oidc_ext: Default::default(),
|
||||
max_age: None,
|
||||
unknown_keys: Default::default(),
|
||||
};
|
||||
|
||||
assert!(
|
||||
idms_prox_read
|
||||
.check_oauth2_authorisation(&ident, &auth_req, ct)
|
||||
.check_oauth2_authorisation(Some(&ident), &auth_req, ct)
|
||||
.unwrap_err()
|
||||
== Oauth2Error::AccessDenied
|
||||
);
|
||||
|
@ -3546,12 +3606,13 @@ mod tests {
|
|||
scope: "read openid".to_string(),
|
||||
nonce: None,
|
||||
oidc_ext: Default::default(),
|
||||
max_age: None,
|
||||
unknown_keys: Default::default(),
|
||||
};
|
||||
|
||||
assert!(
|
||||
idms_prox_read
|
||||
.check_oauth2_authorisation(&idm_admin_ident, &auth_req, ct)
|
||||
.check_oauth2_authorisation(Some(&idm_admin_ident), &auth_req, ct)
|
||||
.unwrap_err()
|
||||
== Oauth2Error::AccessDenied
|
||||
);
|
||||
|
@ -3566,12 +3627,13 @@ mod tests {
|
|||
scope: "read openid".to_string(),
|
||||
nonce: None,
|
||||
oidc_ext: Default::default(),
|
||||
max_age: None,
|
||||
unknown_keys: Default::default(),
|
||||
};
|
||||
|
||||
assert!(
|
||||
idms_prox_read
|
||||
.check_oauth2_authorisation(&anon_ident, &auth_req, ct)
|
||||
.check_oauth2_authorisation(Some(&anon_ident), &auth_req, ct)
|
||||
.unwrap_err()
|
||||
== Oauth2Error::AccessDenied
|
||||
);
|
||||
|
@ -3859,11 +3921,12 @@ mod tests {
|
|||
scope: OAUTH2_SCOPE_OPENID.to_string(),
|
||||
nonce: Some("abcdef".to_string()),
|
||||
oidc_ext: Default::default(),
|
||||
max_age: None,
|
||||
unknown_keys: Default::default(),
|
||||
};
|
||||
|
||||
let consent_request = idms_prox_read
|
||||
.check_oauth2_authorisation(&ident, &auth_req, ct)
|
||||
.check_oauth2_authorisation(Some(&ident), &auth_req, ct)
|
||||
.expect("OAuth2 authorisation failed");
|
||||
|
||||
trace!(?consent_request);
|
||||
|
@ -3928,11 +3991,12 @@ mod tests {
|
|||
scope: OAUTH2_SCOPE_OPENID.to_string(),
|
||||
nonce: Some("abcdef".to_string()),
|
||||
oidc_ext: Default::default(),
|
||||
max_age: None,
|
||||
unknown_keys: Default::default(),
|
||||
};
|
||||
|
||||
let consent_request = idms_prox_read
|
||||
.check_oauth2_authorisation(&ident, &auth_req, ct)
|
||||
.check_oauth2_authorisation(Some(&ident), &auth_req, ct)
|
||||
.expect("OAuth2 authorisation failed");
|
||||
|
||||
trace!(?consent_request);
|
||||
|
@ -5130,11 +5194,12 @@ mod tests {
|
|||
scope: OAUTH2_SCOPE_OPENID.to_string(),
|
||||
nonce: Some("abcdef".to_string()),
|
||||
oidc_ext: Default::default(),
|
||||
max_age: None,
|
||||
unknown_keys: Default::default(),
|
||||
};
|
||||
|
||||
idms_prox_read
|
||||
.check_oauth2_authorisation(&ident, &auth_req, ct)
|
||||
.check_oauth2_authorisation(Some(&ident), &auth_req, ct)
|
||||
.expect("Oauth2 authorisation failed");
|
||||
}
|
||||
|
||||
|
@ -5343,11 +5408,12 @@ mod tests {
|
|||
scope: "openid email".to_string(),
|
||||
nonce: Some("abcdef".to_string()),
|
||||
oidc_ext: Default::default(),
|
||||
max_age: None,
|
||||
unknown_keys: Default::default(),
|
||||
};
|
||||
|
||||
let consent_request = idms_prox_read
|
||||
.check_oauth2_authorisation(&ident, &auth_req, ct)
|
||||
.check_oauth2_authorisation(Some(&ident), &auth_req, ct)
|
||||
.expect("Oauth2 authorisation failed");
|
||||
|
||||
// Should be in the consent phase;
|
||||
|
@ -5401,11 +5467,12 @@ mod tests {
|
|||
scope: "openid email".to_string(),
|
||||
nonce: Some("abcdef".to_string()),
|
||||
oidc_ext: Default::default(),
|
||||
max_age: None,
|
||||
unknown_keys: Default::default(),
|
||||
};
|
||||
|
||||
let consent_request = idms_prox_read
|
||||
.check_oauth2_authorisation(&ident, &auth_req, ct)
|
||||
.check_oauth2_authorisation(Some(&ident), &auth_req, ct)
|
||||
.expect("Oauth2 authorisation failed");
|
||||
|
||||
// Should be present in the consent phase however!
|
||||
|
@ -5538,11 +5605,12 @@ mod tests {
|
|||
scope: OAUTH2_SCOPE_OPENID.to_string(),
|
||||
nonce: None,
|
||||
oidc_ext: Default::default(),
|
||||
max_age: None,
|
||||
unknown_keys: Default::default(),
|
||||
};
|
||||
|
||||
let consent_request = idms_prox_read
|
||||
.check_oauth2_authorisation(&ident, &auth_req, ct)
|
||||
.check_oauth2_authorisation(Some(&ident), &auth_req, ct)
|
||||
.expect("Failed to perform OAuth2 authorisation request.");
|
||||
|
||||
// Should be in the consent phase;
|
||||
|
@ -5615,12 +5683,13 @@ mod tests {
|
|||
scope: OAUTH2_SCOPE_OPENID.to_string(),
|
||||
nonce: None,
|
||||
oidc_ext: Default::default(),
|
||||
max_age: None,
|
||||
unknown_keys: Default::default(),
|
||||
};
|
||||
|
||||
assert!(
|
||||
idms_prox_read
|
||||
.check_oauth2_authorisation(&ident, &auth_req, ct)
|
||||
.check_oauth2_authorisation(Some(&ident), &auth_req, ct)
|
||||
.unwrap_err()
|
||||
== Oauth2Error::InvalidOrigin
|
||||
);
|
||||
|
@ -6324,7 +6393,7 @@ mod tests {
|
|||
btreeset!["value_b".to_string()],
|
||||
),
|
||||
),
|
||||
// Map with a different seperator
|
||||
// Map with a different separator
|
||||
Modify::Present(
|
||||
Attribute::OAuth2RsClaimMap,
|
||||
Value::OauthClaimMap(
|
||||
|
@ -6537,12 +6606,16 @@ mod tests {
|
|||
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
||||
let (_uat, ident, oauth2_rs_uuid) = setup_oauth2_resource_server_public(idms, ct).await;
|
||||
|
||||
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
|
||||
let mut idms_prox_write: crate::idm::server::IdmServerProxyWriteTransaction<'_> =
|
||||
idms.proxy_write(ct).await.unwrap();
|
||||
|
||||
let modlist = ModifyList::new_list(vec![Modify::Present(
|
||||
Attribute::OAuth2AllowLocalhostRedirect,
|
||||
Value::Bool(true),
|
||||
)]);
|
||||
let redirect_uri = Url::parse("http://localhost:8765/oauth2/result")
|
||||
.expect("Failed to parse redirect URL");
|
||||
|
||||
let modlist = ModifyList::new_list(vec![
|
||||
Modify::Present(Attribute::OAuth2AllowLocalhostRedirect, Value::Bool(true)),
|
||||
Modify::Present(Attribute::OAuth2RsOrigin, Value::Url(redirect_uri.clone())),
|
||||
]);
|
||||
|
||||
assert!(idms_prox_write
|
||||
.qs_write
|
||||
|
@ -6567,15 +6640,16 @@ mod tests {
|
|||
code_challenge,
|
||||
code_challenge_method: CodeChallengeMethod::S256,
|
||||
}),
|
||||
redirect_uri: Url::parse("http://localhost:8765/oauth2/result").unwrap(),
|
||||
redirect_uri: redirect_uri.clone(),
|
||||
scope: OAUTH2_SCOPE_OPENID.to_string(),
|
||||
nonce: Some("abcdef".to_string()),
|
||||
oidc_ext: Default::default(),
|
||||
max_age: None,
|
||||
unknown_keys: Default::default(),
|
||||
};
|
||||
|
||||
let consent_request = idms_prox_read
|
||||
.check_oauth2_authorisation(&ident, &auth_req, ct)
|
||||
.check_oauth2_authorisation(Some(&ident), &auth_req, ct)
|
||||
.expect("OAuth2 authorisation failed");
|
||||
|
||||
// Should be in the consent phase;
|
||||
|
@ -6598,7 +6672,7 @@ mod tests {
|
|||
let token_req = AccessTokenRequest {
|
||||
grant_type: GrantTypeReq::AuthorizationCode {
|
||||
code: permit_success.code,
|
||||
redirect_uri: Url::parse("http://localhost:8765/oauth2/result").unwrap(),
|
||||
redirect_uri,
|
||||
// From the first step.
|
||||
code_verifier,
|
||||
},
|
||||
|
@ -6816,4 +6890,70 @@ mod tests {
|
|||
dbg!(&res);
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_url_localhost_domain() {
|
||||
// ref #2390 - localhost with ports for OAuth2 redirect_uri
|
||||
|
||||
// ensure host_is_local isn't true for a non-local host
|
||||
let example_is_not_local = "https://example.com/sdfsdf";
|
||||
println!("Ensuring that {} is not local", example_is_not_local);
|
||||
assert!(!host_is_local(
|
||||
&Url::parse(example_is_not_local)
|
||||
.expect("Failed to parse example.com as a host?")
|
||||
.host()
|
||||
.expect(&format!(
|
||||
"Couldn't get a host from {}",
|
||||
example_is_not_local
|
||||
))
|
||||
));
|
||||
|
||||
let test_urls = [
|
||||
("http://localhost:8080/oauth2/callback", "/oauth2/callback"),
|
||||
("https://localhost/foo/bar", "/foo/bar"),
|
||||
("http://127.0.0.1:12345/foo", "/foo"),
|
||||
("http://[::1]:12345/foo", "/foo"),
|
||||
];
|
||||
|
||||
for (url, path) in test_urls.into_iter() {
|
||||
println!("Testing URL: {}", url);
|
||||
let url = Url::parse(url).expect("One of the test values failed!");
|
||||
assert!(host_is_local(
|
||||
&url.host().expect("Didn't parse a host out?")
|
||||
));
|
||||
|
||||
assert_eq!(url.path(), path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oauth2_rs_type_allow_localhost_redirect() {
|
||||
let test_cases = [
|
||||
(
|
||||
OauthRSType::Public {
|
||||
allow_localhost_redirect: true,
|
||||
},
|
||||
true,
|
||||
),
|
||||
(
|
||||
OauthRSType::Public {
|
||||
allow_localhost_redirect: false,
|
||||
},
|
||||
false,
|
||||
),
|
||||
(
|
||||
OauthRSType::Basic {
|
||||
authz_secret: "supersecret".to_string(),
|
||||
enable_pkce: false,
|
||||
},
|
||||
false,
|
||||
),
|
||||
];
|
||||
|
||||
assert!(test_cases.iter().all(|(rs_type, expected)| {
|
||||
let actual = rs_type.allow_localhost_redirect();
|
||||
println!("Testing {:?} -> {}", rs_type, expected);
|
||||
actual == *expected
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1584,10 +1584,6 @@ mod tests {
|
|||
assert!(matches!(sync_state, ScimSyncState::Refresh));
|
||||
|
||||
drop(idms_prox_read);
|
||||
|
||||
// Use the current state and update.
|
||||
|
||||
// TODO!!!
|
||||
}
|
||||
|
||||
#[idm_test]
|
||||
|
@ -3167,6 +3163,100 @@ mod tests {
|
|||
assert!(idms_prox_write.commit().is_ok());
|
||||
}
|
||||
|
||||
#[idm_test]
|
||||
/// Assert that a SCIM JSON proto entry correctly serialises and deserialises
|
||||
/// and can be applied as a changeset. This serialisation is performed during
|
||||
/// the ScimEntry::try_from step.
|
||||
async fn test_idm_scim_sync_json_proto(idms: &IdmServer, _idms_delayed: &mut IdmServerDelayed) {
|
||||
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
||||
let mut idms_prox_write = idms.proxy_write(ct).await.unwrap();
|
||||
let (_sync_uuid, ident) = test_scim_sync_apply_setup_ident(&mut idms_prox_write, ct);
|
||||
let sse = ScimSyncUpdateEvent { ident };
|
||||
|
||||
// Minimum Viable Person
|
||||
let person_1 = ScimSyncPerson::builder(
|
||||
Uuid::new_v4(),
|
||||
"cn=testperson_1".to_string(),
|
||||
"testperson_1".to_string(),
|
||||
"Test Person One".to_string(),
|
||||
)
|
||||
.build()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
|
||||
// Minimum Viable Group
|
||||
let group_1 = ScimSyncGroup::builder(
|
||||
Uuid::new_v4(),
|
||||
"cn=testgroup_1".to_string(),
|
||||
"testgroup_1".to_string(),
|
||||
)
|
||||
.build()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
|
||||
let user_sshkey = "sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBENubZikrb8hu+HeVRdZ0pp/VAk2qv4JDbuJhvD0yNdWDL2e3cBbERiDeNPkWx58Q4rVnxkbV1fa8E2waRtT91wAAAAEc3NoOg== testuser@fidokey";
|
||||
|
||||
// All Attribute Person
|
||||
let person_2 = ScimSyncPerson::builder(
|
||||
Uuid::new_v4(),
|
||||
"cn=testperson_2".to_string(),
|
||||
"testperson_2".to_string(),
|
||||
"Test Person Two".to_string(),
|
||||
)
|
||||
.set_password_import(Some("ipaNTHash: iEb36u6PsRetBr3YMLdYbA".to_string()))
|
||||
.set_unix_password_import(Some("ipaNTHash: iEb36u6PsRetBr3YMLdYbA".to_string()))
|
||||
.set_totp_import(vec![ScimTotp {
|
||||
external_id: "Totp".to_string(),
|
||||
secret: "QICWZTON72IBS5MXWNURKAONC3JNOOOFMLKNRTIPXBYQ4BLRSEBM7KF5".to_string(),
|
||||
algo: "sha256".to_string(),
|
||||
step: 60,
|
||||
digits: 8,
|
||||
}])
|
||||
.set_mail(vec![MultiValueAttr {
|
||||
primary: Some(true),
|
||||
value: "testuser@example.com".to_string(),
|
||||
..Default::default()
|
||||
}])
|
||||
.set_ssh_publickey(vec![ScimSshPubKey {
|
||||
label: "Key McKeyface".to_string(),
|
||||
value: user_sshkey.to_string(),
|
||||
}])
|
||||
.set_login_shell(Some("/bin/zsh".to_string()))
|
||||
.set_account_valid_from(Some("2023-11-28T04:57:55Z".to_string()))
|
||||
.set_account_expire(Some("2023-11-28T04:57:55Z".to_string()))
|
||||
.set_gidnumber(Some(12346))
|
||||
.build()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
|
||||
// All Attribute Group
|
||||
let group_2 = ScimSyncGroup::builder(
|
||||
Uuid::new_v4(),
|
||||
"cn=testgroup_2".to_string(),
|
||||
"testgroup_2".to_string(),
|
||||
)
|
||||
.set_description(Some("description".to_string()))
|
||||
.set_gidnumber(Some(12345))
|
||||
.set_members(vec!["cn=testperson_1".to_string(), "cn=testperson_2".to_string()].into_iter())
|
||||
.build()
|
||||
.try_into()
|
||||
.unwrap();
|
||||
|
||||
let entries = vec![person_1, group_1, person_2, group_2];
|
||||
|
||||
let changes = ScimSyncRequest {
|
||||
from_state: ScimSyncState::Refresh,
|
||||
to_state: ScimSyncState::Active {
|
||||
cookie: vec![1, 2, 3, 4],
|
||||
},
|
||||
entries,
|
||||
retain: ScimSyncRetentionMode::Ignore,
|
||||
};
|
||||
|
||||
assert!(idms_prox_write.scim_sync_apply(&sse, &changes, ct).is_ok());
|
||||
assert!(idms_prox_write.commit().is_ok());
|
||||
}
|
||||
|
||||
const TEST_SYNC_SCIM_IPA_1: &str = r#"
|
||||
{
|
||||
"from_state": "Refresh",
|
||||
|
|
|
@ -2335,7 +2335,7 @@ mod tests {
|
|||
// Check the uat.
|
||||
}
|
||||
_ => {
|
||||
error!("A critical error has occurred! We have a non-succcess result!");
|
||||
error!("A critical error has occurred! We have a non-success result!");
|
||||
panic!();
|
||||
}
|
||||
}
|
||||
|
@ -2469,7 +2469,7 @@ mod tests {
|
|||
token
|
||||
}
|
||||
_ => {
|
||||
error!("A critical error has occurred! We have a non-succcess result!");
|
||||
error!("A critical error has occurred! We have a non-success result!");
|
||||
panic!();
|
||||
}
|
||||
}
|
||||
|
@ -2540,7 +2540,7 @@ mod tests {
|
|||
// Check the uat.
|
||||
}
|
||||
_ => {
|
||||
error!("A critical error has occurred! We have a non-succcess result!");
|
||||
error!("A critical error has occurred! We have a non-success result!");
|
||||
panic!();
|
||||
}
|
||||
}
|
||||
|
@ -3311,7 +3311,7 @@ mod tests {
|
|||
// Check the uat.
|
||||
}
|
||||
_ => {
|
||||
error!("A critical error has occurred! We have a non-succcess result!");
|
||||
error!("A critical error has occurred! We have a non-success result!");
|
||||
panic!();
|
||||
}
|
||||
}
|
||||
|
@ -3843,7 +3843,7 @@ mod tests {
|
|||
match state {
|
||||
AuthState::Success(uat, AuthIssueSession::Token) => uat,
|
||||
_ => {
|
||||
error!("A critical error has occurred! We have a non-succcess result!");
|
||||
error!("A critical error has occurred! We have a non-success result!");
|
||||
panic!();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -140,7 +140,7 @@ fn enforce_unique<VALID, STATE>(
|
|||
// We can probably bisect over the filter to work this out?
|
||||
|
||||
if conflict_cand {
|
||||
// Some kind of confilct exists. We need to isolate which parts of the filter were suspect.
|
||||
// Some kind of conflict exists. We need to isolate which parts of the filter were suspect.
|
||||
// To do this, we bisect over the filter and it's suspect elements.
|
||||
//
|
||||
// In most cases there is likely only 1 suspect element. But in some there are more. To make
|
||||
|
|
|
@ -56,7 +56,7 @@ const GID_NSPAWN_MAX: u32 = 1879048191;
|
|||
const GID_UNUSED_D_MIN: u32 = 0x7000_0000;
|
||||
pub const GID_UNUSED_D_MAX: u32 = 0x7fff_ffff;
|
||||
|
||||
/// Anything above 2147483648 can confuse the kernel (so basicly half the address space
|
||||
/// Anything above 2147483648 can confuse the kernel (so basically half the address space
|
||||
/// can't be accessed.
|
||||
// const GID_UNSAFE_MAX: u32 = 2147483648;
|
||||
|
||||
|
@ -109,7 +109,7 @@ fn apply_gidnumber<T: Clone>(
|
|||
|| (GID_UNUSED_B_MIN..= GID_UNUSED_B_MAX).contains(&gid)
|
||||
|| (GID_UNUSED_C_MIN..=GID_UNUSED_C_MAX).contains(&gid)
|
||||
// We won't ever generate an id in the nspawn range, but we do secretly allow
|
||||
// it to be set for compatability with services like freeipa or openldap. TBH
|
||||
// it to be set for compatibility with services like freeipa or openldap. TBH
|
||||
// most people don't even use systemd nspawn anyway ...
|
||||
//
|
||||
// I made this design choice to avoid a tunable that may confuse people to
|
||||
|
|
|
@ -216,7 +216,7 @@ fn do_leaf_memberof(
|
|||
tgte.set_ava_set(&Attribute::MemberOf, mo);
|
||||
}
|
||||
|
||||
// If the group has memberOf attributes, we propogate these to
|
||||
// If the group has memberOf attributes, we propagate these to
|
||||
// our entry now.
|
||||
if let Some(group_mo) = memberof_ref {
|
||||
// IMPORTANT this can't be a NONE because we just create MO in
|
||||
|
@ -359,7 +359,7 @@ fn apply_memberof(
|
|||
);
|
||||
|
||||
// Since our groups memberof (and related, direct member of) has changed, we
|
||||
// need to propogate these values forward into our members. At this point we
|
||||
// need to propagate these values forward into our members. At this point we
|
||||
// mark all our members as being part of the affected set.
|
||||
let pre_member = pre.get_ava_refer(Attribute::Member);
|
||||
let post_member = tgte.get_ava_refer(Attribute::Member);
|
||||
|
|
|
@ -37,9 +37,13 @@ lazy_static! {
|
|||
Attribute::Image,
|
||||
// modification of account policy values for dyngroup.
|
||||
Attribute::AuthSessionExpiry,
|
||||
Attribute::PrivilegeExpiry,
|
||||
Attribute::AuthPasswordMinimumLength,
|
||||
Attribute::CredentialTypeMinimum,
|
||||
Attribute::PrivilegeExpiry,
|
||||
Attribute::WebauthnAttestationCaList,
|
||||
Attribute::LimitSearchMaxResults,
|
||||
Attribute::LimitSearchMaxFilterTest,
|
||||
Attribute::AllowPrimaryCredFallback,
|
||||
];
|
||||
|
||||
let mut m = HashSet::with_capacity(attrs.len());
|
||||
|
|
|
@ -1964,7 +1964,7 @@ async fn test_repl_increment_consumer_ruv_trim_past_valid(
|
|||
drop(server_b_txn);
|
||||
}
|
||||
|
||||
// Test two synchronised nodes where changes are not occuring - this situation would previously
|
||||
// Test two synchronised nodes where changes are not occurring - this situation would previously
|
||||
// cause issues because when a change did occur, the ruv would "jump" ahead and cause desyncs.w
|
||||
#[qs_pair_test]
|
||||
async fn test_repl_increment_consumer_ruv_trim_idle_servers(
|
||||
|
@ -3438,7 +3438,7 @@ async fn test_repl_increment_session_new(server_a: &QueryServer, server_b: &Quer
|
|||
/// ensures that any RUV state to a server is now fresh and unique
|
||||
///
|
||||
/// Second, to prevent tainting the RUV with outdated information, we need to stop it
|
||||
/// propogating when consumed. At the end of each consumption, the RUV should be trimmed
|
||||
/// propagating when consumed. At the end of each consumption, the RUV should be trimmed
|
||||
/// if and only if entries exist in it that exceed the CL max. It is only trimmed conditionally
|
||||
/// to prevent infinite replication loops since a trim implies the creation of a new anchor.
|
||||
|
||||
|
|
|
@ -137,7 +137,10 @@ fn resolve_access_conditions(
|
|||
AccessControlReceiver::Group(groups) => {
|
||||
let group_check = ident_memberof
|
||||
// Have at least one group allowed.
|
||||
.map(|imo| imo.intersection(groups).next().is_some())
|
||||
.map(|imo| {
|
||||
trace!(?imo, ?groups);
|
||||
imo.intersection(groups).next().is_some()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
if group_check {
|
||||
|
@ -401,6 +404,7 @@ pub trait AccessControlsTransaction<'a> {
|
|||
let related_acp: Vec<_> = modify_state
|
||||
.iter()
|
||||
.filter_map(|acs| {
|
||||
trace!(acs_name = ?acs.acp.name);
|
||||
let (receiver_condition, target_condition) = resolve_access_conditions(
|
||||
ident,
|
||||
ident_memberof,
|
||||
|
@ -2918,6 +2922,7 @@ mod tests {
|
|||
|
||||
let r_set = vec![Arc::new(ev1.clone()), Arc::new(ev2)];
|
||||
|
||||
// Check the authorisation search event, and that it reduces correctly.
|
||||
let se_a = SearchEvent::new_impersonate_entry(
|
||||
E_TEST_ACCOUNT_1.clone(),
|
||||
filter_all!(f_pres(Attribute::Name)),
|
||||
|
@ -2925,17 +2930,28 @@ mod tests {
|
|||
let ex_a = vec![Arc::new(ev1)];
|
||||
let ex_a_reduced = vec![ev1_reduced];
|
||||
|
||||
test_acp_search!(&se_a, vec![], r_set.clone(), ex_a);
|
||||
test_acp_search_reduce!(&se_a, vec![], r_set.clone(), ex_a_reduced);
|
||||
|
||||
// Check that anonymous is denied even though it's a member of the group.
|
||||
let anon: EntryInitNew = BUILTIN_ACCOUNT_ANONYMOUS_DL6.clone().into();
|
||||
let mut anon = anon.into_invalid_new();
|
||||
anon.set_ava_set(&Attribute::MemberOf, ValueSetRefer::new(UUID_TEST_GROUP_1));
|
||||
|
||||
let anon = Arc::new(anon.into_sealed_committed());
|
||||
|
||||
let se_anon =
|
||||
SearchEvent::new_impersonate_entry(anon, filter_all!(f_pres(Attribute::Name)));
|
||||
let ex_anon = vec![];
|
||||
test_acp_search!(&se_anon, vec![], r_set.clone(), ex_anon);
|
||||
|
||||
// Check the deny case.
|
||||
let se_b = SearchEvent::new_impersonate_entry(
|
||||
E_TEST_ACCOUNT_2.clone(),
|
||||
filter_all!(f_pres(Attribute::Name)),
|
||||
);
|
||||
let ex_b = vec![];
|
||||
|
||||
// Check the authorisation search event, and that it reduces correctly.
|
||||
test_acp_search!(&se_a, vec![], r_set.clone(), ex_a);
|
||||
test_acp_search_reduce!(&se_a, vec![], r_set.clone(), ex_a_reduced);
|
||||
|
||||
// Check the deny case.
|
||||
test_acp_search!(&se_b, vec![], r_set, ex_b);
|
||||
}
|
||||
|
||||
|
|
|
@ -168,6 +168,11 @@ fn search_oauth2_filter_entry(ident: &Identity, entry: &Arc<EntrySealedCommitted
|
|||
match &ident.origin {
|
||||
IdentType::Internal | IdentType::Synch(_) => AccessResult::Ignore,
|
||||
IdentType::User(iuser) => {
|
||||
if iuser.entry.get_uuid() == UUID_ANONYMOUS {
|
||||
debug!("Anonymous can't access OAuth2 entries, ignoring");
|
||||
return AccessResult::Ignore;
|
||||
}
|
||||
|
||||
let contains_o2_rs = entry
|
||||
.get_ava_as_iutf8(Attribute::Class)
|
||||
.map(|set| {
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use crate::prelude::*;
|
||||
|
||||
use kanidm_proto::internal::{
|
||||
|
@ -123,6 +121,9 @@ impl QueryServer {
|
|||
"After setting internal domain info"
|
||||
);
|
||||
|
||||
let mut reload_required = false;
|
||||
|
||||
// If the database domain info is a lower version than our target level, we reload.
|
||||
if domain_info_version < domain_target_level {
|
||||
write_txn
|
||||
.internal_modify_uuid(
|
||||
|
@ -138,10 +139,7 @@ impl QueryServer {
|
|||
|
||||
// Reload if anything in migrations requires it - this triggers the domain migrations
|
||||
// which in turn can trigger schema reloads etc.
|
||||
write_txn.reload()?;
|
||||
// Force a reindex here since schema probably changed and we aren't at the
|
||||
// runtime phase where it will trigger on its own yet.
|
||||
write_txn.reindex()?;
|
||||
reload_required = true;
|
||||
} else if domain_development_taint {
|
||||
// This forces pre-release versions to re-migrate each start up. This solves
|
||||
// the domain-version-sprawl issue so that during a development cycle we can
|
||||
|
@ -154,12 +152,12 @@ impl QueryServer {
|
|||
// AND
|
||||
// We did not already need a version migration as above
|
||||
write_txn.domain_remigrate(DOMAIN_PREVIOUS_TGT_LEVEL)?;
|
||||
write_txn.reload()?;
|
||||
// Force a reindex here since schema probably changed and we aren't at the
|
||||
// runtime phase where it will trigger on its own yet.
|
||||
write_txn.reindex()?;
|
||||
|
||||
reload_required = true;
|
||||
}
|
||||
|
||||
// If we are new enough to support patches, and we are lower than the target patch level
|
||||
// then a reload will be applied after we raise the patch level.
|
||||
if domain_target_level >= DOMAIN_LEVEL_7 && domain_patch_level < DOMAIN_TGT_PATCH_LEVEL {
|
||||
write_txn
|
||||
.internal_modify_uuid(
|
||||
|
@ -170,13 +168,25 @@ impl QueryServer {
|
|||
),
|
||||
)
|
||||
.map(|()| {
|
||||
warn!("Domain level has been raised to {}", domain_target_level);
|
||||
warn!(
|
||||
"Domain patch level has been raised to {}",
|
||||
domain_patch_level
|
||||
);
|
||||
})?;
|
||||
|
||||
// Run the patch migrations if any.
|
||||
write_txn.reload()?;
|
||||
reload_required = true;
|
||||
};
|
||||
|
||||
// Execute whatever operations we have batched up and ready to go. This is needed
|
||||
// to preserve ordering of the operations - if we reloaded after a remigrate then
|
||||
// we would have skipped the patch level fix which needs to have occurred *first*.
|
||||
if reload_required {
|
||||
write_txn.reload()?;
|
||||
// We are not yet at the schema phase where reindexes will auto-trigger
|
||||
// so if one was required, do it now.
|
||||
write_txn.reindex()?;
|
||||
}
|
||||
|
||||
// Now set the db/domain devel taint flag to match our current release status
|
||||
// if it changes. This is what breaks the cycle of db taint from dev -> stable
|
||||
let current_devel_flag = option_env!("KANIDM_PRE_RELEASE").is_some();
|
||||
|
@ -665,6 +675,81 @@ impl<'a> QueryServerWriteTransaction<'a> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Patch Application - This triggers a one-shot fixup task for issue #3178
|
||||
/// to force access controls to re-migrate in existing databases so that they're
|
||||
/// content matches expected values.
|
||||
#[instrument(level = "info", skip_all)]
|
||||
pub(crate) fn migrate_domain_patch_level_2(&mut self) -> Result<(), OperationError> {
|
||||
admin_warn!("applying domain patch 2.");
|
||||
|
||||
debug_assert!(*self.phase >= ServerPhase::SchemaReady);
|
||||
|
||||
let idm_data = [
|
||||
IDM_ACP_ACCOUNT_MAIL_READ_DL6.clone().into(),
|
||||
IDM_ACP_ACCOUNT_SELF_WRITE_V1.clone().into(),
|
||||
IDM_ACP_ACCOUNT_UNIX_EXTEND_V1.clone().into(),
|
||||
IDM_ACP_ACP_MANAGE_V1.clone().into(),
|
||||
IDM_ACP_ALL_ACCOUNTS_POSIX_READ_V1.clone().into(),
|
||||
IDM_ACP_APPLICATION_ENTRY_MANAGER_DL8.clone().into(),
|
||||
IDM_ACP_APPLICATION_MANAGE_DL8.clone().into(),
|
||||
IDM_ACP_DOMAIN_ADMIN_DL8.clone().into(),
|
||||
IDM_ACP_GROUP_ACCOUNT_POLICY_MANAGE_DL8.clone().into(),
|
||||
IDM_ACP_GROUP_ENTRY_MANAGED_BY_MODIFY_V1.clone().into(),
|
||||
IDM_ACP_GROUP_ENTRY_MANAGER_V1.clone().into(),
|
||||
IDM_ACP_GROUP_MANAGE_DL6.clone().into(),
|
||||
IDM_ACP_GROUP_READ_V1.clone().into(),
|
||||
IDM_ACP_GROUP_UNIX_MANAGE_V1.clone().into(),
|
||||
IDM_ACP_HP_CLIENT_CERTIFICATE_MANAGER_DL7.clone().into(),
|
||||
IDM_ACP_HP_GROUP_UNIX_MANAGE_V1.clone().into(),
|
||||
IDM_ACP_HP_PEOPLE_CREDENTIAL_RESET_V1.clone().into(),
|
||||
IDM_ACP_HP_SERVICE_ACCOUNT_ENTRY_MANAGED_BY_MODIFY_V1
|
||||
.clone()
|
||||
.into(),
|
||||
IDM_ACP_MAIL_SERVERS_DL8.clone().into(),
|
||||
IDM_ACP_OAUTH2_MANAGE_DL7.clone().into(),
|
||||
IDM_ACP_PEOPLE_CREATE_DL6.clone().into(),
|
||||
IDM_ACP_PEOPLE_CREDENTIAL_RESET_V1.clone().into(),
|
||||
IDM_ACP_PEOPLE_DELETE_V1.clone().into(),
|
||||
IDM_ACP_PEOPLE_MANAGE_V1.clone().into(),
|
||||
IDM_ACP_PEOPLE_PII_MANAGE_V1.clone().into(),
|
||||
IDM_ACP_PEOPLE_PII_READ_V1.clone().into(),
|
||||
IDM_ACP_PEOPLE_READ_V1.clone().into(),
|
||||
IDM_ACP_PEOPLE_SELF_WRITE_MAIL_V1.clone().into(),
|
||||
IDM_ACP_RADIUS_SECRET_MANAGE_V1.clone().into(),
|
||||
IDM_ACP_RADIUS_SERVERS_V1.clone().into(),
|
||||
IDM_ACP_RECYCLE_BIN_REVIVE_V1.clone().into(),
|
||||
IDM_ACP_RECYCLE_BIN_SEARCH_V1.clone().into(),
|
||||
IDM_ACP_SCHEMA_WRITE_ATTRS_V1.clone().into(),
|
||||
IDM_ACP_SCHEMA_WRITE_CLASSES_V1.clone().into(),
|
||||
IDM_ACP_SELF_NAME_WRITE_DL7.clone().into(),
|
||||
IDM_ACP_SELF_READ_DL8.clone().into(),
|
||||
IDM_ACP_SELF_WRITE_DL8.clone().into(),
|
||||
IDM_ACP_SERVICE_ACCOUNT_CREATE_V1.clone().into(),
|
||||
IDM_ACP_SERVICE_ACCOUNT_DELETE_V1.clone().into(),
|
||||
IDM_ACP_SERVICE_ACCOUNT_ENTRY_MANAGED_BY_MODIFY_V1
|
||||
.clone()
|
||||
.into(),
|
||||
IDM_ACP_SERVICE_ACCOUNT_ENTRY_MANAGER_V1.clone().into(),
|
||||
IDM_ACP_SERVICE_ACCOUNT_MANAGE_V1.clone().into(),
|
||||
IDM_ACP_SYNC_ACCOUNT_MANAGE_V1.clone().into(),
|
||||
IDM_ACP_SYSTEM_CONFIG_ACCOUNT_POLICY_MANAGE_V1
|
||||
.clone()
|
||||
.into(),
|
||||
];
|
||||
|
||||
idm_data
|
||||
.into_iter()
|
||||
.try_for_each(|entry| self.internal_migrate_or_create(entry))
|
||||
.map_err(|err| {
|
||||
error!(?err, "migrate_domain_patch_level_2 -> Error");
|
||||
err
|
||||
})?;
|
||||
|
||||
self.reload()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[instrument(level = "info", skip_all)]
|
||||
pub fn initialise_schema_core(&mut self) -> Result<(), OperationError> {
|
||||
admin_debug!("initialise_schema_core -> start ...");
|
||||
|
@ -786,6 +871,9 @@ impl<'a> QueryServerWriteTransaction<'a> {
|
|||
SCHEMA_ATTR_KEY_ACTION_ROTATE_DL6.clone().into(),
|
||||
SCHEMA_ATTR_KEY_ACTION_REVOKE_DL6.clone().into(),
|
||||
SCHEMA_ATTR_KEY_ACTION_IMPORT_JWS_ES256_DL6.clone().into(),
|
||||
// DL7
|
||||
SCHEMA_ATTR_PATCH_LEVEL_DL7.clone().into(),
|
||||
SCHEMA_ATTR_DOMAIN_DEVELOPMENT_TAINT_DL7.clone().into(),
|
||||
];
|
||||
|
||||
let r = idm_schema
|
||||
|
@ -818,7 +906,6 @@ impl<'a> QueryServerWriteTransaction<'a> {
|
|||
SCHEMA_CLASS_OAUTH2_RS_BASIC_DL5.clone().into(),
|
||||
// DL6
|
||||
SCHEMA_CLASS_ACCOUNT_POLICY_DL6.clone().into(),
|
||||
SCHEMA_CLASS_DOMAIN_INFO_DL6.clone().into(),
|
||||
SCHEMA_CLASS_SERVICE_ACCOUNT_DL6.clone().into(),
|
||||
SCHEMA_CLASS_SYNC_ACCOUNT_DL6.clone().into(),
|
||||
SCHEMA_CLASS_GROUP_DL6.clone().into(),
|
||||
|
@ -828,6 +915,7 @@ impl<'a> QueryServerWriteTransaction<'a> {
|
|||
SCHEMA_CLASS_KEY_OBJECT_JWT_ES256_DL6.clone().into(),
|
||||
SCHEMA_CLASS_KEY_OBJECT_JWE_A128GCM_DL6.clone().into(),
|
||||
SCHEMA_CLASS_KEY_OBJECT_INTERNAL_DL6.clone().into(),
|
||||
SCHEMA_CLASS_DOMAIN_INFO_DL6.clone().into(),
|
||||
];
|
||||
|
||||
let r: Result<(), _> = idm_schema_classes_dl1
|
||||
|
|
|
@ -2075,6 +2075,10 @@ impl<'a> QueryServerWriteTransaction<'a> {
|
|||
self.migrate_domain_8_to_9()?;
|
||||
}
|
||||
|
||||
if previous_patch_level < PATCH_LEVEL_2 && domain_info_patch_level >= PATCH_LEVEL_2 {
|
||||
self.migrate_domain_patch_level_2()?;
|
||||
}
|
||||
|
||||
// This is here to catch when we increase domain levels but didn't create the migration
|
||||
// hooks. If this fails it probably means you need to add another migration hook
|
||||
// in the above.
|
||||
|
@ -2696,7 +2700,7 @@ mod tests {
|
|||
async fn test_scim_entry_structure(server: &QueryServer) {
|
||||
let mut read_txn = server.read().await.unwrap();
|
||||
|
||||
// Query entry (A buitin one ?)
|
||||
// Query entry (A builtin one ?)
|
||||
let entry = read_txn
|
||||
.internal_search_uuid(UUID_IDM_PEOPLE_SELF_NAME_WRITE)
|
||||
.unwrap();
|
||||
|
|
|
@ -863,4 +863,91 @@ mod tests {
|
|||
// do a pw check.
|
||||
assert!(cred_ref.verify_password("test_password").unwrap());
|
||||
}
|
||||
|
||||
#[qs_test]
|
||||
async fn test_modify_name_self_write(server: &QueryServer) {
|
||||
let user_uuid = uuid!("cc8e95b4-c24f-4d68-ba54-8bed76f63930");
|
||||
let e1 = entry_init!(
|
||||
(Attribute::Class, EntryClass::Object.to_value()),
|
||||
(Attribute::Class, EntryClass::Person.to_value()),
|
||||
(Attribute::Class, EntryClass::Account.to_value()),
|
||||
(Attribute::Name, Value::new_iname("testperson1")),
|
||||
(Attribute::Uuid, Value::Uuid(user_uuid)),
|
||||
(Attribute::Description, Value::new_utf8s("testperson1")),
|
||||
(Attribute::DisplayName, Value::new_utf8s("testperson1"))
|
||||
);
|
||||
let mut server_txn = server.write(duration_from_epoch_now()).await.unwrap();
|
||||
|
||||
assert!(server_txn.internal_create(vec![e1]).is_ok());
|
||||
|
||||
// Impersonate the user.
|
||||
|
||||
let testperson_entry = server_txn.internal_search_uuid(user_uuid).unwrap();
|
||||
|
||||
let user_ident = Identity::from_impersonate_entry_readwrite(testperson_entry);
|
||||
|
||||
// Can we change ourself?
|
||||
let me_inv_m = ModifyEvent::new_impersonate_identity(
|
||||
user_ident,
|
||||
filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(user_uuid),)),
|
||||
ModifyList::new_list(vec![
|
||||
Modify::Purged(Attribute::Name),
|
||||
Modify::Present(Attribute::Name, Value::new_iname("test_person_renamed")),
|
||||
Modify::Purged(Attribute::DisplayName),
|
||||
Modify::Present(
|
||||
Attribute::DisplayName,
|
||||
Value::Utf8("test_person_renamed".into()),
|
||||
),
|
||||
Modify::Purged(Attribute::LegalName),
|
||||
Modify::Present(
|
||||
Attribute::LegalName,
|
||||
Value::Utf8("test_person_renamed".into()),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
// Modify success.
|
||||
assert!(server_txn.modify(&me_inv_m).is_ok());
|
||||
|
||||
// Alter the deal.
|
||||
let modify_remove_person = ModifyEvent::new_internal_invalid(
|
||||
filter!(f_eq(
|
||||
Attribute::Uuid,
|
||||
PartialValue::Uuid(UUID_IDM_PEOPLE_SELF_NAME_WRITE),
|
||||
)),
|
||||
ModifyList::new_list(vec![Modify::Purged(Attribute::Member)]),
|
||||
);
|
||||
|
||||
assert!(server_txn.modify(&modify_remove_person).is_ok());
|
||||
|
||||
// Reload the users identity which will cause the memberships to be reflected now.
|
||||
let testperson_entry = server_txn.internal_search_uuid(user_uuid).unwrap();
|
||||
|
||||
let user_ident = Identity::from_impersonate_entry_readwrite(testperson_entry);
|
||||
|
||||
let me_inv_m = ModifyEvent::new_impersonate_identity(
|
||||
user_ident,
|
||||
filter!(f_eq(Attribute::Uuid, PartialValue::Uuid(user_uuid),)),
|
||||
ModifyList::new_list(vec![
|
||||
Modify::Purged(Attribute::Name),
|
||||
Modify::Present(Attribute::Name, Value::new_iname("test_person_renamed")),
|
||||
Modify::Purged(Attribute::DisplayName),
|
||||
Modify::Present(
|
||||
Attribute::DisplayName,
|
||||
Value::Utf8("test_person_renamed".into()),
|
||||
),
|
||||
Modify::Purged(Attribute::LegalName),
|
||||
Modify::Present(
|
||||
Attribute::LegalName,
|
||||
Value::Utf8("test_person_renamed".into()),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
// The modification must now fail.
|
||||
assert_eq!(
|
||||
server_txn.modify(&me_inv_m),
|
||||
Err(OperationError::AccessDenied)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -256,7 +256,7 @@ mod tests {
|
|||
use kanidm_lib_crypto::CryptoPolicy;
|
||||
|
||||
// Test the remove operation, removing all application passwords for an
|
||||
// applicaiton should also remove the KV pair.
|
||||
// application should also remove the KV pair.
|
||||
#[test]
|
||||
fn test_valueset_application_password_remove() {
|
||||
let app1_uuid = Uuid::new_v4();
|
||||
|
|
|
@ -236,6 +236,7 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
|
|||
("code_challenge_method", "S256"),
|
||||
("redirect_uri", TEST_INTEGRATION_RS_REDIRECT_URL),
|
||||
("scope", "email read openid"),
|
||||
("max_age", "1"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
|
|
|
@ -57,7 +57,7 @@ RUN \
|
|||
--release && \
|
||||
cargo install \
|
||||
--git https://github.com/kanidm/webauthn-rs.git \
|
||||
--rev 5f4db4172f8e22aedc68c282d177e98db2b1892f \
|
||||
--rev v0.5.1 \
|
||||
--force fido-mds-tool \
|
||||
--target-dir="/usr/src/kanidm/target/" && \
|
||||
sccache -s
|
||||
|
|
|
@ -7,7 +7,7 @@ use std::fs::read;
|
|||
impl DomainOpt {
|
||||
pub fn debug(&self) -> bool {
|
||||
match self {
|
||||
DomainOpt::SetDisplayName(copt) => copt.copt.debug,
|
||||
DomainOpt::SetDisplayname(copt) => copt.copt.debug,
|
||||
DomainOpt::SetLdapBasedn { copt, .. }
|
||||
| DomainOpt::SetImage { copt, .. }
|
||||
| DomainOpt::RemoveImage { copt }
|
||||
|
@ -19,7 +19,7 @@ impl DomainOpt {
|
|||
|
||||
pub async fn exec(&self) {
|
||||
match self {
|
||||
DomainOpt::SetDisplayName(opt) => {
|
||||
DomainOpt::SetDisplayname(opt) => {
|
||||
eprintln!(
|
||||
"Attempting to set the domain's display name to: {:?}",
|
||||
opt.new_display_name
|
||||
|
|
|
@ -12,6 +12,12 @@ impl GroupAccountPolicyOpt {
|
|||
| GroupAccountPolicyOpt::LimitSearchMaxResults { copt, .. }
|
||||
| GroupAccountPolicyOpt::LimitSearchMaxFilterTest { copt, .. }
|
||||
| GroupAccountPolicyOpt::AllowPrimaryCredFallback { copt, .. }
|
||||
| GroupAccountPolicyOpt::ResetWebauthnAttestationCaList { copt, .. }
|
||||
| GroupAccountPolicyOpt::ResetAuthSessionExpiry { copt, .. }
|
||||
| GroupAccountPolicyOpt::ResetPasswordMinimumLength { copt, .. }
|
||||
| GroupAccountPolicyOpt::ResetPrivilegedSessionExpiry { copt, .. }
|
||||
| GroupAccountPolicyOpt::ResetLimitSearchMaxResults { copt, .. }
|
||||
| GroupAccountPolicyOpt::ResetLimitSearchMaxFilterTest { copt, .. }
|
||||
| GroupAccountPolicyOpt::PrivilegedSessionExpiry { copt, .. } => copt.debug,
|
||||
}
|
||||
}
|
||||
|
@ -37,6 +43,19 @@ impl GroupAccountPolicyOpt {
|
|||
println!("Updated authsession expiry.");
|
||||
}
|
||||
}
|
||||
|
||||
GroupAccountPolicyOpt::ResetAuthSessionExpiry { name, copt } => {
|
||||
let client = copt.to_client(OpType::Write).await;
|
||||
if let Err(e) = client
|
||||
.group_account_policy_authsession_expiry_reset(name)
|
||||
.await
|
||||
{
|
||||
handle_client_error(e, copt.output_mode);
|
||||
} else {
|
||||
println!("Successfully reset authsession expiry.");
|
||||
}
|
||||
}
|
||||
|
||||
GroupAccountPolicyOpt::CredentialTypeMinimum { name, value, copt } => {
|
||||
let client = copt.to_client(OpType::Write).await;
|
||||
if let Err(e) = client
|
||||
|
@ -59,6 +78,17 @@ impl GroupAccountPolicyOpt {
|
|||
println!("Updated password minimum length.");
|
||||
}
|
||||
}
|
||||
GroupAccountPolicyOpt::ResetPasswordMinimumLength { name, copt } => {
|
||||
let client = copt.to_client(OpType::Write).await;
|
||||
if let Err(e) = client
|
||||
.group_account_policy_password_minimum_length_reset(name)
|
||||
.await
|
||||
{
|
||||
handle_client_error(e, copt.output_mode);
|
||||
} else {
|
||||
println!("Successfully reset password minimum length.");
|
||||
}
|
||||
}
|
||||
GroupAccountPolicyOpt::PrivilegedSessionExpiry { name, expiry, copt } => {
|
||||
let client = copt.to_client(OpType::Write).await;
|
||||
if let Err(e) = client
|
||||
|
@ -70,6 +100,17 @@ impl GroupAccountPolicyOpt {
|
|||
println!("Updated privilege session expiry.");
|
||||
}
|
||||
}
|
||||
GroupAccountPolicyOpt::ResetPrivilegedSessionExpiry { name, copt } => {
|
||||
let client = copt.to_client(OpType::Write).await;
|
||||
if let Err(e) = client
|
||||
.group_account_policy_privilege_expiry_reset(name)
|
||||
.await
|
||||
{
|
||||
handle_client_error(e, copt.output_mode);
|
||||
} else {
|
||||
println!("Successfully reset privilege session expiry.");
|
||||
}
|
||||
}
|
||||
GroupAccountPolicyOpt::WebauthnAttestationCaList {
|
||||
name,
|
||||
attestation_ca_list_json,
|
||||
|
@ -85,6 +126,19 @@ impl GroupAccountPolicyOpt {
|
|||
println!("Updated webauthn attestation CA list.");
|
||||
}
|
||||
}
|
||||
|
||||
GroupAccountPolicyOpt::ResetWebauthnAttestationCaList { name, copt } => {
|
||||
let client = copt.to_client(OpType::Write).await;
|
||||
if let Err(e) = client
|
||||
.group_account_policy_webauthn_attestation_reset(name)
|
||||
.await
|
||||
{
|
||||
handle_client_error(e, copt.output_mode);
|
||||
} else {
|
||||
println!("Successfully reset webauthn attestation CA list.");
|
||||
}
|
||||
}
|
||||
|
||||
GroupAccountPolicyOpt::LimitSearchMaxResults {
|
||||
name,
|
||||
maximum,
|
||||
|
@ -100,6 +154,17 @@ impl GroupAccountPolicyOpt {
|
|||
println!("Updated search maximum results limit.");
|
||||
}
|
||||
}
|
||||
GroupAccountPolicyOpt::ResetLimitSearchMaxResults { name, copt } => {
|
||||
let client = copt.to_client(OpType::Write).await;
|
||||
if let Err(e) = client
|
||||
.group_account_policy_limit_search_max_results_reset(name)
|
||||
.await
|
||||
{
|
||||
handle_client_error(e, copt.output_mode);
|
||||
} else {
|
||||
println!("Successfully reset search maximum results limit to default.");
|
||||
}
|
||||
}
|
||||
GroupAccountPolicyOpt::LimitSearchMaxFilterTest {
|
||||
name,
|
||||
maximum,
|
||||
|
@ -115,6 +180,17 @@ impl GroupAccountPolicyOpt {
|
|||
println!("Updated search maximum filter test limit.");
|
||||
}
|
||||
}
|
||||
GroupAccountPolicyOpt::ResetLimitSearchMaxFilterTest { name, copt } => {
|
||||
let client = copt.to_client(OpType::Write).await;
|
||||
if let Err(e) = client
|
||||
.group_account_policy_limit_search_max_filter_test_reset(name)
|
||||
.await
|
||||
{
|
||||
handle_client_error(e, copt.output_mode);
|
||||
} else {
|
||||
println!("Successfully reset search maximum filter test limit.");
|
||||
}
|
||||
}
|
||||
GroupAccountPolicyOpt::AllowPrimaryCredFallback { name, allow, copt } => {
|
||||
let client = copt.to_client(OpType::Write).await;
|
||||
if let Err(e) = client
|
||||
|
|
|
@ -200,11 +200,14 @@ impl GroupOpt {
|
|||
let client = copt.to_client(OpType::Write).await;
|
||||
|
||||
match client
|
||||
.idm_group_set_entry_managed_by(name.as_str(), entry_managed_by.as_str())
|
||||
.idm_group_set_entry_managed_by(name, entry_managed_by)
|
||||
.await
|
||||
{
|
||||
Err(e) => handle_client_error(e, copt.output_mode),
|
||||
Ok(_) => println!("Successfully set members for group {}", name.as_str()),
|
||||
Ok(_) => println!(
|
||||
"Successfully set entry manager to '{}' for group '{}'",
|
||||
entry_managed_by, name
|
||||
),
|
||||
}
|
||||
}
|
||||
GroupOpt::Posix { commands } => match commands {
|
||||
|
|
|
@ -1219,7 +1219,7 @@ async fn sshkey_add_prompt(session_token: &CUSessionToken, client: &KanidmClient
|
|||
ClientErrorHttp(_, Some(DuplicateKey), _) => {
|
||||
eprintln!("SSH Public Key already exists in this account");
|
||||
}
|
||||
_ => eprintln!("An error occured -> {:?}", err),
|
||||
_ => eprintln!("An error occurred -> {:?}", err),
|
||||
}
|
||||
break;
|
||||
} else {
|
||||
|
@ -1249,7 +1249,7 @@ async fn sshkey_remove_prompt(session_token: &CUSessionToken, client: &KanidmCli
|
|||
ClientErrorHttp(_, Some(NoMatchingEntries), _) => {
|
||||
eprintln!("SSH Public Key does not exist. Keys were NOT removed.");
|
||||
}
|
||||
_ => eprintln!("An error occured -> {:?}", err),
|
||||
_ => eprintln!("An error occurred -> {:?}", err),
|
||||
}
|
||||
} else {
|
||||
println!("Successfully removed SSH Public Key");
|
||||
|
|
|
@ -197,6 +197,8 @@ pub enum GroupAccountPolicyOpt {
|
|||
#[clap(flatten)]
|
||||
copt: CommonOpt,
|
||||
},
|
||||
|
||||
|
||||
/// Set the maximum time for privilege session expiry in seconds.
|
||||
#[clap(name = "privilege-expiry")]
|
||||
PrivilegedSessionExpiry {
|
||||
|
@ -205,6 +207,8 @@ pub enum GroupAccountPolicyOpt {
|
|||
#[clap(flatten)]
|
||||
copt: CommonOpt,
|
||||
},
|
||||
|
||||
|
||||
/// The WebAuthn attestation CA list that should be enforced
|
||||
/// on members of this group. Prevents use of passkeys that are
|
||||
/// not in this list. To create this list, use `fido-mds-tool`
|
||||
|
@ -216,6 +220,7 @@ pub enum GroupAccountPolicyOpt {
|
|||
#[clap(flatten)]
|
||||
copt: CommonOpt,
|
||||
},
|
||||
|
||||
/// Sets the maximum number of entries that may be returned in a
|
||||
/// search operation.
|
||||
#[clap(name = "limit-search-max-results")]
|
||||
|
@ -245,6 +250,51 @@ pub enum GroupAccountPolicyOpt {
|
|||
#[clap(flatten)]
|
||||
copt: CommonOpt,
|
||||
},
|
||||
|
||||
/// Reset the maximum time for session expiry to its default value
|
||||
#[clap(name = "reset-auth-expiry")]
|
||||
ResetAuthSessionExpiry {
|
||||
name: String,
|
||||
#[clap(flatten)]
|
||||
copt: CommonOpt,
|
||||
},
|
||||
/// Reset the minimum character length of passwords to its default value.
|
||||
#[clap(name = "reset-password-minimum-length")]
|
||||
ResetPasswordMinimumLength {
|
||||
name: String,
|
||||
#[clap(flatten)]
|
||||
copt: CommonOpt,
|
||||
},
|
||||
/// Reset the maximum time for privilege session expiry to its default value.
|
||||
#[clap(name = "reset-privilege-expiry")]
|
||||
ResetPrivilegedSessionExpiry {
|
||||
name: String,
|
||||
#[clap(flatten)]
|
||||
copt: CommonOpt,
|
||||
},
|
||||
/// Reset the WebAuthn attestation CA list to its default value
|
||||
/// allowing any passkey to be used by members of this group.
|
||||
#[clap(name = "reset-webauthn-attestation-ca-list")]
|
||||
ResetWebauthnAttestationCaList {
|
||||
name: String,
|
||||
#[clap(flatten)]
|
||||
copt: CommonOpt,
|
||||
},
|
||||
/// Reset the searche maxmium results limit to its default value.
|
||||
#[clap(name = "reset-limit-search-max-results")]
|
||||
ResetLimitSearchMaxResults {
|
||||
name: String,
|
||||
#[clap(flatten)]
|
||||
copt: CommonOpt,
|
||||
},
|
||||
/// Reset the max filter test limit to its default value.
|
||||
#[clap(name = "reset-limit-search-max-filter-test")]
|
||||
ResetLimitSearchMaxFilterTest {
|
||||
name: String,
|
||||
#[clap(flatten)]
|
||||
copt: CommonOpt,
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
|
@ -943,7 +993,7 @@ pub struct Oauth2CreateScopeMapOpt {
|
|||
nopt: Named,
|
||||
#[clap(name = "group")]
|
||||
group: String,
|
||||
#[clap(name = "scopes")]
|
||||
#[clap(name = "scopes", required = true)]
|
||||
scopes: Vec<String>,
|
||||
}
|
||||
|
||||
|
@ -1078,7 +1128,7 @@ pub enum Oauth2Opt {
|
|||
#[clap(name = "delete")]
|
||||
/// Delete a oauth2 client
|
||||
Delete(Named),
|
||||
/// Set a new displayname for a client
|
||||
/// Set a new display name for a client
|
||||
#[clap(name = "set-displayname")]
|
||||
SetDisplayname(Oauth2SetDisplayname),
|
||||
/// Set a new name for this client. You may need to update
|
||||
|
@ -1147,7 +1197,7 @@ pub enum Oauth2Opt {
|
|||
DisablePkce(Named),
|
||||
#[clap(name = "warning-enable-legacy-crypto")]
|
||||
/// Enable legacy signing crypto on this oauth2 client. This defaults to being disabled.
|
||||
/// You only need to enable this for openid clients that do not support modern crytopgraphic
|
||||
/// You only need to enable this for openid clients that do not support modern cryptographic
|
||||
/// operations.
|
||||
EnableLegacyCrypto(Named),
|
||||
/// Disable legacy signing crypto on this oauth2 client. This is the default.
|
||||
|
@ -1197,7 +1247,7 @@ pub enum Oauth2Opt {
|
|||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct OptSetDomainDisplayName {
|
||||
pub struct OptSetDomainDisplayname {
|
||||
#[clap(flatten)]
|
||||
copt: CommonOpt,
|
||||
#[clap(name = "new-display-name")]
|
||||
|
@ -1260,9 +1310,9 @@ pub enum DeniedNamesOpt {
|
|||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum DomainOpt {
|
||||
#[clap[name = "set-display-name"]]
|
||||
#[clap[name = "set-displayname"]]
|
||||
/// Set the domain display name
|
||||
SetDisplayName(OptSetDomainDisplayName),
|
||||
SetDisplayname(OptSetDomainDisplayname),
|
||||
#[clap[name = "set-ldap-basedn"]]
|
||||
/// Change the basedn of this server. Takes effect after a server restart.
|
||||
/// Examples are `o=organisation` or `dc=domain,dc=name`. Must be a valid ldap
|
||||
|
|
|
@ -926,9 +926,8 @@ fn ipa_to_scim_entry(
|
|||
let account_valid_from = None;
|
||||
|
||||
let login_shell = entry.remove_ava_single(Attribute::LoginShell.as_ref());
|
||||
let external_id = Some(entry.dn);
|
||||
|
||||
let scim_sync_person = ScimSyncPerson::builder(id, user_name, display_name)
|
||||
let scim_sync_person = ScimSyncPerson::builder(id, entry.dn, user_name, display_name)
|
||||
.set_gidnumber(gidnumber)
|
||||
.set_password_import(password_import)
|
||||
.set_unix_password_import(unix_password_import)
|
||||
|
@ -938,11 +937,10 @@ fn ipa_to_scim_entry(
|
|||
.set_ssh_publickey(ssh_publickey)
|
||||
.set_account_expire(account_expire)
|
||||
.set_account_valid_from(account_valid_from)
|
||||
.set_external_id(external_id)
|
||||
.build();
|
||||
|
||||
let scim_entry_generic: ScimEntry = scim_sync_person.try_into().map_err(|json_err| {
|
||||
error!(?json_err, "Unable to convert group to scim_sync_group");
|
||||
error!(?json_err, "Unable to convert person to scim_sync_person");
|
||||
})?;
|
||||
|
||||
Ok(Some(scim_entry_generic))
|
||||
|
@ -983,13 +981,10 @@ fn ipa_to_scim_entry(
|
|||
.map(|set| set.into_iter().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let external_id = Some(entry.dn);
|
||||
|
||||
let scim_sync_group = ScimSyncGroup::builder(name, id)
|
||||
let scim_sync_group = ScimSyncGroup::builder(id, entry.dn, name)
|
||||
.set_description(description)
|
||||
.set_gidnumber(gidnumber)
|
||||
.set_members(members.into_iter())
|
||||
.set_external_id(external_id)
|
||||
.build();
|
||||
|
||||
let scim_entry_generic: ScimEntry = scim_sync_group.try_into().map_err(|json_err| {
|
||||
|
|
|
@ -617,9 +617,8 @@ fn ldap_to_scim_entry(
|
|||
let login_shell = entry
|
||||
.get_ava_single(&sync_config.person_attr_login_shell)
|
||||
.map(str::to_string);
|
||||
let external_id = Some(entry.dn);
|
||||
|
||||
let scim_sync_person = ScimSyncPerson::builder(id, user_name, display_name)
|
||||
let scim_sync_person = ScimSyncPerson::builder(id, entry.dn, user_name, display_name)
|
||||
.set_gidnumber(gidnumber)
|
||||
.set_password_import(password_import)
|
||||
.set_unix_password_import(unix_password_import)
|
||||
|
@ -629,11 +628,10 @@ fn ldap_to_scim_entry(
|
|||
.set_ssh_publickey(ssh_publickey)
|
||||
.set_account_expire(account_expire)
|
||||
.set_account_valid_from(account_valid_from)
|
||||
.set_external_id(external_id)
|
||||
.build();
|
||||
|
||||
let scim_entry_generic: ScimEntry = scim_sync_person.try_into().map_err(|json_err| {
|
||||
error!(?json_err, "Unable to convert group to scim_sync_group");
|
||||
error!(?json_err, "Unable to convert person to scim_sync_person");
|
||||
})?;
|
||||
|
||||
Ok(Some(scim_entry_generic))
|
||||
|
@ -678,13 +676,10 @@ fn ldap_to_scim_entry(
|
|||
.map(|set| set.into_iter().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let external_id = Some(entry.dn);
|
||||
|
||||
let scim_sync_group = ScimSyncGroup::builder(name, id)
|
||||
let scim_sync_group = ScimSyncGroup::builder(id, entry.dn, name)
|
||||
.set_description(description)
|
||||
.set_gidnumber(gidnumber)
|
||||
.set_members(members.into_iter())
|
||||
.set_external_id(external_id)
|
||||
.build();
|
||||
|
||||
let scim_entry_generic: ScimEntry = scim_sync_group.try_into().map_err(|json_err| {
|
||||
|
|
|
@ -106,8 +106,10 @@ pub enum PamAuthRequest {
|
|||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct PamServiceInfo {
|
||||
pub service: String,
|
||||
pub tty: String,
|
||||
pub rhost: String,
|
||||
// Somehow SDDM doesn't set this ...
|
||||
pub tty: Option<String>,
|
||||
// Only set if it really is a remote host?
|
||||
pub rhost: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
|
@ -144,7 +146,10 @@ impl ClientRequest {
|
|||
ClientRequest::NssGroupByName(id) => format!("NssGroupByName({})", id),
|
||||
ClientRequest::PamAuthenticateInit { account_id, info } => format!(
|
||||
"PamAuthenticateInit{{ account_id={} tty={} pam_secvice{} rhost={} }}",
|
||||
account_id, info.service, info.tty, info.rhost
|
||||
account_id,
|
||||
info.service,
|
||||
info.tty.as_deref().unwrap_or(""),
|
||||
info.rhost.as_deref().unwrap_or("")
|
||||
),
|
||||
ClientRequest::PamAuthenticateStep(_) => "PamAuthenticateStep".to_string(),
|
||||
ClientRequest::PamAccountAllowed(id) => {
|
||||
|
|
|
@ -35,6 +35,7 @@ maintainer = "James Hodgkinson <james@terminaloutcomes.com>"
|
|||
depends = ["libc6", "libpam0g"]
|
||||
section = "network"
|
||||
priority = "optional"
|
||||
maintainer-scripts = "debian/"
|
||||
assets = [
|
||||
# Empty on purpose
|
||||
]
|
||||
|
|
|
@ -4,7 +4,7 @@ Priority: 128
|
|||
|
||||
Auth-Type: Primary
|
||||
Auth:
|
||||
[success=end new_authtok_reqd=done default=ignore] pam_kanidm.so ignore_unknown_user
|
||||
[success=end new_authtok_reqd=done default=ignore] pam_kanidm.so ignore_unknown_user use_first_pass
|
||||
|
||||
Account-Type: Primary
|
||||
Account:
|
||||
|
|
29
unix_integration/pam_kanidm/debian/postinst
Normal file
29
unix_integration/pam_kanidm/debian/postinst
Normal file
|
@ -0,0 +1,29 @@
|
|||
#!/bin/sh
|
||||
# postinst script for libpam-kanidm
|
||||
#
|
||||
# see: dh_installdeb(1)
|
||||
|
||||
set -e
|
||||
|
||||
|
||||
case "$1" in
|
||||
configure)
|
||||
echo "Updating PAM configuration"
|
||||
pam-auth-update --package
|
||||
;;
|
||||
|
||||
abort-upgrade|abort-remove|abort-deconfigure)
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "postinst called with unknown argument \`$1'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# dh_installdeb will replace this with shell code automatically
|
||||
# generated by other debhelper scripts.
|
||||
|
||||
#DEBHELPER#
|
||||
|
||||
exit 0
|
|
@ -140,7 +140,7 @@ pub fn sm_authenticate_connected<P: PamHandler>(
|
|||
let client_response = match daemon_client.call_and_wait(&req, timeout) {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
// Something unrecoverable occured, bail and stop everything
|
||||
// Something unrecoverable occurred, bail and stop everything
|
||||
error!(?err, "PAM_AUTH_ERR");
|
||||
return PamResultCode::PAM_AUTH_ERR;
|
||||
}
|
||||
|
|
|
@ -256,7 +256,7 @@ impl PamHandle {
|
|||
tracing::debug!(?maybe_tty, ?maybe_rhost, ?maybe_service);
|
||||
|
||||
match (maybe_tty, maybe_rhost, maybe_service) {
|
||||
(Some(tty), Some(rhost), Some(service)) => Ok(PamServiceInfo {
|
||||
(tty, rhost, Some(service)) => Ok(PamServiceInfo {
|
||||
service,
|
||||
tty,
|
||||
rhost,
|
||||
|
|
|
@ -7,7 +7,7 @@ After=chronyd.service nscd.service ntpd.service network-online.target
|
|||
Before=systemd-user-sessions.service sshd.service nss-user-lookup.target
|
||||
Wants=nss-user-lookup.target
|
||||
# While it seems confusing, we need to be after nscd.service so that the
|
||||
# Conflicts will triger and then automatically stop it.
|
||||
# Conflicts will trigger and then automatically stop it.
|
||||
Conflicts=nscd.service
|
||||
|
||||
[Service]
|
||||
|
|
|
@ -8,13 +8,12 @@ set -e
|
|||
|
||||
case "$1" in
|
||||
configure)
|
||||
pam-auth-update --package
|
||||
echo "============================="
|
||||
echo "Thanks for installing Kanidm!"
|
||||
echo "============================="
|
||||
echo "Please ensure you modify the configuration files at /etc/kanidm/unixd and /etc/kanidm/config"
|
||||
echo "Full examples are in /usr/share/kanidm-unixd/"
|
||||
echo "To configure nsswitch, please follow instructions in https://kanidm.github.io/kanidm/master/integrations/pam_and_nsswitch.html"
|
||||
echo "PAM has already been autoconfigured by the libpam-kanidm package. To configure nsswitch, please follow instructions in https://kanidm.github.io/kanidm/master/integrations/pam_and_nsswitch.html"
|
||||
;;
|
||||
|
||||
abort-upgrade|abort-remove|abort-deconfigure)
|
||||
|
|
|
@ -67,8 +67,8 @@ async fn main() -> ExitCode {
|
|||
account_id: account_id.clone(),
|
||||
info: PamServiceInfo {
|
||||
service: "kanidm-unix".to_string(),
|
||||
tty: "/dev/null".to_string(),
|
||||
rhost: "localhost".to_string(),
|
||||
tty: None,
|
||||
rhost: None,
|
||||
},
|
||||
};
|
||||
loop {
|
||||
|
|
|
@ -159,7 +159,7 @@ pub enum AuthRequest {
|
|||
MFAPoll {
|
||||
/// Message to display to the user.
|
||||
msg: String,
|
||||
/// Interval in seconds between poll attemts.
|
||||
/// Interval in seconds between poll attempts.
|
||||
polling_interval: u32,
|
||||
},
|
||||
MFAPollWait,
|
||||
|
@ -209,7 +209,7 @@ pub trait IdProvider {
|
|||
async fn attempt_online(&self, _tpm: &mut tpm::BoxedDynTpm, _now: SystemTime) -> bool;
|
||||
|
||||
/// Mark that this provider should attempt to go online next time it
|
||||
/// recieves a request
|
||||
/// receives a request
|
||||
async fn mark_next_check(&self, _now: SystemTime);
|
||||
|
||||
/// Force this provider offline immediately.
|
||||
|
|
|
@ -23,6 +23,7 @@ use kanidm_unix_common::unix_proto::PamAuthRequest;
|
|||
const KANIDM_HMAC_KEY: &str = "kanidm-hmac-key";
|
||||
const KANIDM_PWV1_KEY: &str = "kanidm-pw-v1";
|
||||
|
||||
// If the provider is offline, we need to backoff and wait a bit.
|
||||
const OFFLINE_NEXT_CHECK: Duration = Duration::from_secs(60);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
@ -243,6 +244,7 @@ impl UserToken {
|
|||
}
|
||||
|
||||
impl KanidmProviderInternal {
|
||||
#[instrument(level = "debug", skip_all)]
|
||||
async fn check_online(&mut self, tpm: &mut tpm::BoxedDynTpm, now: SystemTime) -> bool {
|
||||
match self.state {
|
||||
// Proceed
|
||||
|
@ -255,23 +257,35 @@ impl KanidmProviderInternal {
|
|||
}
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip_all)]
|
||||
async fn attempt_online(&mut self, _tpm: &mut tpm::BoxedDynTpm, now: SystemTime) -> bool {
|
||||
match self.client.auth_anonymous().await {
|
||||
Ok(_uat) => {
|
||||
self.state = CacheState::Online;
|
||||
true
|
||||
}
|
||||
Err(ClientError::Transport(err)) => {
|
||||
warn!(?err, "transport failure");
|
||||
self.state = CacheState::OfflineNextCheck(now + OFFLINE_NEXT_CHECK);
|
||||
false
|
||||
}
|
||||
Err(err) => {
|
||||
error!(?err, "Provider authentication failed");
|
||||
self.state = CacheState::OfflineNextCheck(now + OFFLINE_NEXT_CHECK);
|
||||
false
|
||||
let mut max_attempts = 3;
|
||||
while max_attempts > 0 {
|
||||
max_attempts -= 1;
|
||||
match self.client.auth_anonymous().await {
|
||||
Ok(_uat) => {
|
||||
debug!("provider is now online");
|
||||
self.state = CacheState::Online;
|
||||
return true;
|
||||
}
|
||||
Err(ClientError::Http(StatusCode::UNAUTHORIZED, reason, opid)) => {
|
||||
error!(?reason, ?opid, "Provider authentication returned unauthorized, {max_attempts} attempts remaining.");
|
||||
// Provider needs to re-auth ASAP. We set this state value here
|
||||
// so that if we exceed max attempts, the next caller knows to check
|
||||
// online immediately.
|
||||
self.state = CacheState::OfflineNextCheck(now);
|
||||
// attempt again immediately!!!!
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
error!(?err, "Provider online failed");
|
||||
self.state = CacheState::OfflineNextCheck(now + OFFLINE_NEXT_CHECK);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
warn!("Exceeded maximum number of attempts to bring provider online");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -351,7 +365,8 @@ impl IdProvider for KanidmProvider {
|
|||
e, opid
|
||||
),
|
||||
};
|
||||
inner.state = CacheState::OfflineNextCheck(now + OFFLINE_NEXT_CHECK);
|
||||
// Provider needs to re-auth ASAP
|
||||
inner.state = CacheState::OfflineNextCheck(now);
|
||||
Ok(UserTokenState::UseCached)
|
||||
}
|
||||
// 404 / Removed.
|
||||
|
@ -458,7 +473,7 @@ impl IdProvider for KanidmProvider {
|
|||
Ok(AuthResult::Denied)
|
||||
}
|
||||
Err(ClientError::Transport(err)) => {
|
||||
error!(?err);
|
||||
error!(?err, "A client transport error occured.");
|
||||
Err(IdpError::Transport)
|
||||
}
|
||||
Err(ClientError::Http(StatusCode::UNAUTHORIZED, reason, opid)) => {
|
||||
|
|
|
@ -8,6 +8,9 @@ use kanidm_unix_common::unix_passwd::{CryptPw, EtcGroup, EtcShadow, EtcUser};
|
|||
use kanidm_unix_common::unix_proto::PamAuthRequest;
|
||||
use kanidm_unix_common::unix_proto::{NssGroup, NssUser};
|
||||
|
||||
// The minimum GID that Kanidm will consider for creating a UPG
|
||||
const SYSTEM_GID_BOUNDARY: u32 = 1000;
|
||||
|
||||
pub struct SystemProviderInternal {
|
||||
users: HashMap<Id, Arc<EtcUser>>,
|
||||
user_list: Vec<Arc<EtcUser>>,
|
||||
|
@ -223,22 +226,22 @@ impl SystemProvider {
|
|||
let uid = Id::Gid(user.uid);
|
||||
let gid = Id::Gid(user.gid);
|
||||
|
||||
if user.uid != user.gid {
|
||||
error!(name = %user.name, uid = %user.uid, gid = %user.gid, "user uid and gid are not the same, this may be a security risk!");
|
||||
}
|
||||
|
||||
// Security checks.
|
||||
if let Some(group) = system_ids_txn.groups.get(&gid) {
|
||||
if user.uid != user.gid {
|
||||
warn!(name = %user.name, uid = %user.uid, gid = %user.gid, "user uid and gid are not the same, this may be a security risk!");
|
||||
} else if let Some(group) = system_ids_txn.groups.get(&gid) {
|
||||
if group.name != user.name {
|
||||
error!(name = %user.name, uid = %user.uid, gid = %user.gid, "user private group does not appear to have the same name as the user, this may be a security risk!");
|
||||
warn!(name = %user.name, uid = %user.uid, gid = %user.gid, "user private group does not appear to have the same name as the user, this may be a security risk!");
|
||||
}
|
||||
if !(group.members.is_empty()
|
||||
|| (group.members.len() == 1 && group.members.first() == Some(&user.name)))
|
||||
{
|
||||
error!(name = %user.name, uid = %user.uid, gid = %user.gid, members = ?group.members, "user private group must not have members, THIS IS A SECURITY RISK!");
|
||||
warn!(name = %user.name, uid = %user.uid, gid = %user.gid, members = ?group.members, "user private group must not have members, THIS IS A SECURITY RISK!");
|
||||
}
|
||||
} else if user.uid < SYSTEM_GID_BOUNDARY {
|
||||
warn!(name = %user.name, uid = %user.uid, gid = %user.gid, "user private group is not present on system, ignoring as this is a system account.");
|
||||
} else {
|
||||
info!(name = %user.name, uid = %user.uid, gid = %user.gid, "user private group is not present on system, synthesising it");
|
||||
info!(name = %user.name, uid = %user.uid, gid = %user.gid, "user private group is not present on system, synthesising it.");
|
||||
let group = Arc::new(EtcGroup {
|
||||
name: user.name.clone(),
|
||||
password: String::new(),
|
||||
|
|
|
@ -51,17 +51,21 @@ pub enum AuthSession {
|
|||
token: Option<Box<UserToken>>,
|
||||
cred_handler: AuthCredHandler,
|
||||
/// Some authentication operations may need to spawn background tasks. These tasks need
|
||||
/// to know when to stop as the caller has disconnected. This reciever allows that, so
|
||||
/// to know when to stop as the caller has disconnected. This receiver allows that, so
|
||||
/// that tasks which .resubscribe() to this channel can then select! on it and be notified
|
||||
/// when they need to stop.
|
||||
shutdown_rx: broadcast::Receiver<()>,
|
||||
},
|
||||
Offline {
|
||||
account_id: String,
|
||||
id: Id,
|
||||
client: Arc<dyn IdProvider + Sync + Send>,
|
||||
token: Box<UserToken>,
|
||||
cred_handler: AuthCredHandler,
|
||||
},
|
||||
System {
|
||||
account_id: String,
|
||||
id: Id,
|
||||
cred_handler: AuthCredHandler,
|
||||
shadow: Arc<Shadow>,
|
||||
},
|
||||
|
@ -408,9 +412,12 @@ impl Resolver {
|
|||
}
|
||||
None => {
|
||||
error!(provider = ?tok.provider, "Token was resolved by a provider that no longer appears to be present.");
|
||||
// We don't know if this is permanent or transient, so just useCached, unless
|
||||
// the admin clears tokens from providers that are no longer present.
|
||||
Ok(UserTokenState::UseCached)
|
||||
|
||||
// We don't want to use a token from a former provider, we want it refreshed,
|
||||
// so lets indicate that we didn't find the token. If we return useCcahed like
|
||||
// we did previously, we'd never clear and reset this token since we'd never
|
||||
// locate it's provider.
|
||||
Ok(UserTokenState::NotFound)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
@ -477,9 +484,11 @@ impl Resolver {
|
|||
}
|
||||
None => {
|
||||
error!(provider = ?tok.provider, "Token was resolved by a provider that no longer appears to be present.");
|
||||
// We don't know if this is permanent or transient, so just useCached, unless
|
||||
// the admin clears tokens from providers that are no longer present.
|
||||
Ok(GroupTokenState::UseCached)
|
||||
// We don't want to use a token from a former provider, we want it refreshed,
|
||||
// so lets indicate that we didn't find the token. If we return useCcahed like
|
||||
// we did previously, we'd never clear and reset this token since we'd never
|
||||
// locate it's provider.
|
||||
Ok(GroupTokenState::NotFound)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
@ -818,7 +827,7 @@ impl Resolver {
|
|||
match self.system_provider.auth_init(&id, current_time).await {
|
||||
// The system provider will not take part in this authentication.
|
||||
SystemProviderAuthInit::Ignore => {
|
||||
debug!("account unknown to system provider, continue.");
|
||||
debug!(?account_id, "account unknown to system provider, continue.");
|
||||
}
|
||||
// The provider knows the account, and is unable to proceed,
|
||||
// We return unknown here so that pam_kanidm can be skipped and fall back
|
||||
|
@ -853,6 +862,8 @@ impl Resolver {
|
|||
shadow,
|
||||
} => {
|
||||
let auth_session = AuthSession::System {
|
||||
account_id: account_id.to_string(),
|
||||
id,
|
||||
shadow,
|
||||
cred_handler,
|
||||
};
|
||||
|
@ -916,6 +927,8 @@ impl Resolver {
|
|||
match init_result {
|
||||
Ok((next_req, cred_handler)) => {
|
||||
let auth_session = AuthSession::Offline {
|
||||
account_id: account_id.to_string(),
|
||||
id,
|
||||
client,
|
||||
token: Box::new(token),
|
||||
cred_handler,
|
||||
|
@ -998,7 +1011,7 @@ impl Resolver {
|
|||
ref shutdown_rx,
|
||||
} => {
|
||||
let mut hsm_lock = self.hsm.lock().await;
|
||||
client
|
||||
let result = client
|
||||
.unix_user_online_auth_step(
|
||||
account_id,
|
||||
cred_handler,
|
||||
|
@ -1006,9 +1019,26 @@ impl Resolver {
|
|||
hsm_lock.deref_mut(),
|
||||
shutdown_rx,
|
||||
)
|
||||
.await
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(AuthResult::Success { .. }) => {
|
||||
info!(?account_id, "Authentication Success");
|
||||
}
|
||||
Ok(AuthResult::Denied) => {
|
||||
info!(?account_id, "Authentication Denied");
|
||||
}
|
||||
Ok(AuthResult::Next(_)) => {
|
||||
info!(?account_id, "Authentication Continue");
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
|
||||
result
|
||||
}
|
||||
&mut AuthSession::Offline {
|
||||
ref account_id,
|
||||
id: _,
|
||||
ref client,
|
||||
ref token,
|
||||
ref mut cred_handler,
|
||||
|
@ -1016,16 +1046,33 @@ impl Resolver {
|
|||
// We are offline, continue. Remember, authsession should have
|
||||
// *everything you need* to proceed here!
|
||||
let mut hsm_lock = self.hsm.lock().await;
|
||||
client
|
||||
let result = client
|
||||
.unix_user_offline_auth_step(
|
||||
token,
|
||||
cred_handler,
|
||||
pam_next_req,
|
||||
hsm_lock.deref_mut(),
|
||||
)
|
||||
.await
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(AuthResult::Success { .. }) => {
|
||||
info!(?account_id, "Authentication Success");
|
||||
}
|
||||
Ok(AuthResult::Denied) => {
|
||||
info!(?account_id, "Authentication Denied");
|
||||
}
|
||||
Ok(AuthResult::Next(_)) => {
|
||||
info!(?account_id, "Authentication Continue");
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
|
||||
result
|
||||
}
|
||||
&mut AuthSession::System {
|
||||
ref account_id,
|
||||
id: _,
|
||||
ref mut cred_handler,
|
||||
ref shadow,
|
||||
} => {
|
||||
|
@ -1036,11 +1083,15 @@ impl Resolver {
|
|||
|
||||
let next = match system_auth_result {
|
||||
SystemAuthResult::Denied => {
|
||||
info!(?account_id, "Authentication Denied");
|
||||
|
||||
*auth_session = AuthSession::Denied;
|
||||
|
||||
Ok(PamAuthResponse::Denied)
|
||||
}
|
||||
SystemAuthResult::Success => {
|
||||
info!(?account_id, "Authentication Success");
|
||||
|
||||
*auth_session = AuthSession::Success;
|
||||
|
||||
Ok(PamAuthResponse::Success)
|
||||
|
@ -1057,7 +1108,6 @@ impl Resolver {
|
|||
match maybe_err {
|
||||
// What did the provider direct us to do next?
|
||||
Ok(AuthResult::Success { mut token }) => {
|
||||
debug!("provider authentication success.");
|
||||
self.set_cache_usertoken(&mut token).await?;
|
||||
*auth_session = AuthSession::Success;
|
||||
|
||||
|
@ -1069,8 +1119,17 @@ impl Resolver {
|
|||
Ok(PamAuthResponse::Denied)
|
||||
}
|
||||
Ok(AuthResult::Next(req)) => Ok(req.into()),
|
||||
Err(IdpError::NotFound) => Ok(PamAuthResponse::Unknown),
|
||||
_ => Err(()),
|
||||
Err(IdpError::NotFound) => {
|
||||
*auth_session = AuthSession::Denied;
|
||||
|
||||
Ok(PamAuthResponse::Unknown)
|
||||
}
|
||||
Err(err) => {
|
||||
*auth_session = AuthSession::Denied;
|
||||
|
||||
error!(?err, "Unable to proceed, failing the session");
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1086,8 +1145,8 @@ impl Resolver {
|
|||
|
||||
let pam_info = PamServiceInfo {
|
||||
service: "kanidm-unix-test".to_string(),
|
||||
tty: "/dev/null".to_string(),
|
||||
rhost: "localhost".to_string(),
|
||||
tty: Some("/dev/null".to_string()),
|
||||
rhost: None,
|
||||
};
|
||||
|
||||
let mut auth_session = match self
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue