198 Commits

Author SHA1 Message Date
Valentin Tolmer
32f28d664e Bump to version 0.4.1 2022-10-10 17:46:34 +02:00
Hobbabobba
412f4fa644 example_config: add Docuwiki 2022-10-09 13:11:26 +02:00
dependabot[bot]
4ffa565e51 build(deps): bump actions/checkout from 2 to 3.1.0 (#314)
Bumps [actions/checkout](https://github.com/actions/checkout) from 2 to 3.1.0.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v2...v3.1.0)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: nitnelave <valentin.tolmer@gmail.com>
2022-10-08 06:33:30 +02:00
Hobbabobba
2f9ea4f10f example_configs: add hedgedoc
Co-authored-by: nitnelave <valentin.tolmer@gmail.com>
2022-10-07 21:19:55 +02:00
Dedy Martadinata
123fdc5baf docker: use the locally-downloaded assets
Change the index to the local one to use the locally-downloaded fonts and css.
2022-10-07 19:22:20 +02:00
Valentin Tolmer
5402aa5aa2 server: Silence error message when creating DB
Fixes #300
2022-09-30 15:12:15 +02:00
Valentin Tolmer
8069516283 server: Add support for PKCS1 keys
Fixes #288
2022-09-30 13:56:03 +02:00
Valentin Tolmer
6c21f2ef4b clippy: fix warning by implementing Eq 2022-09-27 06:54:29 +02:00
Valentin Tolmer
516893f1f7 server: Fix query building of chained ands/ors
Fixes #303
2022-09-27 05:14:57 +02:00
Marco Dura
1660cb1fbb example_config: grafana fix typos and attributes 2022-09-22 15:22:30 +02:00
Valentin Tolmer
7e1ce10df1 server: allow every config value to be specified as a file
By using https://crates.io/crates/figment_file_provider_adapter

Fixes https://github.com/nitnelave/lldap/issues/263
2022-09-14 11:16:50 +02:00
arcoast
b6ee918ca9 example_configs: add Airsonic 2022-09-10 14:32:56 +02:00
Valentin Tolmer
24efd61464 github: fix static wasm-pack build 2022-09-10 13:12:20 +02:00
Valentin Tolmer
0b6b274cfa example_configs: add Nextcloud 2022-09-10 12:47:41 +02:00
Alexander Olsson
8b01271e94 readme: Fix typo
Co-authored-by: alol <alexander.olsson@flaxplax.com>
2022-08-16 16:53:35 -05:00
Valentin Tolmer
d536addf0a migration-tool: misc cleanup 2022-08-09 13:03:28 +02:00
Valentin Tolmer
2ca083541e migration-tool: Import users' avatars 2022-08-09 13:03:28 +02:00
Valentin Tolmer
686bdc0cb1 app: Add support for modifying an avatar 2022-08-09 13:03:28 +02:00
Valentin Tolmer
60c594438c ldap: Stop returning empty attributes 2022-08-09 13:03:28 +02:00
Valentin Tolmer
b130965264 ldap: return user's avatar 2022-08-09 13:03:28 +02:00
Valentin Tolmer
697a64991d server: Change attribute values to bytes 2022-08-09 13:03:28 +02:00
Valentin Tolmer
3acc448048 server: Add support for users' avatars in GrahpQL 2022-08-09 13:03:28 +02:00
Valentin Tolmer
0e3c5120da app: Switch yew_form dependency back to main repo 2022-08-09 13:03:28 +02:00
Valentin Tolmer
7707367c35 migration-tool: Extract the JWT from the JSON response
The response used to contain just the JWT, but now it's wrapped in JSON.

Fixes #282.
2022-08-03 22:34:19 +02:00
Valentin Tolmer
122e08790f docker: fix tag typo 2022-08-01 18:26:47 +02:00
Valentin Tolmer
64556fc744 server: stop returning "dn" as an attribute
It's already part of the base response

Fixes #254.
2022-08-01 18:26:47 +02:00
Valentin Tolmer
134a9366f5 server: create private key with 400 permissions
Fixes #261.
2022-08-01 17:43:37 +02:00
Dedy Martadinata S
f69b729eb2 Update docker-build-static.yml 2022-08-01 17:31:13 +02:00
Dedy Martadinata Supriyadi
2ac47d5c85 cleanup Dockerfile.dev 2022-08-01 17:31:13 +02:00
Dedy Martadinata Supriyadi
26d3d84de0 use lldap dev image 2022-08-01 17:31:13 +02:00
Dedy Martadinata Supriyadi
b413935932 some cleanup 2022-08-01 17:31:13 +02:00
Dedy Martadinata Supriyadi
e6ae726304 manual fetch aarch64 2022-08-01 17:31:13 +02:00
Dedy Martadinata Supriyadi
520277b611 update tag 2022-08-01 17:31:13 +02:00
Dedy Martadinata Supriyadi
8cdfedddbd add os in tag 2022-08-01 17:31:13 +02:00
Dedy Martadinata Supriyadi
5312400a3f add Dockerfile.dev 2022-08-01 17:31:13 +02:00
Dedy Martadinata Supriyadi
551f5abc4b manual fetch 2022-08-01 17:31:13 +02:00
Dedy Martadinata Supriyadi
10d826fc46 using script 2022-08-01 17:31:13 +02:00
Dedy Martadinata Supriyadi
252bd6cf39 use custom dev image 2022-08-01 17:31:13 +02:00
Dedy Martadinata Supriyadi
ba44dea7b6 add ca-cert 2022-08-01 17:31:13 +02:00
Dedy Martadinata S
b9c823e01a Build using musl 2022-08-01 17:31:13 +02:00
Valentin Tolmer
c108921dcf server: Add a log message when search is restricted
Fixes #264.
2022-08-01 14:02:24 +02:00
Valentin Tolmer
36eed1e091 README: Document the build process, add systemd service
Fixes #269.
2022-08-01 09:14:39 +02:00
Valentin Tolmer
897704fab3 server: Fix extra error message when DB doesn't exist
Fixes #270
2022-08-01 09:14:39 +02:00
Valentin Tolmer
9f70910283 docs: Update the docker config template to add smtp_encryption 2022-08-01 09:14:39 +02:00
dependabot[bot]
3e3c9b97ae build(deps): bump juniper from 0.15.9 to 0.15.10
Bumps [juniper](https://github.com/graphql-rust/juniper) from 0.15.9 to 0.15.10.
- [Release notes](https://github.com/graphql-rust/juniper/releases)
- [Changelog](https://github.com/graphql-rust/juniper/blob/master/release.toml)
- [Commits](https://github.com/graphql-rust/juniper/compare/juniper-v0.15.9...juniper-v0.15.10)

---
updated-dependencies:
- dependency-name: juniper
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-30 19:22:59 +02:00
Valentin Tolmer
8c1ea11b95 server: add an option to use STARTTLS for smtp 2022-07-30 15:58:58 +02:00
Valentin Tolmer
cd0ab378ef server: deprecate smtp.tls_required, add smtp_encryption 2022-07-30 15:58:58 +02:00
Matteo Bonora
5a27ae4862 readme: Add version to docker-compose examples 2022-07-29 10:56:24 +02:00
Jun-Cheol Park
05719642ca Fix: Change input filed to password type in change_password ui (#273) 2022-07-26 11:07:44 +02:00
Iván Izaguirre
5c584536b5 frontend: Add UUID and creation date
This exposes the new info in the GraphQL API, and adds it to the frontend.
2022-07-21 12:10:37 +02:00
Valentin Tolmer
4ba0db4e9e migration_tool: Switch from OpenSSL to Rustls 2022-07-15 15:49:15 +02:00
Valentin Tolmer
5e4ed9ee17 docker: remove libssl-dev 2022-07-15 15:49:15 +02:00
Valentin Tolmer
c399ff2bfa server: switch from OpenSSL to Rustls 2022-07-15 15:49:15 +02:00
Frank Moskal
9e37a06514 server: allow admin email to be set via config 2022-07-13 14:32:35 +02:00
Valentin Tolmer
294ce77a47 server: Fix misc clippy warnings 2022-07-13 12:43:51 +02:00
Dedy Martadinata S
24c6b4a879 docker: Remove debian build, revert to default alpine 2022-07-13 11:41:26 +02:00
Jacob Yundt
2c2696a8c3 docker: Add support for SMTP password file (#240)
Similar to LLDAP_JWT_SECRET and LLDAP_LDAP_USER_PASS, add option to use an
ENV variable to specify the file of the SMTP password:
LLDAP_SMTP_OPTIONS__PASSWORD_FILE
2022-07-13 10:52:40 +02:00
Dedy Martadinata S
479d1e7635 readme: Add details about latest tag 2022-07-13 10:38:31 +02:00
Dedy Martadinata S
3a723460e5 docker: Add volume support 2022-07-13 08:09:36 +02:00
Valentin Tolmer
8011756658 readme: Correct RAM estimate 2022-07-12 12:37:27 +02:00
Dedy Martadinata S
46546dac27 docker: Add support for UID:GID
Adds support for the UID/GID env variables in Docker via `gosu`.
2022-07-12 10:37:08 +02:00
Dedy Martadinata S
9a869a1474 add bash, entrypoint deps. 2022-07-11 16:09:22 +02:00
Sebastian Thiel
09797695aa Fix typo in README.md 2022-07-11 15:38:11 +02:00
Dedy Martadinata S
4f2cf45427 docker: build static binaries, add alpine targets 2022-07-11 15:36:59 +02:00
Valentin Tolmer
901eb7f469 example_configs: Add XBackBone 2022-07-11 12:30:46 +02:00
Valentin Tolmer
91d12a7e97 release: v0.4.0 2022-07-08 19:02:20 +02:00
Valentin Tolmer
e31c7351ea readme: Update mention of readonly group 2022-07-08 19:02:20 +02:00
Valentin Tolmer
cf19fd41b0 server: Update permission checks for strict_readonly 2022-07-08 19:02:20 +02:00
Valentin Tolmer
500a441df7 server: Migrate from lldap_readonly to lldap_strict_readonly 2022-07-08 19:02:20 +02:00
Valentin Tolmer
6701027002 release: Release version 0.3.0 2022-07-08 14:49:01 +02:00
Valentin Tolmer
fab884711f server: Make objectClass matching case-insensitive
Fixes https://github.com/nitnelave/lldap/issues/189
2022-07-08 12:00:55 +02:00
Valentin Tolmer
1a37e1ee04 server: Allow readonly users to change non-admin passwords 2022-07-08 11:49:13 +02:00
dependabot[bot]
786f571e86 build(deps): bump openssl-src from 111.21.0+1.1.1p to 111.22.0+1.1.1q
Bumps [openssl-src](https://github.com/alexcrichton/openssl-src-rs) from 111.21.0+1.1.1p to 111.22.0+1.1.1q.
- [Release notes](https://github.com/alexcrichton/openssl-src-rs/releases)
- [Commits](https://github.com/alexcrichton/openssl-src-rs/commits)

---
updated-dependencies:
- dependency-name: openssl-src
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-08 11:19:34 +02:00
Ben Penkacik
33cd850e65 docs: add emby config 2022-07-08 11:01:02 +02:00
Valentin Tolmer
8c3a168c7f server: remove spurious debug message 2022-07-06 00:15:08 +02:00
Valentin Tolmer
722fc2de57 server: Update Cargo.lock 2022-07-05 18:15:38 +02:00
Valentin Tolmer
c6ffaa2abf server: fix member_of for users with no groups 2022-07-05 18:15:38 +02:00
Dedy Martadinata S
c4a63610c0 Update Dockerfile.ci 2022-07-05 10:42:44 +02:00
Dedy Martadinata S
5bf533272e Add caching 2022-07-02 11:02:00 +02:00
Valentin Tolmer
22fcc5303f codecov: Add config 2022-07-01 18:03:34 +02:00
Valentin Tolmer
8101ddc85f server: Create release candidate 0.3.0-rc.1 2022-07-01 14:57:22 +02:00
Valentin Tolmer
49f4e48aae cargo: update various dependencies 2022-07-01 14:57:22 +02:00
Valentin Tolmer
4092b2e5b1 server: Print version on startup 2022-07-01 14:57:22 +02:00
Greg
b387ceb1c4 Update authelia_config.yml 2022-07-01 14:21:32 +02:00
Greg
85d59e79ca Update Authelia config for Authelia 4.36.1
Authelia 4.36.1 changed the key for password resets. Fixed an error where the user: field was incorrectly set to uid=admin, resulting in a fatal error starting Authelia.
2022-07-01 14:21:32 +02:00
Valentin Tolmer
c5017bbd42 ldap: remove copies from the wildcard expansion 2022-07-01 12:41:12 +02:00
Valentin Tolmer
c72c1fdf2c server: Add a Uuid attribute to every user and group 2022-07-01 12:41:12 +02:00
dependabot[bot]
cbde363fde build(deps): bump docker/setup-qemu-action from 1 to 2
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 1 to 2.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v1...v2)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-01 09:15:16 +02:00
Dedy Martadinata S
ea82b1a644 Set right user to run
Change user to run rootless.
2022-07-01 09:04:52 +02:00
dependabot[bot]
429952c46f build(deps): bump docker/login-action from 1 to 2
Bumps [docker/login-action](https://github.com/docker/login-action) from 1 to 2.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v1...v2)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-01 08:03:17 +02:00
Dedy Martadinata S
0dad470602 docker: Fix push for "latest" tag 2022-06-30 19:31:31 +02:00
Valentin Tolmer
2f1bf87102 app: propagate change events 2022-06-30 17:14:13 +02:00
Valentin Tolmer
1a03346a38 server: refactor auth_service to use Results
This simplifies the flow, and gets rid of wrong clippy warnings about
missing awaits due to the instrumentation.
2022-06-30 17:14:13 +02:00
Valentin Tolmer
23a4763914 server: Add tracing logging
Fixes #17
2022-06-30 17:14:13 +02:00
MickMorley
82f6292927 docs, guacamole: Added Docker option 2022-06-30 15:55:20 +02:00
Dedy Martadinata S
e39e141d6c docker: Create a multiarch CI/CD pipeline 2022-06-30 11:21:14 +02:00
Valentin Tolmer
a512b1844a server: Disambiguate list_users query
The confusion of display_name caused every user to be called like the
first group they belonged to.
2022-06-30 10:32:52 +02:00
Valentin Tolmer
5e2eea0d97 sqlx: update dependency and protect against injections 2022-06-26 11:55:37 +02:00
dependabot[bot]
bafb1dc5cc build(deps): bump tokio from 1.11.0 to 1.13.1
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.11.0 to 1.13.1.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.11.0...tokio-1.13.1)

---
updated-dependencies:
- dependency-name: tokio
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-24 18:01:25 +02:00
MickMorley
45bbe23b3b docs: add example config for Apache Guacamole app (#195) 2022-06-24 12:46:40 +02:00
Ghassan Alduraibi
85ee097a3b docs: add calibre-web config (#187)
* docs: add calibre-web config

* docs: update readme with calibre-web config

* docs: update calibre-web config with login fix

* docs: update calibre-web config with requested changes
2022-06-24 12:44:38 +02:00
Martin Leydier
04afc9d8d9 docs: add grafana config (#186) 2022-06-24 12:41:33 +02:00
publicdesert
b03a38f267 docs: add Readd example config for Seafile
Readds both the previous example for Seafile and an alternative setup with Authelia as an intermediary.
2022-06-24 12:38:40 +02:00
MickMorley
8f446bd932 docs: add Syncthing example 2022-06-21 23:13:37 +02:00
Dedy Martadinata S
1ae7987b88 docs: portainer: add filter by group example 2022-06-20 11:54:16 +02:00
publicdesert
936a6d696a Removed Seafile example config
Removed Seafile example config because of the issue in described in #191
2022-06-17 16:11:50 +02:00
publicdesert
fc7ec97051 Apply suggested changes 2022-06-17 16:11:50 +02:00
publicdesert
a67128338d Add examples for Gitea and Seafile 2022-06-17 16:11:50 +02:00
Christian Kracher
e757638506 Create portainer.md
portainer.ai CE LLDAP configuration
2022-06-12 13:22:50 +02:00
dada513
a673a6aa45 get working usernames 2022-06-11 14:06:56 +02:00
dada513
9b91362730 add wg-portal example 2022-06-11 14:06:56 +02:00
Valentin Tolmer
733d363e25 ldap: handle full scope searches
Nextcloud searches for users by specifying the entire user DN as the
scope. This commit adds support for these specific scopes.
2022-06-10 17:18:46 +02:00
Valentin Tolmer
da186fab38 ldap: add support for memberOf attribute
The "memberOf" filter was already supported, but not the attribute.

Fixes #179
2022-06-10 15:22:06 +02:00
Valentin Tolmer
1f632a8069 example_configs: add Matrix 2022-06-07 15:27:47 +02:00
Valentin Tolmer
ff698df280 server: Introduce a read-only user 2022-06-06 17:27:37 +02:00
Valentin Tolmer
1efab58d0c ldap: add an option to silence unknown fields in the config 2022-05-30 20:08:02 +02:00
Valentin Tolmer
a0b0b455ed ldap: ignore unknown filters 2022-05-30 20:08:02 +02:00
Valentin Tolmer
1d8582f937 ldap: lowercase all DN, fields, values 2022-05-30 19:23:29 +02:00
Valentin Tolmer
7e62cc6eda ldap: handle "present" filters for groups 2022-05-29 19:30:07 +02:00
Valentin Tolmer
55bcced476 readme: fix env variable 2022-05-20 13:03:43 +02:00
Matthew Strasiotto
b7957f598b ldap wildcard handler, error if '*' attribute makes it to get_x_attribute 2022-05-12 13:14:04 +02:00
Matthew Strasiotto
5150d8341f ldap wildcard handler, add tests 2022-05-12 13:14:04 +02:00
Matthew Strasiotto
e5c80b9f17 handle wildcards being given as ldap attribute params
fix wildcard expansion

address some pr comments

Move ldap attribute expansion lists to constants

As per: https://github.com/nitnelave/lldap/pull/164#discussion_r867348971

lldap *+ expansion: remove unneccesary cloning

https://github.com/nitnelave/lldap/pull/164#discussion_r867349805

ldap attribute wildcard handling: remove duplicated wildcards

https://github.com/nitnelave/lldap/pull/164#issuecomment-1120211031

ldap wildcard expansion: refactor

ldap attribute handlers: handle '+' by ignoring, '*' and unmatched by warning and ignoring

attribute wildcard expansion: refactor, don't remove '+'
2022-05-12 13:14:04 +02:00
Matthew Strasiotto
875c59758b handle dn attribute being queried as distinguishedname 2022-05-12 13:14:04 +02:00
Valentin Tolmer
b54fe9128d app: Implement login refresh 2022-05-11 17:14:41 +02:00
Valentin Tolmer
ebffc1c086 server, ldap: Use group membership for admin status 2022-05-08 20:36:57 +02:00
dependabot[bot]
5c1db3cf4a build(deps): bump docker/setup-buildx-action from 1 to 2
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 1 to 2.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v1...v2)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-05-07 20:02:28 +02:00
dependabot[bot]
e173f34edb build(deps): bump docker/metadata-action from 3 to 4
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 3 to 4.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Upgrade guide](https://github.com/docker/metadata-action/blob/master/UPGRADE.md)
- [Commits](https://github.com/docker/metadata-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-05-07 19:53:44 +02:00
dependabot[bot]
05c60979d7 build(deps): bump docker/build-push-action from 2 to 3
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 2 to 3.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-05-07 18:46:11 +02:00
Valentin Tolmer
d6c2805847 server: don't try to load the certificates if they're not needed 2022-05-07 15:01:54 +02:00
dependabot[bot]
89ae7c200c build(deps): bump docker/login-action from 1 to 2
Bumps [docker/login-action](https://github.com/docker/login-action) from 1 to 2.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v1...v2)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-05-07 14:40:44 +02:00
Valentin Tolmer
f689458aa2 server: Implement LDAPS support 2022-05-05 17:19:11 +02:00
Valentin Tolmer
6b6f11db1b server: update clap and add LDAPS options 2022-05-05 17:19:11 +02:00
Valentin Tolmer
f1b86a16ee ldap: return uids instead of cns for users 2022-05-03 12:13:43 +02:00
Valentin Tolmer
4f89b73fe5 readme: Fix anchors 2022-04-29 15:56:57 +02:00
Valentin Tolmer
c7d68af691 github: remove nightly installations 2022-04-29 15:54:06 +02:00
Valentin Tolmer
4537d1ae2b docs: update architecture doc 2022-04-29 15:04:26 +02:00
Valentin Tolmer
90611aefef readme: Make compatible services more explicit 2022-04-29 10:18:26 +02:00
Valentin Tolmer
bd90a3a426 ldap: return actual "cn" value instead of "uid" in LDAP messages 2022-04-29 10:02:43 +02:00
Valentin Tolmer
e1e1d6cd20 ldap: accept "uid" or "cn" as username 2022-04-29 10:02:43 +02:00
JaidenW
16a544b5a0 Update Organizr.md 2022-04-29 09:37:46 +02:00
JaidenW
73ac5a65d4 Create Organizr.md
Help document for configuring LDAP backend on Organizr
2022-04-29 09:37:46 +02:00
Valentin Tolmer
5420dcf2b8 github: skip coverage for doc branches 2022-04-25 17:52:00 +02:00
Cyrix126
cb84f7f387 Add example configuration for dolibarr 2022-04-25 17:39:06 +02:00
Valentin Tolmer
c7f45b12ac app: add bottom padding to avoid overlap with the footer 2022-04-25 10:34:22 +02:00
Valentin Tolmer
f52197e76f server: allow non-admin user to do limited searches 2022-04-25 09:34:25 +02:00
Valentin Tolmer
3ac38bb96f ldap_handler: Reports groups as groupOfNames as well 2022-04-20 10:54:21 +02:00
Valentin Tolmer
2197fe77a5 server: Handle "1.1" special attribute 2022-04-18 12:01:58 +02:00
Valentin Tolmer
8d7881171b examples: Add Jellyfin config. 2022-04-17 23:22:25 +02:00
Valentin Tolmer
f2570cdd3c github: fix coverage action 2022-04-17 23:14:10 +02:00
Valentin Tolmer
be452f4649 gitignore: ignore custom config 2022-04-17 23:14:10 +02:00
Valentin Tolmer
3a6c5fdc65 server: Report errors sending email 2022-04-17 23:14:10 +02:00
Valentin Tolmer
0ccedc6717 app: Fix password reset 2022-04-17 23:14:10 +02:00
dependabot[bot]
b6dd1ed512 build(deps): bump codecov/codecov-action from 2.1.0 to 3
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 2.1.0 to 3.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v2.1.0...v3)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-04-06 12:04:56 +02:00
Valentin Tolmer
a8e5549b3f github: Simplify the coverage action 2022-03-31 16:58:45 +02:00
Valentin Tolmer
ae9b3678df github: Run Codecov even if builds should be skipped 2022-03-31 16:09:02 +02:00
Valentin Tolmer
2221686dc6 app: Add footer 2022-03-31 14:56:55 +02:00
Valentin Tolmer
203bc9a8a2 index: Add crossorigin to enable integrity validation 2022-03-31 14:56:55 +02:00
Valentin Tolmer
ca19e61f50 domain: introduce UserId to make uid case insensitive
Note that if there was a non-lowercase user already in the DB, it cannot
be found again. To fix this, run in the DB:

sqlite> UPDATE users SET user_id = LOWER(user_id);
2022-03-26 18:23:19 +01:00
dependabot[bot]
26cedcb621 build(deps): bump peter-evans/dockerhub-description from 2 to 3
Bumps [peter-evans/dockerhub-description](https://github.com/peter-evans/dockerhub-description) from 2 to 3.
- [Release notes](https://github.com/peter-evans/dockerhub-description/releases)
- [Commits](https://github.com/peter-evans/dockerhub-description/compare/v2...v3)

---
updated-dependencies:
- dependency-name: peter-evans/dockerhub-description
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-23 06:22:39 -05:00
dependabot[bot]
6228c0f87c build(deps): bump actions/checkout from 2 to 3
Bumps [actions/checkout](https://github.com/actions/checkout) from 2 to 3.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-04 13:39:51 +01:00
Hendrik Schlehlein
82df8d4ca1 feat: add simple login 2022-03-04 12:04:10 +01:00
Valentin Tolmer
c850fa4273 server: refactor group requests to use filters 2022-02-12 14:27:02 +01:00
Valentin Tolmer
a1fe703bf0 server: rename RequestFilter to UserRequestFilter 2022-02-12 14:27:02 +01:00
Valentin Tolmer
d20bd196bc ldap_handler: trim spaces in LDAP identifiers 2022-02-11 09:34:21 +01:00
Abbie Wade
747e37592d fixing environment variable name 2022-02-01 23:22:11 +01:00
Abbie Wade
f6c43b691a fixed a typo and added verbose for future users to find 2022-02-01 23:22:11 +01:00
Valentin Tolmer
8e8614fe2e server: fix clippy warning 2021-12-08 12:01:56 +01:00
Valentin Tolmer
204232659d app: fix clippy warning 2021-12-08 12:01:56 +01:00
Valentin Tolmer
6c9086cc78 docker,git: ignore more files 2021-12-08 12:01:56 +01:00
Valentin Tolmer
110b7c7d5b server: fix command line version stuck at 0.1 2021-12-08 12:01:56 +01:00
Valentin Tolmer
ef0a0ffced docker: add migration-tool to the image 2021-12-08 12:01:56 +01:00
Valentin Tolmer
31cf9b8e2c migration: Implement import from LDAP 2021-12-08 12:01:56 +01:00
Sblop
aa83f6cab6 Update bookstack.env.example 2021-12-06 16:24:49 +01:00
Sblop
b38023c48e Update README.md
Added bookstack example
2021-12-06 16:24:49 +01:00
Sblop
496fbf72ea Update and rename bookstack.example to bookstack.env.example 2021-12-06 16:24:49 +01:00
Sblop
86c052f98b Create bookstack.example
Bookstack example
2021-12-06 16:24:49 +01:00
Christian Kracher
610ada972a Update jitsi_meet.conf 2021-12-02 09:27:16 +01:00
Christian Kracher
b664524366 Update jitsi_meet.conf 2021-12-02 09:27:16 +01:00
Valentin Tolmer
182449da03 jitsi: Add search base 2021-12-02 00:09:48 +01:00
kaysond
82770a5ff0 restore comment 2021-12-01 00:38:54 +01:00
kaysond
e11a8460ff add SRI for other resources; add routing for all root requests 2021-12-01 00:38:54 +01:00
kaysond
c761f08995 fix icons download 2021-12-01 00:38:54 +01:00
kaysond
c564de2c92 add SRI back 2021-12-01 00:38:54 +01:00
kaysond
7731b8e593 download static fonts to their own directory 2021-12-01 00:38:54 +01:00
kaysond
4c05058eb2 add bootstrap global var to rollup command 2021-12-01 00:38:54 +01:00
kaysond
45c50923b7 fix rust formatting 2021-12-01 00:38:54 +01:00
kaysond
f730e6a580 add icon fonts to library list 2021-12-01 00:38:54 +01:00
kaysond
06a12f5351 remove unnecessary attrs 2021-12-01 00:38:54 +01:00
kaysond
bf20c448dc fix typo 2021-12-01 00:38:54 +01:00
kaysond
9f138ec4ac server libraries locally in the docker container 2021-12-01 00:38:54 +01:00
Valentin Tolmer
ddeb4c3ce3 cargo: Bump the version number to 0.3.0-alpha.1 2021-11-29 15:50:43 +01:00
Valentin Tolmer
9d623e59c1 docker: Add release as a workflow trigger 2021-11-29 15:50:43 +01:00
dependabot[bot]
e44625bc6a build(deps): bump codecov/codecov-action from 1 to 2.1.0
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 1 to 2.1.0.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/master/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v1...v2.1.0)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-11-29 10:27:13 +01:00
Valentin Tolmer
68013c8919 docker: Create a tagged image on release 2021-11-29 09:54:58 +01:00
Valentin Tolmer
842afac7dd workflows: Ignore changes to docs for actions 2021-11-29 09:54:58 +01:00
Valentin Tolmer
2bbfacf755 workflows: Don't run Rust workflow again for same files 2021-11-29 09:54:58 +01:00
Valentin Tolmer
f152a78cb6 github: add dependabot for checking actions versions 2021-11-29 09:54:58 +01:00
110 changed files with 9076 additions and 2463 deletions

View File

@@ -5,9 +5,6 @@
# Don't track cargo generated files
target/*
server/target/*
app/target/*
auth/target/*
# Don't track the generated JS
app/pkg/*
@@ -16,10 +13,27 @@ app/pkg/*
Dockerfile
.dockerignore
# Don't track docs
*.md
LICENSE
CHANGELOG.md
docs/*
example_configs/*
# Output of `npm install rollup`
node_modules/*
package-lock.json
package.json
# Pre-build binaries
*.tar.gz
# Various config files that shouldn't be tracked
.env
lldap_config.toml
server_key
users.db*
screenshot.png
recipe.json
*.md
cert.pem
key.pem

12
.github/codecov.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
codecov:
require_ci_to_pass: yes
comment:
layout: "diff,flags"
require_changes: true
require_base: true
require_head: true
ignore:
- "app"
- "docs"
- "example_configs"
- "migration-tool"

10
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,10 @@
# Set update schedule for GitHub Actions
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
# Check for updates to GitHub Actions every weekday
interval: "daily"

106
.github/workflows/Dockerfile.ci.alpine vendored Normal file
View File

@@ -0,0 +1,106 @@
FROM debian:bullseye AS lldap
ARG DEBIAN_FRONTEND=noninteractive
ARG TARGETPLATFORM
RUN apt update && apt install -y wget
WORKDIR /dim
COPY bin/ bin/
COPY web/ web/
RUN mkdir -p target/
RUN mkdir -p /lldap/app
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
mv bin/amd64-bin/lldap target/lldap && \
mv bin/amd64-bin/migration-tool target/migration-tool && \
chmod +x target/lldap && \
chmod +x target/migration-tool && \
ls -la target/ . && \
pwd \
; fi
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
mv bin/aarch64-bin/lldap target/lldap && \
mv bin/aarch64-bin/migration-tool target/migration-tool && \
chmod +x target/lldap && \
chmod +x target/migration-tool && \
ls -la target/ . && \
pwd \
; fi
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
mv bin/armhf-bin/lldap target/lldap && \
mv bin/armhf-bin/migration-tool target/migration-tool && \
chmod +x target/lldap && \
chmod +x target/migration-tool && \
ls -la target/ . && \
pwd \
; fi
# Web and App dir
COPY docker-entrypoint.sh /docker-entrypoint.sh
COPY lldap_config.docker_template.toml /lldap/
COPY web/index_local.html web/index.html
RUN cp target/lldap /lldap/ && \
cp target/migration-tool /lldap/ && \
cp -R web/index.html \
web/pkg \
web/static \
/lldap/app/
WORKDIR /lldap
RUN set -x \
&& for file in $(cat /lldap/app/static/libraries.txt); do wget -P app/static "$file"; done \
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
&& chmod a+r -R .
FROM alpine:3.16
WORKDIR /app
ENV UID=1000
ENV GID=1000
ENV USER=lldap
ENV GOSU_VERSION 1.14
# Fetch gosu from git
RUN set -eux; \
\
apk add --no-cache --virtual .gosu-deps \
ca-certificates \
dpkg \
gnupg \
; \
\
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch"; \
wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch.asc"; \
\
# verify the signature
export GNUPGHOME="$(mktemp -d)"; \
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
command -v gpgconf && gpgconf --kill all || :; \
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
\
# clean up fetch dependencies
apk del --no-network .gosu-deps; \
\
chmod +x /usr/local/bin/gosu; \
# verify that the binary works
gosu --version; \
gosu nobody true
RUN apk add --no-cache tini ca-certificates bash && \
addgroup -g $GID $USER && \
adduser \
--disabled-password \
--gecos "" \
--home "$(pwd)" \
--ingroup "$USER" \
--no-create-home \
--uid "$UID" \
"$USER" && \
mkdir -p /data && \
chown $USER:$USER /data
COPY --from=lldap --chown=$CONTAINERUSER:$CONTAINERUSER /lldap /app
COPY --from=lldap --chown=$CONTAINERUSER:$CONTAINERUSER /docker-entrypoint.sh /docker-entrypoint.sh
VOLUME ["/data"]
WORKDIR /app
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
CMD ["run", "--config-file", "/data/lldap_config.toml"]

71
.github/workflows/Dockerfile.ci.debian vendored Normal file
View File

@@ -0,0 +1,71 @@
FROM debian:bullseye AS lldap
ARG DEBIAN_FRONTEND=noninteractive
ARG TARGETPLATFORM
RUN apt update && apt install -y wget
WORKDIR /dim
COPY bin/ bin/
COPY web/ web/
RUN mkdir -p target/
RUN mkdir -p /lldap/app
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
mv bin/amd64-bin/lldap target/lldap && \
mv bin/amd64-bin/migration-tool target/migration-tool && \
chmod +x target/lldap && \
chmod +x target/migration-tool && \
ls -la target/ . && \
pwd \
; fi
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
mv bin/aarch64-bin/lldap target/lldap && \
mv bin/aarch64-bin/migration-tool target/migration-tool && \
chmod +x target/lldap && \
chmod +x target/migration-tool && \
ls -la target/ . && \
pwd \
; fi
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
mv bin/armhf-bin/lldap target/lldap && \
mv bin/armhf-bin/migration-tool target/migration-tool && \
chmod +x target/lldap && \
chmod +x target/migration-tool && \
ls -la target/ . && \
pwd \
; fi
# Web and App dir
COPY docker-entrypoint.sh /docker-entrypoint.sh
COPY lldap_config.docker_template.toml /lldap/
COPY web/index_local.html web/index.html
RUN cp target/lldap /lldap/ && \
cp target/migration-tool /lldap/ && \
cp -R web/index.html \
web/pkg \
web/static \
/lldap/app/
WORKDIR /lldap
RUN set -x \
&& for file in $(cat /lldap/app/static/libraries.txt); do wget -P app/static "$file"; done \
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
&& chmod a+r -R .
FROM debian:bullseye-slim
ENV UID=1000
ENV GID=1000
ENV USER=lldap
RUN apt update && \
apt install -y --no-install-recommends tini openssl ca-certificates gosu && \
apt clean && \
rm -rf /var/lib/apt/lists/* && \
groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
mkdir -p /data && chown $USER:$USER /data
COPY --from=lldap --chown=$USER:$USER /lldap /app
COPY --from=lldap --chown=$USER:$USER /docker-entrypoint.sh /docker-entrypoint.sh
VOLUME ["/data"]
WORKDIR /app
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
CMD ["run", "--config-file", "/data/lldap_config.toml"]

34
.github/workflows/Dockerfile.dev vendored Normal file
View File

@@ -0,0 +1,34 @@
FROM rust:1.62-slim-bullseye
# Set needed env path
ENV PATH="/opt/aarch64-linux-musl-cross/:/opt/aarch64-linux-musl-cross/bin/:/opt/x86_64-linux-musl-cross/:/opt/x86_64-linux-musl-cross/bin/:$PATH"
### Install build deps x86_64
RUN apt update && \
apt install -y --no-install-recommends curl git wget build-essential make perl pkg-config curl tar jq musl-tools && \
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
apt update && \
apt install -y --no-install-recommends nodejs && \
apt clean && \
rm -rf /var/lib/apt/lists/* && \
npm install -g npm && \
npm install -g yarn && \
npm install -g pnpm
### Install build deps aarch64 build
RUN dpkg --add-architecture arm64 && \
apt update && \
apt install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-arm64-cross libc6-dev-arm64-cross && \
apt clean && \
rm -rf /var/lib/apt/lists/* && \
rustup target add aarch64-unknown-linux-gnu
### Add musl-gcc aarch64 and x86_64
RUN wget -c https://musl.cc/x86_64-linux-musl-cross.tgz && \
tar zxf ./x86_64-linux-musl-cross.tgz -C /opt && \
wget -c https://musl.cc/aarch64-linux-musl-cross.tgz && \
tar zxf ./aarch64-linux-musl-cross.tgz -C /opt && \
rm ./x86_64-linux-musl-cross.tgz && \
rm ./aarch64-linux-musl-cross.tgz
CMD ["bash"]

View File

@@ -0,0 +1,413 @@
name: Docker Static
on:
push:
branches:
- 'main'
release:
types:
- 'published'
pull_request:
branches:
- 'main'
workflow_dispatch:
inputs:
msg:
description: "Set message"
default: "Manual trigger"
env:
CARGO_TERM_COLOR: always
# In total 5 jobs, all of the jobs are containerized
# ---
# build-ui , create/compile the web
## Use rustlang/rust:nighlty image
### Install nodejs from nodesource repo
### install wasm
### install rollup
### run app/build.sh
### upload artifacts
# builds-armhf, build-aarch64, build-amd64 create binary for respective arch
## Use rustlang/rust:nightly image
### Add non native architecture dpkg --add-architecture XXX
### Install dev tool gcc g++, etc per respective arch
### Cargo build
### Upload artifacts
## the CARGO_ env
#CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
# This will determine which architecture lib will be used.
# build-ui,builds-armhf, build-aarch64, build-amd64 will upload artifacts will be used next job
# build-docker-image job will fetch artifacts and run Dockerfile.ci then push the image.
# On current https://hub.docker.com/_/rust
# 1-bullseye, 1.61-bullseye, 1.61.0-bullseye, bullseye, 1, 1.61, 1.61.0, latest
# cache
## cargo
## target
jobs:
build-ui:
runs-on: ubuntu-latest
container:
image: rust:1.62
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -Ctarget-feature=+crt-static
steps:
- name: install runtime
run: apt update && apt install -y gcc-x86-64-linux-gnu g++-x86-64-linux-gnu libc6-dev ca-certificates
- name: setup node repo LTS
run: curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
- name: install nodejs
run: apt install -y nodejs && npm -g install npm
- name: smoke test
run: rustc --version
- uses: actions/cache@v3
with:
path: |
/usr/local/cargo/bin
/usr/local/cargo/registry/index
/usr/local/cargo/registry/cache
/usr/local/cargo/git/db
target
key: lldap-ui-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
lldap-ui-
- name: Checkout repository
uses: actions/checkout@v3.1.0
- name: install rollup nodejs
run: npm install -g rollup
- name: install wasm-pack with cargo
run: cargo install wasm-pack || true
env:
RUSTFLAGS: ""
- name: build frontend
run: ./app/build.sh
- name: check path
run: ls -al app/
- name: upload ui artifacts
uses: actions/upload-artifact@v3
with:
name: ui
path: app/
build-armhf:
runs-on: ubuntu-latest
container:
image: rust:1.62
env:
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER: arm-linux-gnueabihf-ld
CARGO_TERM_COLOR: always
RUSTFLAGS: -Ctarget-feature=-crt-static
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
steps:
- name: add armhf architecture
run: dpkg --add-architecture armhf
- name: install runtime
run: apt update && apt install -y gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf libc6-armhf-cross libc6-dev-armhf-cross tar ca-certificates
- name: smoke test
run: rustc --version
- name: add armhf target
run: rustup target add armv7-unknown-linux-gnueabihf
- name: smoke test
run: rustc --version
- name: Checkout repository
uses: actions/checkout@v3.1.0
- uses: actions/cache@v3
with:
path: |
.cargo/bin
.cargo/registry/index
.cargo/registry/cache
.cargo/git/db
target
key: lldap-bin-armhf-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
lldap-bin-armhf-
- name: compile armhf
run: cargo build --target=armv7-unknown-linux-gnueabihf --release -p lldap -p migration-tool
- name: check path
run: ls -al target/release
- name: upload armhf lldap artifacts
uses: actions/upload-artifact@v3
with:
name: armhf-lldap-bin
path: target/armv7-unknown-linux-gnueabihf/release/lldap
- name: upload armhfmigration-tool artifacts
uses: actions/upload-artifact@v3
with:
name: armhf-migration-tool-bin
path: target/armv7-unknown-linux-gnueabihf/release/migration-tool
build-aarch64:
runs-on: ubuntu-latest
container:
##################################################################################
# Github actions currently timeout when downloading musl-gcc #
# Using lldap dev image based on rust:1.62-slim-bullseye and musl-gcc bundled #
# Only for Job build aarch64 and amd64 #
###################################################################################
#image: rust:1.62
image: nitnelave/rust-dev:latest
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: aarch64-linux-musl-gcc
CARGO_TERM_COLOR: always
RUSTFLAGS: -Ctarget-feature=+crt-static
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
steps:
- name: Checkout repository
uses: actions/checkout@v3.1.0
- name: smoke test
run: rustc --version
- name: Checkout repository
uses: actions/checkout@v3.1.0
- uses: actions/cache@v3
with:
path: |
.cargo/bin
.cargo/registry/index
.cargo/registry/cache
.cargo/git/db
target
key: lldap-bin-aarch64-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
lldap-bin-aarch64-
# - name: fetch musl-gcc
# run: |
# wget -c https://musl.cc/aarch64-linux-musl-cross.tgz
# tar zxf ./x86_64-linux-musl-cross.tgz -C /opt
# echo "/opt/aarch64-linux-musl-cross:/opt/aarch64-linux-musl-cross/bin" >> $GITHUB_PATH
- name: add musl aarch64 target
run: rustup target add aarch64-unknown-linux-musl
- name: build lldap aarch4
run: cargo build --target=aarch64-unknown-linux-musl --release -p lldap -p migration-tool
- name: check path
run: ls -al target/aarch64-unknown-linux-musl/release/
- name: upload aarch64 lldap artifacts
uses: actions/upload-artifact@v3
with:
name: aarch64-lldap-bin
path: target/aarch64-unknown-linux-musl/release/lldap
- name: upload aarch64 migration-tool artifacts
uses: actions/upload-artifact@v3
with:
name: aarch64-migration-tool-bin
path: target/aarch64-unknown-linux-musl/release/migration-tool
build-amd64:
runs-on: ubuntu-latest
container:
# image: rust:1.62
image: nitnelave/rust-dev:latest
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -Ctarget-feature=+crt-static
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: x86_64-linux-musl-gcc
steps:
- name: Checkout repository
uses: actions/checkout@v3.1.0
- uses: actions/cache@v3
with:
path: |
.cargo/bin
.cargo/registry/index
.cargo/registry/cache
.cargo/git/db
target
key: lldap-bin-amd64-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
lldap-bin-amd64-
- name: install musl
run: apt update && apt install -y musl-tools tar wget
# - name: fetch musl-gcc
# run: |
# wget -c https://musl.cc/x86_64-linux-musl-cross.tgz
# tar zxf ./x86_64-linux-musl-cross.tgz -C /opt
# echo "/opt/x86_64-linux-musl-cross:/opt/x86_64-linux-musl-cross/bin" >> $GITHUB_PATH
- name: add x86_64 target
run: rustup target add x86_64-unknown-linux-musl
- name: build x86_64 lldap
run: cargo build --target=x86_64-unknown-linux-musl --release -p lldap -p migration-tool
- name: check path
run: ls -al target/x86_64-unknown-linux-musl/release/
- name: upload amd64 lldap artifacts
uses: actions/upload-artifact@v3
with:
name: amd64-lldap-bin
path: target/x86_64-unknown-linux-musl/release/lldap
- name: upload amd64 migration-tool artifacts
uses: actions/upload-artifact@v3
with:
name: amd64-migration-tool-bin
path: target/x86_64-unknown-linux-musl/release/migration-tool
build-docker-image:
needs: [build-ui,build-armhf,build-aarch64,build-amd64]
name: Build Docker image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: install rsync
run: sudo apt update && sudo apt install -y rsync
- name: fetch repo
uses: actions/checkout@v3.1.0
- name: Download armhf lldap artifacts
uses: actions/download-artifact@v3
with:
name: armhf-lldap-bin
path: bin/armhf-bin
- name: Download armhf migration-tool artifacts
uses: actions/download-artifact@v3
with:
name: armhf-migration-tool-bin
path: bin/armhf-bin
- name: Download aarch64 lldap artifacts
uses: actions/download-artifact@v3
with:
name: aarch64-lldap-bin
path: bin/aarch64-bin
- name: Download aarch64 migration-tool artifacts
uses: actions/download-artifact@v3
with:
name: aarch64-migration-tool-bin
path: bin/aarch64-bin
- name: Download amd64 lldap artifacts
uses: actions/download-artifact@v3
with:
name: amd64-lldap-bin
path: bin/amd64-bin
- name: Download amd64 migration-tool artifacts
uses: actions/download-artifact@v3
with:
name: amd64-migration-tool-bin
path: bin/amd64-bin
- name: check bin path
run: ls -al bin/
- name: Download llap ui artifacts
uses: actions/download-artifact@v3
with:
name: ui
path: web
- name: setup qemu
uses: docker/setup-qemu-action@v2
- uses: docker/setup-buildx-action@v2
- name: Docker meta
id: meta
uses: docker/metadata-action@v4
with:
# list of Docker images to use as base name for tags
images: |
nitnelave/lldap
# generate Docker tags based on the following events/attributes
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: parse tag
uses: gacts/github-slug@v1
id: slug
- name: Login to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
######################
#### latest build ####
######################
- name: Build and push latest alpine
if: github.event_name != 'release'
uses: docker/build-push-action@v3
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
file: ./.github/workflows/Dockerfile.ci.alpine
tags: nitnelave/lldap:latest, nitnelave/lldap:latest-alpine
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
- name: Build and push latest debian
if: github.event_name != 'release'
uses: docker/build-push-action@v3
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
file: ./.github/workflows/Dockerfile.ci.debian
tags: nitnelave/lldap:latest-debian
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
#######################
#### release build ####
#######################
- name: Build and push release alpine
if: github.event_name == 'release'
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
# Tag as latest, stable, semver, major, major.minor and major.minor.patch.
file: ./.github/workflows/Dockerfile.ci.alpine
tags: nitnelave/lldap:stable, nitnelave/lldap:stable-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-alpine.${{ steps.slug.outputs.version-minor }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}-alpine
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
- name: Build and push release debian
if: github.event_name == 'release'
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
# Tag as latest, stable, semver, major, major.minor and major.minor.patch.
file: ./.github/workflows/Dockerfile.ci.debian
tags: nitnelave/lldap:stable-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}-debian
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
- name: Move cache
run: rsync -r /tmp/.buildx-cache-new /tmp/.buildx-cache --delete
- name: Update repo description
if: github.event_name != 'pull_request'
uses: peter-evans/dockerhub-description@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
repository: nitnelave/lldap

View File

@@ -1,63 +0,0 @@
name: ci
on:
push:
branches:
- 'main'
tags:
- 'v*.*.*'
pull_request:
branches:
- 'main'
jobs:
docker:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Docker meta
id: meta
uses: docker/metadata-action@v3
with:
# list of Docker images to use as base name for tags
images: |
nitnelave/lldap
# generate Docker tags based on the following events/attributes
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Build and push
uses: docker/build-push-action@v2
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64
tags: nitnelave/lldap:latest
cache-from: type=gha
cache-to: type=gha,mode=max
-
name: Update repo description
if: github.event_name != 'pull_request'
uses: peter-evans/dockerhub-description@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
repository: nitnelave/lldap

View File

@@ -10,13 +10,31 @@ env:
CARGO_TERM_COLOR: always
jobs:
pre_job:
continue-on-error: true
runs-on: ubuntu-latest
# Map a step output to a job output
outputs:
should_skip: ${{ steps.skip_check.outputs.should_skip }}
steps:
- id: skip_check
uses: fkirc/skip-duplicate-actions@master
with:
concurrent_skipping: 'outdated_runs'
skip_after_successful_duplicate: 'true'
paths_ignore: '["**/*.md", "**/docs/**", "example_configs/**", "*.sh"]'
do_not_skip: '["workflow_dispatch", "schedule"]'
cancel_others: true
test:
name: cargo test
needs: pre_job
if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v2
uses: actions/checkout@v3.1.0
- uses: Swatinem/rust-cache@v1
- name: Build
run: cargo build --verbose --workspace
@@ -30,18 +48,12 @@ jobs:
clippy:
name: cargo clippy
needs: pre_job
if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v2
- name: Install nightly toolchain
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: nightly
override: true
components: rustfmt, clippy
uses: actions/checkout@v3.1.0
- uses: Swatinem/rust-cache@v1
@@ -53,18 +65,12 @@ jobs:
format:
name: cargo fmt
needs: pre_job
if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v2
- name: Install nightly toolchain
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: nightly
override: true
components: rustfmt, clippy
uses: actions/checkout@v3.1.0
- uses: Swatinem/rust-cache@v1
@@ -76,27 +82,26 @@ jobs:
coverage:
name: Code coverage
needs: pre_job
if: ${{ needs.pre_job.outputs.should_skip != 'true' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v2
uses: actions/checkout@v3.1.0
- name: Install Rust
run: rustup toolchain install nightly --component llvm-tools-preview
run: rustup toolchain install nightly --component llvm-tools-preview && rustup component add llvm-tools-preview --toolchain stable-x86_64-unknown-linux-gnu
- name: Install cargo-llvm-cov
run: curl -LsSf https://github.com/taiki-e/cargo-llvm-cov/releases/latest/download/cargo-llvm-cov-x86_64-unknown-linux-gnu.tar.gz | tar xzf - -C ~/.cargo/bin
- uses: taiki-e/install-action@cargo-llvm-cov
- uses: Swatinem/rust-cache@v1
- name: clean
run: cargo llvm-cov clean --workspace
- name: Generate code coverage for unit test
run: cargo llvm-cov --workspace --no-report
- name: Aggregate reports
run: cargo llvm-cov --no-run --lcov --output-path lcov.info
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1
uses: codecov/codecov-action@v3
with:
files: lcov.info
fail_ci_if_error: true

9
.gitignore vendored
View File

@@ -1,10 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target
/serve/target/
/app/target
/app/pkg
/auth/target
# These are backup files generated by rustfmt
**/*.rs.bk
@@ -22,6 +19,12 @@ package.json
# Server private key
server_key
# Pre-build binaries
*.tar.gz
# Misc
.env
recipe.json
lldap_config.toml
cert.pem
key.pem

90
CHANGELOG.md Normal file
View File

@@ -0,0 +1,90 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.4.1] - 2022-10-10
### Added
- Added support for STARTTLS for SMTP.
- Added support for user profile pictures, including importing them from OpenLDAP.
- Added support for every config value to be specified in a file.
- Added support for PKCS1 keys.
### Changed
- The `dn` attribute is no longer returned as an attribute (it's still part of the response).
- Empty attributes are no longer returned.
- The docker image now uses the locally-downloaded assets.
## [0.4.0] - 2022-07-08
### Breaking
The `lldap_readonly` group has been renamed `lldap_password_manager` (migration happens automatically) and a new `lldap_strict_readonly` group was introduced.
### Added
- A new `lldap_strict_readonly` group allows granting readonly rights to users (not able to change other's passwords, in particular).
### Changed
- The `lldap_readonly` group is renamed `lldap_password_manager` since it still allows users to change (non-admin) passwords.
### Removed
- The `lldap_readonly` group was removed.
## [0.3.0] - 2022-07-08
### Breaking
As part of the update, the database will do a one-time automatic migration to
add UUIDs and group creation times.
### Added
- Added support and documentation for many services:
- Apache Guacamole
- Bookstack
- Calibre
- Dolibarr
- Emby
- Gitea
- Grafana
- Jellyfin
- Matrix Synapse
- NextCloud
- Organizr
- Portainer
- Seafile
- Syncthing
- WG Portal
- New migration tool from OpenLDAP.
- New docker images for alternate architectures (arm64, arm/v7).
- Added support for LDAPS.
- New readonly group.
- Added UUID attribute for users and groups.
- Frontend now uses the refresh tokens to reduce the number of logins needed.
### Changed
- Much improved logging format.
- Simplified API login.
- Allowed non-admins to run search queries on the content they can see.
- "cn" attribute now returns the Full Name, not Username.
- Unknown attributes now warn instead of erroring.
- Introduced a list of attributes to silence those warnings.
### Deprecated
- Deprecated "cn" as LDAP username, "uid" is the correct attribute.
### Fixed
- Usernames, objectclass and attribute names are now case insensitive.
- Handle "1.1" and other wildcard LDAP attributes.
- Handle "memberOf" attribute.
- Handle fully-specified scope.
### Security
- Prevent SQL injections due to interaction between two libraries.
## [0.2.0] - 2021-11-27

2341
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,14 +2,13 @@
members = [
"server",
"auth",
"app"
"app",
"migration-tool"
]
# TODO: remove when there's a new release.
[patch.crates-io.yew_form]
git = 'https://github.com/sassman/yew_form/'
rev = '67050812695b7a8a90b81b0637e347fc6629daed'
default-members = ["server"]
[patch.crates-io.yew_form_derive]
git = 'https://github.com/sassman/yew_form/'
rev = '67050812695b7a8a90b81b0637e347fc6629daed'
# Remove once https://github.com/kanidm/ldap3_proto/pull/8 is merged.
[patch.crates-io.ldap3_proto]
git = 'https://github.com/nitnelave/ldap3_server/'
rev = '7b50b2b82c383f5f70e02e11072bb916629ed2bc'

View File

@@ -31,11 +31,12 @@ RUN cargo chef prepare --recipe-path /tmp/recipe.json
FROM chef AS builder
COPY --from=planner /tmp/recipe.json recipe.json
RUN cargo chef cook --release -p lldap_app --target wasm32-unknown-unknown \
&& cargo chef cook --release -p lldap
&& cargo chef cook --release -p lldap \
&& cargo chef cook --release -p migration-tool
# Copy the source and build the app and server.
COPY --chown=app:app . .
RUN cargo build --release -p lldap \
RUN cargo build --release -p lldap -p migration-tool \
# Build the frontend.
&& ./app/build.sh
@@ -44,13 +45,16 @@ FROM alpine:3.14
WORKDIR /app
COPY --from=builder /app/app/index.html /app/app/main.js /app/app/style.css app/
COPY --from=builder /app/app/index_local.html app/index.html
COPY --from=builder /app/app/static app/static
COPY --from=builder /app/app/pkg app/pkg
COPY --from=builder /app/target/release/lldap lldap
COPY --from=builder /app/target/release/lldap /app/target/release/migration-tool ./
COPY docker-entrypoint.sh lldap_config.docker_template.toml ./
RUN set -x \
&& apk add --no-cache bash \
&& for file in $(cat app/static/libraries.txt); do wget -P app/static "$file"; done \
&& for file in $(cat app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
&& chmod a+r -R .
ENV LDAP_PORT=3890

149
README.md
View File

@@ -28,11 +28,27 @@
</a>
</p>
- [About](#About)
- [Installation](#Installation)
- [With Docker](#With-Docker)
- [From source](#From-source)
- [Cross-compilation](#Cross-compilation)
- [Client configuration](#Client-configuration)
- [Compatible services](#compatible-services)
- [General configuration guide](#general-configuration-guide)
- [Sample client configurations](#Sample-client-configurations)
- [Comparisons with other services](#Comparisons-with-other-services)
- [vs OpenLDAP](#vs-openldap)
- [vs FreeIPA](#vs-freeipa)
- [I can't log in!](#i-cant-log-in)
- [Contributions](#Contributions)
## About
This project is a lightweight authentication server that provides an
opinionated, simplified LDAP interface for authentication. It integrates with
many backends, from KeyCloak to Authelia to Nextcloud and more!
many backends, from KeyCloak to Authelia to Nextcloud and
[more](#compatible-services)!
<img
src="https://raw.githubusercontent.com/nitnelave/lldap/master/screenshot.png"
@@ -41,6 +57,9 @@ many backends, from KeyCloak to Authelia to Nextcloud and more!
align="right"
/>
It comes with a frontend that makes user management easy, and allows users to
edit their own details or reset their password by email.
The goal is _not_ to provide a full LDAP server; if you're interested in that,
check out OpenLDAP. This server is a user management system that is:
* simple to setup (no messing around with `slapd`),
@@ -63,7 +82,7 @@ truth for users, via LDAP.
The image is available at `nitnelave/lldap`. You should persist the `/data`
folder, which contains your configuration, the database and the private key
file (unless you move them in the config).
file.
Configure the server by copying the `lldap_config.docker_template.toml` to
`/data/lldap_config.toml` and updating the configuration values (especially the
@@ -71,18 +90,26 @@ Configure the server by copying the `lldap_config.docker_template.toml` to
Environment variables should be prefixed with `LLDAP_` to override the
configuration.
Secrets can also be set through a file. The filename should be specified by the variables `LLDAP_JWT_SECRET_FILE` or `LLDAP_USER_PASS_FILE`, and the file contents are loaded into the respective configuration parameters. Note that `_FILE` variables take precedence.
If the `lldap_config.toml` doesn't exist when starting up, LLDAP will use default one. The default admin password is `password`, you can change the password later using the web interface.
Example for docker compose:
Secrets can also be set through a file. The filename should be specified by the
variables `LLDAP_JWT_SECRET_FILE` or `LLDAP_LDAP_USER_PASS_FILE`, and the file
contents are loaded into the respective configuration parameters. Note that
`_FILE` variables take precedence.
Example for docker compose for `:stable` tag:
* When defined with `user: ##:##` , ensure `/data` directory had permission for the defined user, else `1000:1000` used.
```yaml
version: '3'
volumes:
lldap_data:
driver: local
services:
lldap:
image: nitnelave/lldap
image: nitnelave/lldap:stable
# Change this to the user:group you want.
user: "33:33"
ports:
@@ -100,11 +127,56 @@ services:
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
```
Example for docker compose for `:latest` tag:
* `:latest` tag image contain recent pushed codes or feature test, breaks is expected.
* If `UID` and `GID` no defined LLDAP will use default `UID` and `GID` number `1000`
```yaml
version: '3'
volumes:
lldap_data:
driver: local
services:
lldap:
image: nitnelave/lldap:latest
ports:
# For LDAP
- "3890:3890"
# For the web front-end
- "17170:17170"
volumes:
- "lldap_data:/data"
# Alternatively, you can mount a local folder
# - "./lldap_data:/data"
environment:
- UID=####
- GID=####
- LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
- LLDAP_LDAP_USER_PASS=REPLACE_WITH_PASSWORD
- LLDAP_LDAP_BASE_DN=dc=example,dc=com
```
Then the service will listen on two ports, one for LDAP and one for the web
front-end.
### From source
To compile the project, you'll need:
* npm, curl: `sudo apt install curl npm`
* Rust/Cargo: [rustup.rs](https://rustup.rs/)
Then you can compile the server (and the migration tool if you want):
```shell
cargo build --release -p lldap -p migration-tool
```
The resulting binaries will be in `./target/release/`. Alternatively, you can
just run `cargo run -- run` to run the server.
To bring up the server, you'll need to compile the frontend. In addition to
cargo, you'll need:
@@ -114,19 +186,19 @@ cargo, you'll need:
Then you can build the frontend files with `./app/build.sh` (you'll need to run
this after every front-end change to update the WASM package served).
To bring up the server, just run `cargo run`. The default config is in
`src/infra/configuration.rs`, but you can override it by creating an
`lldap_config.toml`, setting environment variables or passing arguments to
`cargo run`.
The default config is in `src/infra/configuration.rs`, but you can override it
by creating an `lldap_config.toml`, setting environment variables or passing
arguments to `cargo run`. Have a look at the docker template:
`lldap_config.docker_template.toml`.
You can also install it as a systemd service, see
[lldap.service](example_configs/lldap.service).
### Cross-compilation
No Docker image is provided for other architectures, due to the difficulty of
setting up cross-compilation inside a Docker image.
Docker images are provided for AMD64, ARM64 and ARM/V7.
Some pre-compiled binaries are provided for each release, starting with 0.2.
If you want to cross-compile, you can do so by installing
If you want to cross-compile yourself, you can do so by installing
[`cross`](https://github.com/rust-embedded/cross):
```sh
@@ -146,6 +218,16 @@ files in an `app` folder next to the binary).
## Client configuration
### Compatible services
Most services that can use LDAP as an authentication provider should work out
of the box. For new services, it's possible that they require a bit of tweaking
on LLDAP's side to make things work. In that case, just create an issue with
the relevant details (logs of the service, LLDAP logs with `verbose=true` in
the config).
### General configuration guide
To configure the services that will talk to LLDAP, here are the values:
- The LDAP user DN is from the configuration. By default,
`cn=admin,ou=people,dc=example,dc=com`.
@@ -160,16 +242,37 @@ Testing group membership through `memberOf` is supported, so you can have a
filter like: `(memberOf=cn=admins,ou=groups,dc=example,dc=com)`.
The administrator group for LLDAP is `lldap_admin`: anyone in this group has
admin rights in the Web UI.
admin rights in the Web UI. Most LDAP integrations should instead use a user in
the `lldap_strict_readonly` or `lldap_password_manager` group, to avoid granting full
administration access to many services.
### Sample client configurations
Some specific clients have been tested to work and come with sample
configuration files, or guides. See the [`example_configs`](example_configs)
folder for help with:
- [Airsonic Advanced](example_configs/airsonic-advanced.md)
- [Apache Guacamole](example_configs/apacheguacamole.md)
- [Authelia](example_configs/authelia_config.yml)
- [Bookstack](example_configs/bookstack.env.example)
- [Calibre-Web](example_configs/calibre_web.md)
- [Dokuwiki](example_configs/dokuwiki.md)
- [Dolibarr](example_configs/dolibarr.md)
- [Emby](example_configs/emby.md)
- [Gitea](example_configs/gitea.md)
- [Grafana](example_configs/grafana_ldap_config.toml)
- [Hedgedoc](example_configs/hedgedoc.md)
- [Jellyfin](example_configs/jellyfin.md)
- [Jitsi Meet](example_configs/jitsi_meet.conf)
- [KeyCloak](example_configs/keycloak.md)
- [Jisti Meet](example_configs/jitsi_meet.conf)
- [Matrix](example_configs/matrix_synapse.yml)
- [Nextcloud](example_configs/nextcloud.md)
- [Organizr](example_configs/Organizr.md)
- [Portainer](example_configs/portainer.md)
- [Seafile](example_configs/seafile.md)
- [Syncthing](example_configs/syncthing.md)
- [WG Portal](example_configs/wg_portal.env.example)
- [XBackBone](example_configs/xbackbone_config.php)
## Comparisons with other services
@@ -187,18 +290,19 @@ OpenLDAP doesn't come with a UI: if you want a web interface, you'll have to
install one (not that many that look nice) and configure it.
LLDAP is much simpler to setup, has a much smaller image (10x smaller, 20x if
you add PhpLdapAdmin), and comes packed with its own purpose-built wed UI.
you add PhpLdapAdmin), and comes packed with its own purpose-built web UI.
### vs FreeIPA
FreeIPA is the one-stop shop for identity management: LDAP, Kerberos, NTP, DNS, Samba, you name it, it has it. In addition to user
management, it also does security policies, single sign-on, certificate
management, linux account management and so on.
FreeIPA is the one-stop shop for identity management: LDAP, Kerberos, NTP, DNS,
Samba, you name it, it has it. In addition to user management, it also does
security policies, single sign-on, certificate management, linux account
management and so on.
If you need all of that, go for it! Keep in mind that a more complex system is
more complex to maintain, though.
LLDAP is much lighter to run (<100 MB RAM including the DB), easier to
LLDAP is much lighter to run (<10 MB RAM including the DB), easier to
configure (no messing around with DNS or security policies) and simpler to
use. It also comes conveniently packed in a docker container.
@@ -219,7 +323,8 @@ set isn't working, try the following:
for docker) has the rights to write to the `/data` folder. If in doubt, you
can `chmod 777 /data` (or whatever the folder) to make it world-writeable.
- Make sure you restart the server.
- If it's still not working, join the [Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
- If it's still not working, join the
[Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
## Contributions

View File

@@ -1,31 +1,34 @@
[package]
name = "lldap_app"
version = "0.2.0"
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
version = "0.4.1"
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
edition = "2021"
[dependencies]
anyhow = "1"
base64 = "0.13"
graphql_client = "0.10"
http = "0.2"
jwt = "0.13"
rand = "0.8"
serde = "1"
serde_json = "1"
validator = "*"
validator = "=0.14"
validator_derive = "*"
wasm-bindgen = "0.2"
yew = "0.18"
yewtil = "*"
yew-router = "0.15"
yew_form = "0.1.8"
yew_form_derive = "*"
# Needed because of https://github.com/tkaitchuck/aHash/issues/95
indexmap = "=1.6.2"
[dependencies.web-sys]
version = "0.3"
features = [
"Document",
"Element",
"FileReader",
"HtmlDocument",
"HtmlInputElement",
"HtmlOptionElement",
@@ -44,5 +47,18 @@ features = [
path = "../auth"
features = [ "opaque_client" ]
[dependencies.image]
features = ["jpeg"]
default-features = false
version = "0.24"
[dependencies.yew_form]
git = "https://github.com/jfbilodeau/yew_form"
rev = "67050812695b7a8a90b81b0637e347fc6629daed"
[dependencies.yew_form_derive]
git = "https://github.com/jfbilodeau/yew_form"
rev = "67050812695b7a8a90b81b0637e347fc6629daed"
[lib]
crate-type = ["cdylib"]

View File

@@ -24,4 +24,4 @@ then
exit 1
fi
$ROLLUP_BIN ./main.js --format iife --file ./pkg/bundle.js
$ROLLUP_BIN ./main.js --format iife --file ./pkg/bundle.js --globals bootstrap:bootstrap

View File

@@ -18,12 +18,19 @@
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css"
as="style" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
integrity="sha384-tKLJeE1ALTUwtXlaGjJYM3sejfssWdAaWR2s97axw4xkiAdMzQjtOjgcyw0Y50KU"
crossorigin="anonymous" as="style" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
crossorigin="anonymous" integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap" />
<link
rel="stylesheet"
href="/static/style.css" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/style.css">
</head>
<body>

37
app/index_local.html Normal file
View File

@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>LLDAP Administration</title>
<script src="/pkg/bundle.js" defer></script>
<link
href="/static/bootstrap.min.css"
rel="preload stylesheet"
integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x"
as="style" />
<script
src="/static/bootstrap.bundle.min.js"
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"></script>
<link
rel="stylesheet"
href="/static/bootstrap-icons.css"
integrity="sha384-tKLJeE1ALTUwtXlaGjJYM3sejfssWdAaWR2s97axw4xkiAdMzQjtOjgcyw0Y50KU"
as="style" />
<link
rel="stylesheet"
integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN"
href="/static/font-awesome.min.css" />
<link
rel="stylesheet"
href="/static/fonts.css" />
<link
rel="stylesheet"
href="/static/style.css" />
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
</body>
</html>

View File

@@ -2,6 +2,8 @@ query GetGroupDetails($id: Int!) {
group(groupId: $id) {
id
displayName
creationDate
uuid
users {
id
displayName

View File

@@ -2,5 +2,6 @@ query GetGroupList {
groups {
id
displayName
creationDate
}
}

View File

@@ -5,7 +5,9 @@ query GetUserDetails($id: String!) {
displayName
firstName
lastName
avatar
creationDate
uuid
groups {
id
displayName

View File

@@ -85,7 +85,7 @@ impl Component for App {
}
if self.user_info.is_none() {
self.route_dispatcher
.send(RouteRequest::ReplaceRoute(Route::new_no_state("/login")));
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login)));
}
true
}
@@ -100,13 +100,14 @@ impl Component for App {
html! {
<div class="container shadow-sm py-3">
{self.view_banner()}
<div class="row justify-content-center">
<div class="row justify-content-center" style="padding-bottom: 80px;">
<div class="shadow-sm py-3" style="max-width: 1000px">
<Router<AppRoute>
render = Router::render(move |s| Self::dispatch_route(s, &link, is_admin))
/>
</div>
</div>
{self.view_footer()}
</div>
}
}
@@ -137,7 +138,7 @@ impl App {
match &self.user_info {
None => {
self.route_dispatcher
.send(RouteRequest::ReplaceRoute(Route::new_no_state("/login")));
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login)));
}
Some((user_name, is_admin)) => match &self.redirect_to {
Some(url) => {
@@ -147,7 +148,7 @@ impl App {
None => {
if *is_admin {
self.route_dispatcher
.send(RouteRequest::ReplaceRoute(Route::new_no_state("/users")));
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::ListUsers)));
} else {
self.route_dispatcher
.send(RouteRequest::ReplaceRoute(Route::from(
@@ -271,6 +272,30 @@ impl App {
}
}
fn view_footer(&self) -> Html {
html! {
<footer class="text-center text-muted fixed-bottom bg-light">
<div>
<span>{format!("LLDAP version {}", env!("CARGO_PKG_VERSION"))}</span>
</div>
<div>
<a href="https://github.com/nitnelave/lldap" class="me-4 text-reset">
<i class="bi-github"></i>
</a>
<a href="https://discord.gg/h5PEdRMNyP" class="me-4 text-reset">
<i class="bi-discord"></i>
</a>
<a href="https://twitter.com/nitnelave1?ref_src=twsrc%5Etfw" class="me-4 text-reset">
<i class="bi-twitter"></i>
</a>
</div>
<div>
<span>{"License "}<a href="https://github.com/nitnelave/lldap/blob/main/LICENSE" class="link-secondary">{"GNU GPL"}</a></span>
</div>
</footer>
}
}
fn is_admin(&self) -> bool {
match &self.user_info {
None => false,

View File

@@ -36,7 +36,7 @@ impl OpaqueData {
}
/// The fields of the form, with the constraints.
#[derive(Model, Validate, PartialEq, Clone, Default)]
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
pub struct FormModel {
#[validate(custom(
function = "empty_or_long",
@@ -64,7 +64,7 @@ pub struct ChangePasswordForm {
route_dispatcher: RouteAgentDispatcher,
}
#[derive(Clone, PartialEq, Properties)]
#[derive(Clone, PartialEq, Eq, Properties)]
pub struct Props {
pub username: String,
pub is_admin: bool,
@@ -211,8 +211,8 @@ impl Component for ChangePasswordForm {
CommonComponentParts::<Self>::update(self, msg)
}
fn change(&mut self, _: Self::Properties) -> ShouldRender {
false
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
@@ -252,6 +252,7 @@ impl Component for ChangePasswordForm {
<Field
form=&self.form
field_name="password"
input_type="password"
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
@@ -271,6 +272,7 @@ impl Component for ChangePasswordForm {
<Field
form=&self.form
field_name="confirm_password"
input_type="password"
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"

View File

@@ -28,7 +28,7 @@ pub struct CreateGroupForm {
form: yew_form::Form<CreateGroupModel>,
}
#[derive(Model, Validate, PartialEq, Clone, Default)]
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
pub struct CreateGroupModel {
#[validate(length(min = 1, message = "Groupname is required"))]
groupname: String,
@@ -92,8 +92,8 @@ impl Component for CreateGroupForm {
CommonComponentParts::<Self>::update(self, msg)
}
fn change(&mut self, _: Self::Properties) -> ShouldRender {
false
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {

View File

@@ -32,7 +32,7 @@ pub struct CreateUserForm {
form: yew_form::Form<CreateUserModel>,
}
#[derive(Model, Validate, PartialEq, Clone, Default)]
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
pub struct CreateUserModel {
#[validate(length(min = 1, message = "Username is required"))]
username: String,
@@ -90,6 +90,7 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
displayName: to_option(model.display_name),
firstName: to_option(model.first_name),
lastName: to_option(model.last_name),
avatar: None,
},
};
self.common.call_graphql::<CreateUser, _>(
@@ -185,8 +186,8 @@ impl Component for CreateUserForm {
CommonComponentParts::<Self>::update(self, msg)
}
fn change(&mut self, _: Self::Properties) -> ShouldRender {
false
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {

View File

@@ -40,7 +40,7 @@ pub enum Msg {
OnUserRemovedFromGroup((String, i64)),
}
#[derive(yew::Properties, Clone, PartialEq)]
#[derive(yew::Properties, Clone, PartialEq, Eq)]
pub struct Props {
pub group_id: i64,
}
@@ -68,6 +68,45 @@ impl GroupDetails {
}
}
fn view_details(&self, g: &Group) -> Html {
html! {
<>
<h3>{g.display_name.to_string()}</h3>
<div class="py-3">
<form class="form">
<div class="form-group row mb-3">
<label for="displayName"
class="form-label col-4 col-form-label">
{"Group: "}
</label>
<div class="col-8">
<span id="groupId" class="form-constrol-static">{g.display_name.to_string()}</span>
</div>
</div>
<div class="form-group row mb-3">
<label for="creationDate"
class="form-label col-4 col-form-label">
{"Creation date: "}
</label>
<div class="col-8">
<span id="creationDate" class="form-constrol-static">{g.creation_date.date().naive_local()}</span>
</div>
</div>
<div class="form-group row mb-3">
<label for="uuid"
class="form-label col-4 col-form-label">
{"UUID: "}
</label>
<div class="col-8">
<span id="uuid" class="form-constrol-static">{g.uuid.to_string()}</span>
</div>
</div>
</form>
</div>
</>
}
}
fn view_user_list(&self, g: &Group) -> Html {
let make_user_row = |user: &User| {
let user_id = user.id.clone();
@@ -92,7 +131,6 @@ impl GroupDetails {
};
html! {
<>
<h3>{g.display_name.to_string()}</h3>
<h5 class="fw-bold">{"Members"}</h5>
<div class="table-responsive">
<table class="table table-striped">
@@ -190,8 +228,8 @@ impl Component for GroupDetails {
CommonComponentParts::<Self>::update(self, msg)
}
fn change(&mut self, _: Self::Properties) -> ShouldRender {
false
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
@@ -201,6 +239,7 @@ impl Component for GroupDetails {
(Some(u), error) => {
html! {
<div>
{self.view_details(u)}
{self.view_user_list(u)}
{self.view_add_user_button(u)}
{self.view_messages(error)}

View File

@@ -13,7 +13,7 @@ use yew::prelude::*;
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/get_group_list.graphql",
response_derives = "Debug,Clone,PartialEq",
response_derives = "Debug,Clone,PartialEq,Eq",
custom_scalars_module = "crate::infra::graphql"
)]
pub struct GetGroupList;
@@ -75,8 +75,8 @@ impl Component for GroupTable {
CommonComponentParts::<Self>::update(self, msg)
}
fn change(&mut self, _: Self::Properties) -> ShouldRender {
false
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
@@ -97,7 +97,8 @@ impl GroupTable {
<table class="table table-striped">
<thead>
<tr>
<th>{"Groups"}</th>
<th>{"Group name"}</th>
<th>{"Creation date"}</th>
<th>{"Delete"}</th>
</tr>
</thead>
@@ -122,6 +123,9 @@ impl GroupTable {
{&group.display_name}
</Link>
</td>
<td>
{&group.creation_date.date().naive_local()}
</td>
<td>
<DeleteGroup
group=group.clone()

View File

@@ -15,10 +15,11 @@ use yew_form_derive::Model;
pub struct LoginForm {
common: CommonComponentParts<Self>,
form: Form<FormModel>,
refreshing: bool,
}
/// The fields of the form, with the constraints.
#[derive(Model, Validate, PartialEq, Clone, Default)]
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
pub struct FormModel {
#[validate(length(min = 1, message = "Missing username"))]
username: String,
@@ -34,6 +35,7 @@ pub struct Props {
pub enum Msg {
Update,
Submit,
AuthenticationRefreshResponse(Result<(String, bool)>),
AuthenticationStartResponse(
(
opaque::client::login::ClientLogin,
@@ -99,6 +101,14 @@ impl CommonComponent<LoginForm> for LoginForm {
.emit(user_info.context("Could not log in")?);
Ok(true)
}
Msg::AuthenticationRefreshResponse(user_info) => {
self.refreshing = false;
self.common.cancel_task();
if let Ok(user_info) = user_info {
self.common.on_logged_in.emit(user_info);
}
Ok(true)
}
}
}
@@ -112,79 +122,96 @@ impl Component for LoginForm {
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
LoginForm {
let mut app = LoginForm {
common: CommonComponentParts::<Self>::create(props, link),
form: Form::<FormModel>::new(FormModel::default()),
refreshing: true,
};
if let Err(e) =
app.common
.call_backend(HostService::refresh, (), Msg::AuthenticationRefreshResponse)
{
ConsoleService::debug(&format!("Could not refresh auth: {}", e));
app.refreshing = false;
}
app
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
CommonComponentParts::<Self>::update(self, msg)
}
fn change(&mut self, _: Self::Properties) -> ShouldRender {
false
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
type Field = yew_form::Field<FormModel>;
html! {
<form
class="form center-block col-sm-4 col-offset-4">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">
<i class="bi-person-fill"/>
</span>
if self.refreshing {
html! {
<div>
<img src={"spinner.gif"} alt={"Loading"} />
</div>
}
} else {
html! {
<form
class="form center-block col-sm-4 col-offset-4">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">
<i class="bi-person-fill"/>
</span>
</div>
<Field
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
form=&self.form
field_name="username"
placeholder="Username"
autocomplete="username"
oninput=self.common.callback(|_| Msg::Update) />
</div>
<Field
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
form=&self.form
field_name="username"
placeholder="Username"
autocomplete="username"
oninput=self.common.callback(|_| Msg::Update) />
</div>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">
<i class="bi-lock-fill"/>
</span>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">
<i class="bi-lock-fill"/>
</span>
</div>
<Field
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
form=&self.form
field_name="password"
input_type="password"
placeholder="Password"
autocomplete="current-password" />
</div>
<Field
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
form=&self.form
field_name="password"
input_type="password"
placeholder="Password"
autocomplete="current-password" />
</div>
<div class="form-group mt-3">
<button
type="submit"
class="btn btn-primary"
disabled=self.common.is_task_running()
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
{"Login"}
</button>
<NavButton
classes="btn-link btn"
disabled=self.common.is_task_running()
route=AppRoute::StartResetPassword>
{"Forgot your password?"}
</NavButton>
</div>
<div class="form-group">
{ if let Some(e) = &self.common.error {
html! { e.to_string() }
} else { html! {} }
}
</div>
</form>
<div class="form-group mt-3">
<button
type="submit"
class="btn btn-primary"
disabled=self.common.is_task_running()
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
{"Login"}
</button>
<NavButton
classes="btn-link btn"
disabled=self.common.is_task_running()
route=AppRoute::StartResetPassword>
{"Forgot your password?"}
</NavButton>
</div>
<div class="form-group">
{ if let Some(e) = &self.common.error {
html! { e.to_string() }
} else { html! {} }
}
</div>
</form>
}
}
}
}

View File

@@ -55,8 +55,8 @@ impl Component for LogoutButton {
CommonComponentParts::<Self>::update(self, msg)
}
fn change(&mut self, _: Self::Properties) -> ShouldRender {
false
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {

View File

@@ -81,8 +81,8 @@ impl Component for RemoveUserFromGroupComponent {
)
}
fn change(&mut self, _: Self::Properties) -> ShouldRender {
false
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {

View File

@@ -18,7 +18,7 @@ pub struct ResetPasswordStep1Form {
}
/// The fields of the form, with the constraints.
#[derive(Model, Validate, PartialEq, Clone, Default)]
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
pub struct FormModel {
#[validate(length(min = 1, message = "Missing username"))]
username: String,
@@ -76,8 +76,8 @@ impl Component for ResetPasswordStep1Form {
CommonComponentParts::<Self>::update(self, msg)
}
fn change(&mut self, _: Self::Properties) -> ShouldRender {
false
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {

View File

@@ -6,7 +6,10 @@ use crate::{
},
};
use anyhow::{bail, Context, Result};
use lldap_auth::*;
use lldap_auth::{
opaque::client::registration as opaque_registration,
password_reset::ServerPasswordResetResponse, registration,
};
use validator_derive::Validate;
use yew::prelude::*;
use yew_form::Form;
@@ -17,7 +20,7 @@ use yew_router::{
};
/// The fields of the form, with the constraints.
#[derive(Model, Validate, PartialEq, Clone, Default)]
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
pub struct FormModel {
#[validate(length(min = 8, message = "Invalid password. Min length: 8"))]
password: String,
@@ -29,17 +32,17 @@ pub struct ResetPasswordStep2Form {
common: CommonComponentParts<Self>,
form: Form<FormModel>,
username: Option<String>,
opaque_data: Option<opaque::client::registration::ClientRegistration>,
opaque_data: Option<opaque_registration::ClientRegistration>,
route_dispatcher: RouteAgentDispatcher,
}
#[derive(Clone, PartialEq, Properties)]
#[derive(Clone, PartialEq, Eq, Properties)]
pub struct Props {
pub token: String,
}
pub enum Msg {
ValidateTokenResponse(Result<String>),
ValidateTokenResponse(Result<ServerPasswordResetResponse>),
FormUpdate,
Submit,
RegistrationStartResponse(Result<Box<registration::ServerRegistrationStartResponse>>),
@@ -50,7 +53,7 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::ValidateTokenResponse(response) => {
self.username = Some(response?);
self.username = Some(response?.user_id);
self.common.cancel_task();
Ok(true)
}
@@ -62,7 +65,7 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
let mut rng = rand::rngs::OsRng;
let new_password = self.form.model().password;
let registration_start_request =
opaque::client::registration::start_registration(&new_password, &mut rng)
opaque_registration::start_registration(&new_password, &mut rng)
.context("Could not initiate password change")?;
let req = registration::ClientRegistrationStartRequest {
username: self.username.clone().unwrap(),
@@ -80,7 +83,7 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
let res = res.context("Could not initiate password change")?;
let registration = self.opaque_data.take().expect("Missing registration data");
let mut rng = rand::rngs::OsRng;
let registration_finish = opaque::client::registration::finish_registration(
let registration_finish = opaque_registration::finish_registration(
registration,
res.registration_response,
&mut rng,
@@ -142,8 +145,8 @@ impl Component for ResetPasswordStep2Form {
CommonComponentParts::<Self>::update(self, msg)
}
fn change(&mut self, _: Self::Properties) -> ShouldRender {
false
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {

View File

@@ -81,7 +81,7 @@ pub struct SelectOption {
props: SelectOptionProps,
}
#[derive(yew::Properties, Clone, PartialEq, Debug)]
#[derive(yew::Properties, Clone, PartialEq, Eq, Debug)]
pub struct SelectOptionProps {
pub value: String,
pub text: String,

View File

@@ -40,7 +40,7 @@ pub enum Msg {
OnUserRemovedFromGroup((String, i64)),
}
#[derive(yew::Properties, Clone, PartialEq)]
#[derive(yew::Properties, Clone, PartialEq, Eq)]
pub struct Props {
pub username: String,
pub is_admin: bool,
@@ -185,8 +185,8 @@ impl Component for UserDetails {
CommonComponentParts::<Self>::update(self, msg)
}
fn change(&mut self, _: Self::Properties) -> ShouldRender {
false
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
@@ -198,8 +198,7 @@ impl Component for UserDetails {
<>
<h3>{u.id.to_string()}</h3>
<UserDetailsForm
user=u.clone()
on_error=self.common.callback(Msg::OnError)/>
user=u.clone() />
<div class="row justify-content-center">
<NavButton
route=AppRoute::ChangePassword(u.id.clone())

View File

@@ -1,3 +1,5 @@
use std::str::FromStr;
use crate::{
components::user_details::User,
infra::common_component::{CommonComponent, CommonComponentParts},
@@ -5,11 +7,39 @@ use crate::{
use anyhow::{bail, Error, Result};
use graphql_client::GraphQLQuery;
use validator_derive::Validate;
use yew::prelude::*;
use wasm_bindgen::JsCast;
use yew::{prelude::*, services::ConsoleService};
use yew_form_derive::Model;
#[derive(PartialEq, Eq, Clone, Default)]
struct JsFile {
file: Option<web_sys::File>,
contents: Option<Vec<u8>>,
}
impl ToString for JsFile {
fn to_string(&self) -> String {
self.file
.as_ref()
.map(web_sys::File::name)
.unwrap_or_else(String::new)
}
}
impl FromStr for JsFile {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
if s.is_empty() {
Ok(JsFile::default())
} else {
bail!("Building file from non-empty string")
}
}
}
/// The fields of the form, with the editable details and the constraints.
#[derive(Model, Validate, PartialEq, Clone)]
#[derive(Model, Validate, PartialEq, Eq, Clone)]
pub struct UserModel {
#[validate(email)]
email: String,
@@ -25,7 +55,7 @@ pub struct UserModel {
schema_path = "../schema.graphql",
query_path = "queries/update_user.graphql",
response_derives = "Debug",
variables_derives = "Clone,PartialEq",
variables_derives = "Clone,PartialEq,Eq",
custom_scalars_module = "crate::infra::graphql"
)]
pub struct UpdateUser;
@@ -34,6 +64,7 @@ pub struct UpdateUser;
pub struct UserDetailsForm {
common: CommonComponentParts<Self>,
form: yew_form::Form<UserModel>,
avatar: JsFile,
/// True if we just successfully updated the user, to display a success message.
just_updated: bool,
}
@@ -43,24 +74,68 @@ pub enum Msg {
Update,
/// The "Submit" button was clicked.
SubmitClicked,
/// A picked file finished loading.
FileLoaded(yew::services::reader::FileData),
/// We got the response from the server about our update message.
UserUpdated(Result<update_user::ResponseData>),
}
#[derive(yew::Properties, Clone, PartialEq)]
#[derive(yew::Properties, Clone, PartialEq, Eq)]
pub struct Props {
/// The current user details.
pub user: User,
/// Callback to report errors (e.g. server error).
pub on_error: Callback<Error>,
}
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::Update => Ok(true),
Msg::Update => {
let window = web_sys::window().expect("no global `window` exists");
let document = window.document().expect("should have a document on window");
let input = document
.get_element_by_id("avatarInput")
.expect("Form field avatarInput should be present")
.dyn_into::<web_sys::HtmlInputElement>()
.expect("Should be an HtmlInputElement");
ConsoleService::log("Form update");
if let Some(files) = input.files() {
ConsoleService::log("Got file list");
if files.length() > 0 {
ConsoleService::log("Got a file");
let new_avatar = JsFile {
file: files.item(0),
contents: None,
};
if self.avatar.file.as_ref().map(|f| f.name())
!= new_avatar.file.as_ref().map(|f| f.name())
{
if let Some(ref file) = new_avatar.file {
self.mut_common().read_file(file.clone(), Msg::FileLoaded)?;
}
self.avatar = new_avatar;
}
}
}
Ok(true)
}
Msg::SubmitClicked => self.submit_user_update_form(),
Msg::UserUpdated(response) => self.user_update_finished(response),
Msg::FileLoaded(data) => {
self.common.cancel_task();
if let Some(file) = &self.avatar.file {
if file.name() == data.name {
if !is_valid_jpeg(data.content.as_slice()) {
// Clear the selection.
self.avatar = JsFile::default();
bail!("Chosen image is not a valid JPEG");
} else {
self.avatar.contents = Some(data.content);
return Ok(true);
}
}
}
Ok(false)
}
}
}
@@ -83,25 +158,25 @@ impl Component for UserDetailsForm {
Self {
common: CommonComponentParts::<Self>::create(props, link),
form: yew_form::Form::new(model),
avatar: JsFile::default(),
just_updated: false,
}
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
self.just_updated = false;
CommonComponentParts::<Self>::update_and_report_error(
self,
msg,
self.common.on_error.clone(),
)
CommonComponentParts::<Self>::update(self, msg)
}
fn change(&mut self, _: Self::Properties) -> ShouldRender {
false
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
type Field = yew_form::Field<UserModel>;
let avatar_base64 = maybe_to_base64(&self.avatar).unwrap_or_default();
let avatar_string = avatar_base64.as_ref().unwrap_or(&self.common.user.avatar);
html! {
<div class="py-3">
<form class="form">
@@ -111,7 +186,24 @@ impl Component for UserDetailsForm {
{"User ID: "}
</label>
<div class="col-8">
<span id="userId" class="form-constrol-static">{&self.common.user.id}</span>
<span id="userId" class="form-constrol-static"><b>{&self.common.user.id}</b></span>
</div>
</div>
<div class="form-group row mb-3">
<div class="col-4 col-form-label">
<img
id="avatarDisplay"
src={format!("data:image/jpeg;base64, {}", avatar_string)}
style="max-height:128px;max-width:128px;height:auto;width:auto;"
alt="Avatar" />
</div>
<div class="col-8">
<input
class="form-control"
id="avatarInput"
type="file"
accept="image/jpeg"
oninput=self.common.callback(|_| Msg::Update) />
</div>
</div>
<div class="form-group row mb-3">
@@ -195,6 +287,15 @@ impl Component for UserDetailsForm {
<span id="creationDate" class="form-constrol-static">{&self.common.user.creation_date.date().naive_local()}</span>
</div>
</div>
<div class="form-group row mb-3">
<label for="uuid"
class="form-label col-4 col-form-label">
{"UUID: "}
</label>
<div class="col-8">
<span id="creationDate" class="form-constrol-static">{&self.common.user.uuid}</span>
</div>
</div>
<div class="form-group row justify-content-center">
<button
type="submit"
@@ -205,6 +306,14 @@ impl Component for UserDetailsForm {
</button>
</div>
</form>
{ if let Some(e) = &self.common.error {
html! {
<div class="alert alert-danger">
{e.to_string() }
</div>
}
} else { html! {} }
}
<div hidden=!self.just_updated>
<span>{"User successfully updated!"}</span>
</div>
@@ -215,9 +324,19 @@ impl Component for UserDetailsForm {
impl UserDetailsForm {
fn submit_user_update_form(&mut self) -> Result<bool> {
ConsoleService::log("Submit");
if !self.form.validate() {
bail!("Invalid inputs");
}
ConsoleService::log("Valid inputs");
if let JsFile {
file: Some(_),
contents: None,
} = &self.avatar
{
bail!("Image file hasn't finished loading, try again");
}
ConsoleService::log("File is correctly loaded");
let base_user = &self.common.user;
let mut user_input = update_user::UpdateUserInput {
id: self.common.user.id.clone(),
@@ -225,6 +344,7 @@ impl UserDetailsForm {
displayName: None,
firstName: None,
lastName: None,
avatar: None,
};
let default_user_input = user_input.clone();
let model = self.form.model();
@@ -241,11 +361,14 @@ impl UserDetailsForm {
if base_user.last_name != model.last_name {
user_input.lastName = Some(model.last_name);
}
user_input.avatar = maybe_to_base64(&self.avatar)?;
// Nothing changed.
if user_input == default_user_input {
ConsoleService::log("No changes");
return Ok(false);
}
let req = update_user::Variables { user: user_input };
ConsoleService::log("Querying");
self.common.call_graphql::<UpdateUser, _>(
req,
Msg::UserUpdated,
@@ -260,18 +383,44 @@ impl UserDetailsForm {
Err(e) => return Err(e),
Ok(_) => {
let model = self.form.model();
self.common.user = User {
id: self.common.user.id.clone(),
email: model.email,
display_name: model.display_name,
first_name: model.first_name,
last_name: model.last_name,
creation_date: self.common.user.creation_date,
groups: self.common.user.groups.clone(),
};
self.common.user.email = model.email;
self.common.user.display_name = model.display_name;
self.common.user.first_name = model.first_name;
self.common.user.last_name = model.last_name;
if let Some(avatar) = maybe_to_base64(&self.avatar)? {
self.common.user.avatar = avatar;
}
self.just_updated = true;
}
};
Ok(true)
}
}
fn is_valid_jpeg(bytes: &[u8]) -> bool {
image::io::Reader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Jpeg)
.decode()
.is_ok()
}
fn maybe_to_base64(file: &JsFile) -> Result<Option<String>> {
match file {
JsFile {
file: None,
contents: _,
} => Ok(None),
JsFile {
file: Some(_),
contents: None,
} => bail!("Image file hasn't finished loading, try again"),
JsFile {
file: Some(_),
contents: Some(data),
} => {
if !is_valid_jpeg(data.as_slice()) {
bail!("Chosen image is not a valid JPEG");
}
Ok(Some(base64::encode(data)))
}
}
}

View File

@@ -81,8 +81,8 @@ impl Component for UserTable {
CommonComponentParts::<Self>::update(self, msg)
}
fn change(&mut self, _: Self::Properties) -> ShouldRender {
false
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {

View File

@@ -186,9 +186,13 @@ impl HostService {
.context("Error clearing cookie")
};
let parse_token = move |data: String| {
get_claims_from_jwt(&data)
serde_json::from_str::<login::ServerLoginResponse>(&data)
.context("Could not parse response")
.and_then(set_cookies)
.and_then(|r| {
get_claims_from_jwt(r.token.as_str())
.context("Could not parse response")
.and_then(set_cookies)
})
};
call_server(
"/auth/opaque/login/finish",
@@ -223,6 +227,32 @@ impl HostService {
)
}
pub fn refresh(_request: (), callback: Callback<Result<(String, bool)>>) -> Result<FetchTask> {
let set_cookies = |jwt_claims: JWTClaims| {
let is_admin = jwt_claims.groups.contains("lldap_admin");
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
.map(|_| (jwt_claims.user.clone(), is_admin))
.context("Error clearing cookie")
};
let parse_token = move |data: String| {
serde_json::from_str::<login::ServerLoginResponse>(&data)
.context("Could not parse response")
.and_then(|r| {
get_claims_from_jwt(r.token.as_str())
.context("Could not parse response")
.and_then(set_cookies)
})
};
call_server(
"/auth/refresh",
yew::format::Nothing,
callback,
"Could not start authentication: ",
parse_token,
)
}
// The `_request` parameter is to make it the same shape as the other functions.
pub fn logout(_request: (), callback: Callback<Result<()>>) -> Result<FetchTask> {
call_server_empty_response_with_error_message(
@@ -247,7 +277,7 @@ impl HostService {
pub fn reset_password_step2(
token: &str,
callback: Callback<Result<String>>,
callback: Callback<Result<lldap_auth::password_reset::ServerPasswordResetResponse>>,
) -> Result<FetchTask> {
call_server_json_with_error_message(
&format!("/auth/reset/step2/{}", token),

View File

@@ -26,7 +26,11 @@ use anyhow::{Error, Result};
use graphql_client::GraphQLQuery;
use yew::{
prelude::*,
services::{fetch::FetchTask, ConsoleService},
services::{
fetch::FetchTask,
reader::{FileData, ReaderService, ReaderTask},
ConsoleService,
},
};
use yewtil::NeqAssign;
@@ -40,13 +44,34 @@ pub trait CommonComponent<C: Component + CommonComponent<C>>: Component {
fn mut_common(&mut self) -> &mut CommonComponentParts<C>;
}
enum AnyTask {
None,
FetchTask(FetchTask),
ReaderTask(ReaderTask),
}
impl AnyTask {
fn is_some(&self) -> bool {
!matches!(self, AnyTask::None)
}
}
impl From<Option<FetchTask>> for AnyTask {
fn from(task: Option<FetchTask>) -> Self {
match task {
Some(t) => AnyTask::FetchTask(t),
None => AnyTask::None,
}
}
}
/// Structure that contains the common parts needed by most components.
/// The fields of [`props`] are directly accessible through a `Deref` implementation.
pub struct CommonComponentParts<C: CommonComponent<C>> {
link: ComponentLink<C>,
pub props: <C as Component>::Properties,
pub error: Option<Error>,
task: Option<FetchTask>,
task: AnyTask,
}
impl<C: CommonComponent<C>> CommonComponentParts<C> {
@@ -57,7 +82,7 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
/// Cancel any background task.
pub fn cancel_task(&mut self) {
self.task = None;
self.task = AnyTask::None;
}
pub fn create(props: <C as Component>::Properties, link: ComponentLink<C>) -> Self {
@@ -65,7 +90,7 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
link,
props,
error: None,
task: None,
task: AnyTask::None,
}
}
@@ -131,7 +156,7 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
M: Fn(Req, Callback<Resp>) -> Result<FetchTask>,
Cb: FnOnce(Resp) -> <C as Component>::Message + 'static,
{
self.task = Some(method(req, self.link.callback_once(callback))?);
self.task = AnyTask::FetchTask(method(req, self.link.callback_once(callback))?);
Ok(())
}
@@ -156,7 +181,19 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
ConsoleService::log(&e.to_string());
self.error = Some(e);
})
.ok();
.ok()
.into();
}
pub(crate) fn read_file<Cb>(&mut self, file: web_sys::File, callback: Cb) -> Result<()>
where
Cb: FnOnce(FileData) -> <C as Component>::Message + 'static,
{
self.task = AnyTask::ReaderTask(ReaderService::read_file(
file,
self.link.callback_once(callback),
)?);
Ok(())
}
}

View File

@@ -5,8 +5,7 @@ use web_sys::HtmlDocument;
fn get_document() -> Result<HtmlDocument> {
web_sys::window()
.map(|w| w.document())
.flatten()
.and_then(|w| w.document())
.ok_or_else(|| anyhow!("Could not get window document"))
.and_then(|d| {
d.dyn_into::<web_sys::HtmlDocument>()
@@ -16,8 +15,7 @@ fn get_document() -> Result<HtmlDocument> {
pub fn set_cookie(cookie_name: &str, value: &str, expiration: &DateTime<Utc>) -> Result<()> {
let doc = web_sys::window()
.map(|w| w.document())
.flatten()
.and_then(|w| w.document())
.ok_or_else(|| anyhow!("Could not get window document"))
.and_then(|d| {
d.dyn_into::<web_sys::HtmlDocument>()

18
app/static/fonts.css Normal file
View File

@@ -0,0 +1,18 @@
/* latin-ext */
@font-face {
font-family: 'Bebas Neue';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(fonts/JTUSjIg69CK48gW7PXoo9Wdhyzbi.woff2) format('woff2');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Bebas Neue';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(fonts/JTUSjIg69CK48gW7PXoo9Wlhyw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

View File

@@ -0,0 +1,3 @@
https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/fonts/bootstrap-icons.woff2
https://fonts.gstatic.com/s/bebasneue/v2/JTUSjIg69CK48gW7PXoo9Wdhyzbi.woff2
https://fonts.gstatic.com/s/bebasneue/v2/JTUSjIg69CK48gW7PXoo9Wlhyw.woff2

4
app/static/libraries.txt Normal file
View File

@@ -0,0 +1,4 @@
https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css
https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js
https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css
https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css

BIN
app/static/spinner.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -1,7 +1,7 @@
[package]
name = "lldap_auth"
version = "0.2.0"
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
version = "0.3.0-alpha.1"
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
edition = "2021"
[features]
@@ -13,7 +13,7 @@ js = []
[dependencies]
rust-argon2 = "0.8"
curve25519-dalek = "3"
digest = "*"
digest = "0.9"
generic-array = "*"
rand = "0.8"
serde = "*"

View File

@@ -3,10 +3,11 @@
use chrono::prelude::*;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fmt;
pub mod opaque;
/// The messages for the 3-step OPAQUE login process.
/// The messages for the 3-step OPAQUE and simple login process.
pub mod login {
use super::*;
@@ -35,6 +36,28 @@ pub mod login {
pub server_data: String,
pub credential_finalization: opaque::client::login::CredentialFinalization,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct ClientSimpleLoginRequest {
pub username: String,
pub password: String,
}
impl fmt::Debug for ClientSimpleLoginRequest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ClientSimpleLoginRequest")
.field("username", &self.username)
.field("password", &"***********")
.finish()
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct ServerLoginResponse {
pub token: String,
#[serde(rename = "refreshToken", skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
}
}
/// The messages for the 3-step OPAQUE registration process.
@@ -68,6 +91,19 @@ pub mod registration {
}
}
/// The messages for the 3-step OPAQUE registration process.
/// It is used to reset a user's password.
pub mod password_reset {
use super::*;
#[derive(Serialize, Deserialize, Clone)]
pub struct ServerPasswordResetResponse {
#[serde(rename = "userId")]
pub user_id: String,
pub token: String,
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct JWTClaims {
pub exp: DateTime<Utc>,

View File

@@ -1,20 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
for SECRET in LLDAP_JWT_SECRET LLDAP_LDAP_USER_PASS; do
FILE_VAR="${SECRET}_FILE"
SECRET_FILE="${!FILE_VAR:-}"
if [[ -n "$SECRET_FILE" ]]; then
if [[ -f "$SECRET_FILE" ]]; then
declare "$SECRET=$(cat $SECRET_FILE)"
export "$SECRET"
echo "[entrypoint] Set $SECRET from $SECRET_FILE"
else
echo "[entrypoint] Could not read contents of $SECRET_FILE (specified in $FILE_VAR)" >&2
fi
fi
done
CONFIG_FILE=/data/lldap_config.toml
if [[ ( ! -w "/data" ) ]] || [[ ( ! -d "/data" ) ]]; then
@@ -35,4 +21,13 @@ if [[ ! -r "$CONFIG_FILE" ]]; then
exit 1;
fi
exec /app/lldap "$@"
echo "> Setup permissions.."
find /app \! -user "$UID" -exec chown "$UID:$GID" '{}' +
find /data \! -user "$UID" -exec chown "$UID:$GID" '{}' +
echo "> Starting lldap.."
echo ""
exec gosu "$UID:$GID" /app/lldap "$@"
exec "$@"

View File

@@ -6,7 +6,8 @@ backend and [yew](https://yew.rs) for the frontend.
Backend:
* Listens on a port for LDAP protocol.
* Only a small, read-only subset of the LDAP protocol is supported.
* An extension to allow resetting the password through LDAP will be added.
* In addition to that, an extension to allow resetting the password is also
supported.
* Listens on another port for HTTP traffic.
* The authentication API, based on JWTs, is under "/auth".
* The user management API is a GraphQL API under "/api/graphql". The schema
@@ -46,11 +47,6 @@ Data storage:
### Passwords
Passwords are hashed using Argon2, the state of the art in terms of password
storage. They are hashed using a secret provided in the configuration (which
can be given as environment variable or command line argument as well): this
should be kept secret and shouldn't change (it would invalidate all passwords).
Authentication is done via the OPAQUE protocol, meaning that the passwords are
never sent to the server, but instead the client proves that they know the
correct password (zero-knowledge proof). This is likely overkill, especially
@@ -59,6 +55,15 @@ but it's one less potential flaw (especially since the LDAP interface can be
restricted to an internal docker-only network while the web app is exposed to
the Internet).
OPAQUE's "passwords" (user-specific blobs of data that can only be used in a
zero-knowledge proof that the password is correct) are hashed using Argon2, the
state of the art in terms of password storage. They are hashed using a secret
provided in the configuration (which can be given as environment variable or
command line argument as well): this should be kept secret and shouldn't change
(it would invalidate all passwords). Note that even if it was compromised, the
attacker wouldn't be able to decrypt the passwords without running an expensive
brute-force search independently for each password.
### JWTs and refresh tokens
When logging in for the first time, users are provided with a refresh token

View File

@@ -0,0 +1,40 @@
# Configuration for Organizr
## System Settings > Main > Authentication
---
### Host Address
```
ldap://localhost:3890
```
Replace `localhost:3890` with your LLDAP host & port
### Host Base DN
```
cn=%s,ou=people,dc=example,dc=com
```
### Account prefix
```
cn=
```
### Account Suffix
```
,ou=people,dc=example,dc=com
```
### Bind Username
```
cn=admin,ou=people,dc=example,dc=com
```
### Bind Password
```
Your password from your LDAP config
```
### LDAP Backend Type
```
OpenLDAP
```
Replace `dc=example,dc=com` with your LLDAP configured domain for all occurances

View File

@@ -0,0 +1,26 @@
# Configuration for Airsonic Advanced
Replace `dc=example,dc=com` with your LLDAP configured domain.
### LDAP URL
```
ldap://lldap:3890/ou=people,dc=example,dc=com
```
### LDAP search filter
```
(&(uid={0})(memberof=cn=airsonic,ou=groups,dc=example,dc=com))
```
### LDAP manager DN
```
cn=admin,ou=people,dc=example,dc=com
```
### Password
```
admin-password
```
Make sure the box `Automatically create users in Airsonic` is checked.
Restart airsonic-advanced

View File

@@ -0,0 +1,56 @@
# Configuration for Apache Guacamole
!! IMPORTANT - LDAP only works with LLDAP if using a [database authentication](https://guacamole.apache.org/doc/gug/ldap-auth.html#associating-ldap-with-a-database). The Apache Guacamole does support using LDAP to store user config but that is not in scope here.
This was achieved by using the docker [jasonbean/guacamole](https://registry.hub.docker.com/r/jasonbean/guacamole/).
## To setup LDAP
### Using `guacamole.properties`
Open and edit your Apache Guacamole properties files
Located at `guacamole/guacamole.properties`
Uncomment and insert the below into your properties file
```
### http://guacamole.apache.org/doc/gug/ldap-auth.html
### LDAP Properties
ldap-hostname: localhost
ldap-port: 3890
ldap-user-base-dn: ou=people,dc=example,dc=com
ldap-username-attribute: uid
ldap-search-bind-dn: uid=admin,ou=people,dc=example,dc=com
ldap-search-bind-password: replacewithyoursecret
ldap-user-search-filter: (memberof=cn=lldap_apacheguac,ou=groups,dc=example,dc=com)
```
### Using docker variables
```
LDAP_HOSTNAME: localhost
LDAP_PORT: 3890
LDAP_ENCRYPTION_METHOD: none
LDAP_USER_BASE_DN: ou=people,dc=example,dc=com
LDAP_USERNAME_ATTRIBUTE: uid
LDAP_SEARCH_BIND_DN: uid=admin,ou=people,dc=example,dc=com
LDAP_SEARCH_BIND_PASSWORD: replacewithyoursecret
LDAP_USER_SEARCH_FILTER: (memberof=cn=lldap_guacamole,ou=groups,dc=example,dc=com)
```
### Notes
* You set it either through `guacamole.properties` or docker variables, not both.
* Exclude `ldap-user-search-filter/LDAP_USER_SEARCH_FILTER` if you do not want to limit users based on a group(s)
* it is a filter that permits users with `lldap_guacamole` sample group.
* Replace `dc=example,dc=com` with your LLDAP configured domain for all occurances
* Apache Guacamole does not lock you out when enabling LDAP. Your `static` IDs still are able to log in.
* setting `LDAP_ENCRYPTION_METHOD` is disabling SSL
## To enable LDAP
Restart your Apache Guacamole app for changes to take effect
## To enable users
Before logging in with an LLDAP user, you have to manually create it using your static ID in Apache Guacamole. This applies to each user that you want to log in with using LDAP authentication. Otherwise the user will be logged in without any permissions/connections/etc.
Using your static ID, create a username that matches your target LDAP username. If applicable, tick the permissions and/or connections that you want this user to see.
Log in with LDAP user.

View File

@@ -7,7 +7,8 @@
authentication_backend:
# Password reset through authelia works normally.
disable_reset_password: false
password_reset:
disable: false
# How often authelia should check if there is an user update in LDAP
refresh_interval: 1m
ldap:
@@ -42,6 +43,6 @@ authentication_backend:
display_name_attribute: displayName
# The username and password of the admin user.
# "admin" should be the admin username you set in the LLDAP configuration
user: cn=admin,ou=people,dc=example,dc=com
user: uid=admin,ou=people,dc=example,dc=com
# Password can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html
password: 'REPLACE_ME'

View File

@@ -0,0 +1,66 @@
## ADD after values in the existing .env file.
## To keep existing documents, you might need to alter ownership/permission in the bookstack database.
# General auth
AUTH_METHOD=ldap
# The LDAP host, Adding a port is optional
LDAP_SERVER=ldap://lldap:3890
# If using LDAP over SSL you should also define the protocol:
# LDAP_SERVER=ldaps://example.com:636
# The base DN from where users will be dk within
LDAP_BASE_DN=ou=people,dc=example,dc=com
# The full DN and password of the user used to search the server
# Can both be left as false to bind anonymously
LDAP_DN=uid=admin,ou=people,dc=example,dc=com
LDAP_PASS=YOUR-ADMIN-PASSWORD-HERE
# A filter to use when searching for users
# The user-provided user-name used to replace any occurrences of '${user}'
# If you're setting this option via other means, such as within a docker-compose.yml,
# you may need escape the $, often using $$ or \$ instead.
LDAP_USER_FILTER=(&(uid=${user}))
# Set the LDAP version to use when connecting to the server
# Should be set to 3 in most cases.
LDAP_VERSION=3
# Set the property to use as a unique identifier for this user.
# Stored and used to match LDAP users with existing BookStack users.
# Prefixing the value with 'BIN;' will assume the LDAP service provides the attribute value as
# binary data and BookStack will convert the value to a hexidecimal representation.
# Defaults to 'uid'.
LDAP_ID_ATTRIBUTE=uid
# Set the default 'email' attribute. Defaults to 'mail'
LDAP_EMAIL_ATTRIBUTE=mail
# Set the property to use for a user's display name. Defaults to 'cn'
LDAP_DISPLAY_NAME_ATTRIBUTE=cn
# Set the attribute to use for the user's avatar image.
# Must provide JPEG binary image data.
# Will be used upon login or registration when the user doesn't
# already have an avatar image set.
# Remove this option or set to 'null' to disable LDAP avatar import.
#LDAP_THUMBNAIL_ATTRIBUTE=jpegphoto
# Force TLS to be used for LDAP communication.
# Use this if you can but your LDAP support will need to support it and
# you may need to import your certificate to the BookStack host machine.
# Defaults to 'false'.
LDAP_START_TLS=false
# If you need to allow untrusted LDAPS certificates, add the below and uncomment (remove the #)
# Only set this option if debugging or you're absolutely sure it's required for your setup.
# If using php-fpm, you may want to restart it after changing this option to avoid instability.
#LDAP_TLS_INSECURE=true
# If you need to debug the details coming from your LDAP server, add the below and uncomment (remove the #)
# Only set this option if debugging since it will block logins and potentially show private details.
#LDAP_DUMP_USER_DETAILS=true

View File

@@ -0,0 +1,97 @@
# Configuration for Calibre-Web
Replace `dc=example,dc=com` with your LLDAP configured domain.
### Login type
```
Use LDAP Authentication
```
### LDAP Server Host Name or IP Address
```
lldap
```
### LDAP Server Port
```
3890
```
### LDAP Encryption
```
none
```
### LDAP Authentication
```
simple
```
### LDAP Administrator Username
```
uid=admin,ou=people,dc=example,dc=com
```
### LDAP Administrator Password
```
CHANGE_ME
```
### LDAP Distinguished Name (DN)
```
dc=example,dc=com
```
### LDAP User Object Filter
```
(&(objectclass=person)(uid=%s))
```
### LDAP Server is OpenLDAP?
```
yes
```
### LDAP Group Object Filter
```
(&(objectclass=groupOfUniqueNames)(cn=%s))
```
### LDAP Group Name
```
calibre_web
```
Note: Create a group in lldap and add users to it that will have access to your Calibre-Web instance
### LDAP Group Members Field
```
uniqueMember
```
### LDAP Member User Filter Detection
```
Custom Filter
```
### LDAP Member User Filter
```
(&(objectclass=person)(uid=%s))
```
Note: lowercase the word "person" until this bug is fixed

View File

@@ -0,0 +1,25 @@
# Configuration for dokuwiki
LDAP configuration is in ```/dokuwiki/conf/local.protected.php```:
```
<?php
$conf['useacl'] = 1; //enable ACL
$conf['authtype'] = 'authldap'; //enable this Auth plugin
$conf['plugin']['authldap']['server'] = 'ldap://lldap_server:3890'; #IP of your lldap
$conf['plugin']['authldap']['usertree'] = 'ou=people,dc=example,dc=com';
$conf['plugin']['authldap']['grouptree'] = 'ou=groups, dc=example, dc=com';
$conf['plugin']['authldap']['userfilter'] = '(&(uid=%{user})(objectClass=person))';
$conf['plugin']['authldap']['groupfilter'] = '(&(objectClass=group)(memberUID=member))';
$conf['plugin']['authldap']['attributes'] = array('cn', 'displayname', 'mail', 'givenname', 'objectclass', 'sn', 'uid', 'memberof');
$conf['plugin']['authldap']['version'] = 3;
$conf['plugin']['authldap']['binddn'] = 'cn=admin,ou=people,dc=example,dc=com';
$conf['plugin']['authldap']['bindpw'] = 'ENTER_YOUR_LLDAP_PASSWORD';
```
DokuWiki by default, ships with an LDAP Authentication Plugin called ```authLDAP``` that allows authentication against an LDAP directory.
All you need to do is to activate the plugin. This can be done on the DokuWiki Extensions Manager.
Once the LDAP settings are defined, proceed to define the default authentication method.
Navigate to Table of Contents > DokuWiki > Authentication.
On the Authentication backend, select ```authldap``` and save the changes.

View File

@@ -0,0 +1,89 @@
# Configuration pour Dolibarr
This example will help you to create user in dolibarr from your users in your lldap server from a specific group and to login with the password from the lldap server.
## To connect ldap->dolibarr
In Dolibarr, install the LDAP module from `Home` -> `Modules/Applications`
Go to the configuration of this module and fill it like this:
- Users and groups synchronization: `LDAP -> Dolibarr`
- Contacts' synchronization: `No`
- Type: `OpenLdap`
- Version: `Version 3`
- Primary server: `ldap://example.com`
- Secondary server: `Empty`
- Server port: port `3890`
- Server DN: `dc=example,dc=com`
- Use TLS: `No`
- Administrator DN: `uid=admin,ou=people,dc=example,dc=com`
- Administrator password: `secret`
Click on modify then "TEST LDAP CONNECTION".
You should get this result on the bottom:
```
TCP connect to LDAP server successful (Server=ldap://example.com, Port=389)
Connect/Authenticate to LDAP server successful (Server=ldap://example.com, Port=389, Admin=uid=admin,ou=people,dc=example,dc=com, Password=**********)
LDAP server configured for version 3
```
And two new tabs will appear on the top: `Users` and `Groups`.
We will use only `Users` in this example to get the users we want to import.
The tab `Groups` would be to import groups.
Click on the `Users` tab and fill it like this:
- Users' DN: `ou=people,dc=example,dc=com`
- List of objectClass: `person`
- Search filter: `memberOf=cn=yournamegroup,ou=groups,dc=example,dc=com`
(or if you don't have a group for your users, leave the search filter empty)
- Full name: `cn`
- Name: `sn`
- First name: `givenname`
- Login `uid`
- Email address `mail`
Click on "MODIFY" and then on "TEST A LDAP SEARCH".
You should get the number of users in the group or all users if you didn't use a filter.
## To import ldap users into the dolibarr database (needed to login with those users):
Navigate to `Users & Groups` -> `New Users`.
Click on the blank form "Users in LDAP database", you will get the list of the users in the group filled above. With the "GET" button, you will import the selected user.
## To enable LDAP login:
Modify your `conf.php` in your dolibarr folder in `htdocs/conf`.
Replace
```
// Authentication settings
$dolibarr_main_authentication='dolibarr';
```
with:
```
// Authentication settings
// Only add "ldap" to only login using the ldap server, or/and "dolibar" to compare with local users. In any case, you need to have the user existing in dolibarr.
$dolibarr_main_authentication='ldap,dolibarr';
$dolibarr_main_auth_ldap_host='ldap://127.0.0.1:3890';
$dolibarr_main_auth_ldap_port='3890';
$dolibarr_main_auth_ldap_version='3';
$dolibarr_main_auth_ldap_servertype='openldap';
$dolibarr_main_auth_ldap_login_attribute='uid';
$dolibarr_main_auth_ldap_dn='ou=people,dc=example,dc=com';
$dolibarr_main_auth_ldap_admin_login='uid=admin,ou=people,dc=example,dc=com';
$dolibarr_main_auth_ldap_admin_pass='secret';
```
You can add this line to enable debug in case anything is wrong:
```
$dolibarr_main_auth_ldap_debug='true';
```

29
example_configs/emby.md Normal file
View File

@@ -0,0 +1,29 @@
# Configuration for Emby
Emby only uses LDAP to create users and validate passwords upon login. Emby administrators are always validated via native emby login.
https://emby.media/introducing-ldap-support-for-emby.html
Replace `dc=example,dc=com` with your LLDAP configured domain.
### Bind DN
```
cn=admin,ou=people,dc=example,dc=com
```
### Bind Credentials
```
changeme (replace with your password)
```
### User search base
```
ou=people,dc=example,dc=com
```
### User search filter
replace the `emby_user` cn with the group name for accounts that should be able to login to Emby, otherwise leave the default `(uid={0})`.
```
(&(uid={0})(memberOf=cn=emby_user,ou=groups,dc=example,dc=com))
```

22
example_configs/gitea.md Normal file
View File

@@ -0,0 +1,22 @@
# Configuration for Gitea
In Gitea, go to `Site Administration > Authentication Sources` and click `Add Authentication Source`
Select `LDAP (via BindDN)`
* Host: Your lldap server's ip/hostname
* Port: Your lldap server's port (3890 by default)
* Bind DN: `uid=admin,ou=people,dc=example,dc=com`
* Bind Password: Your bind user's password
* User Search Base: `ou=people,dc=example,dc=com`
* User Filter: If you want all users to be able to log in, use<br>
`(&(objectClass=person)(|(uid=%[1]s)(mail=%[1]s)))`.<br>
To log in they can either use their email address or user name. If you only want members a specific group to be able to log in, in this case the group `git_user`, use<br>
`(&(memberof=cn=git_user,ou=groups,dc=example,dc=com)(|(uid=%[1]s)(mail=%[1]s)))`<br>
For more info on the user filter, see: https://docs.gitea.io/en-us/authentication/#ldap-via-binddn
* Admin Filter: Use `(memberof=cn=lldap_admin,ou=groups,dc=example,dc=com)` if you want lldap admins to become Gitea admins. Leave empty otherwise.
* Username Attribute: `uid`
* Email Attribute: `mail`
* Check `Enable User Synchronization`
Replace every instance of `dc=example,dc=com` with your configured domain.
After applying the above settings, users should be able to log in with either their user name or email address.

View File

@@ -0,0 +1,49 @@
# This is only the ldap config, you also need to enable ldap support in the main config file
# of Grafana. See https://grafana.com/docs/grafana/latest/auth/ldap/#enable-ldap
# You can test that it is working correctly by trying usernames at: https://<your grafana instance>/admin/ldap
[[servers]]
# Ldap server host (specify multiple hosts space separated)
host = "<your ldap host>"
# Default port is 389 or 636 if use_ssl = true
port = 3890
# Set to true if LDAP server should use an encrypted TLS connection (either with STARTTLS or LDAPS)
use_ssl = false
# If set to true, use LDAP with STARTTLS instead of LDAPS
start_tls = false
# set to true if you want to skip SSL cert validation
ssl_skip_verify = false
# set to the path to your root CA certificate or leave unset to use system defaults
# root_ca_cert = "/path/to/certificate.crt"
# Authentication against LDAP servers requiring client certificates
# client_cert = "/path/to/client.crt"
# client_key = "/path/to/client.key"
# Search user bind dn
bind_dn = "uid=<your grafana user>,ou=people,dc=example,dc=org"
# Search user bind password
# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;"""
bind_password = "<grafana user password>"
# User search filter
search_filter = "(uid=%s)"
# If you want to limit to only users of a specific group use this instead:
# search_filter = "(&(uid=%s)(memberOf=cn=<your group>,ou=groups,dc=example,dc=org))"
# An array of base dns to search through
search_base_dns = ["dc=example,dc=org"]
# Specify names of the LDAP attributes your LDAP uses
[servers.attributes]
member_of = "memberOf"
email = "mail"
name = "givenName"
surname = "sn"
username = "uid"
# If you want to map your ldap groups to grafana's groups, see: https://grafana.com/docs/grafana/latest/auth/ldap/#group-mappings
# As a quick example, here is how you would map lldap's admin group to grafana's admin
# [[servers.group_mappings]]
# group_dn = "uid=lldap_admin,ou=groups,dc=example,dc=org"
# org_role = "Admin"
# grafana_admin = true

View File

@@ -0,0 +1,16 @@
# Configuration for hedgedoc
[Hedgedoc](https://hedgedoc.org/) is a platform to write and share markdown.
### Using docker variables
Any member of the group ```hedgedoc``` can log into hedgedoc.
```
- CMD_LDAP_URL=ldap://lldap:3890
- CMD_LDAP_BINDDN=uid=admin,ou=people,dc=example,dc=com
- CMD_LDAP_BINDCREDENTIALS=insert_your_password
- CMD_LDAP_SEARCHBASE=ou=people,dc=example,dc=com
- CMD_LDAP_SEARCHFILTER=(&(memberOf=cn=hedgedoc,ou=groups,dc=example,dc=com)(uid={{username}}))
- CMD_LDAP_USERIDFIELD=uid
```
Replace `dc=example,dc=com` with your LLDAP configured domain for all occurances

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View File

@@ -0,0 +1,50 @@
# Configuration for Jellyfin
Replace `dc=example,dc=com` with your LLDAP configured domain.
### LDAP Bind User
```
uid=admin,ou=people,dc=example,dc=com
```
### LDAP Base DN for searches
```
ou=people,dc=example,dc=com
```
### LDAP Attributes
```
uid, mail
```
### LDAP Name Attribute
```
uid
```
### User Filter
If you have a `media` group, you can use:
```
(memberof=cn=media,ou=groups,dc=example,dc=com)
```
Otherwise, just use:
```
(uid=*)
```
### Admin Filter
Same here. If you have `media_admin` group (doesn't have to be named like
that), use:
```
(memberof=cn=media_admin,ou=groups,dc=example,dc=com)
```
Otherwise, you can use LLDAP's admin group:
```
(memberof=cn=lldap_admin,ou=groups,dc=example,dc=com)
```

View File

@@ -15,10 +15,10 @@ AUTH_TYPE=ldap
LDAP_URL=ldap://IP:3890
# LDAP base DN.
LDAP_BASE=dc=example,dc=com
LDAP_BASE=ou=people,dc=example,dc=com
# LDAP user DN.
LDAP_BINDDN=cn=admin,ou=people,dc=example,dc=com
LDAP_BINDDN=uid=admin,ou=people,dc=example,dc=com
# LLDAP admin password.
LDAP_BINDPW=password

View File

@@ -25,7 +25,7 @@ The key settings are:
- Connection URL: `ldap://<your-lldap-container>:3890`
- Users DN: `ou=people,dc=example,dc=com` (or whatever `dc` you have)
- Bind Type: `simple`
- Bind DN: `cn=admin,ou=people,dc=example,dc=com` (replace with your admin user and `dc`)
- Bind DN: `uid=admin,ou=people,dc=example,dc=com` (replace with your admin user and `dc`)
- Bind Credential: your LLDAP admin password
Test the connection and authentication, it should work.

View File

@@ -0,0 +1,22 @@
[Unit]
Description=Nitnelave LLDAP
Documentation=https://github.com/nitnelave/lldap
# Only sqlite
After=network.target
[Service]
# The user/group LLDAP is run under. The working directory (see below) should allow write and read access to this user/group.
User=root
Group=root
# The location of the compiled binary
ExecStart=/opt/nitnelave/lldap \
run
# Only allow writes to the following directory and set it to the working directory (user and password data are stored here).
WorkingDirectory=/opt/nitnelave/
ReadWriteDirectories=/opt/nitnelave/
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,14 @@
modules:
- module: "ldap_auth_provider.LdapAuthProviderModule"
config:
enabled: true
uri: "ldap://lldap"
start_tls: false
base: "ou=people,dc=example,dc=com"
attributes:
uid: "uid"
mail: "mail"
name: "cn"
bind_dn: "uid=admin,ou=people,dc=example,dc=com"
bind_password: "password"
filter: "(objectClass=person)"

View File

@@ -0,0 +1,111 @@
# Nextcloud LLDAP example config
## lldap users & groups
This example is using following users & groups in lldap :
* A technical user (ex: `ro_admin`), member of `lldap_strict_readonly` or `lldap_password_manager`
* Several accounts, members of `users` group will be authorized to log in Nextcloud (eg neither `admin` nor `ro_admin`)
* Some "application" groups, let's say `friends` and `family`: users in Nextcloud will be able to share files and view people in dynamic lists only to members of their own group(s)
## Nextcloud config : the cli way
TL;DR let's script it. The "user_ldap" application is shipped with default Nextcloud installation (at least using Docker official stable images), you just have to install & enable it :
```sh
occ app:install user_ldap
occ app:enable user_ldap
occ ldap:create-empty-config
# EDIT: domain
occ ldap:set-config s01 ldapHost "ldap://lldap.example.net."
occ ldap:set-config s01 ldapPort 3890
# EDIT: admin user
occ ldap:set-config s01 ldapAgentName "uid=ro_admin,ou=people,dc=example,dc=com"
# EDIT: password
occ ldap:set-config s01 ldapAgentPassword "password"
# EDIT: Base DN
occ ldap:set-config s01 ldapBase "dc=example,dc=com"
occ ldap:set-config s01 ldapBaseUsers "dc=example,dc=com"
occ ldap:set-config s01 ldapBaseGroups "dc=example,dc=com"
occ ldap:set-config s01 ldapConfigurationActive 1
occ ldap:set-config s01 ldapLoginFilter "(&(objectclass=person)(uid=%uid))"
# EDIT: users group, contains the users who can login to Nextcloud
occ ldap:set-config s01 ldapUserFilter "(&(objectclass=person)(memberOf=cn=users,ou=groups,dc=example,dc=com))"
occ ldap:set-config s01 ldapUserFilterMode 0
occ ldap:set-config s01 ldapUserFilterObjectclass person
occ ldap:set-config s01 turnOnPasswordChange 0
occ ldap:set-config s01 ldapCacheTTL 600
occ ldap:set-config s01 ldapExperiencedAdmin 0
occ ldap:set-config s01 ldapGidNumber gidNumber
# EDIT: list of application groups
occ ldap:set-config s01 ldapGroupFilter "(&(objectclass=groupOfUniqueNames)(|(cn=friends)(cn=family)))"
# EDIT: list of application groups
occ ldap:set-config s01 ldapGroupFilterGroups "friends;family"
occ ldap:set-config s01 ldapGroupFilterMode 0
occ ldap:set-config s01 ldapGroupDisplayName cn
occ ldap:set-config s01 ldapGroupFilterObjectclass groupOfUniqueNames
occ ldap:set-config s01 ldapGroupMemberAssocAttr uniqueMember
occ ldap:set-config s01 ldapLoginFilterEmail 0
occ ldap:set-config s01 ldapLoginFilterUsername 1
occ ldap:set-config s01 ldapMatchingRuleInChainState unknown
occ ldap:set-config s01 ldapNestedGroups 0
occ ldap:set-config s01 ldapPagingSize 500
occ ldap:set-config s01 ldapTLS 0
occ ldap:set-config s01 ldapUserAvatarRule default
occ ldap:set-config s01 ldapUserDisplayName displayname
occ ldap:set-config s01 ldapUserFilterMode 1
occ ldap:set-config s01 ldapUuidGroupAttribute auto
occ ldap:set-config s01 ldapUuidUserAttribute auto
```
With small amount of luck, you should be able to log in your nextcloud instance with LLDAP accounts in the `users` group.
## Nextcloud config : the GUI way
1. enable LDAP application (installed but not enabled by default)
2. setup your ldap server in Settings > Administration > LDAP / AD integration
3. setup Group limitations
### LDAP server config
Fill the LLDAP domain and port, DN + password of your technical account and base DN (as usual : change `example.com` by your own domain) :
![ldap configuration page](images/nextcloud_ldap_srv.png)
### Users tab
Select `person` as object class and then choose `Edit LDAP Query` : the `only from these groups` option is not functional.
We want only users from the `users` group to be allowed to log in Nextcloud :
```
(&(objectclass=person)(memberOf=cn=users,ou=groups,dc=example,dc=com))
```
![login configuration page](images/nextcloud_loginfilter.png)
You can check with `Verify settings and count users` that your filter is working properly (here your accounts `admin` and `ro_admin` will not be counted as users).
### Login attributes
Select `Edit LDAP Query` and enter :
```
(&(objectclass=person)(uid=%uid))
```
![login attributes page](images/nextcloud_login_attributes.png)
Enter a valid username in lldap and check if your filter is working.
### Groups
You can use the menus for this part : select `groupOfUniqueNames` in the first menu and check every group you want members to be allowed to view their group member / share files with.
![groups configuration page](images/nextcloud_groups.png)
The resulting LDAP filter could be simplified removing the first 'OR' condition (I think).
## Sharing restrictions
Go to Settings > Administration > Sharing and check following boxes :
* "Allow username autocompletion to users within the same groups"
* "Restrict users to only share with users in their groups"
![sharing options](images/nextcloud_sharing_options.png)

View File

@@ -0,0 +1,64 @@
# Configuration for Portainer CE/BE
### Settings > Authentication > LDAP > Custom
---
## LDAP configuration
#### LDAP Server
```
localhost:3890 or ip-address:3890
```
#### Anonymous mode
```
off
```
#### Reader DN
```
uid=admin,ou=people,dc=example,dc=com
```
#### Password
```
xxx
```
* Password is the ENV you set at *LLDAP_LDAP_USER_PASS=* or `lldap_config.toml`
## User search configurations
#### Base DN
```
ou=people,dc=example,dc=com
```
#### Username attribute
```
uid
```
### Filter
#### All available user(s)
```
(objectClass=person)
```
* Using this filter will list all user registered in LLDAP
#### All user(s) from specific group
```
(&(objectClass=person)(memberof=cn=lldap_portainer,ou=groups,dc=example,dc=com))
```
* Using this filter will only list user that included in `lldap_portainer` group.
* Admin should manually configure groups and add a user to it. **lldap_portainer** only sample.
## Group search configurations
#### Group Base DN
```
ou=groups,dc=example,dc=com
```
#### Group Membership Attribute
```
cn
```
#### Group Filter
```
is optional
```

View File

@@ -0,0 +1,89 @@
# Configuration for Seafile
Seafile's LDAP interface requires a unique, immutable user identifier in the format of `username@domain`. Since LLDAP does not provide an attribute like `userPrincipalName`, the only attribute that somewhat qualifies is therefore `mail`. However, using `mail` as the user identifier results in the issue that Seafile will treat you as an entirely new user if you change your email address through LLDAP. If this is not an issue for you, you can configure LLDAP as an authentication source in Seafile directly. A better but more elaborate way to use Seafile with LLDAP is by using Authelia as an intermediary. This document will guide you through both setups.
## Configuring Seafile to use LLDAP directly
Add the following to your `seafile/conf/ccnet.conf` file:
```
[LDAP]
HOST = ldap://192.168.1.100:3890
BASE = ou=people,dc=example,dc=com
USER_DN = uid=admin,ou=people,dc=example,dc=com
PASSWORD = CHANGE_ME
LOGIN_ATTR = mail
```
* Replace `192.168.1.100:3890` with your LLDAP server's ip/hostname and port.
* Replace every instance of `dc=example,dc=com` with your configured domain.
After restarting the Seafile server, users should be able to log in with their email address and password.
### Filtering by group membership
If you only want members of a specific group to be able to log in, add the following line:
```
FILTER = memberOf=cn=seafile_user,ou=groups,dc=example,dc=com
```
* Replace `seafile_user` with the name of your group.
## Configuring Seafile to use LLDAP with Authelia as an intermediary
Authelia is an open-source authentication and authorization server that can use LLDAP as a backend and act as an OpenID Connect Provider. We're going to assume that you have already set up Authelia and configured it with LLDAP.
If not, you can find an example configuration [here](authelia_config.yml).
1. Add the following to Authelia's `configuration.yml`:
```
identity_providers:
oidc:
hmac_secret: Your_HMAC_Secret #Replace with a random string
issuer_private_key: |
-----BEGIN RSA PRIVATE KEY-----
Your_Private_Key
#See https://www.authelia.com/configuration/identity-providers/open-id-connect/#issuer_private_key for instructions on how to generate a key
-----END RSA PRIVATE KEY-----
cors:
endpoints:
- authorization
- token
- revocation
- introspection
- userinfo
clients:
- id: seafile
description: Seafile #The display name of the application. Will show up on Authelia consent screens
secret: Your_Shared_Secret #Replace with random string
public: false
authorization_policy: one_factor #Can also be two_factor
scopes:
- openid
- profile
- email
redirect_uris:
- https://seafile.example.com/oauth/callback/
userinfo_signing_algorithm: none
pre_configured_consent_duration: 6M
#On first login you must consent to sharing information between Authelia and Seafile. This option configures the amount of time after which you need to reconsent.
# y = years, M = months, w = weeks, d = days
```
2. Add the following to `seafile/conf/seahub_settings.py`
```
ENABLE_OAUTH = True
OAUTH_ENABLE_INSECURE_TRANSPORT = True
OAUTH_CLIENT_ID = 'seafile' #Must be the same as in Authelia
OAUTH_CLIENT_SECRET = 'Your_Shared_Secret' #Must be the same as in Authelia
OAUTH_REDIRECT_URL = 'https://seafile.example.com/oauth/callback/'
OAUTH_PROVIDER_DOMAIN = 'auth.example.com'
OAUTH_AUTHORIZATION_URL = 'https://auth.example.com/api/oidc/authorization'
OAUTH_TOKEN_URL = 'https://auth.example.com/api/oidc/token'
OAUTH_USER_INFO_URL = 'https://auth.example.com/api/oidc/userinfo'
OAUTH_SCOPE = [
"openid",
"profile",
"email",
]
OAUTH_ATTRIBUTE_MAP = {
"preferred_username": (True, "email"), #Seafile will create a unique identifier of your <LLDAP's User ID >@<the value specified in OAUTH_PROVIDER_DOMAIN>. The identifier is not visible to the user and not actually used as the email address unlike the value suggests
"name": (False, "name"),
"id": (False, "not used"),
"email": (False, "contact_email"),
}
```
Restart both your Authelia and Seafile server. You should see a "Single Sign-On" button on Seafile's login page. Clicking it should redirect you to Authelia. If you use the [example config for Authelia](authelia_config.yml), you should be able to log in using your LLDAP User ID.

View File

@@ -0,0 +1,30 @@
# Configuration for Syncthing
## Actions > Advanced > LDAP
---
| Parameter | Value | Details |
|----------------------|------------------------------------------------------------------------|-------------------------------------------------------|
| Address | `localhost:3890` | Replace `localhost:3890` with your LLDAP host & port |
| Bind DN | `cn=%s,ou=people,dc=example,dc=com` | |
| Insecure Skip Verify | *unchecked* | |
| Search Base DN | `ou=people,dc=example,dc=com` | Only used when using filters. |
| Search Filter | `(&(uid=%s)(memberof=cn=lldap_syncthing,ou=groups,dc=example,dc=com))` | Filters on users belonging to group `lldap_syncthing` |
| Transport | `plain` | |
Replace `dc=example,dc=com` with your LLDAP configured domain for all occurances
Leave **Search Base DN** and **Search Filter** both blank if you are not using any filters.
## Actions > Advanced > GUI
Change **Auth Mode** from `static` to `ldap`
If you get locked out of the UI due to invalid LDAP settings, you can always change the settings from the `config.xml`, save the file, and force restart the app.
### Example
Change the below and restart
` <authMode>ldap</authMode>` to ` <authMode>static</authMode>`

View File

@@ -0,0 +1,16 @@
# Config for wg-portal (https://github.com/h44z/wg-portal)
# Replace dc=example,dc=com with your base DN
# Connection to LLDAP
# Remember that wg-portal requires host networking when ran in docker, so you cannot use docker networks to manage this
LDAP_URL: ldap://localhost:3890
LDAP_BASEDN: "dc=example,dc=com"
LDAP_USER: "uid=admin,ou=people,dc=example,dc=com"
LDAP_PASSWORD: "CHANGEME"
LDAP_LOGIN_FILTER: "(&(objectClass=person)(|(mail={{login_identifier}})(uid={{login_identifier}})))"
LDAP_SYNC_FILTER: "(&(objectClass=person)(mail=*))"
LDAP_ADMIN_GROUP: "uid=everyone,ou=groups,dc=example,dc=com"
LDAP_ATTR_EMAIL: "mail"
LDAP_STARTTLS: "false"

View File

@@ -0,0 +1,21 @@
<?php
return array (
'ldap' =>
array (
'enabled' => true,
'schema' => 'ldap',
// If using same docker network, use 'lldap', otherwise put ip/hostname
'host' => 'lldap',
// Normal ldap port is 389, standard in LLDAP is 3890
'port' => 3890,
'base_domain' => 'ou=people,dc=example,dc=com',
// ???? is replaced with user-provided username, authenticates users in an lldap group called "xbackbone"
// Remove the "(memberof=...)" if you want to allow all users.
'search_filter' => '(&(uid=????)(objectClass=person)(memberof=cn=xbackbone,ou=groups,dc=example,dc=com))',
// the attribute to use as username
'rdn_attribute' => 'uid',
// LDAP admin/service account info below
'service_account_dn' => 'cn=admin,ou=people,dc=example,dc=com',
'service_account_password' => 'REPLACE_ME',
),
);

View File

@@ -3,6 +3,10 @@
## with "LLDAP_". For instance, "ldap_port" can be overridden with the
## "LLDAP_LDAP_PORT" variable.
## Tune the logging to be more verbose by setting this to be true.
## You can set it with the LLDAP_VERBOSE environment variable.
# verbose=false
## The port on which to have the LDAP server.
#ldap_port = 3890
@@ -20,7 +24,7 @@
## them to re-login.
## You should probably set it through the LLDAP_JWT_SECRET environment
## variable from a secret ".env" file.
## This can also be set from a file's contents by specifying the file path
## This can also be set from a file's contents by specifying the file path
## in the LLDAP_JWT_SECRET_FILE environment variable
## You can generate it with (on linux):
## LC_ALL=C tr -dc 'A-Za-z0-9!"#%&'\''()*+,-./:;<=>?@[\]^_{|}~' </dev/urandom | head -c 32; echo ''
@@ -41,6 +45,11 @@
## For the administration interface, this is the username.
#ldap_user_dn = "admin"
## Admin email.
## Email for the admin account. It is only used when initially creating
## the admin user, and can safely be omitted.
#ldap_user_email = "admin@example.com"
## Admin password.
## Password for the admin account, both for the LDAP bind and for the
## administration interface. It is only used when initially creating
@@ -48,7 +57,7 @@
## It should be minimum 8 characters long.
## You can set it with the LLDAP_LDAP_USER_PASS environment variable.
## This can also be set from a file's contents by specifying the file path
## in the LLDAP_USER_PASS_FILE environment variable
## in the LLDAP_LDAP_USER_PASS_FILE environment variable
## Note: you can create another admin user for user administration, this
## is just the default one.
#ldap_user_pass = "REPLACE_WITH_PASSWORD"
@@ -74,6 +83,14 @@ database_url = "sqlite:///data/users.db?mode=rwc"
## Randomly generated on first run if it doesn't exist.
key_file = "/data/private_key"
## Ignored attributes.
## Some services will request attributes that are not present in LLDAP. When it
## is the case, LLDAP will warn about the attribute being unknown. If you want
## to ignore the attribute and the service works without, you can add it to this
## list to silence the warning.
#ignored_user_attributes = [ "sAMAccountName" ]
#ignored_group_attributes = [ "mail", "userPrincipalName" ]
## Options to configure SMTP parameters, to send password reset emails.
## To set these options from environment variables, use the following format
## (example with "password"): LLDAP_SMTP_OPTIONS__PASSWORD
@@ -84,8 +101,8 @@ key_file = "/data/private_key"
#server="smtp.gmail.com"
## The SMTP port.
#port=587
## Whether to connect with TLS.
#tls_required=true
## How the connection is encrypted, either "TLS" or "STARTTLS".
#smtp_encryption = "TLS"
## The SMTP user, usually your email address.
#user="sender@gmail.com"
## The SMTP password.
@@ -95,3 +112,16 @@ key_file = "/data/private_key"
#from="LLDAP Admin <sender@gmail.com>"
## Same for reply-to, optional.
#reply_to="Do not reply <noreply@localhost>"
## Options to configure LDAPS.
## To set these options from environment variables, use the following format
## (example with "port"): LLDAP_LDAPS_OPTIONS__PORT
#[ldaps_options]
## Whether to enable LDAPS.
#enabled=true
## Port on which to listen.
#port=6360
## Certificate file.
#cert_file="/data/cert.pem"
## Certificate key file.
#key_file="/data/key.pem"

33
migration-tool/Cargo.toml Normal file
View File

@@ -0,0 +1,33 @@
[package]
name = "migration-tool"
version = "0.4.1"
edition = "2021"
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
[dependencies]
anyhow = "*"
base64 = "0.13"
rand = "0.8"
requestty = "0.4.1"
serde = "1"
serde_json = "1"
smallvec = "*"
[dependencies.lldap_auth]
path = "../auth"
features = ["opaque_client"]
[dependencies.graphql_client]
features = ["graphql_query_derive", "reqwest-rustls"]
default-features = false
version = "0.11"
[dependencies.reqwest]
version = "*"
default-features = false
features = ["json", "blocking", "rustls-tls"]
[dependencies.ldap3]
version = "*"
default-features = false
features = ["sync", "tls-rustls"]

View File

@@ -0,0 +1,5 @@
mutation AddUserToGroup($user: String!, $group: Int!) {
addUserToGroup(userId: $user, groupId: $group) {
ok
}
}

View File

@@ -0,0 +1,6 @@
mutation CreateGroup($name: String!) {
createGroup(name: $name) {
id
displayName
}
}

View File

@@ -0,0 +1,5 @@
mutation CreateUser($user: CreateUserInput!) {
createUser(user: $user) {
id
}
}

View File

@@ -0,0 +1,9 @@
query ListGroups {
groups {
id
displayName
users {
id
}
}
}

View File

@@ -0,0 +1,5 @@
query ListUsers {
users(filters: null) {
id
}
}

435
migration-tool/src/ldap.rs Normal file
View File

@@ -0,0 +1,435 @@
use anyhow::{anyhow, Context, Result};
use ldap3::{ResultEntry, SearchEntry};
use requestty::{prompt_one, Question};
use smallvec::SmallVec;
use crate::lldap::User;
pub struct LdapClient {
domain: String,
connection: ldap3::LdapConn,
}
/// Checks if the URL starts with the protocol, and whether the host is valid (DNS and listening),
/// potentially with the given port. Returns the address + port that managed to connect, if any.
pub fn check_host_exists(
url: &str,
protocol_and_port: &[(&str, u16)],
) -> std::result::Result<Option<String>, String> {
for (protocol, port) in protocol_and_port {
if url.starts_with(protocol) {
use std::net::ToSocketAddrs;
let trimmed_url = url.trim_start_matches(protocol);
return match trimmed_url.to_socket_addrs() {
Ok(_) => Ok(Some(url.to_owned())),
Err(_) => {
let new_url = format!("{}:{}", trimmed_url, port);
new_url
.to_socket_addrs()
.map_err(|_| format!("Could not resolve host: '{}'", trimmed_url))
.map(|_| Some(format!("{}{}", protocol, new_url)))
}
};
}
}
Ok(None)
}
fn autocomplete_domain_suffix(input: String, domain: &str) -> SmallVec<[String; 1]> {
let mut answers = SmallVec::<[String; 1]>::new();
for part in input.split(',') {
if !part.starts_with('d') {
continue;
}
if domain.starts_with(part) {
answers.push(input.clone() + domain.trim_start_matches(part));
}
}
answers.push(input);
answers
}
/// Asks the user for the URL of the LDAP server, and checks that a connection can be established.
/// Returns the LDAP URL.
fn get_ldap_url() -> Result<String> {
let ldap_protocols = &[("ldap://", 389), ("ldaps://", 636)];
let question = Question::input("ldap_url")
.message("LDAP_URL (ldap://...)")
.auto_complete(|answer, _| {
let mut answers = SmallVec::<[String; 1]>::new();
if "ldap://".starts_with(&answer) {
answers.push("ldap://".to_owned());
}
if "ldaps://".starts_with(&answer) {
answers.push("ldaps://".to_owned());
}
answers.push(answer);
answers
})
.validate(|url, _| {
if let Some(url) = check_host_exists(url, ldap_protocols)? {
ldap3::LdapConn::new(&url)
.map_err(|e| format!("Could not connect to LDAP server: {}", e))?;
Ok(())
} else {
Err("LDAP URL should start with 'ldap://' or 'ldaps://'".to_owned())
}
})
.build();
let answer = prompt_one(question)?;
Ok(
check_host_exists(answer.as_string().unwrap(), ldap_protocols)
.unwrap()
.unwrap(),
)
}
/// Binds the LDAP connection by asking the user for the bind DN and password, and returns the bind
/// DN.
fn bind_ldap(
ldap_connection: &mut ldap3::LdapConn,
previous_binddn: Option<String>,
) -> Result<String> {
let binddn = {
let question = Question::input("ldap_binddn")
.message("LDAP_BIND_DN (cn=...)")
.validate(|dn, _| {
if dn.contains(',') && dn.contains('=') {
Ok(())
} else {
Err(
"Invalid bind DN, expected something like 'cn=admin,dc=example,dc=com'"
.to_owned(),
)
}
})
.auto_complete(|answer, _| {
let mut answers = SmallVec::<[String; 1]>::new();
if let Some(binddn) = &previous_binddn {
answers.push(binddn.clone());
}
answers.push(answer);
answers
})
.build();
let answer = prompt_one(question)?;
answer.as_string().unwrap().to_owned()
};
let password = {
let question = Question::password("ldap_bind_password")
.message("LDAP_BIND_PASSWORD")
.validate(|password, _| {
if !password.is_empty() {
Ok(())
} else {
Err("Empty password".to_owned())
}
})
.build();
let answer = prompt_one(question)?;
answer.as_string().unwrap().to_owned()
};
if let Err(e) = ldap_connection
.simple_bind(&binddn, &password)
.and_then(ldap3::LdapResult::success)
{
println!("Error connecting as '{}': {}", binddn, e);
bind_ldap(ldap_connection, Some(binddn))
} else {
Ok(binddn)
}
}
impl TryFrom<ResultEntry> for User {
type Error = anyhow::Error;
fn try_from(value: ResultEntry) -> Result<Self> {
let entry = SearchEntry::construct(value);
let get_required_attribute = |attr| {
entry
.attrs
.get(attr)
.ok_or_else(|| anyhow!("Missing {} for user", attr))
.and_then(|u| -> Result<String> {
u.iter()
.next()
.map(String::to_owned)
.ok_or_else(|| anyhow!("Too many {}s", attr))
})
};
let id = get_required_attribute("uid")
.or_else(|_| get_required_attribute("sAMAccountName"))
.or_else(|_| get_required_attribute("userPrincipalName"))?;
let email = get_required_attribute("mail")
.or_else(|_| get_required_attribute("rfc822mailbox"))
.context(format!("for user '{}'", id))?;
let get_optional_attribute = |attr: &str| {
entry
.attrs
.get(attr)
.and_then(|v| v.first().map(|s| s.as_str()))
.filter(|s| !s.is_empty())
.map(str::to_owned)
};
let last_name = get_optional_attribute("sn").or_else(|| get_optional_attribute("surname"));
let display_name = get_optional_attribute("cn")
.or_else(|| get_optional_attribute("commonName"))
.or_else(|| get_optional_attribute("name"))
.or_else(|| get_optional_attribute("displayName"));
let first_name = get_optional_attribute("givenName");
let avatar = entry
.attrs
.get("jpegPhoto")
.map(|v| v.iter().map(|s| s.as_bytes().to_vec()).collect::<Vec<_>>())
.or_else(|| entry.bin_attrs.get("jpegPhoto").map(Clone::clone))
.and_then(|v| v.into_iter().next().filter(|s| !s.is_empty()));
let password =
get_optional_attribute("userPassword").or_else(|| get_optional_attribute("password"));
Ok(User::new(
crate::lldap::CreateUserInput {
id,
email,
display_name,
first_name,
last_name,
avatar: avatar.map(base64::encode),
},
password,
entry.dn,
))
}
}
enum OuType {
User,
Group,
}
fn detect_ou(
ldap_connection: &mut ldap3::LdapConn,
domain: &str,
for_type: OuType,
) -> Result<(Option<String>, Vec<String>), anyhow::Error> {
let ous = ldap_connection
.search(
domain,
ldap3::Scope::Subtree,
"(objectClass=organizationalUnit)",
vec!["dn"],
)?
.success()?
.0;
let mut detected_ou = None;
let mut all_ous = Vec::new();
for result_entry in ous {
let dn = SearchEntry::construct(result_entry).dn;
match for_type {
OuType::User => {
if dn.contains("user") || dn.contains("people") || dn.contains("person") {
detected_ou = Some(dn.clone());
}
}
OuType::Group => {
if dn.contains("group") {
detected_ou = Some(dn.clone());
}
}
}
all_ous.push(dn);
}
Ok((detected_ou, all_ous))
}
pub fn get_users(connection: &mut LdapClient) -> Result<Vec<User>, anyhow::Error> {
let LdapClient {
connection: ldap_connection,
domain,
} = connection;
let domain = domain.as_str();
let (maybe_user_ou, all_ous) = detect_ou(ldap_connection, domain, OuType::User)?;
let user_ou = {
let question = Question::input("ldap_user_ou")
.message(format!(
"Where are the users located (under '{}')? {}(LDAP_USERS_DN)",
domain,
maybe_user_ou
.as_ref()
.map(|ou| format!("Detected: {}", ou))
.unwrap_or_default()
))
.validate(|dn, _| {
if dn.contains('=') {
Ok(())
} else {
Err(format!(
"Invalid bind DN, expected something like 'ou=people,{}'",
domain
))
}
})
.default(maybe_user_ou.unwrap_or_default())
.auto_complete(|s, _| {
let mut answers = autocomplete_domain_suffix(s, domain);
answers.extend(all_ous.clone().into_iter());
answers
})
.build();
let answer = prompt_one(question)?;
let mut answer = answer.as_string().unwrap().to_owned();
if !answer.ends_with(domain) {
if !answer.is_empty() {
answer += ",";
}
answer += domain;
}
answer
};
let users = ldap_connection
.search(
&user_ou,
ldap3::Scope::Subtree,
"(|(objectClass=inetOrgPerson)(objectClass=person)(objectClass=mailAccount)(objectClass=posixAccount)(objectClass=user)(objectClass=organizationalPerson))",
vec![
"uid",
"sAMAccountName",
"userPrincipalName",
"mail",
"rfc822mailbox",
"givenName",
"sn",
"surname",
"cn",
"commonName",
"displayName",
"name",
"userPassword",
],
)?
.success()?
.0;
users
.into_iter()
.map(TryFrom::try_from)
.collect::<Result<Vec<User>>>()
}
#[derive(Debug)]
pub struct LdapGroup {
pub name: String,
pub members: Vec<String>,
}
impl TryFrom<ResultEntry> for LdapGroup {
type Error = anyhow::Error;
// https://github.com/graphql-rust/graphql-client/issues/386
#[allow(non_snake_case)]
fn try_from(value: ResultEntry) -> Result<Self> {
let entry = SearchEntry::construct(value);
let get_required_attribute = |attr| {
entry
.attrs
.get(attr)
.ok_or_else(|| anyhow!("Missing {} for user", attr))
.and_then(|u| {
if u.len() > 1 {
Err(anyhow!("Too many {}s", attr))
} else {
Ok(u.first().unwrap().to_owned())
}
})
};
let name = get_required_attribute("cn")
.or_else(|_| get_required_attribute("commonName"))
.or_else(|_| get_required_attribute("displayName"))
.or_else(|_| get_required_attribute("name"))?;
let get_repeated_attribute = |attr: &str| entry.attrs.get(attr).map(|v| v.to_owned());
let members = get_repeated_attribute("member")
.or_else(|| get_repeated_attribute("uniqueMember"))
.unwrap_or_default();
Ok(LdapGroup { name, members })
}
}
pub fn get_groups(connection: &mut LdapClient) -> Result<Vec<LdapGroup>> {
let LdapClient {
connection: ldap_connection,
domain,
} = connection;
let domain = domain.as_str();
let (maybe_group_ou, all_ous) = detect_ou(ldap_connection, domain, OuType::Group)?;
let group_ou = {
let question = Question::input("ldap_group_ou")
.message(format!(
"Where are the groups located (under '{}')? {}(LDAP_GROUPS_DN)",
domain,
maybe_group_ou
.as_ref()
.map(|ou| format!("Detected: {}", ou))
.unwrap_or_default()
))
.validate(|dn, _| {
if dn.contains('=') {
Ok(())
} else {
Err(format!(
"Invalid bind DN, expected something like 'ou=groups,{}'",
domain
))
}
})
.default(maybe_group_ou.unwrap_or_default())
.auto_complete(|s, _| {
let mut answers = autocomplete_domain_suffix(s, domain);
answers.extend(all_ous.clone().into_iter());
answers
})
.build();
let answer = prompt_one(question)?;
let mut answer = answer.as_string().unwrap().to_owned();
if !answer.ends_with(domain) {
if !answer.is_empty() {
answer += ",";
}
answer += domain;
}
answer
};
let groups = ldap_connection
.search(
&group_ou,
ldap3::Scope::Subtree,
"(|(objectClass=group)(objectClass=groupOfNames)(objectClass=groupOfUniqueNames))",
vec![
"cn",
"commonName",
"displayName",
"name",
"member",
"uniqueMember",
],
)?
.success()?
.0;
let input_groups = groups
.into_iter()
.map(TryFrom::try_from)
.collect::<Result<Vec<LdapGroup>>>()?;
Ok(input_groups)
}
pub fn get_ldap_connection() -> Result<LdapClient, anyhow::Error> {
let url = get_ldap_url()?;
let mut ldap_connection = ldap3::LdapConn::new(&url)?;
println!("Server found");
let bind_dn = bind_ldap(&mut ldap_connection, None)?;
println!("Connection established");
let domain = &bind_dn[(bind_dn.find(",dc=").expect("Could not find domain?!") + 1)..];
// domain is 'dc=example,dc=com'
Ok(LdapClient {
connection: ldap_connection,
domain: domain.to_owned(),
})
}

499
migration-tool/src/lldap.rs Normal file
View File

@@ -0,0 +1,499 @@
use std::collections::{HashMap, HashSet};
use anyhow::{anyhow, bail, Context, Result};
use graphql_client::GraphQLQuery;
use requestty::{prompt_one, Question};
use reqwest::blocking::{Client, ClientBuilder};
use smallvec::SmallVec;
use crate::ldap::{check_host_exists, LdapGroup};
pub struct GraphQLClient {
url: String,
auth_header: reqwest::header::HeaderValue,
client: Client,
}
impl GraphQLClient {
fn new(url: String, auth_token: &str, client: Client) -> Result<Self> {
Ok(Self {
url: format!("{}/api/graphql", url),
auth_header: format!("Bearer {}", auth_token).parse()?,
client,
})
}
pub fn post<QueryType>(
&self,
variables: QueryType::Variables,
) -> Result<QueryType::ResponseData>
where
QueryType: GraphQLQuery + 'static,
{
let unwrap_graphql_response = |graphql_client::Response { data, errors, .. }| {
data.ok_or_else(|| {
anyhow!(
"Errors: [{}]",
errors
.unwrap_or_default()
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
)
})
};
self.client
.post(&self.url)
.header(reqwest::header::AUTHORIZATION, &self.auth_header)
// Request body.
.json(&QueryType::build_query(variables))
.send()
.context("while sending a request to the LLDAP server")?
.error_for_status()
.context("error from an LLDAP response")?
// Parse response as Json.
.json::<graphql_client::Response<QueryType::ResponseData>>()
.context("while parsing backend response")
.and_then(unwrap_graphql_response)
.context("GraphQL error from an LLDAP response")
}
}
#[derive(Clone, Debug)]
pub struct User {
pub user_input: create_user::CreateUserInput,
pub password: Option<String>,
pub dn: String,
}
impl User {
// https://github.com/graphql-rust/graphql-client/issues/386
pub fn new(
user_input: create_user::CreateUserInput,
password: Option<String>,
dn: String,
) -> User {
User {
user_input,
password,
dn,
}
}
}
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/create_user.graphql",
response_derives = "Debug",
variables_derives = "Debug,Clone",
custom_scalars_module = "crate::infra::graphql"
)]
struct CreateUser;
pub type CreateUserInput = create_user::CreateUserInput;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/create_group.graphql",
response_derives = "Debug",
variables_derives = "Debug,Clone",
custom_scalars_module = "crate::infra::graphql"
)]
struct CreateGroup;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/list_users.graphql",
response_derives = "Debug",
custom_scalars_module = "crate::infra::graphql"
)]
struct ListUsers;
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/list_groups.graphql",
response_derives = "Debug",
custom_scalars_module = "crate::infra::graphql"
)]
struct ListGroups;
pub type LldapGroup = list_groups::ListGroupsGroups;
fn try_login(
lldap_server: &str,
username: &str,
password: &str,
client: &Client,
) -> Result<String> {
let mut rng = rand::rngs::OsRng;
use lldap_auth::login::*;
use lldap_auth::opaque::client::login::*;
let ClientLoginStartResult { state, message } =
start_login(password, &mut rng).context("Could not initialize login")?;
let req = ClientLoginStartRequest {
username: username.to_owned(),
login_start_request: message,
};
let response = client
.post(format!("{}/auth/opaque/login/start", lldap_server))
.json(&req)
.send()
.context("while trying to login to LLDAP")?;
if !response.status().is_success() {
bail!(
"Failed to start logging in to LLDAP: {}",
response.status().as_str()
);
}
let login_start_response = response.json::<lldap_auth::login::ServerLoginStartResponse>()?;
let login_finish = finish_login(state, login_start_response.credential_response)?;
let req = ClientLoginFinishRequest {
server_data: login_start_response.server_data,
credential_finalization: login_finish.message,
};
let response = client
.post(format!("{}/auth/opaque/login/finish", lldap_server))
.json(&req)
.send()?;
if !response.status().is_success() {
bail!(
"Failed to finish logging in to LLDAP: {}",
response.status().as_str()
);
}
let json = serde_json::from_str::<lldap_auth::login::ServerLoginResponse>(&response.text()?)
.context("Could not parse response")?;
Ok(json.token)
}
pub fn get_lldap_user_and_password(
lldap_server: &str,
client: &Client,
previous_username: Option<String>,
) -> Result<String> {
let username = {
let question = Question::input("lldap_username")
.message("LLDAP_USERNAME (default=admin)")
.default("admin")
.auto_complete(|answer, _| {
let mut answers = SmallVec::<[String; 1]>::new();
if let Some(username) = &previous_username {
answers.push(username.clone());
}
answers.push(answer);
answers
})
.build();
let answer = prompt_one(question)?;
answer.as_string().unwrap().to_owned()
};
let password = {
let question = Question::password("lldap_password")
.message("LLDAP_PASSWORD")
.validate(|password, _| {
if !password.is_empty() {
Ok(())
} else {
Err("Empty password".to_owned())
}
})
.build();
let answer = prompt_one(question)?;
answer.as_string().unwrap().to_owned()
};
match try_login(lldap_server, &username, &password, client) {
Err(e) => {
println!("Could not login: {:#?}", e);
get_lldap_user_and_password(lldap_server, client, Some(username))
}
Ok(token) => Ok(token),
}
}
pub fn get_lldap_client() -> Result<GraphQLClient> {
let client = ClientBuilder::new()
.connect_timeout(std::time::Duration::from_secs(2))
.timeout(std::time::Duration::from_secs(5))
.redirect(reqwest::redirect::Policy::none())
.build()?;
let lldap_server = get_lldap_server(&client)?;
let token = get_lldap_user_and_password(&lldap_server, &client, None)?;
println!("Successfully connected to LLDAP");
GraphQLClient::new(lldap_server, &token, client)
}
pub fn insert_users_into_lldap(
users: Vec<User>,
existing_users: &mut Vec<String>,
graphql_client: &GraphQLClient,
) -> Result<()> {
let mut added_users_count = 0;
let mut skip_all = false;
for user in users {
let uid = user.user_input.id.clone();
loop {
print!("Adding {}... ", &uid);
match graphql_client
.post::<CreateUser>(create_user::Variables {
user: user.user_input.clone(),
})
.context(format!("while creating user '{}'", uid))
{
Err(e) => {
println!("Error: {:#?}", e);
if skip_all {
break;
}
let question = requestty::Question::select("skip_user")
.message(format!("Error while adding user {}", &uid))
.choices(vec!["Skip", "Retry", "Skip all"])
.default_separator()
.choice("Abort")
.build();
let answer = prompt_one(question)?;
let choice = answer.as_list_item().unwrap();
match choice.text.as_str() {
"Skip" => break,
"Retry" => continue,
"Skip all" => {
skip_all = true;
break;
}
"Abort" => return Err(e),
_ => unreachable!(),
}
}
Ok(response) => {
println!("Done!");
added_users_count += 1;
existing_users.push(response.create_user.id);
break;
}
}
}
}
println!("{} users successfully added", added_users_count);
Ok(())
}
pub fn insert_groups_into_lldap(
groups: &[LdapGroup],
lldap_groups: &mut Vec<LldapGroup>,
graphql_client: &GraphQLClient,
) -> Result<()> {
let mut added_groups_count = 0;
let mut skip_all = false;
let existing_group_names =
HashSet::<&str>::from_iter(lldap_groups.iter().map(|g| g.display_name.as_str()));
let new_groups = groups
.iter()
.filter(|g| !existing_group_names.contains(g.name.as_str()))
.collect::<Vec<_>>();
for group in new_groups {
let name = group.name.clone();
loop {
print!("Adding {}... ", &name);
match graphql_client
.post::<CreateGroup>(create_group::Variables { name: name.clone() })
.context(format!("while creating group '{}'", &name))
{
Err(e) => {
println!("Error: {:#?}", e);
if skip_all {
break;
}
let question = requestty::Question::select("skip_group")
.message(format!("Error while adding group {}", &name))
.choices(vec!["Skip", "Retry", "Skip all"])
.default_separator()
.choice("Abort")
.build();
let answer = prompt_one(question)?;
let choice = answer.as_list_item().unwrap();
match choice.text.as_str() {
"Skip" => break,
"Retry" => continue,
"Skip all" => {
skip_all = true;
break;
}
"Abort" => return Err(e),
_ => unreachable!(),
}
}
Ok(response) => {
println!("Done!");
added_groups_count += 1;
lldap_groups.push(LldapGroup {
id: response.create_group.id,
display_name: group.name.clone(),
users: Vec::new(),
});
break;
}
}
}
}
println!("{} groups successfully added", added_groups_count);
Ok(())
}
pub fn get_lldap_users(graphql_client: &GraphQLClient) -> Result<Vec<String>> {
Ok(graphql_client
.post::<ListUsers>(list_users::Variables {})?
.users
.into_iter()
.map(|u| u.id)
.collect())
}
pub fn get_lldap_groups(graphql_client: &GraphQLClient) -> Result<Vec<LldapGroup>> {
Ok(graphql_client
.post::<ListGroups>(list_groups::Variables {})?
.groups)
}
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "../schema.graphql",
query_path = "queries/add_user_to_group.graphql",
response_derives = "Debug",
variables_derives = "Debug,Clone",
custom_scalars_module = "crate::infra::graphql"
)]
struct AddUserToGroup;
pub fn insert_group_memberships_into_lldap(
ldap_users: &[User],
ldap_groups: &[LdapGroup],
existing_users: &[String],
existing_groups: &[LldapGroup],
graphql_client: &GraphQLClient,
) -> Result<()> {
let existing_users = HashSet::<&str>::from_iter(existing_users.iter().map(String::as_str));
let existing_groups = HashMap::<&str, &LldapGroup>::from_iter(
existing_groups.iter().map(|g| (g.display_name.as_str(), g)),
);
let dn_resolver = HashMap::<&str, &str>::from_iter(
ldap_users
.iter()
.map(|u| (u.dn.as_str(), u.user_input.id.as_str())),
);
let mut skip_all = false;
let mut added_membership_count = 0;
for group in ldap_groups {
if let Some(lldap_group) = existing_groups.get(group.name.as_str()) {
let lldap_members =
HashSet::<&str>::from_iter(lldap_group.users.iter().map(|u| u.id.as_str()));
let mut skip_group = false;
for user in &group.members {
let user = if let Some(id) = dn_resolver.get(user.as_str()) {
id
} else {
continue;
};
if lldap_members.contains(user) || !existing_users.contains(user) {
continue;
}
loop {
print!("Adding '{}' to '{}'... ", &user, &group.name);
if let Err(e) = graphql_client
.post::<AddUserToGroup>(add_user_to_group::Variables {
user: user.to_string(),
group: lldap_group.id,
})
.context(format!(
"while adding user '{}' to group '{}'",
&user, &group.name
))
{
println!("Error: {:#?}", e);
if skip_all || skip_group {
break;
}
let question = requestty::Question::select("skip_membership")
.message(format!(
"Error while adding '{}' to group '{}",
&user, &group.name
))
.choices(vec!["Skip", "Retry", "Skip group", "Skip all"])
.default_separator()
.choice("Abort")
.build();
let answer = prompt_one(question)?;
let choice = answer.as_list_item().unwrap();
match choice.text.as_str() {
"Skip" => break,
"Retry" => continue,
"Skip group" => {
skip_group = true;
break;
}
"Skip all" => {
skip_all = true;
break;
}
"Abort" => return Err(e),
_ => unreachable!(),
}
} else {
println!("Done!");
added_membership_count += 1;
break;
}
}
}
}
}
println!("{} memberships successfully added", added_membership_count);
Ok(())
}
fn get_lldap_server(client: &Client) -> Result<String> {
let http_protocols = &[("http://", 17170), ("https://", 17170)];
let question = Question::input("lldap_url")
.message("LLDAP_URL (http://...)")
.auto_complete(|answer, _| {
let mut answers = SmallVec::<[String; 1]>::new();
if "http://".starts_with(&answer) {
answers.push("http://".to_owned());
}
if "https://".starts_with(&answer) {
answers.push("https://".to_owned());
}
answers.push(answer);
answers
})
.validate(|url, _| {
if let Some(url) = check_host_exists(url, http_protocols)? {
client
.get(format!("{}/api/graphql", url))
.send()
.map_err(|e| format!("Host did not answer: {}", e))
.and_then(|response| {
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
Ok(())
} else {
Err("Host doesn't seem to be an LLDAP server".to_owned())
}
})
} else {
Err(
"Could not resolve host (make sure it starts with 'http://' or 'https://')"
.to_owned(),
)
}
})
.build();
let answer = prompt_one(question)?;
Ok(
check_host_exists(answer.as_string().unwrap(), http_protocols)
.unwrap()
.unwrap(),
)
}

205
migration-tool/src/main.rs Normal file
View File

@@ -0,0 +1,205 @@
use std::collections::HashSet;
use anyhow::{anyhow, Result};
use requestty::{prompt_one, Question};
mod ldap;
mod lldap;
use ldap::LdapGroup;
use lldap::{LldapGroup, User};
fn ask_generic_confirmation(name: &str, message: &str) -> Result<bool> {
let confirm = Question::confirm(name)
.message(message)
.default(true)
.build();
let answer = prompt_one(confirm)?;
Ok(answer.as_bool().unwrap())
}
fn get_users_to_add(users: &[User], existing_users: &[String]) -> Result<Option<Vec<User>>> {
let existing_users = HashSet::<&String>::from_iter(existing_users);
let num_found_users = users.len();
let input_users: Vec<_> = users
.iter()
.filter(|u| !existing_users.contains(&u.user_input.id))
.map(User::clone)
.collect();
println!(
"Found {} users, of which {} new users: [\n {}\n]",
num_found_users,
input_users.len(),
input_users
.iter()
.map(|u| format!(
"\"{}\" ({})",
&u.user_input.id,
if u.password.is_some() {
"with password"
} else {
"no password"
}
))
.collect::<Vec<_>>()
.join(",\n ")
);
if !input_users.is_empty()
&& ask_generic_confirmation(
"proceed_users",
"Do you want to proceed to add those users to LLDAP?",
)?
{
Ok(Some(input_users))
} else {
Ok(None)
}
}
fn should_insert_groups(
input_groups: &[LdapGroup],
existing_groups: &[LldapGroup],
) -> Result<bool> {
let existing_group_names =
HashSet::<&str>::from_iter(existing_groups.iter().map(|g| g.display_name.as_str()));
let new_groups = input_groups
.iter()
.filter(|g| !existing_group_names.contains(g.name.as_str()));
let num_new_groups = new_groups.clone().count();
println!(
"Found {} groups, of which {} new groups: [\n {}\n]",
input_groups.len(),
num_new_groups,
new_groups
.map(|g| g.name.as_str())
.collect::<Vec<_>>()
.join(",\n ")
);
Ok(num_new_groups != 0
&& ask_generic_confirmation(
"proceed_groups",
"Do you want to proceed to add those groups to LLDAP?",
)?)
}
struct GroupList {
ldap_groups: Vec<LdapGroup>,
lldap_groups: Vec<LldapGroup>,
}
fn migrate_groups(
graphql_client: &lldap::GraphQLClient,
ldap_connection: &mut ldap::LdapClient,
) -> Result<Option<GroupList>> {
Ok(
if ask_generic_confirmation("should_import_groups", "Do you want to import groups?")? {
let mut existing_groups = lldap::get_lldap_groups(graphql_client)?;
let ldap_groups = ldap::get_groups(ldap_connection)?;
if should_insert_groups(&ldap_groups, &existing_groups)? {
lldap::insert_groups_into_lldap(
&ldap_groups,
&mut existing_groups,
graphql_client,
)?;
}
Some(GroupList {
ldap_groups,
lldap_groups: existing_groups,
})
} else {
None
},
)
}
struct UserList {
lldap_users: Vec<String>,
ldap_users: Vec<User>,
}
fn migrate_users(
graphql_client: &lldap::GraphQLClient,
ldap_connection: &mut ldap::LdapClient,
) -> Result<Option<UserList>> {
Ok(
if ask_generic_confirmation("should_import_users", "Do you want to import users?")? {
let mut existing_users = lldap::get_lldap_users(graphql_client)?;
let users = ldap::get_users(ldap_connection)?;
if let Some(users_to_add) = get_users_to_add(&users, &existing_users)? {
lldap::insert_users_into_lldap(users_to_add, &mut existing_users, graphql_client)?;
}
Some(UserList {
lldap_users: existing_users,
ldap_users: users,
})
} else {
None
},
)
}
fn migrate_memberships(
user_list: Option<UserList>,
group_list: Option<GroupList>,
graphql_client: lldap::GraphQLClient,
ldap_connection: &mut ldap::LdapClient,
) -> Result<()> {
let (ldap_users, existing_users) = user_list
.map(
|UserList {
ldap_users,
lldap_users,
}| (Some(ldap_users), Some(lldap_users)),
)
.unwrap_or_default();
let (ldap_groups, existing_groups) = group_list
.map(
|GroupList {
ldap_groups,
lldap_groups,
}| (Some(ldap_groups), Some(lldap_groups)),
)
.unwrap_or_default();
let ldap_users = ldap_users
.ok_or_else(|| anyhow!("Missing LDAP users"))
.or_else(|_| ldap::get_users(ldap_connection))?;
let ldap_groups = ldap_groups
.ok_or_else(|| anyhow!("Missing LDAP groups"))
.or_else(|_| ldap::get_groups(ldap_connection))?;
let existing_groups = existing_groups
.ok_or_else(|| anyhow!("Missing LLDAP groups"))
.or_else(|_| lldap::get_lldap_groups(&graphql_client))?;
let existing_users = existing_users
.ok_or_else(|| anyhow!("Missing LLDAP users"))
.or_else(|_| lldap::get_lldap_users(&graphql_client))?;
lldap::insert_group_memberships_into_lldap(
&ldap_users,
&ldap_groups,
&existing_users,
&existing_groups,
&graphql_client,
)?;
Ok(())
}
fn main() -> Result<()> {
println!(
"The migration tool requires access to both the original LDAP \
server and the HTTP API of the target LLDAP server."
);
if !ask_generic_confirmation("setup_ready", "Are you ready to start?")? {
return Ok(());
}
let mut ldap_connection = ldap::get_ldap_connection()?;
let graphql_client = lldap::get_lldap_client()?;
let user_list = migrate_users(&graphql_client, &mut ldap_connection)?;
let group_list = migrate_groups(&graphql_client, &mut ldap_connection)?;
if ask_generic_confirmation(
"should_import_memberships",
"Do you want to import group memberships?",
)? {
migrate_memberships(user_list, group_list, graphql_client, &mut ldap_connection)?;
}
Ok(())
}

View File

@@ -17,6 +17,8 @@ type Mutation {
type Group {
id: Int!
displayName: String!
creationDate: DateTimeUtc!
uuid: String!
"The groups to which this user belongs."
users: [User!]!
}
@@ -58,6 +60,7 @@ input CreateUserInput {
displayName: String
firstName: String
lastName: String
avatar: String
}
type User {
@@ -66,7 +69,9 @@ type User {
displayName: String!
firstName: String!
lastName: String!
avatar: String!
creationDate: DateTimeUtc!
uuid: String!
"The groups to which this user belongs."
groups: [Group!]!
}
@@ -82,6 +87,7 @@ input UpdateUserInput {
displayName: String
firstName: String
lastName: String
avatar: String
}
schema {

View File

@@ -1,93 +1,128 @@
[package]
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
edition = "2021"
name = "lldap"
version = "0.2.0"
version = "0.4.1"
[dependencies]
actix = "0.12"
actix-files = "0.6.0-beta.6"
actix-http = "3.0.0-beta.9"
actix-http = "=3.0.0-beta.9"
actix-rt = "2.2.0"
actix-server = "2.0.0-beta.5"
actix-server = "=2.0.0-beta.5"
actix-service = "2.0.0"
actix-web = "4.0.0-beta.8"
actix-web = "=4.0.0-beta.8"
actix-web-httpauth = "0.6.0-beta.2"
anyhow = "*"
async-trait = "0.1"
base64 = "0.13"
bincode = "1.3"
chrono = { version = "*", features = [ "serde" ]}
clap = "3.0.0-beta.4"
cron = "*"
derive_builder = "0.10.2"
figment_file_provider_adapter = "0.1"
futures = "*"
futures-util = "*"
hmac = "0.10"
http = "*"
itertools = "0.10.1"
juniper = "0.15.10"
juniper_actix = "0.4.0"
jwt = "0.13"
ldap3_server = ">=0.1.9"
lldap_auth = { path = "../auth" }
ldap3_proto = "*"
log = "*"
orion = "0.16"
rustls = "0.20"
serde = "*"
serde_json = "1"
sha2 = "0.9"
sqlx-core = "=0.5.1"
sqlx-core = "0.5.11"
thiserror = "*"
time = "0.2"
tokio = { version = "1.2.0", features = ["full"] }
tokio-util = "0.6.3"
tokio-rustls = "0.23"
tokio-stream = "*"
tokio-util = "0.7.3"
tracing = "*"
tracing-actix-web = "0.4.0-beta.7"
tracing-attributes = "^0.1.21"
tracing-log = "*"
tracing-subscriber = "0.3"
rand = { version = "0.8", features = ["small_rng", "getrandom"] }
juniper_actix = "0.4.0"
juniper = "0.15.6"
itertools = "0.10.1"
rustls-pemfile = "1.0.0"
serde_bytes = "0.11.7"
[dependencies.opaque-ke]
version = "0.6"
[dependencies.chrono]
features = ["serde"]
version = "*"
[dependencies.clap]
features = ["std", "color", "suggestions", "derive", "env"]
version = "3.1.15"
[dependencies.figment]
features = ["env", "toml"]
version = "*"
[dependencies.tracing-subscriber]
version = "0.3"
features = ["env-filter", "tracing-log"]
[dependencies.lettre]
features = ["builder", "serde", "smtp-transport", "tokio1-rustls-tls"]
default-features = false
version = "0.10.0-rc.3"
features = [
"builder",
"serde",
"smtp-transport",
"tokio1-native-tls",
"tokio1",
]
[dependencies.sqlx]
version = "0.5.1"
version = "0.5.11"
features = [
"any",
"chrono",
"macros",
"mysql",
"postgres",
"runtime-actix-native-tls",
"runtime-actix-rustls",
"sqlite",
]
[dependencies.sea-query]
version = "0.9.4"
features = ["with-chrono"]
[dependencies.lldap_auth]
path = "../auth"
[dependencies.figment]
features = ["env", "toml"]
version = "*"
[dependencies.sea-query]
version = "^0.25"
features = ["with-chrono", "sqlx-sqlite"]
[dependencies.sea-query-binder]
version = "0.1"
features = ["with-chrono", "sqlx-sqlite", "sqlx-any"]
[dependencies.opaque-ke]
version = "0.6"
[dependencies.rand]
features = ["small_rng", "getrandom"]
version = "0.8"
[dependencies.secstr]
features = ["serde"]
version = "*"
[dependencies.openssl-sys]
features = ["vendored"]
[dependencies.tokio]
features = ["full"]
version = "1.17"
[dependencies.uuid]
features = ["v3"]
version = "*"
[dependencies.tracing-forest]
features = ["smallvec", "chrono", "tokio"]
version = "^0.1.4"
[dependencies.actix-tls]
features = ["default", "rustls"]
version = "=3.0.0-beta.5"
[dependencies.image]
features = ["jpeg"]
default-features = false
version = "0.24"
[dev-dependencies]
mockall = "0.9.1"

View File

@@ -3,7 +3,7 @@ use thiserror::Error;
#[allow(clippy::enum_variant_names)]
#[derive(Error, Debug)]
pub enum DomainError {
#[error("Authentication error for `{0}`")]
#[error("Authentication error: `{0}`")]
AuthenticationError(String),
#[error("Database error: `{0}`")]
DatabaseError(#[from] sqlx::Error),

View File

@@ -3,28 +3,179 @@ use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
#[cfg_attr(not(target_arch = "wasm32"), derive(sqlx::FromRow))]
#[derive(
PartialEq, Hash, Eq, Clone, Debug, Default, Serialize, Deserialize, sqlx::FromRow, sqlx::Type,
)]
#[serde(try_from = "&str")]
#[sqlx(transparent)]
pub struct Uuid(String);
impl Uuid {
pub fn from_name_and_date(name: &str, creation_date: &chrono::DateTime<chrono::Utc>) -> Self {
Uuid(
uuid::Uuid::new_v3(
&uuid::Uuid::NAMESPACE_X500,
&[name.as_bytes(), creation_date.to_rfc3339().as_bytes()].concat(),
)
.to_string(),
)
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
}
impl<'a> std::convert::TryFrom<&'a str> for Uuid {
type Error = anyhow::Error;
fn try_from(s: &'a str) -> anyhow::Result<Self> {
Ok(Uuid(uuid::Uuid::parse_str(s)?.to_string()))
}
}
impl std::string::ToString for Uuid {
fn to_string(&self) -> String {
self.0.clone()
}
}
#[cfg(test)]
#[macro_export]
macro_rules! uuid {
($s:literal) => {
$crate::domain::handler::Uuid::try_from($s).unwrap()
};
}
#[derive(PartialEq, Eq, Clone, Debug, Default, Serialize, Deserialize, sqlx::Type)]
#[serde(from = "String")]
#[sqlx(transparent)]
pub struct UserId(String);
impl UserId {
pub fn new(user_id: &str) -> Self {
Self(user_id.to_lowercase())
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
pub fn into_string(self) -> String {
self.0
}
}
impl std::fmt::Display for UserId {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<String> for UserId {
fn from(s: String) -> Self {
Self::new(&s)
}
}
#[derive(PartialEq, Eq, Clone, Debug, Default, Serialize, Deserialize, sqlx::Type)]
#[sqlx(transparent)]
pub struct JpegPhoto(#[serde(with = "serde_bytes")] Vec<u8>);
impl From<JpegPhoto> for sea_query::Value {
fn from(photo: JpegPhoto) -> Self {
photo.0.into()
}
}
impl From<&JpegPhoto> for sea_query::Value {
fn from(photo: &JpegPhoto) -> Self {
photo.0.as_slice().into()
}
}
impl TryFrom<Vec<u8>> for JpegPhoto {
type Error = anyhow::Error;
fn try_from(bytes: Vec<u8>) -> anyhow::Result<Self> {
// Confirm that it's a valid Jpeg, then store only the bytes.
image::io::Reader::with_format(
std::io::Cursor::new(bytes.as_slice()),
image::ImageFormat::Jpeg,
)
.decode()?;
Ok(JpegPhoto(bytes))
}
}
impl TryFrom<String> for JpegPhoto {
type Error = anyhow::Error;
fn try_from(string: String) -> anyhow::Result<Self> {
// The String format is in base64.
Self::try_from(base64::decode(string.as_str())?)
}
}
impl From<&JpegPhoto> for String {
fn from(val: &JpegPhoto) -> Self {
base64::encode(&val.0)
}
}
impl JpegPhoto {
pub fn into_bytes(self) -> Vec<u8> {
self.0
}
#[cfg(test)]
pub fn for_tests() -> Self {
use image::{ImageOutputFormat, Rgb, RgbImage};
let img = RgbImage::from_fn(32, 32, |x, y| {
if (x + y) % 2 == 0 {
Rgb([0, 0, 0])
} else {
Rgb([255, 255, 255])
}
});
let mut bytes: Vec<u8> = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut bytes),
ImageOutputFormat::Jpeg(0),
)
.unwrap();
Self(bytes)
}
}
#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct User {
pub user_id: String,
pub user_id: UserId,
pub email: String,
pub display_name: String,
pub first_name: String,
pub last_name: String,
// pub avatar: ?,
pub avatar: JpegPhoto,
pub creation_date: chrono::DateTime<chrono::Utc>,
pub uuid: Uuid,
}
#[cfg(test)]
impl Default for User {
fn default() -> Self {
use chrono::TimeZone;
let epoch = chrono::Utc.timestamp(0, 0);
User {
user_id: String::new(),
user_id: UserId::default(),
email: String::new(),
display_name: String::new(),
first_name: String::new(),
last_name: String::new(),
creation_date: chrono::Utc.timestamp(0, 0),
avatar: JpegPhoto::default(),
creation_date: epoch,
uuid: Uuid::from_name_and_date("", &epoch),
}
}
}
@@ -33,20 +184,23 @@ impl Default for User {
pub struct Group {
pub id: GroupId,
pub display_name: String,
pub users: Vec<String>,
pub creation_date: chrono::DateTime<chrono::Utc>,
pub uuid: Uuid,
pub users: Vec<UserId>,
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
pub struct BindRequest {
pub name: String,
pub name: UserId,
pub password: String,
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
pub enum RequestFilter {
And(Vec<RequestFilter>),
Or(Vec<RequestFilter>),
Not(Box<RequestFilter>),
pub enum UserRequestFilter {
And(Vec<UserRequestFilter>),
Or(Vec<UserRequestFilter>),
Not(Box<UserRequestFilter>),
UserId(UserId),
Equality(String, String),
// Check if a user belongs to a group identified by name.
MemberOf(String),
@@ -54,24 +208,38 @@ pub enum RequestFilter {
MemberOfId(GroupId),
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
pub enum GroupRequestFilter {
And(Vec<GroupRequestFilter>),
Or(Vec<GroupRequestFilter>),
Not(Box<GroupRequestFilter>),
DisplayName(String),
Uuid(Uuid),
GroupId(GroupId),
// Check if the group contains a user identified by uid.
Member(UserId),
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
pub struct CreateUserRequest {
// Same fields as User, but no creation_date, and with password.
pub user_id: String,
pub user_id: UserId,
pub email: String,
pub display_name: Option<String>,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub avatar: Option<JpegPhoto>,
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
pub struct UpdateUserRequest {
// Same fields as CreateUserRequest, but no with an extra layer of Option.
pub user_id: String,
pub user_id: UserId,
pub email: Option<String>,
pub display_name: Option<String>,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub avatar: Option<JpegPhoto>,
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
@@ -85,27 +253,43 @@ pub trait LoginHandler: Clone + Send {
async fn bind(&self, request: BindRequest) -> Result<()>;
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
#[sqlx(transparent)]
pub struct GroupId(pub i32);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::FromRow)]
pub struct GroupIdAndName(pub GroupId, pub String);
pub struct GroupDetails {
pub group_id: GroupId,
pub display_name: String,
pub creation_date: chrono::DateTime<chrono::Utc>,
pub uuid: Uuid,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserAndGroups {
pub user: User,
pub groups: Option<Vec<GroupDetails>>,
}
#[async_trait]
pub trait BackendHandler: Clone + Send {
async fn list_users(&self, filters: Option<RequestFilter>) -> Result<Vec<User>>;
async fn list_groups(&self) -> Result<Vec<Group>>;
async fn get_user_details(&self, user_id: &str) -> Result<User>;
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupIdAndName>;
async fn list_users(
&self,
filters: Option<UserRequestFilter>,
get_groups: bool,
) -> Result<Vec<UserAndGroups>>;
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>;
async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>;
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
async fn delete_user(&self, user_id: &str) -> Result<()>;
async fn delete_user(&self, user_id: &UserId) -> Result<()>;
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
async fn delete_group(&self, group_id: GroupId) -> Result<()>;
async fn add_user_to_group(&self, user_id: &str, group_id: GroupId) -> Result<()>;
async fn remove_user_from_group(&self, user_id: &str, group_id: GroupId) -> Result<()>;
async fn get_user_groups(&self, user: &str) -> Result<HashSet<GroupIdAndName>>;
async fn add_user_to_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
async fn get_user_groups(&self, user_id: &UserId) -> Result<HashSet<GroupDetails>>;
}
#[cfg(test)]
@@ -116,22 +300,45 @@ mockall::mock! {
}
#[async_trait]
impl BackendHandler for TestBackendHandler {
async fn list_users(&self, filters: Option<RequestFilter>) -> Result<Vec<User>>;
async fn list_groups(&self) -> Result<Vec<Group>>;
async fn get_user_details(&self, user_id: &str) -> Result<User>;
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupIdAndName>;
async fn list_users(&self, filters: Option<UserRequestFilter>, get_groups: bool) -> Result<Vec<UserAndGroups>>;
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>;
async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>;
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
async fn delete_user(&self, user_id: &str) -> Result<()>;
async fn delete_user(&self, user_id: &UserId) -> Result<()>;
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
async fn delete_group(&self, group_id: GroupId) -> Result<()>;
async fn get_user_groups(&self, user: &str) -> Result<HashSet<GroupIdAndName>>;
async fn add_user_to_group(&self, user_id: &str, group_id: GroupId) -> Result<()>;
async fn remove_user_from_group(&self, user_id: &str, group_id: GroupId) -> Result<()>;
async fn get_user_groups(&self, user_id: &UserId) -> Result<HashSet<GroupDetails>>;
async fn add_user_to_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
}
#[async_trait]
impl LoginHandler for TestBackendHandler {
async fn bind(&self, request: BindRequest) -> Result<()>;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_uuid_time() {
use chrono::prelude::*;
let user_id = "bob";
let date1 = Utc.ymd(2014, 7, 8).and_hms(9, 10, 11);
let date2 = Utc.ymd(2014, 7, 8).and_hms(9, 10, 12);
assert_ne!(
Uuid::from_name_and_date(user_id, &date1),
Uuid::from_name_and_date(user_id, &date2)
);
}
#[test]
fn test_jpeg_try_from_bytes() {
let base64_raw = "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCADqATkDASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAAAAECA//EACQQAQEBAAIBBAMBAQEBAAAAAAABESExQQISUXFhgZGxocHw/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAH/xAAWEQEBAQAAAAAAAAAAAAAAAAAAEQH/2gAMAwEAAhEDEQA/AMriLyCKgg1gQwCgs4FTMOdutepjQak+FzMSVqgxZdRdPPIIvH5WzzGdBriphtTeAXg2ZjKA1pqKDUGZca3foBek8gFv8Ie3fKdA1qb8s7hoL6eLVt51FsAnql3Ut1M7AWbflLMDkEMX/F6/YjK/pADFQAUNA6alYagKk72m/j9p4Bq2fDDSYKLNXPNLoHE/NT6RYC31cJxZ3yWVM+aBYi/S2ZgiAsnYJx5D21vPmqrm3PTfpQQwyAC8JZvSKDni41ZrMuUVVl+Uz9w9v/1QWrZsZ5nFPHYH+JZyureQSF5M+fJ0CAfwRAVRBQA1DAWVUayoJUWoDpsxntPsueBV4+VxhdyAtv8AjOLGpIDMLbeGvbF4iozJfr/WukAVABAXAQXEAAASzVAZdO2WNordm+emFl7XcQSNZiFtv0C9w90nhJf4mA1u+GcJFwIyAqL/AOovwgGNfSRqdIrNa29M0gKCAojU9PAMjWXpckEJFNFEAAXEUBABYz6rZ0ureQc9vyt9XxDF2QAXtABcQAs0AZywkvluJbyipifas52DcyxjlZweAO0xri/hc+wZOEKIu6nSyeToVZyWXwvCg53gW81QQ7aTNAn5dGZJPs1UXURQAUEMCXQLZE93PRZ5hPTgNMrbIzKCm52LZwCs+2M8w2g3sjPuZAXb4IsMAUACzVUGM4/K+md6vEXUUyM5PDR0IxYe6ramih0VNBrS4xoqN8Q1BFQk3yqyAsioioAAKgDSJL4/jQIn5igLrPqtOuf6oOaxbMoAltUAhhIoJiiggrPu+AaOIxtAX3JbaAIaLwi4t9X4T3fg2AFtqcrUUarP20zUDAmqoE0WRBZPNVUVEAAAAVAC8kvih2DSKxOdBqs7Z0l0gI0mKAC4AuHE7ZtBriM+744QAAAAABAFsveIttBICyaikvy1+r/Cen5rWQHIBQa4rIDRqSl5qDWqziqgAAAATA7BpGdqXb2C2+J/UgAtRQBSQtkBWb6vhLbQAAAAAEBRAAAAAUbm+GZNdPxAP+ql2Tjwx7/wIgZ8iKvBk+CJoCXii9gaqZ/qqihAAAEVABGkBFUwBftNkZ3QW34QAAABFAQAVAAAAAARVkl8gs/43sk1jL45LvHArepk+E9XTG35oLqsmIKmLAEygKg0y1AFQBUXwgAAAoBC34S3UAAABAVAAAAAABAUQAVABdRQa1PcYyit2z58M8C4ouM2NXpOEGeWtNZUatiAIoAKIoCoAoG4C9MW6dgIoAIAAAAAAACKWAgL0CAAAALiANCKioNLgM1CrLihmTafkt1EF3SZ5ZVUW4mnIKvAi5fhEURVDWVQBRAAAAAAAAQFRVyAyulgAqCKlF8IqLsEgC9mGoC+IusqCrv5ZEUVOk1RuJfwSLOOkGFi4XPCoYYrNiKauosBGi9ICstM1UAAAAAAFQ0VcTBAXUGgIqGoKhKAzRRUQUAwxoSrGRpkQA/qiosOL9oJptMRRVZa0VUqSiChE6BqMgCwqKqIogAIAqKCKgKoogg0lBFuIKgAAAKNRlf2gqsftsEtZWoAAqAACKoMqAAeSoqp39kL2AqLOlE8rEBFQARYALhigrNC9gGmooLp4TweEQFFBFAECgIoAu0ifIAqAAA//9k=";
let base64_jpeg = base64::decode(base64_raw).unwrap();
JpegPhoto::try_from(base64_jpeg).unwrap();
}
}

View File

@@ -1,4 +1,4 @@
use super::error::*;
use crate::domain::{error::*, handler::UserId};
use async_trait::async_trait;
pub use lldap_auth::{login, registration};
@@ -9,7 +9,7 @@ pub trait OpaqueHandler: Clone + Send {
&self,
request: login::ClientLoginStartRequest,
) -> Result<login::ServerLoginStartResponse>;
async fn login_finish(&self, request: login::ClientLoginFinishRequest) -> Result<String>;
async fn login_finish(&self, request: login::ClientLoginFinishRequest) -> Result<UserId>;
async fn registration_start(
&self,
request: registration::ClientRegistrationStartRequest,
@@ -32,7 +32,7 @@ mockall::mock! {
&self,
request: login::ClientLoginStartRequest
) -> Result<login::ServerLoginStartResponse>;
async fn login_finish(&self, request: login::ClientLoginFinishRequest ) -> Result<String>;
async fn login_finish(&self, request: login::ClientLoginFinishRequest ) -> Result<UserId>;
async fn registration_start(
&self,
request: registration::ClientRegistrationStartRequest

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +1,26 @@
use super::{
error::*,
handler::{BindRequest, LoginHandler},
handler::{BindRequest, LoginHandler, UserId},
opaque_handler::*,
sql_backend_handler::SqlBackendHandler,
sql_tables::*,
};
use async_trait::async_trait;
use lldap_auth::opaque;
use log::*;
use sea_query::{Expr, Iden, Query};
use sea_query_binder::SqlxBinder;
use secstr::SecUtf8;
use sqlx::Row;
use tracing::{debug, instrument};
type SqlOpaqueHandler = SqlBackendHandler;
#[instrument(skip_all, level = "debug", err)]
fn passwords_match(
password_file_bytes: &[u8],
clear_password: &str,
server_setup: &opaque::server::ServerSetup,
username: &str,
username: &UserId,
) -> Result<()> {
use opaque::{client, server};
let mut rng = rand::rngs::OsRng;
@@ -31,7 +33,7 @@ fn passwords_match(
server_setup,
Some(password_file),
client_login_start_result.message,
username,
username.as_str(),
)?;
client::login::finish_login(
client_login_start_result.state,
@@ -47,18 +49,22 @@ impl SqlBackendHandler {
)?)
}
#[instrument(skip_all, level = "debug", err)]
async fn get_password_file_for_user(
&self,
username: &str,
) -> Result<Option<opaque::server::ServerRegistration>> {
// Fetch the previously registered password file from the DB.
let password_file_bytes = {
let query = Query::select()
let (query, values) = Query::select()
.column(Users::PasswordHash)
.from(Users::Table)
.and_where(Expr::col(Users::UserId).eq(username))
.to_string(DbQueryBuilder {});
if let Some(row) = sqlx::query(&query).fetch_optional(&self.sql_pool).await? {
.cond_where(Expr::col(Users::UserId).eq(username))
.build_sqlx(DbQueryBuilder {});
if let Some(row) = sqlx::query_with(query.as_str(), values)
.fetch_optional(&self.sql_pool)
.await?
{
if let Some(bytes) =
row.get::<Option<Vec<u8>>, _>(&*Users::PasswordHash.to_string())
{
@@ -82,21 +88,17 @@ impl SqlBackendHandler {
#[async_trait]
impl LoginHandler for SqlBackendHandler {
#[instrument(skip_all, level = "debug", err)]
async fn bind(&self, request: BindRequest) -> Result<()> {
if request.name == self.config.ldap_user_dn {
if SecUtf8::from(request.password) == self.config.ldap_user_pass {
return Ok(());
} else {
debug!(r#"Invalid password for LDAP bind user"#);
return Err(DomainError::AuthenticationError(request.name));
}
}
let query = Query::select()
let (query, values) = Query::select()
.column(Users::PasswordHash)
.from(Users::Table)
.and_where(Expr::col(Users::UserId).eq(request.name.as_str()))
.to_string(DbQueryBuilder {});
if let Ok(row) = sqlx::query(&query).fetch_one(&self.sql_pool).await {
.cond_where(Expr::col(Users::UserId).eq(&request.name))
.build_sqlx(DbQueryBuilder {});
if let Ok(row) = sqlx::query_with(&query, values)
.fetch_one(&self.sql_pool)
.await
{
if let Some(password_hash) =
row.get::<Option<Vec<u8>>, _>(&*Users::PasswordHash.to_string())
{
@@ -106,22 +108,26 @@ impl LoginHandler for SqlBackendHandler {
self.config.get_server_setup(),
&request.name,
) {
debug!(r#"Invalid password for "{}": {}"#, request.name, e);
debug!(r#"Invalid password for "{}": {}"#, &request.name, e);
} else {
return Ok(());
}
} else {
debug!(r#"User "{}" has no password"#, request.name);
debug!(r#"User "{}" has no password"#, &request.name);
}
} else {
debug!(r#"No user found for "{}""#, request.name);
debug!(r#"No user found for "{}""#, &request.name);
}
Err(DomainError::AuthenticationError(request.name))
Err(DomainError::AuthenticationError(format!(
" for user '{}'",
request.name
)))
}
}
#[async_trait]
impl OpaqueHandler for SqlOpaqueHandler {
#[instrument(skip_all, level = "debug", err)]
async fn login_start(
&self,
request: login::ClientLoginStartRequest,
@@ -150,7 +156,8 @@ impl OpaqueHandler for SqlOpaqueHandler {
})
}
async fn login_finish(&self, request: login::ClientLoginFinishRequest) -> Result<String> {
#[instrument(skip_all, level = "debug", err)]
async fn login_finish(&self, request: login::ClientLoginFinishRequest) -> Result<UserId> {
let secret_key = self.get_orion_secret_key()?;
let login::ServerData {
username,
@@ -165,9 +172,10 @@ impl OpaqueHandler for SqlOpaqueHandler {
opaque::server::login::finish_login(server_login, request.credential_finalization)?
.session_key;
Ok(username)
Ok(UserId::new(&username))
}
#[instrument(skip_all, level = "debug", err)]
async fn registration_start(
&self,
request: registration::ClientRegistrationStartRequest,
@@ -189,6 +197,7 @@ impl OpaqueHandler for SqlOpaqueHandler {
})
}
#[instrument(skip_all, level = "debug", err)]
async fn registration_finish(
&self,
request: registration::ClientRegistrationFinishRequest,
@@ -203,24 +212,24 @@ impl OpaqueHandler for SqlOpaqueHandler {
opaque::server::registration::get_password_file(request.registration_upload);
{
// Set the user password to the new password.
let update_query = Query::update()
let (update_query, values) = Query::update()
.table(Users::Table)
.values(vec![(
Users::PasswordHash,
password_file.serialize().into(),
)])
.and_where(Expr::col(Users::UserId).eq(username))
.to_string(DbQueryBuilder {});
sqlx::query(&update_query).execute(&self.sql_pool).await?;
.value(Users::PasswordHash, password_file.serialize().into())
.cond_where(Expr::col(Users::UserId).eq(username))
.build_sqlx(DbQueryBuilder {});
sqlx::query_with(update_query.as_str(), values)
.execute(&self.sql_pool)
.await?;
}
Ok(())
}
}
/// Convenience function to set a user's password.
#[instrument(skip_all, level = "debug", err)]
pub(crate) async fn register_password(
opaque_handler: &SqlOpaqueHandler,
username: &str,
username: &UserId,
password: &SecUtf8,
) -> Result<()> {
let mut rng = rand::rngs::OsRng;
@@ -278,7 +287,7 @@ mod tests {
async fn insert_user_no_password(handler: &SqlBackendHandler, name: &str) {
handler
.create_user(CreateUserRequest {
user_id: name.to_string(),
user_id: UserId::new(name),
email: "bob@bob.bob".to_string(),
..Default::default()
})
@@ -323,7 +332,12 @@ mod tests {
attempt_login(&opaque_handler, "bob", "bob00")
.await
.unwrap_err();
register_password(&opaque_handler, "bob", &secstr::SecUtf8::from("bob00")).await?;
register_password(
&opaque_handler,
&UserId::new("bob"),
&secstr::SecUtf8::from("bob00"),
)
.await?;
attempt_login(&opaque_handler, "bob", "wrong_password")
.await
.unwrap_err();

View File

@@ -1,5 +1,8 @@
use super::handler::GroupId;
use super::handler::{GroupId, UserId, Uuid};
use sea_query::*;
use sea_query_binder::SqlxBinder;
use sqlx::Row;
use tracing::{debug, warn};
pub type Pool = sqlx::sqlite::SqlitePool;
pub type PoolOptions = sqlx::sqlite::SqlitePoolOptions;
@@ -12,28 +15,27 @@ impl From<GroupId> for Value {
}
}
impl<DB> sqlx::Type<DB> for GroupId
where
DB: sqlx::Database,
i32: sqlx::Type<DB>,
{
fn type_info() -> <DB as sqlx::Database>::TypeInfo {
<i32 as sqlx::Type<DB>>::type_info()
}
fn compatible(ty: &<DB as sqlx::Database>::TypeInfo) -> bool {
<i32 as sqlx::Type<DB>>::compatible(ty)
impl From<UserId> for sea_query::Value {
fn from(user_id: UserId) -> Self {
user_id.into_string().into()
}
}
impl<'r, DB> sqlx::Decode<'r, DB> for GroupId
where
DB: sqlx::Database,
i32: sqlx::Decode<'r, DB>,
{
fn decode(
value: <DB as sqlx::database::HasValueRef<'r>>::ValueRef,
) -> Result<Self, Box<dyn std::error::Error + Sync + Send + 'static>> {
<i32 as sqlx::Decode<'r, DB>>::decode(value).map(GroupId)
impl From<&UserId> for sea_query::Value {
fn from(user_id: &UserId) -> Self {
user_id.as_str().into()
}
}
impl From<Uuid> for sea_query::Value {
fn from(uuid: Uuid) -> Self {
uuid.as_str().into()
}
}
impl From<&Uuid> for sea_query::Value {
fn from(uuid: &Uuid) -> Self {
uuid.as_str().into()
}
}
@@ -50,6 +52,7 @@ pub enum Users {
PasswordHash,
TotpSecret,
MfaType,
Uuid,
}
#[derive(Iden)]
@@ -57,6 +60,8 @@ pub enum Groups {
Table,
GroupId,
DisplayName,
CreationDate,
Uuid,
}
#[derive(Iden)]
@@ -66,6 +71,40 @@ pub enum Memberships {
GroupId,
}
async fn column_exists(pool: &Pool, table_name: &str, column_name: &str) -> sqlx::Result<bool> {
// Sqlite specific
let query = format!(
"SELECT COUNT(*) AS col_count FROM pragma_table_info('{}') WHERE name = '{}'",
table_name, column_name
);
match sqlx::query(&query).fetch_one(pool).await {
Err(_) => Ok(false),
Ok(row) => Ok(row.get::<i32, _>("col_count") > 0),
}
}
pub async fn create_group(group_name: &str, pool: &Pool) -> sqlx::Result<()> {
let now = chrono::Utc::now();
let (query, values) = Query::insert()
.into_table(Groups::Table)
.columns(vec![
Groups::DisplayName,
Groups::CreationDate,
Groups::Uuid,
])
.values_panic(vec![
group_name.into(),
now.naive_utc().into(),
Uuid::from_name_and_date(group_name, &now).into(),
])
.build_sqlx(DbQueryBuilder {});
debug!(%query);
sqlx::query_with(query.as_str(), values)
.execute(pool)
.await
.map(|_| ())
}
pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
// SQLite needs this pragma to be turned on. Other DB might not understand this, so ignore the
// error.
@@ -93,6 +132,7 @@ pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
.col(ColumnDef::new(Users::PasswordHash).binary())
.col(ColumnDef::new(Users::TotpSecret).string_len(64))
.col(ColumnDef::new(Users::MfaType).string_len(64))
.col(ColumnDef::new(Users::Uuid).string_len(36).not_null())
.to_string(DbQueryBuilder {}),
)
.execute(pool)
@@ -114,11 +154,141 @@ pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
.unique_key()
.not_null(),
)
.col(ColumnDef::new(Users::CreationDate).date_time().not_null())
.col(ColumnDef::new(Users::Uuid).string_len(36).not_null())
.to_string(DbQueryBuilder {}),
)
.execute(pool)
.await?;
// If the creation_date column doesn't exist, add it.
if !column_exists(
pool,
&*Groups::Table.to_string(),
&*Groups::CreationDate.to_string(),
)
.await?
{
warn!("`creation_date` column not found in `groups`, creating it");
sqlx::query(
&Table::alter()
.table(Groups::Table)
.add_column(
ColumnDef::new(Groups::CreationDate)
.date_time()
.not_null()
.default(chrono::Utc::now().naive_utc()),
)
.to_string(DbQueryBuilder {}),
)
.execute(pool)
.await?;
}
// If the uuid column doesn't exist, add it.
if !column_exists(
pool,
&*Groups::Table.to_string(),
&*Groups::Uuid.to_string(),
)
.await?
{
warn!("`uuid` column not found in `groups`, creating it");
sqlx::query(
&Table::alter()
.table(Groups::Table)
.add_column(
ColumnDef::new(Groups::Uuid)
.string_len(36)
.not_null()
.default(""),
)
.to_string(DbQueryBuilder {}),
)
.execute(pool)
.await?;
for row in sqlx::query(
&Query::select()
.from(Groups::Table)
.column(Groups::GroupId)
.column(Groups::DisplayName)
.column(Groups::CreationDate)
.to_string(DbQueryBuilder {}),
)
.fetch_all(pool)
.await?
{
sqlx::query(
&Query::update()
.table(Groups::Table)
.value(
Groups::Uuid,
Uuid::from_name_and_date(
&row.get::<String, _>(&*Groups::DisplayName.to_string()),
&row.get::<chrono::DateTime<chrono::Utc>, _>(
&*Groups::CreationDate.to_string(),
),
)
.into(),
)
.and_where(
Expr::col(Groups::GroupId)
.eq(row.get::<GroupId, _>(&*Groups::GroupId.to_string())),
)
.to_string(DbQueryBuilder {}),
)
.execute(pool)
.await?;
}
}
if !column_exists(pool, &*Users::Table.to_string(), &*Users::Uuid.to_string()).await? {
warn!("`uuid` column not found in `users`, creating it");
sqlx::query(
&Table::alter()
.table(Users::Table)
.add_column(
ColumnDef::new(Users::Uuid)
.string_len(36)
.not_null()
.default(""),
)
.to_string(DbQueryBuilder {}),
)
.execute(pool)
.await?;
for row in sqlx::query(
&Query::select()
.from(Users::Table)
.column(Users::UserId)
.column(Users::CreationDate)
.to_string(DbQueryBuilder {}),
)
.fetch_all(pool)
.await?
{
let user_id = row.get::<UserId, _>(&*Users::UserId.to_string());
sqlx::query(
&Query::update()
.table(Users::Table)
.value(
Users::Uuid,
Uuid::from_name_and_date(
user_id.as_str(),
&row.get::<chrono::DateTime<chrono::Utc>, _>(
&*Users::CreationDate.to_string(),
),
)
.into(),
)
.and_where(Expr::col(Users::UserId).eq(user_id))
.to_string(DbQueryBuilder {}),
)
.execute(pool)
.await?;
}
}
sqlx::query(
&Table::create()
.table(Memberships::Table)
@@ -132,16 +302,16 @@ pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
.foreign_key(
ForeignKey::create()
.name("MembershipUserForeignKey")
.table(Memberships::Table, Users::Table)
.col(Memberships::UserId, Users::UserId)
.from(Memberships::Table, Memberships::UserId)
.to(Users::Table, Users::UserId)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("MembershipGroupForeignKey")
.table(Memberships::Table, Groups::Table)
.col(Memberships::GroupId, Groups::GroupId)
.from(Memberships::Table, Memberships::GroupId)
.to(Groups::Table, Groups::GroupId)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
@@ -150,6 +320,29 @@ pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
.execute(pool)
.await?;
if sqlx::query(
&Query::select()
.from(Groups::Table)
.column(Groups::DisplayName)
.cond_where(Expr::col(Groups::DisplayName).eq("lldap_readonly"))
.to_string(DbQueryBuilder {}),
)
.fetch_one(pool)
.await
.is_ok()
{
sqlx::query(
&Query::update()
.table(Groups::Table)
.values(vec![(Groups::DisplayName, "lldap_password_manager".into())])
.cond_where(Expr::col(Groups::DisplayName).eq("lldap_readonly"))
.to_string(DbQueryBuilder {}),
)
.execute(pool)
.await?;
create_group("lldap_strict_readonly", pool).await?
}
Ok(())
}
@@ -159,13 +352,13 @@ mod tests {
use chrono::prelude::*;
use sqlx::{Column, Row};
#[actix_rt::test]
#[tokio::test]
async fn test_init_table() {
let sql_pool = PoolOptions::new().connect("sqlite::memory:").await.unwrap();
init_table(&sql_pool).await.unwrap();
sqlx::query(r#"INSERT INTO users
(user_id, email, display_name, first_name, last_name, creation_date, password_hash)
VALUES ("bôb", "böb@bob.bob", "Bob Bobbersön", "Bob", "Bobberson", "1970-01-01 00:00:00", "bob00")"#).execute(&sql_pool).await.unwrap();
(user_id, email, display_name, first_name, last_name, creation_date, password_hash, uuid)
VALUES ("bôb", "böb@bob.bob", "Bob Bobbersön", "Bob", "Bobberson", "1970-01-01 00:00:00", "bob00", "abc")"#).execute(&sql_pool).await.unwrap();
let row =
sqlx::query(r#"SELECT display_name, creation_date FROM users WHERE user_id = "bôb""#)
.fetch_one(&sql_pool)
@@ -179,10 +372,74 @@ mod tests {
);
}
#[actix_rt::test]
#[tokio::test]
async fn test_already_init_table() {
let sql_pool = PoolOptions::new().connect("sqlite::memory:").await.unwrap();
init_table(&sql_pool).await.unwrap();
init_table(&sql_pool).await.unwrap();
}
#[tokio::test]
async fn test_migrate_tables() {
// Test that we add the column creation_date to groups and uuid to users and groups.
let sql_pool = PoolOptions::new().connect("sqlite::memory:").await.unwrap();
sqlx::query(r#"CREATE TABLE users ( user_id TEXT , creation_date TEXT);"#)
.execute(&sql_pool)
.await
.unwrap();
sqlx::query(
r#"INSERT INTO users (user_id, creation_date)
VALUES ("bôb", "1970-01-01 00:00:00")"#,
)
.execute(&sql_pool)
.await
.unwrap();
sqlx::query(r#"CREATE TABLE groups ( group_id INTEGER PRIMARY KEY, display_name TEXT );"#)
.execute(&sql_pool)
.await
.unwrap();
sqlx::query(
r#"INSERT INTO groups (display_name)
VALUES ("lldap_admin"), ("lldap_readonly")"#,
)
.execute(&sql_pool)
.await
.unwrap();
init_table(&sql_pool).await.unwrap();
sqlx::query(
r#"INSERT INTO groups (display_name, creation_date, uuid)
VALUES ("test", "1970-01-01 00:00:00", "abc")"#,
)
.execute(&sql_pool)
.await
.unwrap();
assert_eq!(
sqlx::query(r#"SELECT uuid FROM users"#)
.fetch_all(&sql_pool)
.await
.unwrap()
.into_iter()
.map(|row| row.get::<Uuid, _>("uuid"))
.collect::<Vec<_>>(),
vec![crate::uuid!("a02eaf13-48a7-30f6-a3d4-040ff7c52b04")]
);
assert_eq!(
sqlx::query(r#"SELECT group_id, display_name FROM groups"#)
.fetch_all(&sql_pool)
.await
.unwrap()
.into_iter()
.map(|row| (
row.get::<GroupId, _>("group_id"),
row.get::<String, _>("display_name")
))
.collect::<Vec<_>>(),
vec![
(GroupId(1), "lldap_admin".to_string()),
(GroupId(2), "lldap_password_manager".to_string()),
(GroupId(3), "lldap_strict_readonly".to_string()),
(GroupId(4), "test".to_string())
]
);
}
}

View File

@@ -1,14 +1,8 @@
use crate::{
domain::{
error::DomainError,
handler::{BackendHandler, BindRequest, GroupIdAndName, LoginHandler},
opaque_handler::OpaqueHandler,
},
infra::{
tcp_backend_handler::*,
tcp_server::{error_to_http_response, AppState},
},
};
use std::collections::{hash_map::DefaultHasher, HashSet};
use std::hash::{Hash, Hasher};
use std::pin::Pin;
use std::task::{Context, Poll};
use actix_web::{
cookie::{Cookie, SameSite},
dev::{Service, ServiceRequest, ServiceResponse, Transform},
@@ -19,27 +13,36 @@ use actix_web_httpauth::extractors::bearer::BearerAuth;
use anyhow::Result;
use chrono::prelude::*;
use futures::future::{ok, Ready};
use futures_util::{FutureExt, TryFutureExt};
use futures_util::FutureExt;
use hmac::Hmac;
use jwt::{SignWithKey, VerifyWithKey};
use lldap_auth::{login, registration, JWTClaims};
use log::*;
use sha2::Sha512;
use std::collections::{hash_map::DefaultHasher, HashSet};
use std::hash::{Hash, Hasher};
use std::pin::Pin;
use std::task::{Context, Poll};
use time::ext::NumericalDuration;
use tracing::{debug, instrument, warn};
use lldap_auth::{login, password_reset, registration, JWTClaims};
use crate::{
domain::{
error::DomainError,
handler::{BackendHandler, BindRequest, GroupDetails, LoginHandler, UserId},
opaque_handler::OpaqueHandler,
},
infra::{
tcp_backend_handler::*,
tcp_server::{error_to_http_response, AppState, TcpError, TcpResult},
},
};
type Token<S> = jwt::Token<jwt::Header, JWTClaims, S>;
type SignedToken = Token<jwt::token::Signed>;
fn create_jwt(key: &Hmac<Sha512>, user: String, groups: HashSet<GroupIdAndName>) -> SignedToken {
fn create_jwt(key: &Hmac<Sha512>, user: String, groups: HashSet<GroupDetails>) -> SignedToken {
let claims = JWTClaims {
exp: Utc::now() + chrono::Duration::days(1),
iat: Utc::now(),
user,
groups: groups.into_iter().map(|g| g.1).collect(),
groups: groups.into_iter().map(|g| g.display_name).collect(),
};
let header = jwt::Header {
algorithm: jwt::AlgorithmType::Hs512,
@@ -48,91 +51,106 @@ fn create_jwt(key: &Hmac<Sha512>, user: String, groups: HashSet<GroupIdAndName>)
jwt::Token::new(header, claims).sign_with_key(key).unwrap()
}
fn get_refresh_token_from_cookie(
request: HttpRequest,
) -> std::result::Result<(u64, String), HttpResponse> {
match request.cookie("refresh_token") {
None => Err(HttpResponse::Unauthorized().body("Missing refresh token")),
Some(t) => match t.value().split_once("+") {
None => Err(HttpResponse::Unauthorized().body("Invalid refresh token")),
Some((token, u)) => {
let refresh_token_hash = {
let mut s = DefaultHasher::new();
token.hash(&mut s);
s.finish()
};
Ok((refresh_token_hash, u.to_string()))
}
},
fn parse_refresh_token(token: &str) -> TcpResult<(u64, UserId)> {
match token.split_once('+') {
None => Err(DomainError::AuthenticationError("Invalid refresh token".to_string()).into()),
Some((token, u)) => {
let refresh_token_hash = {
let mut s = DefaultHasher::new();
token.hash(&mut s);
s.finish()
};
Ok((refresh_token_hash, UserId::new(u)))
}
}
}
fn get_refresh_token(request: HttpRequest) -> TcpResult<(u64, UserId)> {
match (
request.cookie("refresh_token"),
request.headers().get("refresh-token"),
) {
(Some(c), _) => parse_refresh_token(c.value()),
(_, Some(t)) => parse_refresh_token(t.to_str().unwrap()),
(None, None) => {
Err(DomainError::AuthenticationError("Missing refresh token".to_string()).into())
}
}
}
#[instrument(skip_all, level = "debug")]
async fn get_refresh<Backend>(
data: web::Data<AppState<Backend>>,
request: HttpRequest,
) -> HttpResponse
) -> TcpResult<HttpResponse>
where
Backend: TcpBackendHandler + BackendHandler + 'static,
{
let backend_handler = &data.backend_handler;
let jwt_key = &data.jwt_key;
let (refresh_token_hash, user) = match get_refresh_token_from_cookie(request) {
Ok(t) => t,
Err(http_response) => return http_response,
};
let res_found = data
let (refresh_token_hash, user) = get_refresh_token(request)?;
let found = data
.backend_handler
.check_token(refresh_token_hash, &user)
.await;
// Async closures are not supported yet.
match res_found {
Ok(found) => {
if found {
backend_handler.get_user_groups(&user).await
} else {
Err(DomainError::AuthenticationError(
"Invalid refresh token".to_string(),
))
}
}
Err(e) => Err(e),
.await?;
if !found {
return Err(TcpError::DomainError(DomainError::AuthenticationError(
"Invalid refresh token".to_string(),
)));
}
.map(|groups| create_jwt(jwt_key, user.to_string(), groups))
.map(|token| {
HttpResponse::Ok()
.cookie(
Cookie::build("token", token.as_str())
.max_age(1.days())
.path("/")
.http_only(true)
.same_site(SameSite::Strict)
.finish(),
)
.body(token.as_str().to_owned())
})
.unwrap_or_else(error_to_http_response)
Ok(backend_handler
.get_user_groups(&user)
.await
.map(|groups| create_jwt(jwt_key, user.to_string(), groups))
.map(|token| {
HttpResponse::Ok()
.cookie(
Cookie::build("token", token.as_str())
.max_age(1.days())
.path("/")
.http_only(true)
.same_site(SameSite::Strict)
.finish(),
)
.json(&login::ServerLoginResponse {
token: token.as_str().to_owned(),
refresh_token: None,
})
})?)
}
async fn get_password_reset_step1<Backend>(
async fn get_refresh_handler<Backend>(
data: web::Data<AppState<Backend>>,
request: HttpRequest,
) -> HttpResponse
where
Backend: TcpBackendHandler + BackendHandler + 'static,
{
get_refresh(data, request)
.await
.unwrap_or_else(error_to_http_response)
}
#[instrument(skip_all, level = "debug")]
async fn get_password_reset_step1<Backend>(
data: web::Data<AppState<Backend>>,
request: HttpRequest,
) -> TcpResult<()>
where
Backend: TcpBackendHandler + BackendHandler + 'static,
{
let user_id = match request.match_info().get("user_id") {
None => return HttpResponse::BadRequest().body("Missing user ID"),
Some(id) => id,
None => return Err(TcpError::BadRequest("Missing user ID".to_string())),
Some(id) => UserId::new(id),
};
let token = match data.backend_handler.start_password_reset(user_id).await {
Err(e) => return HttpResponse::InternalServerError().body(e.to_string()),
Ok(None) => return HttpResponse::Ok().finish(),
Ok(Some(token)) => token,
let token = match data.backend_handler.start_password_reset(&user_id).await? {
None => return Ok(()),
Some(token) => token,
};
let user = match data.backend_handler.get_user_details(user_id).await {
let user = match data.backend_handler.get_user_details(&user_id).await {
Err(e) => {
warn!("Error getting used details: {:#?}", e);
return HttpResponse::Ok().finish();
return Ok(());
}
Ok(u) => u,
};
@@ -144,36 +162,50 @@ where
&data.mail_options,
) {
warn!("Error sending email: {:#?}", e);
return Err(TcpError::InternalServerError(format!(
"Could not send email: {}",
e
)));
}
HttpResponse::Ok().finish()
Ok(())
}
async fn get_password_reset_step2<Backend>(
async fn get_password_reset_step1_handler<Backend>(
data: web::Data<AppState<Backend>>,
request: HttpRequest,
) -> HttpResponse
where
Backend: TcpBackendHandler + BackendHandler + 'static,
{
let token = match request.match_info().get("token") {
None => return HttpResponse::BadRequest().body("Missing token"),
Some(token) => token,
};
let user_id = match data
get_password_reset_step1(data, request)
.await
.map(|()| HttpResponse::Ok().finish())
.unwrap_or_else(error_to_http_response)
}
#[instrument(skip_all, level = "debug")]
async fn get_password_reset_step2<Backend>(
data: web::Data<AppState<Backend>>,
request: HttpRequest,
) -> TcpResult<HttpResponse>
where
Backend: TcpBackendHandler + BackendHandler + 'static,
{
let token = request
.match_info()
.get("token")
.ok_or_else(|| TcpError::BadRequest("Missing reset token".to_string()))?;
let user_id = data
.backend_handler
.get_user_id_for_password_reset_token(token)
.await
{
Err(_) => return HttpResponse::Unauthorized().body("Invalid or expired token"),
Ok(user_id) => user_id,
};
.await?;
let _ = data
.backend_handler
.delete_password_reset_token(token)
.await;
let groups = HashSet::new();
let token = create_jwt(&data.jwt_key, user_id.to_string(), groups);
HttpResponse::Ok()
Ok(HttpResponse::Ok()
.cookie(
Cookie::build("token", token.as_str())
.max_age(5.minutes())
@@ -183,43 +215,42 @@ where
.same_site(SameSite::Strict)
.finish(),
)
.json(user_id)
.json(&password_reset::ServerPasswordResetResponse {
user_id: user_id.to_string(),
token: token.as_str().to_owned(),
}))
}
async fn get_logout<Backend>(
async fn get_password_reset_step2_handler<Backend>(
data: web::Data<AppState<Backend>>,
request: HttpRequest,
) -> HttpResponse
where
Backend: TcpBackendHandler + BackendHandler + 'static,
{
let (refresh_token_hash, user) = match get_refresh_token_from_cookie(request) {
Ok(t) => t,
Err(http_response) => return http_response,
};
if let Err(response) = data
.backend_handler
get_password_reset_step2(data, request)
.await
.unwrap_or_else(error_to_http_response)
}
#[instrument(skip_all, level = "debug")]
async fn get_logout<Backend>(
data: web::Data<AppState<Backend>>,
request: HttpRequest,
) -> TcpResult<HttpResponse>
where
Backend: TcpBackendHandler + BackendHandler + 'static,
{
let (refresh_token_hash, user) = get_refresh_token(request)?;
data.backend_handler
.delete_refresh_token(refresh_token_hash)
.map_err(error_to_http_response)
.await
{
return response;
};
match data
.backend_handler
.blacklist_jwts(&user)
.map_err(error_to_http_response)
.await
{
Ok(new_blacklisted_jwts) => {
let mut jwt_blacklist = data.jwt_blacklist.write().unwrap();
for jwt in new_blacklisted_jwts {
jwt_blacklist.insert(jwt);
}
}
Err(response) => return response,
};
HttpResponse::Ok()
.await?;
let new_blacklisted_jwts = data.backend_handler.blacklist_jwts(&user).await?;
let mut jwt_blacklist = data.jwt_blacklist.write().unwrap();
for jwt in new_blacklisted_jwts {
jwt_blacklist.insert(jwt);
}
Ok(HttpResponse::Ok()
.cookie(
Cookie::build("token", "")
.max_age(0.days())
@@ -236,15 +267,28 @@ where
.same_site(SameSite::Strict)
.finish(),
)
.finish()
.finish())
}
pub(crate) fn error_to_api_response<T>(error: DomainError) -> ApiResult<T> {
ApiResult::Right(error_to_http_response(error))
async fn get_logout_handler<Backend>(
data: web::Data<AppState<Backend>>,
request: HttpRequest,
) -> HttpResponse
where
Backend: TcpBackendHandler + BackendHandler + 'static,
{
get_logout(data, request)
.await
.unwrap_or_else(error_to_http_response)
}
pub(crate) fn error_to_api_response<T, E: Into<TcpError>>(error: E) -> ApiResult<T> {
ApiResult::Right(error_to_http_response(error.into()))
}
pub type ApiResult<M> = actix_web::Either<web::Json<M>, HttpResponse>;
#[instrument(skip_all, level = "debug")]
async fn opaque_login_start<Backend>(
data: web::Data<AppState<Backend>>,
request: web::Json<login::ClientLoginStartRequest>,
@@ -259,135 +303,207 @@ where
.unwrap_or_else(error_to_api_response)
}
#[instrument(skip_all, level = "debug")]
async fn get_login_successful_response<Backend>(
data: &web::Data<AppState<Backend>>,
name: &str,
) -> HttpResponse
name: &UserId,
) -> TcpResult<HttpResponse>
where
Backend: TcpBackendHandler + BackendHandler,
{
// The authentication was successful, we need to fetch the groups to create the JWT
// token.
data.backend_handler
.get_user_groups(name)
.and_then(|g| async { Ok((g, data.backend_handler.create_refresh_token(name).await?)) })
.await
.map(|(groups, (refresh_token, max_age))| {
let token = create_jwt(&data.jwt_key, name.to_string(), groups);
HttpResponse::Ok()
.cookie(
Cookie::build("token", token.as_str())
.max_age(1.days())
.path("/")
.http_only(true)
.same_site(SameSite::Strict)
.finish(),
)
.cookie(
Cookie::build("refresh_token", refresh_token + "+" + name)
.max_age(max_age.num_days().days())
.path("/auth")
.http_only(true)
.same_site(SameSite::Strict)
.finish(),
)
.body(token.as_str().to_owned())
})
.unwrap_or_else(error_to_http_response)
let groups = data.backend_handler.get_user_groups(name).await?;
let (refresh_token, max_age) = data.backend_handler.create_refresh_token(name).await?;
let token = create_jwt(&data.jwt_key, name.to_string(), groups);
let refresh_token_plus_name = refresh_token + "+" + name.as_str();
Ok(HttpResponse::Ok()
.cookie(
Cookie::build("token", token.as_str())
.max_age(1.days())
.path("/")
.http_only(true)
.same_site(SameSite::Strict)
.finish(),
)
.cookie(
Cookie::build("refresh_token", refresh_token_plus_name.clone())
.max_age(max_age.num_days().days())
.path("/auth")
.http_only(true)
.same_site(SameSite::Strict)
.finish(),
)
.json(&login::ServerLoginResponse {
token: token.as_str().to_owned(),
refresh_token: Some(refresh_token_plus_name),
}))
}
#[instrument(skip_all, level = "debug")]
async fn opaque_login_finish<Backend>(
data: web::Data<AppState<Backend>>,
request: web::Json<login::ClientLoginFinishRequest>,
) -> TcpResult<HttpResponse>
where
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static,
{
let name = data
.backend_handler
.login_finish(request.into_inner())
.await?;
get_login_successful_response(&data, &name).await
}
async fn opaque_login_finish_handler<Backend>(
data: web::Data<AppState<Backend>>,
request: web::Json<login::ClientLoginFinishRequest>,
) -> HttpResponse
where
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static,
{
let name = match data
.backend_handler
.login_finish(request.into_inner())
opaque_login_finish(data, request)
.await
{
Ok(n) => n,
Err(e) => return error_to_http_response(e),
.unwrap_or_else(error_to_http_response)
}
#[instrument(skip_all, level = "debug")]
async fn simple_login<Backend>(
data: web::Data<AppState<Backend>>,
request: web::Json<login::ClientSimpleLoginRequest>,
) -> TcpResult<HttpResponse>
where
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + LoginHandler + 'static,
{
let user_id = UserId::new(&request.username);
let bind_request = BindRequest {
name: user_id.clone(),
password: request.password.clone(),
};
data.backend_handler.bind(bind_request).await?;
get_login_successful_response(&data, &user_id).await
}
async fn simple_login_handler<Backend>(
data: web::Data<AppState<Backend>>,
request: web::Json<login::ClientSimpleLoginRequest>,
) -> HttpResponse
where
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + LoginHandler + 'static,
{
simple_login(data, request)
.await
.unwrap_or_else(error_to_http_response)
}
#[instrument(skip_all, level = "debug")]
async fn post_authorize<Backend>(
data: web::Data<AppState<Backend>>,
request: web::Json<BindRequest>,
) -> TcpResult<HttpResponse>
where
Backend: TcpBackendHandler + BackendHandler + LoginHandler + 'static,
{
let name = request.name.clone();
debug!(%name);
data.backend_handler.bind(request.into_inner()).await?;
get_login_successful_response(&data, &name).await
}
async fn post_authorize<Backend>(
async fn post_authorize_handler<Backend>(
data: web::Data<AppState<Backend>>,
request: web::Json<BindRequest>,
) -> HttpResponse
where
Backend: TcpBackendHandler + BackendHandler + LoginHandler + 'static,
{
let name = request.name.clone();
if let Err(e) = data.backend_handler.bind(request.into_inner()).await {
return error_to_http_response(e);
}
get_login_successful_response(&data, &name).await
post_authorize(data, request)
.await
.unwrap_or_else(error_to_http_response)
}
#[instrument(skip_all, level = "debug")]
async fn opaque_register_start<Backend>(
request: actix_web::HttpRequest,
mut payload: actix_web::web::Payload,
data: web::Data<AppState<Backend>>,
) -> ApiResult<registration::ServerRegistrationStartResponse>
) -> TcpResult<registration::ServerRegistrationStartResponse>
where
Backend: OpaqueHandler + 'static,
Backend: BackendHandler + OpaqueHandler + 'static,
{
use actix_web::FromRequest;
let validation_result = match BearerAuth::from_request(&request, &mut payload.0)
let validation_result = BearerAuth::from_request(&request, &mut payload.0)
.await
.ok()
.and_then(|bearer| check_if_token_is_valid(&data, bearer.token()).ok())
{
Some(t) => t,
None => {
return ApiResult::Right(
HttpResponse::Unauthorized().body("Not authorized to change the user's password"),
)
}
};
.ok_or_else(|| {
TcpError::UnauthorizedError("Not authorized to change the user's password".to_string())
})?;
let registration_start_request =
match web::Json::<registration::ClientRegistrationStartRequest>::from_request(
web::Json::<registration::ClientRegistrationStartRequest>::from_request(
&request,
&mut payload.0,
)
.await
{
Ok(r) => r,
Err(e) => {
return ApiResult::Right(
HttpResponse::BadRequest().body(format!("Bad request: {:#?}", e)),
)
}
}
.map_err(|e| TcpError::BadRequest(format!("{:#?}", e)))?
.into_inner();
let user_id = &registration_start_request.username;
validation_result.can_access(user_id);
data.backend_handler
let user_id = UserId::new(&registration_start_request.username);
let user_is_admin = data
.backend_handler
.get_user_groups(&user_id)
.await?
.iter()
.any(|g| g.display_name == "lldap_admin");
if !validation_result.can_change_password(&user_id, user_is_admin) {
return Err(TcpError::UnauthorizedError(
"Not authorized to change the user's password".to_string(),
));
}
Ok(data
.backend_handler
.registration_start(registration_start_request)
.await?)
}
async fn opaque_register_start_handler<Backend>(
request: actix_web::HttpRequest,
payload: actix_web::web::Payload,
data: web::Data<AppState<Backend>>,
) -> ApiResult<registration::ServerRegistrationStartResponse>
where
Backend: BackendHandler + OpaqueHandler + 'static,
{
opaque_register_start(request, payload, data)
.await
.map(|res| ApiResult::Left(web::Json(res)))
.unwrap_or_else(error_to_api_response)
}
#[instrument(skip_all, level = "debug")]
async fn opaque_register_finish<Backend>(
data: web::Data<AppState<Backend>>,
request: web::Json<registration::ClientRegistrationFinishRequest>,
) -> TcpResult<HttpResponse>
where
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static,
{
data.backend_handler
.registration_finish(request.into_inner())
.await?;
Ok(HttpResponse::Ok().finish())
}
async fn opaque_register_finish_handler<Backend>(
data: web::Data<AppState<Backend>>,
request: web::Json<registration::ClientRegistrationFinishRequest>,
) -> HttpResponse
where
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static,
{
if let Err(e) = data
.backend_handler
.registration_finish(request.into_inner())
opaque_register_finish(data, request)
.await
{
return error_to_http_response(e);
}
HttpResponse::Ok().finish()
.unwrap_or_else(error_to_http_response)
}
pub struct CookieToHeaderTranslatorFactory;
@@ -446,25 +562,63 @@ where
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Permission {
Admin,
PasswordManager,
Readonly,
Regular,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationResults {
pub user: String,
pub is_admin: bool,
pub user: UserId,
pub permission: Permission,
}
impl ValidationResults {
#[cfg(test)]
pub fn admin() -> Self {
Self {
user: "admin".to_string(),
is_admin: true,
user: UserId::new("admin"),
permission: Permission::Admin,
}
}
pub fn can_access(&self, user: &str) -> bool {
self.is_admin || self.user == user
#[must_use]
pub fn is_admin(&self) -> bool {
self.permission == Permission::Admin
}
#[must_use]
pub fn is_admin_or_readonly(&self) -> bool {
self.permission == Permission::Admin
|| self.permission == Permission::Readonly
|| self.permission == Permission::PasswordManager
}
#[must_use]
pub fn can_read(&self, user: &UserId) -> bool {
self.permission == Permission::Admin
|| self.permission == Permission::PasswordManager
|| self.permission == Permission::Readonly
|| &self.user == user
}
#[must_use]
pub fn can_change_password(&self, user: &UserId, user_is_admin: bool) -> bool {
self.permission == Permission::Admin
|| (self.permission == Permission::PasswordManager && !user_is_admin)
|| &self.user == user
}
#[must_use]
pub fn can_write(&self, user: &UserId) -> bool {
self.permission == Permission::Admin || &self.user == user
}
}
#[instrument(skip_all, level = "debug", err, ret)]
pub(crate) fn check_if_token_is_valid<Backend>(
state: &AppState<Backend>,
token_str: &str,
@@ -488,10 +642,18 @@ pub(crate) fn check_if_token_is_valid<Backend>(
if state.jwt_blacklist.read().unwrap().contains(&jwt_hash) {
return Err(ErrorUnauthorized("JWT was logged out"));
}
let is_admin = token.claims().groups.contains("lldap_admin");
let is_in_group = |name| token.claims().groups.contains(name);
Ok(ValidationResults {
user: token.claims().user.clone(),
is_admin,
user: UserId::new(&token.claims().user),
permission: if is_in_group("lldap_admin") {
Permission::Admin
} else if is_in_group("lldap_password_manager") {
Permission::PasswordManager
} else if is_in_group("lldap_strict_readonly") {
Permission::Readonly
} else {
Permission::Regular
},
})
}
@@ -499,34 +661,38 @@ pub fn configure_server<Backend>(cfg: &mut web::ServiceConfig)
where
Backend: TcpBackendHandler + LoginHandler + OpaqueHandler + BackendHandler + 'static,
{
cfg.service(web::resource("").route(web::post().to(post_authorize::<Backend>)))
cfg.service(web::resource("").route(web::post().to(post_authorize_handler::<Backend>)))
.service(
web::resource("/opaque/login/start")
.route(web::post().to(opaque_login_start::<Backend>)),
)
.service(
web::resource("/opaque/login/finish")
.route(web::post().to(opaque_login_finish::<Backend>)),
.route(web::post().to(opaque_login_finish_handler::<Backend>)),
)
.service(web::resource("/refresh").route(web::get().to(get_refresh::<Backend>)))
.service(
web::resource("/simple/login").route(web::post().to(simple_login_handler::<Backend>)),
)
.service(web::resource("/refresh").route(web::get().to(get_refresh_handler::<Backend>)))
.service(
web::resource("/reset/step1/{user_id}")
.route(web::get().to(get_password_reset_step1::<Backend>)),
.route(web::get().to(get_password_reset_step1_handler::<Backend>)),
)
.service(
web::resource("/reset/step2/{token}")
.route(web::get().to(get_password_reset_step2::<Backend>)),
.route(web::get().to(get_password_reset_step2_handler::<Backend>)),
)
.service(web::resource("/logout").route(web::get().to(get_logout::<Backend>)))
.service(web::resource("/logout").route(web::get().to(get_logout_handler::<Backend>)))
.service(
web::scope("/opaque/register")
.wrap(CookieToHeaderTranslatorFactory)
.service(
web::resource("/start").route(web::post().to(opaque_register_start::<Backend>)),
web::resource("/start")
.route(web::post().to(opaque_register_start_handler::<Backend>)),
)
.service(
web::resource("/finish")
.route(web::post().to(opaque_register_finish::<Backend>)),
.route(web::post().to(opaque_register_finish_handler::<Backend>)),
),
);
}

View File

@@ -1,9 +1,10 @@
use clap::Clap;
use clap::Parser;
use lettre::message::Mailbox;
use serde::{Deserialize, Serialize};
/// lldap is a lightweight LDAP server
#[derive(Debug, Clap, Clone)]
#[clap(version = "0.1", author = "The LLDAP team")]
#[derive(Debug, Parser, Clone)]
#[clap(version, author)]
pub struct CLIOpts {
/// Export
#[clap(subcommand)]
@@ -11,7 +12,7 @@ pub struct CLIOpts {
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clap, Clone)]
#[derive(Debug, Parser, Clone)]
pub enum Command {
/// Export the GraphQL schema to *.graphql.
#[clap(name = "export_graphql_schema")]
@@ -24,7 +25,7 @@ pub enum Command {
SendTestEmail(TestEmailOpts),
}
#[derive(Debug, Clap, Clone)]
#[derive(Debug, Parser, Clone)]
pub struct GeneralConfigOpts {
/// Change config file name.
#[clap(
@@ -40,7 +41,7 @@ pub struct GeneralConfigOpts {
pub verbose: bool,
}
#[derive(Debug, Clap, Clone)]
#[derive(Debug, Parser, Clone)]
pub struct RunOpts {
#[clap(flatten)]
pub general_config: GeneralConfigOpts,
@@ -54,10 +55,6 @@ pub struct RunOpts {
#[clap(long, env = "LLDAP_LDAP_PORT")]
pub ldap_port: Option<u16>,
/// Change ldap ssl port. Default: 6360
#[clap(long, env = "LLDAP_LDAPS_PORT")]
pub ldaps_port: Option<u16>,
/// Change HTTP API port. Default: 17170
#[clap(long, env = "LLDAP_HTTP_PORT")]
pub http_port: Option<u16>,
@@ -68,9 +65,12 @@ pub struct RunOpts {
#[clap(flatten)]
pub smtp_opts: SmtpOpts,
#[clap(flatten)]
pub ldaps_opts: LdapsOpts,
}
#[derive(Debug, Clap, Clone)]
#[derive(Debug, Parser, Clone)]
pub struct TestEmailOpts {
#[clap(flatten)]
pub general_config: GeneralConfigOpts,
@@ -83,10 +83,38 @@ pub struct TestEmailOpts {
pub smtp_opts: SmtpOpts,
}
#[derive(Debug, Clap, Clone)]
#[derive(Debug, Parser, Clone)]
#[clap(next_help_heading = Some("LDAPS"), setting = clap::AppSettings::DeriveDisplayOrder)]
pub struct LdapsOpts {
/// Enable LDAPS. Default: false.
#[clap(long, env = "LLDAP_LDAPS_OPTIONS__ENABLED")]
pub ldaps_enabled: Option<bool>,
/// Change ldap ssl port. Default: 6360
#[clap(long, env = "LLDAP_LDAPS_OPTIONS__PORT")]
pub ldaps_port: Option<u16>,
/// Ldaps certificate file. Default: cert.pem
#[clap(long, env = "LLDAP_LDAPS_OPTIONS__CERT_FILE")]
pub ldaps_cert_file: Option<String>,
/// Ldaps certificate key file. Default: key.pem
#[clap(long, env = "LLDAP_LDAPS_OPTIONS__KEY_FILE")]
pub ldaps_key_file: Option<String>,
}
clap::arg_enum! {
#[derive(Clone, Debug, Deserialize, Serialize)]
pub enum SmtpEncryption {
TLS,
STARTTLS,
}
}
#[derive(Debug, Parser, Clone)]
#[clap(next_help_heading = Some("SMTP"), setting = clap::AppSettings::DeriveDisplayOrder)]
pub struct SmtpOpts {
/// Sender email address.
#[clap(long)]
#[clap(long, env = "LLDAP_SMTP_OPTIONS__FROM")]
pub smtp_from: Option<Mailbox>,
@@ -111,11 +139,14 @@ pub struct SmtpOpts {
pub smtp_password: Option<String>,
/// Whether TLS should be used to connect to SMTP.
#[clap(long, env = "LLDAP_SMTP_OPTIONS__TLS_REQUIRED")]
#[clap(long, env = "LLDAP_SMTP_OPTIONS__TLS_REQUIRED", setting=clap::ArgSettings::Hidden)]
pub smtp_tls_required: Option<bool>,
#[clap(long, env = "LLDAP_SMTP_OPTIONS__ENCRYPTION", possible_values = SmtpEncryption::variants(), case_insensitive = true)]
pub smtp_encryption: Option<SmtpEncryption>,
}
#[derive(Debug, Clap, Clone)]
#[derive(Debug, Parser, Clone)]
pub struct ExportGraphQLSchemaOpts {
/// Output to a file. If not specified, the config is printed to the standard output.
#[clap(short, long)]

View File

@@ -1,4 +1,7 @@
use crate::infra::cli::{GeneralConfigOpts, RunOpts, SmtpOpts, TestEmailOpts};
use crate::{
domain::handler::UserId,
infra::cli::{GeneralConfigOpts, LdapsOpts, RunOpts, SmtpEncryption, SmtpOpts, TestEmailOpts},
};
use anyhow::{Context, Result};
use figment::{
providers::{Env, Format, Serialized, Toml},
@@ -26,8 +29,11 @@ pub struct MailOptions {
pub user: String,
#[builder(default = r#"SecUtf8::from("")"#)]
pub password: SecUtf8,
#[builder(default = "true")]
pub tls_required: bool,
#[builder(default = "SmtpEncryption::TLS")]
pub smtp_encryption: SmtpEncryption,
/// Deprecated.
#[builder(default = "None")]
pub tls_required: Option<bool>,
}
impl std::default::Default for MailOptions {
@@ -36,31 +42,56 @@ impl std::default::Default for MailOptions {
}
}
#[derive(Clone, Debug, Deserialize, Serialize, derive_builder::Builder)]
#[builder(pattern = "owned")]
pub struct LdapsOptions {
#[builder(default = "false")]
pub enabled: bool,
#[builder(default = "6360")]
pub port: u16,
#[builder(default = r#"String::from("cert.pem")"#)]
pub cert_file: String,
#[builder(default = r#"String::from("key.pem")"#)]
pub key_file: String,
}
impl std::default::Default for LdapsOptions {
fn default() -> Self {
LdapsOptionsBuilder::default().build().unwrap()
}
}
#[derive(Clone, Debug, Deserialize, Serialize, derive_builder::Builder)]
#[builder(pattern = "owned", build_fn(name = "private_build"))]
pub struct Configuration {
#[builder(default = "3890")]
pub ldap_port: u16,
#[builder(default = "6360")]
pub ldaps_port: u16,
#[builder(default = "17170")]
pub http_port: u16,
#[builder(default = r#"SecUtf8::from("secretjwtsecret")"#)]
pub jwt_secret: SecUtf8,
#[builder(default = r#"String::from("dc=example,dc=com")"#)]
pub ldap_base_dn: String,
#[builder(default = r#"String::from("admin")"#)]
pub ldap_user_dn: String,
#[builder(default = r#"UserId::new("admin")"#)]
pub ldap_user_dn: UserId,
#[builder(default = r#"String::default()"#)]
pub ldap_user_email: String,
#[builder(default = r#"SecUtf8::from("password")"#)]
pub ldap_user_pass: SecUtf8,
#[builder(default = r#"String::from("sqlite://users.db?mode=rwc")"#)]
pub database_url: String,
#[builder(default)]
pub ignored_user_attributes: Vec<String>,
#[builder(default)]
pub ignored_group_attributes: Vec<String>,
#[builder(default = "false")]
pub verbose: bool,
#[builder(default = r#"String::from("server_key")"#)]
pub key_file: String,
#[builder(default)]
pub smtp_options: MailOptions,
#[builder(default)]
pub ldaps_options: LdapsOptions,
#[builder(default = r#"String::from("http://localhost")"#)]
pub http_url: String,
#[serde(skip)]
@@ -79,6 +110,15 @@ impl ConfigurationBuilder {
let server_setup = get_server_setup(self.key_file.as_deref().unwrap_or("server_key"))?;
Ok(self.server_setup(Some(server_setup)).private_build()?)
}
#[cfg(test)]
pub fn for_tests() -> Configuration {
ConfigurationBuilder::default()
.verbose(true)
.server_setup(Some(generate_random_private_key()))
.private_build()
.unwrap()
}
}
impl Configuration {
@@ -91,17 +131,34 @@ impl Configuration {
}
}
fn generate_random_private_key() -> ServerSetup {
let mut rng = rand::rngs::OsRng;
ServerSetup::new(&mut rng)
}
fn write_to_readonly_file(path: &std::path::Path, buffer: &[u8]) -> Result<()> {
use std::{fs::File, io::Write};
assert!(!path.exists());
let mut file = File::create(path)?;
let mut permissions = file.metadata()?.permissions();
permissions.set_readonly(true);
if cfg!(unix) {
use std::os::unix::fs::PermissionsExt;
permissions.set_mode(0o400);
}
file.set_permissions(permissions)?;
Ok(file.write_all(buffer)?)
}
fn get_server_setup(file_path: &str) -> Result<ServerSetup> {
use std::path::Path;
let path = Path::new(file_path);
use std::fs::read;
let path = std::path::Path::new(file_path);
if path.exists() {
let bytes =
std::fs::read(file_path).context(format!("Could not read key file `{}`", file_path))?;
let bytes = read(file_path).context(format!("Could not read key file `{}`", file_path))?;
Ok(ServerSetup::deserialize(&bytes)?)
} else {
let mut rng = rand::rngs::OsRng;
let server_setup = ServerSetup::new(&mut rng);
std::fs::write(path, server_setup.serialize()).context(format!(
let server_setup = generate_random_private_key();
write_to_readonly_file(path, &server_setup.serialize()).context(format!(
"Could not write the generated server setup to file `{}`",
file_path,
))?;
@@ -141,10 +198,6 @@ impl ConfigOverrider for RunOpts {
config.ldap_port = port;
}
if let Some(port) = self.ldaps_port {
config.ldaps_port = port;
}
if let Some(port) = self.http_port {
config.http_port = port;
}
@@ -153,6 +206,7 @@ impl ConfigOverrider for RunOpts {
config.http_url = url.to_string();
}
self.smtp_opts.override_config(config);
self.ldaps_opts.override_config(config);
}
}
@@ -163,6 +217,23 @@ impl ConfigOverrider for TestEmailOpts {
}
}
impl ConfigOverrider for LdapsOpts {
fn override_config(&self, config: &mut Configuration) {
if let Some(enabled) = self.ldaps_enabled {
config.ldaps_options.enabled = enabled;
}
if let Some(port) = self.ldaps_port {
config.ldaps_options.port = port;
}
if let Some(path) = self.ldaps_cert_file.as_ref() {
config.ldaps_options.cert_file = path.clone();
}
if let Some(path) = self.ldaps_key_file.as_ref() {
config.ldaps_options.key_file = path.clone();
}
}
}
impl ConfigOverrider for GeneralConfigOpts {
fn override_config(&self, config: &mut Configuration) {
if self.verbose {
@@ -192,7 +263,7 @@ impl ConfigOverrider for SmtpOpts {
config.smtp_options.password = SecUtf8::from(password.clone());
}
if let Some(tls_required) = self.smtp_tls_required {
config.smtp_options.tls_required = tls_required;
config.smtp_options.tls_required = Some(tls_required);
}
}
}
@@ -208,11 +279,13 @@ where
overrides.general_config().config_file
);
use figment_file_provider_adapter::FileAdapter;
let ignore_keys = ["key_file", "cert_file"];
let mut config: Configuration = Figment::from(Serialized::defaults(
ConfigurationBuilder::default().private_build().unwrap(),
))
.merge(Toml::file(config_file))
.merge(Env::prefixed("LLDAP_").split("__"))
.merge(FileAdapter::wrap(Toml::file(config_file)).ignore(&ignore_keys))
.merge(FileAdapter::wrap(Env::prefixed("LLDAP_").split("__")).ignore(&ignore_keys))
.extract()?;
overrides.override_config(&mut config);
@@ -226,5 +299,8 @@ where
if config.ldap_user_pass == SecUtf8::from("password") {
println!("WARNING: Unsecure default admin password is used.");
}
if config.smtp_options.tls_required.is_some() {
println!("DEPRECATED: smtp_options.tls_required field is deprecated, it never did anything. You can replace it with smtp_options.smtp_encryption.");
}
Ok(config)
}

View File

@@ -7,6 +7,7 @@ use chrono::Local;
use cron::Schedule;
use sea_query::{Expr, Query};
use std::{str::FromStr, time::Duration};
use tracing::{debug, error, info, instrument};
// Define actor
pub struct Scheduler {
@@ -19,7 +20,7 @@ impl Actor for Scheduler {
type Context = Context<Self>;
fn started(&mut self, context: &mut Context<Self>) {
log::info!("DB Cleanup Cron started");
info!("DB Cleanup Cron started");
context.run_later(self.duration_until_next(), move |this, ctx| {
this.schedule_task(ctx)
@@ -27,7 +28,7 @@ impl Actor for Scheduler {
}
fn stopped(&mut self, _ctx: &mut Context<Self>) {
log::info!("DB Cleanup stopped");
info!("DB Cleanup stopped");
}
}
@@ -38,7 +39,6 @@ impl Scheduler {
}
fn schedule_task(&self, ctx: &mut Context<Self>) {
log::info!("Cleaning DB");
let future = actix::fut::wrap_future::<_, Self>(Self::cleanup_db(self.sql_pool.clone()));
ctx.spawn(future);
@@ -47,17 +47,16 @@ impl Scheduler {
});
}
#[instrument(skip_all)]
async fn cleanup_db(sql_pool: Pool) {
if let Err(e) = sqlx::query(
&Query::delete()
.from_table(JwtRefreshStorage::Table)
.and_where(Expr::col(JwtRefreshStorage::ExpiryDate).lt(Local::now().naive_utc()))
.to_string(DbQueryBuilder {}),
)
.execute(&sql_pool)
.await
{
log::error!("DB error while cleaning up JWT refresh tokens: {}", e);
info!("Cleaning DB");
let query = Query::delete()
.from_table(JwtRefreshStorage::Table)
.and_where(Expr::col(JwtRefreshStorage::ExpiryDate).lt(Local::now().naive_utc()))
.to_string(DbQueryBuilder {});
debug!(%query);
if let Err(e) = sqlx::query(&query).execute(&sql_pool).await {
error!("DB error while cleaning up JWT refresh tokens: {}", e);
};
if let Err(e) = sqlx::query(
&Query::delete()
@@ -68,9 +67,9 @@ impl Scheduler {
.execute(&sql_pool)
.await
{
log::error!("DB error while cleaning up JWT storage: {}", e);
error!("DB error while cleaning up JWT storage: {}", e);
};
log::info!("DB cleaned!");
info!("DB cleaned!");
}
fn duration_until_next(&self) -> Duration {

View File

@@ -1,7 +1,10 @@
use crate::domain::handler::{
BackendHandler, CreateUserRequest, GroupId, UpdateGroupRequest, UpdateUserRequest,
BackendHandler, CreateUserRequest, GroupId, JpegPhoto, UpdateGroupRequest, UpdateUserRequest,
UserId,
};
use anyhow::Context as AnyhowContext;
use juniper::{graphql_object, FieldResult, GraphQLInputObject, GraphQLObject};
use tracing::{debug, debug_span, Instrument};
use super::api::Context;
@@ -27,6 +30,8 @@ pub struct CreateUserInput {
display_name: Option<String>,
first_name: Option<String>,
last_name: Option<String>,
// Base64 encoded JpegPhoto.
avatar: Option<String>,
}
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
@@ -37,6 +42,8 @@ pub struct UpdateUserInput {
display_name: Option<String>,
first_name: Option<String>,
last_name: Option<String>,
// Base64 encoded JpegPhoto.
avatar: Option<String>,
}
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
@@ -63,22 +70,39 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
context: &Context<Handler>,
user: CreateUserInput,
) -> FieldResult<super::query::User<Handler>> {
if !context.validation_result.is_admin {
let span = debug_span!("[GraphQL mutation] create_user");
span.in_scope(|| {
debug!(?user.id);
});
if !context.validation_result.is_admin() {
span.in_scope(|| debug!("Unauthorized"));
return Err("Unauthorized user creation".into());
}
let user_id = UserId::new(&user.id);
let avatar = user
.avatar
.map(base64::decode)
.transpose()
.context("Invalid base64 image")?
.map(JpegPhoto::try_from)
.transpose()
.context("Provided image is not a valid JPEG")?;
context
.handler
.create_user(CreateUserRequest {
user_id: user.id.clone(),
user_id: user_id.clone(),
email: user.email,
display_name: user.display_name,
first_name: user.first_name,
last_name: user.last_name,
avatar,
})
.instrument(span.clone())
.await?;
Ok(context
.handler
.get_user_details(&user.id)
.get_user_details(&user_id)
.instrument(span)
.await
.map(Into::into)?)
}
@@ -87,13 +111,19 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
context: &Context<Handler>,
name: String,
) -> FieldResult<super::query::Group<Handler>> {
if !context.validation_result.is_admin {
let span = debug_span!("[GraphQL mutation] create_group");
span.in_scope(|| {
debug!(?name);
});
if !context.validation_result.is_admin() {
span.in_scope(|| debug!("Unauthorized"));
return Err("Unauthorized group creation".into());
}
let group_id = context.handler.create_group(&name).await?;
Ok(context
.handler
.get_group_details(group_id)
.instrument(span)
.await
.map(Into::into)?)
}
@@ -102,18 +132,34 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
context: &Context<Handler>,
user: UpdateUserInput,
) -> FieldResult<Success> {
if !context.validation_result.can_access(&user.id) {
let span = debug_span!("[GraphQL mutation] update_user");
span.in_scope(|| {
debug!(?user.id);
});
let user_id = UserId::new(&user.id);
if !context.validation_result.can_write(&user_id) {
span.in_scope(|| debug!("Unauthorized"));
return Err("Unauthorized user update".into());
}
let avatar = user
.avatar
.map(base64::decode)
.transpose()
.context("Invalid base64 image")?
.map(JpegPhoto::try_from)
.transpose()
.context("Provided image is not a valid JPEG")?;
context
.handler
.update_user(UpdateUserRequest {
user_id: user.id,
user_id,
email: user.email,
display_name: user.display_name,
first_name: user.first_name,
last_name: user.last_name,
avatar,
})
.instrument(span)
.await?;
Ok(Success::new())
}
@@ -122,10 +168,16 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
context: &Context<Handler>,
group: UpdateGroupInput,
) -> FieldResult<Success> {
if !context.validation_result.is_admin {
let span = debug_span!("[GraphQL mutation] update_group");
span.in_scope(|| {
debug!(?group.id);
});
if !context.validation_result.is_admin() {
span.in_scope(|| debug!("Unauthorized"));
return Err("Unauthorized group update".into());
}
if group.id == 1 {
span.in_scope(|| debug!("Cannot change admin group details"));
return Err("Cannot change admin group details".into());
}
context
@@ -134,6 +186,7 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
group_id: GroupId(group.id),
display_name: group.display_name,
})
.instrument(span)
.await?;
Ok(Success::new())
}
@@ -143,12 +196,18 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
user_id: String,
group_id: i32,
) -> FieldResult<Success> {
if !context.validation_result.is_admin {
let span = debug_span!("[GraphQL mutation] add_user_to_group");
span.in_scope(|| {
debug!(?user_id, ?group_id);
});
if !context.validation_result.is_admin() {
span.in_scope(|| debug!("Unauthorized"));
return Err("Unauthorized group membership modification".into());
}
context
.handler
.add_user_to_group(&user_id, GroupId(group_id))
.add_user_to_group(&UserId::new(&user_id), GroupId(group_id))
.instrument(span)
.await?;
Ok(Success::new())
}
@@ -158,38 +217,67 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
user_id: String,
group_id: i32,
) -> FieldResult<Success> {
if !context.validation_result.is_admin {
let span = debug_span!("[GraphQL mutation] remove_user_from_group");
span.in_scope(|| {
debug!(?user_id, ?group_id);
});
if !context.validation_result.is_admin() {
span.in_scope(|| debug!("Unauthorized"));
return Err("Unauthorized group membership modification".into());
}
let user_id = UserId::new(&user_id);
if context.validation_result.user == user_id && group_id == 1 {
span.in_scope(|| debug!("Cannot remove admin rights for current user"));
return Err("Cannot remove admin rights for current user".into());
}
context
.handler
.remove_user_from_group(&user_id, GroupId(group_id))
.instrument(span)
.await?;
Ok(Success::new())
}
async fn delete_user(context: &Context<Handler>, user_id: String) -> FieldResult<Success> {
if !context.validation_result.is_admin {
let span = debug_span!("[GraphQL mutation] delete_user");
span.in_scope(|| {
debug!(?user_id);
});
let user_id = UserId::new(&user_id);
if !context.validation_result.is_admin() {
span.in_scope(|| debug!("Unauthorized"));
return Err("Unauthorized user deletion".into());
}
if context.validation_result.user == user_id {
span.in_scope(|| debug!("Cannot delete current user"));
return Err("Cannot delete current user".into());
}
context.handler.delete_user(&user_id).await?;
context
.handler
.delete_user(&user_id)
.instrument(span)
.await?;
Ok(Success::new())
}
async fn delete_group(context: &Context<Handler>, group_id: i32) -> FieldResult<Success> {
if !context.validation_result.is_admin {
let span = debug_span!("[GraphQL mutation] delete_group");
span.in_scope(|| {
debug!(?group_id);
});
if !context.validation_result.is_admin() {
span.in_scope(|| debug!("Unauthorized"));
return Err("Unauthorized group deletion".into());
}
if group_id == 1 {
span.in_scope(|| debug!("Cannot delete admin group"));
return Err("Cannot delete admin group".into());
}
context.handler.delete_group(GroupId(group_id)).await?;
context
.handler
.delete_group(GroupId(group_id))
.instrument(span)
.await?;
Ok(Success::new())
}
}

Some files were not shown because too many files have changed in this diff Show More