218 Commits

Author SHA1 Message Date
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
Valentin Tolmer
6191fb226a docker: Fix permission issues, remove user from container 2021-11-28 00:55:35 +01:00
Valentin Tolmer
9653d64eb1 config: Prevent loading the wrong server_key 2021-11-28 00:55:35 +01:00
kaysond
5b5395103a copy style.css into the container 2021-11-26 08:08:10 +01:00
kaysond
a1e50defbe add docs to config template 2021-11-25 20:41:59 +01:00
kaysond
656451435e move bash install into previous RUN command 2021-11-25 20:41:59 +01:00
kaysond
859ed97ca8 add an entrypoint script that allows setting secrets from a file; version the upstream containers 2021-11-25 20:41:59 +01:00
Valentin Tolmer
df889ee2fe authelia: Re-enable password reset 2021-11-25 10:27:34 +01:00
Valentin Tolmer
faee271705 docker: Fix pkg copy 2021-11-25 10:10:20 +01:00
Valentin Tolmer
ba7848d043 Revert "github: Fix dockerhub description update"
Description updates doesn't work with app passwords.
2021-11-23 13:10:45 +01:00
Valentin Tolmer
45f5bb51d9 github: Fix dockerhub description update 2021-11-23 13:02:31 +01:00
Valentin Tolmer
c0869b4324 docker: add comment 2021-11-23 11:47:59 +01:00
Valentin Tolmer
edf9e538ce gitignore: misc 2021-11-23 00:25:47 +01:00
Valentin Tolmer
4a5abfd395 app: Implement the second part of password reset 2021-11-23 00:25:47 +01:00
Valentin Tolmer
9fb4afcf60 app: Implement the first screen of password reset 2021-11-23 00:25:47 +01:00
Valentin Tolmer
789c8f367e server: Send an email for password resets 2021-11-23 00:25:47 +01:00
Valentin Tolmer
db2b5cbae0 server: Add http_url to the configuration 2021-11-23 00:25:47 +01:00
Valentin Tolmer
a13bfc3575 server: Implement password reset
It's still missing the email.

This also secures the password change method with a JWT token check: you
have to be logged in to change the password.
2021-11-23 00:25:47 +01:00
Valentin Tolmer
7b5ad47ee2 server: Make the JWT cookies valid for /
This will be used to secure the password change API.
2021-11-23 00:25:47 +01:00
Valentin Tolmer
e1503743b5 server: Add methods to get/set a password reset token 2021-11-23 00:25:47 +01:00
Valentin Tolmer
88732556c1 server: Add an SQL table to store password reset tokens 2021-11-23 00:25:47 +01:00
Valentin Tolmer
35d0cc0fb0 readme: Improve title, add comparisons 2021-11-23 00:25:47 +01:00
Valentin Tolmer
6456149e50 release-tools: Add docker flow and release preparation script 2021-11-23 00:25:47 +01:00
Valentin Tolmer
f1bda21cad misc: Make openssl vendored for cross-compil 2021-11-23 00:25:47 +01:00
Valentin Tolmer
7b081fce61 docker: Small improvements 2021-11-23 00:25:47 +01:00
Valentin Tolmer
618e7e3585 dockerignore: ignore more artefacts 2021-11-23 00:25:47 +01:00
Valentin Tolmer
790fd7c5d1 cargo: Update to 2021 edition 2021-11-23 00:25:47 +01:00
Valentin Tolmer
4551e27b55 server, auth: Update some dependencies 2021-11-23 00:25:47 +01:00
Valentin Tolmer
ad1ee52d76 server: Prevent sqlx from logging unless verbose 2021-11-23 00:25:47 +01:00
Valentin Tolmer
9124339b96 server: Prevent passwords and secrets from being printed 2021-11-23 00:25:47 +01:00
Valentin Tolmer
617a0f53fa server: Send an email with the test command 2021-11-23 00:25:47 +01:00
Valentin Tolmer
2a90443ed8 gitignore: Prevent adding .env to git 2021-11-23 00:25:47 +01:00
Valentin Tolmer
1d54ca8040 server: Load config for both run and mail 2021-11-23 00:25:47 +01:00
Valentin Tolmer
77ced7ea43 misc: Forbid non-ascii identifiers
That prevents a class of unicode attacks, e.g. invisible characters.
2021-11-23 00:25:47 +01:00
Valentin Tolmer
fa0105fa96 cli: Add a "send test email" command
Still unimplemented. This re-organizes the command-line flags.
2021-11-23 00:25:47 +01:00
Valentin Tolmer
18e3892e55 configuration: Add smtp config values. 2021-11-23 00:25:47 +01:00
Valentin Tolmer
350fdcdf9b server: improve error messages 2021-11-23 00:25:47 +01:00
Valentin Tolmer
adf088c74b configuration: move default values inline 2021-11-23 00:25:47 +01:00
Valentin Tolmer
c055c4e671 server: Add lettre dependency to handle emails 2021-11-23 00:25:47 +01:00
Przemek Dragańczuk
98a305e877 Keycloak requires a full DN, not just the username 2021-11-12 15:53:51 +01:00
Valentin Tolmer
47ee56873e ldap: Improve coverage of filters 2021-11-08 11:10:40 +01:00
Valentin Tolmer
ee863f74fc ldap: Add tests for password change 2021-11-08 11:10:40 +01:00
Valentin Tolmer
24e3125e34 ldap: Test the "memberOf" filter 2021-11-08 11:10:40 +01:00
Valentin Tolmer
06b6653dff ldap: Test more invalid DNs 2021-11-08 11:10:40 +01:00
Valentin Tolmer
62745970c6 ldap: Add context to the errors 2021-11-08 11:10:40 +01:00
Valentin Tolmer
ea3142da5d ldap: test message handler 2021-11-08 11:10:40 +01:00
Valentin Tolmer
656edc3763 README: Add keycloak config guide 2021-11-08 09:31:29 +01:00
Valentin Tolmer
d96b534921 ldap: Improve debug messages 2021-11-08 09:31:29 +01:00
Valentin Tolmer
9a024cd7fc ldap: Fix response when both users and groups are returned 2021-11-08 09:31:29 +01:00
Valentin Tolmer
c964428858 fixup: group filters 2021-11-08 09:31:29 +01:00
Valentin Tolmer
f98023e67f ldap: Improve support for group filters 2021-11-08 09:31:29 +01:00
Valentin Tolmer
e68d46d4fe ldap: Make attribute matching case insensitive 2021-11-08 09:31:29 +01:00
Valentin Tolmer
9a680a7d06 server: Add a debug log for LDAP messages 2021-11-08 09:31:29 +01:00
Valentin Tolmer
7345cc42d0 ldap: Add support for createTimestamp and modifyTimestamp
This should help with KeyCloak support.
2021-11-08 09:31:29 +01:00
Valentin Tolmer
d60f5ab460 app: Simplify some CommonComponent uses 2021-10-31 15:52:17 +01:00
Valentin Tolmer
12dfa60eed app: Add docs to CommonComponent 2021-10-31 15:52:17 +01:00
Valentin Tolmer
158e4100ef app: Migrate UserTable to CommonComponent 2021-10-31 15:52:17 +01:00
Valentin Tolmer
87ebee672f app: Migrate UserDetailsForm to CommonComponent 2021-10-31 15:52:17 +01:00
Valentin Tolmer
ec6e1b0c09 app: Migrate RemoveUserFromGroup to CommonComponent 2021-10-31 15:52:17 +01:00
Valentin Tolmer
640126f39a app: Migrate Logout to CommonComponent 2021-10-31 15:52:17 +01:00
Valentin Tolmer
d31ca426f7 app: Migrate GroupTable to CommonComponent 2021-10-31 15:52:17 +01:00
Valentin Tolmer
d4ac9fa703 app: Migrate DeleteUser to CommonComponent 2021-10-31 15:52:17 +01:00
Valentin Tolmer
5523d38838 app: Migrate DeleteGroup to CommonComponent 2021-10-31 15:52:17 +01:00
Valentin Tolmer
587d724c2c app: Migrate CreateUser to CommonComponent 2021-10-31 15:52:17 +01:00
Valentin Tolmer
29f3636064 app: Migrate CreateGroup to CommonComponent 2021-10-31 15:52:17 +01:00
Valentin Tolmer
ec69d30b1c app: Migrate AddUserToGroup to CommonComponent 2021-10-31 15:52:17 +01:00
Valentin Tolmer
232a41d053 app: Migrate AddGroupMember to CommonComponent 2021-10-31 15:52:17 +01:00
Valentin Tolmer
540ac5d241 app: Migrate Login to CommonComponent 2021-10-31 15:52:17 +01:00
Valentin Tolmer
29962881cf app: Migrate user_details to CommonComponent 2021-10-31 15:52:17 +01:00
Valentin Tolmer
65dd1d1fd3 app,infra: Move more functionality in CommonComponent 2021-10-31 15:52:17 +01:00
Valentin Tolmer
ba72e622c2 app: Migrate group_details to CommonComponent 2021-10-31 15:52:17 +01:00
Valentin Tolmer
5a5baf883f app: Migrate change_password to CommonComponent 2021-10-31 15:52:17 +01:00
Valentin Tolmer
6c09af6479 app: Create CommonComponent
This is a utility that gathers common parts of components, like task
and error handling.
2021-10-31 15:52:17 +01:00
Christian Kracher
ba1a5f6011 Update and rename .env to jitsi_meet.conf 2021-10-29 05:02:43 +02:00
Christian Kracher
adc3d656cd Update .env 2021-10-29 05:02:43 +02:00
Christian Kracher
b9f6b915ac Create .env
Jitsi Meet Docker LDAP Authentication configuration
2021-10-29 05:02:43 +02:00
Valentin Tolmer
43ffeca24d ldap: Add support for password modify extension
This allows other systems (e.g. Authelia) to reset passwords for users.
2021-10-28 18:20:01 +02:00
Valentin Tolmer
31e1ff358b ldap: Implement a rootDSE response
This is the message that broadcasts the capabilities of the server,
including the supported extensions.
2021-10-28 18:20:01 +02:00
Valentin Tolmer
026a2f7eb0 app: Fix the login button not re-enabling after failed login 2021-10-28 18:20:01 +02:00
Valentin Tolmer
63f4bf95d2 build: Enable linking with lld 2021-10-28 18:20:01 +02:00
Valentin Tolmer
d423c64d57 ldap: Switch to using LdapOp instead of ServerOp
This is in preparation of supporting the password change message, since
this is from the Extended Operations that is not available in the simple
ServerOp.
2021-10-28 18:20:01 +02:00
Valentin Tolmer
438ac2818a ldap: Add support for "dn" attribute 2021-10-28 16:36:13 +02:00
Alexander
9874449d66 Added Authelia configuration 2021-10-24 12:47:24 +02:00
Alexander
88ff3e7783 Added Authelia configuration 2021-10-24 12:47:24 +02:00
Valentin Tolmer
107c8ec96e ldap: Implement group listing, fix various bugs 2021-10-23 18:24:03 +02:00
Valentin Tolmer
5a00b7d8bb workflows: cache dependency builds and get code coverage 2021-10-22 14:40:59 +02:00
Valentin Tolmer
21e507a9d7 readme: Fix LDAP admin default value in docs 2021-10-22 14:11:04 +02:00
Valentin Tolmer
1859f5ddf0 config: Add LLDAP_ prefix to env varribles 2021-10-20 15:20:56 +02:00
nitnelave
de15ebba6a readme: Add a note about env variable prefix 2021-10-20 15:20:56 +02:00
Valentin Tolmer
aa8bbf96f8 cargo: Bump the version to 0.2.0 2021-10-20 08:58:36 +02:00
102 changed files with 10557 additions and 2896 deletions

View File

