Compare commits

...
Sign in to create a new pull request.

66 commits

Author SHA1 Message Date
Jinna Kiisuo
7ce4af79ba
Backport b6f63f3 to fix unixd default config (#3489)
The change to require the [kanidm] header was already true for 1.4 and it's now breaking new installs via the PPA.
2025-03-08 12:51:06 +10:00
Jinna Kiisuo
3f47d7f008 fix: PAM on Debian, enable use_first_pass by default (#3326)
Since we use Debian's PAM autoconf, pam_unix isn't disabled and remains active.
This means pam_unix triggers first and pam_kanidm should use the password it already tried to match to a local user.

This change also moves the postinst hook for PAM config correctly to the libpam-kanidm package,
since that's the one that delivers the config that needs a reinstall!
2025-02-05 10:43:45 +10:00
William Brown
3ce4e0ff87 Release 1.4.6 2025-01-24 09:45:54 +10:30
George Wu
855e45bbb7 Small UI updates. (#3361)
* Delete unused htmx javascript files.

* Consistently mention applications instead of apps.

* Small formatting change for enrol device.

* Update phrasing in credentials page.
2025-01-24 09:30:02 +10:30
Firstyear
013661bbf4 Allow modification of password minimum length (#3345)
Allow all account policy values to be altered on system protected
objects.
2025-01-09 11:50:47 +10:00
Firstyear
51a976fed5 Ignore anonymous in oauth2 read allow access (#3336)
Administrators will sometimes configure oauth2 clients with `idm_all_accounts`
as an allowed scope group. Despite anonymous being *unable* to interact with
oauth2, this still allowed oauth2 clients to be read by anonymous in this
configuration. For some users, this may be considered a public info
disclosure.
2025-01-08 09:40:48 +10:00
Firstyear
fee2d3b0d6 Resolve passkey regression (#3343)
During other testing I noticed that passkeys no longer worked
on a reauthentication. This was due to a regression in you
guessed it, cookies, where the auth session id wasn't being
removed properly.
2025-01-08 09:40:28 +10:00
James Hodgkinson
a810bc43c0 Renaming "TOTP" in the login flow (#3338) 2025-01-08 09:40:28 +10:00
Firstyear
095df1b216 cookies don't clear unless you set domain (#3332)
* make everything cookie consistent
* Stricter on expiry
* Relearn a painful lesson about needing domains in removal cookies
* fix: DRY cookie creation code and reduce the sins
2025-01-04 10:34:29 +10:00
William Brown
a7fabdedef Release 1.4.5 2024-12-21 17:34:49 +10:00
Firstyear
4803710026 nss/pam resolver should reauth faster (#3309)
This can have visible impacts on accounts that don't have a pam password
cached yet, but then appear to "stall" for a minute or two until it works
due to the fact that the provider was offline and waiting to reauth.

When we are still connected but our provider auth session has expired
we should reconnect faster. This reduces the timeout for reauthentication
for the provider so that it can return to the online state sooner. We
also loop when we detect the provider session is no longer authenticated
so that we can reauth immediately, rather than causing a noticable
interuption.
2024-12-21 17:22:02 +10:00
Firstyear
2f7279d8db Further SCIM sync testing, minor fixes (#3305)
This adds further testing of SCIM sync, especially around
conversion of the SCIM Sync Person and Group types into
SCIM Entry. This test would have prevented #3298 and
 #3299 from occuring.

During testing two more fixes were found. external_id should have
been required (not optional) and a group with no members would
cause a serialisation issue.
2024-12-21 17:22:02 +10:00
Firstyear
46ad459a56 Automatically trigger passkeys on login view (#3307)
Add an on-load handler to pkhtml.js so that when the partial
view is displayed passkey auth is automatically prompted for.
If the users browser blocks this event, the fallback manual
buttons still exist.
2024-12-21 17:22:02 +10:00
William Brown
beb937f303 Re-add enrol another device flow
This was a commonly requested re-addition to the new webui. This
adds the ability for someone to scan a qr code or follow a link
to enrol another device to their account.
2024-12-21 17:22:02 +10:00
William Brown
ab8dd18e4f Improved Cookie Removal
If a path isn't set then cookies aren't removed. More aggressively
remove cookies when they are no longer required.
2024-12-21 17:22:02 +10:00
Firstyear
e0bc19d033 Allow reseting account policy values to defaults (#3306)
* Allow reseting account policy values to defaults

This allows the admin cli to reset account policy values to
defaults by clearing them. Due to how account policy resolves
a lack of value implies the default.
2024-12-21 17:22:02 +10:00
Firstyear
f481e033ef Incorrect member name in groups (#3302)
Member was accidentally set to members which prevented
group synchronisation.
2024-12-21 17:22:02 +10:00
Firstyear
1909d1a15a SCIM Sync Missing Annotation (#3300)
A missing serde annotion in SCIM Sync caused groups to fail to
sync unless they had a description. This resolves the failure
by adding the correct annotation to skip None fields in groups.
2024-12-21 17:22:02 +10:00
Firstyear
6b0c8be718 Ignore system users for UPG synthesiseation (#3297)
Our unix resolver would attempt the right thing to synthesise
user private groups on linux as these are an important security
boundary. However, it turns out that almost every distro has
botched their default system user accounts, and many are
installed with numeric-only UPGs that don't resolve. In the
case that later the user does attempt to fix that, because we
synthesised as UPG for the system account, the user trying to
add the UPG would now fail. In some cases this could cause
system updates to be prevented from installing.

This change limits UPG synth to user accounts only (uid > 1000)
which is the common uid boundary on unix-like platforms.
2024-12-21 17:22:02 +10:00
Firstyear
caa8b2d7a6 Limit OAuth2 resumption to session (#3296)
OAuth2 session resumption was accidentally made a permanent cookie
which led to continuing issues with it causing invalid redirections
after login. Make this a session only cookie.
2024-12-21 17:21:59 +10:00
Firstyear
ab8ef8d977 Use specific errors for intent token revoked (#3291)
Rather than the generic 'invalid state' error, we now return
proper site-specific errors for credential commit failures, with
error messages to explain what went wrong.
2024-12-21 17:14:51 +10:00
Firstyear
d4a373365e Autocomplete password during reauth with TOTP (#3290)
During a re-auth flow, the password was not autocompleted once
totp was autocompleted. This is because in a normal login flow
the autocomplete is performed on the first login.html page,
but in a re-auth we skip that page.

This adds the proper handling to allow the pw to autofill
in the background once the TOTP is completed.
2024-12-21 17:14:51 +10:00
Firstyear
0d967b8dbe Add CORS headers to jwks and userinfo (#3283)
When using jwks from a single page application, the keys and
userinfo were unable to be retrieved due to missing cors headers.
2024-12-13 15:26:29 +10:00
William Brown
c3dbf83312 Release 1.4.4 2024-12-03 15:55:10 +10:00
James Hodgkinson
bc61225600 Check DNS on replication loop start not at task start (#3243) 2024-12-03 14:00:51 +10:00
Firstyear
2ee5f0ccc4 Work around systemd race condition (#3262)
Systemd reload can't handle us reloading so quickly which
causes "reload or restart" to always "restart" kanidm incorrectly.
2024-12-03 14:00:23 +10:00
Firstyear
7c82c951f5 Clear invalid tokens from unix resolver (#3256) 2024-12-03 14:00:23 +10:00
James Hodgkinson
dafc98b1db Allow OAuth2 loopback redirects if the path matches (#3252) 2024-12-03 14:00:23 +10:00
Firstyear
c5f8196666 Correctly display domain name on login (#3254) 2024-12-03 14:00:23 +10:00
Firstyear
24c95ff5ff Display account_id during success/deny paths in unixd (#3253) 2024-12-03 14:00:23 +10:00
George Wu
a2119c54c5 s/idm_people_self_write_mail/idm_people_self_mail_write/g (#3250) 2024-11-30 11:25:39 +10:00
James Hodgkinson
c464f0bd9e handle missing map_group setting in config (#3242) 2024-11-29 12:13:06 +10:00
micolous
2882967f54 owncloud: Add SameSite=Lax config for cross-domain auth (#3245) 2024-11-29 12:13:06 +10:00
James Hodgkinson
d6f6a2671d Yaleman/issue3229 (#3239)
* Fix wrong success message on kanidm group set-entry-manager #3229
2024-11-25 09:39:18 +10:00
Firstyear
078625cbf9 Update to latest fido-mds-tool (#3230) 2024-11-22 17:03:57 +10:00
William Brown
fb001765ae Release 1.4.3 2024-11-22 12:27:13 +10:00
Firstyear
e04d0680a4 Warn when v2 options are used in v1 unixd config (#3228)
Options like map_group would fail silently when version=2 wasn't
set in our unix config. this detects that case and warns that it
is occuring.

To prevent this in the future, we deny unknown keys in v2 so that
if (when?) we add v3, new keys will cause an error.
2024-11-22 12:14:06 +10:00
Firstyear
d058b8c053 Resolve UI Auth Loop with OAuth2 (#3226)
If an OAuth2 auth request resume cookie was present, and at the same
time the kani instance was restarted, the cookie would now fail
to validate on the instance. This caused the user to experience an auth
loop where after every authentication they would see an error *despite*
logging in correctly, and then a refresh would show the correct
apps page.

This removes the auth_req cookie correctly even if it fails to
deserialise.
2024-11-22 12:14:06 +10:00
Firstyear
2dd8891d51 Harden transport in pam unixd (#3227)
In some cases if the transport drops out from underneath unixd,
it can be difficult to diagnose and leads to inconsistent errors
and output such as prompting for a password multiple times when
it can't succeed.

This makes it clearer that the transport had an error, and it
denies the inflight authsession to prevent spurious password
prompts.
2024-11-22 12:14:06 +10:00
Firstyear
abbce9edf3 Improve warning around invalid JWT deserialisation (#3224)
* Improve warning around invalid JWT deserialisation

* typo
2024-11-22 12:14:06 +10:00
George Wu
9bd1fe1481 Update and fix server config files in examples. (#3225) 2024-11-22 12:14:06 +10:00
George Wu
f6d16ff08a Change CLI oauth2 command from set-display-name to set-displayname for consistency. (#3212)
* Change CLI domain command from set-display-name to set-displayname for consistency.

* Also fix CLI.
2024-11-22 12:14:06 +10:00
George Wu
e4c6ca767e Add docs on customising Kanidm. (#3209)
* Add docs on customising Kanidm.

* Add more info about images that can be used.

* s/set-display-name/set-displayname/g
2024-11-22 12:14:06 +10:00
Georg
6458660a24 Correct spelling of occurred (#3222)
Signed-off-by: Georg Pfuetzenreuter <mail@georg-pfuetzenreuter.net>
2024-11-22 12:14:06 +10:00
Firstyear
a6dcb960d7 UI/Feature polish (#3191)
Post release some small user issues arose

* Optimise the autofocus for logins with passkeys to limit clicks
* Sort login mechs by strength
* Fix cookies to persist between browser restarts
2024-11-10 14:06:08 +10:00
Firstyear
a6ecff0caa Prevent Invalid MFA Reg States (#3194) 2024-11-10 14:06:08 +10:00
George Wu
54cea7a9b7 Change CSS for applications so SVG scales nicely in Firefox. (#3200) 2024-11-10 14:06:08 +10:00
Firstyear
ed20725817 20241109 3185 max age (#3196) 2024-11-10 14:06:08 +10:00
Firstyear
69ceb6c4f7 Hoist max_age to prevent incorrect deserialisation (#3190) 2024-11-10 14:06:08 +10:00
William Brown
ee5c382d8e Release 1.4.2 2024-11-08 14:20:27 +10:00
Firstyear
4f55b1cc33 Re-migrate all acps to force updating (#3184)
* Re-migrate all acps to force updating

* Update server/lib/src/server/migrations.rs

---------

Co-authored-by: James Hodgkinson <james@terminaloutcomes.com>
2024-11-08 14:19:10 +10:00
Firstyear
c3e42ba257 security - low - fault in migrations (#3182)
A fault existed in the server's internal migration code, where attributes
that were multivalued would be merged rather than replaced in certain
contexts. This migration path is used for access controls, meaning that
on upgrades, attributes that were meant to be removed from access
controls or changes to access control target groups were not reflected
during the upgrade process.

This has a potentially low security impact as it may have allowed
users to change their name/displayname even if the administrator
had disable the name_self_write access control.
2024-11-07 14:33:11 +10:00
William Brown
ad93202992 Release 1.4.1 2024-11-05 14:56:33 +10:00
Firstyear
99573f2b94 Correct missing CSP header (#3177) 2024-11-05 14:50:27 +10:00
Firstyear
78ced241eb Resolve pam services not always having a tty (#3176) 2024-11-05 11:06:57 +10:00
Firstyear
770efa80f0 Resolve incorrect handling of rhost in pam (#3171) 2024-11-03 10:13:50 +10:00
William Brown
c297c3f5d9 Docker makefile latest 2024-11-01 12:18:05 +10:00
William Brown
26321bc6ed Release 1.4.0 2024-11-01 12:08:35 +10:00
CEbbinghaus
d72b551d2f chore: Made oauth2 scopes required in CLI (#3165) 2024-11-01 12:05:51 +10:00
micolous
e50e967880 More "choosing a domain" revision (#3161)
* More "choosing a domain" revision:

* Link to the domain rename process
* Add some hyphens to make things easier to read
* Move the OAuth 2.0 domain sharing guidance into the origin section
* Add DNS -> IP as a potential issue
* Discourage requesting public suffix list inclusion as a workaround

* Add "own hostname" section
2024-10-30 12:24:42 +10:00
George Wu
daba216803 Update missing inputmode numeric when adding a new TOTP. (#3160) 2024-10-30 12:24:36 +10:00
Firstyear
8afdc065bb Improve OAuth2 authorisation ux (#3158)
- Resolve an issue where oauth2 could trigger the login page to
  incorrectly redirect to an oauth2 application instead of apps
- Add indication of what client application we are accessing
  if the session is not yet authenticated
2024-10-29 18:16:27 +10:00
Firstyear
b8811c9eaf Fix attribute scim sync attribute naming (#3159) 2024-10-29 14:27:19 +10:00
George Wu
750932b322 Change to text input and use numeric mode for TOTP prompts. (#3154)
* Change to text input and use inputmode numeric for TOTP prompts.

* Fix some typos.
2024-10-29 09:29:53 +10:00
Firstyear
6232206d43 Fix release note date and typos (#3153) 2024-10-27 13:10:35 +10:00
William Brown
b7ce4350e3 Release 1.4.0-pre 2024-10-27 10:50:09 +10:00
101 changed files with 2748 additions and 5072 deletions

1274
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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
```

View file

@ -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
View 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
```

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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

Binary file not shown.

View file

@ -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,

View file

@ -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"
#

View file

@ -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"]

View file

@ -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,

View file

@ -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(),

View file

@ -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 |

View file

@ -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>,

View file

@ -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();

View file

@ -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
}

View file

@ -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,
}

View file

@ -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(

View file

@ -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(),
};

View file

@ -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

View file

@ -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",
}
}
}

View file

@ -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>(

View 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())
}

View file

@ -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)
}

View file

@ -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))

View file

@ -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((

View file

@ -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,

View file

@ -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();

View file

@ -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() => {

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View 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

View file

@ -40,3 +40,8 @@ try {
});
} catch (_error) {};
try {
window.addEventListener("load", (event) => {
asskey_login()
});
} catch (_error) {};

View file

@ -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 {

View file

@ -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 %)

View file

@ -1,6 +1,6 @@
<main class="container-lg">
<div>
<h2>Applications list</h2>
<h2>Applications</h2>
</div>
<hr />
(% if apps.is_empty() %)

View file

@ -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
/>

View file

@ -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">

View 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 %)

View file

@ -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=""
/>

View file

@ -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 %)

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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))>

View file

@ -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 %)

View file

@ -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>

View file

@ -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();

View file

@ -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",

View file

@ -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

View file

@ -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,

View file

@ -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()));
}
}

View file

@ -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,

View file

@ -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)
}

View file

@ -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)

View file

@ -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
}));
}
}

View file

@ -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",

View file

@ -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!();
}
}

View file

@ -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

View file

@ -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

View file

@ -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);

View file

@ -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());

View file

@ -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.

View file

@ -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);
}

View file

@ -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| {

View file

@ -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

View file

@ -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();

View file

@ -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)
);
}
}

View file

@ -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();

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -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");

View file

@ -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

View file

@ -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| {

View file

@ -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| {

View file

@ -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) => {

View file

@ -35,6 +35,7 @@ maintainer = "James Hodgkinson <james@terminaloutcomes.com>"
depends = ["libc6", "libpam0g"]
section = "network"
priority = "optional"
maintainer-scripts = "debian/"
assets = [
# Empty on purpose
]

View file

@ -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:

View 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

View file

@ -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;
}

View file

@ -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,

View file

@ -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]

View file

@ -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)

View file

@ -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 {

View file

@ -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.

View file

@ -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)) => {

View file

@ -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(),

View file

@ -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