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 # Don't track git
.git/* .git/*
.github/*
.gitignore
# Don't track cargo generated files # Don't track cargo generated files
target/* target/*
@@ -18,5 +20,6 @@ Dockerfile
lldap_config.toml lldap_config.toml
server_key server_key
users.db* users.db*
.gitignore
screenshot.png 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: steps:
- name: Checkout sources - name: Checkout sources
uses: actions/checkout@v2 uses: actions/checkout@v2
- uses: Swatinem/rust-cache@v1
- name: Build - name: Build
run: cargo build --verbose --workspace run: cargo build --verbose --workspace
- name: Run tests - name: Run tests
@@ -42,6 +43,8 @@ jobs:
override: true override: true
components: rustfmt, clippy components: rustfmt, clippy
- uses: Swatinem/rust-cache@v1
- name: Run cargo clippy - name: Run cargo clippy
uses: actions-rs/cargo@v1 uses: actions-rs/cargo@v1
with: with:
@@ -63,8 +66,37 @@ jobs:
override: true override: true
components: rustfmt, clippy components: rustfmt, clippy
- uses: Swatinem/rust-cache@v1
- name: Run cargo fmt - name: Run cargo fmt
uses: actions-rs/cargo@v1 uses: actions-rs/cargo@v1
with: with:
command: fmt command: fmt
args: --all -- --check 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 private key
server_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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 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]] [[package]]
name = "blake2b_simd" name = "blake2b_simd"
version = "0.5.11" version = "0.5.11"
@@ -997,6 +1009,15 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "fastrand"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b394ed3d285a429378d3b384b9eb1285267e7df4b166df24b7a6939a04dc392e"
dependencies = [
"instant",
]
[[package]] [[package]]
name = "figment" name = "figment"
version = "0.10.6" version = "0.10.6"
@@ -1069,6 +1090,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69a039c3498dc930fe810151a34ba0c1c70b02b8625035592e74432f678591f2" checksum = "69a039c3498dc930fe810151a34ba0c1c70b02b8625035592e74432f678591f2"
[[package]]
name = "funty"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fed34cd105917e91daa4da6b3728c47b068749d6a62c59811f06ed2ac71d9da7"
[[package]] [[package]]
name = "futures" name = "futures"
version = "0.3.17" version = "0.3.17"
@@ -1443,6 +1470,17 @@ dependencies = [
"digest", "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]] [[package]]
name = "http" name = "http"
version = "0.2.4" version = "0.2.4"
@@ -1466,6 +1504,12 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acd94fdbe1d4ff688b67b04eee2e17bd50995534a61539e45adfefb45e5e5503" checksum = "acd94fdbe1d4ff688b67b04eee2e17bd50995534a61539e45adfefb45e5e5503"
[[package]]
name = "httpdate"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6456b8a6c8f33fee7d958fcd1b60d55b11940a79e63ae87013e6d22e26034440"
[[package]] [[package]]
name = "ident_case" name = "ident_case"
version = "1.0.1" version = "1.0.1"
@@ -1644,15 +1688,41 @@ dependencies = [
[[package]] [[package]]
name = "ldap3_server" name = "ldap3_server"
version = "0.1.7" version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3beb05c22d6cb1792389efb3e71ed90af6148b6f26d283db67322d356ab2556d" checksum = "092da326ef499380e33fc8213a621de7fb342d6cd112eb695e16161a0acb061a"
dependencies = [ dependencies = [
"bytes", "bytes",
"lber", "lber",
"tokio-util", "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]] [[package]]
name = "lexical-core" name = "lexical-core"
version = "0.7.6" version = "0.7.6"
@@ -1697,7 +1767,7 @@ checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3"
[[package]] [[package]]
name = "lldap" name = "lldap"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"actix", "actix",
"actix-files", "actix-files",
@@ -1725,13 +1795,16 @@ dependencies = [
"juniper_actix", "juniper_actix",
"jwt", "jwt",
"ldap3_server", "ldap3_server",
"lettre",
"lldap_auth", "lldap_auth",
"log", "log",
"mockall", "mockall",
"opaque-ke", "opaque-ke",
"openssl-sys",
"orion", "orion",
"rand 0.8.4", "rand 0.8.4",
"sea-query", "sea-query",
"secstr",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
@@ -1740,6 +1813,7 @@ dependencies = [
"thiserror", "thiserror",
"time 0.2.27", "time 0.2.27",
"tokio", "tokio",
"tokio-stream",
"tokio-util", "tokio-util",
"tracing", "tracing",
"tracing-actix-web", "tracing-actix-web",
@@ -1749,7 +1823,7 @@ dependencies = [
[[package]] [[package]]
name = "lldap_app" name = "lldap_app"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -1773,7 +1847,7 @@ dependencies = [
[[package]] [[package]]
name = "lldap_auth" name = "lldap_auth"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"curve25519-dalek", "curve25519-dalek",
@@ -1785,8 +1859,6 @@ dependencies = [
"rust-argon2", "rust-argon2",
"serde", "serde",
"sha2", "sha2",
"sqlx",
"sqlx-core",
"thiserror", "thiserror",
] ]
@@ -1833,13 +1905,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]] [[package]]
name = "matchers" name = "match_cfg"
version = "0.0.1" version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f099785f7595cc4b4553a174ce30dd7589ef93391ff414dbb67f62392b9e0ce1" checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4"
dependencies = [
"regex-automata",
]
[[package]] [[package]]
name = "matches" name = "matches"
@@ -1989,6 +2058,18 @@ dependencies = [
"version_check", "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]] [[package]]
name = "nom" name = "nom"
version = "7.0.0" version = "7.0.0"
@@ -2119,8 +2200,9 @@ checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
[[package]] [[package]]
name = "opaque-ke" name = "opaque-ke"
version = "0.6.0-pre.1" version = "0.6.0"
source = "git+https://github.com/novifinancial/opaque-ke?rev=eb59676a940b15f77871aefe1e46d7b5bf85f40a#eb59676a940b15f77871aefe1e46d7b5bf85f40a" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26772682ba4fa69f11ae6e4af8bc83946372981ff31a026648d4acb2553c9ee8"
dependencies = [ dependencies = [
"base64", "base64",
"curve25519-dalek", "curve25519-dalek",
@@ -2157,6 +2239,15 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a" 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]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.66" version = "0.9.66"
@@ -2166,6 +2257,7 @@ dependencies = [
"autocfg 1.0.1", "autocfg 1.0.1",
"cc", "cc",
"libc", "libc",
"openssl-src",
"pkg-config", "pkg-config",
"vcpkg", "vcpkg",
] ]
@@ -2408,6 +2500,29 @@ dependencies = [
"proc-macro2", "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]] [[package]]
name = "rand" name = "rand"
version = "0.7.3" version = "0.7.3"
@@ -2509,15 +2624,6 @@ dependencies = [
"regex-syntax", "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]] [[package]]
name = "regex-syntax" name = "regex-syntax"
version = "0.6.25" version = "0.6.25"
@@ -2607,6 +2713,15 @@ dependencies = [
"winapi", "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]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.1.0" version = "1.1.0"
@@ -2635,6 +2750,16 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "secstr"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cce2c726741c320e5b8f1edd9a21b3c2c292ae94514afd001d41d81ba143dafc"
dependencies = [
"libc",
"serde",
]
[[package]] [[package]]
name = "security-framework" name = "security-framework"
version = "2.4.2" version = "2.4.2"
@@ -3049,6 +3174,12 @@ dependencies = [
"unicode-xid", "unicode-xid",
] ]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.2.0" version = "3.2.0"
@@ -3286,9 +3417,9 @@ dependencies = [
[[package]] [[package]]
name = "tracing-core" name = "tracing-core"
version = "0.1.19" version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ca517f43f0fb96e0c3072ed5c275fe5eece87e8cb52f4a77b69226d3b1c9df8" checksum = "1f4ed65637b8390770814083d20756f87bfa2c21bf2f110babdc5438351746e4"
dependencies = [ dependencies = [
"lazy_static", "lazy_static",
] ]
@@ -3314,36 +3445,18 @@ dependencies = [
"tracing-core", "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]] [[package]]
name = "tracing-subscriber" name = "tracing-subscriber"
version = "0.2.20" version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cbe87a2fa7e35900ce5de20220a582a9483a7063811defce79d7cbd59d4cfe" checksum = "80a4ddde70311d8da398062ecf6fc2c309337de6b0f77d6c27aff8d53f6fca52"
dependencies = [ dependencies = [
"ansi_term", "ansi_term",
"chrono",
"lazy_static",
"matchers",
"regex",
"serde",
"serde_json",
"sharded-slab", "sharded-slab",
"smallvec", "smallvec",
"thread_local", "thread_local",
"tracing",
"tracing-core", "tracing-core",
"tracing-log", "tracing-log",
"tracing-serde",
] ]
[[package]] [[package]]
@@ -3648,6 +3761,12 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "wyz"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214"
[[package]] [[package]]
name = "yansi" name = "yansi"
version = "0.5.0" version = "0.5.0"

View File

@@ -1,5 +1,5 @@
# Build image # Build image
FROM rust:alpine AS chef FROM rust:alpine3.14 AS chef
RUN set -x \ RUN set -x \
# Add user # Add user
@@ -9,12 +9,13 @@ RUN set -x \
--ingroup app \ --ingroup app \
--home /app \ --home /app \
--uid 10001 \ --uid 10001 \
app app \
RUN set -x \
# Install required packages # Install required packages
&& apk add npm openssl-dev musl-dev make perl && apk add npm openssl-dev musl-dev make perl curl
USER app USER app
WORKDIR /app WORKDIR /app
RUN set -x \ RUN set -x \
# Install build tools # Install build tools
&& RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack cargo-chef \ && RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack cargo-chef \
@@ -24,44 +25,38 @@ RUN set -x \
# Prepare the dependency list. # Prepare the dependency list.
FROM chef AS planner FROM chef AS planner
COPY . . 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 FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json COPY --from=planner /tmp/recipe.json recipe.json
RUN cargo chef cook --release -p lldap --recipe-path recipe.json \ RUN cargo chef cook --release -p lldap_app --target wasm32-unknown-unknown \
&& 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 . . COPY --chown=app:app . .
RUN cargo build --release -p lldap RUN cargo build --release -p lldap \
# TODO: release mode. # Build the frontend.
RUN ./app/build.sh && ./app/build.sh
# Final image # 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 \ RUN set -x \
# Add user && apk add --no-cache bash \
&& addgroup --gid 10001 app \ && chmod a+r -R .
&& 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
ENV LDAP_PORT=3890 ENV LDAP_PORT=3890
ENV HTTP_PORT=17170 ENV HTTP_PORT=17170
EXPOSE ${LDAP_PORT} ${HTTP_PORT} 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) <p align="center">
![Discord](https://img.shields.io/discord/898492935446876200) <i style="font-size:24px">LDAP made easy.</i>
![Twitter Follow](https://img.shields.io/twitter/follow/nitnelave1?style=social) </p>
WARNING: This project is still in alpha, with the basic core functionality <p align="center">
implemented but still very rough. For updates, follow <a href="https://github.com/nitnelave/lldap/actions/workflows/rust.yml?query=branch%3Amain">
[@nitnelave1](https://twitter.com/nitnelave1) or join our [Discord <img
server](https://discord.gg/h5PEdRMNyP)! 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 This project is a lightweight authentication server that provides an
opinionated, simplified LDAP interface for authentication: clients that can opinionated, simplified LDAP interface for authentication. It integrates with
only speak LDAP protocol can talk to it and use it as an authentication server. 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, 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: check out OpenLDAP. This server is a user management system that is:
* simple to setup (no messing around with `slapd`) * simple to setup (no messing around with `slapd`),
* simple to manage (friendly web UI) * simple to manage (friendly web UI),
* low resources,
* opinionated with basic defaults so you don't have to understand the * opinionated with basic defaults so you don't have to understand the
subtleties of LDAP. 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 other components (KeyCloak, Authelia, ...) using this server as the source of
truth for users, via LDAP. truth for users, via LDAP.
## Setup ## Installation
### With Docker ### 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 Configure the server by copying the `lldap_config.docker_template.toml` to
`/data/lldap_config.toml` and updating the configuration values (especially the `/data/lldap_config.toml` and updating the configuration values (especially the
`jwt_secret` and `ldap_user_pass`, unless you override them with env variables). `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: Example for docker compose:
@@ -53,6 +83,8 @@ volumes:
services: services:
lldap: lldap:
image: nitnelave/lldap image: nitnelave/lldap
# Change this to the user:group you want.
user: "33:33"
ports: ports:
# For LDAP # For LDAP
- "3890:3890" - "3890:3890"
@@ -60,10 +92,12 @@ services:
- "17170:17170" - "17170:17170"
volumes: volumes:
- "lldap_data:/data" - "lldap_data:/data"
# Alternatively, you can mount a local folder
# - "./lldap_data:/data"
environment: environment:
- JWT_SECRET=REPLACE_WITH_RANDOM - LLDAP_JWT_SECRET=REPLACE_WITH_RANDOM
- LDAP_USER_PASS=REPLACE_WITH_PASSWORD - LLDAP_LDAP_USER_PASS=REPLACE_WITH_PASSWORD
- LDAP_BASE_DN=dc=example,dc=com - LLDAP_LDAP_BASE_DN=dc=example,dc=com
``` ```
Then the service will listen on two ports, one for LDAP and one for the web 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 `lldap_config.toml`, setting environment variables or passing arguments to
`cargo run`. `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 ## Client configuration
To configure the services that will talk to LLDAP, here are the values: To configure the services that will talk to LLDAP, here are the values:
- The LDAP user DN is from the configuration. By default, - 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 - The LDAP password is from the configuration (same as to log in to the web
UI). UI).
- The users are all located in `ou=people,` + the base DN, so by default user - 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 The administrator group for LLDAP is `lldap_admin`: anyone in this group has
admin rights in the Web UI. admin rights in the Web UI.
### 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! ## I can't log in!
If you just set up the server, can get to the login page but the password you 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. - Make sure you restart the server.
- If it's still not working, join the [Discord server](https://discord.gg/h5PEdRMNyP) to ask for help. - If it's still not working, join the [Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
## 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
Contributions are welcome! Just fork and open a PR. Or just file a bug. Contributions are welcome! Just fork and open a PR. Or just file a bug.

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,8 @@ use crate::{
group_table::GroupTable, group_table::GroupTable,
login::LoginForm, login::LoginForm,
logout::LogoutButton, logout::LogoutButton,
reset_password_step1::ResetPasswordStep1Form,
reset_password_step2::ResetPasswordStep2Form,
router::{AppRoute, Link, NavButton}, router::{AppRoute, Link, NavButton},
user_details::UserDetails, user_details::UserDetails,
user_table::UserTable, user_table::UserTable,
@@ -101,40 +103,7 @@ impl Component for App {
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="shadow-sm py-3" style="max-width: 1000px"> <div class="shadow-sm py-3" style="max-width: 1000px">
<Router<AppRoute> <Router<AppRoute>
render = Router::render(move |switch: AppRoute| { render = Router::render(move |s| Self::dispatch_route(s, &link, is_admin))
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 />
}
}
})
/> />
</div> </div>
</div> </div>
@@ -147,7 +116,11 @@ impl App {
fn get_redirect_route() -> Option<AppRoute> { fn get_redirect_route() -> Option<AppRoute> {
let route_service = RouteService::<()>::new(); let route_service = RouteService::<()>::new();
let current_route = route_service.get_path(); 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 None
} else { } else {
use yew_router::Switch; use yew_router::Switch;
@@ -156,6 +129,11 @@ impl App {
} }
fn apply_initial_redirections(&mut self) { 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 { match &self.user_info {
None => { None => {
self.route_dispatcher 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 { fn view_banner(&self) -> Html {
html! { html! {
<header class="p-3 mb-4 border-bottom shadow-sm"> <header class="p-3 mb-4 border-bottom shadow-sm">

View File

@@ -1,14 +1,14 @@
use crate::{ use crate::{
components::router::{AppRoute, NavButton}, components::router::{AppRoute, NavButton},
infra::api::HostService, infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
},
}; };
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Context, Result};
use lldap_auth::*; use lldap_auth::*;
use validator_derive::Validate; use validator_derive::Validate;
use yew::{ use yew::{prelude::*, services::ConsoleService};
prelude::*,
services::{fetch::FetchTask, ConsoleService},
};
use yew_form::Form; use yew_form::Form;
use yew_form_derive::Model; use yew_form_derive::Model;
use yew_router::{ use yew_router::{
@@ -58,13 +58,9 @@ fn empty_or_long(value: &str) -> Result<(), validator::ValidationError> {
} }
pub struct ChangePasswordForm { pub struct ChangePasswordForm {
link: ComponentLink<Self>, common: CommonComponentParts<Self>,
props: Props,
error: Option<anyhow::Error>,
form: Form<FormModel>, form: Form<FormModel>,
opaque_data: OpaqueData, opaque_data: OpaqueData,
// Used to keep the request alive long enough.
task: Option<FetchTask>,
route_dispatcher: RouteAgentDispatcher, route_dispatcher: RouteAgentDispatcher,
} }
@@ -83,25 +79,16 @@ pub enum Msg {
RegistrationFinishResponse(Result<()>), RegistrationFinishResponse(Result<()>),
} }
impl ChangePasswordForm { impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
fn call_backend<M, Req, C, Resp>(&mut self, method: M, req: Req, callback: C) -> Result<()> fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
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> {
match msg { match msg {
Msg::FormUpdate => Ok(true), Msg::FormUpdate => Ok(true),
Msg::Submit => { Msg::Submit => {
if !self.form.validate() { if !self.form.validate() {
bail!("Check the form for errors"); bail!("Check the form for errors");
} }
if self.props.is_admin { if self.common.is_admin {
self.handle_message(Msg::SubmitNewPassword) self.handle_msg(Msg::SubmitNewPassword)
} else { } else {
let old_password = self.form.model().old_password; let old_password = self.form.model().old_password;
if old_password.is_empty() { if old_password.is_empty() {
@@ -113,10 +100,10 @@ impl ChangePasswordForm {
.context("Could not initialize login")?; .context("Could not initialize login")?;
self.opaque_data = OpaqueData::Login(login_start_request.state); self.opaque_data = OpaqueData::Login(login_start_request.state);
let req = login::ClientLoginStartRequest { let req = login::ClientLoginStartRequest {
username: self.props.username.clone(), username: self.common.username.clone(),
login_start_request: login_start_request.message, login_start_request: login_start_request.message,
}; };
self.call_backend( self.common.call_backend(
HostService::login_start, HostService::login_start,
req, req,
Msg::AuthenticationStartResponse, Msg::AuthenticationStartResponse,
@@ -142,7 +129,7 @@ impl ChangePasswordForm {
} }
_ => panic!("Unexpected data in opaque_data field"), _ => panic!("Unexpected data in opaque_data field"),
}; };
self.handle_message(Msg::SubmitNewPassword) self.handle_msg(Msg::SubmitNewPassword)
} }
Msg::SubmitNewPassword => { Msg::SubmitNewPassword => {
let mut rng = rand::rngs::OsRng; let mut rng = rand::rngs::OsRng;
@@ -151,11 +138,11 @@ impl ChangePasswordForm {
opaque::client::registration::start_registration(&new_password, &mut rng) opaque::client::registration::start_registration(&new_password, &mut rng)
.context("Could not initiate password change")?; .context("Could not initiate password change")?;
let req = registration::ClientRegistrationStartRequest { let req = registration::ClientRegistrationStartRequest {
username: self.props.username.clone(), username: self.common.username.clone(),
registration_start_request: registration_start_request.message, registration_start_request: registration_start_request.message,
}; };
self.opaque_data = OpaqueData::Registration(registration_start_request.state); self.opaque_data = OpaqueData::Registration(registration_start_request.state);
self.call_backend( self.common.call_backend(
HostService::register_start, HostService::register_start,
req, req,
Msg::RegistrationStartResponse, Msg::RegistrationStartResponse,
@@ -178,7 +165,7 @@ impl ChangePasswordForm {
server_data: res.server_data, server_data: res.server_data,
registration_upload: registration_finish.message, registration_upload: registration_finish.message,
}; };
self.call_backend( self.common.call_backend(
HostService::register_finish, HostService::register_finish,
req, req,
Msg::RegistrationFinishResponse, Msg::RegistrationFinishResponse,
@@ -189,11 +176,11 @@ impl ChangePasswordForm {
Ok(false) Ok(false)
} }
Msg::RegistrationFinishResponse(response) => { Msg::RegistrationFinishResponse(response) => {
self.task = None; self.common.cancel_task();
if response.is_ok() { if response.is_ok() {
self.route_dispatcher self.route_dispatcher
.send(RouteRequest::ChangeRoute(Route::from( .send(RouteRequest::ChangeRoute(Route::from(
AppRoute::UserDetails(self.props.username.clone()), AppRoute::UserDetails(self.common.username.clone()),
))); )));
} }
response?; response?;
@@ -201,6 +188,10 @@ impl ChangePasswordForm {
} }
} }
} }
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
} }
impl Component for ChangePasswordForm { impl Component for ChangePasswordForm {
@@ -209,27 +200,15 @@ impl Component for ChangePasswordForm {
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
ChangePasswordForm { ChangePasswordForm {
link, common: CommonComponentParts::<Self>::create(props, link),
props,
error: None,
form: yew_form::Form::<FormModel>::new(FormModel::default()), form: yew_form::Form::<FormModel>::new(FormModel::default()),
opaque_data: OpaqueData::None, opaque_data: OpaqueData::None,
task: None,
route_dispatcher: RouteAgentDispatcher::new(), route_dispatcher: RouteAgentDispatcher::new(),
} }
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, msg: Self::Message) -> ShouldRender {
self.error = None; CommonComponentParts::<Self>::update(self, msg)
match self.handle_message(msg) {
Err(e) => {
ConsoleService::error(&e.to_string());
self.error = Some(e);
self.task = None;
true
}
Ok(b) => b,
}
} }
fn change(&mut self, _: Self::Properties) -> ShouldRender { fn change(&mut self, _: Self::Properties) -> ShouldRender {
@@ -237,7 +216,7 @@ impl Component for ChangePasswordForm {
} }
fn view(&self) -> Html { fn view(&self) -> Html {
let is_admin = self.props.is_admin; let is_admin = self.common.is_admin;
type Field = yew_form::Field<FormModel>; type Field = yew_form::Field<FormModel>;
html! { html! {
<> <>
@@ -257,7 +236,7 @@ impl Component for ChangePasswordForm {
class_invalid="is-invalid has-error" class_invalid="is-invalid has-error"
class_valid="has-success" class_valid="has-success"
autocomplete="current-password" autocomplete="current-password"
oninput=self.link.callback(|_| Msg::FormUpdate) /> oninput=self.common.callback(|_| Msg::FormUpdate) />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("old_password")} {&self.form.field_message("old_password")}
</div> </div>
@@ -277,7 +256,7 @@ impl Component for ChangePasswordForm {
class_invalid="is-invalid has-error" class_invalid="is-invalid has-error"
class_valid="has-success" class_valid="has-success"
autocomplete="new-password" autocomplete="new-password"
oninput=self.link.callback(|_| Msg::FormUpdate) /> oninput=self.common.callback(|_| Msg::FormUpdate) />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("password")} {&self.form.field_message("password")}
</div> </div>
@@ -296,7 +275,7 @@ impl Component for ChangePasswordForm {
class_invalid="is-invalid has-error" class_invalid="is-invalid has-error"
class_valid="has-success" class_valid="has-success"
autocomplete="new-password" autocomplete="new-password"
oninput=self.link.callback(|_| Msg::FormUpdate) /> oninput=self.common.callback(|_| Msg::FormUpdate) />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("confirm_password")} {&self.form.field_message("confirm_password")}
</div> </div>
@@ -306,13 +285,13 @@ impl Component for ChangePasswordForm {
<button <button
class="btn btn-primary col-sm-1 col-form-label" class="btn btn-primary col-sm-1 col-form-label"
type="submit" type="submit"
disabled=self.task.is_some() disabled=self.common.is_task_running()
onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})> onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
{"Submit"} {"Submit"}
</button> </button>
</div> </div>
</form> </form>
{ if let Some(e) = &self.error { { if let Some(e) = &self.common.error {
html! { html! {
<div class="alert alert-danger"> <div class="alert alert-danger">
{e.to_string() } {e.to_string() }
@@ -323,7 +302,7 @@ impl Component for ChangePasswordForm {
<div> <div>
<NavButton <NavButton
classes="btn btn-primary" classes="btn btn-primary"
route=AppRoute::UserDetails(self.props.username.clone())> route=AppRoute::UserDetails(self.common.username.clone())>
{"Back"} {"Back"}
</NavButton> </NavButton>
</div> </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 anyhow::{bail, Result};
use graphql_client::GraphQLQuery; use graphql_client::GraphQLQuery;
use validator_derive::Validate; use validator_derive::Validate;
use yew::prelude::*; use yew::prelude::*;
use yew::services::{fetch::FetchTask, ConsoleService}; use yew::services::ConsoleService;
use yew_form_derive::Model; use yew_form_derive::Model;
use yew_router::{ use yew_router::{
agent::{RouteAgentDispatcher, RouteRequest}, agent::{RouteAgentDispatcher, RouteRequest},
@@ -20,12 +23,9 @@ use yew_router::{
pub struct CreateGroup; pub struct CreateGroup;
pub struct CreateGroupForm { pub struct CreateGroupForm {
link: ComponentLink<Self>, common: CommonComponentParts<Self>,
route_dispatcher: RouteAgentDispatcher, route_dispatcher: RouteAgentDispatcher,
form: yew_form::Form<CreateGroupModel>, 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)] #[derive(Model, Validate, PartialEq, Clone, Default)]
@@ -40,7 +40,7 @@ pub enum Msg {
CreateGroupResponse(Result<create_group::ResponseData>), CreateGroupResponse(Result<create_group::ResponseData>),
} }
impl CreateGroupForm { impl CommonComponent<CreateGroupForm> for CreateGroupForm {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg { match msg {
Msg::Update => Ok(true), Msg::Update => Ok(true),
@@ -52,11 +52,11 @@ impl CreateGroupForm {
let req = create_group::Variables { let req = create_group::Variables {
name: model.groupname, name: model.groupname,
}; };
self.task = Some(HostService::graphql_query::<CreateGroup>( self.common.call_graphql::<CreateGroup, _>(
req, req,
self.link.callback(Msg::CreateGroupResponse), Msg::CreateGroupResponse,
"Error trying to create group", "Error trying to create group",
)?); );
Ok(true) Ok(true)
} }
Msg::CreateGroupResponse(response) => { Msg::CreateGroupResponse(response) => {
@@ -70,33 +70,26 @@ impl CreateGroupForm {
} }
} }
} }
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
} }
impl Component for CreateGroupForm { impl Component for CreateGroupForm {
type Message = Msg; type Message = Msg;
type Properties = (); type Properties = ();
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
Self { Self {
link, common: CommonComponentParts::<Self>::create(props, link),
route_dispatcher: RouteAgentDispatcher::new(), route_dispatcher: RouteAgentDispatcher::new(),
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()), form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
error: None,
task: None,
} }
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, msg: Self::Message) -> ShouldRender {
self.error = None; CommonComponentParts::<Self>::update(self, msg)
match self.handle_msg(msg) {
Err(e) => {
ConsoleService::error(&e.to_string());
self.error = Some(e);
self.task = None;
true
}
Ok(b) => b,
}
} }
fn change(&mut self, _: Self::Properties) -> ShouldRender { fn change(&mut self, _: Self::Properties) -> ShouldRender {
@@ -124,7 +117,7 @@ impl Component for CreateGroupForm {
class_invalid="is-invalid has-error" class_invalid="is-invalid has-error"
class_valid="has-success" class_valid="has-success"
autocomplete="groupname" autocomplete="groupname"
oninput=self.link.callback(|_| Msg::Update) /> oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("groupname")} {&self.form.field_message("groupname")}
</div> </div>
@@ -134,13 +127,13 @@ impl Component for CreateGroupForm {
<button <button
class="btn btn-primary col-auto col-form-label" class="btn btn-primary col-auto col-form-label"
type="submit" type="submit"
disabled=self.task.is_some() disabled=self.common.is_task_running()
onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})> onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})>
{"Submit"} {"Submit"}
</button> </button>
</div> </div>
</form> </form>
{ if let Some(e) = &self.error { { if let Some(e) = &self.common.error {
html! { html! {
<div class="alert alert-danger"> <div class="alert alert-danger">
{e.to_string() } {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 anyhow::{bail, Context, Result};
use graphql_client::GraphQLQuery; use graphql_client::GraphQLQuery;
use lldap_auth::{opaque, registration}; use lldap_auth::{opaque, registration};
use validator_derive::Validate; use validator_derive::Validate;
use yew::prelude::*; use yew::prelude::*;
use yew::services::{fetch::FetchTask, ConsoleService}; use yew::services::ConsoleService;
use yew_form_derive::Model; use yew_form_derive::Model;
use yew_router::{ use yew_router::{
agent::{RouteAgentDispatcher, RouteRequest}, agent::{RouteAgentDispatcher, RouteRequest},
@@ -21,12 +27,9 @@ use yew_router::{
pub struct CreateUser; pub struct CreateUser;
pub struct CreateUserForm { pub struct CreateUserForm {
link: ComponentLink<Self>, common: CommonComponentParts<Self>,
route_dispatcher: RouteAgentDispatcher, route_dispatcher: RouteAgentDispatcher,
form: yew_form::Form<CreateUserModel>, 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)] #[derive(Model, Validate, PartialEq, Clone, Default)]
@@ -70,7 +73,7 @@ pub enum Msg {
RegistrationFinishResponse(Result<()>), RegistrationFinishResponse(Result<()>),
} }
impl CreateUserForm { impl CommonComponent<CreateUserForm> for CreateUserForm {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg { match msg {
Msg::Update => Ok(true), Msg::Update => Ok(true),
@@ -89,11 +92,11 @@ impl CreateUserForm {
lastName: to_option(model.last_name), lastName: to_option(model.last_name),
}, },
}; };
self.task = Some(HostService::graphql_query::<CreateUser>( self.common.call_graphql::<CreateUser, _>(
req, req,
self.link.callback(Msg::CreateUserResponse), Msg::CreateUserResponse,
"Error trying to create user", "Error trying to create user",
)?); );
Ok(true) Ok(true)
} }
Msg::CreateUserResponse(r) => { Msg::CreateUserResponse(r) => {
@@ -118,14 +121,11 @@ impl CreateUserForm {
username: user_id, username: user_id,
registration_start_request: message, registration_start_request: message,
}; };
self.task = Some( self.common
HostService::register_start( .call_backend(HostService::register_start, req, move |r| {
req, Msg::RegistrationStartResponse((state, r))
self.link })
.callback_once(move |r| Msg::RegistrationStartResponse((state, r))), .context("Error trying to create user")?;
)
.context("Error trying to create user")?,
);
} else { } else {
self.update(Msg::SuccessfulCreation); self.update(Msg::SuccessfulCreation);
} }
@@ -143,13 +143,13 @@ impl CreateUserForm {
server_data: response.server_data, server_data: response.server_data,
registration_upload: registration_upload.message, registration_upload: registration_upload.message,
}; };
self.task = Some( self.common
HostService::register_finish( .call_backend(
HostService::register_finish,
req, req,
self.link.callback(Msg::RegistrationFinishResponse), Msg::RegistrationFinishResponse,
) )
.context("Error trying to register user")?, .context("Error trying to register user")?;
);
Ok(false) Ok(false)
} }
Msg::RegistrationFinishResponse(response) => { Msg::RegistrationFinishResponse(response) => {
@@ -163,33 +163,26 @@ impl CreateUserForm {
} }
} }
} }
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
} }
impl Component for CreateUserForm { impl Component for CreateUserForm {
type Message = Msg; type Message = Msg;
type Properties = (); type Properties = ();
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
Self { Self {
link, common: CommonComponentParts::<Self>::create(props, link),
route_dispatcher: RouteAgentDispatcher::new(), route_dispatcher: RouteAgentDispatcher::new(),
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()), form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
error: None,
task: None,
} }
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, msg: Self::Message) -> ShouldRender {
self.error = None; CommonComponentParts::<Self>::update(self, msg)
match self.handle_msg(msg) {
Err(e) => {
ConsoleService::error(&e.to_string());
self.error = Some(e);
self.task = None;
true
}
Ok(b) => b,
}
} }
fn change(&mut self, _: Self::Properties) -> ShouldRender { fn change(&mut self, _: Self::Properties) -> ShouldRender {
@@ -217,7 +210,7 @@ impl Component for CreateUserForm {
class_invalid="is-invalid has-error" class_invalid="is-invalid has-error"
class_valid="has-success" class_valid="has-success"
autocomplete="username" autocomplete="username"
oninput=self.link.callback(|_| Msg::Update) /> oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("username")} {&self.form.field_message("username")}
</div> </div>
@@ -237,7 +230,7 @@ impl Component for CreateUserForm {
class_invalid="is-invalid has-error" class_invalid="is-invalid has-error"
class_valid="has-success" class_valid="has-success"
autocomplete="email" autocomplete="email"
oninput=self.link.callback(|_| Msg::Update) /> oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("email")} {&self.form.field_message("email")}
</div> </div>
@@ -256,7 +249,7 @@ impl Component for CreateUserForm {
class_invalid="is-invalid has-error" class_invalid="is-invalid has-error"
class_valid="has-success" class_valid="has-success"
field_name="display_name" field_name="display_name"
oninput=self.link.callback(|_| Msg::Update) /> oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("display_name")} {&self.form.field_message("display_name")}
</div> </div>
@@ -275,7 +268,7 @@ impl Component for CreateUserForm {
class_invalid="is-invalid has-error" class_invalid="is-invalid has-error"
class_valid="has-success" class_valid="has-success"
field_name="first_name" field_name="first_name"
oninput=self.link.callback(|_| Msg::Update) /> oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("first_name")} {&self.form.field_message("first_name")}
</div> </div>
@@ -294,7 +287,7 @@ impl Component for CreateUserForm {
class_invalid="is-invalid has-error" class_invalid="is-invalid has-error"
class_valid="has-success" class_valid="has-success"
field_name="last_name" field_name="last_name"
oninput=self.link.callback(|_| Msg::Update) /> oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("last_name")} {&self.form.field_message("last_name")}
</div> </div>
@@ -314,7 +307,7 @@ impl Component for CreateUserForm {
class_invalid="is-invalid has-error" class_invalid="is-invalid has-error"
class_valid="has-success" class_valid="has-success"
autocomplete="new-password" autocomplete="new-password"
oninput=self.link.callback(|_| Msg::Update) /> oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("password")} {&self.form.field_message("password")}
</div> </div>
@@ -334,7 +327,7 @@ impl Component for CreateUserForm {
class_invalid="is-invalid has-error" class_invalid="is-invalid has-error"
class_valid="has-success" class_valid="has-success"
autocomplete="new-password" autocomplete="new-password"
oninput=self.link.callback(|_| Msg::Update) /> oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("confirm_password")} {&self.form.field_message("confirm_password")}
</div> </div>
@@ -343,14 +336,14 @@ impl Component for CreateUserForm {
<div class="form-group row justify-content-center"> <div class="form-group row justify-content-center">
<button <button
class="btn btn-primary col-auto col-form-label mt-4" class="btn btn-primary col-auto col-form-label mt-4"
disabled=self.task.is_some() disabled=self.common.is_task_running()
type="submit" 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"} {"Submit"}
</button> </button>
</div> </div>
</form> </form>
{ if let Some(e) = &self.error { { if let Some(e) = &self.common.error {
html! { html! {
<div class="alert alert-danger"> <div class="alert alert-danger">
{e.to_string() } {e.to_string() }

View File

@@ -1,12 +1,13 @@
use crate::{ use crate::{
components::group_table::Group, components::group_table::Group,
infra::{api::HostService, modal::Modal}, infra::{
common_component::{CommonComponent, CommonComponentParts},
modal::Modal,
},
}; };
use anyhow::{Error, Result}; use anyhow::{Error, Result};
use graphql_client::GraphQLQuery; use graphql_client::GraphQLQuery;
use yew::prelude::*; use yew::prelude::*;
use yew::services::fetch::FetchTask;
use yewtil::NeqAssign;
#[derive(GraphQLQuery)] #[derive(GraphQLQuery)]
#[graphql( #[graphql(
@@ -18,11 +19,9 @@ use yewtil::NeqAssign;
pub struct DeleteGroupQuery; pub struct DeleteGroupQuery;
pub struct DeleteGroup { pub struct DeleteGroup {
link: ComponentLink<Self>, common: CommonComponentParts<Self>,
props: DeleteGroupProps,
node_ref: NodeRef, node_ref: NodeRef,
modal: Option<Modal>, modal: Option<Modal>,
task: Option<FetchTask>,
} }
#[derive(yew::Properties, Clone, PartialEq, Debug)] #[derive(yew::Properties, Clone, PartialEq, Debug)]
@@ -39,17 +38,51 @@ pub enum Msg {
DeleteGroupResponse(Result<delete_group_query::ResponseData>), 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 { impl Component for DeleteGroup {
type Message = Msg; type Message = Msg;
type Properties = DeleteGroupProps; type Properties = DeleteGroupProps;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
Self { Self {
link, common: CommonComponentParts::<Self>::create(props, link),
props,
node_ref: NodeRef::default(), node_ref: NodeRef::default(),
modal: None, modal: None,
task: None,
} }
} }
@@ -64,39 +97,15 @@ impl Component for DeleteGroup {
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, msg: Self::Message) -> ShouldRender {
match msg { CommonComponentParts::<Self>::update_and_report_error(
Msg::ClickedDeleteGroup => { self,
self.modal.as_ref().expect("modal not initialized").show(); msg,
} self.common.on_error.clone(),
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
} }
fn change(&mut self, props: Self::Properties) -> ShouldRender { fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.props.neq_assign(props) self.common.change(props)
} }
fn view(&self) -> Html { fn view(&self) -> Html {
@@ -104,8 +113,8 @@ impl Component for DeleteGroup {
<> <>
<button <button
class="btn btn-danger" class="btn btn-danger"
disabled=self.task.is_some() disabled=self.common.is_task_running()
onclick=self.link.callback(|_| Msg::ClickedDeleteGroup)> onclick=self.common.callback(|_| Msg::ClickedDeleteGroup)>
<i class="bi-x-circle-fill" aria-label="Delete group" /> <i class="bi-x-circle-fill" aria-label="Delete group" />
</button> </button>
{self.show_modal()} {self.show_modal()}
@@ -119,7 +128,7 @@ impl DeleteGroup {
html! { html! {
<div <div
class="modal fade" 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" tabindex="-1"
aria-labelledby="deleteGroupModalLabel" aria-labelledby="deleteGroupModalLabel"
aria-hidden="true" aria-hidden="true"
@@ -132,24 +141,24 @@ impl DeleteGroup {
type="button" type="button"
class="btn-close" class="btn-close"
aria-label="Close" aria-label="Close"
onclick=self.link.callback(|_| Msg::DismissModal) /> onclick=self.common.callback(|_| Msg::DismissModal) />
</div> </div>
<div class="modal-body"> <div class="modal-body">
<span> <span>
{"Are you sure you want to delete group "} {"Are you sure you want to delete group "}
<b>{&self.props.group.display_name}</b>{"?"} <b>{&self.common.group.display_name}</b>{"?"}
</span> </span>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button <button
type="button" type="button"
class="btn btn-secondary" class="btn btn-secondary"
onclick=self.link.callback(|_| Msg::DismissModal)> onclick=self.common.callback(|_| Msg::DismissModal)>
{"Cancel"} {"Cancel"}
</button> </button>
<button <button
type="button" type="button"
onclick=self.link.callback(|_| Msg::ConfirmDeleteGroup) onclick=self.common.callback(|_| Msg::ConfirmDeleteGroup)
class="btn btn-danger">{"Yes, I'm sure"}</button> class="btn btn-danger">{"Yes, I'm sure"}</button>
</div> </div>
</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 anyhow::{Error, Result};
use graphql_client::GraphQLQuery; use graphql_client::GraphQLQuery;
use yew::prelude::*; use yew::prelude::*;
use yew::services::fetch::FetchTask;
use yewtil::NeqAssign;
#[derive(GraphQLQuery)] #[derive(GraphQLQuery)]
#[graphql( #[graphql(
@@ -15,11 +16,9 @@ use yewtil::NeqAssign;
pub struct DeleteUserQuery; pub struct DeleteUserQuery;
pub struct DeleteUser { pub struct DeleteUser {
link: ComponentLink<Self>, common: CommonComponentParts<Self>,
props: DeleteUserProps,
node_ref: NodeRef, node_ref: NodeRef,
modal: Option<Modal>, modal: Option<Modal>,
task: Option<FetchTask>,
} }
#[derive(yew::Properties, Clone, PartialEq, Debug)] #[derive(yew::Properties, Clone, PartialEq, Debug)]
@@ -36,17 +35,51 @@ pub enum Msg {
DeleteUserResponse(Result<delete_user_query::ResponseData>), 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 { impl Component for DeleteUser {
type Message = Msg; type Message = Msg;
type Properties = DeleteUserProps; type Properties = DeleteUserProps;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
Self { Self {
link, common: CommonComponentParts::<Self>::create(props, link),
props,
node_ref: NodeRef::default(), node_ref: NodeRef::default(),
modal: None, modal: None,
task: None,
} }
} }
@@ -61,39 +94,15 @@ impl Component for DeleteUser {
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, msg: Self::Message) -> ShouldRender {
match msg { CommonComponentParts::<Self>::update_and_report_error(
Msg::ClickedDeleteUser => { self,
self.modal.as_ref().expect("modal not initialized").show(); msg,
} self.common.on_error.clone(),
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
} }
fn change(&mut self, props: Self::Properties) -> ShouldRender { fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.props.neq_assign(props) self.common.change(props)
} }
fn view(&self) -> Html { fn view(&self) -> Html {
@@ -101,8 +110,8 @@ impl Component for DeleteUser {
<> <>
<button <button
class="btn btn-danger" class="btn btn-danger"
disabled=self.task.is_some() disabled=self.common.is_task_running()
onclick=self.link.callback(|_| Msg::ClickedDeleteUser)> onclick=self.common.callback(|_| Msg::ClickedDeleteUser)>
<i class="bi-x-circle-fill" aria-label="Delete user" /> <i class="bi-x-circle-fill" aria-label="Delete user" />
</button> </button>
{self.show_modal()} {self.show_modal()}
@@ -116,7 +125,7 @@ impl DeleteUser {
html! { html! {
<div <div
class="modal fade" class="modal fade"
id="deleteUserModal".to_string() + &self.props.username id="deleteUserModal".to_string() + &self.common.username
tabindex="-1" tabindex="-1"
//role="dialog" //role="dialog"
aria-labelledby="deleteUserModalLabel" aria-labelledby="deleteUserModalLabel"
@@ -130,24 +139,24 @@ impl DeleteUser {
type="button" type="button"
class="btn-close" class="btn-close"
aria-label="Close" aria-label="Close"
onclick=self.link.callback(|_| Msg::DismissModal) /> onclick=self.common.callback(|_| Msg::DismissModal) />
</div> </div>
<div class="modal-body"> <div class="modal-body">
<span> <span>
{"Are you sure you want to delete user "} {"Are you sure you want to delete user "}
<b>{&self.props.username}</b>{"?"} <b>{&self.common.username}</b>{"?"}
</span> </span>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button <button
type="button" type="button"
class="btn btn-secondary" class="btn btn-secondary"
onclick=self.link.callback(|_| Msg::DismissModal)> onclick=self.common.callback(|_| Msg::DismissModal)>
{"Cancel"} {"Cancel"}
</button> </button>
<button <button
type="button" type="button"
onclick=self.link.callback(|_| Msg::ConfirmDeleteUser) onclick=self.common.callback(|_| Msg::ConfirmDeleteUser)
class="btn btn-danger">{"Yes, I'm sure"}</button> class="btn btn-danger">{"Yes, I'm sure"}</button>
</div> </div>
</div> </div>

View File

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

View File

@@ -3,12 +3,11 @@ use crate::{
delete_group::DeleteGroup, delete_group::DeleteGroup,
router::{AppRoute, Link}, router::{AppRoute, Link},
}, },
infra::api::HostService, infra::common_component::{CommonComponent, CommonComponentParts},
}; };
use anyhow::{Error, Result}; use anyhow::{Error, Result};
use graphql_client::GraphQLQuery; use graphql_client::GraphQLQuery;
use yew::prelude::*; use yew::prelude::*;
use yew::services::{fetch::FetchTask, ConsoleService};
#[derive(GraphQLQuery)] #[derive(GraphQLQuery)]
#[graphql( #[graphql(
@@ -24,11 +23,8 @@ use get_group_list::ResponseData;
pub type Group = get_group_list::GetGroupListGroups; pub type Group = get_group_list::GetGroupListGroups;
pub struct GroupTable { pub struct GroupTable {
link: ComponentLink<Self>, common: CommonComponentParts<Self>,
groups: Option<Vec<Group>>, groups: Option<Vec<Group>>,
error: Option<Error>,
// Used to keep the request alive long enough.
_task: Option<FetchTask>,
} }
pub enum Msg { pub enum Msg {
@@ -37,18 +33,24 @@ pub enum Msg {
OnError(Error), OnError(Error),
} }
impl GroupTable { impl CommonComponent<GroupTable> for GroupTable {
fn get_groups(&mut self) { fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
self._task = HostService::graphql_query::<GetGroupList>( match msg {
get_group_list::Variables {}, Msg::ListGroupsResponse(groups) => {
self.link.callback(Msg::ListGroupsResponse), self.groups = Some(groups?.groups.into_iter().collect());
"Error trying to fetch groups", Ok(true)
) }
.map_err(|e| { Msg::OnError(e) => Err(e),
ConsoleService::log(&e.to_string()); Msg::OnGroupDeleted(group_id) => {
e debug_assert!(self.groups.is_some());
}) self.groups.as_mut().unwrap().retain(|u| u.id != group_id);
.ok(); 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 Message = Msg;
type Properties = (); type Properties = ();
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
let mut table = GroupTable { let mut table = GroupTable {
link, common: CommonComponentParts::<Self>::create(props, link),
_task: None,
groups: None, groups: None,
error: None,
}; };
table.get_groups(); table.common.call_graphql::<GetGroupList, _>(
get_group_list::Variables {},
Msg::ListGroupsResponse,
"Error trying to fetch groups",
);
table table
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, msg: Self::Message) -> ShouldRender {
self.error = None; CommonComponentParts::<Self>::update(self, msg)
match self.handle_msg(msg) {
Err(e) => {
ConsoleService::error(&e.to_string());
self.error = Some(e);
true
}
Ok(b) => b,
}
} }
fn change(&mut self, _: Self::Properties) -> ShouldRender { fn change(&mut self, _: Self::Properties) -> ShouldRender {
@@ -94,21 +90,6 @@ impl Component for GroupTable {
} }
impl 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 { fn view_groups(&self) -> Html {
let make_table = |groups: &Vec<Group>| { let make_table = |groups: &Vec<Group>| {
html! { html! {
@@ -144,15 +125,15 @@ impl GroupTable {
<td> <td>
<DeleteGroup <DeleteGroup
group=group.clone() group=group.clone()
on_group_deleted=self.link.callback(Msg::OnGroupDeleted) on_group_deleted=self.common.callback(Msg::OnGroupDeleted)
on_error=self.link.callback(Msg::OnError)/> on_error=self.common.callback(Msg::OnError)/>
</td> </td>
</tr> </tr>
} }
} }
fn view_errors(&self) -> Html { fn view_errors(&self) -> Html {
match &self.error { match &self.common.error {
None => html! {}, None => html! {},
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>}, 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 anyhow::{anyhow, bail, Context, Result};
use lldap_auth::*; use lldap_auth::*;
use validator_derive::Validate; use validator_derive::Validate;
use yew::{ use yew::{prelude::*, services::ConsoleService};
prelude::*,
services::{fetch::FetchTask, ConsoleService},
};
use yew_form::Form; use yew_form::Form;
use yew_form_derive::Model; use yew_form_derive::Model;
pub struct LoginForm { pub struct LoginForm {
link: ComponentLink<Self>, common: CommonComponentParts<Self>,
on_logged_in: Callback<(String, bool)>,
error: Option<anyhow::Error>,
form: Form<FormModel>, form: Form<FormModel>,
// Used to keep the request alive long enough.
task: Option<FetchTask>,
} }
/// The fields of the form, with the constraints. /// The fields of the form, with the constraints.
@@ -44,8 +43,8 @@ pub enum Msg {
AuthenticationFinishResponse(Result<(String, bool)>), AuthenticationFinishResponse(Result<(String, bool)>),
} }
impl LoginForm { impl CommonComponent<LoginForm> for LoginForm {
fn handle_message(&mut self, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg { match msg {
Msg::Update => Ok(true), Msg::Update => Ok(true),
Msg::Submit => { Msg::Submit => {
@@ -61,11 +60,10 @@ impl LoginForm {
username, username,
login_start_request: message, login_start_request: message,
}; };
self.task = Some(HostService::login_start( self.common
req, .call_backend(HostService::login_start, req, move |r| {
self.link Msg::AuthenticationStartResponse((state, r))
.callback_once(move |r| Msg::AuthenticationStartResponse((state, r))), })?;
)?);
Ok(true) Ok(true)
} }
Msg::AuthenticationStartResponse((login_start, res)) => { 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 // Common error, we want to print a full error to the console but only a
// simple one to the user. // simple one to the user.
ConsoleService::error(&format!("Invalid username or password: {}", e)); 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); return Ok(true);
} }
Ok(l) => l, Ok(l) => l,
@@ -86,20 +85,26 @@ impl LoginForm {
server_data: res.server_data, server_data: res.server_data,
credential_finalization: login_finish.message, credential_finalization: login_finish.message,
}; };
self.task = Some(HostService::login_finish( self.common.call_backend(
HostService::login_finish,
req, req,
self.link.callback_once(Msg::AuthenticationFinishResponse), Msg::AuthenticationFinishResponse,
)?); )?;
Ok(false) Ok(false)
} }
Msg::AuthenticationFinishResponse(user_info) => { Msg::AuthenticationFinishResponse(user_info) => {
self.task = None; self.common.cancel_task();
self.on_logged_in self.common
.on_logged_in
.emit(user_info.context("Could not log in")?); .emit(user_info.context("Could not log in")?);
Ok(true) Ok(true)
} }
} }
} }
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
&mut self.common
}
} }
impl Component for LoginForm { impl Component for LoginForm {
@@ -108,25 +113,13 @@ impl Component for LoginForm {
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
LoginForm { LoginForm {
link, common: CommonComponentParts::<Self>::create(props, link),
on_logged_in: props.on_logged_in,
error: None,
form: Form::<FormModel>::new(FormModel::default()), form: Form::<FormModel>::new(FormModel::default()),
task: None,
} }
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, msg: Self::Message) -> ShouldRender {
self.error = None; CommonComponentParts::<Self>::update(self, msg)
match self.handle_message(msg) {
Err(e) => {
ConsoleService::error(&e.to_string());
self.error = Some(e);
self.task = None;
true
}
Ok(b) => b,
}
} }
fn change(&mut self, _: Self::Properties) -> ShouldRender { fn change(&mut self, _: Self::Properties) -> ShouldRender {
@@ -152,7 +145,7 @@ impl Component for LoginForm {
field_name="username" field_name="username"
placeholder="Username" placeholder="Username"
autocomplete="username" autocomplete="username"
oninput=self.link.callback(|_| Msg::Update) /> oninput=self.common.callback(|_| Msg::Update) />
</div> </div>
<div class="input-group"> <div class="input-group">
<div class="input-group-prepend"> <div class="input-group-prepend">
@@ -170,17 +163,23 @@ impl Component for LoginForm {
placeholder="Password" placeholder="Password"
autocomplete="current-password" /> autocomplete="current-password" />
</div> </div>
<div class="form-group"> <div class="form-group mt-3">
<button <button
type="submit" type="submit"
class="btn btn-primary" class="btn btn-primary"
disabled=self.task.is_some() disabled=self.common.is_task_running()
onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})> onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
{"Login"} {"Login"}
</button> </button>
<NavButton
classes="btn-link btn"
disabled=self.common.is_task_running()
route=AppRoute::StartResetPassword>
{"Forgot your password?"}
</NavButton>
</div> </div>
<div class="form-group"> <div class="form-group">
{ if let Some(e) = &self.error { { if let Some(e) = &self.common.error {
html! { e.to_string() } html! { e.to_string() }
} else { html! {} } } 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 anyhow::Result;
use yew::prelude::*; use yew::prelude::*;
use yew::services::{fetch::FetchTask, ConsoleService};
pub struct LogoutButton { pub struct LogoutButton {
link: ComponentLink<Self>, common: CommonComponentParts<Self>,
on_logged_out: Callback<()>,
// Used to keep the request alive long enough.
_task: Option<FetchTask>,
} }
#[derive(Clone, PartialEq, Properties)] #[derive(Clone, PartialEq, Properties)]
@@ -20,43 +20,39 @@ pub enum Msg {
LogoutCompleted(Result<()>), 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 { impl Component for LogoutButton {
type Message = Msg; type Message = Msg;
type Properties = Props; type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
LogoutButton { LogoutButton {
link, common: CommonComponentParts::<Self>::create(props, link),
on_logged_out: props.on_logged_out,
_task: None,
} }
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, msg: Self::Message) -> ShouldRender {
match msg { CommonComponentParts::<Self>::update(self, 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
}
}
}
}
} }
fn change(&mut self, _: Self::Properties) -> ShouldRender { fn change(&mut self, _: Self::Properties) -> ShouldRender {
@@ -67,7 +63,7 @@ impl Component for LogoutButton {
html! { html! {
<button <button
class="dropdown-item" class="dropdown-item"
onclick=self.link.callback(|_| Msg::LogoutRequested)> onclick=self.common.callback(|_| Msg::LogoutRequested)>
{"Logout"} {"Logout"}
</button> </button>
} }

View File

@@ -11,6 +11,8 @@ pub mod group_table;
pub mod login; pub mod login;
pub mod logout; pub mod logout;
pub mod remove_user_from_group; pub mod remove_user_from_group;
pub mod reset_password_step1;
pub mod reset_password_step2;
pub mod router; pub mod router;
pub mod select; pub mod select;
pub mod user_details; 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 anyhow::{Error, Result};
use graphql_client::GraphQLQuery; use graphql_client::GraphQLQuery;
use yew::{ use yew::prelude::*;
prelude::*,
services::{fetch::FetchTask, ConsoleService},
};
#[derive(GraphQLQuery)] #[derive(GraphQLQuery)]
#[graphql( #[graphql(
@@ -17,10 +14,7 @@ use yew::{
pub struct RemoveUserFromGroup; pub struct RemoveUserFromGroup;
pub struct RemoveUserFromGroupComponent { pub struct RemoveUserFromGroupComponent {
link: ComponentLink<Self>, common: CommonComponentParts<Self>,
props: Props,
// Used to keep the request alive long enough.
task: Option<FetchTask>,
} }
#[derive(yew::Properties, Clone, PartialEq)] #[derive(yew::Properties, Clone, PartialEq)]
@@ -36,38 +30,37 @@ pub enum Msg {
RemoveGroupResponse(Result<remove_user_from_group::ResponseData>), RemoveGroupResponse(Result<remove_user_from_group::ResponseData>),
} }
impl RemoveUserFromGroupComponent { impl CommonComponent<RemoveUserFromGroupComponent> for 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)
}
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg { match msg {
Msg::SubmitRemoveGroup => return self.submit_remove_group(), Msg::SubmitRemoveGroup => self.submit_remove_group(),
Msg::RemoveGroupResponse(response) => { Msg::RemoveGroupResponse(response) => {
response?; response?;
self.task = None; self.common.cancel_task();
self.props self.common
.on_user_removed_from_group .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) 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 { impl Component for RemoveUserFromGroupComponent {
@@ -76,21 +69,16 @@ impl Component for RemoveUserFromGroupComponent {
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
Self { Self {
link, common: CommonComponentParts::<Self>::create(props, link),
props,
task: None,
} }
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, msg: Self::Message) -> ShouldRender {
match self.handle_msg(msg) { CommonComponentParts::<Self>::update_and_report_error(
Err(e) => { self,
self.task = None; msg,
self.props.on_error.emit(e); self.common.on_error.clone(),
true )
}
Ok(b) => b,
}
} }
fn change(&mut self, _: Self::Properties) -> ShouldRender { fn change(&mut self, _: Self::Properties) -> ShouldRender {
@@ -101,8 +89,8 @@ impl Component for RemoveUserFromGroupComponent {
html! { html! {
<button <button
class="btn btn-danger" class="btn btn-danger"
disabled=self.task.is_some() disabled=self.common.is_task_running()
onclick=self.link.callback(|_| Msg::SubmitRemoveGroup)> onclick=self.common.callback(|_| Msg::SubmitRemoveGroup)>
<i class="bi-x-circle-fill" aria-label="Remove user from group" /> <i class="bi-x-circle-fill" aria-label="Remove user from group" />
</button> </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 { pub enum AppRoute {
#[to = "/login"] #[to = "/login"]
Login, Login,
#[to = "/reset-password/step1"]
StartResetPassword,
#[to = "/reset-password/step2/{token}"]
FinishResetPassword(String),
#[to = "/users/create"] #[to = "/users/create"]
CreateUser, CreateUser,
#[to = "/users"] #[to = "/users"]

View File

@@ -5,14 +5,11 @@ use crate::{
router::{AppRoute, Link, NavButton}, router::{AppRoute, Link, NavButton},
user_details_form::UserDetailsForm, user_details_form::UserDetailsForm,
}, },
infra::api::HostService, infra::common_component::{CommonComponent, CommonComponentParts},
}; };
use anyhow::{bail, Error, Result}; use anyhow::{bail, Error, Result};
use graphql_client::GraphQLQuery; use graphql_client::GraphQLQuery;
use yew::{ use yew::prelude::*;
prelude::*,
services::{fetch::FetchTask, ConsoleService},
};
#[derive(GraphQLQuery)] #[derive(GraphQLQuery)]
#[graphql( #[graphql(
@@ -27,15 +24,10 @@ pub type User = get_user_details::GetUserDetailsUser;
pub type Group = get_user_details::GetUserDetailsUserGroups; pub type Group = get_user_details::GetUserDetailsUserGroups;
pub struct UserDetails { pub struct UserDetails {
link: ComponentLink<Self>, common: CommonComponentParts<Self>,
props: Props,
/// The user info. If none, the error is in `error`. If `error` is None, then we haven't /// The user info. If none, the error is in `error`. If `error` is None, then we haven't
/// received the server response yet. /// received the server response yet.
user: Option<User>, 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. /// State machine describing the possible transitions of the component state.
@@ -54,22 +46,7 @@ pub struct Props {
pub is_admin: bool, pub is_admin: bool,
} }
impl UserDetails { impl CommonComponent<UserDetails> for 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();
}
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg { match msg {
Msg::UserDetailsResponse(response) => match response { Msg::UserDetailsResponse(response) => match response {
@@ -94,6 +71,22 @@ impl UserDetails {
Ok(true) 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 { fn view_messages(&self, error: &Option<Error>) -> Html {
if let Some(e) = error { if let Some(e) = error {
html! { html! {
@@ -111,7 +104,7 @@ impl UserDetails {
let display_name = group.display_name.clone(); let display_name = group.display_name.clone();
html! { html! {
<tr key="groupRow_".to_string() + &display_name> <tr key="groupRow_".to_string() + &display_name>
{if self.props.is_admin { html! { {if self.common.is_admin { html! {
<> <>
<td> <td>
<Link route=AppRoute::GroupDetails(group.id)> <Link route=AppRoute::GroupDetails(group.id)>
@@ -122,8 +115,8 @@ impl UserDetails {
<RemoveUserFromGroupComponent <RemoveUserFromGroupComponent
username=u.id.clone() username=u.id.clone()
group_id=group.id group_id=group.id
on_user_removed_from_group=self.link.callback(Msg::OnUserRemovedFromGroup) on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup)
on_error=self.link.callback(Msg::OnError)/> on_error=self.common.callback(Msg::OnError)/>
</td> </td>
</> </>
} } else { html! { } } else { html! {
@@ -140,7 +133,7 @@ impl UserDetails {
<thead> <thead>
<tr key="headerRow"> <tr key="headerRow">
<th>{"Group"}</th> <th>{"Group"}</th>
{ if self.props.is_admin { html!{ <th></th> }} else { html!{} }} { if self.common.is_admin { html!{ <th></th> }} else { html!{} }}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -161,13 +154,13 @@ impl UserDetails {
} }
fn view_add_group_button(&self, u: &User) -> Html { fn view_add_group_button(&self, u: &User) -> Html {
if self.props.is_admin { if self.common.is_admin {
html! { html! {
<AddUserToGroupComponent <AddUserToGroupComponent
username=u.id.clone() username=u.id.clone()
groups=u.groups.clone() groups=u.groups.clone()
on_error=self.link.callback(Msg::OnError) on_error=self.common.callback(Msg::OnError)
on_user_added_to_group=self.link.callback(Msg::OnUserAddedToGroup)/> on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/>
} }
} else { } else {
html! {} html! {}
@@ -181,26 +174,15 @@ impl Component for UserDetails {
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
let mut table = Self { let mut table = Self {
link, common: CommonComponentParts::<Self>::create(props, link),
props,
_task: None,
user: None, user: None,
error: None,
}; };
table.get_user_details(); table.get_user_details();
table table
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, msg: Self::Message) -> ShouldRender {
self.error = None; CommonComponentParts::<Self>::update(self, msg)
match self.handle_msg(msg) {
Err(e) => {
ConsoleService::error(&e.to_string());
self.error = Some(e);
true
}
Ok(b) => b,
}
} }
fn change(&mut self, _: Self::Properties) -> ShouldRender { fn change(&mut self, _: Self::Properties) -> ShouldRender {
@@ -208,7 +190,7 @@ impl Component for UserDetails {
} }
fn view(&self) -> Html { fn view(&self) -> Html {
match (&self.user, &self.error) { match (&self.user, &self.common.error) {
(None, None) => html! {{"Loading..."}}, (None, None) => html! {{"Loading..."}},
(None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>}, (None, Some(e)) => html! {<div>{"Error: "}{e.to_string()}</div>},
(Some(u), error) => { (Some(u), error) => {
@@ -217,7 +199,7 @@ impl Component for UserDetails {
<h3>{u.id.to_string()}</h3> <h3>{u.id.to_string()}</h3>
<UserDetailsForm <UserDetailsForm
user=u.clone() user=u.clone()
on_error=self.link.callback(Msg::OnError)/> on_error=self.common.callback(Msg::OnError)/>
<div class="row justify-content-center"> <div class="row justify-content-center">
<NavButton <NavButton
route=AppRoute::ChangePassword(u.id.clone()) 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 anyhow::{bail, Error, Result};
use graphql_client::GraphQLQuery; use graphql_client::GraphQLQuery;
use validator_derive::Validate; use validator_derive::Validate;
use yew::{ use yew::prelude::*;
prelude::*,
services::{fetch::FetchTask, ConsoleService},
};
use yew_form_derive::Model; use yew_form_derive::Model;
/// The fields of the form, with the editable details and the constraints. /// 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. /// A [yew::Component] to display the user details, with a form allowing to edit them.
pub struct UserDetailsForm { pub struct UserDetailsForm {
link: ComponentLink<Self>, common: CommonComponentParts<Self>,
props: Props,
form: yew_form::Form<UserModel>, form: yew_form::Form<UserModel>,
/// True if we just successfully updated the user, to display a success message. /// True if we just successfully updated the user, to display a success message.
just_updated: bool, just_updated: bool,
task: Option<FetchTask>,
} }
pub enum Msg { pub enum Msg {
@@ -57,6 +55,20 @@ pub struct Props {
pub on_error: Callback<Error>, pub on_error: Callback<Error>,
} }
impl CommonComponent<UserDetailsForm> for UserDetailsForm {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
match msg {
Msg::Update => Ok(true),
Msg::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 { impl Component for UserDetailsForm {
type Message = Msg; type Message = Msg;
type Properties = Props; type Properties = Props;
@@ -69,25 +81,19 @@ impl Component for UserDetailsForm {
last_name: props.user.last_name.clone(), last_name: props.user.last_name.clone(),
}; };
Self { Self {
link, common: CommonComponentParts::<Self>::create(props, link),
form: yew_form::Form::new(model), form: yew_form::Form::new(model),
props,
just_updated: false, just_updated: false,
task: None,
} }
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, msg: Self::Message) -> ShouldRender {
self.just_updated = false; self.just_updated = false;
match self.handle_msg(msg) { CommonComponentParts::<Self>::update_and_report_error(
Err(e) => { self,
ConsoleService::error(&e.to_string()); msg,
self.props.on_error.emit(e); self.common.on_error.clone(),
self.task = None; )
true
}
Ok(b) => b,
}
} }
fn change(&mut self, _: Self::Properties) -> ShouldRender { fn change(&mut self, _: Self::Properties) -> ShouldRender {
@@ -105,7 +111,7 @@ impl Component for UserDetailsForm {
{"User ID: "} {"User ID: "}
</label> </label>
<div class="col-8"> <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> </div>
<div class="form-group row mb-3"> <div class="form-group row mb-3">
@@ -121,7 +127,7 @@ impl Component for UserDetailsForm {
form=&self.form form=&self.form
field_name="email" field_name="email"
autocomplete="email" autocomplete="email"
oninput=self.link.callback(|_| Msg::Update) /> oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("email")} {&self.form.field_message("email")}
</div> </div>
@@ -140,7 +146,7 @@ impl Component for UserDetailsForm {
form=&self.form form=&self.form
field_name="display_name" field_name="display_name"
autocomplete="name" autocomplete="name"
oninput=self.link.callback(|_| Msg::Update) /> oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("display_name")} {&self.form.field_message("display_name")}
</div> </div>
@@ -157,7 +163,7 @@ impl Component for UserDetailsForm {
form=&self.form form=&self.form
field_name="first_name" field_name="first_name"
autocomplete="given-name" autocomplete="given-name"
oninput=self.link.callback(|_| Msg::Update) /> oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("first_name")} {&self.form.field_message("first_name")}
</div> </div>
@@ -174,7 +180,7 @@ impl Component for UserDetailsForm {
form=&self.form form=&self.form
field_name="last_name" field_name="last_name"
autocomplete="family-name" autocomplete="family-name"
oninput=self.link.callback(|_| Msg::Update) /> oninput=self.common.callback(|_| Msg::Update) />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("last_name")} {&self.form.field_message("last_name")}
</div> </div>
@@ -186,15 +192,15 @@ impl Component for UserDetailsForm {
{"Creation date: "} {"Creation date: "}
</label> </label>
<div class="col-8"> <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> </div>
<div class="form-group row justify-content-center"> <div class="form-group row justify-content-center">
<button <button
type="submit" type="submit"
class="btn btn-primary col-auto col-form-label" class="btn btn-primary col-auto col-form-label"
disabled=self.task.is_some() disabled=self.common.is_task_running()
onclick=self.link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})> onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})>
{"Update"} {"Update"}
</button> </button>
</div> </div>
@@ -208,21 +214,13 @@ impl Component for UserDetailsForm {
} }
impl 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> { fn submit_user_update_form(&mut self) -> Result<bool> {
if !self.form.validate() { if !self.form.validate() {
bail!("Invalid inputs"); bail!("Invalid inputs");
} }
let base_user = &self.props.user; let base_user = &self.common.user;
let mut user_input = update_user::UpdateUserInput { let mut user_input = update_user::UpdateUserInput {
id: self.props.user.id.clone(), id: self.common.user.id.clone(),
email: None, email: None,
displayName: None, displayName: None,
firstName: None, firstName: None,
@@ -248,28 +246,28 @@ impl UserDetailsForm {
return Ok(false); return Ok(false);
} }
let req = update_user::Variables { user: user_input }; let req = update_user::Variables { user: user_input };
self.task = Some(HostService::graphql_query::<UpdateUser>( self.common.call_graphql::<UpdateUser, _>(
req, req,
self.link.callback(Msg::UserUpdated), Msg::UserUpdated,
"Error trying to update user", "Error trying to update user",
)?); );
Ok(false) Ok(false)
} }
fn user_update_finished(&mut self, r: Result<update_user::ResponseData>) -> Result<bool> { fn user_update_finished(&mut self, r: Result<update_user::ResponseData>) -> Result<bool> {
self.task = None; self.common.cancel_task();
match r { match r {
Err(e) => return Err(e), Err(e) => return Err(e),
Ok(_) => { Ok(_) => {
let model = self.form.model(); let model = self.form.model();
self.props.user = User { self.common.user = User {
id: self.props.user.id.clone(), id: self.common.user.id.clone(),
email: model.email, email: model.email,
display_name: model.display_name, display_name: model.display_name,
first_name: model.first_name, first_name: model.first_name,
last_name: model.last_name, last_name: model.last_name,
creation_date: self.props.user.creation_date, creation_date: self.common.user.creation_date,
groups: self.props.user.groups.clone(), groups: self.common.user.groups.clone(),
}; };
self.just_updated = true; self.just_updated = true;
} }

View File

@@ -3,12 +3,11 @@ use crate::{
delete_user::DeleteUser, delete_user::DeleteUser,
router::{AppRoute, Link}, router::{AppRoute, Link},
}, },
infra::api::HostService, infra::common_component::{CommonComponent, CommonComponentParts},
}; };
use anyhow::{Error, Result}; use anyhow::{Error, Result};
use graphql_client::GraphQLQuery; use graphql_client::GraphQLQuery;
use yew::prelude::*; use yew::prelude::*;
use yew::services::{fetch::FetchTask, ConsoleService};
#[derive(GraphQLQuery)] #[derive(GraphQLQuery)]
#[graphql( #[graphql(
@@ -24,11 +23,8 @@ use list_users_query::{RequestFilter, ResponseData};
type User = list_users_query::ListUsersQueryUsers; type User = list_users_query::ListUsersQueryUsers;
pub struct UserTable { pub struct UserTable {
link: ComponentLink<Self>, common: CommonComponentParts<Self>,
users: Option<Vec<User>>, users: Option<Vec<User>>,
error: Option<Error>,
// Used to keep the request alive long enough.
_task: Option<FetchTask>,
} }
pub enum Msg { pub enum Msg {
@@ -37,18 +33,34 @@ pub enum Msg {
OnError(Error), 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 { impl UserTable {
fn get_users(&mut self, req: Option<RequestFilter>) { 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 }, list_users_query::Variables { filters: req },
self.link.callback(Msg::ListUsersResponse), Msg::ListUsersResponse,
"Error trying to fetch users", "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 Message = Msg;
type Properties = (); type Properties = ();
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
let mut table = UserTable { let mut table = UserTable {
link, common: CommonComponentParts::<Self>::create(props, link),
_task: None,
users: None, users: None,
error: None,
}; };
table.get_users(None); table.get_users(None);
table table
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, msg: Self::Message) -> ShouldRender {
self.error = None; CommonComponentParts::<Self>::update(self, msg)
match self.handle_msg(msg) {
Err(e) => {
ConsoleService::error(&e.to_string());
self.error = Some(e);
true
}
Ok(b) => b,
}
} }
fn change(&mut self, _: Self::Properties) -> ShouldRender { fn change(&mut self, _: Self::Properties) -> ShouldRender {
@@ -94,21 +96,6 @@ impl Component for UserTable {
} }
impl 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 { fn view_users(&self) -> Html {
let make_table = |users: &Vec<User>| { let make_table = |users: &Vec<User>| {
html! { html! {
@@ -150,15 +137,15 @@ impl UserTable {
<td> <td>
<DeleteUser <DeleteUser
username=user.id.clone() username=user.id.clone()
on_user_deleted=self.link.callback(Msg::OnUserDeleted) on_user_deleted=self.common.callback(Msg::OnUserDeleted)
on_error=self.link.callback(Msg::OnError)/> on_error=self.common.callback(Msg::OnError)/>
</td> </td>
</tr> </tr>
} }
} }
fn view_errors(&self) -> Html { fn view_errors(&self) -> Html {
match &self.error { match &self.common.error {
None => html! {}, None => html! {},
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>}, 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( call_server_empty_response_with_error_message(
"/auth/logout", "/auth/logout",
yew::format::Nothing, yew::format::Nothing,
@@ -231,4 +232,28 @@ impl HostService {
"Could not logout", "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 api;
pub mod common_component;
pub mod cookies; pub mod cookies;
pub mod graphql; pub mod graphql;
pub mod modal; pub mod modal;

View File

@@ -1,4 +1,5 @@
#![recursion_limit = "256"] #![recursion_limit = "256"]
#![forbid(non_ascii_idents)]
#![allow(clippy::nonstandard_macro_braces)] #![allow(clippy::nonstandard_macro_braces)]
pub mod components; pub mod components;
pub mod infra; 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] [package]
name = "lldap_auth" 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>"] authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
edition = "2018" edition = "2021"
[features] [features]
default = ["opaque_server", "opaque_client"] default = ["opaque_server", "opaque_client"]
@@ -20,30 +20,13 @@ serde = "*"
sha2 = "0.9" sha2 = "0.9"
thiserror = "*" thiserror = "*"
# TODO: update to 0.6 when out.
[dependencies.opaque-ke] [dependencies.opaque-ke]
git = "https://github.com/novifinancial/opaque-ke" version = "0.6"
rev = "eb59676a940b15f77871aefe1e46d7b5bf85f40a"
[dependencies.chrono] [dependencies.chrono]
version = "*" version = "*"
features = [ "serde" ] 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. # For WASM targets, use the JS getrandom.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom] [target.'cfg(not(target_arch = "wasm32"))'.dependencies.getrandom]
version = "0.2" version = "0.2"

View File

@@ -1,3 +1,4 @@
#![forbid(non_ascii_idents)]
#![allow(clippy::nonstandard_macro_braces)] #![allow(clippy::nonstandard_macro_braces)]
use chrono::prelude::*; use chrono::prelude::*;
use serde::{Deserialize, Serialize}; 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. ## Default configuration for Docker.
## All the values can be overridden through environment variables. For ## All the values can be overridden through environment variables, prefixed
## instance, "ldap_port" can be overridden with the "LDAP_PORT" variable. ## with "LLDAP_". For instance, "ldap_port" can be overridden with the
## "LLDAP_LDAP_PORT" variable.
## The port on which to have the LDAP server. ## The port on which to have the LDAP server.
#ldap_port = 3890 #ldap_port = 3890
@@ -9,13 +10,18 @@
## administration. ## administration.
#http_port = 17170 #http_port = 17170
## The public URL of the server, for password reset links.
#http_url = "http://localhost"
## Random secret for JWT signature. ## Random secret for JWT signature.
## This secret should be random, and should be shared with application ## This secret should be random, and should be shared with application
## servers that need to consume the JWTs. ## servers that need to consume the JWTs.
## Changing this secret will invalidate all user sessions and require ## Changing this secret will invalidate all user sessions and require
## them to re-login. ## 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. ## 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): ## You can generate it with (on linux):
## LC_ALL=C tr -dc 'A-Za-z0-9!"#%&'\''()*+,-./:;<=>?@[\]^_{|}~' </dev/urandom | head -c 32; echo '' ## LC_ALL=C tr -dc 'A-Za-z0-9!"#%&'\''()*+,-./:;<=>?@[\]^_{|}~' </dev/urandom | head -c 32; echo ''
#jwt_secret = "REPLACE_WITH_RANDOM" #jwt_secret = "REPLACE_WITH_RANDOM"
@@ -31,16 +37,19 @@
## Admin username. ## Admin username.
## For the LDAP interface, a value of "admin" here will create the LDAP ## 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. ## For the administration interface, this is the username.
#ldap_user_dn = "admin" #ldap_user_dn = "admin"
## Admin password. ## Admin password.
## Password for the admin account, both for the LDAP bind and for the ## 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. ## It should be minimum 8 characters long.
## You can set it with the LDAP_USER_PASS environment variable. ## You can set it with the LLDAP_LDAP_USER_PASS environment variable.
## Note: you can create another admin user for LDAP/administration, this ## 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. ## is just the default one.
#ldap_user_pass = "REPLACE_WITH_PASSWORD" #ldap_user_pass = "REPLACE_WITH_PASSWORD"
@@ -64,3 +73,25 @@ database_url = "sqlite:///data/users.db?mode=rwc"
## each password. ## each password.
## Randomly generated on first run if it doesn't exist. ## Randomly generated on first run if it doesn't exist.
key_file = "/data/private_key" 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] [package]
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"] authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
edition = "2018" edition = "2021"
name = "lldap" name = "lldap"
version = "0.1.0" version = "0.2.0"
[dependencies] [dependencies]
actix = "0.12" actix = "0.12"
@@ -26,7 +26,7 @@ futures-util = "*"
hmac = "0.10" hmac = "0.10"
http = "*" http = "*"
jwt = "0.13" jwt = "0.13"
ldap3_server = "*" ldap3_server = ">=0.1.9"
lldap_auth = { path = "../auth" } lldap_auth = { path = "../auth" }
log = "*" log = "*"
orion = "0.16" orion = "0.16"
@@ -38,19 +38,28 @@ thiserror = "*"
time = "0.2" time = "0.2"
tokio = { version = "1.2.0", features = ["full"] } tokio = { version = "1.2.0", features = ["full"] }
tokio-util = "0.6.3" tokio-util = "0.6.3"
tokio-stream = "*"
tracing = "*" tracing = "*"
tracing-actix-web = "0.4.0-beta.7" tracing-actix-web = "0.4.0-beta.7"
tracing-log = "*" tracing-log = "*"
tracing-subscriber = "*" tracing-subscriber = "0.3"
rand = { version = "0.8", features = ["small_rng", "getrandom"] } rand = { version = "0.8", features = ["small_rng", "getrandom"] }
juniper_actix = "0.4.0" juniper_actix = "0.4.0"
juniper = "0.15.6" juniper = "0.15.6"
itertools = "0.10.1" itertools = "0.10.1"
# TODO: update to 0.6 when out.
[dependencies.opaque-ke] [dependencies.opaque-ke]
git = "https://github.com/novifinancial/opaque-ke" version = "0.6"
rev = "eb59676a940b15f77871aefe1e46d7b5bf85f40a"
[dependencies.lettre]
version = "0.10.0-rc.3"
features = [
"builder",
"serde",
"smtp-transport",
"tokio1-native-tls",
"tokio1",
]
[dependencies.sqlx] [dependencies.sqlx]
version = "0.5.1" version = "0.5.1"
@@ -72,5 +81,13 @@ features = ["with-chrono"]
features = ["env", "toml"] features = ["env", "toml"]
version = "*" version = "*"
[dependencies.secstr]
features = ["serde"]
version = "*"
[dependencies.openssl-sys]
features = ["vendored"]
version = "*"
[dev-dependencies] [dev-dependencies]
mockall = "0.9.1" mockall = "0.9.1"

View File

@@ -28,9 +28,18 @@ mockall::mock! {
} }
#[async_trait] #[async_trait]
impl OpaqueHandler for TestOpaqueHandler { impl OpaqueHandler for TestOpaqueHandler {
async fn login_start(&self, request: login::ClientLoginStartRequest) -> Result<login::ServerLoginStartResponse>; async fn login_start(
async fn login_finish(&self, request: login::ClientLoginFinishRequest ) -> Result<String>; &self,
async fn registration_start(&self, request: registration::ClientRegistrationStartRequest) -> Result<registration::ServerRegistrationStartResponse>; request: login::ClientLoginStartRequest
async fn registration_finish(&self, request: registration::ClientRegistrationFinishRequest ) -> Result<()>; ) -> 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 sql_pool = get_in_memory_db().await;
let config = ConfigurationBuilder::default() let config = ConfigurationBuilder::default()
.ldap_user_dn("admin".to_string()) .ldap_user_dn("admin".to_string())
.ldap_user_pass("test".to_string()) .ldap_user_pass(secstr::SecUtf8::from("test"))
.build() .build()
.unwrap(); .unwrap();
let handler = SqlBackendHandler::new(config, sql_pool); let handler = SqlBackendHandler::new(config, sql_pool);

View File

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

View File

@@ -15,6 +15,7 @@ use actix_web::{
error::{ErrorBadRequest, ErrorUnauthorized}, error::{ErrorBadRequest, ErrorUnauthorized},
web, HttpRequest, HttpResponse, web, HttpRequest, HttpResponse,
}; };
use actix_web_httpauth::extractors::bearer::BearerAuth;
use anyhow::Result; use anyhow::Result;
use chrono::prelude::*; use chrono::prelude::*;
use futures::future::{ok, Ready}; use futures::future::{ok, Ready};
@@ -22,6 +23,7 @@ use futures_util::{FutureExt, TryFutureExt};
use hmac::Hmac; use hmac::Hmac;
use jwt::{SignWithKey, VerifyWithKey}; use jwt::{SignWithKey, VerifyWithKey};
use lldap_auth::{login, registration, JWTClaims}; use lldap_auth::{login, registration, JWTClaims};
use log::*;
use sha2::Sha512; use sha2::Sha512;
use std::collections::{hash_map::DefaultHasher, HashSet}; use std::collections::{hash_map::DefaultHasher, HashSet};
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
@@ -101,7 +103,7 @@ where
.cookie( .cookie(
Cookie::build("token", token.as_str()) Cookie::build("token", token.as_str())
.max_age(1.days()) .max_age(1.days())
.path("/api") .path("/")
.http_only(true) .http_only(true)
.same_site(SameSite::Strict) .same_site(SameSite::Strict)
.finish(), .finish(),
@@ -111,6 +113,79 @@ where
.unwrap_or_else(error_to_http_response) .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>( async fn get_logout<Backend>(
data: web::Data<AppState<Backend>>, data: web::Data<AppState<Backend>>,
request: HttpRequest, request: HttpRequest,
@@ -148,7 +223,7 @@ where
.cookie( .cookie(
Cookie::build("token", "") Cookie::build("token", "")
.max_age(0.days()) .max_age(0.days())
.path("/api") .path("/")
.http_only(true) .http_only(true)
.same_site(SameSite::Strict) .same_site(SameSite::Strict)
.finish(), .finish(),
@@ -203,7 +278,7 @@ where
.cookie( .cookie(
Cookie::build("token", token.as_str()) Cookie::build("token", token.as_str())
.max_age(1.days()) .max_age(1.days())
.path("/api") .path("/")
.http_only(true) .http_only(true)
.same_site(SameSite::Strict) .same_site(SameSite::Strict)
.finish(), .finish(),
@@ -254,14 +329,45 @@ where
} }
async fn opaque_register_start<Backend>( async fn opaque_register_start<Backend>(
request: actix_web::HttpRequest,
mut payload: actix_web::web::Payload,
data: web::Data<AppState<Backend>>, data: web::Data<AppState<Backend>>,
request: web::Json<registration::ClientRegistrationStartRequest>,
) -> ApiResult<registration::ServerRegistrationStartResponse> ) -> ApiResult<registration::ServerRegistrationStartResponse>
where where
Backend: OpaqueHandler + 'static, 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 data.backend_handler
.registration_start(request.into_inner()) .registration_start(registration_start_request)
.await .await
.map(|res| ApiResult::Left(web::Json(res))) .map(|res| ApiResult::Left(web::Json(res)))
.unwrap_or_else(error_to_api_response) .unwrap_or_else(error_to_api_response)
@@ -402,14 +508,25 @@ where
web::resource("/opaque/login/finish") web::resource("/opaque/login/finish")
.route(web::post().to(opaque_login_finish::<Backend>)), .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("/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 clap::Clap;
use lettre::message::Mailbox;
/// lldap is a lightweight LDAP server /// lldap is a lightweight LDAP server
#[derive(Debug, Clap, Clone)] #[derive(Debug, Clap, Clone)]
@@ -9,6 +10,7 @@ pub struct CLIOpts {
pub command: Command, pub command: Command,
} }
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clap, Clone)] #[derive(Debug, Clap, Clone)]
pub enum Command { pub enum Command {
/// Export the GraphQL schema to *.graphql. /// Export the GraphQL schema to *.graphql.
@@ -17,25 +19,100 @@ pub enum Command {
/// Run the LDAP and GraphQL server. /// Run the LDAP and GraphQL server.
#[clap(name = "run")] #[clap(name = "run")]
Run(RunOpts), 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)] #[derive(Debug, Clap, Clone)]
pub struct RunOpts { pub struct RunOpts {
/// Change config file name #[clap(flatten)]
#[clap(short, long, default_value = "lldap_config.toml")] pub general_config: GeneralConfigOpts,
pub config_file: String,
/// Change ldap port. Default: 389 /// Path to the file that contains the private server key.
#[clap(long)] /// 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>, pub ldap_port: Option<u16>,
/// Change ldap ssl port. Default: 636 /// Change ldap ssl port. Default: 6360
#[clap(long)] #[clap(long, env = "LLDAP_LDAPS_PORT")]
pub ldaps_port: Option<u16>, pub ldaps_port: Option<u16>,
/// Set verbose logging /// Change HTTP API port. Default: 17170
#[clap(short, long)] #[clap(long, env = "LLDAP_HTTP_PORT")]
pub verbose: bool, 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)] #[derive(Debug, Clap, Clone)]

View File

@@ -1,49 +1,83 @@
use crate::infra::cli::{GeneralConfigOpts, RunOpts, SmtpOpts, TestEmailOpts};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use figment::{ use figment::{
providers::{Env, Format, Serialized, Toml}, providers::{Env, Format, Serialized, Toml},
Figment, Figment,
}; };
use lettre::message::Mailbox;
use lldap_auth::opaque::{server::ServerSetup, KeyPair}; use lldap_auth::opaque::{server::ServerSetup, KeyPair};
use log::*; use secstr::SecUtf8;
use serde::{Deserialize, Serialize}; 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)] #[derive(Clone, Debug, Deserialize, Serialize, derive_builder::Builder)]
#[builder( #[builder(pattern = "owned", build_fn(name = "private_build"))]
pattern = "owned",
default = "Configuration::default()",
build_fn(name = "private_build", validate = "Self::validate")
)]
pub struct Configuration { pub struct Configuration {
#[builder(default = "3890")]
pub ldap_port: u16, pub ldap_port: u16,
#[builder(default = "6360")]
pub ldaps_port: u16, pub ldaps_port: u16,
#[builder(default = "17170")]
pub http_port: u16, 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, pub ldap_base_dn: String,
#[builder(default = r#"String::from("admin")"#)]
pub ldap_user_dn: String, 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, pub database_url: String,
#[builder(default = "false")]
pub verbose: bool, pub verbose: bool,
#[builder(default = r#"String::from("server_key")"#)]
pub key_file: String, pub key_file: String,
#[builder(default)]
pub smtp_options: MailOptions,
#[builder(default = r#"String::from("http://localhost")"#)]
pub http_url: String,
#[serde(skip)] #[serde(skip)]
#[builder(field(private), setter(strip_option))] #[builder(field(private), default = "None")]
server_setup: Option<ServerSetup>, server_setup: Option<ServerSetup>,
} }
impl std::default::Default for Configuration {
fn default() -> Self {
ConfigurationBuilder::default().build().unwrap()
}
}
impl ConfigurationBuilder { impl ConfigurationBuilder {
#[cfg(test)]
pub fn build(self) -> Result<Configuration> { pub fn build(self) -> Result<Configuration> {
let server_setup = get_server_setup(self.key_file.as_deref().unwrap_or("server_key"))?; let server_setup = get_server_setup(self.key_file.as_deref().unwrap_or("server_key"))?;
Ok(self.server_setup(server_setup).private_build()?) Ok(self.server_setup(Some(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(())
}
} }
} }
@@ -55,39 +89,6 @@ impl Configuration {
pub fn get_server_keys(&self) -> &KeyPair { pub fn get_server_keys(&self) -> &KeyPair {
self.get_server_setup().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> { 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> { pub trait ConfigOverrider {
let config_file = cli_opts.config_file.clone(); 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())) impl TopLevelCommandOpts for RunOpts {
.merge(Toml::file(config_file)) fn general_config(&self) -> &GeneralConfigOpts {
.merge(Env::prefixed("LLDAP_")) &self.general_config
.extract()?; }
}
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)?); 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) Ok(config)
} }

View File

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

View File

@@ -21,6 +21,15 @@ pub enum JwtStorage {
Blacklisted, 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. /// This needs to be initialized after the domain tables are.
pub async fn init_table(pool: &Pool) -> sqlx::Result<()> { pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
sqlx::query( sqlx::query(
@@ -95,5 +104,38 @@ pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
.execute(pool) .execute(pool)
.await?; .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(()) 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::{
use crate::infra::configuration::Configuration; domain::{
use crate::infra::ldap_handler::LdapHandler; handler::{BackendHandler, LoginHandler},
opaque_handler::OpaqueHandler,
},
infra::{configuration::Configuration, ldap_handler::LdapHandler},
};
use actix_rt::net::TcpStream; use actix_rt::net::TcpStream;
use actix_server::ServerBuilder; use actix_server::ServerBuilder;
use actix_service::{fn_service, ServiceFactoryExt}; use actix_service::{fn_service, ServiceFactoryExt};
use anyhow::{bail, Result}; use anyhow::{Context, Result};
use futures_util::future::ok; use futures_util::future::ok;
use ldap3_server::simple::*; use ldap3_server::{proto::LdapMsg, LdapCodec};
use ldap3_server::LdapCodec;
use log::*; use log::*;
use tokio::net::tcp::WriteHalf; use tokio::net::tcp::WriteHalf;
use tokio_util::codec::{FramedRead, FramedWrite}; use tokio_util::codec::{FramedRead, FramedWrite};
@@ -18,36 +21,31 @@ async fn handle_incoming_message<Backend>(
session: &mut LdapHandler<Backend>, session: &mut LdapHandler<Backend>,
) -> Result<bool> ) -> Result<bool>
where where
Backend: BackendHandler + LoginHandler, Backend: BackendHandler + LoginHandler + OpaqueHandler,
{ {
use futures_util::SinkExt; use futures_util::SinkExt;
use std::convert::TryFrom; let msg = msg.context("while receiving LDAP op")?;
let server_op = match msg.map_err(|_e| ()).and_then(ServerOps::try_from) { debug!("Received LDAP message: {:?}", &msg);
Ok(a_value) => a_value, match session.handle_ldap_message(msg.op).await {
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 {
None => return Ok(false), None => return Ok(false),
Some(result) => { Some(result) => {
for rmsg in result.into_iter() { if result.is_empty() {
if let Err(e) = resp.send(rmsg).await { debug!("No response");
bail!("Error while sending a response: {:?}", e); }
} 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 { resp.flush()
bail!("Error while flushing responses: {:?}", e); .await
} .context("while flushing responses: {:#}")?
} }
} }
Ok(true) Ok(true)
@@ -59,14 +57,14 @@ pub fn build_ldap_server<Backend>(
server_builder: ServerBuilder, server_builder: ServerBuilder,
) -> Result<ServerBuilder> ) -> Result<ServerBuilder>
where where
Backend: BackendHandler + LoginHandler + 'static, Backend: BackendHandler + LoginHandler + OpaqueHandler + 'static,
{ {
use futures_util::StreamExt; use futures_util::StreamExt;
let ldap_base_dn = config.ldap_base_dn.clone(); let ldap_base_dn = config.ldap_base_dn.clone();
let ldap_user_dn = config.ldap_user_dn.clone(); let ldap_user_dn = config.ldap_user_dn.clone();
Ok( server_builder
server_builder.bind("ldap", ("0.0.0.0", config.ldap_port), move || { .bind("ldap", ("0.0.0.0", config.ldap_port), move || {
let backend_handler = backend_handler.clone(); let backend_handler = backend_handler.clone();
let ldap_base_dn = ldap_base_dn.clone(); let ldap_base_dn = ldap_base_dn.clone();
let ldap_user_dn = ldap_user_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); let mut session = LdapHandler::new(backend_handler, ldap_base_dn, ldap_user_dn);
while let Some(msg) = requests.next().await { 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; break;
} }
} }
@@ -91,12 +92,11 @@ where
Ok(stream) Ok(stream)
} }
}) })
.map_err(|err: anyhow::Error| error!("Service Error: {:?}", err)) .map_err(|err: anyhow::Error| error!("Service Error: {:#}", err))
// catch
.and_then(move |_| { .and_then(move |_| {
// finally // finally
ok(()) ok(())
}) })
})?, })
) .with_context(|| format!("while binding to the port {}", config.ldap_port))
} }

View File

@@ -1,25 +1,30 @@
use crate::infra::configuration::Configuration; use crate::infra::configuration::Configuration;
use anyhow::Context; use tracing_subscriber::prelude::*;
use tracing::subscriber::set_global_default;
use tracing_log::LogTracer;
pub fn init(config: Configuration) -> anyhow::Result<()> { pub fn init(config: &Configuration) -> anyhow::Result<()> {
let max_log_level = log_level_from_config(config); let max_log_level = log_level_from_config(config);
let subscriber = tracing_subscriber::fmt() let sqlx_max_log_level = sqlx_log_level_from_config(config);
.with_timer(tracing_subscriber::fmt::time::time()) let filter = tracing_subscriber::filter::Targets::new()
.with_target(false) .with_target("lldap", max_log_level)
.with_level(true) .with_target("sqlx", sqlx_max_log_level);
.with_max_level(max_log_level) tracing_subscriber::registry()
.finish(); .with(tracing_subscriber::fmt::layer().with_filter(filter))
LogTracer::init().context("Failed to set logger")?; .init();
set_global_default(subscriber).context("Failed to set subscriber")?;
Ok(()) Ok(())
} }
fn log_level_from_config(config: Configuration) -> tracing::Level { fn log_level_from_config(config: &Configuration) -> tracing::Level {
if config.verbose { if config.verbose {
tracing::Level::DEBUG tracing::Level::DEBUG
} else { } else {
tracing::Level::INFO 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_handler;
pub mod ldap_server; pub mod ldap_server;
pub mod logging; pub mod logging;
pub mod mail;
pub mod sql_backend_handler; pub mod sql_backend_handler;
pub mod tcp_backend_handler; pub mod tcp_backend_handler;
pub mod tcp_server; pub mod tcp_server;

View File

@@ -6,10 +6,19 @@ use sea_query::{Expr, Iden, Query, SimpleExpr};
use sqlx::Row; use sqlx::Row;
use std::collections::HashSet; 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] #[async_trait]
impl TcpBackendHandler for SqlBackendHandler { impl TcpBackendHandler for SqlBackendHandler {
async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>> { async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>> {
use sqlx::Result;
let query = Query::select() let query = Query::select()
.column(JwtStorage::JwtHash) .column(JwtStorage::JwtHash)
.from(JwtStorage::Table) .from(JwtStorage::Table)
@@ -21,21 +30,15 @@ impl TcpBackendHandler for SqlBackendHandler {
.collect::<Vec<sqlx::Result<u64>>>() .collect::<Vec<sqlx::Result<u64>>>()
.await .await
.into_iter() .into_iter()
.collect::<Result<HashSet<u64>>>() .collect::<sqlx::Result<HashSet<u64>>>()
.map_err(|e| anyhow::anyhow!(e)) .map_err(|e| anyhow::anyhow!(e))
} }
async fn create_refresh_token(&self, user: &str) -> Result<(String, chrono::Duration)> { 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::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
// TODO: Initialize the rng only once. Maybe Arc<Cell>? // TODO: Initialize the rng only once. Maybe Arc<Cell>?
let mut rng = SmallRng::from_entropy(); let refresh_token = gen_random_string(100);
let refresh_token: String = std::iter::repeat(())
.map(|()| rng.sample(Alphanumeric))
.map(char::from)
.take(100)
.collect();
let refresh_token_hash = { let refresh_token_hash = {
let mut s = DefaultHasher::new(); let mut s = DefaultHasher::new();
refresh_token.hash(&mut s); refresh_token.hash(&mut s);
@@ -71,7 +74,7 @@ impl TcpBackendHandler for SqlBackendHandler {
.await? .await?
.is_some()) .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; use sqlx::Result;
let query = Query::select() let query = Query::select()
.column(JwtStorage::JwtHash) .column(JwtStorage::JwtHash)
@@ -94,7 +97,7 @@ impl TcpBackendHandler for SqlBackendHandler {
sqlx::query(&query).execute(&self.sql_pool).await?; sqlx::query(&query).execute(&self.sql_pool).await?;
Ok(result?) 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() let query = Query::delete()
.from_table(JwtRefreshStorage::Table) .from_table(JwtRefreshStorage::Table)
.and_where(Expr::col(JwtRefreshStorage::RefreshTokenHash).eq(refresh_token_hash)) .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?; sqlx::query(&query).execute(&self.sql_pool).await?;
Ok(()) 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 async_trait::async_trait;
use std::collections::HashSet; use std::collections::HashSet;
pub type DomainResult<T> = crate::domain::error::Result<T>; use crate::domain::error::Result;
#[async_trait] #[async_trait]
pub trait TcpBackendHandler { pub trait TcpBackendHandler {
async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>>; async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>>;
async fn create_refresh_token(&self, user: &str) -> DomainResult<(String, chrono::Duration)>; async fn create_refresh_token(&self, user: &str) -> Result<(String, chrono::Duration)>;
async fn check_token(&self, refresh_token_hash: u64, user: &str) -> DomainResult<bool>; async fn check_token(&self, refresh_token_hash: u64, user: &str) -> Result<bool>;
async fn blacklist_jwts(&self, user: &str) -> DomainResult<HashSet<u64>>; async fn blacklist_jwts(&self, user: &str) -> Result<HashSet<u64>>;
async fn delete_refresh_token(&self, refresh_token_hash: u64) -> DomainResult<()>; 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)] #[cfg(test)]
@@ -22,30 +31,33 @@ mockall::mock! {
} }
#[async_trait] #[async_trait]
impl LoginHandler for TestTcpBackendHandler { impl LoginHandler for TestTcpBackendHandler {
async fn bind(&self, request: BindRequest) -> DomainResult<()>; async fn bind(&self, request: BindRequest) -> Result<()>;
} }
#[async_trait] #[async_trait]
impl BackendHandler for TestTcpBackendHandler { impl BackendHandler for TestTcpBackendHandler {
async fn list_users(&self, filters: Option<RequestFilter>) -> DomainResult<Vec<User>>; async fn list_users(&self, filters: Option<RequestFilter>) -> Result<Vec<User>>;
async fn list_groups(&self) -> DomainResult<Vec<Group>>; async fn list_groups(&self) -> Result<Vec<Group>>;
async fn get_user_details(&self, user_id: &str) -> DomainResult<User>; async fn get_user_details(&self, user_id: &str) -> Result<User>;
async fn get_group_details(&self, group_id: GroupId) -> DomainResult<GroupIdAndName>; async fn get_group_details(&self, group_id: GroupId) -> Result<GroupIdAndName>;
async fn get_user_groups(&self, user: &str) -> DomainResult<HashSet<GroupIdAndName>>; async fn get_user_groups(&self, user: &str) -> Result<HashSet<GroupIdAndName>>;
async fn create_user(&self, request: CreateUserRequest) -> DomainResult<()>; async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
async fn update_user(&self, request: UpdateUserRequest) -> DomainResult<()>; async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
async fn update_group(&self, request: UpdateGroupRequest) -> DomainResult<()>; async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
async fn delete_user(&self, user_id: &str) -> DomainResult<()>; async fn delete_user(&self, user_id: &str) -> Result<()>;
async fn create_group(&self, group_name: &str) -> DomainResult<GroupId>; async fn create_group(&self, group_name: &str) -> Result<GroupId>;
async fn delete_group(&self, group_id: GroupId) -> DomainResult<()>; async fn delete_group(&self, group_id: GroupId) -> Result<()>;
async fn add_user_to_group(&self, user_id: &str, group_id: GroupId) -> DomainResult<()>; 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) -> DomainResult<()>; async fn remove_user_from_group(&self, user_id: &str, group_id: GroupId) -> Result<()>;
} }
#[async_trait] #[async_trait]
impl TcpBackendHandler for TestTcpBackendHandler { impl TcpBackendHandler for TestTcpBackendHandler {
async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>>; async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>>;
async fn create_refresh_token(&self, user: &str) -> DomainResult<(String, chrono::Duration)>; async fn create_refresh_token(&self, user: &str) -> Result<(String, chrono::Duration)>;
async fn check_token(&self, refresh_token_hash: u64, user: &str) -> DomainResult<bool>; async fn check_token(&self, refresh_token_hash: u64, user: &str) -> Result<bool>;
async fn blacklist_jwts(&self, user: &str) -> DomainResult<HashSet<u64>>; async fn blacklist_jwts(&self, user: &str) -> Result<HashSet<u64>>;
async fn delete_refresh_token(&self, refresh_token_hash: u64) -> DomainResult<()>; 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}, handler::{BackendHandler, LoginHandler},
opaque_handler::OpaqueHandler, 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_files::{Files, NamedFile};
use actix_http::HttpServiceBuilder; use actix_http::HttpServiceBuilder;
@@ -44,15 +48,19 @@ pub(crate) fn error_to_http_response(error: DomainError) -> HttpResponse {
fn http_config<Backend>( fn http_config<Backend>(
cfg: &mut web::ServiceConfig, cfg: &mut web::ServiceConfig,
backend_handler: Backend, backend_handler: Backend,
jwt_secret: String, jwt_secret: secstr::SecUtf8,
jwt_blacklist: HashSet<u64>, jwt_blacklist: HashSet<u64>,
server_url: String,
mail_options: MailOptions,
) where ) where
Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Sync + 'static, Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Sync + 'static,
{ {
cfg.app_data(web::Data::new(AppState::<Backend> { cfg.app_data(web::Data::new(AppState::<Backend> {
backend_handler, 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), jwt_blacklist: RwLock::new(jwt_blacklist),
server_url,
mail_options,
})) }))
// Serve index.html and main.js, and default to index.html. // Serve index.html and main.js, and default to index.html.
.route( .route(
@@ -76,6 +84,8 @@ pub(crate) struct AppState<Backend> {
pub backend_handler: Backend, pub backend_handler: Backend,
pub jwt_key: Hmac<Sha512>, pub jwt_key: Hmac<Sha512>,
pub jwt_blacklist: RwLock<HashSet<u64>>, pub jwt_blacklist: RwLock<HashSet<u64>>,
pub server_url: String,
pub mail_options: MailOptions,
} }
pub async fn build_tcp_server<Backend>( pub async fn build_tcp_server<Backend>(
@@ -87,16 +97,30 @@ where
Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Sync + 'static, Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Sync + 'static,
{ {
let jwt_secret = config.jwt_secret.clone(); 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 server_builder
.bind("http", ("0.0.0.0", config.http_port), move || { .bind("http", ("0.0.0.0", config.http_port), move || {
let backend_handler = backend_handler.clone(); let backend_handler = backend_handler.clone();
let jwt_secret = jwt_secret.clone(); let jwt_secret = jwt_secret.clone();
let jwt_blacklist = jwt_blacklist.clone(); let jwt_blacklist = jwt_blacklist.clone();
let server_url = server_url.clone();
let mail_options = mail_options.clone();
HttpServiceBuilder::new() HttpServiceBuilder::new()
.finish(map_config( .finish(map_config(
App::new().configure(move |cfg| { 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(), |_| AppConfig::default(),
)) ))

View File

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