@@ -1,11 +1,10 @@
# Don't track git
.git/*
.github/*
.gitignore
# Don't track cargo generated files
target/*
server/target/*
app/target/*
auth/target/*
# Don't track the generated JS
app/pkg/*
@@ -14,9 +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*
.gitignore
screenshot.png
recipe.json
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"

68
.github/workflows/Dockerfile.ci vendored Normal file
View File

@@ -0,0 +1,68 @@
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/
RUN cp target/lldap /lldap/ && \
cp target/migration-tool /lldap/ && \
cp -R web/index.html \
web/pkg \
web/static \
/lldap/app/
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
ENV UID=1000
ENV GID=1000
ENV USER=lldap
RUN apt update && \
apt install -y --no-install-recommends tini ca-certificates && \
apt clean && \
rm -rf /var/lib/apt/lists/* && \
groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER
COPY --from=lldap --chown=$CONTAINERUSER:$CONTAINERUSER /lldap /app
COPY --from=lldap --chown=$CONTAINERUSER:$CONTAINERUSER /docker-entrypoint.sh /docker-entrypoint.sh
WORKDIR /app
USER $USER
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
CMD ["run", "--config-file", "/data/lldap_config.toml"]

410
.github/workflows/docker-build.yml vendored Normal file
View File

@@ -0,0 +1,410 @@
name: Docker
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
RUSTC_WRAPPER: sccache
SCCACHE_DIR: $GITHUB_WORKSPACE/.sccache
SCCACHE_VERSION: v0.3.0
LINK: https://github.com/mozilla/sccache/releases/download
# 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
#OPENSSL_INCLUDE_DIR: "/usr/include/openssl/"
#OPENSSL_LIB_DIR: "/usr/lib/arm-linux-gnueabihf/"
# 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
## .sccache
## cargo
## target
jobs:
build-ui:
runs-on: ubuntu-latest
container:
image: rust:1.61
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 libssl-dev
- 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
- name: Install sccache (ubuntu-latest)
run: |
SCCACHE_FILE=sccache-$SCCACHE_VERSION-x86_64-unknown-linux-musl
mkdir -p $HOME/.local/bin
curl -L "$LINK/$SCCACHE_VERSION/$SCCACHE_FILE.tar.gz" | tar xz
mv -f $SCCACHE_FILE/sccache $HOME/.local/bin/sccache
chmod +x $HOME/.local/bin/sccache
echo "$HOME/.local/bin" >> $GITHUB_PATH
- uses: actions/cache@v3
with:
path: |
.sccache
/usr/local/cargo
target
key: lldap-ui-${{ github.sha }}
restore-keys: |
lldap-ui-
- name: Checkout repository
uses: actions/checkout@v2
- name: install cargo wasm
run: cargo install wasm-pack
- name: install rollup nodejs
run: npm install -g rollup
- 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.61
env:
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
OPENSSL_INCLUDE_DIR: "/usr/include/openssl/"
OPENSSL_LIB_DIR: "/usr/lib/arm-linux-gnueabihf/"
CARGO_TERM_COLOR: always
RUSTFLAGS: -Ctarget-feature=-crt-static
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 libssl-dev:armhf tar
- 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: Install sccache (ubuntu-latest)
run: |
SCCACHE_FILE=sccache-$SCCACHE_VERSION-x86_64-unknown-linux-musl
mkdir -p $HOME/.local/bin
curl -L "$LINK/$SCCACHE_VERSION/$SCCACHE_FILE.tar.gz" | tar xz
mv -f $SCCACHE_FILE/sccache $HOME/.local/bin/sccache
chmod +x $HOME/.local/bin/sccache
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Checkout repository
uses: actions/checkout@v2
- uses: actions/cache@v3
with:
path: |
.sccache
/usr/local/cargo
target
key: lldap-bin-armhf-${{ github.sha }}
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:
image: rust:1.61
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
OPENSSL_INCLUDE_DIR: "/usr/include/openssl/"
OPENSSL_LIB_DIR: "/usr/lib/aarch64-linux-gnu/"
CARGO_TERM_COLOR: always
RUSTFLAGS: -Ctarget-feature=-crt-static
steps:
- name: add arm64 architecture
run: dpkg --add-architecture arm64
- name: install runtime
run: apt update && apt install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-arm64-cross libc6-dev-arm64-cross libssl-dev:arm64 tar
- name: smoke test
run: rustc --version
- name: Checkout repository
uses: actions/checkout@v2
- name: add arm64 target
run: rustup target add aarch64-unknown-linux-gnu
- name: smoke test
run: rustc --version
- name: Install sccache (ubuntu-latest)
run: |
SCCACHE_FILE=sccache-$SCCACHE_VERSION-x86_64-unknown-linux-musl
mkdir -p $HOME/.local/bin
curl -L "$LINK/$SCCACHE_VERSION/$SCCACHE_FILE.tar.gz" | tar xz
mv -f $SCCACHE_FILE/sccache $HOME/.local/bin/sccache
chmod +x $HOME/.local/bin/sccache
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Checkout repository
uses: actions/checkout@v2
- uses: actions/cache@v3
with:
path: |
.sccache
/usr/local/cargo
target
key: lldap-bin-aarch64-${{ github.sha }}
restore-keys: |
lldap-bin-aarch64-
- name: compile aarch64
run: cargo build --target=aarch64-unknown-linux-gnu --release -p lldap -p migration-tool
- name: check path
run: ls -al target/aarch64-unknown-linux-gnu/release/
- name: upload aarch64 lldap artifacts
uses: actions/upload-artifact@v3
with:
name: aarch64-lldap-bin
path: target/aarch64-unknown-linux-gnu/release/lldap
- name: upload aarch64 migration-tool artifacts
uses: actions/upload-artifact@v3
with:
name: aarch64-migration-tool-bin
path: target/aarch64-unknown-linux-gnu/release/migration-tool
build-amd64:
runs-on: ubuntu-latest
container:
image: rust:1.61
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 libssl-dev tar
- name: smoke test
run: rustc --version
- name: Install sccache (ubuntu-latest)
run: |
SCCACHE_FILE=sccache-$SCCACHE_VERSION-x86_64-unknown-linux-musl
mkdir -p $HOME/.local/bin
curl -L "$LINK/$SCCACHE_VERSION/$SCCACHE_FILE.tar.gz" | tar xz
mv -f $SCCACHE_FILE/sccache $HOME/.local/bin/sccache
chmod +x $HOME/.local/bin/sccache
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Checkout repository
uses: actions/checkout@v2
- name: cargo & sscache cache
uses: actions/cache@v3
with:
path: |
.sccache
/usr/local/cargo
target
key: lldap-bin-amd64-${{ github.sha }}
restore-keys: |
lldap-bin-amd64-
#- name: add cargo chef
# run: cargo install cargo-chef
#- name: chef prepare
# run: cargo chef prepare --recipe-path recipe.json
#- name: cook?
# run: cargo chef cook --release --recipe-path recipe.json
- name: compile amd64
run: cargo build --target=x86_64-unknown-linux-gnu --release -p lldap -p migration-tool
- name: check path
run: ls -al target/x86_64-unknown-linux-gnu/release/
- name: upload amd64 lldap artifacts
uses: actions/upload-artifact@v3
with:
name: amd64-lldap-bin
path: target/x86_64-unknown-linux-gnu/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-gnu/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: fetch repo
uses: actions/checkout@v2
- 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 }}
- name: Build and push latest
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
tags: nitnelave/lldap:latest
#cache-from: type=gha
#cache-to: type=gha,mode=max
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
- name: Build and push release
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
tags: nitnelave/lldap:stable, 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 }}
#cache-from: type=gha
#cache-to: type=gha,mode=max
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
- 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

@@ -10,13 +10,32 @@ 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
- uses: Swatinem/rust-cache@v1
- name: Build
run: cargo build --verbose --workspace
- name: Run tests
@@ -29,18 +48,14 @@ 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
uses: actions/checkout@v3
- name: Install nightly toolchain
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: nightly
override: true
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v1
- name: Run cargo clippy
uses: actions-rs/cargo@v1
@@ -50,21 +65,43 @@ 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
uses: actions/checkout@v3
- name: Install nightly toolchain
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: nightly
override: true
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v1
- name: Run cargo fmt
uses: actions-rs/cargo@v1
with:
command: fmt
args: --all -- --check
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@v3
- name: Install Rust
run: rustup toolchain install nightly --component llvm-tools-preview && rustup component add llvm-tools-preview --toolchain stable-x86_64-unknown-linux-gnu
- uses: taiki-e/install-action@cargo-llvm-cov
- uses: Swatinem/rust-cache@v1
- 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@v3
with:
files: lcov.info
fail_ci_if_error: true

13
.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
@@ -21,3 +18,13 @@ package.json
# Server private key
server_key
# Pre-build binaries
*.tar.gz
# Misc
.env
recipe.json
lldap_config.toml
cert.pem
key.pem

60
CHANGELOG.md Normal file
View File

@@ -0,0 +1,60 @@
# 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.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

1871
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,12 @@
members = [
"server",
"auth",
"app"
"app",
"migration-tool"
]
default-members = ["server"]
# TODO: remove when there's a new release.
[patch.crates-io.yew_form]
git = 'https://github.com/sassman/yew_form/'

View File

@@ -1,5 +1,5 @@
# Build image
FROM rust:alpine AS chef
FROM rust:alpine3.14 AS chef
RUN set -x \
# Add user
@@ -9,12 +9,13 @@ RUN set -x \
--ingroup app \
--home /app \
--uid 10001 \
app
RUN set -x \
app \
# Install required packages
&& apk add npm openssl-dev musl-dev make perl
&& apk add npm openssl-dev musl-dev make perl curl
USER app
WORKDIR /app
RUN set -x \
# Install build tools
&& RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack cargo-chef \
@@ -24,44 +25,42 @@ RUN set -x \
# Prepare the dependency list.
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
RUN cargo chef prepare --recipe-path /tmp/recipe.json
# Build dependencies
# Build dependencies.
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release -p lldap --recipe-path recipe.json \
&& cargo chef cook --release -p lldap_app --target wasm32-unknown-unknown
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 migration-tool
# Copy the source and build the app.
# Copy the source and build the app and server.
COPY --chown=app:app . .
RUN cargo build --release -p lldap
# TODO: release mode.
RUN ./app/build.sh
RUN cargo build --release -p lldap -p migration-tool \
# Build the frontend.
&& ./app/build.sh
# Final image
FROM alpine
FROM alpine:3.14
WORKDIR /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 /app/target/release/migration-tool ./
COPY docker-entrypoint.sh lldap_config.docker_template.toml ./
RUN set -x \
# Add user
&& addgroup --gid 10001 app \
&& adduser --disabled-password \
--gecos '' \
--ingroup app \
--home /app \
--uid 10001 \
app
RUN mkdir /data && chown app:app /data
USER app
WORKDIR /app
COPY --chown=app:app --from=builder /app/app/index.html app/index.html
COPY --chown=app:app --from=builder /app/app/main.js app/main.js
COPY --chown=app:app --from=builder /app/app/pkg app/pkg
COPY --chown=app:app --from=builder /app/target/release/lldap lldap
&& 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
ENV HTTP_PORT=17170
EXPOSE ${LDAP_PORT} ${HTTP_PORT}
CMD ["/app/lldap", "run", "--config-file", "/data/lldap_config.toml"]
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["run", "--config-file", "/data/lldap_config.toml"]

276
README.md
View File

@@ -1,25 +1,70 @@
# lldap - Light LDAP implementation for authentication
<h1 align="center">lldap - Light LDAP implementation for authentication</h1>
![Build](https://github.com/nitnelave/lldap/actions/workflows/rust.yml/badge.svg)
![Discord](https://img.shields.io/discord/898492935446876200)
![Twitter Follow](https://img.shields.io/twitter/follow/nitnelave1?style=social)
<p align="center">
<i style="font-size:24px">LDAP made easy.</i>
</p>
WARNING: This project is still in alpha, with the basic core functionality
implemented but still very rough. For updates, follow
[@nitnelave1](https://twitter.com/nitnelave1) or join our [Discord
server](https://discord.gg/h5PEdRMNyP)!
<p align="center">
<a href="https://github.com/nitnelave/lldap/actions/workflows/rust.yml?query=branch%3Amain">
<img
src="https://github.com/nitnelave/lldap/actions/workflows/rust.yml/badge.svg"
alt="Build"/>
</a>
<a href="https://discord.gg/h5PEdRMNyP">
<img alt="Discord" src="https://img.shields.io/discord/898492935446876200?label=discord&logo=discord" />
</a>
<a href="https://twitter.com/nitnelave1?ref_src=twsrc%5Etfw">
<img
src="https://img.shields.io/twitter/follow/nitnelave1?style=social"
alt="Twitter Follow"/>
</a>
<a href="https://github.com/rust-secure-code/safety-dance/">
<img
src="https://img.shields.io/badge/unsafe-forbidden-success.svg"
alt="Unsafe forbidden"/>
</a>
<a href="https://app.codecov.io/gh/nitnelave/lldap">
<img alt="Codecov" src="https://img.shields.io/codecov/c/github/nitnelave/lldap" />
</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 cient 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)
This project is an lightweight authentication server that provides an
opinionated, simplified LDAP interface for authentication: clients that can
only speak LDAP protocol can talk to it and use it as an authentication server.
## About
![Screenshot of the user list page](screenshot.png)
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](#compatible-services)!
<img
src="https://raw.githubusercontent.com/nitnelave/lldap/master/screenshot.png"
alt="Screenshot of the user list page"
width="50%"
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`)
* simple to manage (friendly web UI)
* simple to setup (no messing around with `slapd`),
* simple to manage (friendly web UI),
* low resources,
* opinionated with basic defaults so you don't have to understand the
subtleties of LDAP.
@@ -31,17 +76,24 @@ For more features (OAuth/OpenID support, reverse proxy, ...) you can install
other components (KeyCloak, Authelia, ...) using this server as the source of
truth for users, via LDAP.
## Setup
## Installation
### With Docker
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
`jwt_secret` and `ldap_user_pass`, unless you override them with env variables).
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_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:
@@ -52,7 +104,9 @@ volumes:
services:
lldap:
image: nitnelave/lldap
image: nitnelave/lldap:stable
# Change this to the user:group you want.
user: "33:33"
ports:
# For LDAP
- "3890:3890"
@@ -60,10 +114,12 @@ services:
- "17170:17170"
volumes:
- "lldap_data:/data"
# Alternatively, you can mount a local folder
# - "./lldap_data:/data"
environment:
- JWT_SECRET=REPLACE_WITH_RANDOM
- LDAP_USER_PASS=REPLACE_WITH_PASSWORD
- LDAP_BASE_DN=dc=example,dc=com
- 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
@@ -85,11 +141,43 @@ To bring up the server, just run `cargo run`. The default config is in
`lldap_config.toml`, setting environment variables or passing arguments to
`cargo run`.
### Cross-compilation
Docker images are provided for AMD64, ARM64 and ARM/V7.
If you want to cross-compile yourself, you can do so by installing
[`cross`](https://github.com/rust-embedded/cross):
```sh
cargo install cross
cross build --target=armv7-unknown-linux-musleabihf -p lldap --release
./app/build.sh
```
(Replace `armv7-unknown-linux-musleabihf` with the correct Rust target for your
device.)
You can then get the compiled server binary in
`target/armv7-unknown-linux-musleabihf/release/lldap` and the various needed files
(`index.html`, `main.js`, `pkg` folder) in the `app` folder. Copy them to the
Raspberry Pi (or other target), with the folder structure maintained (`app`
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,dc=example,dc=com`.
`cn=admin,ou=people,dc=example,dc=com`.
- The LDAP password is from the configuration (same as to log in to the web
UI).
- The users are all located in `ou=people,` + the base DN, so by default user
@@ -101,7 +189,64 @@ 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_readonly` 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:
- [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)
- [Dolibarr](example_configs/dolibarr.md)
- [Emby](example_configs/emby.md)
- [Gitea](example_configs/gitea.md)
- [Grafana](example_configs/grafana_ldap_config.toml)
- [Jellyfin](example_configs/jellyfin.md)
- [Jisti Meet](example_configs/jitsi_meet.conf)
- [KeyCloak](example_configs/keycloak.md)
- [Matrix](example_configs/matrix_synapse.yml)
- [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)
## Comparisons with other services
### vs OpenLDAP
OpenLDAP is a monster of a service that implements all of LDAP and all of its
extensions, plus some of its own. That said, if you need all that flexibility,
it might be what you need! Note that installation can be a bit painful
(figuring out how to use `slapd`) and people have mixed experiences following
tutorials online. If you don't configure it properly, you might end up storing
passwords in clear, so a breach of your server would reveal all the stored
passwords!
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.
### 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.
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
configure (no messing around with DNS or security policies) and simpler to
use. It also comes conveniently packed in a docker container.
## I can't log in!
@@ -120,91 +265,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.
## Architecture
The server is entirely written in Rust, using [actix](https://actix.rs) for the
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.
* 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
is defined in `schema.graphql`.
* The static frontend files are served by this port too.
Note that secure protocols (LDAPS, HTTPS) are currently not supported. This can
be worked around by using a reverse proxy in front of the server (for the HTTP
API) that wraps/unwraps the HTTPS messages, or only open the service to
localhost or other trusted docker containers (for the LDAP API).
Frontend:
* User management UI.
* Written in Rust compiled to WASM as an SPA with the Yew library.
* Based on components, with a React-like organization.
Data storage:
* The data (users, groups, memberships, active JWTs, ...) is stored in SQL.
* Currently only SQLite is supported (see
https://github.com/launchbadge/sqlx/issues/1225 for what blocks us from
supporting more SQL backends).
### Code organization
* `auth/`: Contains the shared structures needed for authentication, the
interface between front and back-end. In particular, it contains the OPAQUE
structures and the JWT format.
* `app/`: The frontend.
* `src/components`: The elements containing the business and display logic of
the various pages and their components.
* `src/infra`: Various tools and utilities.
* `server/`: The backend.
* `src/domain/`: Domain-specific logic: users, groups, checking passwords...
* `src/infra/`: API, both GraphQL and LDAP
## Authentication
### 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
considered that the LDAP interface requires sending the password to the server,
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).
### JWTs and refresh tokens
When logging in for the first time, users are provided with a refresh token
that gets stored in an HTTP-only cookie, valid for 30 days. They can use this
token to get a JWT to get access to various servers: the JWT lists the groups
the user belongs to. To simplify the setup, there is a single JWT secret that
should be shared between the authentication server and the application servers;
and users don't get a different token per application server
(this could be implemented, we just didn't have any use case yet).
JWTs are only valid for one day: when they expire, a new JWT can be obtained
from the authentication server using the refresh token. If the user stays
logged in, they would only have to type their password once a month.
#### Logout
In order to handle logout correctly, we rely on a blacklist of JWTs. When a
user logs out, their refresh token is removed from the backend, and all of
their currently valid JWTs are added to a blacklist. Incoming requests are
checked against this blacklist (in-memory, faster than calling the database).
Applications that want to use these JWTs should subscribe to be notified of
blacklisted JWTs (TODO: implement the PubSub service and API).
- If it's still not working, join the
[Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
## Contributions

View File

@@ -1,8 +1,8 @@
[package]
name = "lldap_app"
version = "0.1.0"
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
edition = "2018"
version = "0.3.0"
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
edition = "2021"
[dependencies]
anyhow = "1"
@@ -12,7 +12,7 @@ jwt = "0.13"
rand = "0.8"
serde = "1"
serde_json = "1"
validator = "*"
validator = "=0.14"
validator_derive = "*"
wasm-bindgen = "0.2"
yew = "0.18"
@@ -21,6 +21,9 @@ 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 = [

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

@@ -1,15 +1,11 @@
use crate::{
components::select::{Select, SelectOption, SelectOptionProps},
infra::api::HostService,
infra::common_component::{CommonComponent, CommonComponentParts},
};
use anyhow::{Error, Result};
use graphql_client::GraphQLQuery;
use std::collections::HashSet;
use yew::{
prelude::*,
services::{fetch::FetchTask, ConsoleService},
};
use yewtil::NeqAssign;
use yew::prelude::*;
#[derive(GraphQLQuery)]
#[graphql(
@@ -33,14 +29,11 @@ pub struct ListUserNames;
pub type User = list_user_names::ListUserNamesUsers;
pub struct AddGroupMemberComponent {
link: ComponentLink<Self>,
props: Props,
common: CommonComponentParts<Self>,
/// The list of existing users, initially not loaded.
user_list: Option<Vec<User>>,
/// The currently selected user.
selected_user: Option<User>,
// Used to keep the request alive long enough.
task: Option<FetchTask>,
}
pub enum Msg {
@@ -58,58 +51,24 @@ pub struct Props {
pub on_error: Callback<Error>,
}
impl AddGroupMemberComponent {
fn get_user_list(&mut self) {
self.task = HostService::graphql_query::<ListUserNames>(
list_user_names::Variables { filters: None },
self.link.callback(Msg::UserListResponse),
"Error trying to fetch user list",
)
.map_err(|e| {
ConsoleService::log(&e.to_string());
e
})
.ok();
}
fn submit_add_member(&mut self) -> Result<bool> {
let user_id = match self.selected_user.clone() {
None => return Ok(false),
Some(user) => user.id,
};
self.task = HostService::graphql_query::<AddUserToGroup>(
add_user_to_group::Variables {
user: user_id,
group: self.props.group_id,
},
self.link.callback(Msg::AddMemberResponse),
"Error trying to initiate adding the user to a group",
)
.map_err(|e| {
ConsoleService::log(&e.to_string());
e
})
.ok();
Ok(true)
}
impl CommonComponent<AddGroupMemberComponent> for AddGroupMemberComponent {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::UserListResponse(response) => {
self.user_list = Some(response?.users);
self.task = None;
self.common.cancel_task();
}
Msg::SubmitAddMember => return self.submit_add_member(),
Msg::AddMemberResponse(response) => {
response?;
self.task = None;
self.common.cancel_task();
let user = self
.selected_user
.as_ref()
.expect("Could not get selected user")
.clone();
// Remove the user from the dropdown.
self.props.on_user_added_to_group.emit(user);
self.common.on_user_added_to_group.emit(user);
}
Msg::SelectionChanged(option_props) => {
let was_some = self.selected_user.is_some();
@@ -123,8 +82,38 @@ impl AddGroupMemberComponent {
Ok(true)
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl AddGroupMemberComponent {
fn get_user_list(&mut self) {
self.common.call_graphql::<ListUserNames, _>(
list_user_names::Variables { filters: None },
Msg::UserListResponse,
"Error trying to fetch user list",
);
}
fn submit_add_member(&mut self) -> Result<bool> {
let user_id = match self.selected_user.clone() {
None => return Ok(false),
Some(user) => user.id,
};
self.common.call_graphql::<AddUserToGroup, _>(
add_user_to_group::Variables {
user: user_id,
group: self.common.group_id,
},
Msg::AddMemberResponse,
"Error trying to initiate adding the user to a group",
);
Ok(true)
}
fn get_selectable_user_list(&self, user_list: &[User]) -> Vec<User> {
let user_groups = self.props.users.iter().collect::<HashSet<_>>();
let user_groups = self.common.users.iter().collect::<HashSet<_>>();
user_list
.iter()
.filter(|u| !user_groups.contains(u))
@@ -136,32 +125,27 @@ impl AddGroupMemberComponent {
impl Component for AddGroupMemberComponent {
type Message = Msg;
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
let mut res = Self {
link,
props,
common: CommonComponentParts::<Self>::create(props, link),
user_list: None,
selected_user: None,
task: None,
};
res.get_user_list();
res
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
match self.handle_msg(msg) {
Err(e) => {
ConsoleService::error(&e.to_string());
self.props.on_error.emit(e);
self.task = None;
true
}
Ok(b) => b,
}
CommonComponentParts::<Self>::update_and_report_error(
self,
msg,
self.common.on_error.clone(),
)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.props.neq_assign(props)
self.common.change(props)
}
fn view(&self) -> Html {
@@ -176,7 +160,7 @@ impl Component for AddGroupMemberComponent {
html! {
<div class="row">
<div class="col-sm-3">
<Select on_selection_change=self.link.callback(Msg::SelectionChanged)>
<Select on_selection_change=self.common.callback(Msg::SelectionChanged)>
{
to_add_user_list
.into_iter()
@@ -188,8 +172,8 @@ impl Component for AddGroupMemberComponent {
<div class="col-sm-1">
<button
class="btn btn-success"
disabled=self.selected_user.is_none() || self.task.is_some()
onclick=self.link.callback(|_| Msg::SubmitAddMember)>
disabled=self.selected_user.is_none() || self.common.is_task_running()
onclick=self.common.callback(|_| Msg::SubmitAddMember)>
{"Add"}
</button>
</div>

View File

@@ -3,16 +3,12 @@ use crate::{
select::{Select, SelectOption, SelectOptionProps},
user_details::Group,
},
infra::api::HostService,
infra::common_component::{CommonComponent, CommonComponentParts},
};
use anyhow::{Error, Result};
use graphql_client::GraphQLQuery;
use std::collections::HashSet;
use yew::{
prelude::*,
services::{fetch::FetchTask, ConsoleService},
};
use yewtil::NeqAssign;
use yew::prelude::*;
#[derive(GraphQLQuery)]
#[graphql(
@@ -45,14 +41,11 @@ impl From<GroupListGroup> for Group {
}
pub struct AddUserToGroupComponent {
link: ComponentLink<Self>,
props: Props,
common: CommonComponentParts<Self>,
/// The list of existing groups, initially not loaded.
group_list: Option<Vec<Group>>,
/// The currently selected group.
selected_group: Option<Group>,
// Used to keep the request alive long enough.
task: Option<FetchTask>,
}
pub enum Msg {
@@ -70,51 +63,17 @@ pub struct Props {
pub on_error: Callback<Error>,
}
impl AddUserToGroupComponent {
fn get_group_list(&mut self) {
self.task = HostService::graphql_query::<GetGroupList>(
get_group_list::Variables,
self.link.callback(Msg::GroupListResponse),
"Error trying to fetch group list",
)
.map_err(|e| {
ConsoleService::log(&e.to_string());
e
})
.ok();
}
fn submit_add_group(&mut self) -> Result<bool> {
let group_id = match &self.selected_group {
None => return Ok(false),
Some(group) => group.id,
};
self.task = HostService::graphql_query::<AddUserToGroup>(
add_user_to_group::Variables {
user: self.props.username.clone(),
group: group_id,
},
self.link.callback(Msg::AddGroupResponse),
"Error trying to initiate adding the user to a group",
)
.map_err(|e| {
ConsoleService::log(&e.to_string());
e
})
.ok();
Ok(true)
}
impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::GroupListResponse(response) => {
self.group_list = Some(response?.groups.into_iter().map(Into::into).collect());
self.task = None;
self.common.cancel_task();
}
Msg::SubmitAddGroup => return self.submit_add_group(),
Msg::AddGroupResponse(response) => {
response?;
self.task = None;
self.common.cancel_task();
// Adding the user to the group succeeded, we're not in the process of adding a
// group anymore.
let group = self
@@ -123,7 +82,7 @@ impl AddUserToGroupComponent {
.expect("Could not get selected group")
.clone();
// Remove the group from the dropdown.
self.props.on_user_added_to_group.emit(group);
self.common.on_user_added_to_group.emit(group);
}
Msg::SelectionChanged(option_props) => {
let was_some = self.selected_group.is_some();
@@ -137,8 +96,38 @@ impl AddUserToGroupComponent {
Ok(true)
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl AddUserToGroupComponent {
fn get_group_list(&mut self) {
self.common.call_graphql::<GetGroupList, _>(
get_group_list::Variables,
Msg::GroupListResponse,
"Error trying to fetch group list",
);
}
fn submit_add_group(&mut self) -> Result<bool> {
let group_id = match &self.selected_group {
None => return Ok(false),
Some(group) => group.id,
};
self.common.call_graphql::<AddUserToGroup, _>(
add_user_to_group::Variables {
user: self.common.username.clone(),
group: group_id,
},
Msg::AddGroupResponse,
"Error trying to initiate adding the user to a group",
);
Ok(true)
}
fn get_selectable_group_list(&self, group_list: &[Group]) -> Vec<Group> {
let user_groups = self.props.groups.iter().collect::<HashSet<_>>();
let user_groups = self.common.groups.iter().collect::<HashSet<_>>();
group_list
.iter()
.filter(|g| !user_groups.contains(g))
@@ -152,29 +141,24 @@ impl Component for AddUserToGroupComponent {
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
let mut res = Self {
link,
props,
common: CommonComponentParts::<Self>::create(props, link),
group_list: None,
selected_group: None,
task: None,
};
res.get_group_list();
res
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
match self.handle_msg(msg) {
Err(e) => {
ConsoleService::error(&e.to_string());
self.props.on_error.emit(e);
self.task = None;
true
}
Ok(b) => b,
}
CommonComponentParts::<Self>::update_and_report_error(
self,
msg,
self.common.on_error.clone(),
)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.props.neq_assign(props)
self.common.change(props)
}
fn view(&self) -> Html {
@@ -189,7 +173,7 @@ impl Component for AddUserToGroupComponent {
html! {
<div class="row">
<div class="col-sm-3">
<Select on_selection_change=self.link.callback(Msg::SelectionChanged)>
<Select on_selection_change=self.common.callback(Msg::SelectionChanged)>
{
to_add_group_list
.into_iter()
@@ -201,8 +185,8 @@ impl Component for AddUserToGroupComponent {
<div class="col-sm-1">
<button
class="btn btn-success"
disabled=self.selected_group.is_none() || self.task.is_some()
onclick=self.link.callback(|_| Msg::SubmitAddGroup)>
disabled=self.selected_group.is_none() || self.common.is_task_running()
onclick=self.common.callback(|_| Msg::SubmitAddGroup)>
{"Add"}
</button>
</div>

View File

@@ -7,6 +7,8 @@ use crate::{
group_table::GroupTable,
login::LoginForm,
logout::LogoutButton,
reset_password_step1::ResetPasswordStep1Form,
reset_password_step2::ResetPasswordStep2Form,
router::{AppRoute, Link, NavButton},
user_details::UserDetails,
user_table::UserTable,
@@ -83,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
}
@@ -98,46 +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 |switch: AppRoute| {
match switch {
AppRoute::Login => html! {
<LoginForm on_logged_in=link.callback(Msg::Login)/>
},
AppRoute::CreateUser => html! {
<CreateUserForm/>
},
AppRoute::Index | AppRoute::ListUsers => html! {
<div>
<UserTable />
<NavButton classes="btn btn-primary" route=AppRoute::CreateUser>{"Create a user"}</NavButton>
</div>
},
AppRoute::CreateGroup => html! {
<CreateGroupForm/>
},
AppRoute::ListGroups => html! {
<div>
<GroupTable />
<NavButton classes="btn btn-primary" route=AppRoute::CreateGroup>{"Create a group"}</NavButton>
</div>
},
AppRoute::GroupDetails(group_id) => html! {
<GroupDetails group_id=group_id />
},
AppRoute::UserDetails(username) => html! {
<UserDetails username=username.clone() is_admin=is_admin />
},
AppRoute::ChangePassword(username) => html! {
<ChangePasswordForm username=username.clone() is_admin=is_admin />
}
}
})
render = Router::render(move |s| Self::dispatch_route(s, &link, is_admin))
/>
</div>
</div>
{self.view_footer()}
</div>
}
}
@@ -147,7 +117,11 @@ impl App {
fn get_redirect_route() -> Option<AppRoute> {
let route_service = RouteService::<()>::new();
let current_route = route_service.get_path();
if current_route.is_empty() || current_route == "/" || current_route.contains("login") {
if current_route.is_empty()
|| current_route == "/"
|| current_route.contains("login")
|| current_route.contains("reset-password")
{
None
} else {
use yew_router::Switch;
@@ -156,10 +130,15 @@ impl App {
}
fn apply_initial_redirections(&mut self) {
let route_service = RouteService::<()>::new();
let current_route = route_service.get_path();
if current_route.contains("reset-password") {
return;
}
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) => {
@@ -169,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(
@@ -181,6 +160,47 @@ impl App {
}
}
fn dispatch_route(switch: AppRoute, link: &ComponentLink<Self>, is_admin: bool) -> Html {
match switch {
AppRoute::Login => html! {
<LoginForm on_logged_in=link.callback(Msg::Login)/>
},
AppRoute::CreateUser => html! {
<CreateUserForm/>
},
AppRoute::Index | AppRoute::ListUsers => html! {
<div>
<UserTable />
<NavButton classes="btn btn-primary" route=AppRoute::CreateUser>{"Create a user"}</NavButton>
</div>
},
AppRoute::CreateGroup => html! {
<CreateGroupForm/>
},
AppRoute::ListGroups => html! {
<div>
<GroupTable />
<NavButton classes="btn btn-primary" route=AppRoute::CreateGroup>{"Create a group"}</NavButton>
</div>
},
AppRoute::GroupDetails(group_id) => html! {
<GroupDetails group_id=group_id />
},
AppRoute::UserDetails(username) => html! {
<UserDetails username=username is_admin=is_admin />
},
AppRoute::ChangePassword(username) => html! {
<ChangePasswordForm username=username is_admin=is_admin />
},
AppRoute::StartResetPassword => html! {
<ResetPasswordStep1Form />
},
AppRoute::FinishResetPassword(token) => html! {
<ResetPasswordStep2Form token=token />
},
}
}
fn view_banner(&self) -> Html {
html! {
<header class="p-3 mb-4 border-bottom shadow-sm">
@@ -252,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

@@ -1,14 +1,14 @@
use crate::{
components::router::{AppRoute, NavButton},
infra::api::HostService,
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
},
};
use anyhow::{anyhow, bail, Context, Result};
use lldap_auth::*;
use validator_derive::Validate;
use yew::{
prelude::*,
services::{fetch::FetchTask, ConsoleService},
};
use yew::{prelude::*, services::ConsoleService};
use yew_form::Form;
use yew_form_derive::Model;
use yew_router::{
@@ -58,13 +58,9 @@ fn empty_or_long(value: &str) -> Result<(), validator::ValidationError> {
}
pub struct ChangePasswordForm {
link: ComponentLink<Self>,
props: Props,
error: Option<anyhow::Error>,
common: CommonComponentParts<Self>,
form: Form<FormModel>,
opaque_data: OpaqueData,
// Used to keep the request alive long enough.
task: Option<FetchTask>,
route_dispatcher: RouteAgentDispatcher,
}
@@ -83,25 +79,16 @@ pub enum Msg {
RegistrationFinishResponse(Result<()>),
}
impl ChangePasswordForm {
fn call_backend<M, Req, C, Resp>(&mut self, method: M, req: Req, callback: C) -> Result<()>
where
M: Fn(Req, Callback<Resp>) -> Result<FetchTask>,
C: Fn(Resp) -> <Self as Component>::Message + 'static,
{
self.task = Some(method(req, self.link.callback(callback))?);
Ok(())
}
fn handle_message(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::FormUpdate => Ok(true),
Msg::Submit => {
if !self.form.validate() {
bail!("Check the form for errors");
}
if self.props.is_admin {
self.handle_message(Msg::SubmitNewPassword)
if self.common.is_admin {
self.handle_msg(Msg::SubmitNewPassword)
} else {
let old_password = self.form.model().old_password;
if old_password.is_empty() {
@@ -113,10 +100,10 @@ impl ChangePasswordForm {
.context("Could not initialize login")?;
self.opaque_data = OpaqueData::Login(login_start_request.state);
let req = login::ClientLoginStartRequest {
username: self.props.username.clone(),
username: self.common.username.clone(),
login_start_request: login_start_request.message,
};
self.call_backend(
self.common.call_backend(
HostService::login_start,
req,
Msg::AuthenticationStartResponse,
@@ -142,7 +129,7 @@ impl ChangePasswordForm {
}
_ => panic!("Unexpected data in opaque_data field"),
};
self.handle_message(Msg::SubmitNewPassword)
self.handle_msg(Msg::SubmitNewPassword)
}
Msg::SubmitNewPassword => {
let mut rng = rand::rngs::OsRng;
@@ -151,11 +138,11 @@ impl ChangePasswordForm {
opaque::client::registration::start_registration(&new_password, &mut rng)
.context("Could not initiate password change")?;
let req = registration::ClientRegistrationStartRequest {
username: self.props.username.clone(),
username: self.common.username.clone(),
registration_start_request: registration_start_request.message,
};
self.opaque_data = OpaqueData::Registration(registration_start_request.state);
self.call_backend(
self.common.call_backend(
HostService::register_start,
req,
Msg::RegistrationStartResponse,
@@ -178,7 +165,7 @@ impl ChangePasswordForm {
server_data: res.server_data,
registration_upload: registration_finish.message,
};
self.call_backend(
self.common.call_backend(
HostService::register_finish,
req,
Msg::RegistrationFinishResponse,
@@ -189,11 +176,11 @@ impl ChangePasswordForm {
Ok(false)
}
Msg::RegistrationFinishResponse(response) => {
self.task = None;
self.common.cancel_task();
if response.is_ok() {
self.route_dispatcher
.send(RouteRequest::ChangeRoute(Route::from(
AppRoute::UserDetails(self.props.username.clone()),
AppRoute::UserDetails(self.common.username.clone()),
)));
}
response?;
@@ -201,6 +188,10 @@ impl ChangePasswordForm {
}
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for ChangePasswordForm {
@@ -209,35 +200,23 @@ impl Component for ChangePasswordForm {
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
ChangePasswordForm {
link,
props,
error: None,
common: CommonComponentParts::<Self>::create(props, link),
form: yew_form::Form::<FormModel>::new(FormModel::default()),
opaque_data: OpaqueData::None,
task: None,
route_dispatcher: RouteAgentDispatcher::new(),
}
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
self.error = None;
match self.handle_message(msg) {
Err(e) => {
ConsoleService::error(&e.to_string());
self.error = Some(e);
self.task = None;
true
}
Ok(b) => b,
}
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 {
let is_admin = self.props.is_admin;
let is_admin = self.common.is_admin;
type Field = yew_form::Field<FormModel>;
html! {
<>
@@ -257,7 +236,7 @@ impl Component for ChangePasswordForm {
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="current-password"
oninput=self.link.callback(|_| Msg::FormUpdate) />
oninput=self.common.callback(|_| Msg::FormUpdate) />
<div class="invalid-feedback">
{&self.form.field_message("old_password")}
</div>
@@ -277,7 +256,7 @@ impl Component for ChangePasswordForm {
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="new-password"
oninput=self.link.callback(|_| Msg::FormUpdate) />
oninput=self.common.callback(|_| Msg::FormUpdate) />
<div class="invalid-feedback">
{&self.form.field_message("password")}
</div>
@@ -296,7 +275,7 @@ impl Component for ChangePasswordForm {
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="new-password"
oninput=self.link.callback(|_| Msg::FormUpdate) />
oninput=self.common.callback(|_| Msg::FormUpdate) />
<div class="invalid-feedback">
{&self.form.field_message("confirm_password")}
</div>
@@ -306,13 +285,13 @@ impl Component for ChangePasswordForm {
<button
class="btn btn-primary col-sm-1 col-form-label"
type="submit"
disabled=self.task.is_some()
onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
disabled=self.common.is_task_running()
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
{"Submit"}
</button>
</div>
</form>
{ if let Some(e) = &self.error {
{ if let Some(e) = &self.common.error {
html! {
<div class="alert alert-danger">
{e.to_string() }
@@ -323,7 +302,7 @@ impl Component for ChangePasswordForm {
<div>
<NavButton
classes="btn btn-primary"
route=AppRoute::UserDetails(self.props.username.clone())>
route=AppRoute::UserDetails(self.common.username.clone())>
{"Back"}
</NavButton>
</div>

View File

@@ -1,9 +1,12 @@
use crate::{components::router::AppRoute, infra::api::HostService};
use crate::{
components::router::AppRoute,
infra::common_component::{CommonComponent, CommonComponentParts},
};
use anyhow::{bail, Result};
use graphql_client::GraphQLQuery;
use validator_derive::Validate;
use yew::prelude::*;
use yew::services::{fetch::FetchTask, ConsoleService};
use yew::services::ConsoleService;
use yew_form_derive::Model;
use yew_router::{
agent::{RouteAgentDispatcher, RouteRequest},
@@ -20,12 +23,9 @@ use yew_router::{
pub struct CreateGroup;
pub struct CreateGroupForm {
link: ComponentLink<Self>,
common: CommonComponentParts<Self>,
route_dispatcher: RouteAgentDispatcher,
form: yew_form::Form<CreateGroupModel>,
error: Option<anyhow::Error>,
// Used to keep the request alive long enough.
task: Option<FetchTask>,
}
#[derive(Model, Validate, PartialEq, Clone, Default)]
@@ -40,7 +40,7 @@ pub enum Msg {
CreateGroupResponse(Result<create_group::ResponseData>),
}
impl CreateGroupForm {
impl CommonComponent<CreateGroupForm> for CreateGroupForm {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::Update => Ok(true),
@@ -52,11 +52,11 @@ impl CreateGroupForm {
let req = create_group::Variables {
name: model.groupname,
};
self.task = Some(HostService::graphql_query::<CreateGroup>(
self.common.call_graphql::<CreateGroup, _>(
req,
self.link.callback(Msg::CreateGroupResponse),
Msg::CreateGroupResponse,
"Error trying to create group",
)?);
);
Ok(true)
}
Msg::CreateGroupResponse(response) => {
@@ -70,37 +70,30 @@ impl CreateGroupForm {
}
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for CreateGroupForm {
type Message = Msg;
type Properties = ();
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
Self {
link,
common: CommonComponentParts::<Self>::create(props, link),
route_dispatcher: RouteAgentDispatcher::new(),
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
error: None,
task: None,
}
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
self.error = None;
match self.handle_msg(msg) {
Err(e) => {
ConsoleService::error(&e.to_string());
self.error = Some(e);
self.task = None;
true
}
Ok(b) => b,
}
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 {
@@ -124,7 +117,7 @@ impl Component for CreateGroupForm {
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="groupname"
oninput=self.link.callback(|_| Msg::Update) />
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("groupname")}
</div>
@@ -134,13 +127,13 @@ impl Component for CreateGroupForm {
<button
class="btn btn-primary col-auto col-form-label"
type="submit"
disabled=self.task.is_some()
onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
disabled=self.common.is_task_running()
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
{"Submit"}
</button>
</div>
</form>
{ if let Some(e) = &self.error {
{ if let Some(e) = &self.common.error {
html! {
<div class="alert alert-danger">
{e.to_string() }

View File

@@ -1,10 +1,16 @@
use crate::{components::router::AppRoute, infra::api::HostService};
use crate::{
components::router::AppRoute,
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
},
};
use anyhow::{bail, Context, Result};
use graphql_client::GraphQLQuery;
use lldap_auth::{opaque, registration};
use validator_derive::Validate;
use yew::prelude::*;
use yew::services::{fetch::FetchTask, ConsoleService};
use yew::services::ConsoleService;
use yew_form_derive::Model;
use yew_router::{
agent::{RouteAgentDispatcher, RouteRequest},
@@ -21,12 +27,9 @@ use yew_router::{
pub struct CreateUser;
pub struct CreateUserForm {
link: ComponentLink<Self>,
common: CommonComponentParts<Self>,
route_dispatcher: RouteAgentDispatcher,
form: yew_form::Form<CreateUserModel>,
error: Option<anyhow::Error>,
// Used to keep the request alive long enough.
task: Option<FetchTask>,
}
#[derive(Model, Validate, PartialEq, Clone, Default)]
@@ -70,7 +73,7 @@ pub enum Msg {
RegistrationFinishResponse(Result<()>),
}
impl CreateUserForm {
impl CommonComponent<CreateUserForm> for CreateUserForm {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::Update => Ok(true),
@@ -89,11 +92,11 @@ impl CreateUserForm {
lastName: to_option(model.last_name),
},
};
self.task = Some(HostService::graphql_query::<CreateUser>(
self.common.call_graphql::<CreateUser, _>(
req,
self.link.callback(Msg::CreateUserResponse),
Msg::CreateUserResponse,
"Error trying to create user",
)?);
);
Ok(true)
}
Msg::CreateUserResponse(r) => {
@@ -118,14 +121,11 @@ impl CreateUserForm {
username: user_id,
registration_start_request: message,
};
self.task = Some(
HostService::register_start(
req,
self.link
.callback_once(move |r| Msg::RegistrationStartResponse((state, r))),
)
.context("Error trying to create user")?,
);
self.common
.call_backend(HostService::register_start, req, move |r| {
Msg::RegistrationStartResponse((state, r))
})
.context("Error trying to create user")?;
} else {
self.update(Msg::SuccessfulCreation);
}
@@ -143,13 +143,13 @@ impl CreateUserForm {
server_data: response.server_data,
registration_upload: registration_upload.message,
};
self.task = Some(
HostService::register_finish(
self.common
.call_backend(
HostService::register_finish,
req,
self.link.callback(Msg::RegistrationFinishResponse),
Msg::RegistrationFinishResponse,
)
.context("Error trying to register user")?,
);
.context("Error trying to register user")?;
Ok(false)
}
Msg::RegistrationFinishResponse(response) => {
@@ -163,37 +163,30 @@ impl CreateUserForm {
}
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for CreateUserForm {
type Message = Msg;
type Properties = ();
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
Self {
link,
common: CommonComponentParts::<Self>::create(props, link),
route_dispatcher: RouteAgentDispatcher::new(),
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
error: None,
task: None,
}
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
self.error = None;
match self.handle_msg(msg) {
Err(e) => {
ConsoleService::error(&e.to_string());
self.error = Some(e);
self.task = None;
true
}
Ok(b) => b,
}
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 {
@@ -217,7 +210,7 @@ impl Component for CreateUserForm {
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="username"
oninput=self.link.callback(|_| Msg::Update) />
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("username")}
</div>
@@ -237,7 +230,7 @@ impl Component for CreateUserForm {
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="email"
oninput=self.link.callback(|_| Msg::Update) />
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("email")}
</div>
@@ -256,7 +249,7 @@ impl Component for CreateUserForm {
class_invalid="is-invalid has-error"
class_valid="has-success"
field_name="display_name"
oninput=self.link.callback(|_| Msg::Update) />
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("display_name")}
</div>
@@ -275,7 +268,7 @@ impl Component for CreateUserForm {
class_invalid="is-invalid has-error"
class_valid="has-success"
field_name="first_name"
oninput=self.link.callback(|_| Msg::Update) />
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("first_name")}
</div>
@@ -294,7 +287,7 @@ impl Component for CreateUserForm {
class_invalid="is-invalid has-error"
class_valid="has-success"
field_name="last_name"
oninput=self.link.callback(|_| Msg::Update) />
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("last_name")}
</div>
@@ -314,7 +307,7 @@ impl Component for CreateUserForm {
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="new-password"
oninput=self.link.callback(|_| Msg::Update) />
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("password")}
</div>
@@ -334,7 +327,7 @@ impl Component for CreateUserForm {
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="new-password"
oninput=self.link.callback(|_| Msg::Update) />
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("confirm_password")}
</div>
@@ -343,14 +336,14 @@ impl Component for CreateUserForm {
<div class="form-group row justify-content-center">
<button
class="btn btn-primary col-auto col-form-label mt-4"
disabled=self.task.is_some()
disabled=self.common.is_task_running()
type="submit"
onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
{"Submit"}
</button>
</div>
</form>
{ if let Some(e) = &self.error {
{ if let Some(e) = &self.common.error {
html! {
<div class="alert alert-danger">
{e.to_string() }

View File

@@ -1,12 +1,13 @@
use crate::{
components::group_table::Group,
infra::{api::HostService, modal::Modal},
infra::{
common_component::{CommonComponent, CommonComponentParts},
modal::Modal,
},
};
use anyhow::{Error, Result};
use graphql_client::GraphQLQuery;
use yew::prelude::*;
use yew::services::fetch::FetchTask;
use yewtil::NeqAssign;
#[derive(GraphQLQuery)]
#[graphql(
@@ -18,11 +19,9 @@ use yewtil::NeqAssign;
pub struct DeleteGroupQuery;
pub struct DeleteGroup {
link: ComponentLink<Self>,
props: DeleteGroupProps,
common: CommonComponentParts<Self>,
node_ref: NodeRef,
modal: Option<Modal>,
task: Option<FetchTask>,
}
#[derive(yew::Properties, Clone, PartialEq, Debug)]
@@ -39,17 +38,51 @@ pub enum Msg {
DeleteGroupResponse(Result<delete_group_query::ResponseData>),
}
impl CommonComponent<DeleteGroup> for DeleteGroup {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::ClickedDeleteGroup => {
self.modal.as_ref().expect("modal not initialized").show();
}
Msg::ConfirmDeleteGroup => {
self.update(Msg::DismissModal);
self.common.call_graphql::<DeleteGroupQuery, _>(
delete_group_query::Variables {
group_id: self.common.group.id,
},
Msg::DeleteGroupResponse,
"Error trying to delete group",
);
}
Msg::DismissModal => {
self.modal.as_ref().expect("modal not initialized").hide();
}
Msg::DeleteGroupResponse(response) => {
self.common.cancel_task();
response?;
self.common
.props
.on_group_deleted
.emit(self.common.group.id);
}
}
Ok(true)
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for DeleteGroup {
type Message = Msg;
type Properties = DeleteGroupProps;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
Self {
link,
props,
common: CommonComponentParts::<Self>::create(props, link),
node_ref: NodeRef::default(),
modal: None,
task: None,
}
}
@@ -64,39 +97,15 @@ impl Component for DeleteGroup {
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
match msg {
Msg::ClickedDeleteGroup => {
self.modal.as_ref().expect("modal not initialized").show();
}
Msg::ConfirmDeleteGroup => {
self.update(Msg::DismissModal);
self.task = HostService::graphql_query::<DeleteGroupQuery>(
delete_group_query::Variables {
group_id: self.props.group.id,
},
self.link.callback(Msg::DeleteGroupResponse),
"Error trying to delete group",
)
.map_err(|e| self.props.on_error.emit(e))
.ok();
}
Msg::DismissModal => {
self.modal.as_ref().expect("modal not initialized").hide();
}
Msg::DeleteGroupResponse(response) => {
self.task = None;
if let Err(e) = response {
self.props.on_error.emit(e);
} else {
self.props.on_group_deleted.emit(self.props.group.id);
}
}
}
true
CommonComponentParts::<Self>::update_and_report_error(
self,
msg,
self.common.on_error.clone(),
)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.props.neq_assign(props)
self.common.change(props)
}
fn view(&self) -> Html {
@@ -104,8 +113,8 @@ impl Component for DeleteGroup {
<>
<button
class="btn btn-danger"
disabled=self.task.is_some()
onclick=self.link.callback(|_| Msg::ClickedDeleteGroup)>
disabled=self.common.is_task_running()
onclick=self.common.callback(|_| Msg::ClickedDeleteGroup)>
<i class="bi-x-circle-fill" aria-label="Delete group" />
</button>
{self.show_modal()}
@@ -119,7 +128,7 @@ impl DeleteGroup {
html! {
<div
class="modal fade"
id="deleteGroupModal".to_string() + &self.props.group.id.to_string()
id="deleteGroupModal".to_string() + &self.common.group.id.to_string()
tabindex="-1"
aria-labelledby="deleteGroupModalLabel"
aria-hidden="true"
@@ -132,24 +141,24 @@ impl DeleteGroup {
type="button"
class="btn-close"
aria-label="Close"
onclick=self.link.callback(|_| Msg::DismissModal) />
onclick=self.common.callback(|_| Msg::DismissModal) />
</div>
<div class="modal-body">
<span>
{"Are you sure you want to delete group "}
<b>{&self.props.group.display_name}</b>{"?"}
<b>{&self.common.group.display_name}</b>{"?"}
</span>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
onclick=self.link.callback(|_| Msg::DismissModal)>
onclick=self.common.callback(|_| Msg::DismissModal)>
{"Cancel"}
</button>
<button
type="button"
onclick=self.link.callback(|_| Msg::ConfirmDeleteGroup)
onclick=self.common.callback(|_| Msg::ConfirmDeleteGroup)
class="btn btn-danger">{"Yes, I'm sure"}</button>
</div>
</div>

View File

@@ -1,9 +1,10 @@
use crate::infra::{api::HostService, modal::Modal};
use crate::infra::{
common_component::{CommonComponent, CommonComponentParts},
modal::Modal,
};
use anyhow::{Error, Result};
use graphql_client::GraphQLQuery;
use yew::prelude::*;
use yew::services::fetch::FetchTask;
use yewtil::NeqAssign;
#[derive(GraphQLQuery)]
#[graphql(
@@ -15,11 +16,9 @@ use yewtil::NeqAssign;
pub struct DeleteUserQuery;
pub struct DeleteUser {
link: ComponentLink<Self>,
props: DeleteUserProps,
common: CommonComponentParts<Self>,
node_ref: NodeRef,
modal: Option<Modal>,
task: Option<FetchTask>,
}
#[derive(yew::Properties, Clone, PartialEq, Debug)]
@@ -36,17 +35,51 @@ pub enum Msg {
DeleteUserResponse(Result<delete_user_query::ResponseData>),
}
impl CommonComponent<DeleteUser> for DeleteUser {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::ClickedDeleteUser => {
self.modal.as_ref().expect("modal not initialized").show();
}
Msg::ConfirmDeleteUser => {
self.update(Msg::DismissModal);
self.common.call_graphql::<DeleteUserQuery, _>(
delete_user_query::Variables {
user: self.common.username.clone(),
},
Msg::DeleteUserResponse,
"Error trying to delete user",
);
}
Msg::DismissModal => {
self.modal.as_ref().expect("modal not initialized").hide();
}
Msg::DeleteUserResponse(response) => {
self.common.cancel_task();
response?;
self.common
.props
.on_user_deleted
.emit(self.common.username.clone());
}
}
Ok(true)
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for DeleteUser {
type Message = Msg;
type Properties = DeleteUserProps;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
Self {
link,
props,
common: CommonComponentParts::<Self>::create(props, link),
node_ref: NodeRef::default(),
modal: None,
task: None,
}
}
@@ -61,39 +94,15 @@ impl Component for DeleteUser {
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
match msg {
Msg::ClickedDeleteUser => {
self.modal.as_ref().expect("modal not initialized").show();
}
Msg::ConfirmDeleteUser => {
self.update(Msg::DismissModal);
self.task = HostService::graphql_query::<DeleteUserQuery>(
delete_user_query::Variables {
user: self.props.username.clone(),
},
self.link.callback(Msg::DeleteUserResponse),
"Error trying to delete user",
)
.map_err(|e| self.props.on_error.emit(e))
.ok();
}
Msg::DismissModal => {
self.modal.as_ref().expect("modal not initialized").hide();
}
Msg::DeleteUserResponse(response) => {
self.task = None;
if let Err(e) = response {
self.props.on_error.emit(e);
} else {
self.props.on_user_deleted.emit(self.props.username.clone());
}
}
}
true
CommonComponentParts::<Self>::update_and_report_error(
self,
msg,
self.common.on_error.clone(),
)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.props.neq_assign(props)
self.common.change(props)
}
fn view(&self) -> Html {
@@ -101,8 +110,8 @@ impl Component for DeleteUser {
<>
<button
class="btn btn-danger"
disabled=self.task.is_some()
onclick=self.link.callback(|_| Msg::ClickedDeleteUser)>
disabled=self.common.is_task_running()
onclick=self.common.callback(|_| Msg::ClickedDeleteUser)>
<i class="bi-x-circle-fill" aria-label="Delete user" />
</button>
{self.show_modal()}
@@ -116,7 +125,7 @@ impl DeleteUser {
html! {
<div
class="modal fade"
id="deleteUserModal".to_string() + &self.props.username
id="deleteUserModal".to_string() + &self.common.username
tabindex="-1"
//role="dialog"
aria-labelledby="deleteUserModalLabel"
@@ -130,24 +139,24 @@ impl DeleteUser {
type="button"
class="btn-close"
aria-label="Close"
onclick=self.link.callback(|_| Msg::DismissModal) />
onclick=self.common.callback(|_| Msg::DismissModal) />
</div>
<div class="modal-body">
<span>
{"Are you sure you want to delete user "}
<b>{&self.props.username}</b>{"?"}
<b>{&self.common.username}</b>{"?"}
</span>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
onclick=self.link.callback(|_| Msg::DismissModal)>
onclick=self.common.callback(|_| Msg::DismissModal)>
{"Cancel"}
</button>
<button
type="button"
onclick=self.link.callback(|_| Msg::ConfirmDeleteUser)
onclick=self.common.callback(|_| Msg::ConfirmDeleteUser)
class="btn btn-danger">{"Yes, I'm sure"}</button>
</div>
</div>

View File

@@ -4,14 +4,11 @@ use crate::{
remove_user_from_group::RemoveUserFromGroupComponent,
router::{AppRoute, Link},
},
infra::api::HostService,
infra::common_component::{CommonComponent, CommonComponentParts},
};
use anyhow::{bail, Error, Result};
use graphql_client::GraphQLQuery;
use yew::{
prelude::*,
services::{fetch::FetchTask, ConsoleService},
};
use yew::prelude::*;
#[derive(GraphQLQuery)]
#[graphql(
@@ -27,15 +24,10 @@ pub type User = get_group_details::GetGroupDetailsGroupUsers;
pub type AddGroupMemberUser = add_group_member::User;
pub struct GroupDetails {
link: ComponentLink<Self>,
props: Props,
common: CommonComponentParts<Self>,
/// The group info. If none, the error is in `error`. If `error` is None, then we haven't
/// received the server response yet.
group: Option<Group>,
/// Error message displayed to the user.
error: Option<Error>,
// Used to keep the request alive long enough.
_task: Option<FetchTask>,
}
/// State machine describing the possible transitions of the component state.
@@ -55,45 +47,13 @@ pub struct Props {
impl GroupDetails {
fn get_group_details(&mut self) {
self._task = HostService::graphql_query::<GetGroupDetails>(
self.common.call_graphql::<GetGroupDetails, _>(
get_group_details::Variables {
id: self.props.group_id,
id: self.common.group_id,
},
self.link.callback(Msg::GroupDetailsResponse),
Msg::GroupDetailsResponse,
"Error trying to fetch group details",
)
.map_err(|e| {
ConsoleService::log(&e.to_string());
e
})
.ok();
}
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::GroupDetailsResponse(response) => match response {
Ok(group) => self.group = Some(group.group),
Err(e) => {
self.group = None;
bail!("Error getting user details: {}", e);
}
},
Msg::OnError(e) => return Err(e),
Msg::OnUserAddedToGroup(user) => {
self.group.as_mut().unwrap().users.push(User {
id: user.id,
display_name: user.display_name,
});
}
Msg::OnUserRemovedFromGroup((user_id, _)) => {
self.group
.as_mut()
.unwrap()
.users
.retain(|u| u.id != user_id);
}
}
Ok(true)
);
}
fn view_messages(&self, error: &Option<Error>) -> Html {
@@ -124,8 +84,8 @@ impl GroupDetails {
<RemoveUserFromGroupComponent
username=user_id
group_id=g.id
on_user_removed_from_group=self.link.callback(Msg::OnUserRemovedFromGroup)
on_error=self.link.callback(Msg::OnError)/>
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
on_error=self.common.callback(Msg::OnError)/>
</td>
</tr>
}
@@ -174,46 +134,68 @@ impl GroupDetails {
<AddGroupMemberComponent
group_id=g.id
users=users
on_error=self.link.callback(Msg::OnError)
on_user_added_to_group=self.link.callback(Msg::OnUserAddedToGroup)/>
on_error=self.common.callback(Msg::OnError)
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
}
}
}
impl CommonComponent<GroupDetails> for GroupDetails {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::GroupDetailsResponse(response) => match response {
Ok(group) => self.group = Some(group.group),
Err(e) => {
self.group = None;
bail!("Error getting user details: {}", e);
}
},
Msg::OnError(e) => return Err(e),
Msg::OnUserAddedToGroup(user) => {
self.group.as_mut().unwrap().users.push(User {
id: user.id,
display_name: user.display_name,
});
}
Msg::OnUserRemovedFromGroup((user_id, _)) => {
self.group
.as_mut()
.unwrap()
.users
.retain(|u| u.id != user_id);
}
}
Ok(true)
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for GroupDetails {
type Message = Msg;
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
let mut table = Self {
link,
props,
_task: None,
common: CommonComponentParts::<Self>::create(props, link),
group: None,
error: None,
};
table.get_group_details();
table
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
self.error = None;
match self.handle_msg(msg) {
Err(e) => {
ConsoleService::error(&e.to_string());
self.error = Some(e);
true
}
Ok(b) => b,
}
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 {
match (&self.group, &self.error) {
match (&self.group, &self.common.error) {
(None, None) => html! {{"Loading..."}},
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
(Some(u), error) => {

View File

@@ -3,12 +3,11 @@ use crate::{
delete_group::DeleteGroup,
router::{AppRoute, Link},
},
infra::api::HostService,
infra::common_component::{CommonComponent, CommonComponentParts},
};
use anyhow::{Error, Result};
use graphql_client::GraphQLQuery;
use yew::prelude::*;
use yew::services::{fetch::FetchTask, ConsoleService};
#[derive(GraphQLQuery)]
#[graphql(
@@ -24,11 +23,8 @@ use get_group_list::ResponseData;
pub type Group = get_group_list::GetGroupListGroups;
pub struct GroupTable {
link: ComponentLink<Self>,
common: CommonComponentParts<Self>,
groups: Option<Vec<Group>>,
error: Option<Error>,
// Used to keep the request alive long enough.
_task: Option<FetchTask>,
}
pub enum Msg {
@@ -37,63 +33,7 @@ pub enum Msg {
OnError(Error),
}
impl GroupTable {
fn get_groups(&mut self) {
self._task = HostService::graphql_query::<GetGroupList>(
get_group_list::Variables {},
self.link.callback(Msg::ListGroupsResponse),
"Error trying to fetch groups",
)
.map_err(|e| {
ConsoleService::log(&e.to_string());
e
})
.ok();
}
}
impl Component for GroupTable {
type Message = Msg;
type Properties = ();
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
let mut table = GroupTable {
link,
_task: None,
groups: None,
error: None,
};
table.get_groups();
table
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
self.error = None;
match self.handle_msg(msg) {
Err(e) => {
ConsoleService::error(&e.to_string());
self.error = Some(e);
true
}
Ok(b) => b,
}
}
fn change(&mut self, _: Self::Properties) -> ShouldRender {
false
}
fn view(&self) -> Html {
html! {
<div>
{self.view_groups()}
{self.view_errors()}
</div>
}
}
}
impl GroupTable {
impl CommonComponent<GroupTable> for GroupTable {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::ListGroupsResponse(groups) => {
@@ -109,6 +49,47 @@ impl GroupTable {
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for GroupTable {
type Message = Msg;
type Properties = ();
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
let mut table = GroupTable {
common: CommonComponentParts::<Self>::create(props, link),
groups: None,
};
table.common.call_graphql::<GetGroupList, _>(
get_group_list::Variables {},
Msg::ListGroupsResponse,
"Error trying to fetch groups",
);
table
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
CommonComponentParts::<Self>::update(self, msg)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
html! {
<div>
{self.view_groups()}
{self.view_errors()}
</div>
}
}
}
impl GroupTable {
fn view_groups(&self) -> Html {
let make_table = |groups: &Vec<Group>| {
html! {
@@ -144,15 +125,15 @@ impl GroupTable {
<td>
<DeleteGroup
group=group.clone()
on_group_deleted=self.link.callback(Msg::OnGroupDeleted)
on_error=self.link.callback(Msg::OnError)/>
on_group_deleted=self.common.callback(Msg::OnGroupDeleted)
on_error=self.common.callback(Msg::OnError)/>
</td>
</tr>
}
}
fn view_errors(&self) -> Html {
match &self.error {
match &self.common.error {
None => html! {},
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>},
}

View File

@@ -1,21 +1,21 @@
use crate::infra::api::HostService;
use crate::{
components::router::{AppRoute, NavButton},
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
},
};
use anyhow::{anyhow, bail, Context, Result};
use lldap_auth::*;
use validator_derive::Validate;
use yew::{
prelude::*,
services::{fetch::FetchTask, ConsoleService},
};
use yew::{prelude::*, services::ConsoleService};
use yew_form::Form;
use yew_form_derive::Model;
pub struct LoginForm {
link: ComponentLink<Self>,
on_logged_in: Callback<(String, bool)>,
error: Option<anyhow::Error>,
common: CommonComponentParts<Self>,
form: Form<FormModel>,
// Used to keep the request alive long enough.
task: Option<FetchTask>,
refreshing: bool,
}
/// The fields of the form, with the constraints.
@@ -35,6 +35,7 @@ pub struct Props {
pub enum Msg {
Update,
Submit,
AuthenticationRefreshResponse(Result<(String, bool)>),
AuthenticationStartResponse(
(
opaque::client::login::ClientLogin,
@@ -44,8 +45,8 @@ pub enum Msg {
AuthenticationFinishResponse(Result<(String, bool)>),
}
impl LoginForm {
fn handle_message(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
impl CommonComponent<LoginForm> for LoginForm {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::Update => Ok(true),
Msg::Submit => {
@@ -61,11 +62,10 @@ impl LoginForm {
username,
login_start_request: message,
};
self.task = Some(HostService::login_start(
req,
self.link
.callback_once(move |r| Msg::AuthenticationStartResponse((state, r))),
)?);
self.common
.call_backend(HostService::login_start, req, move |r| {
Msg::AuthenticationStartResponse((state, r))
})?;
Ok(true)
}
Msg::AuthenticationStartResponse((login_start, res)) => {
@@ -77,7 +77,8 @@ impl LoginForm {
// Common error, we want to print a full error to the console but only a
// simple one to the user.
ConsoleService::error(&format!("Invalid username or password: {}", e));
self.error = Some(anyhow!("Invalid username or password"));
self.common.error = Some(anyhow!("Invalid username or password"));
self.common.cancel_task();
return Ok(true);
}
Ok(l) => l,
@@ -86,20 +87,34 @@ impl LoginForm {
server_data: res.server_data,
credential_finalization: login_finish.message,
};
self.task = Some(HostService::login_finish(
self.common.call_backend(
HostService::login_finish,
req,
self.link.callback_once(Msg::AuthenticationFinishResponse),
)?);
Msg::AuthenticationFinishResponse,
)?;
Ok(false)
}
Msg::AuthenticationFinishResponse(user_info) => {
self.task = None;
self.on_logged_in
self.common.cancel_task();
self.common
.on_logged_in
.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)
}
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for LoginForm {
@@ -107,85 +122,96 @@ impl Component for LoginForm {
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
LoginForm {
link,
on_logged_in: props.on_logged_in,
error: None,
let mut app = LoginForm {
common: CommonComponentParts::<Self>::create(props, link),
form: Form::<FormModel>::new(FormModel::default()),
task: None,
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 {
self.error = None;
match self.handle_message(msg) {
Err(e) => {
ConsoleService::error(&e.to_string());
self.error = Some(e);
self.task = None;
true
}
Ok(b) => b,
}
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.link.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">
<button
type="submit"
class="btn btn-primary"
disabled=self.task.is_some()
onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
{"Login"}
</button>
</div>
<div class="form-group">
{ if let Some(e) = &self.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

@@ -1,13 +1,13 @@
use crate::infra::{api::HostService, cookies::delete_cookie};
use crate::infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
cookies::delete_cookie,
};
use anyhow::Result;
use yew::prelude::*;
use yew::services::{fetch::FetchTask, ConsoleService};
pub struct LogoutButton {
link: ComponentLink<Self>,
on_logged_out: Callback<()>,
// Used to keep the request alive long enough.
_task: Option<FetchTask>,
common: CommonComponentParts<Self>,
}
#[derive(Clone, PartialEq, Properties)]
@@ -20,54 +20,50 @@ pub enum Msg {
LogoutCompleted(Result<()>),
}
impl CommonComponent<LogoutButton> for LogoutButton {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::LogoutRequested => {
self.common
.call_backend(HostService::logout, (), Msg::LogoutCompleted)?;
}
Msg::LogoutCompleted(res) => {
res?;
delete_cookie("user_id")?;
self.common.on_logged_out.emit(());
}
}
Ok(false)
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for LogoutButton {
type Message = Msg;
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
LogoutButton {
link,
on_logged_out: props.on_logged_out,
_task: None,
common: CommonComponentParts::<Self>::create(props, link),
}
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
match msg {
Msg::LogoutRequested => {
match HostService::logout(self.link.callback(Msg::LogoutCompleted)) {
Ok(task) => self._task = Some(task),
Err(e) => ConsoleService::error(&e.to_string()),
};
false
}
Msg::LogoutCompleted(res) => {
if let Err(e) = res {
ConsoleService::error(&e.to_string());
}
match delete_cookie("user_id") {
Err(e) => {
ConsoleService::error(&e.to_string());
false
}
Ok(()) => {
self.on_logged_out.emit(());
true
}
}
}
}
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 {
html! {
<button
class="dropdown-item"
onclick=self.link.callback(|_| Msg::LogoutRequested)>
onclick=self.common.callback(|_| Msg::LogoutRequested)>
{"Logout"}
</button>
}

View File

@@ -11,6 +11,8 @@ pub mod group_table;
pub mod login;
pub mod logout;
pub mod remove_user_from_group;
pub mod reset_password_step1;
pub mod reset_password_step2;
pub mod router;
pub mod select;
pub mod user_details;

View File

@@ -1,10 +1,7 @@
use crate::infra::api::HostService;
use crate::infra::common_component::{CommonComponent, CommonComponentParts};
use anyhow::{Error, Result};
use graphql_client::GraphQLQuery;
use yew::{
prelude::*,
services::{fetch::FetchTask, ConsoleService},
};
use yew::prelude::*;
#[derive(GraphQLQuery)]
#[graphql(
@@ -17,10 +14,7 @@ use yew::{
pub struct RemoveUserFromGroup;
pub struct RemoveUserFromGroupComponent {
link: ComponentLink<Self>,
props: Props,
// Used to keep the request alive long enough.
task: Option<FetchTask>,
common: CommonComponentParts<Self>,
}
#[derive(yew::Properties, Clone, PartialEq)]
@@ -36,38 +30,37 @@ pub enum Msg {
RemoveGroupResponse(Result<remove_user_from_group::ResponseData>),
}
impl RemoveUserFromGroupComponent {
fn submit_remove_group(&mut self) -> Result<bool> {
let group = self.props.group_id;
self.task = HostService::graphql_query::<RemoveUserFromGroup>(
remove_user_from_group::Variables {
user: self.props.username.clone(),
group,
},
self.link.callback(Msg::RemoveGroupResponse),
"Error trying to initiate removing the user from a group",
)
.map_err(|e| {
ConsoleService::log(&e.to_string());
e
})
.ok();
Ok(true)
}
impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupComponent {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::SubmitRemoveGroup => return self.submit_remove_group(),
Msg::SubmitRemoveGroup => self.submit_remove_group(),
Msg::RemoveGroupResponse(response) => {
response?;
self.task = None;
self.props
self.common.cancel_task();
self.common
.on_user_removed_from_group
.emit((self.props.username.clone(), self.props.group_id));
.emit((self.common.username.clone(), self.common.group_id));
}
}
Ok(true)
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl RemoveUserFromGroupComponent {
fn submit_remove_group(&mut self) {
self.common.call_graphql::<RemoveUserFromGroup, _>(
remove_user_from_group::Variables {
user: self.common.username.clone(),
group: self.common.group_id,
},
Msg::RemoveGroupResponse,
"Error trying to initiate removing the user from a group",
);
}
}
impl Component for RemoveUserFromGroupComponent {
@@ -76,33 +69,28 @@ impl Component for RemoveUserFromGroupComponent {
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
Self {
link,
props,
task: None,
common: CommonComponentParts::<Self>::create(props, link),
}
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
match self.handle_msg(msg) {
Err(e) => {
self.task = None;
self.props.on_error.emit(e);
true
}
Ok(b) => b,
}
CommonComponentParts::<Self>::update_and_report_error(
self,
msg,
self.common.on_error.clone(),
)
}
fn change(&mut self, _: Self::Properties) -> ShouldRender {
false
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
html! {
<button
class="btn btn-danger"
disabled=self.task.is_some()
onclick=self.link.callback(|_| Msg::SubmitRemoveGroup)>
disabled=self.common.is_task_running()
onclick=self.common.callback(|_| Msg::SubmitRemoveGroup)>
<i class="bi-x-circle-fill" aria-label="Remove user from group" />
</button>
}

View File

@@ -0,0 +1,140 @@
use crate::{
components::router::{AppRoute, NavButton},
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
},
};
use anyhow::{bail, Result};
use validator_derive::Validate;
use yew::prelude::*;
use yew_form::Form;
use yew_form_derive::Model;
pub struct ResetPasswordStep1Form {
common: CommonComponentParts<Self>,
form: Form<FormModel>,
just_succeeded: bool,
}
/// The fields of the form, with the constraints.
#[derive(Model, Validate, PartialEq, Clone, Default)]
pub struct FormModel {
#[validate(length(min = 1, message = "Missing username"))]
username: String,
}
pub enum Msg {
Update,
Submit,
PasswordResetResponse(Result<()>),
}
impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::Update => Ok(true),
Msg::Submit => {
if !self.form.validate() {
bail!("Check the form for errors");
}
let FormModel { username } = self.form.model();
self.common.call_backend(
HostService::reset_password_step1,
&username,
Msg::PasswordResetResponse,
)?;
Ok(true)
}
Msg::PasswordResetResponse(response) => {
response?;
self.just_succeeded = true;
Ok(true)
}
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for ResetPasswordStep1Form {
type Message = Msg;
type Properties = ();
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
ResetPasswordStep1Form {
common: CommonComponentParts::<Self>::create(props, link),
form: Form::<FormModel>::new(FormModel::default()),
just_succeeded: false,
}
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
self.just_succeeded = false;
CommonComponentParts::<Self>::update(self, msg)
}
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>
</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>
{ if self.just_succeeded {
html! {
{"A reset token has been sent to your email."}
}
} else {
html! {
<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})>
{"Reset password"}
</button>
<NavButton
classes="btn-link btn"
disabled=self.common.is_task_running()
route=AppRoute::Login>
{"Back"}
</NavButton>
</div>
}
}}
<div class="form-group">
{ if let Some(e) = &self.common.error {
html! {
<div class="alert alert-danger">
{e.to_string() }
</div>
}
} else { html! {} }
}
</div>
</form>
}
}
}

View File

@@ -0,0 +1,235 @@
use crate::{
components::router::AppRoute,
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
},
};
use anyhow::{bail, Context, Result};
use lldap_auth::{
opaque::client::registration as opaque_registration,
password_reset::ServerPasswordResetResponse, registration,
};
use validator_derive::Validate;
use yew::prelude::*;
use yew_form::Form;
use yew_form_derive::Model;
use yew_router::{
agent::{RouteAgentDispatcher, RouteRequest},
route::Route,
};
/// The fields of the form, with the constraints.
#[derive(Model, Validate, PartialEq, Clone, Default)]
pub struct FormModel {
#[validate(length(min = 8, message = "Invalid password. Min length: 8"))]
password: String,
#[validate(must_match(other = "password", message = "Passwords must match"))]
confirm_password: String,
}
pub struct ResetPasswordStep2Form {
common: CommonComponentParts<Self>,
form: Form<FormModel>,
username: Option<String>,
opaque_data: Option<opaque_registration::ClientRegistration>,
route_dispatcher: RouteAgentDispatcher,
}
#[derive(Clone, PartialEq, Properties)]
pub struct Props {
pub token: String,
}
pub enum Msg {
ValidateTokenResponse(Result<ServerPasswordResetResponse>),
FormUpdate,
Submit,
RegistrationStartResponse(Result<Box<registration::ServerRegistrationStartResponse>>),
RegistrationFinishResponse(Result<()>),
}
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?.user_id);
self.common.cancel_task();
Ok(true)
}
Msg::FormUpdate => Ok(true),
Msg::Submit => {
if !self.form.validate() {
bail!("Check the form for errors");
}
let mut rng = rand::rngs::OsRng;
let new_password = self.form.model().password;
let registration_start_request =
opaque_registration::start_registration(&new_password, &mut rng)
.context("Could not initiate password change")?;
let req = registration::ClientRegistrationStartRequest {
username: self.username.clone().unwrap(),
registration_start_request: registration_start_request.message,
};
self.opaque_data = Some(registration_start_request.state);
self.common.call_backend(
HostService::register_start,
req,
Msg::RegistrationStartResponse,
)?;
Ok(true)
}
Msg::RegistrationStartResponse(res) => {
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_registration::finish_registration(
registration,
res.registration_response,
&mut rng,
)
.context("Error during password change")?;
let req = registration::ClientRegistrationFinishRequest {
server_data: res.server_data,
registration_upload: registration_finish.message,
};
self.common.call_backend(
HostService::register_finish,
req,
Msg::RegistrationFinishResponse,
)?;
Ok(false)
}
Msg::RegistrationFinishResponse(response) => {
self.common.cancel_task();
if response.is_ok() {
self.route_dispatcher
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::Login)));
}
response?;
Ok(true)
}
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for ResetPasswordStep2Form {
type Message = Msg;
type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
let mut component = ResetPasswordStep2Form {
common: CommonComponentParts::<Self>::create(props, link),
form: yew_form::Form::<FormModel>::new(FormModel::default()),
opaque_data: None,
route_dispatcher: RouteAgentDispatcher::new(),
username: None,
};
let token = component.common.token.clone();
component
.common
.call_backend(
HostService::reset_password_step2,
&token,
Msg::ValidateTokenResponse,
)
.unwrap();
component
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
CommonComponentParts::<Self>::update(self, msg)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
match (&self.username, &self.common.error) {
(None, None) => {
return html! {
{"Validating token"}
}
}
(None, Some(e)) => {
return html! {
<div class="alert alert-danger">
{e.to_string() }
</div>
}
}
_ => (),
};
type Field = yew_form::Field<FormModel>;
html! {
<>
<h2>{"Reset your password"}</h2>
<form
class="form">
<div class="form-group row">
<label for="new_password"
class="form-label col-sm-2 col-form-label">
{"New password*:"}
</label>
<div class="col-sm-10">
<Field
form=&self.form
field_name="password"
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="new-password"
input_type="password"
oninput=self.common.callback(|_| Msg::FormUpdate) />
<div class="invalid-feedback">
{&self.form.field_message("password")}
</div>
</div>
</div>
<div class="form-group row">
<label for="confirm_password"
class="form-label col-sm-2 col-form-label">
{"Confirm password*:"}
</label>
<div class="col-sm-10">
<Field
form=&self.form
field_name="confirm_password"
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="new-password"
input_type="password"
oninput=self.common.callback(|_| Msg::FormUpdate) />
<div class="invalid-feedback">
{&self.form.field_message("confirm_password")}
</div>
</div>
</div>
<div class="form-group row mt-2">
<button
class="btn btn-primary col-sm-1 col-form-label"
type="submit"
disabled=self.common.is_task_running()
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
{"Submit"}
</button>
</div>
</form>
{ if let Some(e) = &self.common.error {
html! {
<div class="alert alert-danger">
{e.to_string() }
</div>
}
} else { html! {} }
}
</>
}
}
}

View File

@@ -7,6 +7,10 @@ use yew_router::{
pub enum AppRoute {
#[to = "/login"]
Login,
#[to = "/reset-password/step1"]
StartResetPassword,
#[to = "/reset-password/step2/{token}"]
FinishResetPassword(String),
#[to = "/users/create"]
CreateUser,
#[to = "/users"]

View File

@@ -5,14 +5,11 @@ use crate::{
router::{AppRoute, Link, NavButton},
user_details_form::UserDetailsForm,
},
infra::api::HostService,
infra::common_component::{CommonComponent, CommonComponentParts},
};
use anyhow::{bail, Error, Result};
use graphql_client::GraphQLQuery;
use yew::{
prelude::*,
services::{fetch::FetchTask, ConsoleService},
};
use yew::prelude::*;
#[derive(GraphQLQuery)]
#[graphql(
@@ -27,15 +24,10 @@ pub type User = get_user_details::GetUserDetailsUser;
pub type Group = get_user_details::GetUserDetailsUserGroups;
pub struct UserDetails {
link: ComponentLink<Self>,
props: Props,
common: CommonComponentParts<Self>,
/// The user info. If none, the error is in `error`. If `error` is None, then we haven't
/// received the server response yet.
user: Option<User>,
/// Error message displayed to the user.
error: Option<Error>,
// Used to keep the request alive long enough.
_task: Option<FetchTask>,
}
/// State machine describing the possible transitions of the component state.
@@ -54,22 +46,7 @@ pub struct Props {
pub is_admin: bool,
}
impl UserDetails {
fn get_user_details(&mut self) {
self._task = HostService::graphql_query::<GetUserDetails>(
get_user_details::Variables {
id: self.props.username.clone(),
},
self.link.callback(Msg::UserDetailsResponse),
"Error trying to fetch user details",
)
.map_err(|e| {
ConsoleService::log(&e.to_string());
e
})
.ok();
}
impl CommonComponent<UserDetails> for UserDetails {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::UserDetailsResponse(response) => match response {
@@ -94,6 +71,22 @@ impl UserDetails {
Ok(true)
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl UserDetails {
fn get_user_details(&mut self) {
self.common.call_graphql::<GetUserDetails, _>(
get_user_details::Variables {
id: self.common.username.clone(),
},
Msg::UserDetailsResponse,
"Error trying to fetch user details",
);
}
fn view_messages(&self, error: &Option<Error>) -> Html {
if let Some(e) = error {
html! {
@@ -111,7 +104,7 @@ impl UserDetails {
let display_name = group.display_name.clone();
html! {
<tr key="groupRow_".to_string() + &display_name>
{if self.props.is_admin { html! {
{if self.common.is_admin { html! {
<>
<td>
<Link route=AppRoute::GroupDetails(group.id)>
@@ -122,8 +115,8 @@ impl UserDetails {
<RemoveUserFromGroupComponent
username=u.id.clone()
group_id=group.id
on_user_removed_from_group=self.link.callback(Msg::OnUserRemovedFromGroup)
on_error=self.link.callback(Msg::OnError)/>
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
on_error=self.common.callback(Msg::OnError)/>
</td>
</>
} } else { html! {
@@ -140,7 +133,7 @@ impl UserDetails {
<thead>
<tr key="headerRow">
<th>{"Group"}</th>
{ if self.props.is_admin { html!{ <th></th> }} else { html!{} }}
{ if self.common.is_admin { html!{ <th></th> }} else { html!{} }}
</tr>
</thead>
<tbody>
@@ -161,13 +154,13 @@ impl UserDetails {
}
fn view_add_group_button(&self, u: &User) -> Html {
if self.props.is_admin {
if self.common.is_admin {
html! {
<AddUserToGroupComponent
username=u.id.clone()
groups=u.groups.clone()
on_error=self.link.callback(Msg::OnError)
on_user_added_to_group=self.link.callback(Msg::OnUserAddedToGroup)/>
on_error=self.common.callback(Msg::OnError)
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
}
} else {
html! {}
@@ -181,34 +174,23 @@ impl Component for UserDetails {
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
let mut table = Self {
link,
props,
_task: None,
common: CommonComponentParts::<Self>::create(props, link),
user: None,
error: None,
};
table.get_user_details();
table
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
self.error = None;
match self.handle_msg(msg) {
Err(e) => {
ConsoleService::error(&e.to_string());
self.error = Some(e);
true
}
Ok(b) => b,
}
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 {
match (&self.user, &self.error) {
match (&self.user, &self.common.error) {
(None, None) => html! {{"Loading..."}},
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
(Some(u), error) => {
@@ -217,7 +199,7 @@ impl Component for UserDetails {
<h3>{u.id.to_string()}</h3>
<UserDetailsForm
user=u.clone()
on_error=self.link.callback(Msg::OnError)/>
on_error=self.common.callback(Msg::OnError)/>
<div class="row justify-content-center">
<NavButton
route=AppRoute::ChangePassword(u.id.clone())

View File

@@ -1,11 +1,11 @@
use crate::{components::user_details::User, infra::api::HostService};
use crate::{
components::user_details::User,
infra::common_component::{CommonComponent, CommonComponentParts},
};
use anyhow::{bail, Error, Result};
use graphql_client::GraphQLQuery;
use validator_derive::Validate;
use yew::{
prelude::*,
services::{fetch::FetchTask, ConsoleService},
};
use yew::prelude::*;
use yew_form_derive::Model;
/// The fields of the form, with the editable details and the constraints.
@@ -32,12 +32,10 @@ pub struct UpdateUser;
/// A [yew::Component] to display the user details, with a form allowing to edit them.
pub struct UserDetailsForm {
link: ComponentLink<Self>,
props: Props,
common: CommonComponentParts<Self>,
form: yew_form::Form<UserModel>,
/// True if we just successfully updated the user, to display a success message.
just_updated: bool,
task: Option<FetchTask>,
}
pub enum Msg {
@@ -57,6 +55,20 @@ pub struct Props {
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::SubmitClicked => self.submit_user_update_form(),
Msg::UserUpdated(response) => self.user_update_finished(response),
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for UserDetailsForm {
type Message = Msg;
type Properties = Props;
@@ -69,29 +81,23 @@ impl Component for UserDetailsForm {
last_name: props.user.last_name.clone(),
};
Self {
link,
common: CommonComponentParts::<Self>::create(props, link),
form: yew_form::Form::new(model),
props,
just_updated: false,
task: None,
}
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
self.just_updated = false;
match self.handle_msg(msg) {
Err(e) => {
ConsoleService::error(&e.to_string());
self.props.on_error.emit(e);
self.task = None;
true
}
Ok(b) => b,
}
CommonComponentParts::<Self>::update_and_report_error(
self,
msg,
self.common.on_error.clone(),
)
}
fn change(&mut self, _: Self::Properties) -> ShouldRender {
false
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
@@ -105,7 +111,7 @@ impl Component for UserDetailsForm {
{"User ID: "}
</label>
<div class="col-8">
<span id="userId" class="form-constrol-static">{&self.props.user.id}</span>
<span id="userId" class="form-constrol-static">{&self.common.user.id}</span>
</div>
</div>
<div class="form-group row mb-3">
@@ -121,7 +127,7 @@ impl Component for UserDetailsForm {
form=&self.form
field_name="email"
autocomplete="email"
oninput=self.link.callback(|_| Msg::Update) />
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("email")}
</div>
@@ -140,7 +146,7 @@ impl Component for UserDetailsForm {
form=&self.form
field_name="display_name"
autocomplete="name"
oninput=self.link.callback(|_| Msg::Update) />
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("display_name")}
</div>
@@ -157,7 +163,7 @@ impl Component for UserDetailsForm {
form=&self.form
field_name="first_name"
autocomplete="given-name"
oninput=self.link.callback(|_| Msg::Update) />
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("first_name")}
</div>
@@ -174,7 +180,7 @@ impl Component for UserDetailsForm {
form=&self.form
field_name="last_name"
autocomplete="family-name"
oninput=self.link.callback(|_| Msg::Update) />
oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback">
{&self.form.field_message("last_name")}
</div>
@@ -186,15 +192,15 @@ impl Component for UserDetailsForm {
{"Creation date: "}
</label>
<div class="col-8">
<span id="creationDate" class="form-constrol-static">{&self.props.user.creation_date.date().naive_local()}</span>
<span id="creationDate" class="form-constrol-static">{&self.common.user.creation_date.date().naive_local()}</span>
</div>
</div>
<div class="form-group row justify-content-center">
<button
type="submit"
class="btn btn-primary col-auto col-form-label"
disabled=self.task.is_some()
onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})>
disabled=self.common.is_task_running()
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})>
{"Update"}
</button>
</div>
@@ -208,21 +214,13 @@ impl Component for UserDetailsForm {
}
impl UserDetailsForm {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::Update => Ok(true),
Msg::SubmitClicked => self.submit_user_update_form(),
Msg::UserUpdated(response) => self.user_update_finished(response),
}
}
fn submit_user_update_form(&mut self) -> Result<bool> {
if !self.form.validate() {
bail!("Invalid inputs");
}
let base_user = &self.props.user;
let base_user = &self.common.user;
let mut user_input = update_user::UpdateUserInput {
id: self.props.user.id.clone(),
id: self.common.user.id.clone(),
email: None,
displayName: None,
firstName: None,
@@ -248,28 +246,28 @@ impl UserDetailsForm {
return Ok(false);
}
let req = update_user::Variables { user: user_input };
self.task = Some(HostService::graphql_query::<UpdateUser>(
self.common.call_graphql::<UpdateUser, _>(
req,
self.link.callback(Msg::UserUpdated),
Msg::UserUpdated,
"Error trying to update user",
)?);
);
Ok(false)
}
fn user_update_finished(&mut self, r: Result<update_user::ResponseData>) -> Result<bool> {
self.task = None;
self.common.cancel_task();
match r {
Err(e) => return Err(e),
Ok(_) => {
let model = self.form.model();
self.props.user = User {
id: self.props.user.id.clone(),
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.props.user.creation_date,
groups: self.props.user.groups.clone(),
creation_date: self.common.user.creation_date,
groups: self.common.user.groups.clone(),
};
self.just_updated = true;
}

View File

@@ -3,12 +3,11 @@ use crate::{
delete_user::DeleteUser,
router::{AppRoute, Link},
},
infra::api::HostService,
infra::common_component::{CommonComponent, CommonComponentParts},
};
use anyhow::{Error, Result};
use graphql_client::GraphQLQuery;
use yew::prelude::*;
use yew::services::{fetch::FetchTask, ConsoleService};
#[derive(GraphQLQuery)]
#[graphql(
@@ -24,11 +23,8 @@ use list_users_query::{RequestFilter, ResponseData};
type User = list_users_query::ListUsersQueryUsers;
pub struct UserTable {
link: ComponentLink<Self>,
common: CommonComponentParts<Self>,
users: Option<Vec<User>>,
error: Option<Error>,
// Used to keep the request alive long enough.
_task: Option<FetchTask>,
}
pub enum Msg {
@@ -37,63 +33,7 @@ pub enum Msg {
OnError(Error),
}
impl UserTable {
fn get_users(&mut self, req: Option<RequestFilter>) {
self._task = HostService::graphql_query::<ListUsersQuery>(
list_users_query::Variables { filters: req },
self.link.callback(Msg::ListUsersResponse),
"Error trying to fetch users",
)
.map_err(|e| {
ConsoleService::log(&e.to_string());
e
})
.ok();
}
}
impl Component for UserTable {
type Message = Msg;
type Properties = ();
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
let mut table = UserTable {
link,
_task: None,
users: None,
error: None,
};
table.get_users(None);
table
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
self.error = None;
match self.handle_msg(msg) {
Err(e) => {
ConsoleService::error(&e.to_string());
self.error = Some(e);
true
}
Ok(b) => b,
}
}
fn change(&mut self, _: Self::Properties) -> ShouldRender {
false
}
fn view(&self) -> Html {
html! {
<div>
{self.view_users()}
{self.view_errors()}
</div>
}
}
}
impl UserTable {
impl CommonComponent<UserTable> for UserTable {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::ListUsersResponse(users) => {
@@ -109,6 +49,53 @@ impl UserTable {
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl UserTable {
fn get_users(&mut self, req: Option<RequestFilter>) {
self.common.call_graphql::<ListUsersQuery, _>(
list_users_query::Variables { filters: req },
Msg::ListUsersResponse,
"Error trying to fetch users",
);
}
}
impl Component for UserTable {
type Message = Msg;
type Properties = ();
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
let mut table = UserTable {
common: CommonComponentParts::<Self>::create(props, link),
users: None,
};
table.get_users(None);
table
}
fn update(&mut self, msg: Self::Message) -> ShouldRender {
CommonComponentParts::<Self>::update(self, msg)
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.common.change(props)
}
fn view(&self) -> Html {
html! {
<div>
{self.view_users()}
{self.view_errors()}
</div>
}
}
}
impl UserTable {
fn view_users(&self) -> Html {
let make_table = |users: &Vec<User>| {
html! {
@@ -150,15 +137,15 @@ impl UserTable {
<td>
<DeleteUser
username=user.id.clone()
on_user_deleted=self.link.callback(Msg::OnUserDeleted)
on_error=self.link.callback(Msg::OnError)/>
on_user_deleted=self.common.callback(Msg::OnUserDeleted)
on_error=self.common.callback(Msg::OnError)/>
</td>
</tr>
}
}
fn view_errors(&self) -> Html {
match &self.error {
match &self.common.error {
None => html! {},
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>},
}

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,7 +227,34 @@ impl HostService {
)
}
pub fn logout(callback: Callback<Result<()>>) -> Result<FetchTask> {
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(
"/auth/logout",
yew::format::Nothing,
@@ -231,4 +262,28 @@ impl HostService {
"Could not logout",
)
}
pub fn reset_password_step1(
username: &str,
callback: Callback<Result<()>>,
) -> Result<FetchTask> {
call_server_empty_response_with_error_message(
&format!("/auth/reset/step1/{}", username),
yew::format::Nothing,
callback,
"Could not initiate password reset",
)
}
pub fn reset_password_step2(
token: &str,
callback: Callback<Result<lldap_auth::password_reset::ServerPasswordResetResponse>>,
) -> Result<FetchTask> {
call_server_json_with_error_message(
&format!("/auth/reset/step2/{}", token),
yew::format::Nothing,
callback,
"Could not validate token",
)
}
}

View File

@@ -0,0 +1,175 @@
//! Common Component module.
//! This is used to factor out some common functionality that is recurrent in modules all over the
//! application. In particular:
//! - error handling
//! - task handling
//! - storing props
//!
//! The pattern used is the
//! [CRTP](https://en.wikipedia.org/wiki/Curiously_recurring_template_pattern) pattern: The
//! [`CommonComponent`] trait must be implemented with `Self` as the parameter, e.g.
//!
//! ```ignore
//! struct MyComponent;
//! impl CommonComponent<MyComponent> for MyComponent { ... }
//! ```
//!
//! The component should also have a `CommonComponentParts<Self>` as a field, usually named
//! `common`.
//!
//! Then the [`yew::prelude::Component::update`] method can delegate to
//! [`CommonComponentParts::update`]. This will in turn call [`CommonComponent::handle_msg`] and
//! take care of error and task handling.
use crate::infra::api::HostService;
use anyhow::{Error, Result};
use graphql_client::GraphQLQuery;
use yew::{
prelude::*,
services::{fetch::FetchTask, ConsoleService},
};
use yewtil::NeqAssign;
/// Trait required for common components.
pub trait CommonComponent<C: Component + CommonComponent<C>>: Component {
/// Handle the incoming message. If an error is returned here, any running task will be
/// cancelled, the error will be written to the [`CommonComponentParts::error`] and the
/// component will be refreshed.
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool>;
/// Get a mutable reference to the inner component parts, necessary for the CRTP.
fn mut_common(&mut self) -> &mut CommonComponentParts<C>;
}
/// 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>,
}
impl<C: CommonComponent<C>> CommonComponentParts<C> {
/// Whether there is a currently running task in the background.
pub fn is_task_running(&self) -> bool {
self.task.is_some()
}
/// Cancel any background task.
pub fn cancel_task(&mut self) {
self.task = None;
}
pub fn create(props: <C as Component>::Properties, link: ComponentLink<C>) -> Self {
Self {
link,
props,
error: None,
task: None,
}
}
/// This should be called from the [`yew::prelude::Component::update`]: it will in turn call
/// [`CommonComponent::handle_msg`] and handle any resulting error.
pub fn update(com: &mut C, msg: <C as Component>::Message) -> ShouldRender {
com.mut_common().error = None;
match com.handle_msg(msg) {
Err(e) => {
ConsoleService::error(&e.to_string());
com.mut_common().error = Some(e);
com.mut_common().cancel_task();
true
}
Ok(b) => b,
}
}
/// Same as above, but the resulting error is instead passed to the reporting function.
pub fn update_and_report_error(
com: &mut C,
msg: <C as Component>::Message,
report_fn: Callback<Error>,
) -> ShouldRender {
let should_render = Self::update(com, msg);
com.mut_common()
.error
.take()
.map(|e| {
report_fn.emit(e);
true
})
.unwrap_or(should_render)
}
/// This can be called from [`yew::prelude::Component::update`]: it will check if the
/// properties have changed and return whether the component should update.
pub fn change(&mut self, props: <C as Component>::Properties) -> ShouldRender
where
<C as yew::Component>::Properties: std::cmp::PartialEq,
{
self.props.neq_assign(props)
}
/// Create a callback from the link.
pub fn callback<F, IN, M>(&self, function: F) -> Callback<IN>
where
M: Into<C::Message>,
F: Fn(IN) -> M + 'static,
{
self.link.callback(function)
}
/// Call `method` from the backend with the given `request`, and pass the `callback` for the
/// result. Returns whether _starting the call_ failed.
pub fn call_backend<M, Req, Cb, Resp>(
&mut self,
method: M,
req: Req,
callback: Cb,
) -> Result<()>
where
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))?);
Ok(())
}
/// Call the backend with a GraphQL query.
///
/// `EnumCallback` should usually be left as `_`.
pub fn call_graphql<QueryType, EnumCallback>(
&mut self,
variables: QueryType::Variables,
enum_callback: EnumCallback,
error_message: &'static str,
) where
QueryType: GraphQLQuery + 'static,
EnumCallback: Fn(Result<QueryType::ResponseData>) -> <C as Component>::Message + 'static,
{
self.task = HostService::graphql_query::<QueryType>(
variables,
self.link.callback(enum_callback),
error_message,
)
.map_err::<(), _>(|e| {
ConsoleService::log(&e.to_string());
self.error = Some(e);
})
.ok();
}
}
impl<C: Component + CommonComponent<C>> std::ops::Deref for CommonComponentParts<C> {
type Target = <C as Component>::Properties;
fn deref(&self) -> &<Self as std::ops::Deref>::Target {
&self.props
}
}
impl<C: Component + CommonComponent<C>> std::ops::DerefMut for CommonComponentParts<C> {
fn deref_mut(&mut self) -> &mut <Self as std::ops::Deref>::Target {
&mut self.props
}
}

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

View File

@@ -1,4 +1,5 @@
pub mod api;
pub mod common_component;
pub mod cookies;
pub mod graphql;
pub mod modal;

View File

@@ -1,4 +1,5 @@
#![recursion_limit = "256"]
#![forbid(non_ascii_idents)]
#![allow(clippy::nonstandard_macro_braces)]
pub mod components;
pub mod infra;

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,8 +1,8 @@
[package]
name = "lldap_auth"
version = "0.1.0"
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
edition = "2018"
version = "0.3.0-alpha.1"
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
edition = "2021"
[features]
default = ["opaque_server", "opaque_client"]
@@ -13,37 +13,20 @@ js = []
[dependencies]
rust-argon2 = "0.8"
curve25519-dalek = "3"
digest = "*"
digest = "0.9"
generic-array = "*"
rand = "0.8"
serde = "*"
sha2 = "0.9"
thiserror = "*"
# TODO: update to 0.6 when out.
[dependencies.opaque-ke]
git = "https://github.com/novifinancial/opaque-ke"
rev = "eb59676a940b15f77871aefe1e46d7b5bf85f40a"
version = "0.6"
[dependencies.chrono]
version = "*"
features = [ "serde" ]
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.sqlx]
version = "0.5"
features = [
"any",
"chrono",
"macros",
"mysql",
"postgres",
"runtime-actix-native-tls",
"sqlite",
]
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.sqlx-core]
version = "=0.5.1"
# For WASM targets, use the JS getrandom.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom]
version = "0.2"

View File

@@ -1,11 +1,13 @@
#![forbid(non_ascii_idents)]
#![allow(clippy::nonstandard_macro_braces)]
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::*;
@@ -34,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.
@@ -67,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>,

2
config.toml Normal file
View File

@@ -0,0 +1,2 @@
[build]
rustflags = ["-C", "link-arg=-fuse-ld=lld"]

38
docker-entrypoint.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/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
echo "[entrypoint] The /data folder doesn't exist or cannot be written to. Make sure to mount
a volume or folder to /data to persist data across restarts, and that the current user can
write to it."
exit 1
fi
if [[ ! -f "$CONFIG_FILE" ]]; then
echo "[entrypoint] Copying the default config to $CONFIG_FILE"
echo "[entrypoint] Edit this file to configure LLDAP."
cp /app/lldap_config.docker_template.toml $CONFIG_FILE
fi
if [[ ! -r "$CONFIG_FILE" ]]; then
echo "[entrypoint] Config file is not readable. Check the permissions"
exit 1;
fi
exec /app/lldap "$@"

89
docs/architecture.md Normal file
View File

@@ -0,0 +1,89 @@
# Architecture
The server is entirely written in Rust, using [actix](https://actix.rs) for the
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.
* 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
is defined in `schema.graphql`.
* The static frontend files are served by this port too.
Note that secure protocols (LDAPS, HTTPS) are currently not supported. This can
be worked around by using a reverse proxy in front of the server (for the HTTP
API) that wraps/unwraps the HTTPS messages, or only open the service to
localhost or other trusted docker containers (for the LDAP API).
Frontend:
* User management UI.
* Written in Rust compiled to WASM as an SPA with the Yew library.
* Based on components, with a React-like organization.
Data storage:
* The data (users, groups, memberships, active JWTs, ...) is stored in SQL.
* Currently only SQLite is supported (see
https://github.com/launchbadge/sqlx/issues/1225 for what blocks us from
supporting more SQL backends).
### Code organization
* `auth/`: Contains the shared structures needed for authentication, the
interface between front and back-end. In particular, it contains the OPAQUE
structures and the JWT format.
* `app/`: The frontend.
* `src/components`: The elements containing the business and display logic of
the various pages and their components.
* `src/infra`: Various tools and utilities.
* `server/`: The backend.
* `src/domain/`: Domain-specific logic: users, groups, checking passwords...
* `src/infra/`: API, both GraphQL and LDAP
## Authentication
### 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
considered that the LDAP interface requires sending the password to the server,
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
that gets stored in an HTTP-only cookie, valid for 30 days. They can use this
token to get a JWT to get access to various servers: the JWT lists the groups
the user belongs to. To simplify the setup, there is a single JWT secret that
should be shared between the authentication server and the application servers;
and users don't get a different token per application server
(this could be implemented, we just didn't have any use case yet).
JWTs are only valid for one day: when they expire, a new JWT can be obtained
from the authentication server using the refresh token. If the user stays
logged in, they would only have to type their password once a month.
#### Logout
In order to handle logout correctly, we rely on a blacklist of JWTs. When a
user logs out, their refresh token is removed from the backend, and all of
their currently valid JWTs are added to a blacklist. Incoming requests are
checked against this blacklist (in-memory, faster than calling the database).
Applications that want to use these JWTs should subscribe to be notified of
blacklisted JWTs (TODO: implement the PubSub service and API).

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

@@ -0,0 +1,48 @@
###############################################################
# Authelia configuration #
###############################################################
# This is just the LDAP part of the Authelia configuration!
authentication_backend:
# Password reset through authelia works normally.
password_reset:
disable: false
# How often authelia should check if there is an user update in LDAP
refresh_interval: 1m
ldap:
implementation: custom
# Pattern is ldap://HOSTNAME-OR-IP:PORT
# Normal ldap port is 389, standard in LLDAP is 3890
url: ldap://lldap:3890
# The dial timeout for LDAP.
timeout: 5s
# Use StartTLS with the LDAP connection, TLS not supported right now
start_tls: false
#tls:
# skip_verify: false
# minimum_version: TLS1.2
# Set base dn, like dc=google,dc.com
base_dn: dc=example,dc=com
username_attribute: uid
# You need to set this to ou=people, because all users are stored in this ou!
additional_users_dn: ou=people
# To allow sign in both with username and email, one can use a filter like
# (&(|({username_attribute}={input})({mail_attribute}={input}))(objectClass=person))
users_filter: (&({username_attribute}={input})(objectClass=person))
# Set this to ou=groups, because all groups are stored in this ou
additional_groups_dn: ou=groups
# Only this filter is supported right now
groups_filter: (member={dn})
# The attribute holding the name of the group.
group_name_attribute: cn
# Email attribute
mail_attribute: mail
# The attribute holding the display name of the user. This will be used to greet an authenticated user.
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: 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,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 = "cn=<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 = "cn=lldap_admin,ou=groups,c=example,dc=org"
# org_role = "Admin"
# grafana_admin = true

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

@@ -0,0 +1,30 @@
# .env file
# Enable authentication
ENABLE_AUTH=1
# Enable guest access
ENABLE_GUESTS=1
# Select authentication type
AUTH_TYPE=ldap
# LDAP authentication
# LDAP url for connection
LDAP_URL=ldap://IP:3890
# LDAP base DN.
LDAP_BASE=ou=people,dc=example,dc=com
# LDAP user DN.
LDAP_BINDDN=uid=admin,ou=people,dc=example,dc=com
# LLDAP admin password.
LDAP_BINDPW=password
# LDAP filter.
LDAP_FILTER=(&(uid=%u)(objectClass=person))
# LDAP authentication method
LDAP_AUTH_METHOD=bind

View File

@@ -0,0 +1,64 @@
# KeyCloak configuration
Configuring [KeyCloak](https://www.keycloak.org) takes a bit of effort. Once
the KeyCloak instance is up and you logged in as admin (see [this
guide](https://www.keycloak.org/getting-started/getting-started-docker) to get
started with KeyCloak), you'll need to configure the LDAP mapping.
Keep in mind that LLDAP is _read-only_: that means that if you create some
users in KeyCloak, they won't be reflected to LLDAP. Instead, you should create
the user from LLDAP, and it will appear in KeyCloak. Same for groups. However,
you can set the permissions associated with users or groups in KeyCloak.
## Configure user authentication
In the admin console of KeyCloak, on the left, go to "User Federation". You can
then add an LDAP backend.
The key settings are:
- Edit Mode: `READ_ONLY`
- Vendor: `Other`
- Username LDAP attribute: `uid`
- UUID LDAP attribute: `uid`
- User Object Classes: `person`
- 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: `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.
In the "Advanced Settings", you can "Query Supported Extensions", or just
enable the "LDAPv3 Password Modify Extended Operation".
Turn "Pagination" off.
Save the provider.
## Configure group mapping
Getting the LDAP groups to be imported into KeyCloak requires one more step:
Go back to "User Federation", and edit your LDAP integration. At the top, click
on the "Mappers" tab.
Find or create the `groups` mapper, with type `group-ldap-mapper`. The key
settings are:
- LDAP Groups DN: `ou=groups,dc=example,dc=com` (or whatever `dc` you have)
- Group Name LDAP Attribute: `cn`
- Group Object Classes: `groupOfUniqueNames`
- Mode: `READ_ONLY`
Save, then sync LDAP groups to KeyCloak, and (from the LDAP integration page)
sync the users to KeyCloak as well.
## Give the LDAP admin user admin rights to KeyCloak
Once the groups are synchronized, go to "Manage > Groups" on the left. Click on
`lldap_admin`, then "Edit".
Assign the role "admin" to the group. Now you can log in as the LLDAP admin to
the KeyCloak admin console.

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

@@ -1,6 +1,11 @@
## Default configuration for Docker.
## All the values can be overridden through environment variables. For
## instance, "ldap_port" can be overridden with the "LDAP_PORT" variable.
## All the values can be overridden through environment variables, prefixed
## 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
@@ -9,13 +14,18 @@
## administration.
#http_port = 17170
## The public URL of the server, for password reset links.
#http_url = "http://localhost"
## Random secret for JWT signature.
## This secret should be random, and should be shared with application
## servers that need to consume the JWTs.
## Changing this secret will invalidate all user sessions and require
## them to re-login.
## You should probably set it through the JWT_SECRET environment
## 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
## 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 ''
#jwt_secret = "REPLACE_WITH_RANDOM"
@@ -31,16 +41,19 @@
## Admin username.
## For the LDAP interface, a value of "admin" here will create the LDAP
## user "cn=admin,dc=example,dc=com" (with the base DN above).
## user "cn=admin,ou=people,dc=example,dc=com" (with the base DN above).
## For the administration interface, this is the username.
#ldap_user_dn = "admin"
## Admin password.
## Password for the admin account, both for the LDAP bind and for the
## administration interface.
## administration interface. It is only used when initially creating
## the admin user.
## It should be minimum 8 characters long.
## You can set it with the LDAP_USER_PASS environment variable.
## Note: you can create another admin user for LDAP/administration, this
## 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_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"
@@ -64,3 +77,46 @@ database_url = "sqlite:///data/users.db?mode=rwc"
## each password.
## 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
#[smtp_options]
## Whether to enabled password reset via email, from LLDAP.
#enable_password_reset=true
## The SMTP server.
#server="smtp.gmail.com"
## The SMTP port.
#port=587
## Whether to connect with TLS.
#tls_required=true
## The SMTP user, usually your email address.
#user="sender@gmail.com"
## The SMTP password.
#password="password"
## The header field, optional: how the sender appears in the email. The first
## is a free-form name, followed by an email between <>.
#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"

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

@@ -0,0 +1,23 @@
[package]
name = "migration-tool"
version = "0.3.0-alpha.1"
edition = "2021"
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
[dependencies]
anyhow = "*"
graphql_client = "0.10"
ldap3 = "*"
rand = "0.8"
requestty = "*"
serde = "1"
serde_json = "1"
smallvec = "*"
[dependencies.lldap_auth]
path = "../auth"
features = [ "opaque_client" ]
[dependencies.reqwest]
version = "*"
features = [ "json", "blocking" ]

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

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

@@ -0,0 +1,432 @@
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(|r| r.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| {
if u.len() > 1 {
Err(anyhow!("Too many {}s", attr))
} else {
Ok(u.first().unwrap().to_owned())
}
})
};
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()))
.and_then(|s| {
if s.is_empty() {
None
} else {
Some(s.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 password =
get_optional_attribute("userPassword").or_else(|| get_optional_attribute("password"));
Ok(User::new(
id,
email,
display_name,
first_name,
last_name,
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(),
})
}

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

@@ -0,0 +1,506 @@
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
#[allow(non_snake_case)]
pub fn new(
id: String,
email: String,
displayName: Option<String>,
firstName: Option<String>,
lastName: Option<String>,
password: Option<String>,
dn: String,
) -> User {
User {
user_input: create_user::CreateUserInput {
id,
email,
displayName,
firstName,
lastName,
},
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;
#[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()
);
}
Ok(response.text()?)
}
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(())
}

25
prepare-release.sh Executable file
View File

@@ -0,0 +1,25 @@
#! /bin/sh
set -e
set -x
# Build the binary server, for x86_64.
cargo build --release -p lldap
cargo install cross
cross build --target=armv7-unknown-linux-musleabihf -p lldap --release
# Build the frontend.
./app/build.sh
VERSION=$(git describe --tags)
mkdir -p /tmp/release/x86_64
cp target/release/lldap /tmp/release/x86_64
cp -R app/index.html app/main.js app/pkg lldap_config.docker_template.toml README.md LICENSE /tmp/release/x86_64
tar -czvf lldap-x86_64-${VERSION}.tar.gz /tmp/release/x86_64
mkdir -p /tmp/release/armv7
cp target/armv7-unknown-linux-musleabihf/release/lldap /tmp/release/armv7
cp -R app/index.html app/main.js app/pkg lldap_config.docker_template.toml README.md LICENSE /tmp/release/armv7
tar -czvf lldap-armv7-${VERSION}.tar.gz /tmp/release/armv7

View File

@@ -1,59 +1,73 @@
[package]
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
edition = "2018"
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
edition = "2021"
name = "lldap"
version = "0.1.0"
version = "0.3.0"
[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-tls = "=3.0.0-beta.5"
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"
futures = "*"
futures-util = "*"
hmac = "0.10"
http = "*"
itertools = "0.10.1"
juniper = "0.15.6"
juniper_actix = "0.4.0"
jwt = "0.13"
ldap3_server = "*"
lldap_auth = { path = "../auth" }
ldap3_server = "=0.1.11"
log = "*"
native-tls = "0.2.10"
orion = "0.16"
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-native-tls = "0.3"
tokio-stream = "*"
tokio-util = "0.6.3"
tracing = "*"
tracing-actix-web = "0.4.0-beta.7"
tracing-attributes = "^0.1.21"
tracing-log = "*"
tracing-subscriber = "*"
rand = { version = "0.8", features = ["small_rng", "getrandom"] }
juniper_actix = "0.4.0"
juniper = "0.15.6"
itertools = "0.10.1"
# TODO: update to 0.6 when out.
[dependencies.opaque-ke]
git = "https://github.com/novifinancial/opaque-ke"
rev = "eb59676a940b15f77871aefe1e46d7b5bf85f40a"
[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-native-tls", "tokio1"]
version = "0.10.0-rc.3"
[dependencies.sqlx]
version = "0.5.1"
version = "0.5.11"
features = [
"any",
"chrono",
@@ -64,13 +78,43 @@ features = [
"sqlite",
]
[dependencies.sea-query]
version = "0.9.4"
features = ["with-chrono"]
[dependencies.lldap_auth]
path = "../auth"
[dependencies.figment]
features = ["env", "toml"]
[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.openssl-sys]
features = ["vendored"]
version = "*"
[dependencies.rand]
features = ["small_rng", "getrandom"]
version = "0.8"
[dependencies.secstr]
features = ["serde"]
version = "*"
[dependencies.tokio]
features = ["full"]
version = "1.13.1"
[dependencies.uuid]
features = ["v3"]
version = "*"
[dependencies.tracing-forest]
features = ["smallvec", "chrono", "tokio"]
version = "^0.1.4"
[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,110 @@ 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, 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 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),
creation_date: epoch,
uuid: Uuid::from_name_and_date("", &epoch),
}
}
}
@@ -33,20 +115,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,10 +139,22 @@ 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>,
@@ -67,7 +164,7 @@ pub struct CreateUserRequest {
#[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>,
@@ -85,27 +182,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)]
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 +229,38 @@ 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)
);
}
}

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,
@@ -28,9 +28,18 @@ mockall::mock! {
}
#[async_trait]
impl OpaqueHandler for TestOpaqueHandler {
async fn login_start(&self, request: login::ClientLoginStartRequest) -> Result<login::ServerLoginStartResponse>;
async fn login_finish(&self, request: login::ClientLoginFinishRequest ) -> Result<String>;
async fn registration_start(&self, request: registration::ClientRegistrationStartRequest) -> Result<registration::ServerRegistrationStartResponse>;
async fn registration_finish(&self, request: registration::ClientRegistrationFinishRequest ) -> Result<()>;
async fn login_start(
&self,
request: login::ClientLoginStartRequest
) -> Result<login::ServerLoginStartResponse>;
async fn login_finish(&self, request: login::ClientLoginFinishRequest ) -> Result<UserId>;
async fn registration_start(
&self,
request: registration::ClientRegistrationStartRequest
) -> Result<registration::ServerRegistrationStartResponse>;
async fn registration_finish(
&self,
request: registration::ClientRegistrationFinishRequest
) -> Result<()>;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +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;
@@ -30,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,
@@ -46,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())
{
@@ -81,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 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())
{
@@ -105,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,
@@ -149,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,
@@ -164,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,
@@ -188,6 +197,7 @@ impl OpaqueHandler for SqlOpaqueHandler {
})
}
#[instrument(skip_all, level = "debug", err)]
async fn registration_finish(
&self,
request: registration::ClientRegistrationFinishRequest,
@@ -202,29 +212,30 @@ 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,
password: &str,
username: &UserId,
password: &SecUtf8,
) -> Result<()> {
let mut rng = rand::rngs::OsRng;
use registration::*;
let registration_start = opaque::client::registration::start_registration(password, &mut rng)?;
let registration_start =
opaque::client::registration::start_registration(password.unsecure(), &mut rng)?;
let start_response = opaque_handler
.registration_start(ClientRegistrationStartRequest {
username: username.to_string(),
@@ -276,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()
})
@@ -321,7 +332,12 @@ mod tests {
attempt_login(&opaque_handler, "bob", "bob00")
.await
.unwrap_err();
register_password(&opaque_handler, "bob", "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,7 @@
use super::handler::GroupId;
use super::handler::{GroupId, UserId, Uuid};
use sea_query::*;
use sqlx::Row;
use tracing::warn;
pub type Pool = sqlx::sqlite::SqlitePool;
pub type PoolOptions = sqlx::sqlite::SqlitePoolOptions;
@@ -12,28 +14,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 +51,7 @@ pub enum Users {
PasswordHash,
TotpSecret,
MfaType,
Uuid,
}
#[derive(Iden)]
@@ -57,6 +59,8 @@ pub enum Groups {
Table,
GroupId,
DisplayName,
CreationDate,
Uuid,
}
#[derive(Iden)]
@@ -66,6 +70,19 @@ 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
);
Ok(sqlx::query(&query)
.fetch_one(pool)
.await?
.get::<i32, _>("col_count")
> 0)
}
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 +110,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 +132,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 +280,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),
)
@@ -159,13 +307,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 +327,49 @@ 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 int, display_name TEXT );"#)
.execute(&sql_pool)
.await
.unwrap();
init_table(&sql_pool).await.unwrap();
sqlx::query(
r#"INSERT INTO groups (group_id, display_name, creation_date, uuid)
VALUES (3, "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")]
);
}
}

View File

@@ -1,43 +1,48 @@
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},
error::{ErrorBadRequest, ErrorUnauthorized},
web, HttpRequest, HttpResponse,
};
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 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,
@@ -46,109 +51,210 @@ 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("/api")
.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_logout<Backend>(
async fn get_refresh_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
.delete_refresh_token(refresh_token_hash)
.map_err(error_to_http_response)
get_refresh(data, request)
.await
{
return response;
.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 Err(TcpError::BadRequest("Missing user ID".to_string())),
Some(id) => UserId::new(id),
};
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);
}
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 {
Err(e) => {
warn!("Error getting used details: {:#?}", e);
return Ok(());
}
Err(response) => return response,
Ok(u) => u,
};
HttpResponse::Ok()
if let Err(e) = super::mail::send_password_reset_email(
&user.display_name,
&user.email,
&token,
&data.server_url,
&data.mail_options,
) {
warn!("Error sending email: {:#?}", e);
return Err(TcpError::InternalServerError(format!(
"Could not send email: {}",
e
)));
}
Ok(())
}
async fn get_password_reset_step1_handler<Backend>(
data: web::Data<AppState<Backend>>,
request: HttpRequest,
) -> HttpResponse
where
Backend: TcpBackendHandler + BackendHandler + 'static,
{
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?;
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);
Ok(HttpResponse::Ok()
.cookie(
Cookie::build("token", token.as_str())
.max_age(5.minutes())
// Cookie is only valid to reset the password.
.path("/auth")
.http_only(true)
.same_site(SameSite::Strict)
.finish(),
)
.json(&password_reset::ServerPasswordResetResponse {
user_id: user_id.to_string(),
token: token.as_str().to_owned(),
}))
}
async fn get_password_reset_step2_handler<Backend>(
data: web::Data<AppState<Backend>>,
request: HttpRequest,
) -> HttpResponse
where
Backend: TcpBackendHandler + BackendHandler + 'static,
{
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)
.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())
.path("/api")
.path("/")
.http_only(true)
.same_site(SameSite::Strict)
.finish(),
@@ -161,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>,
@@ -184,104 +303,201 @@ 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("/api")
.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>>,
) -> TcpResult<registration::ServerRegistrationStartResponse>
where
Backend: OpaqueHandler + 'static,
{
use actix_web::FromRequest;
let validation_result = BearerAuth::from_request(&request, &mut payload.0)
.await
.ok()
.and_then(|bearer| check_if_token_is_valid(&data, bearer.token()).ok())
.ok_or_else(|| {
TcpError::UnauthorizedError("Not authorized to change the user's password".to_string())
})?;
let registration_start_request =
web::Json::<registration::ClientRegistrationStartRequest>::from_request(
&request,
&mut payload.0,
)
.await
.map_err(|e| TcpError::BadRequest(format!("{:#?}", e)))?
.into_inner();
let user_id = &registration_start_request.username;
if !validation_result.can_write(user_id) {
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>>,
request: web::Json<registration::ClientRegistrationStartRequest>,
) -> ApiResult<registration::ServerRegistrationStartResponse>
where
Backend: OpaqueHandler + 'static,
{
data.backend_handler
.registration_start(request.into_inner())
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;
@@ -340,9 +556,17 @@ where
}
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Permission {
Admin,
Readonly,
Regular,
}
#[derive(Debug)]
pub struct ValidationResults {
pub user: String,
pub is_admin: bool,
pub permission: Permission,
}
impl ValidationResults {
@@ -350,15 +574,34 @@ impl ValidationResults {
pub fn admin() -> Self {
Self {
user: "admin".to_string(),
is_admin: true,
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
}
#[must_use]
pub fn can_read(&self, user: &str) -> bool {
self.permission == Permission::Admin
|| self.permission == Permission::Readonly
|| self.user == user
}
#[must_use]
pub fn can_write(&self, user: &str) -> 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,
@@ -382,10 +625,16 @@ 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,
permission: if is_in_group("lldap_admin") {
Permission::Admin
} else if is_in_group("lldap_readonly") {
Permission::Readonly
} else {
Permission::Regular
},
})
}
@@ -393,23 +642,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("/opaque/register/start")
.route(web::post().to(opaque_register_start::<Backend>)),
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_handler::<Backend>)),
)
.service(
web::resource("/opaque/register/finish")
.route(web::post().to(opaque_register_finish::<Backend>)),
web::resource("/reset/step2/{token}")
.route(web::get().to(get_password_reset_step2_handler::<Backend>)),
)
.service(web::resource("/refresh").route(web::get().to(get_refresh::<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_handler::<Backend>)),
)
.service(
web::resource("/finish")
.route(web::post().to(opaque_register_finish_handler::<Backend>)),
),
);
}

View File

@@ -1,15 +1,17 @@
use clap::Clap;
use clap::Parser;
use lettre::message::Mailbox;
/// 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)]
pub command: Command,
}
#[derive(Debug, Clap, Clone)]
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Parser, Clone)]
pub enum Command {
/// Export the GraphQL schema to *.graphql.
#[clap(name = "export_graphql_schema")]
@@ -17,28 +19,122 @@ pub enum Command {
/// Run the LDAP and GraphQL server.
#[clap(name = "run")]
Run(RunOpts),
/// Send a test email.
#[clap(name = "send_test_email")]
SendTestEmail(TestEmailOpts),
}
#[derive(Debug, Clap, Clone)]
pub struct RunOpts {
/// Change config file name
#[clap(short, long, default_value = "lldap_config.toml")]
#[derive(Debug, Parser, Clone)]
pub struct GeneralConfigOpts {
/// Change config file name.
#[clap(
short,
long,
default_value = "lldap_config.toml",
env = "LLDAP_CONFIG_FILE"
)]
pub config_file: String,
/// Change ldap port. Default: 389
#[clap(long)]
pub ldap_port: Option<u16>,
/// Change ldap ssl port. Default: 636
#[clap(long)]
pub ldaps_port: Option<u16>,
/// Set verbose logging
/// Set verbose logging.
#[clap(short, long)]
pub verbose: bool,
}
#[derive(Debug, Clap, Clone)]
#[derive(Debug, Parser, Clone)]
pub struct RunOpts {
#[clap(flatten)]
pub general_config: GeneralConfigOpts,
/// Path to the file that contains the private server key.
/// It will be created if it doesn't exist.
#[clap(long, env = "LLDAP_SERVER_KEY_FILE")]
pub server_key_file: Option<String>,
/// Change ldap port. Default: 3890
#[clap(long, env = "LLDAP_LDAP_PORT")]
pub ldap_port: Option<u16>,
/// Change HTTP API port. Default: 17170
#[clap(long, env = "LLDAP_HTTP_PORT")]
pub http_port: Option<u16>,
/// URL of the server, for password reset links.
#[clap(long, env = "LLDAP_HTTP_URL")]
pub http_url: Option<String>,
#[clap(flatten)]
pub smtp_opts: SmtpOpts,
#[clap(flatten)]
pub ldaps_opts: LdapsOpts,
}
#[derive(Debug, Parser, Clone)]
pub struct TestEmailOpts {
#[clap(flatten)]
pub general_config: GeneralConfigOpts,
/// Email address to send an email to.
#[clap(long, env = "LLDAP_TEST_EMAIL_TO")]
pub to: String,
#[clap(flatten)]
pub smtp_opts: SmtpOpts,
}
#[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>,
}
#[derive(Debug, Parser, Clone)]
#[clap(next_help_heading = Some("SMTP"), setting = clap::AppSettings::DeriveDisplayOrder)]
pub struct SmtpOpts {
/// Sender email address.
#[clap(long, env = "LLDAP_SMTP_OPTIONS__FROM")]
pub smtp_from: Option<Mailbox>,
/// Reply-to email address.
#[clap(long, env = "LLDAP_SMTP_OPTIONS__TO")]
pub smtp_reply_to: Option<Mailbox>,
/// SMTP server.
#[clap(long, env = "LLDAP_SMTP_OPTIONS__SERVER")]
pub smtp_server: Option<String>,
/// SMTP port, 587 by default.
#[clap(long, env = "LLDAP_SMTP_OPTIONS__PORT")]
pub smtp_port: Option<u16>,
/// SMTP user.
#[clap(long, env = "LLDAP_SMTP_OPTIONS__USER")]
pub smtp_user: Option<String>,
/// SMTP password.
#[clap(long, env = "LLDAP_SMTP_OPTIONS__PASSWORD", hide_env_values = true)]
pub smtp_password: Option<String>,
/// Whether TLS should be used to connect to SMTP.
#[clap(long, env = "LLDAP_SMTP_OPTIONS__TLS_REQUIRED")]
pub smtp_tls_required: Option<bool>,
}
#[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,49 +1,109 @@
use crate::{
domain::handler::UserId,
infra::cli::{GeneralConfigOpts, LdapsOpts, RunOpts, SmtpOpts, TestEmailOpts},
};
use anyhow::{Context, Result};
use figment::{
providers::{Env, Format, Serialized, Toml},
Figment,
};
use lettre::message::Mailbox;
use lldap_auth::opaque::{server::ServerSetup, KeyPair};
use log::*;
use secstr::SecUtf8;
use serde::{Deserialize, Serialize};
use crate::infra::cli::RunOpts;
#[derive(Clone, Debug, Deserialize, Serialize, derive_builder::Builder)]
#[builder(pattern = "owned")]
pub struct MailOptions {
#[builder(default = "false")]
pub enable_password_reset: bool,
#[builder(default = "None")]
pub from: Option<Mailbox>,
#[builder(default = "None")]
pub reply_to: Option<Mailbox>,
#[builder(default = r#""localhost".to_string()"#)]
pub server: String,
#[builder(default = "587")]
pub port: u16,
#[builder(default = r#""admin".to_string()"#)]
pub user: String,
#[builder(default = r#"SecUtf8::from("")"#)]
pub password: SecUtf8,
#[builder(default = "true")]
pub tls_required: bool,
}
impl std::default::Default for MailOptions {
fn default() -> Self {
MailOptionsBuilder::default().build().unwrap()
}
}
#[derive(Clone, Debug, Deserialize, Serialize, derive_builder::Builder)]
#[builder(
pattern = "owned",
default = "Configuration::default()",
build_fn(name = "private_build", validate = "Self::validate")
)]
pub struct Configuration {
pub ldap_port: u16,
pub ldaps_port: u16,
pub http_port: u16,
pub jwt_secret: String,
pub ldap_base_dn: String,
pub ldap_user_dn: String,
pub ldap_user_pass: String,
pub database_url: String,
pub verbose: bool,
#[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 = "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#"UserId::new("admin")"#)]
pub ldap_user_dn: UserId,
#[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)]
#[builder(field(private), setter(strip_option))]
#[builder(field(private), default = "None")]
server_setup: Option<ServerSetup>,
}
impl std::default::Default for Configuration {
fn default() -> Self {
ConfigurationBuilder::default().build().unwrap()
}
}
impl ConfigurationBuilder {
#[cfg(test)]
pub fn build(self) -> Result<Configuration> {
let server_setup = get_server_setup(self.key_file.as_deref().unwrap_or("server_key"))?;
Ok(self.server_setup(server_setup).private_build()?)
}
fn validate(&self) -> Result<(), String> {
if self.server_setup.is_none() {
Err("Don't use `private_build`, use `build` instead".to_string())
} else {
Ok(())
}
Ok(self.server_setup(Some(server_setup)).private_build()?)
}
}
@@ -55,39 +115,6 @@ impl Configuration {
pub fn get_server_keys(&self) -> &KeyPair {
self.get_server_setup().keypair()
}
fn merge_with_cli(mut self: Configuration, cli_opts: RunOpts) -> Configuration {
if cli_opts.verbose {
self.verbose = true;
}
if let Some(port) = cli_opts.ldap_port {
self.ldap_port = port;
}
if let Some(port) = cli_opts.ldaps_port {
self.ldaps_port = port;
}
self
}
pub(super) fn default() -> Self {
Configuration {
ldap_port: 3890,
ldaps_port: 6360,
http_port: 17170,
jwt_secret: String::from("secretjwtsecret"),
ldap_base_dn: String::from("dc=example,dc=com"),
// cn=admin,dc=example,dc=com
ldap_user_dn: String::from("admin"),
ldap_user_pass: String::from("password"),
database_url: String::from("sqlite://users.db?mode=rwc"),
verbose: false,
key_file: String::from("server_key"),
server_setup: None,
}
}
}
fn get_server_setup(file_path: &str) -> Result<ServerSetup> {
@@ -108,17 +135,136 @@ fn get_server_setup(file_path: &str) -> Result<ServerSetup> {
}
}
pub fn init(cli_opts: RunOpts) -> Result<Configuration> {
let config_file = cli_opts.config_file.clone();
pub trait ConfigOverrider {
fn override_config(&self, config: &mut Configuration);
}
info!("Loading configuration from {}", cli_opts.config_file);
pub trait TopLevelCommandOpts {
fn general_config(&self) -> &GeneralConfigOpts;
}
let config: Configuration = Figment::from(Serialized::defaults(Configuration::default()))
.merge(Toml::file(config_file))
.merge(Env::prefixed("LLDAP_"))
.extract()?;
impl TopLevelCommandOpts for RunOpts {
fn general_config(&self) -> &GeneralConfigOpts {
&self.general_config
}
}
let mut config = config.merge_with_cli(cli_opts);
impl TopLevelCommandOpts for TestEmailOpts {
fn general_config(&self) -> &GeneralConfigOpts {
&self.general_config
}
}
impl ConfigOverrider for RunOpts {
fn override_config(&self, config: &mut Configuration) {
self.general_config.override_config(config);
if let Some(path) = self.server_key_file.as_ref() {
config.key_file = path.to_string();
}
if let Some(port) = self.ldap_port {
config.ldap_port = port;
}
if let Some(port) = self.http_port {
config.http_port = port;
}
if let Some(url) = self.http_url.as_ref() {
config.http_url = url.to_string();
}
self.smtp_opts.override_config(config);
self.ldaps_opts.override_config(config);
}
}
impl ConfigOverrider for TestEmailOpts {
fn override_config(&self, config: &mut Configuration) {
self.general_config.override_config(config);
self.smtp_opts.override_config(config);
}
}
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 {
config.verbose = true;
}
}
}
impl ConfigOverrider for SmtpOpts {
fn override_config(&self, config: &mut Configuration) {
if let Some(from) = &self.smtp_from {
config.smtp_options.from = Some(from.clone());
}
if let Some(reply_to) = &self.smtp_reply_to {
config.smtp_options.reply_to = Some(reply_to.clone());
}
if let Some(server) = &self.smtp_server {
config.smtp_options.server = server.clone();
}
if let Some(port) = self.smtp_port {
config.smtp_options.port = port;
}
if let Some(user) = &self.smtp_user {
config.smtp_options.user = user.clone();
}
if let Some(password) = &self.smtp_password {
config.smtp_options.password = SecUtf8::from(password.clone());
}
if let Some(tls_required) = self.smtp_tls_required {
config.smtp_options.tls_required = tls_required;
}
}
}
pub fn init<C>(overrides: C) -> Result<Configuration>
where
C: TopLevelCommandOpts + ConfigOverrider,
{
let config_file = overrides.general_config().config_file.clone();
println!(
"Loading configuration from {}",
overrides.general_config().config_file
);
let mut config: Configuration = Figment::from(Serialized::defaults(
ConfigurationBuilder::default().private_build().unwrap(),
))
.merge(Toml::file(config_file))
.merge(Env::prefixed("LLDAP_").split("__"))
.extract()?;
overrides.override_config(&mut config);
if config.verbose {
println!("Configuration: {:#?}", &config);
}
config.server_setup = Some(get_server_setup(&config.key_file)?);
if config.jwt_secret == SecUtf8::from("secretjwtsecret") {
println!("WARNING: Default JWT secret used! This is highly unsafe and can allow attackers to log in as admin.");
}
if config.ldap_user_pass == SecUtf8::from("password") {
println!("WARNING: Unsecure default admin password is used.");
}
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,8 @@
use crate::domain::handler::{
BackendHandler, CreateUserRequest, GroupId, UpdateGroupRequest, UpdateUserRequest,
BackendHandler, CreateUserRequest, GroupId, UpdateGroupRequest, UpdateUserRequest, UserId,
};
use juniper::{graphql_object, FieldResult, GraphQLInputObject, GraphQLObject};
use tracing::{debug, debug_span, Instrument};
use super::api::Context;
@@ -63,22 +64,30 @@ 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);
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,
})
.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 +96,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 +117,24 @@ 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);
});
if !context.validation_result.can_write(&user.id) {
span.in_scope(|| debug!("Unauthorized"));
return Err("Unauthorized user update".into());
}
context
.handler
.update_user(UpdateUserRequest {
user_id: user.id,
user_id: UserId::new(&user.id),
email: user.email,
display_name: user.display_name,
first_name: user.first_name,
last_name: user.last_name,
})
.instrument(span)
.await?;
Ok(Success::new())
}
@@ -122,10 +143,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 +161,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 +171,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 +192,65 @@ 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());
}
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))
.remove_user_from_group(&UserId::new(&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);
});
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(&UserId::new(&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())
}
}

View File

@@ -1,11 +1,12 @@
use crate::domain::handler::{BackendHandler, GroupId, GroupIdAndName};
use crate::domain::handler::{BackendHandler, GroupDetails, GroupId, UserId};
use juniper::{graphql_object, FieldResult, GraphQLInputObject};
use serde::{Deserialize, Serialize};
use std::convert::TryInto;
use tracing::{debug, debug_span, Instrument};
type DomainRequestFilter = crate::domain::handler::RequestFilter;
type DomainRequestFilter = crate::domain::handler::UserRequestFilter;
type DomainUser = crate::domain::handler::User;
type DomainGroup = crate::domain::handler::Group;
type DomainUserAndGroups = crate::domain::handler::UserAndGroups;
use super::api::Context;
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
@@ -49,6 +50,9 @@ impl TryInto<DomainRequestFilter> for RequestFilter {
return Err("Multiple fields specified in request filter".to_string());
}
if let Some(e) = self.eq {
if e.field.to_lowercase() == "uid" {
return Ok(DomainRequestFilter::UserId(UserId::new(&e.value)));
}
return Ok(DomainRequestFilter::Equality(e.field, e.value));
}
if let Some(c) = self.any {
@@ -105,12 +109,18 @@ impl<Handler: BackendHandler + Sync> Query<Handler> {
}
pub async fn user(context: &Context<Handler>, user_id: String) -> FieldResult<User<Handler>> {
if !context.validation_result.can_access(&user_id) {
let span = debug_span!("[GraphQL query] user");
span.in_scope(|| {
debug!(?user_id);
});
if !context.validation_result.can_read(&user_id) {
span.in_scope(|| debug!("Unauthorized"));
return Err("Unauthorized access to user data".into());
}
Ok(context
.handler
.get_user_details(&user_id)
.get_user_details(&UserId::new(&user_id))
.instrument(span)
.await
.map(Into::into)?)
}
@@ -119,34 +129,49 @@ impl<Handler: BackendHandler + Sync> Query<Handler> {
context: &Context<Handler>,
#[graphql(name = "where")] filters: Option<RequestFilter>,
) -> FieldResult<Vec<User<Handler>>> {
if !context.validation_result.is_admin {
let span = debug_span!("[GraphQL query] users");
span.in_scope(|| {
debug!(?filters);
});
if !context.validation_result.is_admin_or_readonly() {
span.in_scope(|| debug!("Unauthorized"));
return Err("Unauthorized access to user list".into());
}
Ok(context
.handler
.list_users(filters.map(TryInto::try_into).transpose()?)
.list_users(filters.map(TryInto::try_into).transpose()?, false)
.instrument(span)
.await
.map(|v| v.into_iter().map(Into::into).collect())?)
}
async fn groups(context: &Context<Handler>) -> FieldResult<Vec<Group<Handler>>> {
if !context.validation_result.is_admin {
let span = debug_span!("[GraphQL query] groups");
if !context.validation_result.is_admin_or_readonly() {
span.in_scope(|| debug!("Unauthorized"));
return Err("Unauthorized access to group list".into());
}
Ok(context
.handler
.list_groups()
.list_groups(None)
.instrument(span)
.await
.map(|v| v.into_iter().map(Into::into).collect())?)
}
async fn group(context: &Context<Handler>, group_id: i32) -> FieldResult<Group<Handler>> {
if !context.validation_result.is_admin {
let span = debug_span!("[GraphQL query] group");
span.in_scope(|| {
debug!(?group_id);
});
if !context.validation_result.is_admin_or_readonly() {
span.in_scope(|| debug!("Unauthorized"));
return Err("Unauthorized access to group data".into());
}
Ok(context
.handler
.get_group_details(GroupId(group_id))
.instrument(span)
.await
.map(Into::into)?)
}
@@ -159,6 +184,7 @@ pub struct User<Handler: BackendHandler> {
_phantom: std::marker::PhantomData<Box<Handler>>,
}
#[cfg(test)]
impl<Handler: BackendHandler> Default for User<Handler> {
fn default() -> Self {
Self {
@@ -171,7 +197,7 @@ impl<Handler: BackendHandler> Default for User<Handler> {
#[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler + Sync> User<Handler> {
fn id(&self) -> &str {
&self.user.user_id
self.user.user_id.as_str()
}
fn email(&self) -> &str {
@@ -196,9 +222,14 @@ impl<Handler: BackendHandler + Sync> User<Handler> {
/// The groups to which this user belongs.
async fn groups(&self, context: &Context<Handler>) -> FieldResult<Vec<Group<Handler>>> {
let span = debug_span!("[GraphQL query] user::groups");
span.in_scope(|| {
debug!(user_id = ?self.user.user_id);
});
Ok(context
.handler
.get_user_groups(&self.user.user_id)
.instrument(span)
.await
.map(|set| set.into_iter().map(Into::into).collect())?)
}
@@ -213,11 +244,21 @@ impl<Handler: BackendHandler> From<DomainUser> for User<Handler> {
}
}
impl<Handler: BackendHandler> From<DomainUserAndGroups> for User<Handler> {
fn from(user: DomainUserAndGroups) -> Self {
Self {
user: user.user,
_phantom: std::marker::PhantomData,
}
}
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
/// Represents a single group.
pub struct Group<Handler: BackendHandler> {
group_id: i32,
display_name: String,
creation_date: chrono::DateTime<chrono::Utc>,
members: Option<Vec<String>>,
_phantom: std::marker::PhantomData<Box<Handler>>,
}
@@ -232,24 +273,32 @@ impl<Handler: BackendHandler + Sync> Group<Handler> {
}
/// The groups to which this user belongs.
async fn users(&self, context: &Context<Handler>) -> FieldResult<Vec<User<Handler>>> {
if !context.validation_result.is_admin {
let span = debug_span!("[GraphQL query] group::users");
span.in_scope(|| {
debug!(name = %self.display_name);
});
if !context.validation_result.is_admin_or_readonly() {
span.in_scope(|| debug!("Unauthorized"));
return Err("Unauthorized access to group data".into());
}
Ok(context
.handler
.list_users(Some(DomainRequestFilter::MemberOfId(GroupId(
self.group_id,
))))
.list_users(
Some(DomainRequestFilter::MemberOfId(GroupId(self.group_id))),
false,
)
.instrument(span)
.await
.map(|v| v.into_iter().map(Into::into).collect())?)
}
}
impl<Handler: BackendHandler> From<GroupIdAndName> for Group<Handler> {
fn from(group_id_and_name: GroupIdAndName) -> Self {
impl<Handler: BackendHandler> From<GroupDetails> for Group<Handler> {
fn from(group_details: GroupDetails) -> Self {
Self {
group_id: group_id_and_name.0 .0,
display_name: group_id_and_name.1,
group_id: group_details.group_id.0,
display_name: group_details.display_name,
creation_date: group_details.creation_date,
members: None,
_phantom: std::marker::PhantomData,
}
@@ -261,7 +310,8 @@ impl<Handler: BackendHandler> From<DomainGroup> for Group<Handler> {
Self {
group_id: group.id.0,
display_name: group.display_name,
members: Some(group.users.into_iter().map(Into::into).collect()),
creation_date: group.creation_date,
members: Some(group.users.into_iter().map(UserId::into_string).collect()),
_phantom: std::marker::PhantomData,
}
}
@@ -270,7 +320,11 @@ impl<Handler: BackendHandler> From<DomainGroup> for Group<Handler> {
#[cfg(test)]
mod tests {
use super::*;
use crate::{domain::handler::MockTestBackendHandler, infra::auth_service::ValidationResults};
use crate::{
domain::handler::{MockTestBackendHandler, UserRequestFilter},
infra::auth_service::ValidationResults,
};
use chrono::TimeZone;
use juniper::{
execute, graphql_value, DefaultScalarValue, EmptyMutation, EmptySubscription, GraphQLType,
RootNode, Variables,
@@ -303,18 +357,23 @@ mod tests {
let mut mock = MockTestBackendHandler::new();
mock.expect_get_user_details()
.with(eq("bob"))
.with(eq(UserId::new("bob")))
.return_once(|_| {
Ok(DomainUser {
user_id: "bob".to_string(),
user_id: UserId::new("bob"),
email: "bob@bobbers.on".to_string(),
..Default::default()
})
});
let mut groups = HashSet::new();
groups.insert(GroupIdAndName(GroupId(3), "Bobbersons".to_string()));
groups.insert(GroupDetails {
group_id: GroupId(3),
display_name: "Bobbersons".to_string(),
creation_date: chrono::Utc.timestamp_nanos(42),
uuid: crate::uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
});
mock.expect_get_user_groups()
.with(eq("bob"))
.with(eq(UserId::new("bob")))
.return_once(|_| Ok(groups));
let context = Context::<MockTestBackendHandler> {
@@ -359,23 +418,34 @@ mod tests {
}"#;
let mut mock = MockTestBackendHandler::new();
use crate::domain::handler::RequestFilter;
mock.expect_list_users()
.with(eq(Some(RequestFilter::Or(vec![
RequestFilter::Equality("id".to_string(), "bob".to_string()),
RequestFilter::Equality("email".to_string(), "robert@bobbers.on".to_string()),
]))))
.return_once(|_| {
.with(
eq(Some(UserRequestFilter::Or(vec![
UserRequestFilter::Equality("id".to_string(), "bob".to_string()),
UserRequestFilter::Equality(
"email".to_string(),
"robert@bobbers.on".to_string(),
),
]))),
eq(false),
)
.return_once(|_, _| {
Ok(vec![
DomainUser {
user_id: "bob".to_string(),
email: "bob@bobbers.on".to_string(),
..Default::default()
DomainUserAndGroups {
user: DomainUser {
user_id: UserId::new("bob"),
email: "bob@bobbers.on".to_string(),
..Default::default()
},
groups: None,
},
DomainUser {
user_id: "robert".to_string(),
email: "robert@bobbers.on".to_string(),
..Default::default()
DomainUserAndGroups {
user: DomainUser {
user_id: UserId::new("robert"),
email: "robert@bobbers.on".to_string(),
..Default::default()
},
groups: None,
},
])
});

View File

@@ -21,6 +21,15 @@ pub enum JwtStorage {
Blacklisted,
}
/// Contains the temporary tokens to reset the password, sent by email.
#[derive(Iden)]
pub enum PasswordResetTokens {
Table,
Token,
UserId,
ExpiryDate,
}
/// This needs to be initialized after the domain tables are.
pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
sqlx::query(
@@ -46,8 +55,8 @@ pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
.foreign_key(
ForeignKey::create()
.name("JwtRefreshStorageUserForeignKey")
.table(JwtRefreshStorage::Table, Users::Table)
.col(JwtRefreshStorage::UserId, Users::UserId)
.from(JwtRefreshStorage::Table, JwtRefreshStorage::UserId)
.to(Users::Table, Users::UserId)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
@@ -85,8 +94,41 @@ pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
.foreign_key(
ForeignKey::create()
.name("JwtStorageUserForeignKey")
.table(JwtStorage::Table, Users::Table)
.col(JwtStorage::UserId, Users::UserId)
.from(JwtStorage::Table, JwtStorage::UserId)
.to(Users::Table, Users::UserId)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_string(DbQueryBuilder {}),
)
.execute(pool)
.await?;
sqlx::query(
&Table::create()
.table(PasswordResetTokens::Table)
.if_not_exists()
.col(
ColumnDef::new(PasswordResetTokens::Token)
.string_len(255)
.not_null()
.primary_key(),
)
.col(
ColumnDef::new(PasswordResetTokens::UserId)
.string_len(255)
.not_null(),
)
.col(
ColumnDef::new(PasswordResetTokens::ExpiryDate)
.date_time()
.not_null(),
)
.foreign_key(
ForeignKey::create()
.name("PasswordResetTokensUserForeignKey")
.from(PasswordResetTokens::Table, PasswordResetTokens::UserId)
.to(Users::Table, Users::UserId)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,102 +1,193 @@
use crate::domain::handler::{BackendHandler, LoginHandler};
use crate::infra::configuration::Configuration;
use crate::infra::ldap_handler::LdapHandler;
use crate::{
domain::{
handler::{BackendHandler, LoginHandler},
opaque_handler::OpaqueHandler,
},
infra::{configuration::Configuration, ldap_handler::LdapHandler},
};
use actix_rt::net::TcpStream;
use actix_server::ServerBuilder;
use actix_service::{fn_service, ServiceFactoryExt};
use anyhow::{bail, Result};
use futures_util::future::ok;
use ldap3_server::simple::*;
use ldap3_server::LdapCodec;
use log::*;
use tokio::net::tcp::WriteHalf;
use anyhow::{Context, Result};
use ldap3_server::{proto::LdapMsg, LdapCodec};
use native_tls::{Identity, TlsAcceptor};
use tokio_native_tls::TlsAcceptor as NativeTlsAcceptor;
use tokio_util::codec::{FramedRead, FramedWrite};
use tracing::{debug, error, info, instrument};
async fn handle_incoming_message<Backend>(
#[instrument(skip_all, level = "info", name = "LDAP request")]
async fn handle_ldap_message<Backend, Writer>(
msg: Result<LdapMsg, std::io::Error>,
resp: &mut FramedWrite<WriteHalf<'_>, LdapCodec>,
resp: &mut Writer,
session: &mut LdapHandler<Backend>,
) -> Result<bool>
where
Backend: BackendHandler + LoginHandler,
Backend: BackendHandler + LoginHandler + OpaqueHandler,
Writer: futures_util::Sink<LdapMsg> + Unpin,
<Writer as futures_util::Sink<LdapMsg>>::Error: std::error::Error + Send + Sync + 'static,
{
use futures_util::SinkExt;
use std::convert::TryFrom;
let server_op = match msg.map_err(|_e| ()).and_then(ServerOps::try_from) {
Ok(a_value) => a_value,
Err(an_error) => {
let _err = resp
.send(DisconnectionNotice::gen(
LdapResultCode::Other,
"Internal Server Error",
))
.await;
let _err = resp.flush().await;
bail!("Internal server error: {:?}", an_error);
}
};
match session.handle_ldap_message(server_op).await {
let msg = msg.context("while receiving LDAP op")?;
debug!(?msg);
match session.handle_ldap_message(msg.op).await {
None => return Ok(false),
Some(result) => {
for rmsg in result.into_iter() {
if let Err(e) = resp.send(rmsg).await {
bail!("Error while sending a response: {:?}", e);
}
if result.is_empty() {
debug!("No response");
}
for response in result.into_iter() {
debug!(?response);
resp.send(LdapMsg {
msgid: msg.msgid,
op: response,
ctrl: vec![],
})
.await
.context("while sending a response: {:#}")?
}
if let Err(e) = resp.flush().await {
bail!("Error while flushing responses: {:?}", e);
}
resp.flush()
.await
.context("while flushing responses: {:#}")?
}
}
Ok(true)
}
fn get_file_as_byte_vec(filename: &str) -> Result<Vec<u8>> {
(|| -> Result<Vec<u8>> {
use std::fs::{metadata, File};
use std::io::Read;
let mut f = File::open(&filename).context("file not found")?;
let metadata = metadata(&filename).context("unable to read metadata")?;
let mut buffer = vec![0; metadata.len() as usize];
f.read(&mut buffer).context("buffer overflow")?;
Ok(buffer)
})()
.context(format!("while reading file {}", filename))
}
#[instrument(skip_all, level = "info", name = "LDAP session")]
async fn handle_ldap_stream<Stream, Backend>(
stream: Stream,
backend_handler: Backend,
ldap_base_dn: String,
ignored_user_attributes: Vec<String>,
ignored_group_attributes: Vec<String>,
) -> Result<Stream>
where
Backend: BackendHandler + LoginHandler + OpaqueHandler + 'static,
Stream: tokio::io::AsyncRead + tokio::io::AsyncWrite,
{
use tokio_stream::StreamExt;
let (r, w) = tokio::io::split(stream);
// Configure the codec etc.
let mut requests = FramedRead::new(r, LdapCodec);
let mut resp = FramedWrite::new(w, LdapCodec);
let mut session = LdapHandler::new(
backend_handler,
ldap_base_dn,
ignored_user_attributes,
ignored_group_attributes,
);
while let Some(msg) = requests.next().await {
if !handle_ldap_message(msg, &mut resp, &mut session)
.await
.context("while handling incoming messages")?
{
break;
}
}
Ok(requests.into_inner().unsplit(resp.into_inner()))
}
fn get_tls_acceptor(config: &Configuration) -> Result<NativeTlsAcceptor> {
// Load TLS key and cert files
let cert_file = get_file_as_byte_vec(&config.ldaps_options.cert_file)?;
let key_file = get_file_as_byte_vec(&config.ldaps_options.key_file)?;
let identity = Identity::from_pkcs8(&cert_file, &key_file)?;
Ok(TlsAcceptor::new(identity)?.into())
}
pub fn build_ldap_server<Backend>(
config: &Configuration,
backend_handler: Backend,
server_builder: ServerBuilder,
) -> Result<ServerBuilder>
where
Backend: BackendHandler + LoginHandler + 'static,
Backend: BackendHandler + LoginHandler + OpaqueHandler + 'static,
{
use futures_util::StreamExt;
let context = (
backend_handler,
config.ldap_base_dn.clone(),
config.ignored_user_attributes.clone(),
config.ignored_group_attributes.clone(),
);
let ldap_base_dn = config.ldap_base_dn.clone();
let ldap_user_dn = config.ldap_user_dn.clone();
Ok(
server_builder.bind("ldap", ("0.0.0.0", config.ldap_port), move || {
let backend_handler = backend_handler.clone();
let ldap_base_dn = ldap_base_dn.clone();
let ldap_user_dn = ldap_user_dn.clone();
fn_service(move |mut stream: TcpStream| {
let backend_handler = backend_handler.clone();
let ldap_base_dn = ldap_base_dn.clone();
let ldap_user_dn = ldap_user_dn.clone();
let context_for_tls = context.clone();
let binder = move || {
let context = context.clone();
fn_service(move |stream: TcpStream| {
let context = context.clone();
async move {
let (handler, base_dn, ignored_user_attributes, ignored_group_attributes) = context;
handle_ldap_stream(
stream,
handler,
base_dn,
ignored_user_attributes,
ignored_group_attributes,
)
.await
}
})
.map_err(|err: anyhow::Error| error!("[LDAP] Service Error: {:#}", err))
};
info!("Starting the LDAP server on port {}", config.ldap_port);
let server_builder = server_builder
.bind("ldap", ("0.0.0.0", config.ldap_port), binder)
.with_context(|| format!("while binding to the port {}", config.ldap_port));
if config.ldaps_options.enabled {
let tls_context = (
context_for_tls,
get_tls_acceptor(config).context("while setting up the SSL certificate")?,
);
let tls_binder = move || {
let tls_context = tls_context.clone();
fn_service(move |stream: TcpStream| {
let tls_context = tls_context.clone();
async move {
// Configure the codec etc.
let (r, w) = stream.split();
let mut requests = FramedRead::new(r, LdapCodec);
let mut resp = FramedWrite::new(w, LdapCodec);
let mut session = LdapHandler::new(backend_handler, ldap_base_dn, ldap_user_dn);
while let Some(msg) = requests.next().await {
if !handle_incoming_message(msg, &mut resp, &mut session).await? {
break;
}
}
Ok(stream)
let (
(handler, base_dn, ignored_user_attributes, ignored_group_attributes),
tls_acceptor,
) = tls_context;
let tls_stream = tls_acceptor.accept(stream).await?;
handle_ldap_stream(
tls_stream,
handler,
base_dn,
ignored_user_attributes,
ignored_group_attributes,
)
.await
}
})
.map_err(|err: anyhow::Error| error!("Service Error: {:?}", err))
// catch
.and_then(move |_| {
// finally
ok(())
})
})?,
)
.map_err(|err: anyhow::Error| error!("[LDAPS] Service Error: {:#}", err))
};
info!(
"Starting the LDAPS server on port {}",
config.ldaps_options.port
);
server_builder.and_then(|s| {
s.bind("ldaps", ("0.0.0.0", config.ldaps_options.port), tls_binder)
.with_context(|| format!("while binding to the port {}", config.ldaps_options.port))
})
} else {
server_builder
}
}

View File

@@ -1,25 +1,50 @@
use crate::infra::configuration::Configuration;
use anyhow::Context;
use tracing::subscriber::set_global_default;
use tracing_log::LogTracer;
use actix_web::{
dev::{ServiceRequest, ServiceResponse},
Error,
};
use tracing::{error, info, Span};
use tracing_actix_web::{root_span, RootSpanBuilder};
use tracing_subscriber::{filter::EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
pub fn init(config: Configuration) -> anyhow::Result<()> {
let max_log_level = log_level_from_config(config);
let subscriber = tracing_subscriber::fmt()
.with_timer(tracing_subscriber::fmt::time::time())
.with_target(false)
.with_level(true)
.with_max_level(max_log_level)
.finish();
LogTracer::init().context("Failed to set logger")?;
set_global_default(subscriber).context("Failed to set subscriber")?;
Ok(())
}
/// We will define a custom root span builder to capture additional fields, specific
/// to our application, on top of the ones provided by `DefaultRootSpanBuilder` out of the box.
pub struct CustomRootSpanBuilder;
fn log_level_from_config(config: Configuration) -> tracing::Level {
if config.verbose {
tracing::Level::DEBUG
} else {
tracing::Level::INFO
impl RootSpanBuilder for CustomRootSpanBuilder {
fn on_request_start(request: &ServiceRequest) -> Span {
let span = root_span!(request);
span.in_scope(|| {
info!(uri = %request.uri());
});
span
}
fn on_request_end<B>(_: Span, outcome: &Result<ServiceResponse<B>, Error>) {
match &outcome {
Ok(response) => {
if let Some(error) = response.response().error() {
error!(?error);
} else {
info!(status_code = &response.response().status().as_u16());
}
}
Err(error) => error!(?error),
};
}
}
pub fn init(config: &Configuration) -> anyhow::Result<()> {
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new(if config.verbose {
"sqlx=warn,debug"
} else {
"sqlx=warn,info"
})
});
tracing_subscriber::registry()
.with(env_filter)
.with(tracing_forest::ForestLayer::default())
.init();
Ok(())
}

65
server/src/infra/mail.rs Normal file
View File

@@ -0,0 +1,65 @@
use crate::infra::configuration::MailOptions;
use anyhow::Result;
use lettre::{
message::Mailbox, transport::smtp::authentication::Credentials, Message, SmtpTransport,
Transport,
};
use tracing::debug;
fn send_email(to: Mailbox, subject: &str, body: String, options: &MailOptions) -> Result<()> {
let from = options
.from
.clone()
.unwrap_or_else(|| "LLDAP <nobody@lldap>".parse().unwrap());
let reply_to = options.reply_to.clone().unwrap_or_else(|| from.clone());
debug!(
"Sending email to '{}' as '{}' via '{}'@'{}':'{}'",
&to, &from, &options.user, &options.server, options.port
);
let email = Message::builder()
.from(from)
.reply_to(reply_to)
.to(to)
.subject(subject)
.body(body)?;
let creds = Credentials::new(
options.user.clone(),
options.password.unsecure().to_string(),
);
let mailer = SmtpTransport::relay(&options.server)?
.credentials(creds)
.build();
mailer.send(&email)?;
Ok(())
}
pub fn send_password_reset_email(
username: &str,
to: &str,
token: &str,
domain: &str,
options: &MailOptions,
) -> Result<()> {
let to = to.parse()?;
let body = format!(
"Hello {},
This email has been sent to you in order to validate your identity.
If you did not initiate the process your credentials might have been
compromised. You should reset your password and contact an administrator.
To reset your password please visit the following URL: {}/reset-password/step2/{}
Please contact an administrator if you did not initiate the process.",
username, domain, token
);
send_email(to, "[LLDAP] Password reset requested", body, options)
}
pub fn send_test_email(to: Mailbox, options: &MailOptions) -> Result<()> {
send_email(
to,
"LLDAP test email",
"The test is successful! You can send emails from LLDAP".to_string(),
options,
)
}

View File

@@ -7,6 +7,7 @@ pub mod jwt_sql_tables;
pub mod ldap_handler;
pub mod ldap_server;
pub mod logging;
pub mod mail;
pub mod sql_backend_handler;
pub mod tcp_backend_handler;
pub mod tcp_server;

View File

@@ -1,48 +1,57 @@
use super::{jwt_sql_tables::*, tcp_backend_handler::*};
use crate::domain::{error::*, sql_backend_handler::SqlBackendHandler};
use crate::domain::{error::*, handler::UserId, sql_backend_handler::SqlBackendHandler};
use async_trait::async_trait;
use futures_util::StreamExt;
use sea_query::{Expr, Iden, Query, SimpleExpr};
use sqlx::Row;
use sea_query_binder::SqlxBinder;
use sqlx::{query_as_with, query_with, Row};
use std::collections::HashSet;
use tracing::{debug, instrument};
fn gen_random_string(len: usize) -> String {
use rand::{distributions::Alphanumeric, rngs::SmallRng, Rng, SeedableRng};
let mut rng = SmallRng::from_entropy();
std::iter::repeat(())
.map(|()| rng.sample(Alphanumeric))
.map(char::from)
.take(len)
.collect()
}
#[async_trait]
impl TcpBackendHandler for SqlBackendHandler {
#[instrument(skip_all, level = "debug")]
async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>> {
use sqlx::Result;
let query = Query::select()
let (query, values) = Query::select()
.column(JwtStorage::JwtHash)
.from(JwtStorage::Table)
.to_string(DbQueryBuilder {});
.build_sqlx(DbQueryBuilder {});
sqlx::query(&query)
debug!(%query);
query_with(&query, values)
.map(|row: DbRow| row.get::<i64, _>(&*JwtStorage::JwtHash.to_string()) as u64)
.fetch(&self.sql_pool)
.collect::<Vec<sqlx::Result<u64>>>()
.await
.into_iter()
.collect::<Result<HashSet<u64>>>()
.collect::<sqlx::Result<HashSet<u64>>>()
.map_err(|e| anyhow::anyhow!(e))
}
async fn create_refresh_token(&self, user: &str) -> Result<(String, chrono::Duration)> {
use rand::{distributions::Alphanumeric, rngs::SmallRng, Rng, SeedableRng};
#[instrument(skip_all, level = "debug")]
async fn create_refresh_token(&self, user: &UserId) -> Result<(String, chrono::Duration)> {
debug!(?user);
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
// TODO: Initialize the rng only once. Maybe Arc<Cell>?
let mut rng = SmallRng::from_entropy();
let refresh_token: String = std::iter::repeat(())
.map(|()| rng.sample(Alphanumeric))
.map(char::from)
.take(100)
.collect();
let refresh_token = gen_random_string(100);
let refresh_token_hash = {
let mut s = DefaultHasher::new();
refresh_token.hash(&mut s);
s.finish()
};
let duration = chrono::Duration::days(30);
let query = Query::insert()
let (query, values) = Query::insert()
.into_table(JwtRefreshStorage::Table)
.columns(vec![
JwtRefreshStorage::RefreshTokenHash,
@@ -54,52 +63,133 @@ impl TcpBackendHandler for SqlBackendHandler {
user.into(),
(chrono::Utc::now() + duration).naive_utc().into(),
])
.to_string(DbQueryBuilder {});
sqlx::query(&query).execute(&self.sql_pool).await?;
.build_sqlx(DbQueryBuilder {});
debug!(%query);
query_with(&query, values).execute(&self.sql_pool).await?;
Ok((refresh_token, duration))
}
async fn check_token(&self, refresh_token_hash: u64, user: &str) -> Result<bool> {
let query = Query::select()
#[instrument(skip_all, level = "debug")]
async fn check_token(&self, refresh_token_hash: u64, user: &UserId) -> Result<bool> {
debug!(?user);
let (query, values) = Query::select()
.expr(SimpleExpr::Value(1.into()))
.from(JwtRefreshStorage::Table)
.and_where(Expr::col(JwtRefreshStorage::RefreshTokenHash).eq(refresh_token_hash as i64))
.and_where(Expr::col(JwtRefreshStorage::UserId).eq(user))
.to_string(DbQueryBuilder {});
Ok(sqlx::query(&query)
.build_sqlx(DbQueryBuilder {});
debug!(%query);
Ok(query_with(&query, values)
.fetch_optional(&self.sql_pool)
.await?
.is_some())
}
async fn blacklist_jwts(&self, user: &str) -> DomainResult<HashSet<u64>> {
#[instrument(skip_all, level = "debug")]
async fn blacklist_jwts(&self, user: &UserId) -> Result<HashSet<u64>> {
debug!(?user);
use sqlx::Result;
let query = Query::select()
let (query, values) = Query::select()
.column(JwtStorage::JwtHash)
.from(JwtStorage::Table)
.and_where(Expr::col(JwtStorage::UserId).eq(user))
.and_where(Expr::col(JwtStorage::Blacklisted).eq(true))
.to_string(DbQueryBuilder {});
let result = sqlx::query(&query)
.build_sqlx(DbQueryBuilder {});
let result = query_with(&query, values)
.map(|row: DbRow| row.get::<i64, _>(&*JwtStorage::JwtHash.to_string()) as u64)
.fetch(&self.sql_pool)
.collect::<Vec<sqlx::Result<u64>>>()
.await
.into_iter()
.collect::<Result<HashSet<u64>>>();
let query = Query::update()
let (query, values) = Query::update()
.table(JwtStorage::Table)
.values(vec![(JwtStorage::Blacklisted, true.into())])
.and_where(Expr::col(JwtStorage::UserId).eq(user))
.to_string(DbQueryBuilder {});
sqlx::query(&query).execute(&self.sql_pool).await?;
.build_sqlx(DbQueryBuilder {});
debug!(%query);
query_with(&query, values).execute(&self.sql_pool).await?;
Ok(result?)
}
async fn delete_refresh_token(&self, refresh_token_hash: u64) -> DomainResult<()> {
let query = Query::delete()
#[instrument(skip_all, level = "debug")]
async fn delete_refresh_token(&self, refresh_token_hash: u64) -> Result<()> {
let (query, values) = Query::delete()
.from_table(JwtRefreshStorage::Table)
.and_where(Expr::col(JwtRefreshStorage::RefreshTokenHash).eq(refresh_token_hash))
.to_string(DbQueryBuilder {});
sqlx::query(&query).execute(&self.sql_pool).await?;
.and_where(Expr::col(JwtRefreshStorage::RefreshTokenHash).eq(refresh_token_hash as i64))
.build_sqlx(DbQueryBuilder {});
debug!(%query);
query_with(&query, values).execute(&self.sql_pool).await?;
Ok(())
}
#[instrument(skip_all, level = "debug")]
async fn start_password_reset(&self, user: &UserId) -> Result<Option<String>> {
debug!(?user);
let (query, values) = Query::select()
.column(Users::UserId)
.from(Users::Table)
.and_where(Expr::col(Users::UserId).eq(user))
.build_sqlx(DbQueryBuilder {});
debug!(%query);
// Check that the user exists.
if query_with(&query, values)
.fetch_one(&self.sql_pool)
.await
.is_err()
{
debug!("User not found");
return Ok(None);
}
let token = gen_random_string(100);
let duration = chrono::Duration::minutes(10);
let (query, values) = Query::insert()
.into_table(PasswordResetTokens::Table)
.columns(vec![
PasswordResetTokens::Token,
PasswordResetTokens::UserId,
PasswordResetTokens::ExpiryDate,
])
.values_panic(vec![
token.clone().into(),
user.into(),
(chrono::Utc::now() + duration).naive_utc().into(),
])
.build_sqlx(DbQueryBuilder {});
debug!(%query);
query_with(&query, values).execute(&self.sql_pool).await?;
Ok(Some(token))
}
#[instrument(skip_all, level = "debug", ret)]
async fn get_user_id_for_password_reset_token(&self, token: &str) -> Result<UserId> {
let (query, values) = Query::select()
.column(PasswordResetTokens::UserId)
.from(PasswordResetTokens::Table)
.and_where(Expr::col(PasswordResetTokens::Token).eq(token))
.and_where(
Expr::col(PasswordResetTokens::ExpiryDate).gt(chrono::Utc::now().naive_utc()),
)
.build_sqlx(DbQueryBuilder {});
debug!(%query);
let (user_id,) = query_as_with(&query, values)
.fetch_one(&self.sql_pool)
.await?;
Ok(user_id)
}
#[instrument(skip_all, level = "debug")]
async fn delete_password_reset_token(&self, token: &str) -> Result<()> {
let (query, values) = Query::delete()
.from_table(PasswordResetTokens::Table)
.and_where(Expr::col(PasswordResetTokens::Token).eq(token))
.build_sqlx(DbQueryBuilder {});
debug!(%query);
query_with(&query, values).execute(&self.sql_pool).await?;
Ok(())
}
}

View File

@@ -1,15 +1,24 @@
use async_trait::async_trait;
use std::collections::HashSet;
pub type DomainResult<T> = crate::domain::error::Result<T>;
use crate::domain::{error::Result, handler::UserId};
#[async_trait]
pub trait TcpBackendHandler {
async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>>;
async fn create_refresh_token(&self, user: &str) -> DomainResult<(String, chrono::Duration)>;
async fn check_token(&self, refresh_token_hash: u64, user: &str) -> DomainResult<bool>;
async fn blacklist_jwts(&self, user: &str) -> DomainResult<HashSet<u64>>;
async fn delete_refresh_token(&self, refresh_token_hash: u64) -> DomainResult<()>;
async fn create_refresh_token(&self, user: &UserId) -> Result<(String, chrono::Duration)>;
async fn check_token(&self, refresh_token_hash: u64, user: &UserId) -> Result<bool>;
async fn blacklist_jwts(&self, user: &UserId) -> Result<HashSet<u64>>;
async fn delete_refresh_token(&self, refresh_token_hash: u64) -> Result<()>;
/// Request a token to reset a user's password.
/// If the user doesn't exist, returns `Ok(None)`, otherwise `Ok(Some(token))`.
async fn start_password_reset(&self, user: &UserId) -> Result<Option<String>>;
/// Get the user ID associated with a password reset token.
async fn get_user_id_for_password_reset_token(&self, token: &str) -> Result<UserId>;
async fn delete_password_reset_token(&self, token: &str) -> Result<()>;
}
#[cfg(test)]
@@ -22,30 +31,33 @@ mockall::mock! {
}
#[async_trait]
impl LoginHandler for TestTcpBackendHandler {
async fn bind(&self, request: BindRequest) -> DomainResult<()>;
async fn bind(&self, request: BindRequest) -> Result<()>;
}
#[async_trait]
impl BackendHandler for TestTcpBackendHandler {
async fn list_users(&self, filters: Option<RequestFilter>) -> DomainResult<Vec<User>>;
async fn list_groups(&self) -> DomainResult<Vec<Group>>;
async fn get_user_details(&self, user_id: &str) -> DomainResult<User>;
async fn get_group_details(&self, group_id: GroupId) -> DomainResult<GroupIdAndName>;
async fn get_user_groups(&self, user: &str) -> DomainResult<HashSet<GroupIdAndName>>;
async fn create_user(&self, request: CreateUserRequest) -> DomainResult<()>;
async fn update_user(&self, request: UpdateUserRequest) -> DomainResult<()>;
async fn update_group(&self, request: UpdateGroupRequest) -> DomainResult<()>;
async fn delete_user(&self, user_id: &str) -> DomainResult<()>;
async fn create_group(&self, group_name: &str) -> DomainResult<GroupId>;
async fn delete_group(&self, group_id: GroupId) -> DomainResult<()>;
async fn add_user_to_group(&self, user_id: &str, group_id: GroupId) -> DomainResult<()>;
async fn remove_user_from_group(&self, user_id: &str, group_id: GroupId) -> DomainResult<()>;
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 get_user_groups(&self, user: &UserId) -> Result<HashSet<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: &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: &UserId, group_id: GroupId) -> Result<()>;
async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
}
#[async_trait]
impl TcpBackendHandler for TestTcpBackendHandler {
async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>>;
async fn create_refresh_token(&self, user: &str) -> DomainResult<(String, chrono::Duration)>;
async fn check_token(&self, refresh_token_hash: u64, user: &str) -> DomainResult<bool>;
async fn blacklist_jwts(&self, user: &str) -> DomainResult<HashSet<u64>>;
async fn delete_refresh_token(&self, refresh_token_hash: u64) -> DomainResult<()>;
async fn create_refresh_token(&self, user: &UserId) -> Result<(String, chrono::Duration)>;
async fn check_token(&self, refresh_token_hash: u64, user: &UserId) -> Result<bool>;
async fn blacklist_jwts(&self, user: &UserId) -> Result<HashSet<u64>>;
async fn delete_refresh_token(&self, refresh_token_hash: u64) -> Result<()>;
async fn start_password_reset(&self, user: &UserId) -> Result<Option<String>>;
async fn get_user_id_for_password_reset_token(&self, token: &str) -> Result<UserId>;
async fn delete_password_reset_token(&self, token: &str) -> Result<()>;
}
}

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