89 Commits

Author SHA1 Message Date
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
60 changed files with 3760 additions and 1377 deletions

View File

@@ -1,5 +1,7 @@
# Don't track git
.git/*
.github/*
.gitignore
# Don't track cargo generated files
target/*
@@ -18,5 +20,6 @@ Dockerfile
lldap_config.toml
server_key
users.db*
.gitignore
screenshot.png
recipe.json
*.md

63
.github/workflows/docker.yml vendored Normal file
View File

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

View File

@@ -17,6 +17,7 @@ jobs:
steps:
- name: Checkout sources
uses: actions/checkout@v2
- uses: Swatinem/rust-cache@v1
- name: Build
run: cargo build --verbose --workspace
- name: Run tests
@@ -42,6 +43,8 @@ jobs:
override: true
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v1
- name: Run cargo clippy
uses: actions-rs/cargo@v1
with:
@@ -63,8 +66,37 @@ jobs:
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
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v2
- name: Install Rust
run: rustup toolchain install nightly --component llvm-tools-preview
- name: Install cargo-llvm-cov
run: curl -LsSf https://github.com/taiki-e/cargo-llvm-cov/releases/latest/download/cargo-llvm-cov-x86_64-unknown-linux-gnu.tar.gz | tar xzf - -C ~/.cargo/bin
- uses: Swatinem/rust-cache@v1
- name: clean
run: cargo llvm-cov clean --workspace
- name: Generate code coverage for unit test
run: cargo llvm-cov --workspace --no-report
- name: Aggregate reports
run: cargo llvm-cov --no-run --lcov --output-path lcov.info
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v1
with:
files: lcov.info
fail_ci_if_error: true

4
.gitignore vendored
View File

@@ -21,3 +21,7 @@ package.json
# Server private key
server_key
# Misc
.env
recipe.json

211
Cargo.lock generated
View File

@@ -480,6 +480,18 @@ version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitvec"
version = "0.19.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8942c8d352ae1838c9dda0b0ca2ab657696ef2232a20147cf1b30ae1a9cb4321"
dependencies = [
"funty",
"radium",
"tap",
"wyz",
]
[[package]]
name = "blake2b_simd"
version = "0.5.11"
@@ -997,6 +1009,15 @@ dependencies = [
"synstructure",
]
[[package]]
name = "fastrand"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b394ed3d285a429378d3b384b9eb1285267e7df4b166df24b7a6939a04dc392e"
dependencies = [
"instant",
]
[[package]]
name = "figment"
version = "0.10.6"
@@ -1069,6 +1090,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69a039c3498dc930fe810151a34ba0c1c70b02b8625035592e74432f678591f2"
[[package]]
name = "funty"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fed34cd105917e91daa4da6b3728c47b068749d6a62c59811f06ed2ac71d9da7"
[[package]]
name = "futures"
version = "0.3.17"
@@ -1443,6 +1470,17 @@ dependencies = [
"digest",
]
[[package]]
name = "hostname"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867"
dependencies = [
"libc",
"match_cfg",
"winapi",
]
[[package]]
name = "http"
version = "0.2.4"
@@ -1466,6 +1504,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acd94fdbe1d4ff688b67b04eee2e17bd50995534a61539e45adfefb45e5e5503"
[[package]]
name = "httpdate"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6456b8a6c8f33fee7d958fcd1b60d55b11940a79e63ae87013e6d22e26034440"
[[package]]
name = "ident_case"
version = "1.0.1"
@@ -1644,15 +1688,41 @@ dependencies = [
[[package]]
name = "ldap3_server"
version = "0.1.7"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3beb05c22d6cb1792389efb3e71ed90af6148b6f26d283db67322d356ab2556d"
checksum = "092da326ef499380e33fc8213a621de7fb342d6cd112eb695e16161a0acb061a"
dependencies = [
"bytes",
"lber",
"tokio-util",
]
[[package]]
name = "lettre"
version = "0.10.0-rc.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8697ded52353bdd6fec234b3135972433397e86d0493d9fc38fbf407b7c106a"
dependencies = [
"async-trait",
"base64",
"fastrand",
"futures-io",
"futures-util",
"hostname",
"httpdate",
"idna",
"mime",
"native-tls",
"nom 6.1.2",
"once_cell",
"quoted_printable",
"r2d2",
"regex",
"serde",
"tokio",
"tokio-native-tls",
]
[[package]]
name = "lexical-core"
version = "0.7.6"
@@ -1697,7 +1767,7 @@ checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3"
[[package]]
name = "lldap"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"actix",
"actix-files",
@@ -1725,13 +1795,16 @@ dependencies = [
"juniper_actix",
"jwt",
"ldap3_server",
"lettre",
"lldap_auth",
"log",
"mockall",
"opaque-ke",
"openssl-sys",
"orion",
"rand 0.8.4",
"sea-query",
"secstr",
"serde",
"serde_json",
"sha2",
@@ -1740,6 +1813,7 @@ dependencies = [
"thiserror",
"time 0.2.27",
"tokio",
"tokio-stream",
"tokio-util",
"tracing",
"tracing-actix-web",
@@ -1749,7 +1823,7 @@ dependencies = [
[[package]]
name = "lldap_app"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"anyhow",
"chrono",
@@ -1773,7 +1847,7 @@ dependencies = [
[[package]]
name = "lldap_auth"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"chrono",
"curve25519-dalek",
@@ -1785,8 +1859,6 @@ dependencies = [
"rust-argon2",
"serde",
"sha2",
"sqlx",
"sqlx-core",
"thiserror",
]
@@ -1833,13 +1905,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "matchers"
version = "0.0.1"
name = "match_cfg"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f099785f7595cc4b4553a174ce30dd7589ef93391ff414dbb67f62392b9e0ce1"
dependencies = [
"regex-automata",
]
checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4"
[[package]]
name = "matches"
@@ -1989,6 +2058,18 @@ dependencies = [
"version_check",
]
[[package]]
name = "nom"
version = "6.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7413f999671bd4745a7b624bd370a569fb6bc574b23c83a3c5ed2e453f3d5e2"
dependencies = [
"bitvec",
"funty",
"memchr",
"version_check",
]
[[package]]
name = "nom"
version = "7.0.0"
@@ -2119,8 +2200,9 @@ checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]]
name = "opaque-ke"
version = "0.6.0-pre.1"
source = "git+https://github.com/novifinancial/opaque-ke?rev=eb59676a940b15f77871aefe1e46d7b5bf85f40a#eb59676a940b15f77871aefe1e46d7b5bf85f40a"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26772682ba4fa69f11ae6e4af8bc83946372981ff31a026648d4acb2553c9ee8"
dependencies = [
"base64",
"curve25519-dalek",
@@ -2157,6 +2239,15 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a"
[[package]]
name = "openssl-src"
version = "111.16.0+1.1.1l"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ab2173f69416cf3ec12debb5823d244127d23a9b127d5a5189aa97c5fa2859f"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.66"
@@ -2166,6 +2257,7 @@ dependencies = [
"autocfg 1.0.1",
"cc",
"libc",
"openssl-src",
"pkg-config",
"vcpkg",
]
@@ -2408,6 +2500,29 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "quoted_printable"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1238256b09923649ec89b08104c4dfe9f6cb2fea734a5db5384e44916d59e9c5"
[[package]]
name = "r2d2"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "545c5bc2b880973c9c10e4067418407a0ccaa3091781d1671d46eb35107cb26f"
dependencies = [
"log",
"parking_lot",
"scheduled-thread-pool",
]
[[package]]
name = "radium"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "941ba9d78d8e2f7ce474c015eea4d9c6d25b6a3327f9832ee29a4de27f91bbb8"
[[package]]
name = "rand"
version = "0.7.3"
@@ -2509,15 +2624,6 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
dependencies = [
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.6.25"
@@ -2607,6 +2713,15 @@ dependencies = [
"winapi",
]
[[package]]
name = "scheduled-thread-pool"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6f74fd1204073fa02d5d5d68bec8021be4c38690b61264b2fdb48083d0e7d7"
dependencies = [
"parking_lot",
]
[[package]]
name = "scopeguard"
version = "1.1.0"
@@ -2635,6 +2750,16 @@ dependencies = [
"syn",
]
[[package]]
name = "secstr"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cce2c726741c320e5b8f1edd9a21b3c2c292ae94514afd001d41d81ba143dafc"
dependencies = [
"libc",
"serde",
]
[[package]]
name = "security-framework"
version = "2.4.2"
@@ -3049,6 +3174,12 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tempfile"
version = "3.2.0"
@@ -3286,9 +3417,9 @@ dependencies = [
[[package]]
name = "tracing-core"
version = "0.1.19"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ca517f43f0fb96e0c3072ed5c275fe5eece87e8cb52f4a77b69226d3b1c9df8"
checksum = "1f4ed65637b8390770814083d20756f87bfa2c21bf2f110babdc5438351746e4"
dependencies = [
"lazy_static",
]
@@ -3314,36 +3445,18 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-serde"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb65ea441fbb84f9f6748fd496cf7f63ec9af5bca94dd86456978d055e8eb28b"
dependencies = [
"serde",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.2.20"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cbe87a2fa7e35900ce5de20220a582a9483a7063811defce79d7cbd59d4cfe"
checksum = "80a4ddde70311d8da398062ecf6fc2c309337de6b0f77d6c27aff8d53f6fca52"
dependencies = [
"ansi_term",
"chrono",
"lazy_static",
"matchers",
"regex",
"serde",
"serde_json",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
"tracing-serde",
]
[[package]]
@@ -3648,6 +3761,12 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "wyz"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214"
[[package]]
name = "yansi"
version = "0.5.0"

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,38 @@ 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
# 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 \
# Build the frontend.
&& ./app/build.sh
# Final image
FROM alpine
FROM alpine:3.14
WORKDIR /app
COPY --from=builder /app/app/index.html /app/app/main.js /app/app/style.css app/
COPY --from=builder /app/app/pkg app/pkg
COPY --from=builder /app/target/release/lldap lldap
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 \
&& 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"]

221
README.md
View File

@@ -1,25 +1,51 @@
# 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
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.
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!
![Screenshot of the user list page](screenshot.png)
<img
src="https://raw.githubusercontent.com/nitnelave/lldap/master/screenshot.png"
alt="Screenshot of the user list page"
width="50%"
align="right"
/>
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,7 +57,7 @@ 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
@@ -42,6 +68,10 @@ file (unless you move them in the config).
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_USER_PASS_FILE`, and the file contents are loaded into the respective configuration parameters. Note that `_FILE` variables take precedence.
Example for docker compose:
@@ -53,6 +83,8 @@ volumes:
services:
lldap:
image: nitnelave/lldap
# Change this to the user:group you want.
user: "33:33"
ports:
# For LDAP
- "3890:3890"
@@ -60,10 +92,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 +119,36 @@ 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
No Docker image is provided for other architectures, due to the difficulty of
setting up cross-compilation inside a Docker image.
Some pre-compiled binaries are provided for each release, starting with 0.2.
If you want to cross-compile, you can do so by installing
[`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
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
@@ -103,6 +162,46 @@ 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.
### 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:
- [Authelia](example_configs/authelia_config.yml)
- [KeyCloak](example_configs/keycloak.md)
- [Jisti Meet](example_configs/jitsi_meet.conf)
## 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!
If you just set up the server, can get to the login page but the password you
@@ -122,90 +221,6 @@ set isn't working, try the following:
- 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).
## Contributions
Contributions are welcome! Just fork and open a PR. Or just file a bug.

View File

@@ -1,8 +1,8 @@
[package]
name = "lldap_app"
version = "0.1.0"
version = "0.2.0"
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
edition = "2018"
edition = "2021"
[dependencies]
anyhow = "1"

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,
@@ -101,40 +103,7 @@ impl Component for App {
<div class="row justify-content-center">
<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>
@@ -147,7 +116,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,6 +129,11 @@ 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
@@ -181,6 +159,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">

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,27 +200,15 @@ 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 {
@@ -237,7 +216,7 @@ impl Component for ChangePasswordForm {
}
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,33 +70,26 @@ 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 {
@@ -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,33 +163,26 @@ 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 {
@@ -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,38 +134,60 @@ 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 {
@@ -213,7 +195,7 @@ impl Component for GroupDetails {
}
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,18 +33,24 @@ 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 CommonComponent<GroupTable> for GroupTable {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::ListGroupsResponse(groups) => {
self.groups = Some(groups?.groups.into_iter().collect());
Ok(true)
}
Msg::OnError(e) => Err(e),
Msg::OnGroupDeleted(group_id) => {
debug_assert!(self.groups.is_some());
self.groups.as_mut().unwrap().retain(|u| u.id != group_id);
Ok(true)
}
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
@@ -56,27 +58,21 @@ impl Component for GroupTable {
type Message = Msg;
type Properties = ();
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
let mut table = GroupTable {
link,
_task: None,
common: CommonComponentParts::<Self>::create(props, link),
groups: None,
error: None,
};
table.get_groups();
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 {
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 {
@@ -94,21 +90,6 @@ impl Component for GroupTable {
}
impl GroupTable {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::ListGroupsResponse(groups) => {
self.groups = Some(groups?.groups.into_iter().collect());
Ok(true)
}
Msg::OnError(e) => Err(e),
Msg::OnGroupDeleted(group_id) => {
debug_assert!(self.groups.is_some());
self.groups.as_mut().unwrap().retain(|u| u.id != group_id);
Ok(true)
}
}
}
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,20 @@
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>,
}
/// The fields of the form, with the constraints.
@@ -44,8 +43,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 +60,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 +75,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 +85,26 @@ 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)
}
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl Component for LoginForm {
@@ -108,25 +113,13 @@ impl Component for LoginForm {
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
LoginForm {
link,
on_logged_in: props.on_logged_in,
error: None,
common: CommonComponentParts::<Self>::create(props, link),
form: Form::<FormModel>::new(FormModel::default()),
task: None,
}
}
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 {
@@ -152,7 +145,7 @@ impl Component for LoginForm {
field_name="username"
placeholder="Username"
autocomplete="username"
oninput=self.link.callback(|_| Msg::Update) />
oninput=self.common.callback(|_| Msg::Update) />
</div>
<div class="input-group">
<div class="input-group-prepend">
@@ -170,17 +163,23 @@ impl Component for LoginForm {
placeholder="Password"
autocomplete="current-password" />
</div>
<div class="form-group">
<div class="form-group mt-3">
<button
type="submit"
class="btn btn-primary"
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})>
{"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.error {
{ if let Some(e) = &self.common.error {
html! { e.to_string() }
} else { html! {} }
}

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,43 +20,39 @@ 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 {
@@ -67,7 +63,7 @@ impl Component for LogoutButton {
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,21 +69,16 @@ 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 {
@@ -101,8 +89,8 @@ impl Component for RemoveUserFromGroupComponent {
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, _: Self::Properties) -> ShouldRender {
false
}
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,232 @@
use crate::{
components::router::AppRoute,
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
},
};
use anyhow::{bail, Context, Result};
use lldap_auth::*;
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::client::registration::ClientRegistration>,
route_dispatcher: RouteAgentDispatcher,
}
#[derive(Clone, PartialEq, Properties)]
pub struct Props {
pub token: String,
}
pub enum Msg {
ValidateTokenResponse(Result<String>),
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?);
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::client::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::client::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, _: Self::Properties) -> ShouldRender {
false
}
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,26 +174,15 @@ 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 {
@@ -208,7 +190,7 @@ impl Component for UserDetails {
}
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,25 +81,19 @@ 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 {
@@ -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,18 +33,34 @@ pub enum Msg {
OnError(Error),
}
impl CommonComponent<UserTable> for UserTable {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::ListUsersResponse(users) => {
self.users = Some(users?.users.into_iter().collect());
Ok(true)
}
Msg::OnError(e) => Err(e),
Msg::OnUserDeleted(user_id) => {
debug_assert!(self.users.is_some());
self.users.as_mut().unwrap().retain(|u| u.id != user_id);
Ok(true)
}
}
}
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
}
impl UserTable {
fn get_users(&mut self, req: Option<RequestFilter>) {
self._task = HostService::graphql_query::<ListUsersQuery>(
self.common.call_graphql::<ListUsersQuery, _>(
list_users_query::Variables { filters: req },
self.link.callback(Msg::ListUsersResponse),
Msg::ListUsersResponse,
"Error trying to fetch users",
)
.map_err(|e| {
ConsoleService::log(&e.to_string());
e
})
.ok();
);
}
}
@@ -56,27 +68,17 @@ impl Component for UserTable {
type Message = Msg;
type Properties = ();
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
let mut table = UserTable {
link,
_task: None,
common: CommonComponentParts::<Self>::create(props, link),
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,
}
CommonComponentParts::<Self>::update(self, msg)
}
fn change(&mut self, _: Self::Properties) -> ShouldRender {
@@ -94,21 +96,6 @@ impl Component for UserTable {
}
impl UserTable {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::ListUsersResponse(users) => {
self.users = Some(users?.users.into_iter().collect());
Ok(true)
}
Msg::OnError(e) => Err(e),
Msg::OnUserDeleted(user_id) => {
debug_assert!(self.users.is_some());
self.users.as_mut().unwrap().retain(|u| u.id != user_id);
Ok(true)
}
}
}
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

@@ -223,7 +223,8 @@ impl HostService {
)
}
pub fn logout(callback: Callback<Result<()>>) -> Result<FetchTask> {
// 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 +232,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<String>>,
) -> 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

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

84
architecture.md Normal file
View File

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

View File

@@ -1,8 +1,8 @@
[package]
name = "lldap_auth"
version = "0.1.0"
version = "0.2.0"
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
edition = "2018"
edition = "2021"
[features]
default = ["opaque_server", "opaque_client"]
@@ -20,30 +20,13 @@ 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,3 +1,4 @@
#![forbid(non_ascii_idents)]
#![allow(clippy::nonstandard_macro_braces)]
use chrono::prelude::*;
use serde::{Deserialize, Serialize};

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

View File

@@ -0,0 +1,47 @@
###############################################################
# Authelia configuration #
###############################################################
# This is just the LDAP part of the Authelia configuration!
authentication_backend:
# Password reset through authelia works normally.
disable_reset_password: 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: cn=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,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=dc=example,dc=com
# LDAP user DN.
LDAP_BINDDN=cn=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: `cn=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

@@ -1,6 +1,7 @@
## 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.
## The port on which to have the LDAP server.
#ldap_port = 3890
@@ -9,13 +10,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 +37,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_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 +73,25 @@ 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"
## 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>"

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,8 +1,8 @@
[package]
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
edition = "2018"
edition = "2021"
name = "lldap"
version = "0.1.0"
version = "0.2.0"
[dependencies]
actix = "0.12"
@@ -26,7 +26,7 @@ futures-util = "*"
hmac = "0.10"
http = "*"
jwt = "0.13"
ldap3_server = "*"
ldap3_server = ">=0.1.9"
lldap_auth = { path = "../auth" }
log = "*"
orion = "0.16"
@@ -38,19 +38,28 @@ thiserror = "*"
time = "0.2"
tokio = { version = "1.2.0", features = ["full"] }
tokio-util = "0.6.3"
tokio-stream = "*"
tracing = "*"
tracing-actix-web = "0.4.0-beta.7"
tracing-log = "*"
tracing-subscriber = "*"
tracing-subscriber = "0.3"
rand = { version = "0.8", features = ["small_rng", "getrandom"] }
juniper_actix = "0.4.0"
juniper = "0.15.6"
itertools = "0.10.1"
# TODO: update to 0.6 when out.
[dependencies.opaque-ke]
git = "https://github.com/novifinancial/opaque-ke"
rev = "eb59676a940b15f77871aefe1e46d7b5bf85f40a"
version = "0.6"
[dependencies.lettre]
version = "0.10.0-rc.3"
features = [
"builder",
"serde",
"smtp-transport",
"tokio1-native-tls",
"tokio1",
]
[dependencies.sqlx]
version = "0.5.1"
@@ -72,5 +81,13 @@ features = ["with-chrono"]
features = ["env", "toml"]
version = "*"
[dependencies.secstr]
features = ["serde"]
version = "*"
[dependencies.openssl-sys]
features = ["vendored"]
version = "*"
[dev-dependencies]
mockall = "0.9.1"

View File

@@ -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<String>;
async fn registration_start(
&self,
request: registration::ClientRegistrationStartRequest
) -> Result<registration::ServerRegistrationStartResponse>;
async fn registration_finish(
&self,
request: registration::ClientRegistrationFinishRequest
) -> Result<()>;
}
}

View File

@@ -437,7 +437,7 @@ mod tests {
let sql_pool = get_in_memory_db().await;
let config = ConfigurationBuilder::default()
.ldap_user_dn("admin".to_string())
.ldap_user_pass("test".to_string())
.ldap_user_pass(secstr::SecUtf8::from("test"))
.build()
.unwrap();
let handler = SqlBackendHandler::new(config, sql_pool);

View File

@@ -9,6 +9,7 @@ use async_trait::async_trait;
use lldap_auth::opaque;
use log::*;
use sea_query::{Expr, Iden, Query};
use secstr::SecUtf8;
use sqlx::Row;
type SqlOpaqueHandler = SqlBackendHandler;
@@ -83,7 +84,7 @@ impl SqlBackendHandler {
impl LoginHandler for SqlBackendHandler {
async fn bind(&self, request: BindRequest) -> Result<()> {
if request.name == self.config.ldap_user_dn {
if request.password == self.config.ldap_user_pass {
if SecUtf8::from(request.password) == self.config.ldap_user_pass {
return Ok(());
} else {
debug!(r#"Invalid password for LDAP bind user"#);
@@ -220,11 +221,12 @@ impl OpaqueHandler for SqlOpaqueHandler {
pub(crate) async fn register_password(
opaque_handler: &SqlOpaqueHandler,
username: &str,
password: &str,
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(),
@@ -321,7 +323,7 @@ mod tests {
attempt_login(&opaque_handler, "bob", "bob00")
.await
.unwrap_err();
register_password(&opaque_handler, "bob", "bob00").await?;
register_password(&opaque_handler, "bob", &secstr::SecUtf8::from("bob00")).await?;
attempt_login(&opaque_handler, "bob", "wrong_password")
.await
.unwrap_err();

View File

@@ -15,6 +15,7 @@ use actix_web::{
error::{ErrorBadRequest, ErrorUnauthorized},
web, HttpRequest, HttpResponse,
};
use actix_web_httpauth::extractors::bearer::BearerAuth;
use anyhow::Result;
use chrono::prelude::*;
use futures::future::{ok, Ready};
@@ -22,6 +23,7 @@ use futures_util::{FutureExt, TryFutureExt};
use hmac::Hmac;
use jwt::{SignWithKey, VerifyWithKey};
use lldap_auth::{login, registration, JWTClaims};
use log::*;
use sha2::Sha512;
use std::collections::{hash_map::DefaultHasher, HashSet};
use std::hash::{Hash, Hasher};
@@ -101,7 +103,7 @@ where
.cookie(
Cookie::build("token", token.as_str())
.max_age(1.days())
.path("/api")
.path("/")
.http_only(true)
.same_site(SameSite::Strict)
.finish(),
@@ -111,6 +113,79 @@ where
.unwrap_or_else(error_to_http_response)
}
async fn get_password_reset_step1<Backend>(
data: web::Data<AppState<Backend>>,
request: HttpRequest,
) -> HttpResponse
where
Backend: TcpBackendHandler + BackendHandler + 'static,
{
let user_id = match request.match_info().get("user_id") {
None => return HttpResponse::BadRequest().body("Missing user ID"),
Some(id) => id,
};
let token = match data.backend_handler.start_password_reset(user_id).await {
Err(e) => return HttpResponse::InternalServerError().body(e.to_string()),
Ok(None) => return HttpResponse::Ok().finish(),
Ok(Some(token)) => token,
};
let user = match data.backend_handler.get_user_details(user_id).await {
Err(e) => {
warn!("Error getting used details: {:#?}", e);
return HttpResponse::Ok().finish();
}
Ok(u) => u,
};
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);
}
HttpResponse::Ok().finish()
}
async fn get_password_reset_step2<Backend>(
data: web::Data<AppState<Backend>>,
request: HttpRequest,
) -> HttpResponse
where
Backend: TcpBackendHandler + BackendHandler + 'static,
{
let token = match request.match_info().get("token") {
None => return HttpResponse::BadRequest().body("Missing token"),
Some(token) => token,
};
let user_id = match data
.backend_handler
.get_user_id_for_password_reset_token(token)
.await
{
Err(_) => return HttpResponse::Unauthorized().body("Invalid or expired token"),
Ok(user_id) => user_id,
};
let _ = data
.backend_handler
.delete_password_reset_token(token)
.await;
let groups = HashSet::new();
let token = create_jwt(&data.jwt_key, user_id.to_string(), groups);
HttpResponse::Ok()
.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(user_id)
}
async fn get_logout<Backend>(
data: web::Data<AppState<Backend>>,
request: HttpRequest,
@@ -148,7 +223,7 @@ where
.cookie(
Cookie::build("token", "")
.max_age(0.days())
.path("/api")
.path("/")
.http_only(true)
.same_site(SameSite::Strict)
.finish(),
@@ -203,7 +278,7 @@ where
.cookie(
Cookie::build("token", token.as_str())
.max_age(1.days())
.path("/api")
.path("/")
.http_only(true)
.same_site(SameSite::Strict)
.finish(),
@@ -254,14 +329,45 @@ where
}
async fn opaque_register_start<Backend>(
request: actix_web::HttpRequest,
mut payload: actix_web::web::Payload,
data: web::Data<AppState<Backend>>,
request: web::Json<registration::ClientRegistrationStartRequest>,
) -> ApiResult<registration::ServerRegistrationStartResponse>
where
Backend: OpaqueHandler + 'static,
{
use actix_web::FromRequest;
let validation_result = match BearerAuth::from_request(&request, &mut payload.0)
.await
.ok()
.and_then(|bearer| check_if_token_is_valid(&data, bearer.token()).ok())
{
Some(t) => t,
None => {
return ApiResult::Right(
HttpResponse::Unauthorized().body("Not authorized to change the user's password"),
)
}
};
let registration_start_request =
match web::Json::<registration::ClientRegistrationStartRequest>::from_request(
&request,
&mut payload.0,
)
.await
{
Ok(r) => r,
Err(e) => {
return ApiResult::Right(
HttpResponse::BadRequest().body(format!("Bad request: {:#?}", e)),
)
}
}
.into_inner();
let user_id = &registration_start_request.username;
validation_result.can_access(user_id);
data.backend_handler
.registration_start(request.into_inner())
.registration_start(registration_start_request)
.await
.map(|res| ApiResult::Left(web::Json(res)))
.unwrap_or_else(error_to_api_response)
@@ -402,14 +508,25 @@ where
web::resource("/opaque/login/finish")
.route(web::post().to(opaque_login_finish::<Backend>)),
)
.service(
web::resource("/opaque/register/start")
.route(web::post().to(opaque_register_start::<Backend>)),
)
.service(
web::resource("/opaque/register/finish")
.route(web::post().to(opaque_register_finish::<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("/reset/step1/{user_id}")
.route(web::get().to(get_password_reset_step1::<Backend>)),
)
.service(
web::resource("/reset/step2/{token}")
.route(web::get().to(get_password_reset_step2::<Backend>)),
)
.service(web::resource("/logout").route(web::get().to(get_logout::<Backend>)))
.service(
web::scope("/opaque/register")
.wrap(CookieToHeaderTranslatorFactory)
.service(
web::resource("/start").route(web::post().to(opaque_register_start::<Backend>)),
)
.service(
web::resource("/finish")
.route(web::post().to(opaque_register_finish::<Backend>)),
),
);
}

View File

@@ -1,4 +1,5 @@
use clap::Clap;
use lettre::message::Mailbox;
/// lldap is a lightweight LDAP server
#[derive(Debug, Clap, Clone)]
@@ -9,6 +10,7 @@ pub struct CLIOpts {
pub command: Command,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clap, Clone)]
pub enum Command {
/// Export the GraphQL schema to *.graphql.
@@ -17,25 +19,100 @@ 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 GeneralConfigOpts {
/// Change config file name.
#[clap(
short,
long,
default_value = "lldap_config.toml",
env = "LLDAP_CONFIG_FILE"
)]
pub config_file: String,
/// Set verbose logging.
#[clap(short, long)]
pub verbose: bool,
}
#[derive(Debug, Clap, Clone)]
pub struct RunOpts {
/// Change config file name
#[clap(short, long, default_value = "lldap_config.toml")]
pub config_file: String,
#[clap(flatten)]
pub general_config: GeneralConfigOpts,
/// Change ldap port. Default: 389
#[clap(long)]
/// 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 ldap ssl port. Default: 636
#[clap(long)]
/// Change ldap ssl port. Default: 6360
#[clap(long, env = "LLDAP_LDAPS_PORT")]
pub ldaps_port: Option<u16>,
/// Set verbose logging
#[clap(short, long)]
pub verbose: bool,
/// 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,
}
#[derive(Debug, Clap, 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, Clap, Clone)]
pub struct SmtpOpts {
/// Sender email address.
#[clap(long)]
#[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, Clap, Clone)]

View File

@@ -1,49 +1,83 @@
use crate::infra::cli::{GeneralConfigOpts, 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")
)]
#[builder(pattern = "owned", build_fn(name = "private_build"))]
pub struct Configuration {
#[builder(default = "3890")]
pub ldap_port: u16,
#[builder(default = "6360")]
pub ldaps_port: u16,
#[builder(default = "17170")]
pub http_port: u16,
pub jwt_secret: String,
#[builder(default = r#"SecUtf8::from("secretjwtsecret")"#)]
pub jwt_secret: SecUtf8,
#[builder(default = r#"String::from("dc=example,dc=com")"#)]
pub ldap_base_dn: String,
#[builder(default = r#"String::from("admin")"#)]
pub ldap_user_dn: String,
pub ldap_user_pass: String,
#[builder(default = r#"SecUtf8::from("password")"#)]
pub ldap_user_pass: SecUtf8,
#[builder(default = r#"String::from("sqlite://users.db?mode=rwc")"#)]
pub database_url: String,
#[builder(default = "false")]
pub verbose: bool,
#[builder(default = r#"String::from("server_key")"#)]
pub key_file: String,
#[builder(default)]
pub smtp_options: MailOptions,
#[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 +89,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 +109,122 @@ 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.ldaps_port {
config.ldaps_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);
}
}
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 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

@@ -1,7 +1,6 @@
use crate::domain::handler::{BackendHandler, GroupId, GroupIdAndName};
use juniper::{graphql_object, FieldResult, GraphQLInputObject};
use serde::{Deserialize, Serialize};
use std::convert::TryInto;
type DomainRequestFilter = crate::domain::handler::RequestFilter;
type DomainUser = crate::domain::handler::User;

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(
@@ -95,5 +104,38 @@ pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
.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")
.table(PasswordResetTokens::Table, Users::Table)
.col(PasswordResetTokens::UserId, Users::UserId)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_string(DbQueryBuilder {}),
)
.execute(pool)
.await?;
Ok(())
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,16 @@
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 anyhow::{Context, Result};
use futures_util::future::ok;
use ldap3_server::simple::*;
use ldap3_server::LdapCodec;
use ldap3_server::{proto::LdapMsg, LdapCodec};
use log::*;
use tokio::net::tcp::WriteHalf;
use tokio_util::codec::{FramedRead, FramedWrite};
@@ -18,36 +21,31 @@ async fn handle_incoming_message<Backend>(
session: &mut LdapHandler<Backend>,
) -> Result<bool>
where
Backend: BackendHandler + LoginHandler,
Backend: BackendHandler + LoginHandler + OpaqueHandler,
{
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!("Received LDAP message: {:?}", &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 result_op in result.into_iter() {
debug!("Replying with LDAP op: {:?}", &result_op);
resp.send(LdapMsg {
msgid: msg.msgid,
op: result_op,
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)
@@ -59,14 +57,14 @@ pub fn build_ldap_server<Backend>(
server_builder: ServerBuilder,
) -> Result<ServerBuilder>
where
Backend: BackendHandler + LoginHandler + 'static,
Backend: BackendHandler + LoginHandler + OpaqueHandler + 'static,
{
use futures_util::StreamExt;
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 || {
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();
@@ -83,7 +81,10 @@ where
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? {
if !handle_incoming_message(msg, &mut resp, &mut session)
.await
.context("while handling incoming messages")?
{
break;
}
}
@@ -91,12 +92,11 @@ where
Ok(stream)
}
})
.map_err(|err: anyhow::Error| error!("Service Error: {:?}", err))
// catch
.map_err(|err: anyhow::Error| error!("Service Error: {:#}", err))
.and_then(move |_| {
// finally
ok(())
})
})?,
)
})
.with_context(|| format!("while binding to the port {}", config.ldap_port))
}

View File

@@ -1,25 +1,30 @@
use crate::infra::configuration::Configuration;
use anyhow::Context;
use tracing::subscriber::set_global_default;
use tracing_log::LogTracer;
use tracing_subscriber::prelude::*;
pub fn init(config: Configuration) -> anyhow::Result<()> {
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")?;
let sqlx_max_log_level = sqlx_log_level_from_config(config);
let filter = tracing_subscriber::filter::Targets::new()
.with_target("lldap", max_log_level)
.with_target("sqlx", sqlx_max_log_level);
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer().with_filter(filter))
.init();
Ok(())
}
fn log_level_from_config(config: Configuration) -> tracing::Level {
fn log_level_from_config(config: &Configuration) -> tracing::Level {
if config.verbose {
tracing::Level::DEBUG
} else {
tracing::Level::INFO
}
}
fn sqlx_log_level_from_config(config: &Configuration) -> tracing::Level {
if config.verbose {
tracing::Level::INFO
} else {
tracing::Level::WARN
}
}

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

@@ -6,10 +6,19 @@ use sea_query::{Expr, Iden, Query, SimpleExpr};
use sqlx::Row;
use std::collections::HashSet;
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 {
async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>> {
use sqlx::Result;
let query = Query::select()
.column(JwtStorage::JwtHash)
.from(JwtStorage::Table)
@@ -21,21 +30,15 @@ impl TcpBackendHandler for SqlBackendHandler {
.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};
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);
@@ -71,7 +74,7 @@ impl TcpBackendHandler for SqlBackendHandler {
.await?
.is_some())
}
async fn blacklist_jwts(&self, user: &str) -> DomainResult<HashSet<u64>> {
async fn blacklist_jwts(&self, user: &str) -> Result<HashSet<u64>> {
use sqlx::Result;
let query = Query::select()
.column(JwtStorage::JwtHash)
@@ -94,7 +97,7 @@ impl TcpBackendHandler for SqlBackendHandler {
sqlx::query(&query).execute(&self.sql_pool).await?;
Ok(result?)
}
async fn delete_refresh_token(&self, refresh_token_hash: u64) -> DomainResult<()> {
async fn delete_refresh_token(&self, refresh_token_hash: u64) -> Result<()> {
let query = Query::delete()
.from_table(JwtRefreshStorage::Table)
.and_where(Expr::col(JwtRefreshStorage::RefreshTokenHash).eq(refresh_token_hash))
@@ -102,4 +105,59 @@ impl TcpBackendHandler for SqlBackendHandler {
sqlx::query(&query).execute(&self.sql_pool).await?;
Ok(())
}
async fn start_password_reset(&self, user: &str) -> Result<Option<String>> {
let query = Query::select()
.column(Users::UserId)
.from(Users::Table)
.and_where(Expr::col(Users::UserId).eq(user))
.to_string(DbQueryBuilder {});
// Check that the user exists.
if sqlx::query(&query).fetch_one(&self.sql_pool).await.is_err() {
return Ok(None);
}
let token = gen_random_string(100);
let duration = chrono::Duration::minutes(10);
let query = 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(),
])
.to_string(DbQueryBuilder {});
sqlx::query(&query).execute(&self.sql_pool).await?;
Ok(Some(token))
}
async fn get_user_id_for_password_reset_token(&self, token: &str) -> Result<String> {
let query = 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()),
)
.to_string(DbQueryBuilder {});
let (user_id,) = sqlx::query_as(&query).fetch_one(&self.sql_pool).await?;
Ok(user_id)
}
async fn delete_password_reset_token(&self, token: &str) -> Result<()> {
let query = Query::delete()
.from_table(PasswordResetTokens::Table)
.and_where(Expr::col(PasswordResetTokens::Token).eq(token))
.to_string(DbQueryBuilder {});
sqlx::query(&query).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;
#[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: &str) -> Result<(String, chrono::Duration)>;
async fn check_token(&self, refresh_token_hash: u64, user: &str) -> Result<bool>;
async fn blacklist_jwts(&self, user: &str) -> 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: &str) -> 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<String>;
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<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 get_user_groups(&self, user: &str) -> Result<HashSet<GroupIdAndName>>;
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 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_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: &str) -> Result<(String, chrono::Duration)>;
async fn check_token(&self, refresh_token_hash: u64, user: &str) -> Result<bool>;
async fn blacklist_jwts(&self, user: &str) -> Result<HashSet<u64>>;
async fn delete_refresh_token(&self, refresh_token_hash: u64) -> Result<()>;
async fn start_password_reset(&self, user: &str) -> Result<Option<String>>;
async fn get_user_id_for_password_reset_token(&self, token: &str) -> Result<String>;
async fn delete_password_reset_token(&self, token: &str) -> Result<()>;
}
}

View File

@@ -4,7 +4,11 @@ use crate::{
handler::{BackendHandler, LoginHandler},
opaque_handler::OpaqueHandler,
},
infra::{auth_service, configuration::Configuration, tcp_backend_handler::*},
infra::{
auth_service,
configuration::{Configuration, MailOptions},
tcp_backend_handler::*,
},
};
use actix_files::{Files, NamedFile};
use actix_http::HttpServiceBuilder;
@@ -44,15 +48,19 @@ pub(crate) fn error_to_http_response(error: DomainError) -> HttpResponse {
fn http_config<Backend>(
cfg: &mut web::ServiceConfig,
backend_handler: Backend,
jwt_secret: String,
jwt_secret: secstr::SecUtf8,
jwt_blacklist: HashSet<u64>,
server_url: String,
mail_options: MailOptions,
) where
Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Sync + 'static,
{
cfg.app_data(web::Data::new(AppState::<Backend> {
backend_handler,
jwt_key: Hmac::new_varkey(jwt_secret.as_bytes()).unwrap(),
jwt_key: Hmac::new_varkey(jwt_secret.unsecure().as_bytes()).unwrap(),
jwt_blacklist: RwLock::new(jwt_blacklist),
server_url,
mail_options,
}))
// Serve index.html and main.js, and default to index.html.
.route(
@@ -76,6 +84,8 @@ pub(crate) struct AppState<Backend> {
pub backend_handler: Backend,
pub jwt_key: Hmac<Sha512>,
pub jwt_blacklist: RwLock<HashSet<u64>>,
pub server_url: String,
pub mail_options: MailOptions,
}
pub async fn build_tcp_server<Backend>(
@@ -87,16 +97,30 @@ where
Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Sync + 'static,
{
let jwt_secret = config.jwt_secret.clone();
let jwt_blacklist = backend_handler.get_jwt_blacklist().await?;
let jwt_blacklist = backend_handler
.get_jwt_blacklist()
.await
.context("while getting the jwt blacklist")?;
let server_url = config.http_url.clone();
let mail_options = config.smtp_options.clone();
server_builder
.bind("http", ("0.0.0.0", config.http_port), move || {
let backend_handler = backend_handler.clone();
let jwt_secret = jwt_secret.clone();
let jwt_blacklist = jwt_blacklist.clone();
let server_url = server_url.clone();
let mail_options = mail_options.clone();
HttpServiceBuilder::new()
.finish(map_config(
App::new().configure(move |cfg| {
http_config(cfg, backend_handler, jwt_secret, jwt_blacklist)
http_config(
cfg,
backend_handler,
jwt_secret,
jwt_blacklist,
server_url,
mail_options,
)
}),
|_| AppConfig::default(),
))

View File

@@ -1,4 +1,5 @@
#![forbid(unsafe_code)]
#![forbid(non_ascii_idents)]
#![allow(clippy::nonstandard_macro_braces)]
use crate::{
@@ -8,7 +9,7 @@ use crate::{
sql_opaque_handler::register_password,
sql_tables::PoolOptions,
},
infra::{cli::*, configuration::Configuration, db_cleaner::Scheduler},
infra::{cli::*, configuration::Configuration, db_cleaner::Scheduler, mail},
};
use actix::Actor;
use anyhow::{anyhow, Context, Result};
@@ -19,10 +20,11 @@ mod domain;
mod infra;
async fn create_admin_user(handler: &SqlBackendHandler, config: &Configuration) -> Result<()> {
let pass_length = config.ldap_user_pass.unsecure().len();
assert!(
config.ldap_user_pass.len() >= 8,
pass_length >= 8,
"Minimum password length is 8 characters, got {} characters",
config.ldap_user_pass.len()
pass_length
);
handler
.create_user(CreateUserRequest {
@@ -47,51 +49,69 @@ async fn run_server(config: Configuration) -> Result<()> {
let sql_pool = PoolOptions::new()
.max_connections(5)
.connect(&config.database_url)
.await?;
domain::sql_tables::init_table(&sql_pool).await?;
.await
.context("while connecting to the DB")?;
domain::sql_tables::init_table(&sql_pool)
.await
.context("while creating the tables")?;
let backend_handler = SqlBackendHandler::new(config.clone(), sql_pool.clone());
if let Err(e) = backend_handler.get_user_details(&config.ldap_user_dn).await {
warn!("Could not get admin user, trying to create it: {:#}", e);
create_admin_user(&backend_handler, &config)
.await
.map_err(|e| anyhow!("Error setting up admin login/account: {:#}", e))?;
.map_err(|e| anyhow!("Error setting up admin login/account: {:#}", e))
.context("while creating the admin user")?;
}
let server_builder = infra::ldap_server::build_ldap_server(
&config,
backend_handler.clone(),
actix_server::Server::build(),
)?;
)
.context("while binding the LDAP server")?;
infra::jwt_sql_tables::init_table(&sql_pool).await?;
let server_builder =
infra::tcp_server::build_tcp_server(&config, backend_handler, server_builder).await?;
infra::tcp_server::build_tcp_server(&config, backend_handler, server_builder)
.await
.context("while binding the TCP server")?;
// Run every hour.
let scheduler = Scheduler::new("0 0 * * * * *", sql_pool);
scheduler.start();
server_builder.workers(1).run().await?;
server_builder
.workers(1)
.run()
.await
.context("while starting the server")?;
Ok(())
}
fn run_server_command(opts: RunOpts) -> Result<()> {
let config = infra::configuration::init(opts.clone())?;
infra::logging::init(config.clone())?;
debug!("CLI: {:#?}", &opts);
let config = infra::configuration::init(opts)?;
infra::logging::init(&config)?;
info!("Starting LLDAP....");
debug!("CLI: {:#?}", opts);
debug!("Configuration: {:#?}", config);
actix::run(
run_server(config).unwrap_or_else(|e| error!("Could not bring up the servers: {:?}", e)),
run_server(config).unwrap_or_else(|e| error!("Could not bring up the servers: {:#}", e)),
)?;
info!("End.");
Ok(())
}
fn send_test_email_command(opts: TestEmailOpts) -> Result<()> {
let to = opts.to.parse()?;
let config = infra::configuration::init(opts)?;
infra::logging::init(&config)?;
mail::send_test_email(to, &config.smtp_options)
}
fn main() -> Result<()> {
let cli_opts = infra::cli::init();
match cli_opts.command {
Command::ExportGraphQLSchema(opts) => infra::graphql::api::export_schema(opts),
Command::Run(opts) => run_server_command(opts),
Command::SendTestEmail(opts) => send_test_email_command(opts),
}
}