Merge branch 'main' into feature/avatar-display

This commit is contained in:
Austin 2023-03-22 15:57:51 +00:00
commit 970af4f01a
92 changed files with 5165 additions and 3391 deletions

View File

@ -13,12 +13,10 @@ RUN groupadd --gid $USER_GID $USERNAME \
&& chmod 0440 /etc/sudoers.d/$USERNAME && chmod 0440 /etc/sudoers.d/$USERNAME
RUN apt update && \ RUN apt update && \
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \ apt install -y --no-install-recommends libssl-dev musl-dev make perl curl gzip && \
apt update && \ rm -rf /var/lib/apt/lists/*
apt install -y --no-install-recommends nodejs libssl-dev musl-dev make perl curl
RUN RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack \ RUN RUSTFLAGS=-Ctarget-feature=-crt-static cargo install wasm-pack \
&& npm install -g rollup \
&& rustup target add wasm32-unknown-unknown && rustup target add wasm32-unknown-unknown
USER $USERNAME USER $USERNAME

View File

@ -1,5 +1,8 @@
{ {
"name": "LLDAP dev", "name": "LLDAP dev",
"build": { "dockerfile": "Dockerfile" }, "build": { "dockerfile": "Dockerfile" },
"forwardPorts": [3890, 17170] "forwardPorts": [
3890,
17170
]
} }

View File

@ -10,28 +10,34 @@ RUN mkdir -p target/
RUN mkdir -p /lldap/app RUN mkdir -p /lldap/app
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \ RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
mv bin/amd64-bin/lldap target/lldap && \ mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
mv bin/amd64-bin/migration-tool target/migration-tool && \ mv bin/x86_64-unknown-linux-musl-migration-tool-bin/migration-tool target/migration-tool && \
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
chmod +x target/lldap && \ chmod +x target/lldap && \
chmod +x target/migration-tool && \ chmod +x target/migration-tool && \
chmod +x target/lldap_set_password && \
ls -la target/ . && \ ls -la target/ . && \
pwd \ pwd \
; fi ; fi
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \ RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
mv bin/aarch64-bin/lldap target/lldap && \ mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
mv bin/aarch64-bin/migration-tool target/migration-tool && \ mv bin/aarch64-unknown-linux-musl-migration-tool-bin/migration-tool target/migration-tool && \
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
chmod +x target/lldap && \ chmod +x target/lldap && \
chmod +x target/migration-tool && \ chmod +x target/migration-tool && \
chmod +x target/lldap_set_password && \
ls -la target/ . && \ ls -la target/ . && \
pwd \ pwd \
; fi ; fi
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \ RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
mv bin/armhf-bin/lldap target/lldap && \ mv bin/armv7-unknown-linux-gnueabihf-lldap-bin/lldap target/lldap && \
mv bin/armhf-bin/migration-tool target/migration-tool && \ mv bin/armv7-unknown-linux-gnueabihf-migration-tool-bin/migration-tool target/migration-tool && \
mv bin/armv7-unknown-linux-gnueabihf-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
chmod +x target/lldap && \ chmod +x target/lldap && \
chmod +x target/migration-tool && \ chmod +x target/migration-tool && \
chmod +x target/lldap_set_password && \
ls -la target/ . && \ ls -la target/ . && \
pwd \ pwd \
; fi ; fi
@ -42,6 +48,7 @@ COPY lldap_config.docker_template.toml /lldap/
COPY web/index_local.html web/index.html COPY web/index_local.html web/index.html
RUN cp target/lldap /lldap/ && \ RUN cp target/lldap /lldap/ && \
cp target/migration-tool /lldap/ && \ cp target/migration-tool /lldap/ && \
cp target/lldap_set_password /lldap/ && \
cp -R web/index.html \ cp -R web/index.html \
web/pkg \ web/pkg \
web/static \ web/static \
@ -98,10 +105,10 @@ RUN apk add --no-cache tini ca-certificates bash tzdata && \
"$USER" && \ "$USER" && \
mkdir -p /data && \ mkdir -p /data && \
chown $USER:$USER /data chown $USER:$USER /data
COPY --from=lldap --chown=$CONTAINERUSER:$CONTAINERUSER /lldap /app COPY --from=lldap --chown=$USER:$USER /lldap /app
COPY --from=lldap --chown=$CONTAINERUSER:$CONTAINERUSER /docker-entrypoint.sh /docker-entrypoint.sh COPY --from=lldap --chown=$USER:$USER /docker-entrypoint.sh /docker-entrypoint.sh
VOLUME ["/data"] VOLUME ["/data"]
WORKDIR /app WORKDIR /app
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"] ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
CMD ["run", "--config-file", "/data/lldap_config.toml"] CMD ["run", "--config-file", "/data/lldap_config.toml"]
HEALTHCHECK CMD ["/app/lldap", "run", "--config-file", "/data/lldap_config.toml"] HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]

View File

@ -10,28 +10,34 @@ RUN mkdir -p target/
RUN mkdir -p /lldap/app RUN mkdir -p /lldap/app
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \ RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
mv bin/amd64-bin/lldap target/lldap && \ mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
mv bin/amd64-bin/migration-tool target/migration-tool && \ mv bin/x86_64-unknown-linux-musl-migration-tool-bin/migration-tool target/migration-tool && \
mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
chmod +x target/lldap && \ chmod +x target/lldap && \
chmod +x target/migration-tool && \ chmod +x target/migration-tool && \
chmod +x target/lldap_set_password && \
ls -la target/ . && \ ls -la target/ . && \
pwd \ pwd \
; fi ; fi
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \ RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
mv bin/aarch64-bin/lldap target/lldap && \ mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap target/lldap && \
mv bin/aarch64-bin/migration-tool target/migration-tool && \ mv bin/aarch64-unknown-linux-musl-migration-tool-bin/migration-tool target/migration-tool && \
mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
chmod +x target/lldap && \ chmod +x target/lldap && \
chmod +x target/migration-tool && \ chmod +x target/migration-tool && \
chmod +x target/lldap_set_password && \
ls -la target/ . && \ ls -la target/ . && \
pwd \ pwd \
; fi ; fi
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \ RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
mv bin/armhf-bin/lldap target/lldap && \ mv bin/armv7-unknown-linux-gnueabihf-lldap-bin/lldap target/lldap && \
mv bin/armhf-bin/migration-tool target/migration-tool && \ mv bin/armv7-unknown-linux-gnueabihf-migration-tool-bin/migration-tool target/migration-tool && \
mv bin/armv7-unknown-linux-gnueabihf-lldap_set_password-bin/lldap_set_password target/lldap_set_password && \
chmod +x target/lldap && \ chmod +x target/lldap && \
chmod +x target/migration-tool && \ chmod +x target/migration-tool && \
chmod +x target/lldap_set_password && \
ls -la target/ . && \ ls -la target/ . && \
pwd \ pwd \
; fi ; fi
@ -42,6 +48,7 @@ COPY lldap_config.docker_template.toml /lldap/
COPY web/index_local.html web/index.html COPY web/index_local.html web/index.html
RUN cp target/lldap /lldap/ && \ RUN cp target/lldap /lldap/ && \
cp target/migration-tool /lldap/ && \ cp target/migration-tool /lldap/ && \
cp target/lldap_set_password /lldap/ && \
cp -R web/index.html \ cp -R web/index.html \
web/pkg \ web/pkg \
web/static \ web/static \
@ -69,4 +76,4 @@ VOLUME ["/data"]
WORKDIR /app WORKDIR /app
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"] ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
CMD ["run", "--config-file", "/data/lldap_config.toml"] CMD ["run", "--config-file", "/data/lldap_config.toml"]
HEALTHCHECK CMD ["/app/lldap", "run", "--config-file", "/data/lldap_config.toml"] HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]

View File

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

View File

@ -19,55 +19,54 @@ on:
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
# In total 5 jobs, all the jobs are containerized
# --- ### CI Docs
# build-ui , create/compile the web # build-ui , create/compile the web
## Use rustlang/rust:nighlty image
### Install nodejs from nodesource repo
### install wasm ### install wasm
### install rollup ### install rollup
### run app/build.sh ### run app/build.sh
### upload artifacts ### upload artifacts
# builds-armhf, build-aarch64, build-amd64 create binary for respective arch # build-bin
## Use rustlang/rust:nightly image ## build-armhf, build-aarch64, build-amd64 , create binary for respective arch
### Add non-native architecture dpkg --add-architecture XXX #######################################################################################
### Install dev tool gcc g++, etc. per respective arch # GitHub actions randomly timeout when downloading musl-gcc, using custom dev image #
# Look into .github/workflows/Dockerfile.dev for development image details #
# Using lldap dev image based on https://hub.docker.com/_/rust and musl-gcc bundled #
#######################################################################################
### Cargo build ### Cargo build
### Upload artifacts ### aarch64 and amd64 is musl based
### armv7 is glibc based, musl had issue with time_t when cross compile https://github.com/rust-lang/libc/issues/1848
## the CARGO_ env
#CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
# This will determine which architecture lib will be used.
# build-ui,builds-armhf, build-aarch64, build-amd64 will upload artifacts will be used next job # build-ui,builds-armhf, build-aarch64, build-amd64 will upload artifacts will be used next job
# lldap-test
### will run lldap with postgres, mariadb and sqlite backend, do selfcheck command.
# Build docker image
### Triplet docker image arch with debian base
### amd64 & aarch64 with alpine base
# build-docker-image job will fetch artifacts and run Dockerfile.ci then push the image. # build-docker-image job will fetch artifacts and run Dockerfile.ci then push the image.
### Look into .github/workflows/Dockerfile.ci.debian or .github/workflowds/Dockerfile.ci.alpine
# On current https://hub.docker.com/_/rust # create release artifacts
# 1-bullseye, 1.61-bullseye, 1.61.0-bullseye, bullseye, 1, 1.61, 1.61.0, latest ### Fetch artifacts
### Clean up web artifact
### Setup folder structure
### Compress
### Upload
# cache # cache based on Cargo.lock per cargo target
## cargo
## target
jobs: jobs:
build-ui: build-ui:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: rust:1.65 image: nitnelave/rust-dev:latest
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -Ctarget-feature=+crt-static
steps: steps:
- name: install runtime - name: Checkout repository
run: apt update && apt install -y gcc-x86-64-linux-gnu g++-x86-64-linux-gnu libc6-dev ca-certificates uses: actions/checkout@v3.4.0
- name: setup node repo LTS
run: curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
- name: install nodejs
run: apt install -y nodejs && npm -g install npm
- name: smoke test
run: rustc --version
- uses: actions/cache@v3 - uses: actions/cache@v3
with: with:
path: | path: |
@ -79,142 +78,42 @@ jobs:
key: lldap-ui-${{ hashFiles('**/Cargo.lock') }} key: lldap-ui-${{ hashFiles('**/Cargo.lock') }}
restore-keys: | restore-keys: |
lldap-ui- lldap-ui-
- name: Checkout repository - name: Install rollup (nodejs)
uses: actions/checkout@v3.2.0
- name: install rollup nodejs
run: npm install -g rollup run: npm install -g rollup
- name: install wasm-pack with cargo - name: Add wasm target (rust)
run: rustup target add wasm32-unknown-unknown
- name: Install wasm-pack with cargo
run: cargo install wasm-pack || true run: cargo install wasm-pack || true
env: env:
RUSTFLAGS: "" RUSTFLAGS: ""
- name: build frontend - name: Build frontend
run: ./app/build.sh run: ./app/build.sh
- name: check path - name: Check build path
run: ls -al app/ run: ls -al app/
- name: upload ui artifacts - name: Upload ui artifacts
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: ui name: ui
path: app/ path: app/
build-armhf:
build-bin:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
target: [armv7-unknown-linux-gnueabihf, aarch64-unknown-linux-musl, x86_64-unknown-linux-musl]
container: container:
image: rust:1.65 image: nitnelave/rust-dev:latest
env: env:
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER: arm-linux-gnueabihf-ld
CARGO_TERM_COLOR: always
RUSTFLAGS: -Ctarget-feature=-crt-static
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
steps:
- name: add armhf architecture
run: dpkg --add-architecture armhf
- name: install runtime
run: apt update && apt install -y gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf libc6-armhf-cross libc6-dev-armhf-cross tar ca-certificates
- name: smoke test
run: rustc --version
- name: add armhf target
run: rustup target add armv7-unknown-linux-gnueabihf
- name: smoke test
run: rustc --version
- name: Checkout repository
uses: actions/checkout@v3.2.0
- uses: actions/cache@v3
with:
path: |
.cargo/bin
.cargo/registry/index
.cargo/registry/cache
.cargo/git/db
target
key: lldap-bin-armhf-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
lldap-bin-armhf-
- name: compile armhf
run: cargo build --target=armv7-unknown-linux-gnueabihf --release -p lldap -p migration-tool
- name: check path
run: ls -al target/release
- name: upload armhf lldap artifacts
uses: actions/upload-artifact@v3
with:
name: armhf-lldap-bin
path: target/armv7-unknown-linux-gnueabihf/release/lldap
- name: upload armhfmigration-tool artifacts
uses: actions/upload-artifact@v3
with:
name: armhf-migration-tool-bin
path: target/armv7-unknown-linux-gnueabihf/release/migration-tool
build-aarch64:
runs-on: ubuntu-latest
container:
##################################################################################
# GitHub actions currently timeout when downloading musl-gcc #
# Using lldap dev image based on rust:1.65-slim-bullseye and musl-gcc bundled #
# Only for Job build aarch64 and amd64 #
###################################################################################
#image: rust:1.65
image: nitnelave/rust-dev:latest
env:
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: aarch64-linux-musl-gcc CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: aarch64-linux-musl-gcc
CARGO_TERM_COLOR: always
RUSTFLAGS: -Ctarget-feature=+crt-static
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
steps:
- name: Checkout repository
uses: actions/checkout@v3.2.0
- name: smoke test
run: rustc --version
- name: Checkout repository
uses: actions/checkout@v3.2.0
- uses: actions/cache@v3
with:
path: |
.cargo/bin
.cargo/registry/index
.cargo/registry/cache
.cargo/git/db
target
key: lldap-bin-aarch64-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
lldap-bin-aarch64-
# - name: fetch musl-gcc
# run: |
# wget -c https://musl.cc/aarch64-linux-musl-cross.tgz
# tar zxf ./x86_64-linux-musl-cross.tgz -C /opt
# echo "/opt/aarch64-linux-musl-cross:/opt/aarch64-linux-musl-cross/bin" >> $GITHUB_PATH
- name: add musl aarch64 target
run: rustup target add aarch64-unknown-linux-musl
- name: build lldap aarch4
run: cargo build --target=aarch64-unknown-linux-musl --release -p lldap -p migration-tool
- name: check path
run: ls -al target/aarch64-unknown-linux-musl/release/
- name: upload aarch64 lldap artifacts
uses: actions/upload-artifact@v3
with:
name: aarch64-lldap-bin
path: target/aarch64-unknown-linux-musl/release/lldap
- name: upload aarch64 migration-tool artifacts
uses: actions/upload-artifact@v3
with:
name: aarch64-migration-tool-bin
path: target/aarch64-unknown-linux-musl/release/migration-tool
build-amd64:
runs-on: ubuntu-latest
container:
# image: rust:1.65
image: nitnelave/rust-dev:latest
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -Ctarget-feature=+crt-static
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: x86_64-linux-musl-gcc CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: x86_64-linux-musl-gcc
CARGO_TERM_COLOR: always
RUSTFLAGS: -Ctarget-feature=+crt-static
CARGO_HOME: ${GITHUB_WORKSPACE}/.cargo
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3.2.0 uses: actions/checkout@v3.4.0
- uses: actions/cache@v3 - uses: actions/cache@v3
with: with:
path: | path: |
@ -223,82 +122,111 @@ jobs:
.cargo/registry/cache .cargo/registry/cache
.cargo/git/db .cargo/git/db
target target
key: lldap-bin-amd64-${{ hashFiles('**/Cargo.lock') }} key: lldap-bin-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: | restore-keys: |
lldap-bin-amd64- lldap-bin-${{ matrix.target }}-
- name: install musl - name: Compile ${{ matrix.target }} lldap and tools
run: apt update && apt install -y musl-tools tar wget run: cargo build --target=${{ matrix.target }} --release -p lldap -p migration-tool -p lldap_set_password
# - name: fetch musl-gcc - name: Check path
# run: | run: ls -al target/release
# wget -c https://musl.cc/x86_64-linux-musl-cross.tgz - name: Upload ${{ matrix.target}} lldap artifacts
# tar zxf ./x86_64-linux-musl-cross.tgz -C /opt
# echo "/opt/x86_64-linux-musl-cross:/opt/x86_64-linux-musl-cross/bin" >> $GITHUB_PATH
- name: add x86_64 target
run: rustup target add x86_64-unknown-linux-musl
- name: build x86_64 lldap
run: cargo build --target=x86_64-unknown-linux-musl --release -p lldap -p migration-tool
- name: check path
run: ls -al target/x86_64-unknown-linux-musl/release/
- name: upload amd64 lldap artifacts
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: amd64-lldap-bin name: ${{ matrix.target}}-lldap-bin
path: target/x86_64-unknown-linux-musl/release/lldap path: target/${{ matrix.target }}/release/lldap
- name: upload amd64 migration-tool artifacts - name: Upload ${{ matrix.target }} migration tool artifacts
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: amd64-migration-tool-bin name: ${{ matrix.target }}-migration-tool-bin
path: target/x86_64-unknown-linux-musl/release/migration-tool path: target/${{ matrix.target }}/release/migration-tool
- name: Upload ${{ matrix.target }} password tool artifacts
uses: actions/upload-artifact@v3
with:
name: ${{ matrix.target }}-lldap_set_password-bin
path: target/${{ matrix.target }}/release/lldap_set_password
lldap-database-integration-test:
needs: [build-ui,build-bin]
name: LLDAP test
runs-on: ubuntu-latest
services:
mariadb:
image: mariadb:latest
ports:
- 3306:3306
env:
MYSQL_USER: lldapuser
MYSQL_PASSWORD: lldappass
MYSQL_DATABASE: lldap
MYSQL_ROOT_PASSWORD: rootpass
postgresql:
image: postgres:latest
ports:
- 5432:5432
env:
POSTGRES_USER: lldapuser
POSTGRES_PASSWORD: lldappass
POSTGRES_DB: lldap
steps:
- name: Download artifacts
uses: actions/download-artifact@v3
with:
name: x86_64-unknown-linux-musl-lldap-bin
path: bin/
- name: Where is the bin?
run: ls -alR bin
- name: Set executables to LLDAP
run: chmod +x bin/lldap
- name: Run lldap with postgres DB and healthcheck
run: |
bin/lldap run &
sleep 10s
bin/lldap healthcheck
env:
LLDAP_database_url: postgres://lldapuser:lldappass@localhost/lldap
LLDAP_ldap_port: 3890
LLDAP_http_port: 17170
- name: Run lldap with mariadb DB (MySQL Compatible) and healthcheck
run: |
bin/lldap run &
sleep 10s
bin/lldap healthcheck
env:
LLDAP_database_url: mysql://lldapuser:lldappass@localhost/lldap
LLDAP_ldap_port: 3891
LLDAP_http_port: 17171
- name: Run lldap with sqlite DB and healthcheck
run: |
bin/lldap run &
sleep 10s
bin/lldap healthcheck
env:
LLDAP_database_url: sqlite://users.db?mode=rwc
LLDAP_ldap_port: 3892
LLDAP_http_port: 17172
build-docker-image: build-docker-image:
needs: [build-ui,build-armhf,build-aarch64,build-amd64] needs: [build-ui, build-bin]
name: Build Docker image name: Build Docker image
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
packages: write packages: write
steps: steps:
- name: install rsync - name: Checkout repository
run: sudo apt update && sudo apt install -y rsync uses: actions/checkout@v3.4.0
- name: fetch repo - name: Download all artifacts
uses: actions/checkout@v3.2.0
- name: Download armhf lldap artifacts
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
with: with:
name: armhf-lldap-bin path: bin
path: bin/armhf-bin
- name: Download armhf migration-tool artifacts
uses: actions/download-artifact@v3
with:
name: armhf-migration-tool-bin
path: bin/armhf-bin
- name: Download aarch64 lldap artifacts
uses: actions/download-artifact@v3
with:
name: aarch64-lldap-bin
path: bin/aarch64-bin
- name: Download aarch64 migration-tool artifacts
uses: actions/download-artifact@v3
with:
name: aarch64-migration-tool-bin
path: bin/aarch64-bin
- name: Download amd64 lldap artifacts
uses: actions/download-artifact@v3
with:
name: amd64-lldap-bin
path: bin/amd64-bin
- name: Download amd64 migration-tool artifacts
uses: actions/download-artifact@v3
with:
name: amd64-migration-tool-bin
path: bin/amd64-bin
- name: check bin path
run: ls -al bin/
- name: Download llap ui artifacts - name: Download llap ui artifacts
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
@ -306,7 +234,7 @@ jobs:
name: ui name: ui
path: web path: web
- name: setup qemu - name: Setup QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v2
- uses: docker/setup-buildx-action@v2 - uses: docker/setup-buildx-action@v2
@ -325,13 +253,6 @@ jobs:
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}} type=semver,pattern={{major}}
type=sha type=sha
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: parse tag - name: parse tag
uses: gacts/github-slug@v1 uses: gacts/github-slug@v1
@ -344,39 +265,39 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
###################### ########################################
#### latest build #### #### docker image :latest tag build ####
###################### ########################################
- name: Build and push latest alpine - name: Build and push latest alpine
if: github.event_name != 'release' if: github.event_name != 'release'
uses: docker/build-push-action@v3 uses: docker/build-push-action@v4
with: with:
context: . context: .
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
file: ./.github/workflows/Dockerfile.ci.alpine file: ./.github/workflows/Dockerfile.ci.alpine
tags: nitnelave/lldap:latest, nitnelave/lldap:latest-alpine tags: nitnelave/lldap:latest, nitnelave/lldap:latest-alpine
cache-from: type=local,src=/tmp/.buildx-cache cache-from: type=gha,mode=max
cache-to: type=local,dest=/tmp/.buildx-cache-new cache-to: type=gha,mode=max
- name: Build and push latest debian - name: Build and push latest debian
if: github.event_name != 'release' if: github.event_name != 'release'
uses: docker/build-push-action@v3 uses: docker/build-push-action@v4
with: with:
context: . context: .
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64,linux/arm/v7
file: ./.github/workflows/Dockerfile.ci.debian file: ./.github/workflows/Dockerfile.ci.debian
tags: nitnelave/lldap:latest-debian tags: nitnelave/lldap:latest-debian
cache-from: type=local,src=/tmp/.buildx-cache cache-from: type=gha,mode=max
cache-to: type=local,dest=/tmp/.buildx-cache-new cache-to: type=gha,mode=max
####################### ########################################
#### release build #### #### docker image :semver tag build ####
####################### ########################################
- name: Build and push release alpine - name: Build and push release alpine
if: github.event_name == 'release' if: github.event_name == 'release'
uses: docker/build-push-action@v3 uses: docker/build-push-action@v4
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
@ -384,12 +305,12 @@ jobs:
# Tag as latest, stable, semver, major, major.minor and major.minor.patch. # Tag as latest, stable, semver, major, major.minor and major.minor.patch.
file: ./.github/workflows/Dockerfile.ci.alpine file: ./.github/workflows/Dockerfile.ci.alpine
tags: nitnelave/lldap:stable, nitnelave/lldap:stable-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-alpine.${{ steps.slug.outputs.version-minor }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}-alpine tags: nitnelave/lldap:stable, nitnelave/lldap:stable-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-alpine.${{ steps.slug.outputs.version-minor }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}-alpine
cache-from: type=local,src=/tmp/.buildx-cache cache-from: type=gha,mode=max
cache-to: type=local,dest=/tmp/.buildx-cache-new cache-to: type=gha,mode=max
- name: Build and push release debian - name: Build and push release debian
if: github.event_name == 'release' if: github.event_name == 'release'
uses: docker/build-push-action@v3 uses: docker/build-push-action@v4
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7 platforms: linux/amd64,linux/arm64,linux/arm/v7
@ -397,11 +318,8 @@ jobs:
# Tag as latest, stable, semver, major, major.minor and major.minor.patch. # Tag as latest, stable, semver, major, major.minor and major.minor.patch.
file: ./.github/workflows/Dockerfile.ci.debian file: ./.github/workflows/Dockerfile.ci.debian
tags: nitnelave/lldap:stable-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}-debian tags: nitnelave/lldap:stable-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}-debian
cache-from: type=local,src=/tmp/.buildx-cache cache-from: type=gha,mode=max
cache-to: type=local,dest=/tmp/.buildx-cache-new cache-to: type=gha,mode=max
- name: Move cache
run: rsync -r /tmp/.buildx-cache-new /tmp/.buildx-cache --delete
- name: Update repo description - name: Update repo description
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
@ -411,75 +329,86 @@ jobs:
password: ${{ secrets.DOCKERHUB_PASSWORD }} password: ${{ secrets.DOCKERHUB_PASSWORD }}
repository: nitnelave/lldap repository: nitnelave/lldap
###############################################################
### Download artifacts, clean up ui, upload to release page ###
###############################################################
create-release-artifacts: create-release-artifacts:
needs: [build-ui,build-armhf,build-aarch64,build-amd64] needs: [build-ui, build-bin]
name: Create release artifacts name: Create release artifacts
if: github.event_name == 'release' if: github.event_name == 'release'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Download all artifacts
- name: Download armhf lldap artifacts
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
with: with:
name: armhf-lldap-bin path: bin/
path: bin/armhf-bin - name: Check file
- name: Download armhf migration-tool artifacts run: ls -alR bin/
uses: actions/download-artifact@v3 - name: Fixing Filename
with: run: |
name: armhf-migration-tool-bin mv bin/aarch64-unknown-linux-musl-lldap-bin/lldap bin/aarch64-lldap
path: bin/armhf-bin mv bin/x86_64-unknown-linux-musl-lldap-bin/lldap bin/amd64-lldap
- name: Fix binary name armhf mv bin/armv7-unknown-linux-gnueabihf-lldap-bin/lldap bin/armhf-lldap
run: mv bin/armhf-bin/lldap bin/armhf-bin/lldap-armhf && mv bin/armhf-bin/migration-tool bin/armhf-bin/migration-tool-armhf mv bin/aarch64-unknown-linux-musl-migration-tool-bin/migration-tool bin/aarch64-migration-tool
mv bin/x86_64-unknown-linux-musl-migration-tool-bin/migration-tool bin/amd64-migration-tool
- name: Download aarch64 lldap artifacts mv bin/armv7-unknown-linux-gnueabihf-migration-tool-bin/migration-tool bin/armhf-migration-tool
uses: actions/download-artifact@v3 mv bin/aarch64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password bin/aarch64-lldap_set_password
with: mv bin/x86_64-unknown-linux-musl-lldap_set_password-bin/lldap_set_password bin/amd64-lldap_set_password
name: aarch64-lldap-bin mv bin/armv7-unknown-linux-gnueabihf-lldap_set_password-bin/lldap_set_password bin/armhf-lldap_set_password
path: bin/aarch64-bin chmod +x bin/*-lldap
- name: Download aarch64 migration-tool artifacts chmod +x bin/*-migration-tool
uses: actions/download-artifact@v3 chmod +x bin/*-lldap_set_password
with:
name: aarch64-migration-tool-bin
path: bin/aarch64-bin
- name: Fix binary name aarch64
run: mv bin/aarch64-bin/lldap bin/aarch64-bin/lldap-aarch64 && mv bin/aarch64-bin/migration-tool bin/aarch64-bin/migration-tool-aarch64
- name: Download amd64 lldap artifacts
uses: actions/download-artifact@v3
with:
name: amd64-lldap-bin
path: bin/amd64-bin
- name: Download amd64 migration-tool artifacts
uses: actions/download-artifact@v3
with:
name: amd64-migration-tool-bin
path: bin/amd64-bin
- name: Fix binary name amd64
run: mv bin/amd64-bin/lldap bin/amd64-bin/lldap-amd64 && mv bin/amd64-bin/migration-tool bin/amd64-bin/migration-tool-amd64
- name: Download llap ui artifacts - name: Download llap ui artifacts
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
with: with:
name: ui name: ui
path: web path: web
- name: Web Cleanup - name: UI (web) artifacts cleanup
run: mkdir app && mv web/index.html app/index.html && mv web/static app/static && mv web/pkg app/pkg run: mkdir app && mv web/index.html app/index.html && mv web/static app/static && mv web/pkg app/pkg
- name: compress web - name: Fetch web components
run: sudo apt update && sudo apt install -y zip && zip -r web.zip app/ run: |
sudo apt update
sudo apt install wget
for file in $(cat app/static/libraries.txt); do wget -P app/static "$file"; done
for file in $(cat app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done
chmod a+r -R .
- name: Setup LLDAP dir for packing
run: |
mkdir aarch64-lldap
mkdir amd64-lldap
mkdir armhf-lldap
mv bin/aarch64-lldap aarch64-lldap/lldap
mv bin/amd64-lldap amd64-lldap/lldap
mv bin/armhf-lldap armhf-lldap/lldap
mv bin/aarch64-migration-tool aarch64-lldap/migration-tool
mv bin/amd64-migration-tool amd64-lldap/migration-tool
mv bin/armhf-migration-tool armhf-lldap/migration-tool
mv bin/aarch64-lldap_set_password aarch64-lldap/lldap_set_password
mv bin/amd64-lldap_set_password amd64-lldap/lldap_set_password
mv bin/armhf-lldap_set_password armhf-lldap/lldap_set_password
cp -r app aarch64-lldap/
cp -r app amd64-lldap/
cp -r app armhf-lldap/
ls -alR aarch64-lldap/
ls -alR amd64-lldap/
ls -alR armhf-lldap/
- name: Packing LLDAP and Web UI
run: |
tar -czvf aarch64-lldap.tar.gz aarch64-lldap/
tar -czvf amd64-lldap.tar.gz amd64-lldap/
tar -czvf armhf-lldap.tar.gz armhf-lldap/
- name: Upload artifacts release - name: Upload compressed release
uses: ncipollo/release-action@v1 uses: ncipollo/release-action@v1
id: create_release id: create_release
with: with:
allowUpdates: true allowUpdates: true
artifacts: "bin/armhf-bin/lldap-armhf, artifacts: aarch64-lldap.tar.gz,
bin/aarch64-bin/lldap-aarch64, amd64-lldap.tar.gz,
bin/amd64-bin/lldap-amd64, armhf-lldap.tar.gz
bin/armhf-bin/migration-tool-armhf,
bin/aarch64-bin/migration-tool-aarch64,
bin/amd64-bin/migration-tool-amd64,
web.zip"
env: env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}

View File

@ -34,7 +34,7 @@ jobs:
steps: steps:
- name: Checkout sources - name: Checkout sources
uses: actions/checkout@v3.2.0 uses: actions/checkout@v3.4.0
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- name: Build - name: Build
run: cargo build --verbose --workspace run: cargo build --verbose --workspace
@ -53,7 +53,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout sources - name: Checkout sources
uses: actions/checkout@v3.2.0 uses: actions/checkout@v3.4.0
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
@ -70,7 +70,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout sources - name: Checkout sources
uses: actions/checkout@v3.2.0 uses: actions/checkout@v3.4.0
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
@ -87,7 +87,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout sources - name: Checkout sources
uses: actions/checkout@v3.2.0 uses: actions/checkout@v3.4.0
- name: Install Rust - name: Install Rust
run: rustup toolchain install nightly --component llvm-tools-preview && rustup component add llvm-tools-preview --toolchain stable-x86_64-unknown-linux-gnu run: rustup toolchain install nightly --component llvm-tools-preview && rustup component add llvm-tools-preview --toolchain stable-x86_64-unknown-linux-gnu

2047
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,16 +3,21 @@ members = [
"server", "server",
"auth", "auth",
"app", "app",
"migration-tool" "migration-tool",
"set-password",
] ]
default-members = ["server"] default-members = ["server"]
# Remove once https://github.com/kanidm/ldap3_proto/pull/8 is merged. [profile.release]
[patch.crates-io.ldap3_proto] lto = true
git = 'https://github.com/nitnelave/ldap3_server/'
rev = '7b50b2b82c383f5f70e02e11072bb916629ed2bc' [profile.release.package.lldap_app]
opt-level = 's'
[patch.crates-io.opaque-ke] [patch.crates-io.opaque-ke]
git = 'https://github.com/nitnelave/opaque-ke/' git = 'https://github.com/nitnelave/opaque-ke/'
branch = 'zeroize_1.5' branch = 'zeroize_1.5'
[patch.crates-io.lber]
git = 'https://github.com/inejge/ldap3/'

View File

@ -1,5 +1,5 @@
# Build image # Build image
FROM rust:alpine3.14 AS chef FROM rust:alpine3.16 AS chef
RUN set -x \ RUN set -x \
# Add user # Add user
@ -11,7 +11,7 @@ RUN set -x \
--uid 10001 \ --uid 10001 \
app \ app \
# Install required packages # Install required packages
&& apk add npm openssl-dev musl-dev make perl curl && apk add openssl-dev musl-dev make perl curl gzip
USER app USER app
WORKDIR /app WORKDIR /app
@ -19,7 +19,6 @@ 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 \
&& npm install rollup \
&& rustup target add wasm32-unknown-unknown && rustup target add wasm32-unknown-unknown
# Prepare the dependency list. # Prepare the dependency list.
@ -32,16 +31,17 @@ FROM chef AS builder
COPY --from=planner /tmp/recipe.json recipe.json COPY --from=planner /tmp/recipe.json recipe.json
RUN cargo chef cook --release -p lldap_app --target wasm32-unknown-unknown \ RUN cargo chef cook --release -p lldap_app --target wasm32-unknown-unknown \
&& cargo chef cook --release -p lldap \ && cargo chef cook --release -p lldap \
&& cargo chef cook --release -p migration-tool && cargo chef cook --release -p migration-tool \
&& cargo chef cook --release -p lldap_set_password
# Copy the source and build the app and server. # Copy the source and build the app and server.
COPY --chown=app:app . . COPY --chown=app:app . .
RUN cargo build --release -p lldap -p migration-tool \ RUN cargo build --release -p lldap -p migration-tool -p lldap_set_password \
# Build the frontend. # Build the frontend.
&& ./app/build.sh && ./app/build.sh
# Final image # Final image
FROM alpine:3.14 FROM alpine:3.16
ENV GOSU_VERSION 1.14 ENV GOSU_VERSION 1.14
# Fetch gosu from git # Fetch gosu from git
@ -78,7 +78,7 @@ WORKDIR /app
COPY --from=builder /app/app/index_local.html app/index.html COPY --from=builder /app/app/index_local.html app/index.html
COPY --from=builder /app/app/static app/static COPY --from=builder /app/app/static app/static
COPY --from=builder /app/app/pkg app/pkg COPY --from=builder /app/app/pkg app/pkg
COPY --from=builder /app/target/release/lldap /app/target/release/migration-tool ./ COPY --from=builder /app/target/release/lldap /app/target/release/migration-tool /app/target/release/lldap_set_password ./
COPY docker-entrypoint.sh lldap_config.docker_template.toml ./ COPY docker-entrypoint.sh lldap_config.docker_template.toml ./
RUN set -x \ RUN set -x \
@ -94,4 +94,4 @@ EXPOSE ${LDAP_PORT} ${HTTP_PORT}
ENTRYPOINT ["/app/docker-entrypoint.sh"] ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["run", "--config-file", "/data/lldap_config.toml"] CMD ["run", "--config-file", "/data/lldap_config.toml"]
HEALTHCHECK CMD ["/app/lldap", "run", "--config-file", "/data/lldap_config.toml"] HEALTHCHECK CMD ["/app/lldap", "healthcheck", "--config-file", "/data/lldap_config.toml"]

199
README.md
View File

@ -28,20 +28,20 @@
</a> </a>
</p> </p>
- [About](#About) - [About](#about)
- [Installation](#Installation) - [Installation](#installation)
- [With Docker](#With-Docker) - [With Docker](#with-docker)
- [From source](#From-source) - [From source](#from-source)
- [Cross-compilation](#Cross-compilation) - [Cross-compilation](#cross-compilation)
- [Client configuration](#Client-configuration) - [Client configuration](#client-configuration)
- [Compatible services](#compatible-services) - [Compatible services](#compatible-services)
- [General configuration guide](#general-configuration-guide) - [General configuration guide](#general-configuration-guide)
- [Sample client configurations](#Sample-client-configurations) - [Sample client configurations](#sample-client-configurations)
- [Comparisons with other services](#Comparisons-with-other-services) - [Comparisons with other services](#comparisons-with-other-services)
- [vs OpenLDAP](#vs-openldap) - [vs OpenLDAP](#vs-openldap)
- [vs FreeIPA](#vs-freeipa) - [vs FreeIPA](#vs-freeipa)
- [I can't log in!](#i-cant-log-in) - [I can't log in!](#i-cant-log-in)
- [Contributions](#Contributions) - [Contributions](#contributions)
## About ## About
@ -62,10 +62,11 @@ edit their own details or reset their password by email.
The goal is _not_ to provide a full LDAP server; if you're interested in that, 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 manage (friendly web UI), - simple to setup (no messing around with `slapd`),
* low resources, - simple to manage (friendly web UI),
* opinionated with basic defaults so you don't have to understand the - low resources,
- opinionated with basic defaults so you don't have to understand the
subtleties of LDAP. subtleties of LDAP.
It mostly targets self-hosting servers, with open-source components like It mostly targets self-hosting servers, with open-source components like
@ -98,14 +99,14 @@ contents are loaded into the respective configuration parameters. Note that
`_FILE` variables take precedence. `_FILE` variables take precedence.
Example for docker compose: Example for docker compose:
* You can use either the `:latest` tag image or `:stable` as used in this example.
* `:latest` tag image contains recently pushed code or feature tests, in which some instability can be expected.
* If `UID` and `GID` no defined LLDAP will use default `UID` and `GID` number `1000`.
* If no `TZ` is set, default `UTC` timezone will be used.
- You can use either the `:latest` tag image or `:stable` as used in this example.
- `:latest` tag image contains recently pushed code or feature tests, in which some instability can be expected.
- If `UID` and `GID` no defined LLDAP will use default `UID` and `GID` number `1000`.
- If no `TZ` is set, default `UTC` timezone will be used.
```yaml ```yaml
version: '3' version: "3"
volumes: volumes:
lldap_data: lldap_data:
@ -135,13 +136,16 @@ services:
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
front-end. front-end.
### With Kubernetes
See https://github.com/Evantage-WS/lldap-kubernetes for a LLDAP deployment for Kubernetes
### From source ### From source
To compile the project, you'll need: To compile the project, you'll need:
* nodejs 16: [nodesource nodejs installation guide](https://github.com/nodesource/distributions) - curl and gzip: `sudo apt install curl gzip`
* curl: `sudo apt install curl` - Rust/Cargo: [rustup.rs](https://rustup.rs/)
* Rust/Cargo: [rustup.rs](https://rustup.rs/)
Then you can compile the server (and the migration tool if you want): Then you can compile the server (and the migration tool if you want):
@ -155,8 +159,7 @@ just run `cargo run -- run` to run the server.
To bring up the server, you'll need to compile the frontend. In addition to To bring up the server, you'll need to compile the frontend. In addition to
cargo, you'll need: cargo, you'll need:
* WASM-pack: `cargo install wasm-pack` - WASM-pack: `cargo install wasm-pack`
* rollup.js: `npm install rollup`
Then you can build the frontend files with `./app/build.sh` (you'll need to run Then you can build the frontend files with `./app/build.sh` (you'll need to run
this after every front-end change to update the WASM package served). this after every front-end change to update the WASM package served).
@ -204,14 +207,15 @@ the config).
### General configuration guide ### General configuration guide
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,
`cn=admin,ou=people,dc=example,dc=com`. - The LDAP user DN is from the configuration. By default,
- The LDAP password is from the configuration (same as to log in to the web `cn=admin,ou=people,dc=example,dc=com`.
UI). - The LDAP password is from the configuration (same as to log in to the web
- The users are all located in `ou=people,` + the base DN, so by default user UI).
`bob` is at `cn=bob,ou=people,dc=example,dc=com`. - The users are all located in `ou=people,` + the base DN, so by default user
- Similarly, the groups are located in `ou=groups`, so the group `family` `bob` is at `cn=bob,ou=people,dc=example,dc=com`.
will be at `cn=family,ou=groups,dc=example,dc=com`. - Similarly, the groups are located in `ou=groups`, so the group `family`
will be at `cn=family,ou=groups,dc=example,dc=com`.
Testing group membership through `memberOf` is supported, so you can have a Testing group membership through `memberOf` is supported, so you can have a
filter like: `(memberOf=cn=admins,ou=groups,dc=example,dc=com)`. filter like: `(memberOf=cn=admins,ou=groups,dc=example,dc=com)`.
@ -226,57 +230,64 @@ administration access to many services.
Some specific clients have been tested to work and come with sample Some specific clients have been tested to work and come with sample
configuration files, or guides. See the [`example_configs`](example_configs) configuration files, or guides. See the [`example_configs`](example_configs)
folder for help with: folder for help with:
- [Airsonic Advanced](example_configs/airsonic-advanced.md)
- [Apache Guacamole](example_configs/apacheguacamole.md) - [Airsonic Advanced](example_configs/airsonic-advanced.md)
- [Authelia](example_configs/authelia_config.yml) - [Apache Guacamole](example_configs/apacheguacamole.md)
- [Bookstack](example_configs/bookstack.env.example) - [Authelia](example_configs/authelia_config.yml)
- [Calibre-Web](example_configs/calibre_web.md) - [Authentik](example_configs/authentik.md)
- [Dell iDRAC](example_configs/dell_idrac.md) - [Bookstack](example_configs/bookstack.env.example)
- [Dokuwiki](example_configs/dokuwiki.md) - [Calibre-Web](example_configs/calibre_web.md)
- [Dolibarr](example_configs/dolibarr.md) - [Dell iDRAC](example_configs/dell_idrac.md)
- [Emby](example_configs/emby.md) - [Dex](example_configs/dex_config.yml)
- [Gitea](example_configs/gitea.md) - [Dokuwiki](example_configs/dokuwiki.md)
- [Grafana](example_configs/grafana_ldap_config.toml) - [Dolibarr](example_configs/dolibarr.md)
- [Hedgedoc](example_configs/hedgedoc.md) - [Emby](example_configs/emby.md)
- [Jellyfin](example_configs/jellyfin.md) - [Gitea](example_configs/gitea.md)
- [Jitsi Meet](example_configs/jitsi_meet.conf) - [Grafana](example_configs/grafana_ldap_config.toml)
- [KeyCloak](example_configs/keycloak.md) - [Hedgedoc](example_configs/hedgedoc.md)
- [Matrix](example_configs/matrix_synapse.yml) - [Jellyfin](example_configs/jellyfin.md)
- [Nextcloud](example_configs/nextcloud.md) - [Jitsi Meet](example_configs/jitsi_meet.conf)
- [Organizr](example_configs/Organizr.md) - [KeyCloak](example_configs/keycloak.md)
- [Portainer](example_configs/portainer.md) - [Matrix](example_configs/matrix_synapse.yml)
- [Seafile](example_configs/seafile.md) - [Nextcloud](example_configs/nextcloud.md)
- [Syncthing](example_configs/syncthing.md) - [Nexus](example_configs/nexus.md)
- [Vaultwarden](example_configs/vaultwarden.md) - [Organizr](example_configs/Organizr.md)
- [WeKan](example_configs/wekan.md) - [Portainer](example_configs/portainer.md)
- [WG Portal](example_configs/wg_portal.env.example) - [Rancher](example_configs/rancher.md)
- [XBackBone](example_configs/xbackbone_config.php) - [Seafile](example_configs/seafile.md)
- [Zendto](example_configs/zendto.md) - [Syncthing](example_configs/syncthing.md)
- [Vaultwarden](example_configs/vaultwarden.md)
- [WeKan](example_configs/wekan.md)
- [WG Portal](example_configs/wg_portal.env.example)
- [WikiJS](example_configs/wikijs.md)
- [XBackBone](example_configs/xbackbone_config.php)
- [Zendto](example_configs/zendto.md)
## Comparisons with other services ## Comparisons with other services
### vs OpenLDAP ### vs OpenLDAP
OpenLDAP is a monster of a service that implements all of LDAP and all of its [OpenLDAP](https://www.openldap.org) is a monster of a service that implements
extensions, plus some of its own. That said, if you need all that flexibility, all of LDAP and all of its extensions, plus some of its own. That said, if you
it might be what you need! Note that installation can be a bit painful need all that flexibility, it might be what you need! Note that installation
(figuring out how to use `slapd`) and people have mixed experiences following can be a bit painful (figuring out how to use `slapd`) and people have mixed
tutorials online. If you don't configure it properly, you might end up storing experiences following tutorials online. If you don't configure it properly, you
passwords in clear, so a breach of your server would reveal all the stored might end up storing passwords in clear, so a breach of your server would
passwords! reveal all the stored passwords!
OpenLDAP doesn't come with a UI: if you want a web interface, you'll have to 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. install one (not that many look nice) and configure it.
LLDAP is much simpler to setup, has a much smaller image (10x smaller, 20x if 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 web UI. you add PhpLdapAdmin), and comes packed with its own purpose-built web UI.
However, it's not as flexible as OpenLDAP.
### vs FreeIPA ### vs FreeIPA
FreeIPA is the one-stop shop for identity management: LDAP, Kerberos, NTP, DNS, [FreeIPA](http://www.freeipa.org) is the one-stop shop for identity management:
Samba, you name it, it has it. In addition to user management, it also does LDAP, Kerberos, NTP, DNS, Samba, you name it, it has it. In addition to user
security policies, single sign-on, certificate management, linux account management, it also does security policies, single sign-on, certificate
management and so on. 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 If you need all of that, go for it! Keep in mind that a more complex system is
more complex to maintain, though. more complex to maintain, though.
@ -285,25 +296,37 @@ LLDAP is much lighter to run (<10 MB RAM including the DB), easier to
configure (no messing around with DNS or security policies) and simpler to configure (no messing around with DNS or security policies) and simpler to
use. It also comes conveniently packed in a docker container. use. It also comes conveniently packed in a docker container.
### vs Kanidm
[Kanidm](https://kanidm.com) is an up-and-coming Rust identity management
platform, covering all your bases: OAuth, Linux accounts, SSH keys, Radius,
WebAuthn. It comes with a (read-only) LDAPS server.
It's fairly easy to install and does much more; but their LDAP server is
read-only, and by having more moving parts it is inherently more complex. If
you don't need to modify the users through LDAP and you're planning on
installing something like [KeyCloak](https://www.keycloak.org) to provide
modern identity protocols, check out Kanidm.
## 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
set isn't working, try the following: set isn't working, try the following:
- (For docker): Make sure that the `/data` folder is persistent, either to a - (For docker): Make sure that the `/data` folder is persistent, either to a
docker volume or mounted from the host filesystem. docker volume or mounted from the host filesystem.
- Check if there is a `lldap_config.toml` file (either in `/data` for docker - Check if there is a `lldap_config.toml` file (either in `/data` for docker
or in the current directory). If there isn't, copy or in the current directory). If there isn't, copy
`lldap_config.docker_template.toml` there, and fill in the various values `lldap_config.docker_template.toml` there, and fill in the various values
(passwords, secrets, ...). (passwords, secrets, ...).
- Check if there is a `users.db` file (either in `/data` for docker or where - Check if there is a `users.db` file (either in `/data` for docker or where
you specified the DB URL, which defaults to the current directory). If you specified the DB URL, which defaults to the current directory). If
there isn't, check that the user running the command (user with ID 10001 there isn't, check that the user running the command (user with ID 10001
for docker) has the rights to write to the `/data` folder. If in doubt, you for docker) has the rights to write to the `/data` folder. If in doubt, you
can `chmod 777 /data` (or whatever the folder) to make it world-writeable. can `chmod 777 /data` (or whatever the folder) to make it world-writeable.
- Make sure you restart the server. - Make sure you restart the server.
- If it's still not working, join the - If it's still not working, join the
[Discord server](https://discord.gg/h5PEdRMNyP) to ask for help. [Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
## Contributions ## Contributions

View File

@ -8,22 +8,25 @@ include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
[dependencies] [dependencies]
anyhow = "1" anyhow = "1"
base64 = "0.13" base64 = "0.13"
gloo-console = "0.2.3"
gloo-file = "0.2.3"
gloo-net = "*"
graphql_client = "0.10" graphql_client = "0.10"
http = "0.2" http = "0.2"
jwt = "0.13" jwt = "0.13"
rand = "0.8" rand = "0.8"
serde = "1" serde = "1"
serde_json = "1" serde_json = "1"
url-escape = "0.1.1"
validator = "=0.14" validator = "=0.14"
validator_derive = "*" validator_derive = "*"
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
yew = "0.18" wasm-bindgen-futures = "*"
yewtil = "*" yew = "0.19.3"
yew-router = "0.15" yew-router = "0.16"
# Needed because of https://github.com/tkaitchuck/aHash/issues/95 # Needed because of https://github.com/tkaitchuck/aHash/issues/95
indexmap = "=1.6.2" indexmap = "=1.6.2"
url-escape = "0.1.1"
[dependencies.web-sys] [dependencies.web-sys]
version = "0.3" version = "0.3"
@ -56,11 +59,11 @@ version = "0.24"
[dependencies.yew_form] [dependencies.yew_form]
git = "https://github.com/jfbilodeau/yew_form" git = "https://github.com/jfbilodeau/yew_form"
rev = "67050812695b7a8a90b81b0637e347fc6629daed" rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
[dependencies.yew_form_derive] [dependencies.yew_form_derive]
git = "https://github.com/jfbilodeau/yew_form" git = "https://github.com/jfbilodeau/yew_form"
rev = "67050812695b7a8a90b81b0637e347fc6629daed" rev = "4b9fabffb63393ec7626a4477fd36de12a07fac9"
[lib] [lib]
crate-type = ["cdylib"] crate-type = ["cdylib"]

View File

@ -6,22 +6,12 @@ then
>&2 echo '`wasm-pack` not found. Try running `cargo install wasm-pack`' >&2 echo '`wasm-pack` not found. Try running `cargo install wasm-pack`'
exit 1 exit 1
fi fi
if ! which gzip > /dev/null 2>&1
wasm-pack build --target web
ROLLUP_BIN=$(which rollup 2>/dev/null)
if [ -f ../node_modules/rollup/dist/bin/rollup ]
then then
ROLLUP_BIN=../node_modules/rollup/dist/bin/rollup >&2 echo '`gzip` not found.'
elif [ -f node_modules/rollup/dist/bin/rollup ]
then
ROLLUP_BIN=node_modules/rollup/dist/bin/rollup
fi
if [ -z "$ROLLUP_BIN" ]
then
>&2 echo '`rollup` not found. Try running `npm install rollup`'
exit 1 exit 1
fi fi
$ROLLUP_BIN ./main.js --format iife --file ./pkg/bundle.js --globals bootstrap:bootstrap wasm-pack build --target web --release
gzip -9 -f pkg/lldap_app_bg.wasm

View File

@ -4,17 +4,21 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>LLDAP Administration</title> <title>LLDAP Administration</title>
<script src="/pkg/bundle.js" defer></script> <script src="/static/main.js" type="module" defer></script>
<link <link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" href="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/css/bootstrap-nightshade.min.css"
rel="preload stylesheet" rel="preload stylesheet"
integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x" integrity="sha384-CvItGYrXmque42UjYhp+bjRR8tgQz78Nlwk42gYsNzBc6y0DuXNtdUaRzr1cl2uK"
crossorigin="anonymous" crossorigin="anonymous"
as="style" /> as="style" />
<script <script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js" src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js"
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ" integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/js/darkmode.min.js"
integrity="sha384-A4SLs39X/aUfwRclRaXvNeXNBTLZdnZdHhhteqbYFS2jZTRD79tKeFeBn7SGXNpi"
crossorigin="anonymous"></script>
<link <link
rel="stylesheet" rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css"
@ -30,6 +34,11 @@
<link <link
rel="stylesheet" rel="stylesheet"
href="/static/style.css" /> href="/static/style.css" />
<script>
function inDarkMode(){
return darkmode.inDarkMode;
}
</script>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
</head> </head>

View File

@ -4,15 +4,18 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>LLDAP Administration</title> <title>LLDAP Administration</title>
<script src="/pkg/bundle.js" defer></script> <script src="/static/main.js" type="module" defer></script>
<link <link
href="/static/bootstrap.min.css" href="/static/bootstrap-nightshade.min.css"
rel="preload stylesheet" rel="preload stylesheet"
integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x" integrity="sha384-CvItGYrXmque42UjYhp+bjRR8tgQz78Nlwk42gYsNzBc6y0DuXNtdUaRzr1cl2uK"
as="style" /> as="style" />
<script <script
src="/static/bootstrap.bundle.min.js" src="/static/bootstrap.bundle.min.js"
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"></script> integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"></script>
<script
src="/static/darkmode.min.js"
integrity="sha384-A4SLs39X/aUfwRclRaXvNeXNBTLZdnZdHhhteqbYFS2jZTRD79tKeFeBn7SGXNpi"></script>
<link <link
rel="stylesheet" rel="stylesheet"
href="/static/bootstrap-icons.css" href="/static/bootstrap-icons.css"
@ -28,6 +31,11 @@
<link <link
rel="stylesheet" rel="stylesheet"
href="/static/style.css" /> href="/static/style.css" />
<script>
function inDarkMode(){
return darkmode.inDarkMode;
}
</script>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
</head> </head>

View File

@ -52,23 +52,25 @@ pub struct Props {
} }
impl CommonComponent<AddGroupMemberComponent> for AddGroupMemberComponent { impl CommonComponent<AddGroupMemberComponent> for AddGroupMemberComponent {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(
&mut self,
ctx: &Context<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.common.cancel_task();
} }
Msg::SubmitAddMember => return self.submit_add_member(), Msg::SubmitAddMember => return self.submit_add_member(ctx),
Msg::AddMemberResponse(response) => { Msg::AddMemberResponse(response) => {
response?; response?;
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.common.on_user_added_to_group.emit(user); ctx.props().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();
@ -88,23 +90,25 @@ impl CommonComponent<AddGroupMemberComponent> for AddGroupMemberComponent {
} }
impl AddGroupMemberComponent { impl AddGroupMemberComponent {
fn get_user_list(&mut self) { fn get_user_list(&mut self, ctx: &Context<Self>) {
self.common.call_graphql::<ListUserNames, _>( self.common.call_graphql::<ListUserNames, _>(
ctx,
list_user_names::Variables { filters: None }, list_user_names::Variables { filters: None },
Msg::UserListResponse, Msg::UserListResponse,
"Error trying to fetch user list", "Error trying to fetch user list",
); );
} }
fn submit_add_member(&mut self) -> Result<bool> { fn submit_add_member(&mut self, ctx: &Context<Self>) -> Result<bool> {
let user_id = match self.selected_user.clone() { let user_id = match self.selected_user.clone() {
None => return Ok(false), None => return Ok(false),
Some(user) => user.id, Some(user) => user.id,
}; };
self.common.call_graphql::<AddUserToGroup, _>( self.common.call_graphql::<AddUserToGroup, _>(
ctx,
add_user_to_group::Variables { add_user_to_group::Variables {
user: user_id, user: user_id,
group: self.common.group_id, group: ctx.props().group_id,
}, },
Msg::AddMemberResponse, Msg::AddMemberResponse,
"Error trying to initiate adding the user to a group", "Error trying to initiate adding the user to a group",
@ -112,8 +116,8 @@ impl AddGroupMemberComponent {
Ok(true) Ok(true)
} }
fn get_selectable_user_list(&self, user_list: &[User]) -> Vec<User> { fn get_selectable_user_list(&self, ctx: &Context<Self>, user_list: &[User]) -> Vec<User> {
let user_groups = self.common.users.iter().collect::<HashSet<_>>(); let user_groups = ctx.props().users.iter().collect::<HashSet<_>>();
user_list user_list
.iter() .iter()
.filter(|u| !user_groups.contains(u)) .filter(|u| !user_groups.contains(u))
@ -126,41 +130,39 @@ 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(ctx: &Context<Self>) -> Self {
let mut res = Self { let mut res = Self {
common: CommonComponentParts::<Self>::create(props, link), common: CommonComponentParts::<Self>::create(),
user_list: None, user_list: None,
selected_user: None, selected_user: None,
}; };
res.get_user_list(); res.get_user_list(ctx);
res res
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update_and_report_error( CommonComponentParts::<Self>::update_and_report_error(
self, self,
ctx,
msg, msg,
self.common.on_error.clone(), ctx.props().on_error.clone(),
) )
} }
fn change(&mut self, props: Self::Properties) -> ShouldRender { fn view(&self, ctx: &Context<Self>) -> Html {
self.common.change(props) let link = ctx.link();
}
fn view(&self) -> Html {
if let Some(user_list) = &self.user_list { if let Some(user_list) = &self.user_list {
let to_add_user_list = self.get_selectable_user_list(user_list); let to_add_user_list = self.get_selectable_user_list(ctx, user_list);
#[allow(unused_braces)] #[allow(unused_braces)]
let make_select_option = |user: User| { let make_select_option = |user: User| {
html_nested! { html_nested! {
<SelectOption value=user.id.clone() text=user.display_name.clone() key=user.id /> <SelectOption value={user.id.clone()} text={user.display_name.clone()} key={user.id} />
} }
}; };
html! { html! {
<div class="row"> <div class="row">
<div class="col-sm-3"> <div class="col-sm-3">
<Select on_selection_change=self.common.callback(Msg::SelectionChanged)> <Select on_selection_change={link.callback(Msg::SelectionChanged)}>
{ {
to_add_user_list to_add_user_list
.into_iter() .into_iter()
@ -172,8 +174,8 @@ impl Component for AddGroupMemberComponent {
<div class="col-3"> <div class="col-3">
<button <button
class="btn btn-secondary" class="btn btn-secondary"
disabled=self.selected_user.is_none() || self.common.is_task_running() disabled={self.selected_user.is_none() || self.common.is_task_running()}
onclick=self.common.callback(|_| Msg::SubmitAddMember)> onclick={link.callback(|_| Msg::SubmitAddMember)}>
<i class="bi-person-plus me-2"></i> <i class="bi-person-plus me-2"></i>
{"Add to group"} {"Add to group"}
</button> </button>

View File

@ -64,16 +64,18 @@ pub struct Props {
} }
impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent { impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(
&mut self,
ctx: &Context<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.common.cancel_task();
} }
Msg::SubmitAddGroup => return self.submit_add_group(), Msg::SubmitAddGroup => return self.submit_add_group(ctx),
Msg::AddGroupResponse(response) => { Msg::AddGroupResponse(response) => {
response?; response?;
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
@ -82,7 +84,7 @@ impl CommonComponent<AddUserToGroupComponent> for 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.common.on_user_added_to_group.emit(group); ctx.props().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();
@ -102,22 +104,24 @@ impl CommonComponent<AddUserToGroupComponent> for AddUserToGroupComponent {
} }
impl AddUserToGroupComponent { impl AddUserToGroupComponent {
fn get_group_list(&mut self) { fn get_group_list(&mut self, ctx: &Context<Self>) {
self.common.call_graphql::<GetGroupList, _>( self.common.call_graphql::<GetGroupList, _>(
ctx,
get_group_list::Variables, get_group_list::Variables,
Msg::GroupListResponse, Msg::GroupListResponse,
"Error trying to fetch group list", "Error trying to fetch group list",
); );
} }
fn submit_add_group(&mut self) -> Result<bool> { fn submit_add_group(&mut self, ctx: &Context<Self>) -> Result<bool> {
let group_id = match &self.selected_group { let group_id = match &self.selected_group {
None => return Ok(false), None => return Ok(false),
Some(group) => group.id, Some(group) => group.id,
}; };
self.common.call_graphql::<AddUserToGroup, _>( self.common.call_graphql::<AddUserToGroup, _>(
ctx,
add_user_to_group::Variables { add_user_to_group::Variables {
user: self.common.username.clone(), user: ctx.props().username.clone(),
group: group_id, group: group_id,
}, },
Msg::AddGroupResponse, Msg::AddGroupResponse,
@ -126,8 +130,8 @@ impl AddUserToGroupComponent {
Ok(true) Ok(true)
} }
fn get_selectable_group_list(&self, group_list: &[Group]) -> Vec<Group> { fn get_selectable_group_list(&self, props: &Props, group_list: &[Group]) -> Vec<Group> {
let user_groups = self.common.groups.iter().collect::<HashSet<_>>(); let user_groups = props.groups.iter().collect::<HashSet<_>>();
group_list group_list
.iter() .iter()
.filter(|g| !user_groups.contains(g)) .filter(|g| !user_groups.contains(g))
@ -139,41 +143,39 @@ impl AddUserToGroupComponent {
impl Component for AddUserToGroupComponent { impl Component for AddUserToGroupComponent {
type Message = Msg; type Message = Msg;
type Properties = Props; type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(ctx: &Context<Self>) -> Self {
let mut res = Self { let mut res = Self {
common: CommonComponentParts::<Self>::create(props, link), common: CommonComponentParts::<Self>::create(),
group_list: None, group_list: None,
selected_group: None, selected_group: None,
}; };
res.get_group_list(); res.get_group_list(ctx);
res res
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update_and_report_error( CommonComponentParts::<Self>::update_and_report_error(
self, self,
ctx,
msg, msg,
self.common.on_error.clone(), ctx.props().on_error.clone(),
) )
} }
fn change(&mut self, props: Self::Properties) -> ShouldRender { fn view(&self, ctx: &Context<Self>) -> Html {
self.common.change(props) let link = ctx.link();
}
fn view(&self) -> Html {
if let Some(group_list) = &self.group_list { if let Some(group_list) = &self.group_list {
let to_add_group_list = self.get_selectable_group_list(group_list); let to_add_group_list = self.get_selectable_group_list(ctx.props(), group_list);
#[allow(unused_braces)] #[allow(unused_braces)]
let make_select_option = |group: Group| { let make_select_option = |group: Group| {
html_nested! { html_nested! {
<SelectOption value=group.id.to_string() text=group.display_name key=group.id /> <SelectOption value={group.id.to_string()} text={group.display_name} key={group.id} />
} }
}; };
html! { html! {
<div class="row"> <div class="row">
<div class="col-sm-3"> <div class="col-sm-3">
<Select on_selection_change=self.common.callback(Msg::SelectionChanged)> <Select on_selection_change={link.callback(Msg::SelectionChanged)}>
{ {
to_add_group_list to_add_group_list
.into_iter() .into_iter()
@ -185,8 +187,8 @@ impl Component for AddUserToGroupComponent {
<div class="col-sm-3"> <div class="col-sm-3">
<button <button
class="btn btn-secondary" class="btn btn-secondary"
disabled=self.selected_group.is_none() || self.common.is_task_running() disabled={self.selected_group.is_none() || self.common.is_task_running()}
onclick=self.common.callback(|_| Msg::SubmitAddGroup)> onclick={link.callback(|_| Msg::SubmitAddGroup)}>
<i class="bi-person-plus me-2"></i> <i class="bi-person-plus me-2"></i>
{"Add to group"} {"Add to group"}
</button> </button>

View File

@ -10,104 +10,143 @@ use crate::{
logout::LogoutButton, logout::LogoutButton,
reset_password_step1::ResetPasswordStep1Form, reset_password_step1::ResetPasswordStep1Form,
reset_password_step2::ResetPasswordStep2Form, reset_password_step2::ResetPasswordStep2Form,
router::{AppRoute, Link, NavButton}, router::{AppRoute, Link, Redirect},
user_details::UserDetails, user_details::UserDetails,
user_table::UserTable, user_table::UserTable,
}, },
infra::cookies::get_cookie, infra::{api::HostService, cookies::get_cookie},
};
use yew::prelude::*;
use yew::services::ConsoleService;
use yew_router::{
agent::{RouteAgentDispatcher, RouteRequest},
route::Route,
router::Router,
service::RouteService,
}; };
use gloo_console::error;
use wasm_bindgen::prelude::*;
use yew::{
function_component,
html::Scope,
prelude::{html, Component, Html},
Context,
};
use yew_router::{
prelude::{History, Location},
scope_ext::RouterScopeExt,
BrowserRouter, Switch,
};
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = darkmode)]
fn toggleDarkMode(doSave: bool);
#[wasm_bindgen]
fn inDarkMode() -> bool;
}
#[function_component(DarkModeToggle)]
pub fn dark_mode_toggle() -> Html {
html! {
<div class="form-check form-switch">
<input class="form-check-input" onclick={|_| toggleDarkMode(true)} type="checkbox" id="darkModeToggle" checked={inDarkMode()}/>
<label class="form-check-label" for="darkModeToggle">{"Dark mode"}</label>
</div>
}
}
#[function_component(AppContainer)]
pub fn app_container() -> Html {
html! {
<BrowserRouter>
<App />
</BrowserRouter>
}
}
pub struct App { pub struct App {
link: ComponentLink<Self>,
user_info: Option<(String, bool)>, user_info: Option<(String, bool)>,
redirect_to: Option<AppRoute>, redirect_to: Option<AppRoute>,
route_dispatcher: RouteAgentDispatcher, password_reset_enabled: Option<bool>,
} }
pub enum Msg { pub enum Msg {
Login((String, bool)), Login((String, bool)),
Logout, Logout,
PasswordResetProbeFinished(anyhow::Result<bool>),
} }
impl Component for App { impl Component for App {
type Message = Msg; type Message = Msg;
type Properties = (); type Properties = ();
fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(ctx: &Context<Self>) -> Self {
let mut app = Self { let app = Self {
link,
user_info: get_cookie("user_id") user_info: get_cookie("user_id")
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
ConsoleService::error(&e.to_string()); error!(&e.to_string());
None None
}) })
.and_then(|u| { .and_then(|u| {
get_cookie("is_admin") get_cookie("is_admin")
.map(|so| so.map(|s| (u, s == "true"))) .map(|so| so.map(|s| (u, s == "true")))
.unwrap_or_else(|e| { .unwrap_or_else(|e| {
ConsoleService::error(&e.to_string()); error!(&e.to_string());
None None
}) })
}), }),
redirect_to: Self::get_redirect_route(), redirect_to: Self::get_redirect_route(ctx),
route_dispatcher: RouteAgentDispatcher::new(), password_reset_enabled: None,
}; };
app.apply_initial_redirections(); ctx.link().send_future(async move {
Msg::PasswordResetProbeFinished(HostService::probe_password_reset().await)
});
app.apply_initial_redirections(ctx);
app app
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
let history = ctx.link().history().unwrap();
match msg { match msg {
Msg::Login((user_name, is_admin)) => { Msg::Login((user_name, is_admin)) => {
self.user_info = Some((user_name.clone(), is_admin)); self.user_info = Some((user_name.clone(), is_admin));
self.route_dispatcher history.push(self.redirect_to.take().unwrap_or_else(|| {
.send(RouteRequest::ChangeRoute(Route::from( if is_admin {
self.redirect_to.take().unwrap_or_else(|| { AppRoute::ListUsers
if is_admin { } else {
AppRoute::ListUsers AppRoute::UserDetails {
} else { user_id: user_name.clone(),
AppRoute::UserDetails(user_name.clone()) }
} }
}), }));
)));
} }
Msg::Logout => { Msg::Logout => {
self.user_info = None; self.user_info = None;
self.redirect_to = None; self.redirect_to = None;
history.push(AppRoute::Login);
}
Msg::PasswordResetProbeFinished(Ok(enabled)) => {
self.password_reset_enabled = Some(enabled);
}
Msg::PasswordResetProbeFinished(Err(err)) => {
self.password_reset_enabled = Some(false);
error!(&format!(
"Could not probe for password reset support: {err:#}"
));
} }
}
if self.user_info.is_none() {
self.route_dispatcher
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login)));
} }
true true
} }
fn change(&mut self, _: Self::Properties) -> ShouldRender { fn view(&self, ctx: &Context<Self>) -> Html {
false let link = ctx.link().clone();
}
fn view(&self) -> Html {
let link = self.link.clone();
let is_admin = self.is_admin(); let is_admin = self.is_admin();
let password_reset_enabled = self.password_reset_enabled;
html! { html! {
<div> <div>
{self.view_banner()} {self.view_banner(ctx)}
<div class="container py-3 bg-kug"> <div class="container py-3 bg-kug">
<div class="row justify-content-center" style="padding-bottom: 80px;"> <div class="row justify-content-center" style="padding-bottom: 80px;">
<div class="py-3" style="max-width: 1000px"> <main class="py-3" style="max-width: 1000px">
<Router<AppRoute> <Switch<AppRoute>
render = Router::render(move |s| Self::dispatch_route(s, &link, is_admin)) render={Switch::render(move |routes| Self::dispatch_route(routes, &link, is_admin, password_reset_enabled))}
/> />
</div> </main>
</div> </div>
{self.view_footer()} {self.view_footer()}
</div> </div>
@ -117,56 +156,56 @@ impl Component for App {
} }
impl App { impl App {
fn get_redirect_route() -> Option<AppRoute> { // Get the page to land on after logging in, defaulting to the index.
let route_service = RouteService::<()>::new(); fn get_redirect_route(ctx: &Context<Self>) -> Option<AppRoute> {
let current_route = route_service.get_path(); let route = ctx.link().history().unwrap().location().route::<AppRoute>();
if current_route.is_empty() route.filter(|route| {
|| current_route == "/" !matches!(
|| current_route.contains("login") route,
|| current_route.contains("reset-password") AppRoute::Index
{ | AppRoute::Login
None | AppRoute::StartResetPassword
} else { | AppRoute::FinishResetPassword { token: _ }
use yew_router::Switch; )
AppRoute::from_route_part::<()>(current_route, None).0 })
}
} }
fn apply_initial_redirections(&mut self) { fn apply_initial_redirections(&self, ctx: &Context<Self>) {
let route_service = RouteService::<()>::new(); let history = ctx.link().history().unwrap();
let current_route = route_service.get_path(); let route = history.location().route::<AppRoute>();
if current_route.contains("reset-password") { let redirection = match (route, &self.user_info, &self.redirect_to) {
return; (
} Some(AppRoute::StartResetPassword | AppRoute::FinishResetPassword { token: _ }),
match &self.user_info { _,
None => { _,
self.route_dispatcher ) if self.password_reset_enabled == Some(false) => Some(AppRoute::Login),
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login))); (None, _, _) | (_, None, _) => Some(AppRoute::Login),
// User is logged in, a URL was given, don't redirect.
(_, Some(_), Some(_)) => None,
(_, Some((user_name, is_admin)), None) => {
if *is_admin {
Some(AppRoute::ListUsers)
} else {
Some(AppRoute::UserDetails {
user_id: user_name.clone(),
})
}
} }
Some((user_name, is_admin)) => match &self.redirect_to { };
Some(url) => { if let Some(redirect_to) = redirection {
self.route_dispatcher history.push(redirect_to);
.send(RouteRequest::ReplaceRoute(Route::from(url.clone())));
}
None => {
if *is_admin {
self.route_dispatcher
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::ListUsers)));
} else {
self.route_dispatcher
.send(RouteRequest::ReplaceRoute(Route::from(
AppRoute::UserDetails(user_name.clone()),
)));
}
}
},
} }
} }
fn dispatch_route(switch: AppRoute, link: &ComponentLink<Self>, is_admin: bool) -> Html { fn dispatch_route(
switch: &AppRoute,
link: &Scope<Self>,
is_admin: bool,
password_reset_enabled: Option<bool>,
) -> Html {
match switch { match switch {
AppRoute::Login => html! { AppRoute::Login => html! {
<LoginForm on_logged_in=link.callback(Msg::Login)/> <LoginForm on_logged_in={link.callback(Msg::Login)} password_reset_enabled={password_reset_enabled.unwrap_or(false)}/>
}, },
AppRoute::CreateUser => html! { AppRoute::CreateUser => html! {
<CreateUserForm/> <CreateUserForm/>
@ -174,10 +213,10 @@ impl App {
AppRoute::Index | AppRoute::ListUsers => html! { AppRoute::Index | AppRoute::ListUsers => html! {
<div> <div>
<UserTable /> <UserTable />
<NavButton classes="btn btn-primary" route=AppRoute::CreateUser> <Link classes="btn btn-primary" to={AppRoute::CreateUser}>
<i class="bi-person-plus me-2"></i> <i class="bi-person-plus me-2"></i>
{"Create a user"} {"Create a user"}
</NavButton> </Link>
</div> </div>
}, },
AppRoute::CreateGroup => html! { AppRoute::CreateGroup => html! {
@ -186,36 +225,45 @@ impl App {
AppRoute::ListGroups => html! { AppRoute::ListGroups => html! {
<div> <div>
<GroupTable /> <GroupTable />
<NavButton classes="btn btn-primary" route=AppRoute::CreateGroup> <Link classes="btn btn-primary" to={AppRoute::CreateGroup}>
<i class="bi-plus-circle me-2"></i> <i class="bi-plus-circle me-2"></i>
{"Create a group"} {"Create a group"}
</NavButton> </Link>
</div> </div>
}, },
AppRoute::GroupDetails(group_id) => html! { AppRoute::GroupDetails { group_id } => html! {
<GroupDetails group_id=group_id /> <GroupDetails group_id={*group_id} />
}, },
AppRoute::UserDetails(username) => html! { AppRoute::UserDetails { user_id } => html! {
<UserDetails username=username is_admin=is_admin /> <UserDetails username={user_id.clone()} is_admin={is_admin} />
}, },
AppRoute::ChangePassword(username) => html! { AppRoute::ChangePassword { user_id } => html! {
<ChangePasswordForm username=username is_admin=is_admin /> <ChangePasswordForm username={user_id.clone()} is_admin={is_admin} />
}, },
AppRoute::StartResetPassword => html! { AppRoute::StartResetPassword => match password_reset_enabled {
<ResetPasswordStep1Form /> Some(true) => html! { <ResetPasswordStep1Form /> },
Some(false) => {
html! { <Redirect to={AppRoute::Login}/> }
}
None => html! {},
}, },
AppRoute::FinishResetPassword(token) => html! { AppRoute::FinishResetPassword { token } => match password_reset_enabled {
<ResetPasswordStep2Form token=token /> Some(true) => html! { <ResetPasswordStep2Form token={token.clone()} /> },
Some(false) => {
html! { <Redirect to={AppRoute::Login}/> }
}
None => html! {},
}, },
} }
} }
fn view_banner(&self) -> Html { fn view_banner(&self, ctx: &Context<Self>) -> Html {
html! { html! {
<header class="p-2 mb-3 border-bottom"> <header class="p-2 mb-3 border-bottom">
<div class="container"> <div class="container">
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start"> <div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
<a href="/" class="d-flex align-items-center mt-2 mb-lg-0 me-md-5 text-dark text-decoration-none"> <a href="/" class="d-flex align-items-center mt-2 mb-lg-0 me-md-5 text-decoration-none">
<h2>{"LLDAP"}</h2> <h2>{"LLDAP"}</h2>
</a> </a>
@ -224,16 +272,16 @@ impl App {
<> <>
<li> <li>
<Link <Link
classes="nav-link px-2 link-dark h6" classes="nav-link px-2 h6"
route=AppRoute::ListUsers> to={AppRoute::ListUsers}>
<i class="bi-people me-2"></i> <i class="bi-people me-2"></i>
{"Users"} {"Users"}
</Link> </Link>
</li> </li>
<li> <li>
<Link <Link
classes="nav-link px-2 link-dark h6" classes="nav-link px-2 h6"
route=AppRoute::ListGroups> to={AppRoute::ListGroups}>
<i class="bi-collection me-2"></i> <i class="bi-collection me-2"></i>
{"Groups"} {"Groups"}
</Link> </Link>
@ -242,7 +290,7 @@ impl App {
} } else { html!{} } } } } else { html!{} } }
</ul> </ul>
<div class="dropdown text-end"> /*<div class="dropdown text-end">
<a href="#" <a href="#"
class="d-block link-dark text-decoration-none dropdown-toggle" class="d-block link-dark text-decoration-none dropdown-toggle"
id="dropdownUser" id="dropdownUser"
@ -283,16 +331,64 @@ impl App {
</li> </li>
</ul> </ul>
} } else { html!{} } } } } else { html!{} } }
</div> </div>*/
{ self.view_user_menu(ctx) }
<DarkModeToggle />
</div> </div>
</div> </div>
</header> </header>
} }
} }
fn view_user_menu(&self, ctx: &Context<Self>) -> Html {
if let Some((user_id, _)) = &self.user_info {
let link = ctx.link();
html! {
<div class="dropdown text-end">
<a href="#"
class="d-block nav-link text-decoration-none dropdown-toggle"
id="dropdownUser"
data-bs-toggle="dropdown"
aria-expanded="false">
<svg xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
fill="currentColor"
class="bi bi-person-circle"
viewBox="0 0 16 16">
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
</svg>
<span class="ms-2">
{user_id}
</span>
</a>
<ul
class="dropdown-menu text-small dropdown-menu-lg-end"
aria-labelledby="dropdownUser1"
style="">
<li>
<Link
classes="dropdown-item"
to={AppRoute::UserDetails{ user_id: user_id.clone() }}>
{"View details"}
</Link>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<LogoutButton on_logged_out={link.callback(|_| Msg::Logout)} />
</li>
</ul>
</div>
}
} else {
html! {}
}
}
fn view_footer(&self) -> Html { fn view_footer(&self) -> Html {
html! { html! {
<footer class="text-center text-muted fixed-bottom bg-light py-2"> <footer class="text-center fixed-bottom text-muted bg-light py-2">
<div> <div>
<span>{format!("LLDAP version {}", env!("CARGO_PKG_VERSION"))}</span> <span>{format!("LLDAP version {}", env!("CARGO_PKG_VERSION"))}</span>
</div> </div>

View File

@ -1,34 +1,27 @@
use crate::{ use crate::{
components::router::{AppRoute, NavButton}, components::router::{AppRoute, Link},
infra::{ infra::{
api::HostService, api::HostService,
common_component::{CommonComponent, CommonComponentParts}, common_component::{CommonComponent, CommonComponentParts},
}, },
}; };
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Result};
use gloo_console::error;
use lldap_auth::*; use lldap_auth::*;
use validator_derive::Validate; use validator_derive::Validate;
use yew::{prelude::*, services::ConsoleService}; use yew::prelude::*;
use yew_form::Form; use yew_form::Form;
use yew_form_derive::Model; use yew_form_derive::Model;
use yew_router::{ use yew_router::{prelude::History, scope_ext::RouterScopeExt};
agent::{RouteAgentDispatcher, RouteRequest},
route::Route,
};
#[derive(PartialEq, Eq)] #[derive(PartialEq, Eq, Default)]
enum OpaqueData { enum OpaqueData {
#[default]
None, None,
Login(opaque::client::login::ClientLogin), Login(opaque::client::login::ClientLogin),
Registration(opaque::client::registration::ClientRegistration), Registration(opaque::client::registration::ClientRegistration),
} }
impl Default for OpaqueData {
fn default() -> Self {
OpaqueData::None
}
}
impl OpaqueData { impl OpaqueData {
fn take(&mut self) -> Self { fn take(&mut self) -> Self {
std::mem::take(self) std::mem::take(self)
@ -61,7 +54,6 @@ pub struct ChangePasswordForm {
common: CommonComponentParts<Self>, common: CommonComponentParts<Self>,
form: Form<FormModel>, form: Form<FormModel>,
opaque_data: OpaqueData, opaque_data: OpaqueData,
route_dispatcher: RouteAgentDispatcher,
} }
#[derive(Clone, PartialEq, Eq, Properties)] #[derive(Clone, PartialEq, Eq, Properties)]
@ -80,15 +72,20 @@ pub enum Msg {
} }
impl CommonComponent<ChangePasswordForm> for ChangePasswordForm { impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
use anyhow::Context;
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.common.is_admin { if ctx.props().is_admin {
self.handle_msg(Msg::SubmitNewPassword) self.handle_msg(ctx, 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() {
@ -100,14 +97,14 @@ impl CommonComponent<ChangePasswordForm> for 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.common.username.clone(), username: ctx.props().username.clone(),
login_start_request: login_start_request.message, login_start_request: login_start_request.message,
}; };
self.common.call_backend( self.common.call_backend(
HostService::login_start, ctx,
req, HostService::login_start(req),
Msg::AuthenticationStartResponse, Msg::AuthenticationStartResponse,
)?; );
Ok(true) Ok(true)
} }
} }
@ -119,17 +116,14 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
|e| { |e| {
// 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!( error!(&format!("Invalid username or password: {}", e));
"Invalid username or password: {}",
e
));
anyhow!("Invalid username or password") anyhow!("Invalid username or password")
}, },
)?; )?;
} }
_ => panic!("Unexpected data in opaque_data field"), _ => panic!("Unexpected data in opaque_data field"),
}; };
self.handle_msg(Msg::SubmitNewPassword) self.handle_msg(ctx, Msg::SubmitNewPassword)
} }
Msg::SubmitNewPassword => { Msg::SubmitNewPassword => {
let mut rng = rand::rngs::OsRng; let mut rng = rand::rngs::OsRng;
@ -138,15 +132,15 @@ impl CommonComponent<ChangePasswordForm> for 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.common.username.clone(), username: ctx.props().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.common.call_backend( self.common.call_backend(
HostService::register_start, ctx,
req, HostService::register_start(req),
Msg::RegistrationStartResponse, Msg::RegistrationStartResponse,
)?; );
Ok(true) Ok(true)
} }
Msg::RegistrationStartResponse(res) => { Msg::RegistrationStartResponse(res) => {
@ -166,22 +160,20 @@ impl CommonComponent<ChangePasswordForm> for ChangePasswordForm {
registration_upload: registration_finish.message, registration_upload: registration_finish.message,
}; };
self.common.call_backend( self.common.call_backend(
HostService::register_finish, ctx,
req, HostService::register_finish(req),
Msg::RegistrationFinishResponse, Msg::RegistrationFinishResponse,
) );
} }
_ => panic!("Unexpected data in opaque_data field"), _ => panic!("Unexpected data in opaque_data field"),
}?; };
Ok(false) Ok(false)
} }
Msg::RegistrationFinishResponse(response) => { Msg::RegistrationFinishResponse(response) => {
self.common.cancel_task();
if response.is_ok() { if response.is_ok() {
self.route_dispatcher ctx.link().history().unwrap().push(AppRoute::UserDetails {
.send(RouteRequest::ChangeRoute(Route::from( user_id: ctx.props().username.clone(),
AppRoute::UserDetails(self.common.username.clone()), });
)));
} }
response?; response?;
Ok(true) Ok(true)
@ -198,25 +190,21 @@ impl Component for ChangePasswordForm {
type Message = Msg; type Message = Msg;
type Properties = Props; type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(_: &Context<Self>) -> Self {
ChangePasswordForm { ChangePasswordForm {
common: CommonComponentParts::<Self>::create(props, link), common: CommonComponentParts::<Self>::create(),
form: yew_form::Form::<FormModel>::new(FormModel::default()), form: yew_form::Form::<FormModel>::new(FormModel::default()),
opaque_data: OpaqueData::None, opaque_data: OpaqueData::None,
route_dispatcher: RouteAgentDispatcher::new(),
} }
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, msg) CommonComponentParts::<Self>::update(self, ctx, msg)
} }
fn change(&mut self, props: Self::Properties) -> ShouldRender { fn view(&self, ctx: &Context<Self>) -> Html {
self.common.change(props) let is_admin = ctx.props().is_admin;
} let link = ctx.link();
fn view(&self) -> Html {
let is_admin = self.common.is_admin;
type Field = yew_form::Field<FormModel>; type Field = yew_form::Field<FormModel>;
html! { html! {
<> <>
@ -244,13 +232,14 @@ impl Component for ChangePasswordForm {
</label> </label>
<div class="col-sm-10"> <div class="col-sm-10">
<Field <Field
form=&self.form form={&self.form}
field_name="old_password" field_name="old_password"
input_type="password"
class="form-control" class="form-control"
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.common.callback(|_| Msg::FormUpdate) /> oninput={link.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>
@ -266,14 +255,14 @@ impl Component for ChangePasswordForm {
</label> </label>
<div class="col-sm-10"> <div class="col-sm-10">
<Field <Field
form=&self.form form={&self.form}
field_name="password" field_name="password"
input_type="password" input_type="password"
class="form-control" class="form-control"
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.common.callback(|_| Msg::FormUpdate) /> oninput={link.callback(|_| Msg::FormUpdate)} />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("password")} {&self.form.field_message("password")}
</div> </div>
@ -288,14 +277,14 @@ impl Component for ChangePasswordForm {
</label> </label>
<div class="col-sm-10"> <div class="col-sm-10">
<Field <Field
form=&self.form form={&self.form}
field_name="confirm_password" field_name="confirm_password"
input_type="password" input_type="password"
class="form-control" class="form-control"
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.common.callback(|_| Msg::FormUpdate) /> oninput={link.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>
@ -305,17 +294,17 @@ impl Component for ChangePasswordForm {
<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.common.is_task_running() disabled={self.common.is_task_running()}
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})> onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
<i class="bi-save me-2"></i> <i class="bi-save me-2"></i>
{"Save changes"} {"Save changes"}
</button> </button>
<NavButton <Link
classes="btn btn-secondary ms-2 col-auto col-form-label" classes="btn btn-secondary ms-2 col-auto col-form-label"
route=AppRoute::UserDetails(self.common.username.clone())> to={AppRoute::UserDetails{user_id: ctx.props().username.clone()}}>
<i class="bi-arrow-return-left me-2"></i> <i class="bi-arrow-return-left me-2"></i>
{"Back"} {"Back"}
</NavButton> </Link>
</div> </div>
</form> </form>
</> </>

View File

@ -3,15 +3,12 @@ use crate::{
infra::common_component::{CommonComponent, CommonComponentParts}, infra::common_component::{CommonComponent, CommonComponentParts},
}; };
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use gloo_console::log;
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::ConsoleService;
use yew_form_derive::Model; use yew_form_derive::Model;
use yew_router::{ use yew_router::{prelude::History, scope_ext::RouterScopeExt};
agent::{RouteAgentDispatcher, RouteRequest},
route::Route,
};
#[derive(GraphQLQuery)] #[derive(GraphQLQuery)]
#[graphql( #[graphql(
@ -24,7 +21,6 @@ pub struct CreateGroup;
pub struct CreateGroupForm { pub struct CreateGroupForm {
common: CommonComponentParts<Self>, common: CommonComponentParts<Self>,
route_dispatcher: RouteAgentDispatcher,
form: yew_form::Form<CreateGroupModel>, form: yew_form::Form<CreateGroupModel>,
} }
@ -41,7 +37,11 @@ pub enum Msg {
} }
impl CommonComponent<CreateGroupForm> for CreateGroupForm { impl CommonComponent<CreateGroupForm> for CreateGroupForm {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg { match msg {
Msg::Update => Ok(true), Msg::Update => Ok(true),
Msg::SubmitForm => { Msg::SubmitForm => {
@ -53,6 +53,7 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
name: model.groupname, name: model.groupname,
}; };
self.common.call_graphql::<CreateGroup, _>( self.common.call_graphql::<CreateGroup, _>(
ctx,
req, req,
Msg::CreateGroupResponse, Msg::CreateGroupResponse,
"Error trying to create group", "Error trying to create group",
@ -60,12 +61,11 @@ impl CommonComponent<CreateGroupForm> for CreateGroupForm {
Ok(true) Ok(true)
} }
Msg::CreateGroupResponse(response) => { Msg::CreateGroupResponse(response) => {
ConsoleService::log(&format!( log!(&format!(
"Created group '{}'", "Created group '{}'",
&response?.create_group.display_name &response?.create_group.display_name
)); ));
self.route_dispatcher ctx.link().history().unwrap().push(AppRoute::ListGroups);
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::ListGroups)));
Ok(true) Ok(true)
} }
} }
@ -80,23 +80,19 @@ impl Component for CreateGroupForm {
type Message = Msg; type Message = Msg;
type Properties = (); type Properties = ();
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(_: &Context<Self>) -> Self {
Self { Self {
common: CommonComponentParts::<Self>::create(props, link), common: CommonComponentParts::<Self>::create(),
route_dispatcher: RouteAgentDispatcher::new(),
form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()), form: yew_form::Form::<CreateGroupModel>::new(CreateGroupModel::default()),
} }
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, msg) CommonComponentParts::<Self>::update(self, ctx, msg)
} }
fn change(&mut self, props: Self::Properties) -> ShouldRender { fn view(&self, ctx: &Context<Self>) -> Html {
self.common.change(props) let link = ctx.link();
}
fn view(&self) -> Html {
type Field = yew_form::Field<CreateGroupModel>; type Field = yew_form::Field<CreateGroupModel>;
html! { html! {
<div class="row justify-content-center"> <div class="row justify-content-center">
@ -113,13 +109,13 @@ impl Component for CreateGroupForm {
</label> </label>
<div class="col-8"> <div class="col-8">
<Field <Field
form=&self.form form={&self.form}
field_name="groupname" field_name="groupname"
class="form-control" class="form-control"
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.common.callback(|_| Msg::Update) /> oninput={link.callback(|_| Msg::Update)} />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("groupname")} {&self.form.field_message("groupname")}
</div> </div>
@ -129,8 +125,8 @@ 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.common.is_task_running() disabled={self.common.is_task_running()}
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})> onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}>
<i class="bi-save me-2"></i> <i class="bi-save me-2"></i>
{"Submit"} {"Submit"}
</button> </button>

View File

@ -5,17 +5,14 @@ use crate::{
common_component::{CommonComponent, CommonComponentParts}, common_component::{CommonComponent, CommonComponentParts},
}, },
}; };
use anyhow::{bail, Context, Result}; use anyhow::{bail, Result};
use gloo_console::log;
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::ConsoleService;
use yew_form_derive::Model; use yew_form_derive::Model;
use yew_router::{ use yew_router::{prelude::History, scope_ext::RouterScopeExt};
agent::{RouteAgentDispatcher, RouteRequest},
route::Route,
};
#[derive(GraphQLQuery)] #[derive(GraphQLQuery)]
#[graphql( #[graphql(
@ -28,7 +25,6 @@ pub struct CreateUser;
pub struct CreateUserForm { pub struct CreateUserForm {
common: CommonComponentParts<Self>, common: CommonComponentParts<Self>,
route_dispatcher: RouteAgentDispatcher,
form: yew_form::Form<CreateUserModel>, form: yew_form::Form<CreateUserModel>,
} }
@ -73,7 +69,11 @@ pub enum Msg {
} }
impl CommonComponent<CreateUserForm> for CreateUserForm { impl CommonComponent<CreateUserForm> for CreateUserForm {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg { match msg {
Msg::Update => Ok(true), Msg::Update => Ok(true),
Msg::SubmitForm => { Msg::SubmitForm => {
@ -93,6 +93,7 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
}, },
}; };
self.common.call_graphql::<CreateUser, _>( self.common.call_graphql::<CreateUser, _>(
ctx,
req, req,
Msg::CreateUserResponse, Msg::CreateUserResponse,
"Error trying to create user", "Error trying to create user",
@ -102,7 +103,7 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
Msg::CreateUserResponse(r) => { Msg::CreateUserResponse(r) => {
match r { match r {
Err(e) => return Err(e), Err(e) => return Err(e),
Ok(r) => ConsoleService::log(&format!( Ok(r) => log!(&format!(
"Created user '{}' at '{}'", "Created user '{}' at '{}'",
&r.create_user.id, &r.create_user.creation_date &r.create_user.id, &r.create_user.creation_date
)), )),
@ -122,12 +123,11 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
registration_start_request: message, registration_start_request: message,
}; };
self.common self.common
.call_backend(HostService::register_start, req, move |r| { .call_backend(ctx, HostService::register_start(req), move |r| {
Msg::RegistrationStartResponse((state, r)) Msg::RegistrationStartResponse((state, r))
}) });
.context("Error trying to create user")?;
} else { } else {
self.update(Msg::SuccessfulCreation); self.update(ctx, Msg::SuccessfulCreation);
} }
Ok(false) Ok(false)
} }
@ -143,22 +143,19 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
server_data: response.server_data, server_data: response.server_data,
registration_upload: registration_upload.message, registration_upload: registration_upload.message,
}; };
self.common self.common.call_backend(
.call_backend( ctx,
HostService::register_finish, HostService::register_finish(req),
req, Msg::RegistrationFinishResponse,
Msg::RegistrationFinishResponse, );
)
.context("Error trying to register user")?;
Ok(false) Ok(false)
} }
Msg::RegistrationFinishResponse(response) => { Msg::RegistrationFinishResponse(response) => {
response?; response?;
self.handle_msg(Msg::SuccessfulCreation) self.handle_msg(ctx, Msg::SuccessfulCreation)
} }
Msg::SuccessfulCreation => { Msg::SuccessfulCreation => {
self.route_dispatcher ctx.link().history().unwrap().push(AppRoute::ListUsers);
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::ListUsers)));
Ok(true) Ok(true)
} }
} }
@ -173,23 +170,19 @@ impl Component for CreateUserForm {
type Message = Msg; type Message = Msg;
type Properties = (); type Properties = ();
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(_: &Context<Self>) -> Self {
Self { Self {
common: CommonComponentParts::<Self>::create(props, link), common: CommonComponentParts::<Self>::create(),
route_dispatcher: RouteAgentDispatcher::new(),
form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()), form: yew_form::Form::<CreateUserModel>::new(CreateUserModel::default()),
} }
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, msg) CommonComponentParts::<Self>::update(self, ctx, msg)
} }
fn change(&mut self, props: Self::Properties) -> ShouldRender { fn view(&self, ctx: &Context<Self>) -> Html {
self.common.change(props) let link = &ctx.link();
}
fn view(&self) -> Html {
type Field = yew_form::Field<CreateUserModel>; type Field = yew_form::Field<CreateUserModel>;
html! { html! {
<div class="row justify-content-center"> <div class="row justify-content-center">
@ -206,13 +199,13 @@ impl Component for CreateUserForm {
</label> </label>
<div class="col-8"> <div class="col-8">
<Field <Field
form=&self.form form={&self.form}
field_name="username" field_name="username"
class="form-control" class="form-control"
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.common.callback(|_| Msg::Update) /> oninput={link.callback(|_| Msg::Update)} />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("username")} {&self.form.field_message("username")}
</div> </div>
@ -227,14 +220,14 @@ impl Component for CreateUserForm {
</label> </label>
<div class="col-8"> <div class="col-8">
<Field <Field
form=&self.form form={&self.form}
input_type="email" input_type="email"
field_name="email" field_name="email"
class="form-control" class="form-control"
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.common.callback(|_| Msg::Update) /> oninput={link.callback(|_| Msg::Update)} />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("email")} {&self.form.field_message("email")}
</div> </div>
@ -247,13 +240,13 @@ impl Component for CreateUserForm {
</label> </label>
<div class="col-8"> <div class="col-8">
<Field <Field
form=&self.form form={&self.form}
autocomplete="name" autocomplete="name"
class="form-control" class="form-control"
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.common.callback(|_| Msg::Update) /> oninput={link.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>
@ -266,13 +259,13 @@ impl Component for CreateUserForm {
</label> </label>
<div class="col-8"> <div class="col-8">
<Field <Field
form=&self.form form={&self.form}
autocomplete="given-name" autocomplete="given-name"
class="form-control" class="form-control"
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.common.callback(|_| Msg::Update) /> oninput={link.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>
@ -285,13 +278,13 @@ impl Component for CreateUserForm {
</label> </label>
<div class="col-8"> <div class="col-8">
<Field <Field
form=&self.form form={&self.form}
autocomplete="family-name" autocomplete="family-name"
class="form-control" class="form-control"
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.common.callback(|_| Msg::Update) /> oninput={link.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>
@ -304,14 +297,14 @@ impl Component for CreateUserForm {
</label> </label>
<div class="col-8"> <div class="col-8">
<Field <Field
form=&self.form form={&self.form}
input_type="password" input_type="password"
field_name="password" field_name="password"
class="form-control" class="form-control"
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.common.callback(|_| Msg::Update) /> oninput={link.callback(|_| Msg::Update)} />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("password")} {&self.form.field_message("password")}
</div> </div>
@ -324,14 +317,14 @@ impl Component for CreateUserForm {
</label> </label>
<div class="col-8"> <div class="col-8">
<Field <Field
form=&self.form form={&self.form}
input_type="password" input_type="password"
field_name="confirm_password" field_name="confirm_password"
class="form-control" class="form-control"
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.common.callback(|_| Msg::Update) /> oninput={link.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>
@ -340,9 +333,9 @@ 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.common.is_task_running() disabled={self.common.is_task_running()}
type="submit" type="submit"
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})> onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}>
<i class="bi-save me-2"></i> <i class="bi-save me-2"></i>
{"Submit"} {"Submit"}
</button> </button>

View File

@ -39,16 +39,21 @@ pub enum Msg {
} }
impl CommonComponent<DeleteGroup> for DeleteGroup { impl CommonComponent<DeleteGroup> for DeleteGroup {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg { match msg {
Msg::ClickedDeleteGroup => { Msg::ClickedDeleteGroup => {
self.modal.as_ref().expect("modal not initialized").show(); self.modal.as_ref().expect("modal not initialized").show();
} }
Msg::ConfirmDeleteGroup => { Msg::ConfirmDeleteGroup => {
self.update(Msg::DismissModal); self.update(ctx, Msg::DismissModal);
self.common.call_graphql::<DeleteGroupQuery, _>( self.common.call_graphql::<DeleteGroupQuery, _>(
ctx,
delete_group_query::Variables { delete_group_query::Variables {
group_id: self.common.group.id, group_id: ctx.props().group.id,
}, },
Msg::DeleteGroupResponse, Msg::DeleteGroupResponse,
"Error trying to delete group", "Error trying to delete group",
@ -58,12 +63,8 @@ impl CommonComponent<DeleteGroup> for DeleteGroup {
self.modal.as_ref().expect("modal not initialized").hide(); self.modal.as_ref().expect("modal not initialized").hide();
} }
Msg::DeleteGroupResponse(response) => { Msg::DeleteGroupResponse(response) => {
self.common.cancel_task();
response?; response?;
self.common ctx.props().on_group_deleted.emit(ctx.props().group.id);
.props
.on_group_deleted
.emit(self.common.group.id);
} }
} }
Ok(true) Ok(true)
@ -78,15 +79,15 @@ 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(_: &Context<Self>) -> Self {
Self { Self {
common: CommonComponentParts::<Self>::create(props, link), common: CommonComponentParts::<Self>::create(),
node_ref: NodeRef::default(), node_ref: NodeRef::default(),
modal: None, modal: None,
} }
} }
fn rendered(&mut self, first_render: bool) { fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
if first_render { if first_render {
self.modal = Some(Modal::new( self.modal = Some(Modal::new(
self.node_ref self.node_ref
@ -96,43 +97,42 @@ impl Component for DeleteGroup {
} }
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update_and_report_error( CommonComponentParts::<Self>::update_and_report_error(
self, self,
ctx,
msg, msg,
self.common.on_error.clone(), ctx.props().on_error.clone(),
) )
} }
fn change(&mut self, props: Self::Properties) -> ShouldRender { fn view(&self, ctx: &Context<Self>) -> Html {
self.common.change(props) let link = &ctx.link();
}
fn view(&self) -> Html {
html! { html! {
<> <>
<button <button
class="btn btn-danger" class="btn btn-danger"
disabled=self.common.is_task_running() disabled={self.common.is_task_running()}
onclick=self.common.callback(|_| Msg::ClickedDeleteGroup)> onclick={link.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(ctx)}
</> </>
} }
} }
} }
impl DeleteGroup { impl DeleteGroup {
fn show_modal(&self) -> Html { fn show_modal(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
html! { html! {
<div <div
class="modal fade" class="modal fade"
id="deleteGroupModal".to_string() + &self.common.group.id.to_string() id={"deleteGroupModal".to_string() + &ctx.props().group.id.to_string()}
tabindex="-1" tabindex="-1"
aria-labelledby="deleteGroupModalLabel" aria-labelledby="deleteGroupModalLabel"
aria-hidden="true" aria-hidden="true"
ref=self.node_ref.clone()> ref={self.node_ref.clone()}>
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@ -141,25 +141,25 @@ impl DeleteGroup {
type="button" type="button"
class="btn-close" class="btn-close"
aria-label="Close" aria-label="Close"
onclick=self.common.callback(|_| Msg::DismissModal) /> onclick={link.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.common.group.display_name}</b>{"?"} <b>{&ctx.props().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.common.callback(|_| Msg::DismissModal)> onclick={link.callback(|_| Msg::DismissModal)}>
<i class="bi-x-circle me-2"></i> <i class="bi-x-circle me-2"></i>
{"Cancel"} {"Cancel"}
</button> </button>
<button <button
type="button" type="button"
onclick=self.common.callback(|_| Msg::ConfirmDeleteGroup) onclick={link.callback(|_| Msg::ConfirmDeleteGroup)}
class="btn btn-danger"> class="btn btn-danger">
<i class="bi-check-circle me-2"></i> <i class="bi-check-circle me-2"></i>
{"Yes, I'm sure"} {"Yes, I'm sure"}

View File

@ -36,16 +36,21 @@ pub enum Msg {
} }
impl CommonComponent<DeleteUser> for DeleteUser { impl CommonComponent<DeleteUser> for DeleteUser {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg { match msg {
Msg::ClickedDeleteUser => { Msg::ClickedDeleteUser => {
self.modal.as_ref().expect("modal not initialized").show(); self.modal.as_ref().expect("modal not initialized").show();
} }
Msg::ConfirmDeleteUser => { Msg::ConfirmDeleteUser => {
self.update(Msg::DismissModal); self.update(ctx, Msg::DismissModal);
self.common.call_graphql::<DeleteUserQuery, _>( self.common.call_graphql::<DeleteUserQuery, _>(
ctx,
delete_user_query::Variables { delete_user_query::Variables {
user: self.common.username.clone(), user: ctx.props().username.clone(),
}, },
Msg::DeleteUserResponse, Msg::DeleteUserResponse,
"Error trying to delete user", "Error trying to delete user",
@ -55,12 +60,10 @@ impl CommonComponent<DeleteUser> for DeleteUser {
self.modal.as_ref().expect("modal not initialized").hide(); self.modal.as_ref().expect("modal not initialized").hide();
} }
Msg::DeleteUserResponse(response) => { Msg::DeleteUserResponse(response) => {
self.common.cancel_task();
response?; response?;
self.common ctx.props()
.props
.on_user_deleted .on_user_deleted
.emit(self.common.username.clone()); .emit(ctx.props().username.clone());
} }
} }
Ok(true) Ok(true)
@ -75,15 +78,15 @@ 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(_: &Context<Self>) -> Self {
Self { Self {
common: CommonComponentParts::<Self>::create(props, link), common: CommonComponentParts::<Self>::create(),
node_ref: NodeRef::default(), node_ref: NodeRef::default(),
modal: None, modal: None,
} }
} }
fn rendered(&mut self, first_render: bool) { fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
if first_render { if first_render {
self.modal = Some(Modal::new( self.modal = Some(Modal::new(
self.node_ref self.node_ref
@ -93,44 +96,43 @@ impl Component for DeleteUser {
} }
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update_and_report_error( CommonComponentParts::<Self>::update_and_report_error(
self, self,
ctx,
msg, msg,
self.common.on_error.clone(), ctx.props().on_error.clone(),
) )
} }
fn change(&mut self, props: Self::Properties) -> ShouldRender { fn view(&self, ctx: &Context<Self>) -> Html {
self.common.change(props) let link = &ctx.link();
}
fn view(&self) -> Html {
html! { html! {
<> <>
<button <button
class="btn btn-danger" class="btn btn-danger"
disabled=self.common.is_task_running() disabled={self.common.is_task_running()}
onclick=self.common.callback(|_| Msg::ClickedDeleteUser)> onclick={link.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(ctx)}
</> </>
} }
} }
} }
impl DeleteUser { impl DeleteUser {
fn show_modal(&self) -> Html { fn show_modal(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
html! { html! {
<div <div
class="modal fade" class="modal fade"
id="deleteUserModal".to_string() + &self.common.username id={"deleteUserModal".to_string() + &ctx.props().username}
tabindex="-1" tabindex="-1"
//role="dialog" //role="dialog"
aria-labelledby="deleteUserModalLabel" aria-labelledby="deleteUserModalLabel"
aria-hidden="true" aria-hidden="true"
ref=self.node_ref.clone()> ref={self.node_ref.clone()}>
<div class="modal-dialog" /*role="document"*/> <div class="modal-dialog" /*role="document"*/>
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@ -139,25 +141,25 @@ impl DeleteUser {
type="button" type="button"
class="btn-close" class="btn-close"
aria-label="Close" aria-label="Close"
onclick=self.common.callback(|_| Msg::DismissModal) /> onclick={link.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.common.username}</b>{"?"} <b>{&ctx.props().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.common.callback(|_| Msg::DismissModal)> onclick={link.callback(|_| Msg::DismissModal)}>
<i class="bi-x-circle me-2"></i> <i class="bi-x-circle me-2"></i>
{"Cancel"} {"Cancel"}
</button> </button>
<button <button
type="button" type="button"
onclick=self.common.callback(|_| Msg::ConfirmDeleteUser) onclick={link.callback(|_| Msg::ConfirmDeleteUser)}
class="btn btn-danger"> class="btn btn-danger">
<i class="bi-check-circle me-2"></i> <i class="bi-check-circle me-2"></i>
{"Yes, I'm sure"} {"Yes, I'm sure"}

View File

@ -46,10 +46,11 @@ pub struct Props {
} }
impl GroupDetails { impl GroupDetails {
fn get_group_details(&mut self) { fn get_group_details(&mut self, ctx: &Context<Self>) {
self.common.call_graphql::<GetGroupDetails, _>( self.common.call_graphql::<GetGroupDetails, _>(
ctx,
get_group_details::Variables { get_group_details::Variables {
id: self.common.group_id, id: ctx.props().group_id,
}, },
Msg::GroupDetailsResponse, Msg::GroupDetailsResponse,
"Error trying to fetch group details", "Error trying to fetch group details",
@ -107,24 +108,25 @@ impl GroupDetails {
} }
} }
fn view_user_list(&self, g: &Group) -> Html { fn view_user_list(&self, ctx: &Context<Self>, g: &Group) -> Html {
let link = ctx.link();
let make_user_row = |user: &User| { let make_user_row = |user: &User| {
let user_id = user.id.clone(); let user_id = user.id.clone();
let display_name = user.display_name.clone(); let display_name = user.display_name.clone();
html! { html! {
<tr> <tr>
<td> <td>
<Link route=AppRoute::UserDetails(user_id.clone())> <Link to={AppRoute::UserDetails{user_id: user_id.clone()}}>
{user_id.clone()} {user_id.clone()}
</Link> </Link>
</td> </td>
<td>{display_name}</td> <td>{display_name}</td>
<td> <td>
<RemoveUserFromGroupComponent <RemoveUserFromGroupComponent
username=user_id username={user_id}
group_id=g.id group_id={g.id}
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup) on_user_removed_from_group={link.callback(Msg::OnUserRemovedFromGroup)}
on_error=self.common.callback(Msg::OnError)/> on_error={link.callback(Msg::OnError)}/>
</td> </td>
</tr> </tr>
} }
@ -159,7 +161,8 @@ impl GroupDetails {
} }
} }
fn view_add_user_button(&self, g: &Group) -> Html { fn view_add_user_button(&self, ctx: &Context<Self>, g: &Group) -> Html {
let link = ctx.link();
let users: Vec<_> = g let users: Vec<_> = g
.users .users
.iter() .iter()
@ -170,16 +173,16 @@ impl GroupDetails {
.collect(); .collect();
html! { html! {
<AddGroupMemberComponent <AddGroupMemberComponent
group_id=g.id group_id={g.id}
users=users users={users}
on_error=self.common.callback(Msg::OnError) on_error={link.callback(Msg::OnError)}
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/> on_user_added_to_group={link.callback(Msg::OnUserAddedToGroup)}/>
} }
} }
} }
impl CommonComponent<GroupDetails> for GroupDetails { impl CommonComponent<GroupDetails> for GroupDetails {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
match msg { match msg {
Msg::GroupDetailsResponse(response) => match response { Msg::GroupDetailsResponse(response) => match response {
Ok(group) => self.group = Some(group.group), Ok(group) => self.group = Some(group.group),
@ -215,24 +218,20 @@ 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(ctx: &Context<Self>) -> Self {
let mut table = Self { let mut table = Self {
common: CommonComponentParts::<Self>::create(props, link), common: CommonComponentParts::<Self>::create(),
group: None, group: None,
}; };
table.get_group_details(); table.get_group_details(ctx);
table table
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, msg) CommonComponentParts::<Self>::update(self, ctx, msg)
} }
fn change(&mut self, props: Self::Properties) -> ShouldRender { fn view(&self, ctx: &Context<Self>) -> Html {
self.common.change(props)
}
fn view(&self) -> Html {
match (&self.group, &self.common.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>},
@ -240,8 +239,8 @@ impl Component for GroupDetails {
html! { html! {
<div> <div>
{self.view_details(u)} {self.view_details(u)}
{self.view_user_list(u)} {self.view_user_list(ctx, u)}
{self.view_add_user_button(u)} {self.view_add_user_button(ctx, u)}
{self.view_messages(error)} {self.view_messages(error)}
</div> </div>
} }

View File

@ -34,7 +34,7 @@ pub enum Msg {
} }
impl CommonComponent<GroupTable> for GroupTable { impl CommonComponent<GroupTable> for GroupTable {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
match msg { match msg {
Msg::ListGroupsResponse(groups) => { Msg::ListGroupsResponse(groups) => {
self.groups = Some(groups?.groups.into_iter().collect()); self.groups = Some(groups?.groups.into_iter().collect());
@ -58,12 +58,13 @@ impl Component for GroupTable {
type Message = Msg; type Message = Msg;
type Properties = (); type Properties = ();
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(ctx: &Context<Self>) -> Self {
let mut table = GroupTable { let mut table = GroupTable {
common: CommonComponentParts::<Self>::create(props, link), common: CommonComponentParts::<Self>::create(),
groups: None, groups: None,
}; };
table.common.call_graphql::<GetGroupList, _>( table.common.call_graphql::<GetGroupList, _>(
ctx,
get_group_list::Variables {}, get_group_list::Variables {},
Msg::ListGroupsResponse, Msg::ListGroupsResponse,
"Error trying to fetch groups", "Error trying to fetch groups",
@ -71,18 +72,14 @@ impl Component for GroupTable {
table table
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, msg) CommonComponentParts::<Self>::update(self, ctx, msg)
} }
fn change(&mut self, props: Self::Properties) -> ShouldRender { fn view(&self, ctx: &Context<Self>) -> Html {
self.common.change(props)
}
fn view(&self) -> Html {
html! { html! {
<div> <div>
{self.view_groups()} {self.view_groups(ctx)}
{self.view_errors()} {self.view_errors()}
</div> </div>
} }
@ -90,7 +87,7 @@ impl Component for GroupTable {
} }
impl GroupTable { impl GroupTable {
fn view_groups(&self) -> Html { fn view_groups(&self, ctx: &Context<Self>) -> Html {
let make_table = |groups: &Vec<Group>| { let make_table = |groups: &Vec<Group>| {
html! { html! {
<div class="table-responsive"> <div class="table-responsive">
@ -103,7 +100,7 @@ impl GroupTable {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{groups.iter().map(|u| self.view_group(u)).collect::<Vec<_>>()} {groups.iter().map(|u| self.view_group(ctx, u)).collect::<Vec<_>>()}
</tbody> </tbody>
</table> </table>
</div> </div>
@ -115,11 +112,12 @@ impl GroupTable {
} }
} }
fn view_group(&self, group: &Group) -> Html { fn view_group(&self, ctx: &Context<Self>, group: &Group) -> Html {
let link = ctx.link();
html! { html! {
<tr key=group.id> <tr key={group.id}>
<td> <td>
<Link route=AppRoute::GroupDetails(group.id)> <Link to={AppRoute::GroupDetails{group_id: group.id}}>
{&group.display_name} {&group.display_name}
</Link> </Link>
</td> </td>
@ -128,9 +126,9 @@ impl GroupTable {
</td> </td>
<td> <td>
<DeleteGroup <DeleteGroup
group=group.clone() group={group.clone()}
on_group_deleted=self.common.callback(Msg::OnGroupDeleted) on_group_deleted={link.callback(Msg::OnGroupDeleted)}
on_error=self.common.callback(Msg::OnError)/> on_error={link.callback(Msg::OnError)}/>
</td> </td>
</tr> </tr>
} }

View File

@ -1,14 +1,15 @@
use crate::{ use crate::{
components::router::{AppRoute, NavButton}, components::router::{AppRoute, Link},
infra::{ infra::{
api::HostService, api::HostService,
common_component::{CommonComponent, CommonComponentParts}, common_component::{CommonComponent, CommonComponentParts},
}, },
}; };
use anyhow::{anyhow, bail, Context, Result}; use anyhow::{anyhow, bail, Result};
use gloo_console::error;
use lldap_auth::*; use lldap_auth::*;
use validator_derive::Validate; use validator_derive::Validate;
use yew::{prelude::*, services::ConsoleService}; use yew::prelude::*;
use yew_form::Form; use yew_form::Form;
use yew_form_derive::Model; use yew_form_derive::Model;
@ -30,6 +31,7 @@ pub struct FormModel {
#[derive(Clone, PartialEq, Properties)] #[derive(Clone, PartialEq, Properties)]
pub struct Props { pub struct Props {
pub on_logged_in: Callback<(String, bool)>, pub on_logged_in: Callback<(String, bool)>,
pub password_reset_enabled: bool,
} }
pub enum Msg { pub enum Msg {
@ -46,7 +48,12 @@ pub enum Msg {
} }
impl CommonComponent<LoginForm> for LoginForm { impl CommonComponent<LoginForm> for LoginForm {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
use anyhow::Context;
match msg { match msg {
Msg::Update => Ok(true), Msg::Update => Ok(true),
Msg::Submit => { Msg::Submit => {
@ -63,9 +70,9 @@ impl CommonComponent<LoginForm> for LoginForm {
login_start_request: message, login_start_request: message,
}; };
self.common self.common
.call_backend(HostService::login_start, req, move |r| { .call_backend(ctx, HostService::login_start(req), move |r| {
Msg::AuthenticationStartResponse((state, r)) Msg::AuthenticationStartResponse((state, r))
})?; });
Ok(true) Ok(true)
} }
Msg::AuthenticationStartResponse((login_start, res)) => { Msg::AuthenticationStartResponse((login_start, res)) => {
@ -76,9 +83,8 @@ impl CommonComponent<LoginForm> for LoginForm {
Err(e) => { Err(e) => {
// 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)); error!(&format!("Invalid username or password: {}", e));
self.common.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,
@ -88,24 +94,22 @@ impl CommonComponent<LoginForm> for LoginForm {
credential_finalization: login_finish.message, credential_finalization: login_finish.message,
}; };
self.common.call_backend( self.common.call_backend(
HostService::login_finish, ctx,
req, HostService::login_finish(req),
Msg::AuthenticationFinishResponse, Msg::AuthenticationFinishResponse,
)?; );
Ok(false) Ok(false)
} }
Msg::AuthenticationFinishResponse(user_info) => { Msg::AuthenticationFinishResponse(user_info) => {
self.common.cancel_task(); ctx.props()
self.common
.on_logged_in .on_logged_in
.emit(user_info.context("Could not log in")?); .emit(user_info.context("Could not log in")?);
Ok(true) Ok(true)
} }
Msg::AuthenticationRefreshResponse(user_info) => { Msg::AuthenticationRefreshResponse(user_info) => {
self.refreshing = false; self.refreshing = false;
self.common.cancel_task();
if let Ok(user_info) = user_info { if let Ok(user_info) = user_info {
self.common.on_logged_in.emit(user_info); ctx.props().on_logged_in.emit(user_info);
} }
Ok(true) Ok(true)
} }
@ -121,32 +125,28 @@ impl Component for LoginForm {
type Message = Msg; type Message = Msg;
type Properties = Props; type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(ctx: &Context<Self>) -> Self {
let mut app = LoginForm { let mut app = LoginForm {
common: CommonComponentParts::<Self>::create(props, link), common: CommonComponentParts::<Self>::create(),
form: Form::<FormModel>::new(FormModel::default()), form: Form::<FormModel>::new(FormModel::default()),
refreshing: true, refreshing: true,
}; };
if let Err(e) = app.common.call_backend(
app.common ctx,
.call_backend(HostService::refresh, (), Msg::AuthenticationRefreshResponse) HostService::refresh(),
{ Msg::AuthenticationRefreshResponse,
ConsoleService::debug(&format!("Could not refresh auth: {}", e)); );
app.refreshing = false;
}
app app
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, msg) CommonComponentParts::<Self>::update(self, ctx, msg)
} }
fn change(&mut self, props: Self::Properties) -> ShouldRender { fn view(&self, ctx: &Context<Self>) -> Html {
self.common.change(props)
}
fn view(&self) -> Html {
type Field = yew_form::Field<FormModel>; type Field = yew_form::Field<FormModel>;
let password_reset_enabled = ctx.props().password_reset_enabled;
let link = &ctx.link();
if self.refreshing { if self.refreshing {
html! { html! {
<div> <div>
@ -167,11 +167,11 @@ impl Component for LoginForm {
class="form-control" class="form-control"
class_invalid="is-invalid has-error" class_invalid="is-invalid has-error"
class_valid="has-success" class_valid="has-success"
form=&self.form form={&self.form}
field_name="username" field_name="username"
placeholder="Username" placeholder="Username"
autocomplete="username" autocomplete="username"
oninput=self.common.callback(|_| Msg::Update) /> oninput={link.callback(|_| Msg::Update)} />
</div> </div>
<div class="input-group"> <div class="input-group">
<div class="input-group-prepend"> <div class="input-group-prepend">
@ -183,7 +183,7 @@ impl Component for LoginForm {
class="form-control" class="form-control"
class_invalid="is-invalid has-error" class_invalid="is-invalid has-error"
class_valid="has-success" class_valid="has-success"
form=&self.form form={&self.form}
field_name="password" field_name="password"
input_type="password" input_type="password"
placeholder="Password" placeholder="Password"
@ -193,17 +193,23 @@ impl Component for LoginForm {
<button <button
type="submit" type="submit"
class="btn btn-primary" class="btn btn-primary"
disabled=self.common.is_task_running() disabled={self.common.is_task_running()}
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})> onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
<i class="bi-box-arrow-in-right me-2"/> <i class="bi-box-arrow-in-right me-2"/>
{"Login"} {"Login"}
</button> </button>
<NavButton { if password_reset_enabled {
classes="btn-link btn" html! {
disabled=self.common.is_task_running() <Link
route=AppRoute::StartResetPassword> classes="btn-link btn"
{"Forgot your password?"} disabled={self.common.is_task_running()}
</NavButton> to={AppRoute::StartResetPassword}>
{"Forgot your password?"}
</Link>
}
} else {
html!{}
}}
</div> </div>
<div class="form-group"> <div class="form-group">
{ if let Some(e) = &self.common.error { { if let Some(e) = &self.common.error {

View File

@ -21,16 +21,20 @@ pub enum Msg {
} }
impl CommonComponent<LogoutButton> for LogoutButton { impl CommonComponent<LogoutButton> for LogoutButton {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg { match msg {
Msg::LogoutRequested => { Msg::LogoutRequested => {
self.common self.common
.call_backend(HostService::logout, (), Msg::LogoutCompleted)?; .call_backend(ctx, HostService::logout(), Msg::LogoutCompleted);
} }
Msg::LogoutCompleted(res) => { Msg::LogoutCompleted(res) => {
res?; res?;
delete_cookie("user_id")?; delete_cookie("user_id")?;
self.common.on_logged_out.emit(()); ctx.props().on_logged_out.emit(());
} }
} }
Ok(false) Ok(false)
@ -45,25 +49,22 @@ 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(_: &Context<Self>) -> Self {
LogoutButton { LogoutButton {
common: CommonComponentParts::<Self>::create(props, link), common: CommonComponentParts::<Self>::create(),
} }
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, msg) CommonComponentParts::<Self>::update(self, ctx, msg)
} }
fn change(&mut self, props: Self::Properties) -> ShouldRender { fn view(&self, ctx: &Context<Self>) -> Html {
self.common.change(props) let link = &ctx.link();
}
fn view(&self) -> Html {
html! { html! {
<button <button
class="dropdown-item" class="dropdown-item"
onclick=self.common.callback(|_| Msg::LogoutRequested)> onclick={link.callback(|_| Msg::LogoutRequested)}>
{"Logout"} {"Logout"}
</button> </button>
} }

View File

@ -31,15 +31,18 @@ pub enum Msg {
} }
impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupComponent { impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupComponent {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg { match msg {
Msg::SubmitRemoveGroup => self.submit_remove_group(), Msg::SubmitRemoveGroup => self.submit_remove_group(ctx),
Msg::RemoveGroupResponse(response) => { Msg::RemoveGroupResponse(response) => {
response?; response?;
self.common.cancel_task(); ctx.props()
self.common
.on_user_removed_from_group .on_user_removed_from_group
.emit((self.common.username.clone(), self.common.group_id)); .emit((ctx.props().username.clone(), ctx.props().group_id));
} }
} }
Ok(true) Ok(true)
@ -51,11 +54,12 @@ impl CommonComponent<RemoveUserFromGroupComponent> for RemoveUserFromGroupCompon
} }
impl RemoveUserFromGroupComponent { impl RemoveUserFromGroupComponent {
fn submit_remove_group(&mut self) { fn submit_remove_group(&mut self, ctx: &Context<Self>) {
self.common.call_graphql::<RemoveUserFromGroup, _>( self.common.call_graphql::<RemoveUserFromGroup, _>(
ctx,
remove_user_from_group::Variables { remove_user_from_group::Variables {
user: self.common.username.clone(), user: ctx.props().username.clone(),
group: self.common.group_id, group: ctx.props().group_id,
}, },
Msg::RemoveGroupResponse, Msg::RemoveGroupResponse,
"Error trying to initiate removing the user from a group", "Error trying to initiate removing the user from a group",
@ -67,30 +71,28 @@ impl Component for RemoveUserFromGroupComponent {
type Message = Msg; type Message = Msg;
type Properties = Props; type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(_: &Context<Self>) -> Self {
Self { Self {
common: CommonComponentParts::<Self>::create(props, link), common: CommonComponentParts::<Self>::create(),
} }
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update_and_report_error( CommonComponentParts::<Self>::update_and_report_error(
self, self,
ctx,
msg, msg,
self.common.on_error.clone(), ctx.props().on_error.clone(),
) )
} }
fn change(&mut self, props: Self::Properties) -> ShouldRender { fn view(&self, ctx: &Context<Self>) -> Html {
self.common.change(props) let link = &ctx.link();
}
fn view(&self) -> Html {
html! { html! {
<button <button
class="btn btn-danger" class="btn btn-danger"
disabled=self.common.is_task_running() disabled={self.common.is_task_running()}
onclick=self.common.callback(|_| Msg::SubmitRemoveGroup)> onclick={link.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

@ -1,5 +1,5 @@
use crate::{ use crate::{
components::router::{AppRoute, NavButton}, components::router::{AppRoute, Link},
infra::{ infra::{
api::HostService, api::HostService,
common_component::{CommonComponent, CommonComponentParts}, common_component::{CommonComponent, CommonComponentParts},
@ -31,7 +31,11 @@ pub enum Msg {
} }
impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form { impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg { match msg {
Msg::Update => Ok(true), Msg::Update => Ok(true),
Msg::Submit => { Msg::Submit => {
@ -40,10 +44,10 @@ impl CommonComponent<ResetPasswordStep1Form> for ResetPasswordStep1Form {
} }
let FormModel { username } = self.form.model(); let FormModel { username } = self.form.model();
self.common.call_backend( self.common.call_backend(
HostService::reset_password_step1, ctx,
&username, HostService::reset_password_step1(username),
Msg::PasswordResetResponse, Msg::PasswordResetResponse,
)?; );
Ok(true) Ok(true)
} }
Msg::PasswordResetResponse(response) => { Msg::PasswordResetResponse(response) => {
@ -63,25 +67,22 @@ impl Component for ResetPasswordStep1Form {
type Message = Msg; type Message = Msg;
type Properties = (); type Properties = ();
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(_: &Context<Self>) -> Self {
ResetPasswordStep1Form { ResetPasswordStep1Form {
common: CommonComponentParts::<Self>::create(props, link), common: CommonComponentParts::<Self>::create(),
form: Form::<FormModel>::new(FormModel::default()), form: Form::<FormModel>::new(FormModel::default()),
just_succeeded: false, just_succeeded: false,
} }
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
self.just_succeeded = false; self.just_succeeded = false;
CommonComponentParts::<Self>::update(self, msg) CommonComponentParts::<Self>::update(self, ctx, msg)
} }
fn change(&mut self, props: Self::Properties) -> ShouldRender { fn view(&self, ctx: &Context<Self>) -> Html {
self.common.change(props)
}
fn view(&self) -> Html {
type Field = yew_form::Field<FormModel>; type Field = yew_form::Field<FormModel>;
let link = &ctx.link();
html! { html! {
<form <form
class="form center-block col-sm-4 col-offset-4"> class="form center-block col-sm-4 col-offset-4">
@ -95,11 +96,11 @@ impl Component for ResetPasswordStep1Form {
class="form-control" class="form-control"
class_invalid="is-invalid has-error" class_invalid="is-invalid has-error"
class_valid="has-success" class_valid="has-success"
form=&self.form form={&self.form}
field_name="username" field_name="username"
placeholder="Username or email" placeholder="Username or email"
autocomplete="username" autocomplete="username"
oninput=self.common.callback(|_| Msg::Update) /> oninput={link.callback(|_| Msg::Update)} />
</div> </div>
{ if self.just_succeeded { { if self.just_succeeded {
html! { html! {
@ -111,17 +112,17 @@ impl Component for ResetPasswordStep1Form {
<button <button
type="submit" type="submit"
class="btn btn-primary" class="btn btn-primary"
disabled=self.common.is_task_running() disabled={self.common.is_task_running()}
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})> onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
<i class="bi-check-circle me-2"/> <i class="bi-check-circle me-2"/>
{"Reset password"} {"Reset password"}
</button> </button>
<NavButton <Link
classes="btn-link btn" classes="btn-link btn"
disabled=self.common.is_task_running() disabled={self.common.is_task_running()}
route=AppRoute::Login> to={AppRoute::Login}>
{"Back"} {"Back"}
</NavButton> </Link>
</div> </div>
} }
}} }}

View File

@ -1,11 +1,11 @@
use crate::{ use crate::{
components::router::AppRoute, components::router::{AppRoute, Link},
infra::{ infra::{
api::HostService, api::HostService,
common_component::{CommonComponent, CommonComponentParts}, common_component::{CommonComponent, CommonComponentParts},
}, },
}; };
use anyhow::{bail, Context, Result}; use anyhow::{bail, Result};
use lldap_auth::{ use lldap_auth::{
opaque::client::registration as opaque_registration, opaque::client::registration as opaque_registration,
password_reset::ServerPasswordResetResponse, registration, password_reset::ServerPasswordResetResponse, registration,
@ -14,10 +14,7 @@ use validator_derive::Validate;
use yew::prelude::*; use yew::prelude::*;
use yew_form::Form; use yew_form::Form;
use yew_form_derive::Model; use yew_form_derive::Model;
use yew_router::{ use yew_router::{prelude::History, scope_ext::RouterScopeExt};
agent::{RouteAgentDispatcher, RouteRequest},
route::Route,
};
/// The fields of the form, with the constraints. /// The fields of the form, with the constraints.
#[derive(Model, Validate, PartialEq, Eq, Clone, Default)] #[derive(Model, Validate, PartialEq, Eq, Clone, Default)]
@ -33,7 +30,6 @@ pub struct ResetPasswordStep2Form {
form: Form<FormModel>, form: Form<FormModel>,
username: Option<String>, username: Option<String>,
opaque_data: Option<opaque_registration::ClientRegistration>, opaque_data: Option<opaque_registration::ClientRegistration>,
route_dispatcher: RouteAgentDispatcher,
} }
#[derive(Clone, PartialEq, Eq, Properties)] #[derive(Clone, PartialEq, Eq, Properties)]
@ -50,11 +46,15 @@ pub enum Msg {
} }
impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form { impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
use anyhow::Context;
match msg { match msg {
Msg::ValidateTokenResponse(response) => { Msg::ValidateTokenResponse(response) => {
self.username = Some(response?.user_id); self.username = Some(response?.user_id);
self.common.cancel_task();
Ok(true) Ok(true)
} }
Msg::FormUpdate => Ok(true), Msg::FormUpdate => Ok(true),
@ -73,10 +73,10 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
}; };
self.opaque_data = Some(registration_start_request.state); self.opaque_data = Some(registration_start_request.state);
self.common.call_backend( self.common.call_backend(
HostService::register_start, ctx,
req, HostService::register_start(req),
Msg::RegistrationStartResponse, Msg::RegistrationStartResponse,
)?; );
Ok(true) Ok(true)
} }
Msg::RegistrationStartResponse(res) => { Msg::RegistrationStartResponse(res) => {
@ -94,17 +94,15 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
registration_upload: registration_finish.message, registration_upload: registration_finish.message,
}; };
self.common.call_backend( self.common.call_backend(
HostService::register_finish, ctx,
req, HostService::register_finish(req),
Msg::RegistrationFinishResponse, Msg::RegistrationFinishResponse,
)?; );
Ok(false) Ok(false)
} }
Msg::RegistrationFinishResponse(response) => { Msg::RegistrationFinishResponse(response) => {
self.common.cancel_task();
if response.is_ok() { if response.is_ok() {
self.route_dispatcher ctx.link().history().unwrap().push(AppRoute::Login);
.send(RouteRequest::ChangeRoute(Route::from(AppRoute::Login)));
} }
response?; response?;
Ok(true) Ok(true)
@ -121,35 +119,28 @@ impl Component for ResetPasswordStep2Form {
type Message = Msg; type Message = Msg;
type Properties = Props; type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(ctx: &Context<Self>) -> Self {
let mut component = ResetPasswordStep2Form { let mut component = ResetPasswordStep2Form {
common: CommonComponentParts::<Self>::create(props, link), common: CommonComponentParts::<Self>::create(),
form: yew_form::Form::<FormModel>::new(FormModel::default()), form: yew_form::Form::<FormModel>::new(FormModel::default()),
opaque_data: None, opaque_data: None,
route_dispatcher: RouteAgentDispatcher::new(),
username: None, username: None,
}; };
let token = component.common.token.clone(); let token = ctx.props().token.clone();
component component.common.call_backend(
.common ctx,
.call_backend( HostService::reset_password_step2(token),
HostService::reset_password_step2, Msg::ValidateTokenResponse,
&token, );
Msg::ValidateTokenResponse,
)
.unwrap();
component component
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, msg) CommonComponentParts::<Self>::update(self, ctx, msg)
} }
fn change(&mut self, props: Self::Properties) -> ShouldRender { fn view(&self, ctx: &Context<Self>) -> Html {
self.common.change(props) let link = &ctx.link();
}
fn view(&self) -> Html {
match (&self.username, &self.common.error) { match (&self.username, &self.common.error) {
(None, None) => { (None, None) => {
return html! { return html! {
@ -158,9 +149,17 @@ impl Component for ResetPasswordStep2Form {
} }
(None, Some(e)) => { (None, Some(e)) => {
return html! { return html! {
<div class="alert alert-danger"> <>
{e.to_string() } <div class="alert alert-danger">
</div> {e.to_string() }
</div>
<Link
classes="btn-link btn"
disabled={self.common.is_task_running()}
to={AppRoute::Login}>
{"Back"}
</Link>
</>
} }
} }
_ => (), _ => (),
@ -178,14 +177,14 @@ impl Component for ResetPasswordStep2Form {
</label> </label>
<div class="col-sm-10"> <div class="col-sm-10">
<Field <Field
form=&self.form form={&self.form}
field_name="password" field_name="password"
class="form-control" class="form-control"
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"
input_type="password" input_type="password"
oninput=self.common.callback(|_| Msg::FormUpdate) /> oninput={link.callback(|_| Msg::FormUpdate)} />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("password")} {&self.form.field_message("password")}
</div> </div>
@ -198,14 +197,14 @@ impl Component for ResetPasswordStep2Form {
</label> </label>
<div class="col-sm-10"> <div class="col-sm-10">
<Field <Field
form=&self.form form={&self.form}
field_name="confirm_password" field_name="confirm_password"
class="form-control" class="form-control"
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"
input_type="password" input_type="password"
oninput=self.common.callback(|_| Msg::FormUpdate) /> oninput={link.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>
@ -215,8 +214,8 @@ impl Component for ResetPasswordStep2Form {
<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.common.is_task_running() disabled={self.common.is_task_running()}
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})> onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
{"Submit"} {"Submit"}
</button> </button>
</div> </div>

View File

@ -1,34 +1,30 @@
use yew_router::{ use yew_router::Routable;
components::{RouterAnchor, RouterButton},
Switch,
};
#[derive(Switch, Debug, Clone)] #[derive(Routable, Debug, Clone, PartialEq)]
pub enum AppRoute { pub enum AppRoute {
#[to = "/login"] #[at("/login")]
Login, Login,
#[to = "/reset-password/step1"] #[at("/reset-password/step1")]
StartResetPassword, StartResetPassword,
#[to = "/reset-password/step2/{token}"] #[at("/reset-password/step2/:token")]
FinishResetPassword(String), FinishResetPassword { token: String },
#[to = "/users/create"] #[at("/users/create")]
CreateUser, CreateUser,
#[to = "/users"] #[at("/users")]
ListUsers, ListUsers,
#[to = "/user/{user_id}/password"] #[at("/user/:user_id/password")]
ChangePassword(String), ChangePassword { user_id: String },
#[to = "/user/{user_id}"] #[at("/user/:user_id")]
UserDetails(String), UserDetails { user_id: String },
#[to = "/groups/create"] #[at("/groups/create")]
CreateGroup, CreateGroup,
#[to = "/groups"] #[at("/groups")]
ListGroups, ListGroups,
#[to = "/group/{group_id}"] #[at("/group/:group_id")]
GroupDetails(i64), GroupDetails { group_id: i64 },
#[to = "/"] #[at("/")]
Index, Index,
} }
pub type Link = RouterAnchor<AppRoute>; pub type Link = yew_router::components::Link<AppRoute>;
pub type Redirect = yew_router::components::Redirect<AppRoute>;
pub type NavButton = RouterButton<AppRoute>;

View File

@ -1,9 +1,6 @@
use yew::{html::ChangeData, prelude::*}; use yew::prelude::*;
use yewtil::NeqAssign;
pub struct Select { pub struct Select {
link: ComponentLink<Self>,
props: SelectProps,
node_ref: NodeRef, node_ref: NodeRef,
} }
@ -14,100 +11,70 @@ pub struct SelectProps {
} }
pub enum SelectMsg { pub enum SelectMsg {
OnSelectChange(ChangeData), OnSelectChange,
} }
impl Select { impl Select {
fn get_nth_child_props(&self, nth: i32) -> Option<SelectOptionProps> { fn get_nth_child_props(&self, ctx: &Context<Self>, nth: i32) -> Option<SelectOptionProps> {
if nth == -1 { if nth == -1 {
return None; return None;
} }
self.props ctx.props()
.children .children
.iter() .iter()
.nth(nth as usize) .nth(nth as usize)
.map(|child| child.props) .map(|child| (*child.props).clone())
} }
fn send_selection_update(&self) { fn send_selection_update(&self, ctx: &Context<Self>) {
let select_node = self.node_ref.cast::<web_sys::HtmlSelectElement>().unwrap(); let select_node = self.node_ref.cast::<web_sys::HtmlSelectElement>().unwrap();
self.props ctx.props()
.on_selection_change .on_selection_change
.emit(self.get_nth_child_props(select_node.selected_index())) .emit(self.get_nth_child_props(ctx, select_node.selected_index()))
} }
} }
impl Component for Select { impl Component for Select {
type Message = SelectMsg; type Message = SelectMsg;
type Properties = SelectProps; type Properties = SelectProps;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(_: &Context<Self>) -> Self {
Self { Self {
link,
props,
node_ref: NodeRef::default(), node_ref: NodeRef::default(),
} }
} }
fn rendered(&mut self, _first_render: bool) { fn rendered(&mut self, ctx: &Context<Self>, _first_render: bool) {
self.send_selection_update(); self.send_selection_update(ctx);
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, ctx: &Context<Self>, _: Self::Message) -> bool {
let SelectMsg::OnSelectChange(data) = msg; self.send_selection_update(ctx);
match data {
ChangeData::Select(_) => self.send_selection_update(),
_ => unreachable!(),
}
false false
} }
fn change(&mut self, props: Self::Properties) -> ShouldRender { fn view(&self, ctx: &Context<Self>) -> Html {
self.props.children.neq_assign(props.children)
}
fn view(&self) -> Html {
html! { html! {
<select class="form-select" <select class="form-select"
ref=self.node_ref.clone() ref={self.node_ref.clone()}
disabled=self.props.children.is_empty() disabled={ctx.props().children.is_empty()}
onchange=self.link.callback(SelectMsg::OnSelectChange)> onchange={ctx.link().callback(|_| SelectMsg::OnSelectChange)}>
{ self.props.children.clone() } { ctx.props().children.clone() }
</select> </select>
} }
} }
} }
pub struct SelectOption {
props: SelectOptionProps,
}
#[derive(yew::Properties, Clone, PartialEq, Eq, Debug)] #[derive(yew::Properties, Clone, PartialEq, Eq, Debug)]
pub struct SelectOptionProps { pub struct SelectOptionProps {
pub value: String, pub value: String,
pub text: String, pub text: String,
} }
impl Component for SelectOption { #[function_component(SelectOption)]
type Message = (); pub fn select_option(props: &SelectOptionProps) -> Html {
type Properties = SelectOptionProps; html! {
<option value={props.value.clone()}>
fn create(props: Self::Properties, _: ComponentLink<Self>) -> Self { {&props.text}
Self { props } </option>
}
fn update(&mut self, _: Self::Message) -> ShouldRender {
false
}
fn change(&mut self, props: Self::Properties) -> ShouldRender {
self.props.neq_assign(props)
}
fn view(&self) -> Html {
html! {
<option value=self.props.value.clone()>
{&self.props.text}
</option>
}
} }
} }

View File

@ -2,7 +2,7 @@ use crate::{
components::{ components::{
add_user_to_group::AddUserToGroupComponent, add_user_to_group::AddUserToGroupComponent,
remove_user_from_group::RemoveUserFromGroupComponent, remove_user_from_group::RemoveUserFromGroupComponent,
router::{AppRoute, Link, NavButton}, router::{AppRoute, Link},
user_details_form::UserDetailsForm, user_details_form::UserDetailsForm,
}, },
infra::common_component::{CommonComponent, CommonComponentParts}, infra::common_component::{CommonComponent, CommonComponentParts},
@ -47,7 +47,7 @@ pub struct Props {
} }
impl CommonComponent<UserDetails> for UserDetails { impl CommonComponent<UserDetails> for UserDetails {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
match msg { match msg {
Msg::UserDetailsResponse(response) => match response { Msg::UserDetailsResponse(response) => match response {
Ok(user) => self.user = Some(user.user), Ok(user) => self.user = Some(user.user),
@ -77,10 +77,11 @@ impl CommonComponent<UserDetails> for UserDetails {
} }
impl UserDetails { impl UserDetails {
fn get_user_details(&mut self) { fn get_user_details(&mut self, ctx: &Context<Self>) {
self.common.call_graphql::<GetUserDetails, _>( self.common.call_graphql::<GetUserDetails, _>(
ctx,
get_user_details::Variables { get_user_details::Variables {
id: self.common.username.clone(), id: ctx.props().username.clone(),
}, },
Msg::UserDetailsResponse, Msg::UserDetailsResponse,
"Error trying to fetch user details", "Error trying to fetch user details",
@ -99,24 +100,25 @@ impl UserDetails {
} }
} }
fn view_group_memberships(&self, u: &User) -> Html { fn view_group_memberships(&self, ctx: &Context<Self>, u: &User) -> Html {
let link = &ctx.link();
let make_group_row = |group: &Group| { let make_group_row = |group: &Group| {
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.common.is_admin { html! { {if ctx.props().is_admin { html! {
<> <>
<td> <td>
<Link route=AppRoute::GroupDetails(group.id)> <Link to={AppRoute::GroupDetails{group_id: group.id}}>
{&group.display_name} {&group.display_name}
</Link> </Link>
</td> </td>
<td> <td>
<RemoveUserFromGroupComponent <RemoveUserFromGroupComponent
username=u.id.clone() username={u.id.clone()}
group_id=group.id group_id={group.id}
on_user_removed_from_group=self.common.callback(Msg::OnUserRemovedFromGroup) on_user_removed_from_group={link.callback(Msg::OnUserRemovedFromGroup)}
on_error=self.common.callback(Msg::OnError)/> on_error={link.callback(Msg::OnError)}/>
</td> </td>
</> </>
} } else { html! { } } else { html! {
@ -133,7 +135,7 @@ impl UserDetails {
<thead> <thead>
<tr key="headerRow"> <tr key="headerRow">
<th>{"Group"}</th> <th>{"Group"}</th>
{ if self.common.is_admin { html!{ <th></th> }} else { html!{} }} { if ctx.props().is_admin { html!{ <th></th> }} else { html!{} }}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -153,14 +155,15 @@ impl UserDetails {
} }
} }
fn view_add_group_button(&self, u: &User) -> Html { fn view_add_group_button(&self, ctx: &Context<Self>, u: &User) -> Html {
if self.common.is_admin { let link = &ctx.link();
if ctx.props().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.common.callback(Msg::OnError) on_error={link.callback(Msg::OnError)}
on_user_added_to_group=self.common.callback(Msg::OnUserAddedToGroup)/> on_user_added_to_group={link.callback(Msg::OnUserAddedToGroup)}/>
} }
} else { } else {
html! {} html! {}
@ -172,24 +175,20 @@ impl Component for UserDetails {
type Message = Msg; type Message = Msg;
type Properties = Props; type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(ctx: &Context<Self>) -> Self {
let mut table = Self { let mut table = Self {
common: CommonComponentParts::<Self>::create(props, link), common: CommonComponentParts::<Self>::create(),
user: None, user: None,
}; };
table.get_user_details(); table.get_user_details(ctx);
table table
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, msg) CommonComponentParts::<Self>::update(self, ctx, msg)
} }
fn change(&mut self, props: Self::Properties) -> ShouldRender { fn view(&self, ctx: &Context<Self>) -> Html {
self.common.change(props)
}
fn view(&self) -> Html {
match (&self.user, &self.common.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>},
@ -198,20 +197,19 @@ impl Component for UserDetails {
<> <>
<h3>{u.id.to_string()}</h3> <h3>{u.id.to_string()}</h3>
<div class="d-flex flex-row-reverse"> <div class="d-flex flex-row-reverse">
<NavButton <Link
route=AppRoute::ChangePassword(u.id.clone()) to={AppRoute::ChangePassword{user_id: u.id.clone()}}
classes="btn btn-secondary"> classes="btn btn-secondary">
<i class="bi-key me-2"></i> <i class="bi-key me-2"></i>
{"Modify password"} {"Modify password"}
</NavButton> </Link>
</div> </div>
<div> <div>
<h5 class="row m-3 fw-bold">{"User details"}</h5> <h5 class="row m-3 fw-bold">{"User details"}</h5>
</div> </div>
<UserDetailsForm <UserDetailsForm user={u.clone()} />
user=u.clone() /> {self.view_group_memberships(ctx, u)}
{self.view_group_memberships(u)} {self.view_add_group_button(ctx, u)}
{self.view_add_group_button(u)}
{self.view_messages(error)} {self.view_messages(error)}
</> </>
} }

View File

@ -5,15 +5,19 @@ use crate::{
infra::common_component::{CommonComponent, CommonComponentParts}, infra::common_component::{CommonComponent, CommonComponentParts},
}; };
use anyhow::{bail, Error, Result}; use anyhow::{bail, Error, Result};
use gloo_file::{
callbacks::{read_as_bytes, FileReader},
File,
};
use graphql_client::GraphQLQuery; use graphql_client::GraphQLQuery;
use validator_derive::Validate; use validator_derive::Validate;
use wasm_bindgen::JsCast; use web_sys::{FileList, HtmlInputElement, InputEvent};
use yew::{prelude::*, services::ConsoleService}; use yew::prelude::*;
use yew_form_derive::Model; use yew_form_derive::Model;
#[derive(PartialEq, Eq, Clone, Default)] #[derive(Default)]
struct JsFile { struct JsFile {
file: Option<web_sys::File>, file: Option<File>,
contents: Option<Vec<u8>>, contents: Option<Vec<u8>>,
} }
@ -21,7 +25,7 @@ impl ToString for JsFile {
fn to_string(&self) -> String { fn to_string(&self) -> String {
self.file self.file
.as_ref() .as_ref()
.map(web_sys::File::name) .map(File::name)
.unwrap_or_else(String::new) .unwrap_or_else(String::new)
} }
} }
@ -64,17 +68,21 @@ pub struct UserDetailsForm {
common: CommonComponentParts<Self>, common: CommonComponentParts<Self>,
form: yew_form::Form<UserModel>, form: yew_form::Form<UserModel>,
avatar: JsFile, avatar: JsFile,
reader: Option<FileReader>,
/// 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,
user: User,
} }
pub enum Msg { pub enum Msg {
/// A form field changed. /// A form field changed.
Update, Update,
/// A new file was selected.
FileSelected(File),
/// The "Submit" button was clicked. /// The "Submit" button was clicked.
SubmitClicked, SubmitClicked,
/// A picked file finished loading. /// A picked file finished loading.
FileLoaded(yew::services::reader::FileData), FileLoaded(String, Result<Vec<u8>>),
/// We got the response from the server about our update message. /// We got the response from the server about our update message.
UserUpdated(Result<update_user::ResponseData>), UserUpdated(Result<update_user::ResponseData>),
} }
@ -86,53 +94,47 @@ pub struct Props {
} }
impl CommonComponent<UserDetailsForm> for UserDetailsForm { impl CommonComponent<UserDetailsForm> for UserDetailsForm {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool> {
match msg { match msg {
Msg::Update => { Msg::Update => Ok(true),
let window = web_sys::window().expect("no global `window` exists"); Msg::FileSelected(new_avatar) => {
let document = window.document().expect("should have a document on window"); if self.avatar.file.as_ref().map(|f| f.name()) != Some(new_avatar.name()) {
let input = document let file_name = new_avatar.name();
.get_element_by_id("avatarInput") let link = ctx.link().clone();
.expect("Form field avatarInput should be present") self.reader = Some(read_as_bytes(&new_avatar, move |res| {
.dyn_into::<web_sys::HtmlInputElement>() link.send_message(Msg::FileLoaded(
.expect("Should be an HtmlInputElement"); file_name,
ConsoleService::log("Form update"); res.map_err(|e| anyhow::anyhow!("{:#}", e)),
if let Some(files) = input.files() { ))
ConsoleService::log("Got file list"); }));
if files.length() > 0 { self.avatar = JsFile {
ConsoleService::log("Got a file"); file: Some(new_avatar),
let new_avatar = JsFile { contents: None,
file: files.item(0), };
contents: None,
};
if self.avatar.file.as_ref().map(|f| f.name())
!= new_avatar.file.as_ref().map(|f| f.name())
{
if let Some(ref file) = new_avatar.file {
self.mut_common().read_file(file.clone(), Msg::FileLoaded)?;
}
self.avatar = new_avatar;
}
}
} }
Ok(true) Ok(true)
} }
Msg::SubmitClicked => self.submit_user_update_form(), Msg::SubmitClicked => self.submit_user_update_form(ctx),
Msg::UserUpdated(response) => self.user_update_finished(response), Msg::UserUpdated(response) => self.user_update_finished(response),
Msg::FileLoaded(data) => { Msg::FileLoaded(file_name, data) => {
self.common.cancel_task();
if let Some(file) = &self.avatar.file { if let Some(file) = &self.avatar.file {
if file.name() == data.name { if file.name() == file_name {
if !is_valid_jpeg(data.content.as_slice()) { let data = data?;
if !is_valid_jpeg(data.as_slice()) {
// Clear the selection. // Clear the selection.
self.avatar = JsFile::default(); self.avatar = JsFile::default();
bail!("Chosen image is not a valid JPEG"); bail!("Chosen image is not a valid JPEG");
} else { } else {
self.avatar.contents = Some(data.content); self.avatar.contents = Some(data);
return Ok(true); return Ok(true);
} }
} }
} }
self.reader = None;
Ok(false) Ok(false)
} }
} }
@ -147,37 +149,36 @@ impl Component for UserDetailsForm {
type Message = Msg; type Message = Msg;
type Properties = Props; type Properties = Props;
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(ctx: &Context<Self>) -> Self {
let model = UserModel { let model = UserModel {
email: props.user.email.clone(), email: ctx.props().user.email.clone(),
display_name: props.user.display_name.clone(), display_name: ctx.props().user.display_name.clone(),
first_name: props.user.first_name.clone(), first_name: ctx.props().user.first_name.clone(),
last_name: props.user.last_name.clone(), last_name: ctx.props().user.last_name.clone(),
}; };
Self { Self {
common: CommonComponentParts::<Self>::create(props, link), common: CommonComponentParts::<Self>::create(),
form: yew_form::Form::new(model), form: yew_form::Form::new(model),
avatar: JsFile::default(), avatar: JsFile::default(),
just_updated: false, just_updated: false,
reader: None,
user: ctx.props().user.clone(),
} }
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
self.just_updated = false; self.just_updated = false;
CommonComponentParts::<Self>::update(self, msg) CommonComponentParts::<Self>::update(self, ctx, msg)
} }
fn change(&mut self, props: Self::Properties) -> ShouldRender { fn view(&self, ctx: &Context<Self>) -> Html {
self.common.change(props)
}
fn view(&self) -> Html {
type Field = yew_form::Field<UserModel>; type Field = yew_form::Field<UserModel>;
let link = &ctx.link();
let avatar_base64 = maybe_to_base64(&self.avatar).unwrap_or_default(); let avatar_base64 = maybe_to_base64(&self.avatar).unwrap_or_default();
let avatar_string = avatar_base64 let avatar_string = avatar_base64
.as_deref() .as_deref()
.or(self.common.user.avatar.as_deref()) .or(self.user.avatar.as_deref())
.unwrap_or(""); .unwrap_or("");
html! { html! {
<div class="py-3"> <div class="py-3">
@ -188,7 +189,7 @@ impl Component for UserDetailsForm {
{"User ID: "} {"User ID: "}
</label> </label>
<div class="col-8"> <div class="col-8">
<span id="userId" class="form-control-static"><i>{&self.common.user.id}</i></span> <span id="userId" class="form-control-static"><i>{&self.user.id}</i></span>
</div> </div>
</div> </div>
<div class="form-group row mb-3"> <div class="form-group row mb-3">
@ -197,7 +198,7 @@ impl Component for UserDetailsForm {
{"Creation date: "} {"Creation date: "}
</label> </label>
<div class="col-8"> <div class="col-8">
<span id="creationDate" class="form-control-static">{&self.common.user.creation_date.naive_local().date()}</span> <span id="creationDate" class="form-control-static">{&self.user.creation_date.naive_local().date()}</span>
</div> </div>
</div> </div>
<div class="form-group row mb-3"> <div class="form-group row mb-3">
@ -206,7 +207,7 @@ impl Component for UserDetailsForm {
{"UUID: "} {"UUID: "}
</label> </label>
<div class="col-8"> <div class="col-8">
<span id="creationDate" class="form-control-static">{&self.common.user.uuid}</span> <span id="creationDate" class="form-control-static">{&self.user.uuid}</span>
</div> </div>
</div> </div>
<div class="form-group row mb-3"> <div class="form-group row mb-3">
@ -221,10 +222,10 @@ impl Component for UserDetailsForm {
class="form-control" class="form-control"
class_invalid="is-invalid has-error" class_invalid="is-invalid has-error"
class_valid="has-success" class_valid="has-success"
form=&self.form form={&self.form}
field_name="email" field_name="email"
autocomplete="email" autocomplete="email"
oninput=self.common.callback(|_| Msg::Update) /> oninput={link.callback(|_| Msg::Update)} />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("email")} {&self.form.field_message("email")}
</div> </div>
@ -240,10 +241,10 @@ impl Component for UserDetailsForm {
class="form-control" class="form-control"
class_invalid="is-invalid has-error" class_invalid="is-invalid has-error"
class_valid="has-success" class_valid="has-success"
form=&self.form form={&self.form}
field_name="display_name" field_name="display_name"
autocomplete="name" autocomplete="name"
oninput=self.common.callback(|_| Msg::Update) /> oninput={link.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>
@ -257,10 +258,10 @@ impl Component for UserDetailsForm {
<div class="col-8"> <div class="col-8">
<Field <Field
class="form-control" class="form-control"
form=&self.form form={&self.form}
field_name="first_name" field_name="first_name"
autocomplete="given-name" autocomplete="given-name"
oninput=self.common.callback(|_| Msg::Update) /> oninput={link.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>
@ -274,10 +275,10 @@ impl Component for UserDetailsForm {
<div class="col-8"> <div class="col-8">
<Field <Field
class="form-control" class="form-control"
form=&self.form form={&self.form}
field_name="last_name" field_name="last_name"
autocomplete="family-name" autocomplete="family-name"
oninput=self.common.callback(|_| Msg::Update) /> oninput={link.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>
@ -296,7 +297,10 @@ impl Component for UserDetailsForm {
id="avatarInput" id="avatarInput"
type="file" type="file"
accept="image/jpeg" accept="image/jpeg"
oninput=self.common.callback(|_| Msg::Update) /> oninput={link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
Self::upload_files(input.files())
})} />
</div> </div>
<div class="col-4"> <div class="col-4">
<img <img
@ -312,8 +316,8 @@ impl Component for UserDetailsForm {
<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.common.is_task_running() disabled={self.common.is_task_running()}
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})> onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})}>
<i class="bi-save me-2"></i> <i class="bi-save me-2"></i>
{"Save changes"} {"Save changes"}
</button> </button>
@ -328,7 +332,7 @@ impl Component for UserDetailsForm {
} }
} else { html! {} } } else { html! {} }
} }
<div hidden=!self.just_updated> <div hidden={!self.just_updated}>
<div class="alert alert-success mt-4">{"User successfully updated!"}</div> <div class="alert alert-success mt-4">{"User successfully updated!"}</div>
</div> </div>
</div> </div>
@ -337,12 +341,10 @@ impl Component for UserDetailsForm {
} }
impl UserDetailsForm { impl UserDetailsForm {
fn submit_user_update_form(&mut self) -> Result<bool> { fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
ConsoleService::log("Submit");
if !self.form.validate() { if !self.form.validate() {
bail!("Invalid inputs"); bail!("Invalid inputs");
} }
ConsoleService::log("Valid inputs");
if let JsFile { if let JsFile {
file: Some(_), file: Some(_),
contents: None, contents: None,
@ -350,10 +352,9 @@ impl UserDetailsForm {
{ {
bail!("Image file hasn't finished loading, try again"); bail!("Image file hasn't finished loading, try again");
} }
ConsoleService::log("File is correctly loaded"); let base_user = &self.user;
let base_user = &self.common.user;
let mut user_input = update_user::UpdateUserInput { let mut user_input = update_user::UpdateUserInput {
id: self.common.user.id.clone(), id: self.user.id.clone(),
email: None, email: None,
displayName: None, displayName: None,
firstName: None, firstName: None,
@ -378,12 +379,11 @@ impl UserDetailsForm {
user_input.avatar = maybe_to_base64(&self.avatar)?; user_input.avatar = maybe_to_base64(&self.avatar)?;
// Nothing changed. // Nothing changed.
if user_input == default_user_input { if user_input == default_user_input {
ConsoleService::log("No changes");
return Ok(false); return Ok(false);
} }
let req = update_user::Variables { user: user_input }; let req = update_user::Variables { user: user_input };
ConsoleService::log("Querying");
self.common.call_graphql::<UpdateUser, _>( self.common.call_graphql::<UpdateUser, _>(
ctx,
req, req,
Msg::UserUpdated, Msg::UserUpdated,
"Error trying to update user", "Error trying to update user",
@ -392,23 +392,30 @@ impl UserDetailsForm {
} }
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.common.cancel_task(); r?;
match r { let model = self.form.model();
Err(e) => return Err(e), self.user.email = model.email;
Ok(_) => { self.user.display_name = model.display_name;
let model = self.form.model(); self.user.first_name = model.first_name;
self.common.user.email = model.email; self.user.last_name = model.last_name;
self.common.user.display_name = model.display_name; if let Some(avatar) = maybe_to_base64(&self.avatar)? {
self.common.user.first_name = model.first_name; self.user.avatar = Some(avatar);
self.common.user.last_name = model.last_name; }
if let Some(avatar) = maybe_to_base64(&self.avatar)? { self.just_updated = true;
self.common.user.avatar = Some(avatar);
}
self.just_updated = true;
}
};
Ok(true) Ok(true)
} }
fn upload_files(files: Option<FileList>) -> Msg {
if let Some(files) = files {
if files.length() > 0 {
Msg::FileSelected(File::from(files.item(0).unwrap()))
} else {
Msg::Update
}
} else {
Msg::Update
}
}
} }
fn is_valid_jpeg(bytes: &[u8]) -> bool { fn is_valid_jpeg(bytes: &[u8]) -> bool {

View File

@ -34,7 +34,7 @@ pub enum Msg {
} }
impl CommonComponent<UserTable> for UserTable { impl CommonComponent<UserTable> for UserTable {
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> { fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
match msg { match msg {
Msg::ListUsersResponse(users) => { Msg::ListUsersResponse(users) => {
self.users = Some(users?.users.into_iter().collect()); self.users = Some(users?.users.into_iter().collect());
@ -55,8 +55,9 @@ impl CommonComponent<UserTable> for UserTable {
} }
impl UserTable { impl UserTable {
fn get_users(&mut self, req: Option<RequestFilter>) { fn get_users(&mut self, ctx: &Context<Self>, req: Option<RequestFilter>) {
self.common.call_graphql::<ListUsersQuery, _>( self.common.call_graphql::<ListUsersQuery, _>(
ctx,
list_users_query::Variables { filters: req }, list_users_query::Variables { filters: req },
Msg::ListUsersResponse, Msg::ListUsersResponse,
"Error trying to fetch users", "Error trying to fetch users",
@ -68,27 +69,23 @@ impl Component for UserTable {
type Message = Msg; type Message = Msg;
type Properties = (); type Properties = ();
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self { fn create(ctx: &Context<Self>) -> Self {
let mut table = UserTable { let mut table = UserTable {
common: CommonComponentParts::<Self>::create(props, link), common: CommonComponentParts::<Self>::create(),
users: None, users: None,
}; };
table.get_users(None); table.get_users(ctx, None);
table table
} }
fn update(&mut self, msg: Self::Message) -> ShouldRender { fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, msg) CommonComponentParts::<Self>::update(self, ctx, msg)
} }
fn change(&mut self, props: Self::Properties) -> ShouldRender { fn view(&self, ctx: &Context<Self>) -> Html {
self.common.change(props)
}
fn view(&self) -> Html {
html! { html! {
<div> <div>
{self.view_users()} {self.view_users(ctx)}
{self.view_errors()} {self.view_errors()}
</div> </div>
} }
@ -96,7 +93,7 @@ impl Component for UserTable {
} }
impl UserTable { impl UserTable {
fn view_users(&self) -> Html { fn view_users(&self, ctx: &Context<Self>) -> Html {
let make_table = |users: &Vec<User>| { let make_table = |users: &Vec<User>| {
html! { html! {
<div class="table-responsive"> <div class="table-responsive">
@ -113,7 +110,7 @@ impl UserTable {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{users.iter().map(|u| self.view_user(u)).collect::<Vec<_>>()} {users.iter().map(|u| self.view_user(ctx, u)).collect::<Vec<_>>()}
</tbody> </tbody>
</table> </table>
</div> </div>
@ -125,10 +122,11 @@ impl UserTable {
} }
} }
fn view_user(&self, user: &User) -> Html { fn view_user(&self, ctx: &Context<Self>, user: &User) -> Html {
let link = &ctx.link();
html! { html! {
<tr key=user.id.clone()> <tr key={user.id.clone()}>
<td><Link route=AppRoute::UserDetails(user.id.clone())>{&user.id}</Link></td> <td><Link to={AppRoute::UserDetails{user_id: user.id.clone()}}>{&user.id}</Link></td>
<td>{&user.email}</td> <td>{&user.email}</td>
<td>{&user.display_name}</td> <td>{&user.display_name}</td>
<td>{&user.first_name}</td> <td>{&user.first_name}</td>
@ -136,9 +134,9 @@ impl UserTable {
<td>{&user.creation_date.naive_local().date()}</td> <td>{&user.creation_date.naive_local().date()}</td>
<td> <td>
<DeleteUser <DeleteUser
username=user.id.clone() username={user.id.clone()}
on_user_deleted=self.common.callback(Msg::OnUserDeleted) on_user_deleted={link.callback(Msg::OnUserDeleted)}
on_error=self.common.callback(Msg::OnError)/> on_error={link.callback(Msg::OnError)}/>
</td> </td>
</tr> </tr>
} }

View File

@ -1,136 +1,84 @@
use super::cookies::set_cookie; use super::cookies::set_cookie;
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use gloo_net::http::{Method, Request};
use graphql_client::GraphQLQuery; use graphql_client::GraphQLQuery;
use lldap_auth::{login, registration, JWTClaims}; use lldap_auth::{login, registration, JWTClaims};
use yew::callback::Callback; use serde::{de::DeserializeOwned, Serialize};
use yew::format::Json; use web_sys::RequestCredentials;
use yew::services::fetch::{Credentials, FetchOptions, FetchService, FetchTask, Request, Response};
#[derive(Default)] #[derive(Default)]
pub struct HostService {} pub struct HostService {}
fn get_default_options() -> FetchOptions {
FetchOptions {
credentials: Some(Credentials::SameOrigin),
..FetchOptions::default()
}
}
fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> { fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> {
use jwt::*; use jwt::*;
let token = Token::<header::Header, JWTClaims, token::Unverified>::parse_unverified(jwt)?; let token = Token::<header::Header, JWTClaims, token::Unverified>::parse_unverified(jwt)?;
Ok(token.claims().clone()) Ok(token.claims().clone())
} }
fn create_handler<Resp, CallbackResult, F>( const NO_BODY: Option<()> = None;
callback: Callback<Result<CallbackResult>>,
handler: F,
) -> Callback<Response<Result<Resp>>>
where
F: Fn(http::StatusCode, Resp) -> Result<CallbackResult> + 'static,
CallbackResult: 'static,
{
Callback::once(move |response: Response<Result<Resp>>| {
let (meta, maybe_data) = response.into_parts();
let message = maybe_data
.context("Could not reach server")
.and_then(|data| handler(meta.status, data));
callback.emit(message)
})
}
struct RequestBody<T>(T); async fn call_server(
url: &str,
impl<'a, R> From<&'a R> for RequestBody<Json<&'a R>> body: Option<impl Serialize>,
where error_message: &'static str,
R: serde::ser::Serialize, ) -> Result<String> {
{ let mut request = Request::new(url)
fn from(request: &'a R) -> Self { .header("Content-Type", "application/json")
Self(Json(request)) .credentials(RequestCredentials::SameOrigin);
if let Some(b) = body {
request = request
.body(serde_json::to_string(&b)?)
.method(Method::POST);
}
let response = request.send().await?;
if response.ok() {
Ok(response.text().await?)
} else {
Err(anyhow!(
"{}[{} {}]: {}",
error_message,
response.status(),
response.status_text(),
response.text().await?
))
} }
} }
impl From<yew::format::Nothing> for RequestBody<yew::format::Nothing> { async fn call_server_json_with_error_message<CallbackResult, Body: Serialize>(
fn from(request: yew::format::Nothing) -> Self { url: &str,
Self(request) request: Option<Body>,
} error_message: &'static str,
) -> Result<CallbackResult>
where
CallbackResult: DeserializeOwned + 'static,
{
let data = call_server(url, request, error_message).await?;
serde_json::from_str(&data).context("Could not parse response")
} }
fn call_server<Req, CallbackResult, F, RB>( async fn call_server_empty_response_with_error_message<Body: Serialize>(
url: &str, url: &str,
request: RB, request: Option<Body>,
callback: Callback<Result<CallbackResult>>,
error_message: &'static str, error_message: &'static str,
parse_response: F, ) -> Result<()> {
) -> Result<FetchTask> call_server(url, request, error_message).await.map(|_| ())
where
F: Fn(String) -> Result<CallbackResult> + 'static,
CallbackResult: 'static,
RB: Into<RequestBody<Req>>,
Req: Into<yew::format::Text>,
{
let request = {
// If the request type is empty (if the size is 0), it's a get.
if std::mem::size_of::<RB>() == 0 {
Request::get(url)
} else {
Request::post(url)
}
}
.header("Content-Type", "application/json")
.body(request.into().0)?;
let handler = create_handler(callback, move |status: http::StatusCode, data: String| {
if status.is_success() {
parse_response(data)
} else {
Err(anyhow!("{}[{}]: {}", error_message, status, data))
}
});
FetchService::fetch_with_options(request, get_default_options(), handler)
} }
fn call_server_json_with_error_message<CallbackResult, RB, Req>( fn set_cookies_from_jwt(response: login::ServerLoginResponse) -> Result<(String, bool)> {
url: &str, let jwt_claims = get_claims_from_jwt(response.token.as_str()).context("Could not parse JWT")?;
request: RB, let is_admin = jwt_claims.groups.contains("lldap_admin");
callback: Callback<Result<CallbackResult>>, set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
error_message: &'static str, .map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
) -> Result<FetchTask> .map(|_| (jwt_claims.user.clone(), is_admin))
where .context("Error setting cookie")
CallbackResult: serde::de::DeserializeOwned + 'static,
RB: Into<RequestBody<Req>>,
Req: Into<yew::format::Text>,
{
call_server(url, request, callback, error_message, |data: String| {
serde_json::from_str(&data).context("Could not parse response")
})
}
fn call_server_empty_response_with_error_message<RB, Req>(
url: &str,
request: RB,
callback: Callback<Result<()>>,
error_message: &'static str,
) -> Result<FetchTask>
where
RB: Into<RequestBody<Req>>,
Req: Into<yew::format::Text>,
{
call_server(
url,
request,
callback,
error_message,
|_data: String| Ok(()),
)
} }
impl HostService { impl HostService {
pub fn graphql_query<QueryType>( pub async fn graphql_query<QueryType>(
variables: QueryType::Variables, variables: QueryType::Variables,
callback: Callback<Result<QueryType::ResponseData>>,
error_message: &'static str, error_message: &'static str,
) -> Result<FetchTask> ) -> Result<QueryType::ResponseData>
where where
QueryType: GraphQLQuery + 'static, QueryType: GraphQLQuery + 'static,
{ {
@ -147,143 +95,103 @@ impl HostService {
) )
}) })
}; };
let parse_graphql_response = move |data: String| {
serde_json::from_str(&data)
.context("Could not parse response")
.and_then(unwrap_graphql_response)
};
let request_body = QueryType::build_query(variables); let request_body = QueryType::build_query(variables);
call_server( call_server_json_with_error_message::<graphql_client::Response<_>, _>(
"/api/graphql", "/api/graphql",
&request_body, Some(request_body),
callback,
error_message, error_message,
parse_graphql_response,
) )
.await
.and_then(unwrap_graphql_response)
} }
pub fn login_start( pub async fn login_start(
request: login::ClientLoginStartRequest, request: login::ClientLoginStartRequest,
callback: Callback<Result<Box<login::ServerLoginStartResponse>>>, ) -> Result<Box<login::ServerLoginStartResponse>> {
) -> Result<FetchTask> {
call_server_json_with_error_message( call_server_json_with_error_message(
"/auth/opaque/login/start", "/auth/opaque/login/start",
&request, Some(request),
callback,
"Could not start authentication: ", "Could not start authentication: ",
) )
.await
} }
pub fn login_finish( pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
request: login::ClientLoginFinishRequest, call_server_json_with_error_message::<login::ServerLoginResponse, _>(
callback: Callback<Result<(String, bool)>>,
) -> Result<FetchTask> {
let set_cookies = |jwt_claims: JWTClaims| {
let is_admin = jwt_claims.groups.contains("lldap_admin");
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
.map(|_| (jwt_claims.user.clone(), is_admin))
.context("Error clearing cookie")
};
let parse_token = move |data: String| {
serde_json::from_str::<login::ServerLoginResponse>(&data)
.context("Could not parse response")
.and_then(|r| {
get_claims_from_jwt(r.token.as_str())
.context("Could not parse response")
.and_then(set_cookies)
})
};
call_server(
"/auth/opaque/login/finish", "/auth/opaque/login/finish",
&request, Some(request),
callback,
"Could not finish authentication", "Could not finish authentication",
parse_token,
) )
.await
.and_then(set_cookies_from_jwt)
} }
pub fn register_start( pub async fn register_start(
request: registration::ClientRegistrationStartRequest, request: registration::ClientRegistrationStartRequest,
callback: Callback<Result<Box<registration::ServerRegistrationStartResponse>>>, ) -> Result<Box<registration::ServerRegistrationStartResponse>> {
) -> Result<FetchTask> {
call_server_json_with_error_message( call_server_json_with_error_message(
"/auth/opaque/register/start", "/auth/opaque/register/start",
&request, Some(request),
callback,
"Could not start registration: ", "Could not start registration: ",
) )
.await
} }
pub fn register_finish( pub async fn register_finish(
request: registration::ClientRegistrationFinishRequest, request: registration::ClientRegistrationFinishRequest,
callback: Callback<Result<()>>, ) -> Result<()> {
) -> Result<FetchTask> {
call_server_empty_response_with_error_message( call_server_empty_response_with_error_message(
"/auth/opaque/register/finish", "/auth/opaque/register/finish",
&request, Some(request),
callback,
"Could not finish registration", "Could not finish registration",
) )
.await
} }
pub fn refresh(_request: (), callback: Callback<Result<(String, bool)>>) -> Result<FetchTask> { pub async fn refresh() -> Result<(String, bool)> {
let set_cookies = |jwt_claims: JWTClaims| { call_server_json_with_error_message::<login::ServerLoginResponse, _>(
let is_admin = jwt_claims.groups.contains("lldap_admin");
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
.map(|_| (jwt_claims.user.clone(), is_admin))
.context("Error clearing cookie")
};
let parse_token = move |data: String| {
serde_json::from_str::<login::ServerLoginResponse>(&data)
.context("Could not parse response")
.and_then(|r| {
get_claims_from_jwt(r.token.as_str())
.context("Could not parse response")
.and_then(set_cookies)
})
};
call_server(
"/auth/refresh", "/auth/refresh",
yew::format::Nothing, NO_BODY,
callback,
"Could not start authentication: ", "Could not start authentication: ",
parse_token,
) )
.await
.and_then(set_cookies_from_jwt)
} }
// The `_request` parameter is to make it the same shape as the other functions. // The `_request` parameter is to make it the same shape as the other functions.
pub fn logout(_request: (), callback: Callback<Result<()>>) -> Result<FetchTask> { pub async fn logout() -> Result<()> {
call_server_empty_response_with_error_message( call_server_empty_response_with_error_message("/auth/logout", NO_BODY, "Could not logout")
"/auth/logout", .await
yew::format::Nothing,
callback,
"Could not logout",
)
} }
pub fn reset_password_step1( pub async fn reset_password_step1(username: String) -> Result<()> {
username: &str,
callback: Callback<Result<()>>,
) -> Result<FetchTask> {
call_server_empty_response_with_error_message( call_server_empty_response_with_error_message(
&format!("/auth/reset/step1/{}", url_escape::encode_query(username)), &format!("/auth/reset/step1/{}", url_escape::encode_query(&username)),
yew::format::Nothing, NO_BODY,
callback,
"Could not initiate password reset", "Could not initiate password reset",
) )
.await
} }
pub fn reset_password_step2( pub async fn reset_password_step2(
token: &str, token: String,
callback: Callback<Result<lldap_auth::password_reset::ServerPasswordResetResponse>>, ) -> Result<lldap_auth::password_reset::ServerPasswordResetResponse> {
) -> Result<FetchTask> {
call_server_json_with_error_message( call_server_json_with_error_message(
&format!("/auth/reset/step2/{}", token), &format!("/auth/reset/step2/{}", token),
yew::format::Nothing, NO_BODY,
callback,
"Could not validate token", "Could not validate token",
) )
.await
}
pub async fn probe_password_reset() -> Result<bool> {
Ok(
gloo_net::http::Request::get("/auth/reset/step1/lldap_unlikely_very_long_user_name")
.header("Content-Type", "application/json")
.send()
.await?
.status()
!= http::StatusCode::NOT_FOUND,
)
} }
} }

View File

@ -21,88 +21,62 @@
//! [`CommonComponentParts::update`]. This will in turn call [`CommonComponent::handle_msg`] and //! [`CommonComponentParts::update`]. This will in turn call [`CommonComponent::handle_msg`] and
//! take care of error and task handling. //! take care of error and task handling.
use std::{
future::Future,
marker::PhantomData,
sync::{Arc, Mutex},
};
use crate::infra::api::HostService; use crate::infra::api::HostService;
use anyhow::{Error, Result}; use anyhow::{Error, Result};
use gloo_console::error;
use graphql_client::GraphQLQuery; use graphql_client::GraphQLQuery;
use yew::{ use yew::prelude::*;
prelude::*,
services::{
fetch::FetchTask,
reader::{FileData, ReaderService, ReaderTask},
ConsoleService,
},
};
use yewtil::NeqAssign;
/// Trait required for common components. /// Trait required for common components.
pub trait CommonComponent<C: Component + CommonComponent<C>>: Component { pub trait CommonComponent<C: Component + CommonComponent<C>>: Component {
/// Handle the incoming message. If an error is returned here, any running task will be /// 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 /// cancelled, the error will be written to the [`CommonComponentParts::error`] and the
/// component will be refreshed. /// component will be refreshed.
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool>; fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> Result<bool>;
/// Get a mutable reference to the inner component parts, necessary for the CRTP. /// Get a mutable reference to the inner component parts, necessary for the CRTP.
fn mut_common(&mut self) -> &mut CommonComponentParts<C>; fn mut_common(&mut self) -> &mut CommonComponentParts<C>;
} }
enum AnyTask {
None,
FetchTask(FetchTask),
ReaderTask(ReaderTask),
}
impl AnyTask {
fn is_some(&self) -> bool {
!matches!(self, AnyTask::None)
}
}
impl From<Option<FetchTask>> for AnyTask {
fn from(task: Option<FetchTask>) -> Self {
match task {
Some(t) => AnyTask::FetchTask(t),
None => AnyTask::None,
}
}
}
/// Structure that contains the common parts needed by most components. /// Structure that contains the common parts needed by most components.
/// The fields of [`props`] are directly accessible through a `Deref` implementation. /// The fields of [`props`] are directly accessible through a `Deref` implementation.
pub struct CommonComponentParts<C: CommonComponent<C>> { pub struct CommonComponentParts<C: CommonComponent<C>> {
link: ComponentLink<C>,
pub props: <C as Component>::Properties,
pub error: Option<Error>, pub error: Option<Error>,
task: AnyTask, is_task_running: Arc<Mutex<bool>>,
_phantom: PhantomData<C>,
} }
impl<C: CommonComponent<C>> CommonComponentParts<C> { impl<C: CommonComponent<C>> CommonComponentParts<C> {
pub fn create() -> Self {
CommonComponentParts {
error: None,
is_task_running: Arc::new(Mutex::new(false)),
_phantom: PhantomData::<C>,
}
}
/// Whether there is a currently running task in the background. /// Whether there is a currently running task in the background.
pub fn is_task_running(&self) -> bool { pub fn is_task_running(&self) -> bool {
self.task.is_some() *self.is_task_running.lock().unwrap()
}
/// Cancel any background task.
pub fn cancel_task(&mut self) {
self.task = AnyTask::None;
}
pub fn create(props: <C as Component>::Properties, link: ComponentLink<C>) -> Self {
Self {
link,
props,
error: None,
task: AnyTask::None,
}
} }
/// This should be called from the [`yew::prelude::Component::update`]: it will in turn call /// This should be called from the [`yew::prelude::Component::update`]: it will in turn call
/// [`CommonComponent::handle_msg`] and handle any resulting error. /// [`CommonComponent::handle_msg`] and handle any resulting error.
pub fn update(com: &mut C, msg: <C as Component>::Message) -> ShouldRender { pub fn update(com: &mut C, ctx: &Context<C>, msg: <C as Component>::Message) -> bool {
com.mut_common().error = None; com.mut_common().error = None;
match com.handle_msg(msg) { match com.handle_msg(ctx, msg) {
Err(e) => { Err(e) => {
ConsoleService::error(&e.to_string()); error!(&e.to_string());
com.mut_common().error = Some(e); com.mut_common().error = Some(e);
com.mut_common().cancel_task(); assert!(!*com.mut_common().is_task_running.lock().unwrap());
true true
} }
Ok(b) => b, Ok(b) => b,
@ -112,10 +86,11 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
/// Same as above, but the resulting error is instead passed to the reporting function. /// Same as above, but the resulting error is instead passed to the reporting function.
pub fn update_and_report_error( pub fn update_and_report_error(
com: &mut C, com: &mut C,
ctx: &Context<C>,
msg: <C as Component>::Message, msg: <C as Component>::Message,
report_fn: Callback<Error>, report_fn: Callback<Error>,
) -> ShouldRender { ) -> bool {
let should_render = Self::update(com, msg); let should_render = Self::update(com, ctx, msg);
com.mut_common() com.mut_common()
.error .error
.take() .take()
@ -126,38 +101,24 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
.unwrap_or(should_render) .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 /// Call `method` from the backend with the given `request`, and pass the `callback` for the
/// result. Returns whether _starting the call_ failed. /// result.
pub fn call_backend<M, Req, Cb, Resp>( pub fn call_backend<Fut, Cb, Resp>(&mut self, ctx: &Context<C>, fut: Fut, callback: Cb)
&mut self,
method: M,
req: Req,
callback: Cb,
) -> Result<()>
where where
M: Fn(Req, Callback<Resp>) -> Result<FetchTask>, Fut: Future<Output = Resp> + 'static,
Cb: FnOnce(Resp) -> <C as Component>::Message + 'static, Cb: FnOnce(Resp) -> <C as Component>::Message + 'static,
{ {
self.task = AnyTask::FetchTask(method(req, self.link.callback_once(callback))?); {
Ok(()) let mut running = self.is_task_running.lock().unwrap();
assert!(!*running);
*running = true;
}
let is_task_running = self.is_task_running.clone();
ctx.link().send_future(async move {
let res = fut.await;
*is_task_running.lock().unwrap() = false;
callback(res)
});
} }
/// Call the backend with a GraphQL query. /// Call the backend with a GraphQL query.
@ -165,6 +126,7 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
/// `EnumCallback` should usually be left as `_`. /// `EnumCallback` should usually be left as `_`.
pub fn call_graphql<QueryType, EnumCallback>( pub fn call_graphql<QueryType, EnumCallback>(
&mut self, &mut self,
ctx: &Context<C>,
variables: QueryType::Variables, variables: QueryType::Variables,
enum_callback: EnumCallback, enum_callback: EnumCallback,
error_message: &'static str, error_message: &'static str,
@ -172,41 +134,10 @@ impl<C: CommonComponent<C>> CommonComponentParts<C> {
QueryType: GraphQLQuery + 'static, QueryType: GraphQLQuery + 'static,
EnumCallback: Fn(Result<QueryType::ResponseData>) -> <C as Component>::Message + 'static, EnumCallback: Fn(Result<QueryType::ResponseData>) -> <C as Component>::Message + 'static,
{ {
self.task = HostService::graphql_query::<QueryType>( self.call_backend(
variables, ctx,
self.link.callback(enum_callback), HostService::graphql_query::<QueryType>(variables, error_message),
error_message, enum_callback,
) );
.map_err::<(), _>(|e| {
ConsoleService::log(&e.to_string());
self.error = Some(e);
})
.ok()
.into();
}
pub(crate) fn read_file<Cb>(&mut self, file: web_sys::File, callback: Cb) -> Result<()>
where
Cb: FnOnce(FileData) -> <C as Component>::Message + 'static,
{
self.task = AnyTask::ReaderTask(ReaderService::read_file(
file,
self.link.callback_once(callback),
)?);
Ok(())
}
}
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,16 +1,16 @@
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "bootstrap")] #[wasm_bindgen]
extern "C" { extern "C" {
#[wasm_bindgen] #[wasm_bindgen(js_namespace = bootstrap)]
pub type Modal; pub type Modal;
#[wasm_bindgen(constructor)] #[wasm_bindgen(constructor, js_namespace = bootstrap)]
pub fn new(e: web_sys::Element) -> Modal; pub fn new(e: web_sys::Element) -> Modal;
#[wasm_bindgen(method)] #[wasm_bindgen(method, js_namespace = bootstrap)]
pub fn show(this: &Modal); pub fn show(this: &Modal);
#[wasm_bindgen(method)] #[wasm_bindgen(method, js_namespace = bootstrap)]
pub fn hide(this: &Modal); pub fn hide(this: &Modal);
} }

View File

@ -1,6 +1,8 @@
#![recursion_limit = "256"] #![recursion_limit = "256"]
#![forbid(non_ascii_idents)] #![forbid(non_ascii_idents)]
#![allow(clippy::nonstandard_macro_braces)] #![allow(clippy::uninlined_format_args)]
#![allow(clippy::let_unit_value)]
pub mod components; pub mod components;
pub mod infra; pub mod infra;
@ -8,7 +10,7 @@ use wasm_bindgen::prelude::{wasm_bindgen, JsValue};
#[wasm_bindgen] #[wasm_bindgen]
pub fn run_app() -> Result<(), JsValue> { pub fn run_app() -> Result<(), JsValue> {
yew::start_app::<components::app::App>(); yew::start_app::<components::app::AppContainer>();
Ok(()) Ok(())
} }

View File

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

View File

@ -1,4 +1,4 @@
import init, { run_app } from './pkg/lldap_app.js'; import init, { run_app } from '/pkg/lldap_app.js';
async function main() { async function main() {
await init('/pkg/lldap_app_bg.wasm'); await init('/pkg/lldap_app_bg.wasm');
run_app(); run_app();

View File

@ -10,3 +10,23 @@ header h2 {
font-weight: 700; font-weight: 700;
text-decoration: none; text-decoration: none;
} }
html.dark .bg-light {
background-color: rgba(59,59,59,1) !important;
}
html.dark a {
color: #e1e1e1
}
a {
color: #212529
}
html.dark .nav-link {
color: #e1e1e1
}
.nav-link {
color: #212529
}

BIN
docs/cookie.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View File

@ -0,0 +1,91 @@
# Migration
Existing servers can migrate from one database backend to another. This page includes guidance for migrating from SQLite - similar concepts apply when migrating from databases of other types.
NOTE: [pgloader](https://github.com/dimitri/pgloader) is a tool that can easily migrate to PostgreSQL from other databases. Consider it if your target database is PostgreSQL
The process is as follows:
1. Create empty schema on target database
2. Stop/pause LLDAP and dump existing values
3. Sanitize for target DB (not always required)
4. Insert data into target
5. Change LLDAP config to new target and restart
The steps below assume you already have PostgreSQL or MySQL set up with an empty database for LLDAP to use.
## Create schema on target
LLDAP has a command that will connect to a target database and initialize the
schema. If running with docker, run the following command to use your active
instance (this has the benefit of ensuring your container has access):
```
docker exec -it <LLDAP container name> /app/lldap create_schema -d <Target database url>
```
If it succeeds, you can proceed to the next step.
## Create a dump of existing data
We want to dump (almost) all existing values to some file - the exception being the `metadata` table. Be sure to stop/pause LLDAP during this step, as some
databases (SQLite in this example) will give an error if LLDAP is in the middle of a write. The dump should consist just INSERT
statements. There are various ways to do this, but a simple enough way is filtering a
whole database dump. For example:
```
sqlite3 /path/to/lldap/config/users.db .dump | grep "^INSERT" | grep -v "^INSERT INTO metadata" > /path/to/dump.sql
```
## Sanitize data
Some databases might use different formats for some data - for example, PostgreSQL uses
a different syntax for hex strings than SQLite. We also want to make sure inserts are done in
a transaction in case one of the statements fail.
### To PostgreSQL
PostgreSQL uses a different hex string format. The command below should switch SQLite
format to PostgreSQL format, and wrap it all in a transaction:
```
sed -i -r -e "s/X'([[:xdigit:]]+'[^'])/'\\\x\\1/g" \
-e '1s/^/BEGIN;\n/' \
-e '$aCOMMIT;' /path/to/dump.sql
```
### To MySQL
MySQL mostly cooperates, but it gets some errors if you don't escape the `groups` table. Run the
following command to wrap all table names in backticks for good measure, and wrap the inserts in
a transaction:
```
sed -i -r -e 's/^INSERT INTO ([a-zA-Z0-9_]+) /INSERT INTO `\1` /' \
-e '1s/^/START TRANSACTION;\n/' \
-e '$aCOMMIT;' /path/to/dump.sql
```
## Insert data
Insert the data generated from the previous step into the target database. If you encounter errors,
you may need to manually tweak your dump, or make changed in LLDAP and recreate the dump.
### PostgreSQL
`psql -d <database> -U <username> -W < /path/to/dump.sql`
or
`psql -d <database> -U <username> -W -f /path/to/dump.sql`
### MySQL
`mysql -u <username> -p <database> < /path/to/dump.sql`
## Switch to new database
Modify your `database_url` in `lldap_config.toml` (or `LLDAP_DATABASE_URL` in the env)
to point to your new database (the same value used when generating schema). Restart
LLDAP and check the logs to ensure there were no errors.

90
docs/scripting.md Normal file
View File

@ -0,0 +1,90 @@
# Scripting
Programmatically accessing LLDAP can be done either through the LDAP protocol,
or via the GraphQL API.
## LDAP
Most _read-only_ queries about users and groups are supported. Anything not
supported would be considered a missing feature or a bug.
Most _modification_ queries are not supported, except for creating users and
changing the password (through the extended password operation). Those could be
added in the future, on a case-by-case basis.
Most _meta_-queries about the LDAP server itself are not supported and are out
of scope. That includes anything that touches the schema, for instance. LLDAP
still supports basic RootDSE queries.
Anonymous bind is not supported.
## GraphQL
The best way to interact with LLDAP programmatically is via the GraphQL
interface. You can use any language that has a GraphQL library (most of them
do), and use the [GraphQL Schema](../schema.graphql) to guide your queries.
### Getting a token
You'll need a JWT (authentication token) to issue GraphQL queries. Your view of
the system will be limited by the rights of your user. In particular, regular
users can only see themselves and the groups they belong to (but not other
members of these groups, for instance).
#### Manually
Log in to the web front-end of LLDAP. Then open the developer tools (F12), find
the "Storage > Cookies", and you'll find the "token" cookie with your JWT.
![Cookies menu with a JWT](cookie.png)
#### Automatically
The easiest way is to send a json POST request to `/auth/simple/login` with
`{"username": "john", "password": "1234"}` in the body.
Then you'll receive a JSON response with:
```
{
"token": "eYbat...",
"refreshToken": "3bCka...",
}
```
### Using the token
You can use the token directly, either as a cookie, or as a bearer auth token
(add an "Authorization" header with contents `"Bearer <token>"`).
The JWT is valid for 1 day (unless you log out explicitly).
You can use the refresh token to query `/auth/refresh` and get another JWT. The
refresh token is valid for 30 days.
### Testing your GraphQL queries
You can go to `/api/graphql/playground` to test your queries and explore the
data in the playground. You'll need to provide the JWT in the headers:
```
{ "Authorization": "Bearer abcdef123..." }
```
Then you can enter your query, for instance:
```graphql
{
user(userId:"admin") {
displayName
}
groups {
id
displayName
users {
id
email
}
}
}
```
The schema is on the right, along with some basic docs.

View File

@ -0,0 +1,105 @@
# Name
```
lldap
```
# Slug
```
lldap
```
- [x] Enabled
- [x] Sync Users
- [x] User password writeback
- [x] Sync groups
# Connection settings
## Server URI
```
ldap://lldap:3890
```
- [ ] Enable StartTLS
## TLS Verification Certificate
```
---------
```
## Bind CN
```
uid=admin,ou=people,dc=example,dc=com
```
## Bind Password
```
ADMIN_PASSWORD
```
## Base DN
```
dc=example,dc=com
```
# LDAP Attribute mapping
## User Property Mappings
- [x] authentik default LDAP Mapping: mail
- [x] authentik default LDAP Mapping: Name
- [x] authentik default Active Directory Mapping: givenName
- [ ] authentik default Active Directory Mapping: sAMAccountName
- [x] authentik default Active Directory Mapping: sn
- [ ] authentik default Active Directory Mapping: userPrincipalName
- [x] authentik default OpenLDAP Mapping: cn
- [x] authentik default OpenLDAP Mapping: uid
## Group Property Mappings
- [ ] authentik default LDAP Mapping: mail
- [ ] authentik default LDAP Mapping: Name
- [ ] authentik default Active Directory Mapping: givenName
- [ ] authentik default Active Directory Mapping: sAMAccountName
- [ ] authentik default Active Directory Mapping: sn
- [ ] authentik default Active Directory Mapping: userPrincipalName
- [x] authentik default OpenLDAP Mapping: cn
- [ ] authentik default OpenLDAP Mapping: uid
# Additional settings
## Group
```
---------
```
## User path
```
LDAP/users
```
## Addition User DN
```
ou=people
```
## Addition Group DN
```
ou=groups
```
## User object filter
```
(objectClass=person)
```
## Group object filter
```
(objectClass=groupOfUniqueNames)
```
## Group membership field
```
member
```
## Object uniqueness field
```
uid
```

View File

@ -0,0 +1,32 @@
# lldap configuration:
# LLDAP_LDAP_BASE_DN: dc=example,dc=com
# ##############################
# rest of the Dex options
# ##############################
connectors:
- type: ldap
id: ldap
name: LDAP
config:
host: lldap-host # make sure it does not start with `ldap://`
port: 3890 # or 6360 if you have ldaps enabled
insecureNoSSL: true # or false if you have ldaps enabled
insecureSkipVerify: true # or false if you have ldaps enabled
bindDN: uid=admin,ou=people,dc=example,dc=com # replace admin with your admin user
bindPW: very-secure-password # replace with your admin password
userSearch:
baseDN: ou=people,dc=example,dc=com
username: uid
idAttr: uid
emailAttr: mail
nameAttr: displayName
preferredUsernameAttr: uid
groupSearch:
baseDN: ou=groups,dc=example,dc=com
filter: "(objectClass=groupOfUniqueNames)"
userMatchers:
- userAttr: DN
groupAttr: member
nameAttr: cn

View File

@ -1,4 +1,4 @@
# Configuration for Gitea # Configuration for Gitea (& Forgejo)
In Gitea, go to `Site Administration > Authentication Sources` and click `Add Authentication Source` In Gitea, go to `Site Administration > Authentication Sources` and click `Add Authentication Source`
Select `LDAP (via BindDN)` Select `LDAP (via BindDN)`
@ -14,9 +14,36 @@ To log in they can either use their email address or user name. If you only want
For more info on the user filter, see: https://docs.gitea.io/en-us/authentication/#ldap-via-binddn For more info on the user filter, see: https://docs.gitea.io/en-us/authentication/#ldap-via-binddn
* Admin Filter: Use `(memberof=cn=lldap_admin,ou=groups,dc=example,dc=com)` if you want lldap admins to become Gitea admins. Leave empty otherwise. * Admin Filter: Use `(memberof=cn=lldap_admin,ou=groups,dc=example,dc=com)` if you want lldap admins to become Gitea admins. Leave empty otherwise.
* Username Attribute: `uid` * Username Attribute: `uid`
* First Name Attribute: `givenName`
* Surname Attribute: `sn`
* Email Attribute: `mail` * Email Attribute: `mail`
* Avatar Attribute: `jpegPhoto`
* Check `Enable User Synchronization` * Check `Enable User Synchronization`
Replace every instance of `dc=example,dc=com` with your configured domain. Replace every instance of `dc=example,dc=com` with your configured domain.
After applying the above settings, users should be able to log in with either their user name or email address. After applying the above settings, users should be able to log in with either their user name or email address.
## Syncronizing LDAP groups with existing teams in organisations
Groups in LLDAP can be syncronized with teams in organisations. Organisations and teams must be created manually in Gitea.
It is possible to syncronize one LDAP group with multiple teams in a Gitea organization.
Check `Enable LDAP Groups`
* Group Search Base DN: `ou=groups,dc=example,dc=com`
* Group Attribute Containing List Of Users: `member`
* User Attribute Listed In Group: `dn`
* Map LDAP groups to Organization teams: `{"cn=Groupname1,ou=groups,dc=example,dc=com":{"Organization1": ["Teamname"]},"cn=Groupname2,ou=groups,dc=example,dc=com": {"Organization2": ["Teamname1", "Teamname2"]}}`
Check `Remove Users from syncronised teams...`
The `Map LDAP groups to Organization teams` config is JSON formatted and can be extended to as many groups as needed.
Replace every instance of `dc=example,dc=com` with your configured domain.
# Configuration for Gitea in `simple auth` mode
* The configuration method is the same as `BindDN` mode.
* `BindDN` and `password` are not required
* Gitea will not be able to pre-sync users, user account will be created at login time.

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

View File

@ -35,6 +35,12 @@ Otherwise, just use:
``` ```
(uid=*) (uid=*)
``` ```
### Admin Base DN
The DN of your admin group. If you have `media_admin` as your group you would use:
```
cn=media_admin,ou=groups,dc=example,dc=com
```
### Admin Filter ### Admin Filter

56
example_configs/nexus.md Normal file
View File

@ -0,0 +1,56 @@
# Configuration for Sonatype Nexus Repository Manager 3
In Nexus log in as an administrator, go to `Server Administration and configuration (gear icon)`
Select `LDAP` under the `Security` section
Click `Create connection`
* Host: A name for the connection e.g. lldap
* Type: ldap
* Host: Your lldap server's ip/hostname
* Port: Your lldap server's port (3890 by default)
* Base DN: `dc=example,dc=com`
* Authentication Method: Simple Authentication
* Username or DN: `uid=admin,ou=people,dc=example,dc=com` or preferably create a read only user in lldap with the lldap_strict_readonly group.
* Password: The password for the user specified above
Click `Verify connection` if successful click `Next`
* Select a template: Generic ldap server
* User Relative DN: `ou=people`
* User subtree: Leave unchecked
* Object class: person
* User Filter: Leave empty to allow all users to log in or `(memberOf=uid=nexus_users,ou=groups,dc=example,dc=com)` for a specific group
* Username Attribute: `uid`
* Real Name Attribute: `cn`
* Email Attribute: `mail`
* Password Attribute: Leave blank
* Check `Enable User Synchronization`
Test user login credentials with `Verify login`
## Set up group mapping as roles
Check `Map LDAP groups as roles`
* Group Type: `Static Groups`
* Group relative DN: `ou=groups`
* Group subtree: Leave unchecked
* Group object class: `groupOfUniqueNames`
* Group ID attribute: `cn`
* Group member attribute: `member`
* Group member format: `uid=${username},ou=people,dc=example,dc=com`
Check user mapping with `Verify user mapping`
## Map specific roles to groups
In Nexus log in as an administrator, go to `Server Administration and configuration (gear icon)`
Select `Roles` under the `Security` section
Click `Create Role`
* Role ID: e.g. nexus_admin (name in nexus)
* Role Name: e.g. nexus_admin (group in lldap)
* Add privileges/roles as needed e.g. under Roles add nx-admin to the "contained" list
Click `Save`

View File

@ -0,0 +1,95 @@
# Configuration for SUSE Rancher (any version)
### Left (hamburger) menu > Users & Authentication > OpenLDAP (yes, we are using the OpenLDAP config page)
---
## LDAP configuration
#### Hostname/IP
```
ip-address, DNS name or when running in Kubernetes (see https://github.com/Evantage-WS/lldap-kubernetes), lldap-service.lldap.svc.cluster.local
```
#### Port
```
3890
```
#### Service Account Distinguished name
A better option is to use a readonly account for accessing the LLDAP server
```
cn=admin,ou=people,dc=example,dc=com
```
#### Service Account Password
```
xxx
```
#### User Search Base
```
ou=people,dc=example,dc=com
```
#### Group Search Base
```
ou=groups,dc=example,dc=com
```
#### Object Class (users)
```
inetOrgPerson
```
#### Object Class (groups)
```
groupOfUniqueNames
```
#### Username Attribute
```
uid
```
#### Name Attribute
```
cn
```
#### Login Attribute
```
uid
```
#### Group Member User Attribute
```
dn
```
#### User Member Attribute
```
memberOf
```
#### Search Attribute (groups)
```
cn
```
#### Search Attribute (users)
```
uid|sn|givenName
```
#### Group Member Mapping Attribute
```
member
```
#### Group DN Attribute
```
dn
```
##### Choose "Search direct and nested group memberships"
##### Fill in the username and password of an admin user at Test and Enable Authentication and hit save
## Rancher OpenLDAP config page
![Rancher OpenLDAP config page](images/rancher_ldap_config.png)

64
example_configs/wikijs.md Normal file
View File

@ -0,0 +1,64 @@
# Configuration for WikiJS
Replace `dc=example,dc=com` with your LLDAP configured domain.
### LDAP URL
```
ldap://lldap:3890
```
### Admin Bind DN
```
uid=admin,ou=people,dc=example,dc=com
```
or
```
uid=readonlyuser,ou=people,dc=example,dc=com
```
### Admin Bind Credentials
```
ADMINPASSWORD
```
or
```
READONLYUSERPASSWORD
```
### Search Base
```
ou=people,dc=example,dc=com
```
### Search Filter
If you wish the permitted users to be restricted to just the `wiki` group:
```
(&(memberof=cn=wiki,ou=groups,dc=example,dc=com)(|(uid={{username}})(mail={{username}))(objectClass=person))
```
If you wish any of the registered LLDAP users to be permitted to use WikiJS:
```
(&(|(uid={{username}})(mail={{username}))(objectClass=person))
```
### Use TLS
Left toggled off
### Verify TLS Certificate
Left toggled off
### TLS Certificate Path
Left blank
### Unique ID Field Mapping
```
uid
```
### Email Field Mapping
```
mail
```
### Display Name Field Mapping
```
givenname
```
### Avatar Picture Field Mapping
```
jpegPhoto
```
### Allow self-registration
Toggled on
### Limit to specific email domains
Left blank
### Assign to group
I created a group called `users` and assign my LDAP users to that by default.
You can use the local admin account to login and promote an LDAP user to `admin` group if you wish and then deactivate the local login option

View File

@ -75,16 +75,16 @@
#ldap_user_pass = "REPLACE_WITH_PASSWORD" #ldap_user_pass = "REPLACE_WITH_PASSWORD"
## Database URL. ## Database URL.
## This encodes the type of database (SQlite, Mysql and so ## This encodes the type of database (SQlite, MySQL, or PostgreSQL)
## on), the path, the user, password, and sometimes the mode (when ## , the path, the user, password, and sometimes the mode (when
## relevant). ## relevant).
## Note: Currently, only SQlite is supported. SQlite should come with ## Note: SQlite should come with "?mode=rwc" to create the DB
## "?mode=rwc" to create the DB if not present. ## if not present.
## Example URLs: ## Example URLs:
## - "postgres://postgres-user:password@postgres-server/my-database" ## - "postgres://postgres-user:password@postgres-server/my-database"
## - "mysql://mysql-user:password@mysql-server/my-database" ## - "mysql://mysql-user:password@mysql-server/my-database"
## ##
## This can be overridden with the DATABASE_URL env variable. ## This can be overridden with the LLDAP_DATABASE_URL env variable.
database_url = "sqlite:///data/users.db?mode=rwc" database_url = "sqlite:///data/users.db?mode=rwc"
## Private key file. ## Private key file.
@ -113,7 +113,7 @@ key_file = "/data/private_key"
#server="smtp.gmail.com" #server="smtp.gmail.com"
## The SMTP port. ## The SMTP port.
#port=587 #port=587
## How the connection is encrypted, either "TLS" or "STARTTLS". ## How the connection is encrypted, either "NONE" (no encryption), "TLS" or "STARTTLS".
#smtp_encryption = "TLS" #smtp_encryption = "TLS"
## The SMTP user, usually your email address. ## The SMTP user, usually your email address.
#user="sender@gmail.com" #user="sender@gmail.com"

View File

@ -1,3 +1,5 @@
#![allow(clippy::uninlined_format_args)]
use std::collections::HashSet; use std::collections::HashSet;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};

View File

@ -5,47 +5,47 @@ name = "lldap"
version = "0.4.2-alpha" version = "0.4.2-alpha"
[dependencies] [dependencies]
actix = "0.12" actix = "0.13"
actix-files = "0.6.0-beta.6" actix-files = "0.6"
actix-http = "=3.0.0-beta.9" actix-http = "3"
actix-rt = "2.2.0" actix-rt = "2"
actix-server = "=2.0.0-beta.5" actix-server = "2"
actix-service = "2.0.0" actix-service = "2"
actix-web = "=4.0.0-beta.8" actix-web = "4.3"
actix-web-httpauth = "0.6.0-beta.2" actix-web-httpauth = "0.8"
anyhow = "*" anyhow = "*"
async-trait = "0.1" async-trait = "0.1"
base64 = "0.13" base64 = "*"
bincode = "1.3" bincode = "1.3"
cron = "*" cron = "*"
derive_builder = "0.10.2" derive_builder = "0.12"
figment_file_provider_adapter = "0.1" figment_file_provider_adapter = "0.1"
futures = "*" futures = "*"
futures-util = "*" futures-util = "*"
hmac = "0.10" hmac = "0.12"
http = "*" http = "*"
itertools = "0.10.1" itertools = "0.10"
juniper = "0.15.10" juniper = "0.15"
juniper_actix = "0.4.0" jwt = "0.16"
jwt = "0.13" lber = "0.4.1"
ldap3_proto = "*" ldap3_proto = ">=0.3.1"
log = "*" log = "*"
orion = "0.16" orion = "0.17"
rustls = "0.20" rustls = "0.20"
serde = "*" serde = "*"
serde_json = "1" serde_json = "1"
sha2 = "0.9" sha2 = "0.10"
thiserror = "*" thiserror = "*"
time = "0.2" time = "0.3"
tokio-rustls = "0.23" tokio-rustls = "0.23"
tokio-stream = "*" tokio-stream = "*"
tokio-util = "0.7.3" tokio-util = "0.7"
tracing = "*" tracing = "*"
tracing-actix-web = "0.4.0-beta.7" tracing-actix-web = "0.7"
tracing-attributes = "^0.1.21" tracing-attributes = "^0.1.21"
tracing-log = "*" tracing-log = "*"
rustls-pemfile = "1.0.0" rustls-pemfile = "1"
serde_bytes = "0.11.7" serde_bytes = "0.11"
webpki-roots = "*" webpki-roots = "*"
[dependencies.chrono] [dependencies.chrono]
@ -54,7 +54,7 @@ version = "*"
[dependencies.clap] [dependencies.clap]
features = ["std", "color", "suggestions", "derive", "env"] features = ["std", "color", "suggestions", "derive", "env"]
version = "3.1.15" version = "4"
[dependencies.figment] [dependencies.figment]
features = ["env", "toml"] features = ["env", "toml"]
@ -67,15 +67,11 @@ features = ["env-filter", "tracing-log"]
[dependencies.lettre] [dependencies.lettre]
features = ["builder", "serde", "smtp-transport", "tokio1-rustls-tls"] features = ["builder", "serde", "smtp-transport", "tokio1-rustls-tls"]
default-features = false default-features = false
version = "0.10.0-rc.3" version = "0.10.1"
[dependencies.lldap_auth] [dependencies.lldap_auth]
path = "../auth" path = "../auth"
[dependencies.sea-query]
version = "*"
features = ["with-chrono"]
[dependencies.opaque-ke] [dependencies.opaque-ke]
version = "0.6" version = "0.6"
@ -89,7 +85,7 @@ version = "*"
[dependencies.tokio] [dependencies.tokio]
features = ["full"] features = ["full"]
version = "1.17" version = "1.25"
[dependencies.uuid] [dependencies.uuid]
features = ["v3"] features = ["v3"]
@ -101,7 +97,7 @@ version = "^0.1.4"
[dependencies.actix-tls] [dependencies.actix-tls]
features = ["default", "rustls"] features = ["default", "rustls"]
version = "=3.0.0-beta.5" version = "3"
[dependencies.image] [dependencies.image]
features = ["jpeg"] features = ["jpeg"]
@ -109,7 +105,7 @@ default-features = false
version = "0.24" version = "0.24"
[dependencies.sea-orm] [dependencies.sea-orm]
version= "0.10.3" version= "0.11"
default-features = false default-features = false
features = ["macros", "with-chrono", "with-uuid", "sqlx-all", "runtime-actix-rustls"] features = ["macros", "with-chrono", "with-uuid", "sqlx-all", "runtime-actix-rustls"]
@ -119,4 +115,4 @@ default-features = false
features = ["rustls-tls-webpki-roots"] features = ["rustls-tls-webpki-roots"]
[dev-dependencies] [dev-dependencies]
mockall = "0.9.1" mockall = "0.11"

View File

@ -14,31 +14,85 @@ pub struct BindRequest {
pub password: String, pub password: String,
} }
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
pub struct SubStringFilter {
pub initial: Option<String>,
pub any: Vec<String>,
pub final_: Option<String>,
}
impl SubStringFilter {
pub fn to_sql_filter(&self) -> String {
let mut filter = String::with_capacity(
self.initial.as_ref().map(String::len).unwrap_or_default()
+ 1
+ self.any.iter().map(String::len).sum::<usize>()
+ self.any.len()
+ self.final_.as_ref().map(String::len).unwrap_or_default(),
);
if let Some(f) = &self.initial {
filter.push_str(&f.to_ascii_lowercase());
}
filter.push('%');
for part in self.any.iter() {
filter.push_str(&part.to_ascii_lowercase());
filter.push('%');
}
if let Some(f) = &self.final_ {
filter.push_str(&f.to_ascii_lowercase());
}
filter
}
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)] #[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
pub enum UserRequestFilter { pub enum UserRequestFilter {
And(Vec<UserRequestFilter>), And(Vec<UserRequestFilter>),
Or(Vec<UserRequestFilter>), Or(Vec<UserRequestFilter>),
Not(Box<UserRequestFilter>), Not(Box<UserRequestFilter>),
UserId(UserId), UserId(UserId),
UserIdSubString(SubStringFilter),
Equality(UserColumn, String), Equality(UserColumn, String),
SubString(UserColumn, SubStringFilter),
// Check if a user belongs to a group identified by name. // Check if a user belongs to a group identified by name.
MemberOf(String), MemberOf(String),
// Same, by id. // Same, by id.
MemberOfId(GroupId), MemberOfId(GroupId),
} }
impl From<bool> for UserRequestFilter {
fn from(val: bool) -> Self {
if val {
Self::And(vec![])
} else {
Self::Not(Box::new(Self::And(vec![])))
}
}
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)] #[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
pub enum GroupRequestFilter { pub enum GroupRequestFilter {
And(Vec<GroupRequestFilter>), And(Vec<GroupRequestFilter>),
Or(Vec<GroupRequestFilter>), Or(Vec<GroupRequestFilter>),
Not(Box<GroupRequestFilter>), Not(Box<GroupRequestFilter>),
DisplayName(String), DisplayName(String),
DisplayNameSubString(SubStringFilter),
Uuid(Uuid), Uuid(Uuid),
GroupId(GroupId), GroupId(GroupId),
// Check if the group contains a user identified by uid. // Check if the group contains a user identified by uid.
Member(UserId), Member(UserId),
} }
impl From<bool> for GroupRequestFilter {
fn from(val: bool) -> Self {
if val {
Self::And(vec![])
} else {
Self::Not(Box::new(Self::And(vec![])))
}
}
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)] #[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
pub struct CreateUserRequest { pub struct CreateUserRequest {
// Same fields as User, but no creation_date, and with password. // Same fields as User, but no creation_date, and with password.
@ -68,13 +122,17 @@ pub struct UpdateGroupRequest {
} }
#[async_trait] #[async_trait]
pub trait LoginHandler: Clone + Send { pub trait LoginHandler: Send + Sync {
async fn bind(&self, request: BindRequest) -> Result<()>; async fn bind(&self, request: BindRequest) -> Result<()>;
} }
#[async_trait] #[async_trait]
pub trait GroupBackendHandler { pub trait GroupListerBackendHandler {
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>; async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>;
}
#[async_trait]
pub trait GroupBackendHandler {
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>; async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>;
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>; async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
async fn create_group(&self, group_name: &str) -> Result<GroupId>; async fn create_group(&self, group_name: &str) -> Result<GroupId>;
@ -82,12 +140,16 @@ pub trait GroupBackendHandler {
} }
#[async_trait] #[async_trait]
pub trait UserBackendHandler { pub trait UserListerBackendHandler {
async fn list_users( async fn list_users(
&self, &self,
filters: Option<UserRequestFilter>, filters: Option<UserRequestFilter>,
get_groups: bool, get_groups: bool,
) -> Result<Vec<UserAndGroups>>; ) -> Result<Vec<UserAndGroups>>;
}
#[async_trait]
pub trait UserBackendHandler {
async fn get_user_details(&self, user_id: &UserId) -> Result<User>; async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
async fn create_user(&self, request: CreateUserRequest) -> Result<()>; async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>; async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
@ -98,7 +160,15 @@ pub trait UserBackendHandler {
} }
#[async_trait] #[async_trait]
pub trait BackendHandler: Clone + Send + GroupBackendHandler + UserBackendHandler {} pub trait BackendHandler:
Send
+ Sync
+ GroupBackendHandler
+ UserBackendHandler
+ UserListerBackendHandler
+ GroupListerBackendHandler
{
}
#[cfg(test)] #[cfg(test)]
mockall::mock! { mockall::mock! {
@ -107,16 +177,22 @@ mockall::mock! {
fn clone(&self) -> Self; fn clone(&self) -> Self;
} }
#[async_trait] #[async_trait]
impl GroupBackendHandler for TestBackendHandler { impl GroupListerBackendHandler for TestBackendHandler {
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>; async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>;
}
#[async_trait]
impl GroupBackendHandler for TestBackendHandler {
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>; async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>;
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>; async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
async fn create_group(&self, group_name: &str) -> Result<GroupId>; async fn create_group(&self, group_name: &str) -> Result<GroupId>;
async fn delete_group(&self, group_id: GroupId) -> Result<()>; async fn delete_group(&self, group_id: GroupId) -> Result<()>;
} }
#[async_trait] #[async_trait]
impl UserBackendHandler for TestBackendHandler { impl UserListerBackendHandler for TestBackendHandler {
async fn list_users(&self, filters: Option<UserRequestFilter>, get_groups: bool) -> Result<Vec<UserAndGroups>>; async fn list_users(&self, filters: Option<UserRequestFilter>, get_groups: bool) -> Result<Vec<UserAndGroups>>;
}
#[async_trait]
impl UserBackendHandler for TestBackendHandler {
async fn get_user_details(&self, user_id: &UserId) -> Result<User>; async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
async fn create_user(&self, request: CreateUserRequest) -> Result<()>; async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>; async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
@ -135,13 +211,21 @@ mockall::mock! {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use base64::Engine;
use super::*; use super::*;
#[test] #[test]
fn test_uuid_time() { fn test_uuid_time() {
use chrono::prelude::*; use chrono::prelude::*;
let user_id = "bob"; let user_id = "bob";
let date1 = Utc.with_ymd_and_hms(2014, 7, 8, 9, 10, 11).unwrap(); let date1 = Utc
let date2 = Utc.with_ymd_and_hms(2014, 7, 8, 9, 10, 12).unwrap(); .with_ymd_and_hms(2014, 7, 8, 9, 10, 11)
.unwrap()
.naive_utc();
let date2 = Utc
.with_ymd_and_hms(2014, 7, 8, 9, 10, 12)
.unwrap()
.naive_utc();
assert_ne!( assert_ne!(
Uuid::from_name_and_date(user_id, &date1), Uuid::from_name_and_date(user_id, &date1),
Uuid::from_name_and_date(user_id, &date2) Uuid::from_name_and_date(user_id, &date2)
@ -151,7 +235,9 @@ mod tests {
#[test] #[test]
fn test_jpeg_try_from_bytes() { fn test_jpeg_try_from_bytes() {
let base64_raw = "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCADqATkDASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAAAAECA//EACQQAQEBAAIBBAMBAQEBAAAAAAABESExQQISUXFhgZGxocHw/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAH/xAAWEQEBAQAAAAAAAAAAAAAAAAAAEQH/2gAMAwEAAhEDEQA/AMriLyCKgg1gQwCgs4FTMOdutepjQak+FzMSVqgxZdRdPPIIvH5WzzGdBriphtTeAXg2ZjKA1pqKDUGZca3foBek8gFv8Ie3fKdA1qb8s7hoL6eLVt51FsAnql3Ut1M7AWbflLMDkEMX/F6/YjK/pADFQAUNA6alYagKk72m/j9p4Bq2fDDSYKLNXPNLoHE/NT6RYC31cJxZ3yWVM+aBYi/S2ZgiAsnYJx5D21vPmqrm3PTfpQQwyAC8JZvSKDni41ZrMuUVVl+Uz9w9v/1QWrZsZ5nFPHYH+JZyureQSF5M+fJ0CAfwRAVRBQA1DAWVUayoJUWoDpsxntPsueBV4+VxhdyAtv8AjOLGpIDMLbeGvbF4iozJfr/WukAVABAXAQXEAAASzVAZdO2WNordm+emFl7XcQSNZiFtv0C9w90nhJf4mA1u+GcJFwIyAqL/AOovwgGNfSRqdIrNa29M0gKCAojU9PAMjWXpckEJFNFEAAXEUBABYz6rZ0ureQc9vyt9XxDF2QAXtABcQAs0AZywkvluJbyipifas52DcyxjlZweAO0xri/hc+wZOEKIu6nSyeToVZyWXwvCg53gW81QQ7aTNAn5dGZJPs1UXURQAUEMCXQLZE93PRZ5hPTgNMrbIzKCm52LZwCs+2M8w2g3sjPuZAXb4IsMAUACzVUGM4/K+md6vEXUUyM5PDR0IxYe6ramih0VNBrS4xoqN8Q1BFQk3yqyAsioioAAKgDSJL4/jQIn5igLrPqtOuf6oOaxbMoAltUAhhIoJiiggrPu+AaOIxtAX3JbaAIaLwi4t9X4T3fg2AFtqcrUUarP20zUDAmqoE0WRBZPNVUVEAAAAVAC8kvih2DSKxOdBqs7Z0l0gI0mKAC4AuHE7ZtBriM+744QAAAAABAFsveIttBICyaikvy1+r/Cen5rWQHIBQa4rIDRqSl5qDWqziqgAAAATA7BpGdqXb2C2+J/UgAtRQBSQtkBWb6vhLbQAAAAAEBRAAAAAUbm+GZNdPxAP+ql2Tjwx7/wIgZ8iKvBk+CJoCXii9gaqZ/qqihAAAEVABGkBFUwBftNkZ3QW34QAAABFAQAVAAAAAARVkl8gs/43sk1jL45LvHArepk+E9XTG35oLqsmIKmLAEygKg0y1AFQBUXwgAAAoBC34S3UAAABAVAAAAAABAUQAVABdRQa1PcYyit2z58M8C4ouM2NXpOEGeWtNZUatiAIoAKIoCoAoG4C9MW6dgIoAIAAAAAAACKWAgL0CAAAALiANCKioNLgM1CrLihmTafkt1EF3SZ5ZVUW4mnIKvAi5fhEURVDWVQBRAAAAAAAAQFRVyAyulgAqCKlF8IqLsEgC9mGoC+IusqCrv5ZEUVOk1RuJfwSLOOkGFi4XPCoYYrNiKauosBGi9ICstM1UAAAAAAFQ0VcTBAXUGgIqGoKhKAzRRUQUAwxoSrGRpkQA/qiosOL9oJptMRRVZa0VUqSiChE6BqMgCwqKqIogAIAqKCKgKoogg0lBFuIKgAAAKNRlf2gqsftsEtZWoAAqAACKoMqAAeSoqp39kL2AqLOlE8rEBFQARYALhigrNC9gGmooLp4TweEQFFBFAECgIoAu0ifIAqAAA//9k="; let base64_raw = "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCADqATkDASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAAAAECA//EACQQAQEBAAIBBAMBAQEBAAAAAAABESExQQISUXFhgZGxocHw/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAH/xAAWEQEBAQAAAAAAAAAAAAAAAAAAEQH/2gAMAwEAAhEDEQA/AMriLyCKgg1gQwCgs4FTMOdutepjQak+FzMSVqgxZdRdPPIIvH5WzzGdBriphtTeAXg2ZjKA1pqKDUGZca3foBek8gFv8Ie3fKdA1qb8s7hoL6eLVt51FsAnql3Ut1M7AWbflLMDkEMX/F6/YjK/pADFQAUNA6alYagKk72m/j9p4Bq2fDDSYKLNXPNLoHE/NT6RYC31cJxZ3yWVM+aBYi/S2ZgiAsnYJx5D21vPmqrm3PTfpQQwyAC8JZvSKDni41ZrMuUVVl+Uz9w9v/1QWrZsZ5nFPHYH+JZyureQSF5M+fJ0CAfwRAVRBQA1DAWVUayoJUWoDpsxntPsueBV4+VxhdyAtv8AjOLGpIDMLbeGvbF4iozJfr/WukAVABAXAQXEAAASzVAZdO2WNordm+emFl7XcQSNZiFtv0C9w90nhJf4mA1u+GcJFwIyAqL/AOovwgGNfSRqdIrNa29M0gKCAojU9PAMjWXpckEJFNFEAAXEUBABYz6rZ0ureQc9vyt9XxDF2QAXtABcQAs0AZywkvluJbyipifas52DcyxjlZweAO0xri/hc+wZOEKIu6nSyeToVZyWXwvCg53gW81QQ7aTNAn5dGZJPs1UXURQAUEMCXQLZE93PRZ5hPTgNMrbIzKCm52LZwCs+2M8w2g3sjPuZAXb4IsMAUACzVUGM4/K+md6vEXUUyM5PDR0IxYe6ramih0VNBrS4xoqN8Q1BFQk3yqyAsioioAAKgDSJL4/jQIn5igLrPqtOuf6oOaxbMoAltUAhhIoJiiggrPu+AaOIxtAX3JbaAIaLwi4t9X4T3fg2AFtqcrUUarP20zUDAmqoE0WRBZPNVUVEAAAAVAC8kvih2DSKxOdBqs7Z0l0gI0mKAC4AuHE7ZtBriM+744QAAAAABAFsveIttBICyaikvy1+r/Cen5rWQHIBQa4rIDRqSl5qDWqziqgAAAATA7BpGdqXb2C2+J/UgAtRQBSQtkBWb6vhLbQAAAAAEBRAAAAAUbm+GZNdPxAP+ql2Tjwx7/wIgZ8iKvBk+CJoCXii9gaqZ/qqihAAAEVABGkBFUwBftNkZ3QW34QAAABFAQAVAAAAAARVkl8gs/43sk1jL45LvHArepk+E9XTG35oLqsmIKmLAEygKg0y1AFQBUXwgAAAoBC34S3UAAABAVAAAAAABAUQAVABdRQa1PcYyit2z58M8C4ouM2NXpOEGeWtNZUatiAIoAKIoCoAoG4C9MW6dgIoAIAAAAAAACKWAgL0CAAAALiANCKioNLgM1CrLihmTafkt1EF3SZ5ZVUW4mnIKvAi5fhEURVDWVQBRAAAAAAAAQFRVyAyulgAqCKlF8IqLsEgC9mGoC+IusqCrv5ZEUVOk1RuJfwSLOOkGFi4XPCoYYrNiKauosBGi9ICstM1UAAAAAAFQ0VcTBAXUGgIqGoKhKAzRRUQUAwxoSrGRpkQA/qiosOL9oJptMRRVZa0VUqSiChE6BqMgCwqKqIogAIAqKCKgKoogg0lBFuIKgAAAKNRlf2gqsftsEtZWoAAqAACKoMqAAeSoqp39kL2AqLOlE8rEBFQARYALhigrNC9gGmooLp4TweEQFFBFAECgIoAu0ifIAqAAA//9k=";
let base64_jpeg = base64::decode(base64_raw).unwrap(); let base64_jpeg = base64::engine::general_purpose::STANDARD
.decode(base64_raw)
.unwrap();
JpegPhoto::try_from(base64_jpeg).unwrap(); JpegPhoto::try_from(base64_jpeg).unwrap();
} }
} }

View File

@ -1,10 +1,10 @@
use ldap3_proto::{ use ldap3_proto::{
proto::LdapOp, LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry, proto::LdapOp, LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry,
}; };
use tracing::{debug, info, instrument, warn}; use tracing::{debug, instrument, warn};
use crate::domain::{ use crate::domain::{
handler::{BackendHandler, GroupRequestFilter}, handler::{GroupListerBackendHandler, GroupRequestFilter},
ldap::error::LdapError, ldap::error::LdapError,
types::{Group, GroupColumn, UserId, Uuid}, types::{Group, GroupColumn, UserId, Uuid},
}; };
@ -12,15 +12,16 @@ use crate::domain::{
use super::{ use super::{
error::LdapResult, error::LdapResult,
utils::{ utils::{
expand_attribute_wildcards, get_user_id_from_distinguished_name, map_group_field, LdapInfo, expand_attribute_wildcards, get_group_id_from_distinguished_name,
get_user_id_from_distinguished_name, map_group_field, LdapInfo,
}, },
}; };
fn get_group_attribute( pub fn get_group_attribute(
group: &Group, group: &Group,
base_dn_str: &str, base_dn_str: &str,
attribute: &str, attribute: &str,
user_filter: &Option<&UserId>, user_filter: &Option<UserId>,
ignored_group_attributes: &[String], ignored_group_attributes: &[String],
) -> Option<Vec<Vec<u8>>> { ) -> Option<Vec<Vec<u8>>> {
let attribute = attribute.to_ascii_lowercase(); let attribute = attribute.to_ascii_lowercase();
@ -28,12 +29,12 @@ fn get_group_attribute(
"objectclass" => vec![b"groupOfUniqueNames".to_vec()], "objectclass" => vec![b"groupOfUniqueNames".to_vec()],
// Always returned as part of the base response. // Always returned as part of the base response.
"dn" | "distinguishedname" => return None, "dn" | "distinguishedname" => return None,
"cn" | "uid" => vec![group.display_name.clone().into_bytes()], "cn" | "uid" | "id" => vec![group.display_name.clone().into_bytes()],
"entryuuid" => vec![group.uuid.to_string().into_bytes()], "entryuuid" | "uuid" => vec![group.uuid.to_string().into_bytes()],
"member" | "uniquemember" => group "member" | "uniquemember" => group
.users .users
.iter() .iter()
.filter(|u| user_filter.map(|f| *u == f).unwrap_or(true)) .filter(|u| user_filter.as_ref().map(|f| *u == f).unwrap_or(true))
.map(|u| format!("uid={},ou=people,{}", u, base_dn_str).into_bytes()) .map(|u| format!("uid={},ou=people,{}", u, base_dn_str).into_bytes())
.collect(), .collect(),
"1.1" => return None, "1.1" => return None,
@ -72,14 +73,18 @@ const ALL_GROUP_ATTRIBUTE_KEYS: &[&str] = &[
"entryuuid", "entryuuid",
]; ];
fn expand_group_attribute_wildcards(attributes: &[String]) -> Vec<&str> {
expand_attribute_wildcards(attributes, ALL_GROUP_ATTRIBUTE_KEYS)
}
fn make_ldap_search_group_result_entry( fn make_ldap_search_group_result_entry(
group: Group, group: Group,
base_dn_str: &str, base_dn_str: &str,
attributes: &[String], attributes: &[String],
user_filter: &Option<&UserId>, user_filter: &Option<UserId>,
ignored_group_attributes: &[String], ignored_group_attributes: &[String],
) -> LdapSearchResultEntry { ) -> LdapSearchResultEntry {
let expanded_attributes = expand_attribute_wildcards(attributes, ALL_GROUP_ATTRIBUTE_KEYS); let expanded_attributes = expand_group_attribute_wildcards(attributes);
LdapSearchResultEntry { LdapSearchResultEntry {
dn: format!("cn={},ou=groups,{}", group.display_name, base_dn_str), dn: format!("cn={},ou=groups,{}", group.display_name, base_dn_str),
@ -120,12 +125,20 @@ fn convert_group_filter(
)?; )?;
Ok(GroupRequestFilter::Member(user_name)) Ok(GroupRequestFilter::Member(user_name))
} }
"objectclass" => match value.as_str() { "objectclass" => Ok(GroupRequestFilter::from(matches!(
"groupofuniquenames" | "groupofnames" => Ok(GroupRequestFilter::And(vec![])), value.as_str(),
_ => Ok(GroupRequestFilter::Not(Box::new(GroupRequestFilter::And( "groupofuniquenames" | "groupofnames"
vec![], ))),
)))), "dn" => Ok(get_group_id_from_distinguished_name(
}, value.to_ascii_lowercase().as_str(),
&ldap_info.base_dn,
&ldap_info.base_dn_str,
)
.map(GroupRequestFilter::DisplayName)
.unwrap_or_else(|_| {
warn!("Invalid dn filter on group: {}", value);
GroupRequestFilter::from(false)
})),
_ => match map_group_field(field) { _ => match map_group_field(field) {
Some(GroupColumn::DisplayName) => { Some(GroupColumn::DisplayName) => {
Ok(GroupRequestFilter::DisplayName(value.to_string())) Ok(GroupRequestFilter::DisplayName(value.to_string()))
@ -144,9 +157,7 @@ fn convert_group_filter(
field field
); );
} }
Ok(GroupRequestFilter::Not(Box::new(GroupRequestFilter::And( Ok(GroupRequestFilter::from(false))
vec![],
))))
} }
}, },
} }
@ -160,16 +171,26 @@ fn convert_group_filter(
LdapFilter::Not(filter) => Ok(GroupRequestFilter::Not(Box::new(rec(filter)?))), LdapFilter::Not(filter) => Ok(GroupRequestFilter::Not(Box::new(rec(filter)?))),
LdapFilter::Present(field) => { LdapFilter::Present(field) => {
let field = &field.to_ascii_lowercase(); let field = &field.to_ascii_lowercase();
if field == "objectclass" Ok(GroupRequestFilter::from(
|| field == "dn" field == "objectclass"
|| field == "distinguishedname" || field == "dn"
|| map_group_field(field).is_some() || field == "distinguishedname"
{ || map_group_field(field).is_some(),
Ok(GroupRequestFilter::And(vec![])) ))
} else { }
Ok(GroupRequestFilter::Not(Box::new(GroupRequestFilter::And( LdapFilter::Substring(field, substring_filter) => {
vec![], let field = &field.to_ascii_lowercase();
)))) match map_group_field(field.as_str()) {
Some(GroupColumn::DisplayName) => Ok(GroupRequestFilter::DisplayNameSubString(
substring_filter.clone().into(),
)),
_ => Err(LdapError {
code: LdapResultCode::UnwillingToPerform,
message: format!(
"Unsupported group attribute for substring filter: {:?}",
field
),
}),
} }
} }
_ => Err(LdapError { _ => Err(LdapError {
@ -180,42 +201,37 @@ fn convert_group_filter(
} }
#[instrument(skip_all, level = "debug")] #[instrument(skip_all, level = "debug")]
pub async fn get_groups_list<Backend: BackendHandler>( pub async fn get_groups_list<Backend: GroupListerBackendHandler>(
ldap_info: &LdapInfo, ldap_info: &LdapInfo,
ldap_filter: &LdapFilter, ldap_filter: &LdapFilter,
attributes: &[String],
base: &str, base: &str,
user_filter: &Option<&UserId>, backend: &Backend,
backend: &mut Backend, ) -> LdapResult<Vec<Group>> {
) -> LdapResult<Vec<LdapOp>> {
debug!(?ldap_filter); debug!(?ldap_filter);
let filter = convert_group_filter(ldap_info, ldap_filter)?; let filters = convert_group_filter(ldap_info, ldap_filter)?;
let parsed_filters = match user_filter { debug!(?filters);
None => filter, backend
Some(u) => { .list_groups(Some(filters))
info!("Unprivileged search, limiting results");
GroupRequestFilter::And(vec![filter, GroupRequestFilter::Member((*u).clone())])
}
};
debug!(?parsed_filters);
let groups = backend
.list_groups(Some(parsed_filters))
.await .await
.map_err(|e| LdapError { .map_err(|e| LdapError {
code: LdapResultCode::Other, code: LdapResultCode::Other,
message: format!(r#"Error while listing groups "{}": {:#}"#, base, e), message: format!(r#"Error while listing groups "{}": {:#}"#, base, e),
})?;
Ok(groups
.into_iter()
.map(|u| {
LdapOp::SearchResultEntry(make_ldap_search_group_result_entry(
u,
&ldap_info.base_dn_str,
attributes,
user_filter,
&ldap_info.ignored_group_attributes,
))
}) })
.collect::<Vec<_>>()) }
pub fn convert_groups_to_ldap_op<'a>(
groups: Vec<Group>,
attributes: &'a [String],
ldap_info: &'a LdapInfo,
user_filter: &'a Option<UserId>,
) -> impl Iterator<Item = LdapOp> + 'a {
groups.into_iter().map(move |g| {
LdapOp::SearchResultEntry(make_ldap_search_group_result_entry(
g,
&ldap_info.base_dn_str,
attributes,
user_filter,
&ldap_info.ignored_group_attributes,
))
})
} }

View File

@ -1,12 +1,16 @@
use chrono::TimeZone;
use ldap3_proto::{ use ldap3_proto::{
proto::LdapOp, LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry, proto::LdapOp, LdapFilter, LdapPartialAttribute, LdapResultCode, LdapSearchResultEntry,
}; };
use tracing::{debug, info, instrument, warn}; use tracing::{debug, instrument, warn};
use crate::domain::{ use crate::domain::{
handler::{BackendHandler, UserRequestFilter}, handler::{UserListerBackendHandler, UserRequestFilter},
ldap::{error::LdapError, utils::expand_attribute_wildcards}, ldap::{
types::{GroupDetails, User, UserColumn, UserId}, error::LdapError,
utils::{expand_attribute_wildcards, get_user_id_from_distinguished_name},
},
types::{GroupDetails, User, UserAndGroups, UserColumn, UserId},
}; };
use super::{ use super::{
@ -14,7 +18,7 @@ use super::{
utils::{get_group_id_from_distinguished_name, map_user_field, LdapInfo}, utils::{get_group_id_from_distinguished_name, map_user_field, LdapInfo},
}; };
fn get_user_attribute( pub fn get_user_attribute(
user: &User, user: &User,
attribute: &str, attribute: &str,
base_dn_str: &str, base_dn_str: &str,
@ -31,25 +35,26 @@ fn get_user_attribute(
], ],
// dn is always returned as part of the base response. // dn is always returned as part of the base response.
"dn" | "distinguishedname" => return None, "dn" | "distinguishedname" => return None,
"uid" => vec![user.user_id.to_string().into_bytes()], "uid" | "user_id" | "id" => vec![user.user_id.to_string().into_bytes()],
"entryuuid" => vec![user.uuid.to_string().into_bytes()], "entryuuid" | "uuid" => vec![user.uuid.to_string().into_bytes()],
"mail" => vec![user.email.clone().into_bytes()], "mail" | "email" => vec![user.email.clone().into_bytes()],
"givenname" => vec![user.first_name.clone()?.into_bytes()], "givenname" | "first_name" | "firstname" => vec![user.first_name.clone()?.into_bytes()],
"sn" => vec![user.last_name.clone()?.into_bytes()], "sn" | "last_name" | "lastname" => vec![user.last_name.clone()?.into_bytes()],
"jpegphoto" => vec![user.avatar.clone()?.into_bytes()], "jpegphoto" | "avatar" => vec![user.avatar.clone()?.into_bytes()],
"memberof" => groups "memberof" => groups
.into_iter() .into_iter()
.flatten() .flatten()
.map(|id_and_name| { .map(|id_and_name| {
format!( format!("cn={},ou=groups,{}", &id_and_name.display_name, base_dn_str).into_bytes()
"uid={},ou=groups,{}",
&id_and_name.display_name, base_dn_str
)
.into_bytes()
}) })
.collect(), .collect(),
"cn" | "displayname" => vec![user.display_name.clone()?.into_bytes()], "cn" | "displayname" => vec![user.display_name.clone()?.into_bytes()],
"createtimestamp" | "modifytimestamp" => vec![user.creation_date.to_rfc3339().into_bytes()], "creationdate" | "creation_date" | "createtimestamp" | "modifytimestamp" => {
vec![chrono::Utc
.from_utc_datetime(&user.creation_date)
.to_rfc3339()
.into_bytes()]
}
"1.1" => return None, "1.1" => return None,
// We ignore the operational attribute wildcard. // We ignore the operational attribute wildcard.
"+" => return None, "+" => return None,
@ -92,15 +97,15 @@ const ALL_USER_ATTRIBUTE_KEYS: &[&str] = &[
fn make_ldap_search_user_result_entry( fn make_ldap_search_user_result_entry(
user: User, user: User,
base_dn_str: &str, base_dn_str: &str,
attributes: &[&str], attributes: &[String],
groups: Option<&[GroupDetails]>, groups: Option<&[GroupDetails]>,
ignored_user_attributes: &[String], ignored_user_attributes: &[String],
) -> LdapSearchResultEntry { ) -> LdapSearchResultEntry {
let expanded_attributes = expand_user_attribute_wildcards(attributes);
let dn = format!("uid={},ou=people,{}", user.user_id.as_str(), base_dn_str); let dn = format!("uid={},ou=people,{}", user.user_id.as_str(), base_dn_str);
LdapSearchResultEntry { LdapSearchResultEntry {
dn, dn,
attributes: attributes attributes: expanded_attributes
.iter() .iter()
.filter_map(|a| { .filter_map(|a| {
let values = let values =
@ -127,22 +132,27 @@ fn convert_user_filter(ldap_info: &LdapInfo, filter: &LdapFilter) -> LdapResult<
LdapFilter::Equality(field, value) => { LdapFilter::Equality(field, value) => {
let field = &field.to_ascii_lowercase(); let field = &field.to_ascii_lowercase();
match field.as_str() { match field.as_str() {
"memberof" => { "memberof" => Ok(UserRequestFilter::MemberOf(
let group_name = get_group_id_from_distinguished_name( get_group_id_from_distinguished_name(
&value.to_ascii_lowercase(), &value.to_ascii_lowercase(),
&ldap_info.base_dn, &ldap_info.base_dn,
&ldap_info.base_dn_str, &ldap_info.base_dn_str,
)?; )?,
Ok(UserRequestFilter::MemberOf(group_name)) )),
} "objectclass" => Ok(UserRequestFilter::from(matches!(
"objectclass" => match value.to_ascii_lowercase().as_str() { value.to_ascii_lowercase().as_str(),
"person" | "inetorgperson" | "posixaccount" | "mailaccount" => { "person" | "inetorgperson" | "posixaccount" | "mailaccount"
Ok(UserRequestFilter::And(vec![])) ))),
} "dn" => Ok(get_user_id_from_distinguished_name(
_ => Ok(UserRequestFilter::Not(Box::new(UserRequestFilter::And( value.to_ascii_lowercase().as_str(),
vec![], &ldap_info.base_dn,
)))), &ldap_info.base_dn_str,
}, )
.map(UserRequestFilter::UserId)
.unwrap_or_else(|_| {
warn!("Invalid dn filter on user: {}", value);
UserRequestFilter::from(false)
})),
_ => match map_user_field(field) { _ => match map_user_field(field) {
Some(UserColumn::UserId) => Ok(UserRequestFilter::UserId(UserId::new(value))), Some(UserColumn::UserId) => Ok(UserRequestFilter::UserId(UserId::new(value))),
Some(field) => Ok(UserRequestFilter::Equality(field, value.clone())), Some(field) => Ok(UserRequestFilter::Equality(field, value.clone())),
@ -154,9 +164,7 @@ fn convert_user_filter(ldap_info: &LdapInfo, filter: &LdapFilter) -> LdapResult<
field field
); );
} }
Ok(UserRequestFilter::Not(Box::new(UserRequestFilter::And( Ok(UserRequestFilter::from(false))
vec![],
))))
} }
}, },
} }
@ -164,16 +172,33 @@ fn convert_user_filter(ldap_info: &LdapInfo, filter: &LdapFilter) -> LdapResult<
LdapFilter::Present(field) => { LdapFilter::Present(field) => {
let field = &field.to_ascii_lowercase(); let field = &field.to_ascii_lowercase();
// Check that it's a field we support. // Check that it's a field we support.
if field == "objectclass" Ok(UserRequestFilter::from(
|| field == "dn" field == "objectclass"
|| field == "distinguishedname" || field == "dn"
|| map_user_field(field).is_some() || field == "distinguishedname"
{ || map_user_field(field).is_some(),
Ok(UserRequestFilter::And(vec![])) ))
} else { }
Ok(UserRequestFilter::Not(Box::new(UserRequestFilter::And( LdapFilter::Substring(field, substring_filter) => {
vec![], let field = &field.to_ascii_lowercase();
)))) match map_user_field(field.as_str()) {
Some(UserColumn::UserId) => Ok(UserRequestFilter::UserIdSubString(
substring_filter.clone().into(),
)),
None
| Some(UserColumn::CreationDate)
| Some(UserColumn::Avatar)
| Some(UserColumn::Uuid) => Err(LdapError {
code: LdapResultCode::UnwillingToPerform,
message: format!(
"Unsupported user attribute for substring filter: {:?}",
field
),
}),
Some(field) => Ok(UserRequestFilter::SubString(
field,
substring_filter.clone().into(),
)),
} }
} }
_ => Err(LdapError { _ => Err(LdapError {
@ -183,47 +208,42 @@ fn convert_user_filter(ldap_info: &LdapInfo, filter: &LdapFilter) -> LdapResult<
} }
} }
fn expand_user_attribute_wildcards(attributes: &[String]) -> Vec<&str> {
expand_attribute_wildcards(attributes, ALL_USER_ATTRIBUTE_KEYS)
}
#[instrument(skip_all, level = "debug")] #[instrument(skip_all, level = "debug")]
pub async fn get_user_list<Backend: BackendHandler>( pub async fn get_user_list<Backend: UserListerBackendHandler>(
ldap_info: &LdapInfo, ldap_info: &LdapInfo,
ldap_filter: &LdapFilter, ldap_filter: &LdapFilter,
attributes: &[String], request_groups: bool,
base: &str, base: &str,
user_filter: &Option<&UserId>, backend: &Backend,
backend: &mut Backend, ) -> LdapResult<Vec<UserAndGroups>> {
) -> LdapResult<Vec<LdapOp>> {
debug!(?ldap_filter); debug!(?ldap_filter);
let filters = convert_user_filter(ldap_info, ldap_filter)?; let filters = convert_user_filter(ldap_info, ldap_filter)?;
let parsed_filters = match user_filter { debug!(?filters);
None => filters, backend
Some(u) => { .list_users(Some(filters), request_groups)
info!("Unprivileged search, limiting results");
UserRequestFilter::And(vec![filters, UserRequestFilter::UserId((*u).clone())])
}
};
debug!(?parsed_filters);
let expanded_attributes = expand_attribute_wildcards(attributes, ALL_USER_ATTRIBUTE_KEYS);
let need_groups = expanded_attributes
.iter()
.any(|s| s.to_ascii_lowercase() == "memberof");
let users = backend
.list_users(Some(parsed_filters), need_groups)
.await .await
.map_err(|e| LdapError { .map_err(|e| LdapError {
code: LdapResultCode::Other, code: LdapResultCode::Other,
message: format!(r#"Error while searching user "{}": {:#}"#, base, e), message: format!(r#"Error while searching user "{}": {:#}"#, base, e),
})?;
Ok(users
.into_iter()
.map(|u| {
LdapOp::SearchResultEntry(make_ldap_search_user_result_entry(
u.user,
&ldap_info.base_dn_str,
&expanded_attributes,
u.groups.as_deref(),
&ldap_info.ignored_user_attributes,
))
}) })
.collect::<Vec<_>>()) }
pub fn convert_users_to_ldap_op<'a>(
users: Vec<UserAndGroups>,
attributes: &'a [String],
ldap_info: &'a LdapInfo,
) -> impl Iterator<Item = LdapOp> + 'a {
users.into_iter().map(move |u| {
LdapOp::SearchResultEntry(make_ldap_search_user_result_entry(
u.user,
&ldap_info.base_dn_str,
attributes,
u.groups.as_deref(),
&ldap_info.ignored_user_attributes,
))
})
} }

View File

@ -1,12 +1,29 @@
use itertools::Itertools; use itertools::Itertools;
use ldap3_proto::LdapResultCode; use ldap3_proto::{proto::LdapSubstringFilter, LdapResultCode};
use tracing::{debug, instrument, warn}; use tracing::{debug, instrument, warn};
use crate::domain::{ use crate::domain::{
handler::SubStringFilter,
ldap::error::{LdapError, LdapResult}, ldap::error::{LdapError, LdapResult},
types::{GroupColumn, UserColumn, UserId}, types::{GroupColumn, UserColumn, UserId},
}; };
impl From<LdapSubstringFilter> for SubStringFilter {
fn from(
LdapSubstringFilter {
initial,
any,
final_,
}: LdapSubstringFilter,
) -> Self {
Self {
initial,
any,
final_,
}
}
}
fn make_dn_pair<I>(mut iter: I) -> LdapResult<(String, String)> fn make_dn_pair<I>(mut iter: I) -> LdapResult<(String, String)>
where where
I: Iterator<Item = String>, I: Iterator<Item = String>,
@ -141,9 +158,9 @@ pub fn map_user_field(field: &str) -> Option<UserColumn> {
"uid" | "user_id" | "id" => UserColumn::UserId, "uid" | "user_id" | "id" => UserColumn::UserId,
"mail" | "email" => UserColumn::Email, "mail" | "email" => UserColumn::Email,
"cn" | "displayname" | "display_name" => UserColumn::DisplayName, "cn" | "displayname" | "display_name" => UserColumn::DisplayName,
"givenname" | "first_name" => UserColumn::FirstName, "givenname" | "first_name" | "firstname" => UserColumn::FirstName,
"sn" | "last_name" => UserColumn::LastName, "sn" | "last_name" | "lastname" => UserColumn::LastName,
"avatar" => UserColumn::Avatar, "avatar" | "jpegphoto" => UserColumn::Avatar,
"creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => { "creationdate" | "createtimestamp" | "modifytimestamp" | "creation_date" => {
UserColumn::CreationDate UserColumn::CreationDate
} }

View File

@ -11,7 +11,7 @@ pub struct Model {
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub group_id: GroupId, pub group_id: GroupId,
pub display_name: String, pub display_name: String,
pub creation_date: chrono::DateTime<chrono::Utc>, pub creation_date: chrono::NaiveDateTime,
pub uuid: Uuid, pub uuid: Uuid,
} }

View File

@ -11,7 +11,7 @@ pub struct Model {
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub refresh_token_hash: i64, pub refresh_token_hash: i64,
pub user_id: UserId, pub user_id: UserId,
pub expiry_date: chrono::DateTime<chrono::Utc>, pub expiry_date: chrono::NaiveDateTime,
} }
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@ -11,7 +11,7 @@ pub struct Model {
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub jwt_hash: i64, pub jwt_hash: i64,
pub user_id: UserId, pub user_id: UserId,
pub expiry_date: chrono::DateTime<chrono::Utc>, pub expiry_date: chrono::NaiveDateTime,
pub blacklisted: bool, pub blacklisted: bool,
} }

View File

@ -11,7 +11,7 @@ pub struct Model {
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub token: String, pub token: String,
pub user_id: UserId, pub user_id: UserId,
pub expiry_date: chrono::DateTime<chrono::Utc>, pub expiry_date: chrono::NaiveDateTime,
} }
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@ -1,6 +1,6 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.3 //! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.3
use sea_orm::entity::prelude::*; use sea_orm::{entity::prelude::*, sea_query::BlobSize};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::domain::types::{JpegPhoto, UserId, Uuid}; use crate::domain::types::{JpegPhoto, UserId, Uuid};
@ -18,7 +18,7 @@ pub struct Model {
pub first_name: Option<String>, pub first_name: Option<String>,
pub last_name: Option<String>, pub last_name: Option<String>,
pub avatar: Option<JpegPhoto>, pub avatar: Option<JpegPhoto>,
pub creation_date: chrono::DateTime<chrono::Utc>, pub creation_date: chrono::NaiveDateTime,
pub password_hash: Option<Vec<u8>>, pub password_hash: Option<Vec<u8>>,
pub totp_secret: Option<String>, pub totp_secret: Option<String>,
pub mfa_type: Option<String>, pub mfa_type: Option<String>,
@ -56,9 +56,9 @@ impl ColumnTrait for Column {
Column::DisplayName => ColumnType::String(Some(255)), Column::DisplayName => ColumnType::String(Some(255)),
Column::FirstName => ColumnType::String(Some(255)), Column::FirstName => ColumnType::String(Some(255)),
Column::LastName => ColumnType::String(Some(255)), Column::LastName => ColumnType::String(Some(255)),
Column::Avatar => ColumnType::Binary, Column::Avatar => ColumnType::Binary(BlobSize::Long),
Column::CreationDate => ColumnType::DateTime, Column::CreationDate => ColumnType::DateTime,
Column::PasswordHash => ColumnType::Binary, Column::PasswordHash => ColumnType::Binary(BlobSize::Medium),
Column::TotpSecret => ColumnType::String(Some(64)), Column::TotpSecret => ColumnType::String(Some(64)),
Column::MfaType => ColumnType::String(Some(64)), Column::MfaType => ColumnType::String(Some(64)),
Column::Uuid => ColumnType::String(Some(36)), Column::Uuid => ColumnType::String(Some(36)),

View File

@ -4,7 +4,7 @@ use async_trait::async_trait;
pub use lldap_auth::{login, registration}; pub use lldap_auth::{login, registration};
#[async_trait] #[async_trait]
pub trait OpaqueHandler: Clone + Send { pub trait OpaqueHandler: Send + Sync {
async fn login_start( async fn login_start(
&self, &self,
request: login::ClientLoginStartRequest, request: login::ClientLoginStartRequest,

View File

@ -1,4 +1,4 @@
use super::{handler::BackendHandler, sql_tables::DbConnection}; use crate::domain::{handler::BackendHandler, sql_tables::DbConnection};
use crate::infra::configuration::Configuration; use crate::infra::configuration::Configuration;
use async_trait::async_trait; use async_trait::async_trait;
@ -23,7 +23,8 @@ pub mod tests {
use crate::{ use crate::{
domain::{ domain::{
handler::{ handler::{
CreateUserRequest, GroupBackendHandler, UserBackendHandler, UserRequestFilter, CreateUserRequest, GroupBackendHandler, UserBackendHandler,
UserListerBackendHandler, UserRequestFilter,
}, },
sql_tables::init_table, sql_tables::init_table,
types::{GroupId, UserId}, types::{GroupId, UserId},

View File

@ -1,20 +1,23 @@
use crate::domain::{ use crate::domain::{
error::{DomainError, Result}, error::{DomainError, Result},
handler::{GroupBackendHandler, GroupRequestFilter, UpdateGroupRequest}, handler::{
GroupBackendHandler, GroupListerBackendHandler, GroupRequestFilter, UpdateGroupRequest,
},
model::{self, GroupColumn, MembershipColumn}, model::{self, GroupColumn, MembershipColumn},
sql_backend_handler::SqlBackendHandler, sql_backend_handler::SqlBackendHandler,
types::{Group, GroupDetails, GroupId, Uuid}, types::{Group, GroupDetails, GroupId, Uuid},
}; };
use async_trait::async_trait; use async_trait::async_trait;
use sea_orm::{ use sea_orm::{
sea_query::{Alias, Cond, Expr, Func, IntoCondition, SimpleExpr},
ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect,
QueryTrait, QueryTrait,
}; };
use sea_query::{Cond, IntoCondition, SimpleExpr};
use tracing::{debug, instrument}; use tracing::{debug, instrument};
fn get_group_filter_expr(filter: GroupRequestFilter) -> Cond { fn get_group_filter_expr(filter: GroupRequestFilter) -> Cond {
use GroupRequestFilter::*; use GroupRequestFilter::*;
let group_table = Alias::new("groups");
match filter { match filter {
And(fs) => { And(fs) => {
if fs.is_empty() { if fs.is_empty() {
@ -46,11 +49,17 @@ fn get_group_filter_expr(filter: GroupRequestFilter) -> Cond {
.into_query(), .into_query(),
) )
.into_condition(), .into_condition(),
DisplayNameSubString(filter) => SimpleExpr::FunctionCall(Func::lower(Expr::col((
group_table,
GroupColumn::DisplayName,
))))
.like(filter.to_sql_filter())
.into_condition(),
} }
} }
#[async_trait] #[async_trait]
impl GroupBackendHandler for SqlBackendHandler { impl GroupListerBackendHandler for SqlBackendHandler {
#[instrument(skip_all, level = "debug", ret, err)] #[instrument(skip_all, level = "debug", ret, err)]
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>> { async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>> {
debug!(?filters); debug!(?filters);
@ -87,7 +96,10 @@ impl GroupBackendHandler for SqlBackendHandler {
}) })
.collect()) .collect())
} }
}
#[async_trait]
impl GroupBackendHandler for SqlBackendHandler {
#[instrument(skip_all, level = "debug", ret, err)] #[instrument(skip_all, level = "debug", ret, err)]
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails> { async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails> {
debug!(?group_id); debug!(?group_id);
@ -116,7 +128,7 @@ impl GroupBackendHandler for SqlBackendHandler {
#[instrument(skip_all, level = "debug", ret, err)] #[instrument(skip_all, level = "debug", ret, err)]
async fn create_group(&self, group_name: &str) -> Result<GroupId> { async fn create_group(&self, group_name: &str) -> Result<GroupId> {
debug!(?group_name); debug!(?group_name);
let now = chrono::Utc::now(); let now = chrono::Utc::now().naive_utc();
let uuid = Uuid::from_name_and_date(group_name, &now); let uuid = Uuid::from_name_and_date(group_name, &now);
let new_group = model::groups::ActiveModel { let new_group = model::groups::ActiveModel {
display_name: ActiveValue::Set(group_name.to_owned()), display_name: ActiveValue::Set(group_name.to_owned()),
@ -146,7 +158,7 @@ impl GroupBackendHandler for SqlBackendHandler {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::domain::{sql_backend_handler::tests::*, types::UserId}; use crate::domain::{handler::SubStringFilter, sql_backend_handler::tests::*, types::UserId};
async fn get_group_ids( async fn get_group_ids(
handler: &SqlBackendHandler, handler: &SqlBackendHandler,
@ -221,6 +233,24 @@ mod tests {
); );
} }
#[tokio::test]
async fn test_list_groups_substring_filter() {
let fixture = TestFixture::new().await;
assert_eq!(
get_group_ids(
&fixture.handler,
Some(GroupRequestFilter::DisplayNameSubString(SubStringFilter {
initial: Some("be".to_owned()),
any: vec!["sT".to_owned()],
final_: Some("P".to_owned()),
})),
)
.await,
// Best group
vec![fixture.groups[0]]
);
}
#[tokio::test] #[tokio::test]
async fn test_get_group_details() { async fn test_get_group_details() {
let fixture = TestFixture::new().await; let fixture = TestFixture::new().await;

View File

@ -2,12 +2,16 @@ use crate::domain::{
sql_tables::{DbConnection, SchemaVersion}, sql_tables::{DbConnection, SchemaVersion},
types::{GroupId, UserId, Uuid}, types::{GroupId, UserId, Uuid},
}; };
use sea_orm::{ConnectionTrait, FromQueryResult, Statement}; use sea_orm::{
use sea_query::{ColumnDef, Expr, ForeignKey, ForeignKeyAction, Iden, Query, Table, Value}; sea_query::{self, ColumnDef, Expr, ForeignKey, ForeignKeyAction, Query, Table, Value},
ConnectionTrait, FromQueryResult, Iden, Statement, TransactionTrait,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{instrument, warn}; use tracing::{info, instrument, warn};
#[derive(Iden, PartialEq, Eq, Debug, Serialize, Deserialize, Clone)] use super::sql_tables::LAST_SCHEMA_VERSION;
#[derive(Iden, PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Copy)]
pub enum Users { pub enum Users {
Table, Table,
UserId, UserId,
@ -23,7 +27,7 @@ pub enum Users {
Uuid, Uuid,
} }
#[derive(Iden, PartialEq, Eq, Debug, Serialize, Deserialize, Clone)] #[derive(Iden, PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Copy)]
pub enum Groups { pub enum Groups {
Table, Table,
GroupId, GroupId,
@ -32,7 +36,7 @@ pub enum Groups {
Uuid, Uuid,
} }
#[derive(Iden)] #[derive(Iden, Clone, Copy)]
pub enum Memberships { pub enum Memberships {
Table, Table,
UserId, UserId,
@ -116,6 +120,7 @@ pub async fn upgrade_to_v1(pool: &DbConnection) -> std::result::Result<(), sea_o
.col( .col(
ColumnDef::new(Groups::GroupId) ColumnDef::new(Groups::GroupId)
.integer() .integer()
.auto_increment()
.not_null() .not_null()
.primary_key(), .primary_key(),
) )
@ -169,7 +174,7 @@ pub async fn upgrade_to_v1(pool: &DbConnection) -> std::result::Result<(), sea_o
struct ShortGroupDetails { struct ShortGroupDetails {
group_id: GroupId, group_id: GroupId,
display_name: String, display_name: String,
creation_date: chrono::DateTime<chrono::Utc>, creation_date: chrono::NaiveDateTime,
} }
for result in ShortGroupDetails::find_by_statement( for result in ShortGroupDetails::find_by_statement(
builder.build( builder.build(
@ -219,7 +224,7 @@ pub async fn upgrade_to_v1(pool: &DbConnection) -> std::result::Result<(), sea_o
#[derive(FromQueryResult)] #[derive(FromQueryResult)]
struct ShortUserDetails { struct ShortUserDetails {
user_id: UserId, user_id: UserId,
creation_date: chrono::DateTime<chrono::Utc>, creation_date: chrono::NaiveDateTime,
} }
for result in ShortUserDetails::find_by_statement( for result in ShortUserDetails::find_by_statement(
builder.build( builder.build(
@ -309,7 +314,7 @@ pub async fn upgrade_to_v1(pool: &DbConnection) -> std::result::Result<(), sea_o
Table::create() Table::create()
.table(Metadata::Table) .table(Metadata::Table)
.if_not_exists() .if_not_exists()
.col(ColumnDef::new(Metadata::Version).tiny_integer()), .col(ColumnDef::new(Metadata::Version).small_integer()),
), ),
) )
.await?; .await?;
@ -329,12 +334,158 @@ pub async fn upgrade_to_v1(pool: &DbConnection) -> std::result::Result<(), sea_o
Ok(()) Ok(())
} }
pub async fn migrate_from_version( async fn replace_column<I: Iden + Copy + 'static, const N: usize>(
_pool: &DbConnection, pool: &DbConnection,
version: SchemaVersion, table_name: I,
column_name: I,
mut new_column: ColumnDef,
update_values: [Statement; N],
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
if version.0 > 1 { // Update the definition of a column (in a compatible way). Due to Sqlite, this is more complicated:
anyhow::bail!("DB version downgrading is not supported"); // - rename the column to a temporary name
} // - create the column with the new definition
// - copy the data from the temp column to the new one
// - update the new one if there are changes needed
// - drop the old one
let builder = pool.get_database_backend();
pool.transaction::<_, (), sea_orm::DbErr>(move |transaction| {
Box::pin(async move {
#[derive(Iden)]
enum TempTable {
TempName,
}
transaction
.execute(
builder.build(
Table::alter()
.table(table_name)
.rename_column(column_name, TempTable::TempName),
),
)
.await?;
transaction
.execute(
builder.build(Table::alter().table(table_name).add_column(&mut new_column)),
)
.await?;
transaction
.execute(
builder.build(
Query::update()
.table(table_name)
.value(column_name, Expr::col((table_name, TempTable::TempName))),
),
)
.await?;
for statement in update_values {
transaction.execute(statement).await?;
}
transaction
.execute(
builder.build(
Table::alter()
.table(table_name)
.drop_column(TempTable::TempName),
),
)
.await?;
Ok(())
})
})
.await?;
Ok(())
}
async fn migrate_to_v2(pool: &DbConnection) -> anyhow::Result<()> {
let builder = pool.get_database_backend();
// Allow nulls in DisplayName, and change empty string to null.
replace_column(
pool,
Users::Table,
Users::DisplayName,
ColumnDef::new(Users::DisplayName)
.string_len(255)
.to_owned(),
[builder.build(
Query::update()
.table(Users::Table)
.value(Users::DisplayName, Option::<String>::None)
.cond_where(Expr::col(Users::DisplayName).eq("")),
)],
)
.await?;
Ok(())
}
async fn migrate_to_v3(pool: &DbConnection) -> anyhow::Result<()> {
let builder = pool.get_database_backend();
// Allow nulls in First and LastName. Users who created their DB in 0.4.1 have the not null constraint.
replace_column(
pool,
Users::Table,
Users::FirstName,
ColumnDef::new(Users::FirstName).string_len(255).to_owned(),
[builder.build(
Query::update()
.table(Users::Table)
.value(Users::FirstName, Option::<String>::None)
.cond_where(Expr::col(Users::FirstName).eq("")),
)],
)
.await?;
replace_column(
pool,
Users::Table,
Users::LastName,
ColumnDef::new(Users::LastName).string_len(255).to_owned(),
[builder.build(
Query::update()
.table(Users::Table)
.value(Users::LastName, Option::<String>::None)
.cond_where(Expr::col(Users::LastName).eq("")),
)],
)
.await?;
// Change Avatar from binary to blob(long), because for MySQL this is 64kb.
replace_column(
pool,
Users::Table,
Users::Avatar,
ColumnDef::new(Users::Avatar)
.blob(sea_query::BlobSize::Long)
.to_owned(),
[],
)
.await?;
Ok(())
}
pub async fn migrate_from_version(
pool: &DbConnection,
version: SchemaVersion,
) -> anyhow::Result<()> {
match version.cmp(&LAST_SCHEMA_VERSION) {
std::cmp::Ordering::Less => info!(
"Upgrading DB schema from {} to {}",
version.0, LAST_SCHEMA_VERSION.0
),
std::cmp::Ordering::Equal => return Ok(()),
std::cmp::Ordering::Greater => anyhow::bail!("DB version downgrading is not supported"),
}
if version < SchemaVersion(2) {
migrate_to_v2(pool).await?;
}
if version < SchemaVersion(3) {
migrate_to_v3(pool).await?;
}
let builder = pool.get_database_backend();
pool.execute(
builder.build(
Query::update()
.table(Metadata::Table)
.value(Metadata::Version, Value::from(LAST_SCHEMA_VERSION)),
),
)
.await?;
Ok(()) Ok(())
} }

View File

@ -7,8 +7,9 @@ use super::{
types::UserId, types::UserId,
}; };
use async_trait::async_trait; use async_trait::async_trait;
use base64::Engine;
use lldap_auth::opaque; use lldap_auth::opaque;
use sea_orm::{ActiveModelTrait, ActiveValue, EntityTrait, FromQueryResult, QuerySelect}; use sea_orm::{ActiveModelTrait, ActiveValue, EntityTrait, QuerySelect};
use secstr::SecUtf8; use secstr::SecUtf8;
use tracing::{debug, instrument}; use tracing::{debug, instrument};
@ -50,18 +51,14 @@ impl SqlBackendHandler {
#[instrument(skip_all, level = "debug", err)] #[instrument(skip_all, level = "debug", err)]
async fn get_password_file_for_user(&self, user_id: UserId) -> Result<Option<Vec<u8>>> { async fn get_password_file_for_user(&self, user_id: UserId) -> Result<Option<Vec<u8>>> {
#[derive(FromQueryResult)]
struct OnlyPasswordHash {
password_hash: Option<Vec<u8>>,
}
// Fetch the previously registered password file from the DB. // Fetch the previously registered password file from the DB.
Ok(model::User::find_by_id(user_id) Ok(model::User::find_by_id(user_id)
.select_only() .select_only()
.column(UserColumn::PasswordHash) .column(UserColumn::PasswordHash)
.into_model::<OnlyPasswordHash>() .into_tuple::<(Option<Vec<u8>>,)>()
.one(&self.sql_pool) .one(&self.sql_pool)
.await? .await?
.and_then(|u| u.password_hash)) .and_then(|u| u.0))
} }
} }
@ -133,7 +130,7 @@ impl OpaqueHandler for SqlOpaqueHandler {
let encrypted_state = orion::aead::seal(&secret_key, &bincode::serialize(&server_data)?)?; let encrypted_state = orion::aead::seal(&secret_key, &bincode::serialize(&server_data)?)?;
Ok(login::ServerLoginStartResponse { Ok(login::ServerLoginStartResponse {
server_data: base64::encode(&encrypted_state), server_data: base64::engine::general_purpose::STANDARD.encode(encrypted_state),
credential_response: start_response.message, credential_response: start_response.message,
}) })
} }
@ -146,7 +143,7 @@ impl OpaqueHandler for SqlOpaqueHandler {
server_login, server_login,
} = bincode::deserialize(&orion::aead::open( } = bincode::deserialize(&orion::aead::open(
&secret_key, &secret_key,
&base64::decode(&request.server_data)?, &base64::engine::general_purpose::STANDARD.decode(&request.server_data)?,
)?)?; )?)?;
// Finish the login: this makes sure the client data is correct, and gives a session key we // Finish the login: this makes sure the client data is correct, and gives a session key we
// don't need. // don't need.
@ -174,7 +171,7 @@ impl OpaqueHandler for SqlOpaqueHandler {
}; };
let encrypted_state = orion::aead::seal(&secret_key, &bincode::serialize(&server_data)?)?; let encrypted_state = orion::aead::seal(&secret_key, &bincode::serialize(&server_data)?)?;
Ok(registration::ServerRegistrationStartResponse { Ok(registration::ServerRegistrationStartResponse {
server_data: base64::encode(encrypted_state), server_data: base64::engine::general_purpose::STANDARD.encode(encrypted_state),
registration_response: start_response.message, registration_response: start_response.message,
}) })
} }
@ -187,7 +184,7 @@ impl OpaqueHandler for SqlOpaqueHandler {
let secret_key = self.get_orion_secret_key()?; let secret_key = self.get_orion_secret_key()?;
let registration::ServerData { username } = bincode::deserialize(&orion::aead::open( let registration::ServerData { username } = bincode::deserialize(&orion::aead::open(
&secret_key, &secret_key,
&base64::decode(&request.server_data)?, &base64::engine::general_purpose::STANDARD.decode(&request.server_data)?,
)?)?; )?)?;
let password_file = let password_file =

View File

@ -3,16 +3,15 @@ use sea_orm::Value;
pub type DbConnection = sea_orm::DatabaseConnection; pub type DbConnection = sea_orm::DatabaseConnection;
#[derive(Copy, PartialEq, Eq, Debug, Clone)] #[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord)]
pub struct SchemaVersion(pub u8); pub struct SchemaVersion(pub i16);
impl sea_orm::TryGetable for SchemaVersion { impl sea_orm::TryGetable for SchemaVersion {
fn try_get( fn try_get_by<I: sea_orm::ColIdx>(
res: &sea_orm::QueryResult, res: &sea_orm::QueryResult,
pre: &str, index: I,
col: &str,
) -> Result<Self, sea_orm::TryGetError> { ) -> Result<Self, sea_orm::TryGetError> {
Ok(SchemaVersion(u8::try_get(res, pre, col)?)) Ok(SchemaVersion(i16::try_get_by(res, index)?))
} }
} }
@ -22,6 +21,8 @@ impl From<SchemaVersion> for Value {
} }
} }
pub const LAST_SCHEMA_VERSION: SchemaVersion = SchemaVersion(3);
pub async fn init_table(pool: &DbConnection) -> anyhow::Result<()> { pub async fn init_table(pool: &DbConnection) -> anyhow::Result<()> {
let version = { let version = {
if let Some(version) = get_schema_version(pool).await { if let Some(version) = get_schema_version(pool).await {
@ -67,7 +68,7 @@ mod tests {
#[derive(FromQueryResult, PartialEq, Eq, Debug)] #[derive(FromQueryResult, PartialEq, Eq, Debug)]
struct ShortUserDetails { struct ShortUserDetails {
display_name: String, display_name: String,
creation_date: chrono::DateTime<chrono::Utc>, creation_date: chrono::NaiveDateTime,
} }
let result = ShortUserDetails::find_by_statement(raw_statement( let result = ShortUserDetails::find_by_statement(raw_statement(
r#"SELECT display_name, creation_date FROM users WHERE user_id = "bôb""#, r#"SELECT display_name, creation_date FROM users WHERE user_id = "bôb""#,
@ -80,7 +81,7 @@ mod tests {
result, result,
ShortUserDetails { ShortUserDetails {
display_name: "Bob Bobbersön".to_owned(), display_name: "Bob Bobbersön".to_owned(),
creation_date: Utc.timestamp_opt(0, 0).unwrap() creation_date: Utc.timestamp_opt(0, 0).unwrap().naive_utc(),
} }
); );
} }
@ -99,14 +100,21 @@ mod tests {
let sql_pool = get_in_memory_db().await; let sql_pool = get_in_memory_db().await;
sql_pool sql_pool
.execute(raw_statement( .execute(raw_statement(
r#"CREATE TABLE users ( user_id TEXT , creation_date TEXT);"#, r#"CREATE TABLE users ( user_id TEXT, display_name TEXT, first_name TEXT NOT NULL, last_name TEXT, avatar BLOB, creation_date TEXT);"#,
)) ))
.await .await
.unwrap(); .unwrap();
sql_pool sql_pool
.execute(raw_statement( .execute(raw_statement(
r#"INSERT INTO users (user_id, creation_date) r#"INSERT INTO users (user_id, display_name, first_name, creation_date)
VALUES ("bôb", "1970-01-01 00:00:00")"#, VALUES ("bôb", "", "", "1970-01-01 00:00:00")"#,
))
.await
.unwrap();
sql_pool
.execute(raw_statement(
r#"INSERT INTO users (user_id, display_name, first_name, creation_date)
VALUES ("john", "John Doe", "John", "1971-01-01 00:00:00")"#,
)) ))
.await .await
.unwrap(); .unwrap();
@ -132,17 +140,30 @@ mod tests {
.await .await
.unwrap(); .unwrap();
#[derive(FromQueryResult, PartialEq, Eq, Debug)] #[derive(FromQueryResult, PartialEq, Eq, Debug)]
struct JustUuid { struct SimpleUser {
display_name: Option<String>,
first_name: Option<String>,
uuid: Uuid, uuid: Uuid,
} }
assert_eq!( assert_eq!(
JustUuid::find_by_statement(raw_statement(r#"SELECT uuid FROM users"#)) SimpleUser::find_by_statement(raw_statement(
.all(&sql_pool) r#"SELECT display_name, first_name, uuid FROM users ORDER BY display_name"#
.await ))
.unwrap(), .all(&sql_pool)
vec![JustUuid { .await
uuid: crate::uuid!("a02eaf13-48a7-30f6-a3d4-040ff7c52b04") .unwrap(),
}] vec![
SimpleUser {
display_name: None,
first_name: None,
uuid: crate::uuid!("a02eaf13-48a7-30f6-a3d4-040ff7c52b04")
},
SimpleUser {
display_name: Some("John Doe".to_owned()),
first_name: Some("John".to_owned()),
uuid: crate::uuid!("986765a5-3f03-389e-b47b-536b2d6e1bec")
}
]
); );
#[derive(FromQueryResult, PartialEq, Eq, Debug)] #[derive(FromQueryResult, PartialEq, Eq, Debug)]
struct ShortGroupDetails { struct ShortGroupDetails {
@ -180,7 +201,7 @@ mod tests {
.unwrap() .unwrap()
.unwrap(), .unwrap(),
sql_migrations::JustSchemaVersion { sql_migrations::JustSchemaVersion {
version: SchemaVersion(1) version: LAST_SCHEMA_VERSION
} }
); );
} }

View File

@ -1,6 +1,9 @@
use super::{ use crate::domain::{
error::{DomainError, Result}, error::{DomainError, Result},
handler::{CreateUserRequest, UpdateUserRequest, UserBackendHandler, UserRequestFilter}, handler::{
CreateUserRequest, UpdateUserRequest, UserBackendHandler, UserListerBackendHandler,
UserRequestFilter,
},
model::{self, GroupColumn, UserColumn}, model::{self, GroupColumn, UserColumn},
sql_backend_handler::SqlBackendHandler, sql_backend_handler::SqlBackendHandler,
types::{GroupDetails, GroupId, User, UserAndGroups, UserId, Uuid}, types::{GroupDetails, GroupId, User, UserAndGroups, UserId, Uuid},
@ -8,11 +11,10 @@ use super::{
use async_trait::async_trait; use async_trait::async_trait;
use sea_orm::{ use sea_orm::{
entity::IntoActiveValue, entity::IntoActiveValue,
sea_query::{Cond, Expr, IntoCondition, SimpleExpr}, sea_query::{Alias, Cond, Expr, Func, IntoColumnRef, IntoCondition, SimpleExpr},
ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, QueryOrder, ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, ModelTrait, QueryFilter, QueryOrder,
QuerySelect, QueryTrait, Set, QuerySelect, QueryTrait, Set,
}; };
use sea_query::{Alias, IntoColumnRef};
use std::collections::HashSet; use std::collections::HashSet;
use tracing::{debug, instrument}; use tracing::{debug, instrument};
@ -50,8 +52,15 @@ fn get_user_filter_expr(filter: UserRequestFilter) -> Cond {
MemberOfId(group_id) => Expr::col((group_table, GroupColumn::GroupId)) MemberOfId(group_id) => Expr::col((group_table, GroupColumn::GroupId))
.eq(group_id) .eq(group_id)
.into_condition(), .into_condition(),
UserIdSubString(filter) => UserColumn::UserId
.like(&filter.to_sql_filter())
.into_condition(),
SubString(col, filter) => SimpleExpr::FunctionCall(Func::lower(Expr::col(col)))
.like(filter.to_sql_filter())
.into_condition(),
} }
} }
fn to_value(opt_name: &Option<String>) -> ActiveValue<Option<String>> { fn to_value(opt_name: &Option<String>) -> ActiveValue<Option<String>> {
match opt_name { match opt_name {
None => ActiveValue::NotSet, None => ActiveValue::NotSet,
@ -64,7 +73,7 @@ fn to_value(opt_name: &Option<String>) -> ActiveValue<Option<String>> {
} }
#[async_trait] #[async_trait]
impl UserBackendHandler for SqlBackendHandler { impl UserListerBackendHandler for SqlBackendHandler {
#[instrument(skip_all, level = "debug", ret, err)] #[instrument(skip_all, level = "debug", ret, err)]
async fn list_users( async fn list_users(
&self, &self,
@ -129,7 +138,10 @@ impl UserBackendHandler for SqlBackendHandler {
.collect()) .collect())
} }
} }
}
#[async_trait]
impl UserBackendHandler for SqlBackendHandler {
#[instrument(skip_all, level = "debug", ret)] #[instrument(skip_all, level = "debug", ret)]
async fn get_user_details(&self, user_id: &UserId) -> Result<User> { async fn get_user_details(&self, user_id: &UserId) -> Result<User> {
debug!(?user_id); debug!(?user_id);
@ -158,7 +170,7 @@ impl UserBackendHandler for SqlBackendHandler {
#[instrument(skip_all, level = "debug", err)] #[instrument(skip_all, level = "debug", err)]
async fn create_user(&self, request: CreateUserRequest) -> Result<()> { async fn create_user(&self, request: CreateUserRequest) -> Result<()> {
debug!(user_id = ?request.user_id); debug!(user_id = ?request.user_id);
let now = chrono::Utc::now(); let now = chrono::Utc::now().naive_utc();
let uuid = Uuid::from_name_and_date(request.user_id.as_str(), &now); let uuid = Uuid::from_name_and_date(request.user_id.as_str(), &now);
let new_user = model::users::ActiveModel { let new_user = model::users::ActiveModel {
user_id: Set(request.user_id), user_id: Set(request.user_id),
@ -237,6 +249,7 @@ impl UserBackendHandler for SqlBackendHandler {
mod tests { mod tests {
use super::*; use super::*;
use crate::domain::{ use crate::domain::{
handler::SubStringFilter,
sql_backend_handler::tests::*, sql_backend_handler::tests::*,
types::{JpegPhoto, UserColumn}, types::{JpegPhoto, UserColumn},
}; };
@ -287,6 +300,31 @@ mod tests {
assert_eq!(users, vec!["bob"]); assert_eq!(users, vec!["bob"]);
} }
#[tokio::test]
async fn test_list_users_substring_filter() {
let fixture = TestFixture::new().await;
let users = get_user_names(
&fixture.handler,
Some(UserRequestFilter::And(vec![
UserRequestFilter::UserIdSubString(SubStringFilter {
initial: Some("Pa".to_owned()),
any: vec!["rI".to_owned()],
final_: Some("K".to_owned()),
}),
UserRequestFilter::SubString(
UserColumn::FirstName,
SubStringFilter {
initial: None,
any: vec!["r".to_owned(), "t".to_owned()],
final_: None,
},
),
])),
)
.await;
assert_eq!(users, vec!["patrick"]);
}
#[tokio::test] #[tokio::test]
async fn test_list_users_false_filter() { async fn test_list_users_false_filter() {
let fixture = TestFixture::new().await; let fixture = TestFixture::new().await;

View File

@ -1,3 +1,5 @@
use base64::Engine;
use chrono::{NaiveDateTime, TimeZone};
use sea_orm::{ use sea_orm::{
entity::IntoActiveValue, entity::IntoActiveValue,
sea_query::{value::ValueType, ArrayType, ColumnType, Nullable, ValueTypeErr}, sea_query::{value::ValueType, ArrayType, ColumnType, Nullable, ValueTypeErr},
@ -7,18 +9,23 @@ use serde::{Deserialize, Serialize};
pub use super::model::{GroupColumn, UserColumn}; pub use super::model::{GroupColumn, UserColumn};
pub type DateTime = chrono::DateTime<chrono::Utc>;
#[derive(PartialEq, Hash, Eq, Clone, Debug, Default, Serialize, Deserialize)] #[derive(PartialEq, Hash, Eq, Clone, Debug, Default, Serialize, Deserialize)]
#[serde(try_from = "&str")] #[serde(try_from = "&str")]
pub struct Uuid(String); pub struct Uuid(String);
impl Uuid { impl Uuid {
pub fn from_name_and_date(name: &str, creation_date: &DateTime) -> Self { pub fn from_name_and_date(name: &str, creation_date: &NaiveDateTime) -> Self {
Uuid( Uuid(
uuid::Uuid::new_v3( uuid::Uuid::new_v3(
&uuid::Uuid::NAMESPACE_X500, &uuid::Uuid::NAMESPACE_X500,
&[name.as_bytes(), creation_date.to_rfc3339().as_bytes()].concat(), &[
name.as_bytes(),
chrono::Utc
.from_utc_datetime(creation_date)
.to_rfc3339()
.as_bytes(),
]
.concat(),
) )
.to_string(), .to_string(),
) )
@ -47,8 +54,11 @@ impl std::string::ToString for Uuid {
} }
impl TryGetable for Uuid { impl TryGetable for Uuid {
fn try_get(res: &QueryResult, pre: &str, col: &str) -> std::result::Result<Self, TryGetError> { fn try_get_by<I: sea_orm::ColIdx>(
Ok(Uuid(String::try_get(res, pre, col)?)) res: &QueryResult,
index: I,
) -> std::result::Result<Self, TryGetError> {
Ok(Uuid(String::try_get_by(res, index)?))
} }
} }
@ -136,8 +146,8 @@ impl From<&UserId> for Value {
} }
impl TryGetable for UserId { impl TryGetable for UserId {
fn try_get(res: &QueryResult, pre: &str, col: &str) -> Result<Self, TryGetError> { fn try_get_by<I: sea_orm::ColIdx>(res: &QueryResult, index: I) -> Result<Self, TryGetError> {
Ok(UserId::new(&String::try_get(res, pre, col)?)) Ok(UserId::new(&String::try_get_by(res, index)?))
} }
} }
@ -215,13 +225,15 @@ impl TryFrom<String> for JpegPhoto {
type Error = anyhow::Error; type Error = anyhow::Error;
fn try_from(string: String) -> anyhow::Result<Self> { fn try_from(string: String) -> anyhow::Result<Self> {
// The String format is in base64. // The String format is in base64.
<Self as TryFrom<_>>::try_from(base64::decode(string.as_str())?) <Self as TryFrom<_>>::try_from(
base64::engine::general_purpose::STANDARD.decode(string.as_str())?,
)
} }
} }
impl From<&JpegPhoto> for String { impl From<&JpegPhoto> for String {
fn from(val: &JpegPhoto) -> Self { fn from(val: &JpegPhoto) -> Self {
base64::encode(&val.0) base64::engine::general_purpose::STANDARD.encode(&val.0)
} }
} }
@ -255,8 +267,8 @@ impl JpegPhoto {
} }
impl TryGetable for JpegPhoto { impl TryGetable for JpegPhoto {
fn try_get(res: &QueryResult, pre: &str, col: &str) -> Result<Self, TryGetError> { fn try_get_by<I: sea_orm::ColIdx>(res: &QueryResult, index: I) -> Result<Self, TryGetError> {
<Self as std::convert::TryFrom<Vec<_>>>::try_from(Vec::<u8>::try_get(res, pre, col)?) <Self as std::convert::TryFrom<Vec<_>>>::try_from(Vec::<u8>::try_get_by(res, index)?)
.map_err(|e| { .map_err(|e| {
TryGetError::DbErr(DbErr::TryIntoErr { TryGetError::DbErr(DbErr::TryIntoErr {
from: "[u8]", from: "[u8]",
@ -308,15 +320,14 @@ pub struct User {
pub first_name: Option<String>, pub first_name: Option<String>,
pub last_name: Option<String>, pub last_name: Option<String>,
pub avatar: Option<JpegPhoto>, pub avatar: Option<JpegPhoto>,
pub creation_date: DateTime, pub creation_date: NaiveDateTime,
pub uuid: Uuid, pub uuid: Uuid,
} }
#[cfg(test)] #[cfg(test)]
impl Default for User { impl Default for User {
fn default() -> Self { fn default() -> Self {
use chrono::TimeZone; let epoch = chrono::Utc.timestamp_opt(0, 0).unwrap().naive_utc();
let epoch = chrono::Utc.timestamp_opt(0, 0).unwrap();
User { User {
user_id: UserId::default(), user_id: UserId::default(),
email: String::new(), email: String::new(),
@ -340,8 +351,8 @@ impl From<GroupId> for Value {
} }
impl TryGetable for GroupId { impl TryGetable for GroupId {
fn try_get(res: &QueryResult, pre: &str, col: &str) -> Result<Self, TryGetError> { fn try_get_by<I: sea_orm::ColIdx>(res: &QueryResult, index: I) -> Result<Self, TryGetError> {
Ok(GroupId(i32::try_get(res, pre, col)?)) Ok(GroupId(i32::try_get_by(res, index)?))
} }
} }
@ -359,7 +370,7 @@ impl ValueType for GroupId {
} }
fn column_type() -> ColumnType { fn column_type() -> ColumnType {
ColumnType::Integer(None) ColumnType::Integer
} }
} }
@ -373,7 +384,7 @@ impl TryFromU64 for GroupId {
pub struct Group { pub struct Group {
pub id: GroupId, pub id: GroupId,
pub display_name: String, pub display_name: String,
pub creation_date: DateTime, pub creation_date: NaiveDateTime,
pub uuid: Uuid, pub uuid: Uuid,
pub users: Vec<UserId>, pub users: Vec<UserId>,
} }
@ -382,7 +393,7 @@ pub struct Group {
pub struct GroupDetails { pub struct GroupDetails {
pub group_id: GroupId, pub group_id: GroupId,
pub display_name: String, pub display_name: String,
pub creation_date: DateTime, pub creation_date: NaiveDateTime,
pub uuid: Uuid, pub uuid: Uuid,
} }

View File

@ -0,0 +1,317 @@
use std::collections::HashSet;
use async_trait::async_trait;
use tracing::info;
use crate::domain::{
error::Result,
handler::{
BackendHandler, CreateUserRequest, GroupListerBackendHandler, GroupRequestFilter,
UpdateGroupRequest, UpdateUserRequest, UserListerBackendHandler, UserRequestFilter,
},
types::{Group, GroupDetails, GroupId, User, UserAndGroups, UserId},
};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Permission {
Admin,
PasswordManager,
Readonly,
Regular,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationResults {
pub user: UserId,
pub permission: Permission,
}
impl ValidationResults {
#[cfg(test)]
pub fn admin() -> Self {
Self {
user: UserId::new("admin"),
permission: Permission::Admin,
}
}
#[must_use]
pub fn is_admin(&self) -> bool {
self.permission == Permission::Admin
}
#[must_use]
pub fn can_read_all(&self) -> bool {
self.permission == Permission::Admin
|| self.permission == Permission::Readonly
|| self.permission == Permission::PasswordManager
}
#[must_use]
pub fn can_read(&self, user: &UserId) -> bool {
self.permission == Permission::Admin
|| self.permission == Permission::PasswordManager
|| self.permission == Permission::Readonly
|| &self.user == user
}
#[must_use]
pub fn can_change_password(&self, user: &UserId, user_is_admin: bool) -> bool {
self.permission == Permission::Admin
|| (self.permission == Permission::PasswordManager && !user_is_admin)
|| &self.user == user
}
#[must_use]
pub fn can_write(&self, user: &UserId) -> bool {
self.permission == Permission::Admin || &self.user == user
}
}
#[async_trait]
pub trait UserReadableBackendHandler {
async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
async fn get_user_groups(&self, user_id: &UserId) -> Result<HashSet<GroupDetails>>;
}
#[async_trait]
pub trait ReadonlyBackendHandler: UserReadableBackendHandler {
async fn list_users(
&self,
filters: Option<UserRequestFilter>,
get_groups: bool,
) -> Result<Vec<UserAndGroups>>;
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>;
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>;
}
#[async_trait]
pub trait UserWriteableBackendHandler: UserReadableBackendHandler {
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
}
#[async_trait]
pub trait AdminBackendHandler:
UserWriteableBackendHandler + ReadonlyBackendHandler + UserWriteableBackendHandler
{
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
async fn delete_user(&self, user_id: &UserId) -> Result<()>;
async fn add_user_to_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
async fn delete_group(&self, group_id: GroupId) -> Result<()>;
}
#[async_trait]
impl<Handler: BackendHandler> UserReadableBackendHandler for Handler {
async fn get_user_details(&self, user_id: &UserId) -> Result<User> {
self.get_user_details(user_id).await
}
async fn get_user_groups(&self, user_id: &UserId) -> Result<HashSet<GroupDetails>> {
self.get_user_groups(user_id).await
}
}
#[async_trait]
impl<Handler: BackendHandler> ReadonlyBackendHandler for Handler {
async fn list_users(
&self,
filters: Option<UserRequestFilter>,
get_groups: bool,
) -> Result<Vec<UserAndGroups>> {
self.list_users(filters, get_groups).await
}
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>> {
self.list_groups(filters).await
}
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails> {
self.get_group_details(group_id).await
}
}
#[async_trait]
impl<Handler: BackendHandler> UserWriteableBackendHandler for Handler {
async fn update_user(&self, request: UpdateUserRequest) -> Result<()> {
self.update_user(request).await
}
}
#[async_trait]
impl<Handler: BackendHandler> AdminBackendHandler for Handler {
async fn create_user(&self, request: CreateUserRequest) -> Result<()> {
self.create_user(request).await
}
async fn delete_user(&self, user_id: &UserId) -> Result<()> {
self.delete_user(user_id).await
}
async fn add_user_to_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()> {
self.add_user_to_group(user_id, group_id).await
}
async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()> {
self.remove_user_from_group(user_id, group_id).await
}
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()> {
self.update_group(request).await
}
async fn create_group(&self, group_name: &str) -> Result<GroupId> {
self.create_group(group_name).await
}
async fn delete_group(&self, group_id: GroupId) -> Result<()> {
self.delete_group(group_id).await
}
}
pub struct AccessControlledBackendHandler<Handler> {
handler: Handler,
}
impl<Handler: Clone> Clone for AccessControlledBackendHandler<Handler> {
fn clone(&self) -> Self {
Self {
handler: self.handler.clone(),
}
}
}
impl<Handler> AccessControlledBackendHandler<Handler> {
pub fn unsafe_get_handler(&self) -> &Handler {
&self.handler
}
}
impl<Handler: BackendHandler> AccessControlledBackendHandler<Handler> {
pub fn new(handler: Handler) -> Self {
Self { handler }
}
pub fn get_admin_handler(
&self,
validation_result: &ValidationResults,
) -> Option<&impl AdminBackendHandler> {
validation_result.is_admin().then_some(&self.handler)
}
pub fn get_readonly_handler(
&self,
validation_result: &ValidationResults,
) -> Option<&impl ReadonlyBackendHandler> {
validation_result.can_read_all().then_some(&self.handler)
}
pub fn get_writeable_handler(
&self,
validation_result: &ValidationResults,
user_id: &UserId,
) -> Option<&impl UserWriteableBackendHandler> {
validation_result
.can_write(user_id)
.then_some(&self.handler)
}
pub fn get_readable_handler(
&self,
validation_result: &ValidationResults,
user_id: &UserId,
) -> Option<&impl UserReadableBackendHandler> {
validation_result.can_read(user_id).then_some(&self.handler)
}
pub fn get_user_restricted_lister_handler(
&self,
validation_result: &ValidationResults,
) -> UserRestrictedListerBackendHandler<'_, Handler> {
UserRestrictedListerBackendHandler {
handler: &self.handler,
user_filter: if validation_result.can_read_all() {
None
} else {
info!("Unprivileged search, limiting results");
Some(validation_result.user.clone())
},
}
}
pub async fn get_permissions_for_user(&self, user_id: UserId) -> Result<ValidationResults> {
let user_groups = self.handler.get_user_groups(&user_id).await?;
Ok(self.get_permissions_from_groups(user_id, user_groups.iter().map(|g| &g.display_name)))
}
pub fn get_permissions_from_groups<'a, Groups: Iterator<Item = &'a String> + Clone + 'a>(
&self,
user_id: UserId,
groups: Groups,
) -> ValidationResults {
let is_in_group = |name| groups.clone().any(|g| g == name);
ValidationResults {
user: user_id,
permission: if is_in_group("lldap_admin") {
Permission::Admin
} else if is_in_group("lldap_password_manager") {
Permission::PasswordManager
} else if is_in_group("lldap_strict_readonly") {
Permission::Readonly
} else {
Permission::Regular
},
}
}
}
pub struct UserRestrictedListerBackendHandler<'a, Handler> {
handler: &'a Handler,
pub user_filter: Option<UserId>,
}
#[async_trait]
impl<'a, Handler: UserListerBackendHandler + Sync> UserListerBackendHandler
for UserRestrictedListerBackendHandler<'a, Handler>
{
async fn list_users(
&self,
filters: Option<UserRequestFilter>,
get_groups: bool,
) -> Result<Vec<UserAndGroups>> {
let user_filter = self
.user_filter
.as_ref()
.map(|u| UserRequestFilter::UserId(u.clone()));
let filters = match (filters, user_filter) {
(None, None) => None,
(None, u) => u,
(f, None) => f,
(Some(f), Some(u)) => Some(UserRequestFilter::And(vec![f, u])),
};
self.handler.list_users(filters, get_groups).await
}
}
#[async_trait]
impl<'a, Handler: GroupListerBackendHandler + Sync> GroupListerBackendHandler
for UserRestrictedListerBackendHandler<'a, Handler>
{
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>> {
let group_filter = self
.user_filter
.as_ref()
.map(|u| GroupRequestFilter::Member(u.clone()));
let filters = match (filters, group_filter) {
(None, None) => None,
(None, u) => u,
(f, None) => f,
(Some(f), Some(u)) => Some(GroupRequestFilter::And(vec![f, u])),
};
self.handler.list_groups(filters).await
}
}
#[async_trait]
pub trait UserAndGroupListerBackendHandler:
UserListerBackendHandler + GroupListerBackendHandler
{
}
#[async_trait]
impl<'a, Handler: GroupListerBackendHandler + UserListerBackendHandler + Sync>
UserAndGroupListerBackendHandler for UserRestrictedListerBackendHandler<'a, Handler>
{
}

View File

@ -30,6 +30,7 @@ use crate::{
types::{GroupDetails, UserColumn, UserId}, types::{GroupDetails, UserColumn, UserId},
}, },
infra::{ infra::{
access_control::{ReadonlyBackendHandler, UserReadableBackendHandler, ValidationResults},
tcp_backend_handler::*, tcp_backend_handler::*,
tcp_server::{error_to_http_response, AppState, TcpError, TcpResult}, tcp_server::{error_to_http_response, AppState, TcpError, TcpResult},
}, },
@ -87,11 +88,10 @@ async fn get_refresh<Backend>(
where where
Backend: TcpBackendHandler + BackendHandler + 'static, Backend: TcpBackendHandler + BackendHandler + 'static,
{ {
let backend_handler = &data.backend_handler;
let jwt_key = &data.jwt_key; let jwt_key = &data.jwt_key;
let (refresh_token_hash, user) = get_refresh_token(request)?; let (refresh_token_hash, user) = get_refresh_token(request)?;
let found = data let found = data
.backend_handler .get_tcp_handler()
.check_token(refresh_token_hash, &user) .check_token(refresh_token_hash, &user)
.await?; .await?;
if !found { if !found {
@ -99,7 +99,8 @@ where
"Invalid refresh token".to_string(), "Invalid refresh token".to_string(),
))); )));
} }
Ok(backend_handler Ok(data
.get_readonly_handler()
.get_user_groups(&user) .get_user_groups(&user)
.await .await
.map(|groups| create_jwt(jwt_key, user.to_string(), groups)) .map(|groups| create_jwt(jwt_key, user.to_string(), groups))
@ -145,7 +146,7 @@ where
.get("user_id") .get("user_id")
.ok_or_else(|| TcpError::BadRequest("Missing user ID".to_string()))?; .ok_or_else(|| TcpError::BadRequest("Missing user ID".to_string()))?;
let user_results = data let user_results = data
.backend_handler .get_readonly_handler()
.list_users( .list_users(
Some(UserRequestFilter::Or(vec![ Some(UserRequestFilter::Or(vec![
UserRequestFilter::UserId(UserId::new(user_string)), UserRequestFilter::UserId(UserId::new(user_string)),
@ -163,7 +164,7 @@ where
} }
let user = &user_results[0].user; let user = &user_results[0].user;
let token = match data let token = match data
.backend_handler .get_tcp_handler()
.start_password_reset(&user.user_id) .start_password_reset(&user.user_id)
.await? .await?
{ {
@ -214,13 +215,17 @@ where
let token = request let token = request
.match_info() .match_info()
.get("token") .get("token")
.ok_or_else(|| TcpError::BadRequest("Missing reset token".to_string()))?; .ok_or_else(|| TcpError::BadRequest("Missing reset token".to_owned()))?;
let user_id = data let user_id = data
.backend_handler .get_tcp_handler()
.get_user_id_for_password_reset_token(token) .get_user_id_for_password_reset_token(token)
.await?; .await
.map_err(|e| {
debug!("Reset token error: {e:#}");
TcpError::NotFoundError("Wrong or expired reset token".to_owned())
})?;
let _ = data let _ = data
.backend_handler .get_tcp_handler()
.delete_password_reset_token(token) .delete_password_reset_token(token)
.await; .await;
let groups = HashSet::new(); let groups = HashSet::new();
@ -262,10 +267,10 @@ where
Backend: TcpBackendHandler + BackendHandler + 'static, Backend: TcpBackendHandler + BackendHandler + 'static,
{ {
let (refresh_token_hash, user) = get_refresh_token(request)?; let (refresh_token_hash, user) = get_refresh_token(request)?;
data.backend_handler data.get_tcp_handler()
.delete_refresh_token(refresh_token_hash) .delete_refresh_token(refresh_token_hash)
.await?; .await?;
let new_blacklisted_jwts = data.backend_handler.blacklist_jwts(&user).await?; let new_blacklisted_jwts = data.get_tcp_handler().blacklist_jwts(&user).await?;
let mut jwt_blacklist = data.jwt_blacklist.write().unwrap(); let mut jwt_blacklist = data.jwt_blacklist.write().unwrap();
for jwt in new_blacklisted_jwts { for jwt in new_blacklisted_jwts {
jwt_blacklist.insert(jwt); jwt_blacklist.insert(jwt);
@ -316,7 +321,7 @@ async fn opaque_login_start<Backend>(
where where
Backend: OpaqueHandler + 'static, Backend: OpaqueHandler + 'static,
{ {
data.backend_handler data.get_opaque_handler()
.login_start(request.into_inner()) .login_start(request.into_inner())
.await .await
.map(|res| ApiResult::Left(web::Json(res))) .map(|res| ApiResult::Left(web::Json(res)))
@ -333,8 +338,8 @@ where
{ {
// The authentication was successful, we need to fetch the groups to create the JWT // The authentication was successful, we need to fetch the groups to create the JWT
// token. // token.
let groups = data.backend_handler.get_user_groups(name).await?; let groups = data.get_readonly_handler().get_user_groups(name).await?;
let (refresh_token, max_age) = data.backend_handler.create_refresh_token(name).await?; let (refresh_token, max_age) = data.get_tcp_handler().create_refresh_token(name).await?;
let token = create_jwt(&data.jwt_key, name.to_string(), groups); let token = create_jwt(&data.jwt_key, name.to_string(), groups);
let refresh_token_plus_name = refresh_token + "+" + name.as_str(); let refresh_token_plus_name = refresh_token + "+" + name.as_str();
@ -370,7 +375,7 @@ where
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static, Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static,
{ {
let name = data let name = data
.backend_handler .get_opaque_handler()
.login_finish(request.into_inner()) .login_finish(request.into_inner())
.await?; .await?;
get_login_successful_response(&data, &name).await get_login_successful_response(&data, &name).await
@ -401,7 +406,7 @@ where
name: user_id.clone(), name: user_id.clone(),
password: request.password.clone(), password: request.password.clone(),
}; };
data.backend_handler.bind(bind_request).await?; data.get_login_handler().bind(bind_request).await?;
get_login_successful_response(&data, &user_id).await get_login_successful_response(&data, &user_id).await
} }
@ -427,7 +432,7 @@ where
{ {
let name = request.name.clone(); let name = request.name.clone();
debug!(%name); debug!(%name);
data.backend_handler.bind(request.into_inner()).await?; data.get_login_handler().bind(request.into_inner()).await?;
get_login_successful_response(&data, &name).await get_login_successful_response(&data, &name).await
} }
@ -446,14 +451,15 @@ where
#[instrument(skip_all, level = "debug")] #[instrument(skip_all, level = "debug")]
async fn opaque_register_start<Backend>( async fn opaque_register_start<Backend>(
request: actix_web::HttpRequest, request: actix_web::HttpRequest,
mut payload: actix_web::web::Payload, payload: actix_web::web::Payload,
data: web::Data<AppState<Backend>>, data: web::Data<AppState<Backend>>,
) -> TcpResult<registration::ServerRegistrationStartResponse> ) -> TcpResult<registration::ServerRegistrationStartResponse>
where where
Backend: BackendHandler + OpaqueHandler + 'static, Backend: BackendHandler + OpaqueHandler + 'static,
{ {
use actix_web::FromRequest; use actix_web::FromRequest;
let validation_result = BearerAuth::from_request(&request, &mut payload.0) let inner_payload = &mut payload.into_inner();
let validation_result = BearerAuth::from_request(&request, inner_payload)
.await .await
.ok() .ok()
.and_then(|bearer| check_if_token_is_valid(&data, bearer.token()).ok()) .and_then(|bearer| check_if_token_is_valid(&data, bearer.token()).ok())
@ -463,14 +469,14 @@ where
let registration_start_request = let registration_start_request =
web::Json::<registration::ClientRegistrationStartRequest>::from_request( web::Json::<registration::ClientRegistrationStartRequest>::from_request(
&request, &request,
&mut payload.0, inner_payload,
) )
.await .await
.map_err(|e| TcpError::BadRequest(format!("{:#?}", e)))? .map_err(|e| TcpError::BadRequest(format!("{:#?}", e)))?
.into_inner(); .into_inner();
let user_id = UserId::new(&registration_start_request.username); let user_id = UserId::new(&registration_start_request.username);
let user_is_admin = data let user_is_admin = data
.backend_handler .get_readonly_handler()
.get_user_groups(&user_id) .get_user_groups(&user_id)
.await? .await?
.iter() .iter()
@ -481,7 +487,7 @@ where
)); ));
} }
Ok(data Ok(data
.backend_handler .get_opaque_handler()
.registration_start(registration_start_request) .registration_start(registration_start_request)
.await?) .await?)
} }
@ -508,7 +514,7 @@ async fn opaque_register_finish<Backend>(
where where
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static, Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static,
{ {
data.backend_handler data.get_opaque_handler()
.registration_finish(request.into_inner()) .registration_finish(request.into_inner())
.await?; .await?;
Ok(HttpResponse::Ok().finish()) Ok(HttpResponse::Ok().finish())
@ -582,64 +588,8 @@ where
} }
} }
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Permission {
Admin,
PasswordManager,
Readonly,
Regular,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationResults {
pub user: UserId,
pub permission: Permission,
}
impl ValidationResults {
#[cfg(test)]
pub fn admin() -> Self {
Self {
user: UserId::new("admin"),
permission: Permission::Admin,
}
}
#[must_use]
pub fn is_admin(&self) -> bool {
self.permission == Permission::Admin
}
#[must_use]
pub fn is_admin_or_readonly(&self) -> bool {
self.permission == Permission::Admin
|| self.permission == Permission::Readonly
|| self.permission == Permission::PasswordManager
}
#[must_use]
pub fn can_read(&self, user: &UserId) -> bool {
self.permission == Permission::Admin
|| self.permission == Permission::PasswordManager
|| self.permission == Permission::Readonly
|| &self.user == user
}
#[must_use]
pub fn can_change_password(&self, user: &UserId, user_is_admin: bool) -> bool {
self.permission == Permission::Admin
|| (self.permission == Permission::PasswordManager && !user_is_admin)
|| &self.user == user
}
#[must_use]
pub fn can_write(&self, user: &UserId) -> bool {
self.permission == Permission::Admin || &self.user == user
}
}
#[instrument(skip_all, level = "debug", err, ret)] #[instrument(skip_all, level = "debug", err, ret)]
pub(crate) fn check_if_token_is_valid<Backend>( pub(crate) fn check_if_token_is_valid<Backend: BackendHandler>(
state: &AppState<Backend>, state: &AppState<Backend>,
token_str: &str, token_str: &str,
) -> Result<ValidationResults, actix_web::Error> { ) -> Result<ValidationResults, actix_web::Error> {
@ -662,22 +612,13 @@ pub(crate) fn check_if_token_is_valid<Backend>(
if state.jwt_blacklist.read().unwrap().contains(&jwt_hash) { if state.jwt_blacklist.read().unwrap().contains(&jwt_hash) {
return Err(ErrorUnauthorized("JWT was logged out")); return Err(ErrorUnauthorized("JWT was logged out"));
} }
let is_in_group = |name| token.claims().groups.contains(name); Ok(state.backend_handler.get_permissions_from_groups(
Ok(ValidationResults { UserId::new(&token.claims().user),
user: UserId::new(&token.claims().user), token.claims().groups.iter(),
permission: if is_in_group("lldap_admin") { ))
Permission::Admin
} else if is_in_group("lldap_password_manager") {
Permission::PasswordManager
} else if is_in_group("lldap_strict_readonly") {
Permission::Readonly
} else {
Permission::Regular
},
})
} }
pub fn configure_server<Backend>(cfg: &mut web::ServiceConfig) pub fn configure_server<Backend>(cfg: &mut web::ServiceConfig, enable_password_reset: bool)
where where
Backend: TcpBackendHandler + LoginHandler + OpaqueHandler + BackendHandler + 'static, Backend: TcpBackendHandler + LoginHandler + OpaqueHandler + BackendHandler + 'static,
{ {
@ -694,14 +635,6 @@ where
web::resource("/simple/login").route(web::post().to(simple_login_handler::<Backend>)), web::resource("/simple/login").route(web::post().to(simple_login_handler::<Backend>)),
) )
.service(web::resource("/refresh").route(web::get().to(get_refresh_handler::<Backend>))) .service(web::resource("/refresh").route(web::get().to(get_refresh_handler::<Backend>)))
.service(
web::resource("/reset/step1/{user_id}")
.route(web::get().to(get_password_reset_step1_handler::<Backend>)),
)
.service(
web::resource("/reset/step2/{token}")
.route(web::get().to(get_password_reset_step2_handler::<Backend>)),
)
.service(web::resource("/logout").route(web::get().to(get_logout_handler::<Backend>))) .service(web::resource("/logout").route(web::get().to(get_logout_handler::<Backend>)))
.service( .service(
web::scope("/opaque/register") web::scope("/opaque/register")
@ -715,4 +648,14 @@ where
.route(web::post().to(opaque_register_finish_handler::<Backend>)), .route(web::post().to(opaque_register_finish_handler::<Backend>)),
), ),
); );
if enable_password_reset {
cfg.service(
web::resource("/reset/step1/{user_id}")
.route(web::get().to(get_password_reset_step1_handler::<Backend>)),
)
.service(
web::resource("/reset/step2/{token}")
.route(web::get().to(get_password_reset_step2_handler::<Backend>)),
);
}
} }

View File

@ -1,4 +1,4 @@
use clap::Parser; use clap::{builder::EnumValueParser, Parser};
use lettre::message::Mailbox; use lettre::message::Mailbox;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -26,6 +26,9 @@ pub enum Command {
/// Send a test email. /// Send a test email.
#[clap(name = "send_test_email")] #[clap(name = "send_test_email")]
SendTestEmail(TestEmailOpts), SendTestEmail(TestEmailOpts),
/// Create database schema.
#[clap(name = "create_schema")]
CreateSchema(RunOpts),
} }
#[derive(Debug, Parser, Clone)] #[derive(Debug, Parser, Clone)]
@ -74,6 +77,10 @@ pub struct RunOpts {
#[clap(long, env = "LLDAP_HTTP_URL")] #[clap(long, env = "LLDAP_HTTP_URL")]
pub http_url: Option<String>, pub http_url: Option<String>,
/// Database connection URL
#[clap(short, long, env = "LLDAP_DATABASE_URL")]
pub database_url: Option<String>,
#[clap(flatten)] #[clap(flatten)]
pub smtp_opts: SmtpOpts, pub smtp_opts: SmtpOpts,
@ -95,7 +102,7 @@ pub struct TestEmailOpts {
} }
#[derive(Debug, Parser, Clone)] #[derive(Debug, Parser, Clone)]
#[clap(next_help_heading = Some("LDAPS"), setting = clap::AppSettings::DeriveDisplayOrder)] #[clap(next_help_heading = Some("LDAPS"))]
pub struct LdapsOpts { pub struct LdapsOpts {
/// Enable LDAPS. Default: false. /// Enable LDAPS. Default: false.
#[clap(long, env = "LLDAP_LDAPS_OPTIONS__ENABLED")] #[clap(long, env = "LLDAP_LDAPS_OPTIONS__ENABLED")]
@ -114,16 +121,16 @@ pub struct LdapsOpts {
pub ldaps_key_file: Option<String>, pub ldaps_key_file: Option<String>,
} }
clap::arg_enum! { #[derive(Clone, Debug, Deserialize, Serialize, clap::ValueEnum)]
#[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "UPPERCASE")]
pub enum SmtpEncryption { pub enum SmtpEncryption {
TLS, None,
STARTTLS, Tls,
} StartTls,
} }
#[derive(Debug, Parser, Clone)] #[derive(Debug, Parser, Clone)]
#[clap(next_help_heading = Some("SMTP"), setting = clap::AppSettings::DeriveDisplayOrder)] #[clap(next_help_heading = Some("SMTP"))]
pub struct SmtpOpts { pub struct SmtpOpts {
/// Sender email address. /// Sender email address.
#[clap(long, env = "LLDAP_SMTP_OPTIONS__FROM")] #[clap(long, env = "LLDAP_SMTP_OPTIONS__FROM")]
@ -150,10 +157,10 @@ pub struct SmtpOpts {
pub smtp_password: Option<String>, pub smtp_password: Option<String>,
/// Whether TLS should be used to connect to SMTP. /// Whether TLS should be used to connect to SMTP.
#[clap(long, env = "LLDAP_SMTP_OPTIONS__TLS_REQUIRED", setting=clap::ArgSettings::Hidden)] #[clap(long, env = "LLDAP_SMTP_OPTIONS__TLS_REQUIRED", hide = true)]
pub smtp_tls_required: Option<bool>, pub smtp_tls_required: Option<bool>,
#[clap(long, env = "LLDAP_SMTP_OPTIONS__ENCRYPTION", possible_values = SmtpEncryption::variants(), case_insensitive = true)] #[clap(long, env = "LLDAP_SMTP_OPTIONS__ENCRYPTION", value_parser = EnumValueParser::<SmtpEncryption>::new(), ignore_case = true)]
pub smtp_encryption: Option<SmtpEncryption>, pub smtp_encryption: Option<SmtpEncryption>,
} }

View File

@ -29,7 +29,7 @@ pub struct MailOptions {
pub user: String, pub user: String,
#[builder(default = r#"SecUtf8::from("")"#)] #[builder(default = r#"SecUtf8::from("")"#)]
pub password: SecUtf8, pub password: SecUtf8,
#[builder(default = "SmtpEncryption::TLS")] #[builder(default = "SmtpEncryption::Tls")]
pub smtp_encryption: SmtpEncryption, pub smtp_encryption: SmtpEncryption,
/// Deprecated. /// Deprecated.
#[builder(default = "None")] #[builder(default = "None")]
@ -209,6 +209,10 @@ impl ConfigOverrider for RunOpts {
if let Some(url) = self.http_url.as_ref() { if let Some(url) = self.http_url.as_ref() {
config.http_url = url.to_string(); config.http_url = url.to_string();
} }
if let Some(database_url) = self.database_url.as_ref() {
config.database_url = database_url.to_string();
}
self.smtp_opts.override_config(config); self.smtp_opts.override_config(config);
self.ldaps_opts.override_config(config); self.ldaps_opts.override_config(config);
} }
@ -266,6 +270,9 @@ impl ConfigOverrider for SmtpOpts {
if let Some(password) = &self.smtp_password { if let Some(password) = &self.smtp_password {
config.smtp_options.password = SecUtf8::from(password.clone()); config.smtp_options.password = SecUtf8::from(password.clone());
} }
if let Some(smtp_encryption) = &self.smtp_encryption {
config.smtp_options.smtp_encryption = smtp_encryption.clone();
}
if let Some(tls_required) = self.smtp_tls_required { if let Some(tls_required) = self.smtp_tls_required {
config.smtp_options.tls_required = Some(tls_required); config.smtp_options.tls_required = Some(tls_required);
} }

View File

@ -1,29 +1,84 @@
use crate::{ use crate::{
domain::handler::BackendHandler, domain::{handler::BackendHandler, types::UserId},
infra::{ infra::{
auth_service::{check_if_token_is_valid, ValidationResults}, access_control::{
AccessControlledBackendHandler, AdminBackendHandler, ReadonlyBackendHandler,
UserReadableBackendHandler, UserWriteableBackendHandler, ValidationResults,
},
auth_service::check_if_token_is_valid,
cli::ExportGraphQLSchemaOpts, cli::ExportGraphQLSchemaOpts,
graphql::{mutation::Mutation, query::Query},
tcp_server::AppState, tcp_server::AppState,
}, },
}; };
use actix_web::{web, Error, HttpResponse}; use actix_web::FromRequest;
use actix_web::HttpMessage;
use actix_web::{error::JsonPayloadError, web, Error, HttpRequest, HttpResponse};
use actix_web_httpauth::extractors::bearer::BearerAuth; use actix_web_httpauth::extractors::bearer::BearerAuth;
use juniper::{EmptySubscription, RootNode}; use juniper::{
use juniper_actix::{graphiql_handler, graphql_handler, playground_handler}; http::{
graphiql::graphiql_source, playground::playground_source, GraphQLBatchRequest,
use super::{mutation::Mutation, query::Query}; GraphQLRequest,
},
EmptySubscription, FieldError, RootNode, ScalarValue,
};
use tracing::debug;
pub struct Context<Handler: BackendHandler> { pub struct Context<Handler: BackendHandler> {
pub handler: Box<Handler>, pub handler: AccessControlledBackendHandler<Handler>,
pub validation_result: ValidationResults, pub validation_result: ValidationResults,
} }
pub fn field_error_callback<'a>(
span: &'a tracing::Span,
error_message: &'a str,
) -> impl 'a + FnOnce() -> FieldError {
move || {
span.in_scope(|| debug!("Unauthorized"));
FieldError::from(error_message)
}
}
impl<Handler: BackendHandler> Context<Handler> {
#[cfg(test)]
pub fn new_for_tests(handler: Handler, validation_result: ValidationResults) -> Self {
Self {
handler: AccessControlledBackendHandler::new(handler),
validation_result,
}
}
pub fn get_admin_handler(&self) -> Option<&impl AdminBackendHandler> {
self.handler.get_admin_handler(&self.validation_result)
}
pub fn get_readonly_handler(&self) -> Option<&impl ReadonlyBackendHandler> {
self.handler.get_readonly_handler(&self.validation_result)
}
pub fn get_writeable_handler(
&self,
user_id: &UserId,
) -> Option<&impl UserWriteableBackendHandler> {
self.handler
.get_writeable_handler(&self.validation_result, user_id)
}
pub fn get_readable_handler(
&self,
user_id: &UserId,
) -> Option<&impl UserReadableBackendHandler> {
self.handler
.get_readable_handler(&self.validation_result, user_id)
}
}
impl<Handler: BackendHandler> juniper::Context for Context<Handler> {} impl<Handler: BackendHandler> juniper::Context for Context<Handler> {}
type Schema<Handler> = type Schema<Handler> =
RootNode<'static, Query<Handler>, Mutation<Handler>, EmptySubscription<Context<Handler>>>; RootNode<'static, Query<Handler>, Mutation<Handler>, EmptySubscription<Context<Handler>>>;
fn schema<Handler: BackendHandler + Sync>() -> Schema<Handler> { fn schema<Handler: BackendHandler>() -> Schema<Handler> {
Schema::new( Schema::new(
Query::<Handler>::new(), Query::<Handler>::new(),
Mutation::<Handler>::new(), Mutation::<Handler>::new(),
@ -52,30 +107,134 @@ pub fn export_schema(opts: ExportGraphQLSchemaOpts) -> anyhow::Result<()> {
} }
async fn graphiql_route() -> Result<HttpResponse, Error> { async fn graphiql_route() -> Result<HttpResponse, Error> {
graphiql_handler("/api/graphql", None).await let html = graphiql_source("/api/graphql", None);
Ok(HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(html))
} }
async fn playground_route() -> Result<HttpResponse, Error> { async fn playground_route() -> Result<HttpResponse, Error> {
playground_handler("/api/graphql", None).await let html = playground_source("/api/graphql", None);
Ok(HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(html))
} }
async fn graphql_route<Handler: BackendHandler + Sync>( #[derive(serde::Deserialize, Clone, PartialEq, Debug)]
struct GetGraphQLRequest {
query: String,
#[serde(rename = "operationName")]
operation_name: Option<String>,
variables: Option<String>,
}
impl<S> From<GetGraphQLRequest> for GraphQLRequest<S>
where
S: ScalarValue,
{
fn from(get_req: GetGraphQLRequest) -> Self {
let GetGraphQLRequest {
query,
operation_name,
variables,
} = get_req;
let variables = variables.map(|s| serde_json::from_str(&s).unwrap());
Self::new(query, operation_name, variables)
}
}
/// Actix GraphQL Handler for GET requests
pub async fn get_graphql_handler<Query, Mutation, Subscription, CtxT, S>(
schema: &juniper::RootNode<'static, Query, Mutation, Subscription, S>,
context: &CtxT,
req: HttpRequest,
) -> Result<HttpResponse, Error>
where
Query: juniper::GraphQLTypeAsync<S, Context = CtxT>,
Query::TypeInfo: Sync,
Mutation: juniper::GraphQLTypeAsync<S, Context = CtxT>,
Mutation::TypeInfo: Sync,
Subscription: juniper::GraphQLSubscriptionType<S, Context = CtxT>,
Subscription::TypeInfo: Sync,
CtxT: Sync,
S: ScalarValue + Send + Sync,
{
let get_req = web::Query::<GetGraphQLRequest>::from_query(req.query_string())?;
let req = GraphQLRequest::from(get_req.into_inner());
let gql_response = req.execute(schema, context).await;
let body_response = serde_json::to_string(&gql_response)?;
let mut response = match gql_response.is_ok() {
true => HttpResponse::Ok(),
false => HttpResponse::BadRequest(),
};
Ok(response
.content_type("application/json")
.body(body_response))
}
/// Actix GraphQL Handler for POST requests
pub async fn post_graphql_handler<Query, Mutation, Subscription, CtxT, S>(
schema: &juniper::RootNode<'static, Query, Mutation, Subscription, S>,
context: &CtxT,
req: HttpRequest,
mut payload: actix_http::Payload,
) -> Result<HttpResponse, Error>
where
Query: juniper::GraphQLTypeAsync<S, Context = CtxT>,
Query::TypeInfo: Sync,
Mutation: juniper::GraphQLTypeAsync<S, Context = CtxT>,
Mutation::TypeInfo: Sync,
Subscription: juniper::GraphQLSubscriptionType<S, Context = CtxT>,
Subscription::TypeInfo: Sync,
CtxT: Sync,
S: ScalarValue + Send + Sync,
{
let req = match req.content_type() {
"application/json" => {
let body = String::from_request(&req, &mut payload).await?;
serde_json::from_str::<GraphQLBatchRequest<S>>(&body)
.map_err(JsonPayloadError::Deserialize)
}
"application/graphql" => {
let body = String::from_request(&req, &mut payload).await?;
Ok(GraphQLBatchRequest::Single(GraphQLRequest::new(
body, None, None,
)))
}
_ => Err(JsonPayloadError::ContentType),
}?;
let gql_batch_response = req.execute(schema, context).await;
let gql_response = serde_json::to_string(&gql_batch_response)?;
let mut response = match gql_batch_response.is_ok() {
true => HttpResponse::Ok(),
false => HttpResponse::BadRequest(),
};
Ok(response.content_type("application/json").body(gql_response))
}
async fn graphql_route<Handler: BackendHandler + Clone>(
req: actix_web::HttpRequest, req: actix_web::HttpRequest,
mut payload: actix_web::web::Payload, payload: actix_web::web::Payload,
data: web::Data<AppState<Handler>>, data: web::Data<AppState<Handler>>,
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
use actix_web::FromRequest; let mut inner_payload = payload.into_inner();
let bearer = BearerAuth::from_request(&req, &mut payload.0).await?; let bearer = BearerAuth::from_request(&req, &mut inner_payload).await?;
let validation_result = check_if_token_is_valid(&data, bearer.token())?; let validation_result = check_if_token_is_valid(&data, bearer.token())?;
let context = Context::<Handler> { let context = Context::<Handler> {
handler: Box::new(data.backend_handler.clone()), handler: data.backend_handler.clone(),
validation_result, validation_result,
}; };
graphql_handler(&schema(), &context, req, payload).await let schema = &schema();
let context = &context;
match *req.method() {
actix_http::Method::POST => post_graphql_handler(schema, context, req, inner_payload).await,
actix_http::Method::GET => get_graphql_handler(schema, context, req).await,
_ => Err(actix_web::error::UrlGenerationError::ResourceNotFound.into()),
}
} }
pub fn configure_endpoint<Backend>(cfg: &mut web::ServiceConfig) pub fn configure_endpoint<Backend>(cfg: &mut web::ServiceConfig)
where where
Backend: BackendHandler + Sync + 'static, Backend: BackendHandler + Clone + 'static,
{ {
let json_config = web::JsonConfig::default() let json_config = web::JsonConfig::default()
.limit(4096) .limit(4096)

View File

@ -1,8 +1,18 @@
use crate::domain::{ use crate::{
handler::{BackendHandler, CreateUserRequest, UpdateGroupRequest, UpdateUserRequest}, domain::{
types::{GroupId, JpegPhoto, UserId}, handler::{BackendHandler, CreateUserRequest, UpdateGroupRequest, UpdateUserRequest},
types::{GroupId, JpegPhoto, UserId},
},
infra::{
access_control::{
AdminBackendHandler, ReadonlyBackendHandler, UserReadableBackendHandler,
UserWriteableBackendHandler,
},
graphql::api::field_error_callback,
},
}; };
use anyhow::Context as AnyhowContext; use anyhow::Context as AnyhowContext;
use base64::Engine;
use juniper::{graphql_object, FieldResult, GraphQLInputObject, GraphQLObject}; use juniper::{graphql_object, FieldResult, GraphQLInputObject, GraphQLObject};
use tracing::{debug, debug_span, Instrument}; use tracing::{debug, debug_span, Instrument};
@ -65,30 +75,28 @@ impl Success {
} }
#[graphql_object(context = Context<Handler>)] #[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler + Sync> Mutation<Handler> { impl<Handler: BackendHandler> Mutation<Handler> {
async fn create_user( async fn create_user(
context: &Context<Handler>, context: &Context<Handler>,
user: CreateUserInput, user: CreateUserInput,
) -> FieldResult<super::query::User<Handler>> { ) -> FieldResult<super::query::User<Handler>> {
let span = debug_span!("[GraphQL mutation] create_user"); let span = debug_span!("[GraphQL mutation] create_user");
span.in_scope(|| { span.in_scope(|| {
debug!(?user.id); debug!("{:?}", &user.id);
}); });
if !context.validation_result.is_admin() { let handler = context
span.in_scope(|| debug!("Unauthorized")); .get_admin_handler()
return Err("Unauthorized user creation".into()); .ok_or_else(field_error_callback(&span, "Unauthorized user creation"))?;
}
let user_id = UserId::new(&user.id); let user_id = UserId::new(&user.id);
let avatar = user let avatar = user
.avatar .avatar
.map(base64::decode) .map(|bytes| base64::engine::general_purpose::STANDARD.decode(bytes))
.transpose() .transpose()
.context("Invalid base64 image")? .context("Invalid base64 image")?
.map(JpegPhoto::try_from) .map(JpegPhoto::try_from)
.transpose() .transpose()
.context("Provided image is not a valid JPEG")?; .context("Provided image is not a valid JPEG")?;
context handler
.handler
.create_user(CreateUserRequest { .create_user(CreateUserRequest {
user_id: user_id.clone(), user_id: user_id.clone(),
email: user.email, email: user.email,
@ -99,8 +107,7 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
}) })
.instrument(span.clone()) .instrument(span.clone())
.await?; .await?;
Ok(context Ok(handler
.handler
.get_user_details(&user_id) .get_user_details(&user_id)
.instrument(span) .instrument(span)
.await .await
@ -115,13 +122,11 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
span.in_scope(|| { span.in_scope(|| {
debug!(?name); debug!(?name);
}); });
if !context.validation_result.is_admin() { let handler = context
span.in_scope(|| debug!("Unauthorized")); .get_admin_handler()
return Err("Unauthorized group creation".into()); .ok_or_else(field_error_callback(&span, "Unauthorized group creation"))?;
} let group_id = handler.create_group(&name).await?;
let group_id = context.handler.create_group(&name).await?; Ok(handler
Ok(context
.handler
.get_group_details(group_id) .get_group_details(group_id)
.instrument(span) .instrument(span)
.await .await
@ -137,20 +142,18 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
debug!(?user.id); debug!(?user.id);
}); });
let user_id = UserId::new(&user.id); let user_id = UserId::new(&user.id);
if !context.validation_result.can_write(&user_id) { let handler = context
span.in_scope(|| debug!("Unauthorized")); .get_writeable_handler(&user_id)
return Err("Unauthorized user update".into()); .ok_or_else(field_error_callback(&span, "Unauthorized user update"))?;
}
let avatar = user let avatar = user
.avatar .avatar
.map(base64::decode) .map(|bytes| base64::engine::general_purpose::STANDARD.decode(bytes))
.transpose() .transpose()
.context("Invalid base64 image")? .context("Invalid base64 image")?
.map(JpegPhoto::try_from) .map(JpegPhoto::try_from)
.transpose() .transpose()
.context("Provided image is not a valid JPEG")?; .context("Provided image is not a valid JPEG")?;
context handler
.handler
.update_user(UpdateUserRequest { .update_user(UpdateUserRequest {
user_id, user_id,
email: user.email, email: user.email,
@ -172,16 +175,14 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
span.in_scope(|| { span.in_scope(|| {
debug!(?group.id); debug!(?group.id);
}); });
if !context.validation_result.is_admin() { let handler = context
span.in_scope(|| debug!("Unauthorized")); .get_admin_handler()
return Err("Unauthorized group update".into()); .ok_or_else(field_error_callback(&span, "Unauthorized group update"))?;
}
if group.id == 1 { if group.id == 1 {
span.in_scope(|| debug!("Cannot change admin group details")); span.in_scope(|| debug!("Cannot change admin group details"));
return Err("Cannot change admin group details".into()); return Err("Cannot change admin group details".into());
} }
context handler
.handler
.update_group(UpdateGroupRequest { .update_group(UpdateGroupRequest {
group_id: GroupId(group.id), group_id: GroupId(group.id),
display_name: group.display_name, display_name: group.display_name,
@ -200,12 +201,13 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
span.in_scope(|| { span.in_scope(|| {
debug!(?user_id, ?group_id); debug!(?user_id, ?group_id);
}); });
if !context.validation_result.is_admin() { let handler = context
span.in_scope(|| debug!("Unauthorized")); .get_admin_handler()
return Err("Unauthorized group membership modification".into()); .ok_or_else(field_error_callback(
} &span,
context "Unauthorized group membership modification",
.handler ))?;
handler
.add_user_to_group(&UserId::new(&user_id), GroupId(group_id)) .add_user_to_group(&UserId::new(&user_id), GroupId(group_id))
.instrument(span) .instrument(span)
.await?; .await?;
@ -221,17 +223,18 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
span.in_scope(|| { span.in_scope(|| {
debug!(?user_id, ?group_id); debug!(?user_id, ?group_id);
}); });
if !context.validation_result.is_admin() { let handler = context
span.in_scope(|| debug!("Unauthorized")); .get_admin_handler()
return Err("Unauthorized group membership modification".into()); .ok_or_else(field_error_callback(
} &span,
"Unauthorized group membership modification",
))?;
let user_id = UserId::new(&user_id); let user_id = UserId::new(&user_id);
if context.validation_result.user == user_id && group_id == 1 { if context.validation_result.user == user_id && group_id == 1 {
span.in_scope(|| debug!("Cannot remove admin rights for current user")); span.in_scope(|| debug!("Cannot remove admin rights for current user"));
return Err("Cannot remove admin rights for current user".into()); return Err("Cannot remove admin rights for current user".into());
} }
context handler
.handler
.remove_user_from_group(&user_id, GroupId(group_id)) .remove_user_from_group(&user_id, GroupId(group_id))
.instrument(span) .instrument(span)
.await?; .await?;
@ -244,19 +247,14 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
debug!(?user_id); debug!(?user_id);
}); });
let user_id = UserId::new(&user_id); let user_id = UserId::new(&user_id);
if !context.validation_result.is_admin() { let handler = context
span.in_scope(|| debug!("Unauthorized")); .get_admin_handler()
return Err("Unauthorized user deletion".into()); .ok_or_else(field_error_callback(&span, "Unauthorized user deletion"))?;
}
if context.validation_result.user == user_id { if context.validation_result.user == user_id {
span.in_scope(|| debug!("Cannot delete current user")); span.in_scope(|| debug!("Cannot delete current user"));
return Err("Cannot delete current user".into()); return Err("Cannot delete current user".into());
} }
context handler.delete_user(&user_id).instrument(span).await?;
.handler
.delete_user(&user_id)
.instrument(span)
.await?;
Ok(Success::new()) Ok(Success::new())
} }
@ -265,16 +263,14 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
span.in_scope(|| { span.in_scope(|| {
debug!(?group_id); debug!(?group_id);
}); });
if !context.validation_result.is_admin() { let handler = context
span.in_scope(|| debug!("Unauthorized")); .get_admin_handler()
return Err("Unauthorized group deletion".into()); .ok_or_else(field_error_callback(&span, "Unauthorized group deletion"))?;
}
if group_id == 1 { if group_id == 1 {
span.in_scope(|| debug!("Cannot delete admin group")); span.in_scope(|| debug!("Cannot delete admin group"));
return Err("Cannot delete admin group".into()); return Err("Cannot delete admin group".into());
} }
context handler
.handler
.delete_group(GroupId(group_id)) .delete_group(GroupId(group_id))
.instrument(span) .instrument(span)
.await?; .await?;

View File

@ -1,8 +1,15 @@
use crate::domain::{ use crate::{
handler::BackendHandler, domain::{
ldap::utils::map_user_field, handler::BackendHandler,
types::{GroupDetails, GroupId, UserColumn, UserId}, ldap::utils::map_user_field,
types::{GroupDetails, GroupId, UserColumn, UserId},
},
infra::{
access_control::{ReadonlyBackendHandler, UserReadableBackendHandler},
graphql::api::field_error_callback,
},
}; };
use chrono::TimeZone;
use juniper::{graphql_object, FieldResult, GraphQLInputObject}; use juniper::{graphql_object, FieldResult, GraphQLInputObject};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{debug, debug_span, Instrument}; use tracing::{debug, debug_span, Instrument};
@ -111,7 +118,7 @@ impl<Handler: BackendHandler> Query<Handler> {
} }
#[graphql_object(context = Context<Handler>)] #[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler + Sync> Query<Handler> { impl<Handler: BackendHandler> Query<Handler> {
fn api_version() -> &'static str { fn api_version() -> &'static str {
"1.0" "1.0"
} }
@ -122,12 +129,13 @@ impl<Handler: BackendHandler + Sync> Query<Handler> {
debug!(?user_id); debug!(?user_id);
}); });
let user_id = UserId::new(&user_id); let user_id = UserId::new(&user_id);
if !context.validation_result.can_read(&user_id) { let handler = context
span.in_scope(|| debug!("Unauthorized")); .get_readable_handler(&user_id)
return Err("Unauthorized access to user data".into()); .ok_or_else(field_error_callback(
} &span,
Ok(context "Unauthorized access to user data",
.handler ))?;
Ok(handler
.get_user_details(&user_id) .get_user_details(&user_id)
.instrument(span) .instrument(span)
.await .await
@ -142,12 +150,13 @@ impl<Handler: BackendHandler + Sync> Query<Handler> {
span.in_scope(|| { span.in_scope(|| {
debug!(?filters); debug!(?filters);
}); });
if !context.validation_result.is_admin_or_readonly() { let handler = context
span.in_scope(|| debug!("Unauthorized")); .get_readonly_handler()
return Err("Unauthorized access to user list".into()); .ok_or_else(field_error_callback(
} &span,
Ok(context "Unauthorized access to user list",
.handler ))?;
Ok(handler
.list_users(filters.map(TryInto::try_into).transpose()?, false) .list_users(filters.map(TryInto::try_into).transpose()?, false)
.instrument(span) .instrument(span)
.await .await
@ -156,12 +165,13 @@ impl<Handler: BackendHandler + Sync> Query<Handler> {
async fn groups(context: &Context<Handler>) -> FieldResult<Vec<Group<Handler>>> { async fn groups(context: &Context<Handler>) -> FieldResult<Vec<Group<Handler>>> {
let span = debug_span!("[GraphQL query] groups"); let span = debug_span!("[GraphQL query] groups");
if !context.validation_result.is_admin_or_readonly() { let handler = context
span.in_scope(|| debug!("Unauthorized")); .get_readonly_handler()
return Err("Unauthorized access to group list".into()); .ok_or_else(field_error_callback(
} &span,
Ok(context "Unauthorized access to group list",
.handler ))?;
Ok(handler
.list_groups(None) .list_groups(None)
.instrument(span) .instrument(span)
.await .await
@ -173,12 +183,13 @@ impl<Handler: BackendHandler + Sync> Query<Handler> {
span.in_scope(|| { span.in_scope(|| {
debug!(?group_id); debug!(?group_id);
}); });
if !context.validation_result.is_admin_or_readonly() { let handler = context
span.in_scope(|| debug!("Unauthorized")); .get_readonly_handler()
return Err("Unauthorized access to group data".into()); .ok_or_else(field_error_callback(
} &span,
Ok(context "Unauthorized access to group data",
.handler ))?;
Ok(handler
.get_group_details(GroupId(group_id)) .get_group_details(GroupId(group_id))
.instrument(span) .instrument(span)
.await .await
@ -204,7 +215,7 @@ impl<Handler: BackendHandler> Default for User<Handler> {
} }
#[graphql_object(context = Context<Handler>)] #[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler + Sync> User<Handler> { impl<Handler: BackendHandler> User<Handler> {
fn id(&self) -> &str { fn id(&self) -> &str {
self.user.user_id.as_str() self.user.user_id.as_str()
} }
@ -230,7 +241,7 @@ impl<Handler: BackendHandler + Sync> User<Handler> {
} }
fn creation_date(&self) -> chrono::DateTime<chrono::Utc> { fn creation_date(&self) -> chrono::DateTime<chrono::Utc> {
self.user.creation_date chrono::Utc.from_utc_datetime(&self.user.creation_date)
} }
fn uuid(&self) -> &str { fn uuid(&self) -> &str {
@ -243,8 +254,10 @@ impl<Handler: BackendHandler + Sync> User<Handler> {
span.in_scope(|| { span.in_scope(|| {
debug!(user_id = ?self.user.user_id); debug!(user_id = ?self.user.user_id);
}); });
Ok(context let handler = context
.handler .get_readable_handler(&self.user.user_id)
.expect("We shouldn't be able to get there without readable permission");
Ok(handler
.get_user_groups(&self.user.user_id) .get_user_groups(&self.user.user_id)
.instrument(span) .instrument(span)
.await .await
@ -275,14 +288,14 @@ impl<Handler: BackendHandler> From<DomainUserAndGroups> for User<Handler> {
pub struct Group<Handler: BackendHandler> { pub struct Group<Handler: BackendHandler> {
group_id: i32, group_id: i32,
display_name: String, display_name: String,
creation_date: chrono::DateTime<chrono::Utc>, creation_date: chrono::NaiveDateTime,
uuid: String, uuid: String,
members: Option<Vec<String>>, members: Option<Vec<String>>,
_phantom: std::marker::PhantomData<Box<Handler>>, _phantom: std::marker::PhantomData<Box<Handler>>,
} }
#[graphql_object(context = Context<Handler>)] #[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler + Sync> Group<Handler> { impl<Handler: BackendHandler> Group<Handler> {
fn id(&self) -> i32 { fn id(&self) -> i32 {
self.group_id self.group_id
} }
@ -290,7 +303,7 @@ impl<Handler: BackendHandler + Sync> Group<Handler> {
self.display_name.clone() self.display_name.clone()
} }
fn creation_date(&self) -> chrono::DateTime<chrono::Utc> { fn creation_date(&self) -> chrono::DateTime<chrono::Utc> {
self.creation_date chrono::Utc.from_utc_datetime(&self.creation_date)
} }
fn uuid(&self) -> String { fn uuid(&self) -> String {
self.uuid.clone() self.uuid.clone()
@ -301,12 +314,13 @@ impl<Handler: BackendHandler + Sync> Group<Handler> {
span.in_scope(|| { span.in_scope(|| {
debug!(name = %self.display_name); debug!(name = %self.display_name);
}); });
if !context.validation_result.is_admin_or_readonly() { let handler = context
span.in_scope(|| debug!("Unauthorized")); .get_readonly_handler()
return Err("Unauthorized access to group data".into()); .ok_or_else(field_error_callback(
} &span,
Ok(context "Unauthorized access to group data",
.handler ))?;
Ok(handler
.list_users( .list_users(
Some(DomainRequestFilter::MemberOfId(GroupId(self.group_id))), Some(DomainRequestFilter::MemberOfId(GroupId(self.group_id))),
false, false,
@ -346,7 +360,9 @@ impl<Handler: BackendHandler> From<DomainGroup> for Group<Handler> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::{domain::handler::MockTestBackendHandler, infra::auth_service::ValidationResults}; use crate::{
domain::handler::MockTestBackendHandler, infra::access_control::ValidationResults,
};
use chrono::TimeZone; use chrono::TimeZone;
use juniper::{ use juniper::{
execute, graphql_value, DefaultScalarValue, EmptyMutation, EmptySubscription, GraphQLType, execute, graphql_value, DefaultScalarValue, EmptyMutation, EmptySubscription, GraphQLType,
@ -389,7 +405,7 @@ mod tests {
Ok(DomainUser { Ok(DomainUser {
user_id: UserId::new("bob"), user_id: UserId::new("bob"),
email: "bob@bobbers.on".to_string(), email: "bob@bobbers.on".to_string(),
creation_date: chrono::Utc.timestamp_millis_opt(42).unwrap(), creation_date: chrono::Utc.timestamp_millis_opt(42).unwrap().naive_utc(),
uuid: crate::uuid!("b1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"), uuid: crate::uuid!("b1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
..Default::default() ..Default::default()
}) })
@ -398,17 +414,15 @@ mod tests {
groups.insert(GroupDetails { groups.insert(GroupDetails {
group_id: GroupId(3), group_id: GroupId(3),
display_name: "Bobbersons".to_string(), display_name: "Bobbersons".to_string(),
creation_date: chrono::Utc.timestamp_nanos(42), creation_date: chrono::Utc.timestamp_nanos(42).naive_utc(),
uuid: crate::uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"), uuid: crate::uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
}); });
mock.expect_get_user_groups() mock.expect_get_user_groups()
.with(eq(UserId::new("bob"))) .with(eq(UserId::new("bob")))
.return_once(|_| Ok(groups)); .return_once(|_| Ok(groups));
let context = Context::<MockTestBackendHandler> { let context =
handler: Box::new(mock), Context::<MockTestBackendHandler>::new_for_tests(mock, ValidationResults::admin());
validation_result: ValidationResults::admin(),
};
let schema = schema(Query::<MockTestBackendHandler>::new()); let schema = schema(Query::<MockTestBackendHandler>::new());
assert_eq!( assert_eq!(
@ -485,10 +499,8 @@ mod tests {
]) ])
}); });
let context = Context::<MockTestBackendHandler> { let context =
handler: Box::new(mock), Context::<MockTestBackendHandler>::new_for_tests(mock, ValidationResults::admin());
validation_result: ValidationResults::admin(),
};
let schema = schema(Query::<MockTestBackendHandler>::new()); let schema = schema(Query::<MockTestBackendHandler>::new());
assert_eq!( assert_eq!(

View File

@ -99,6 +99,7 @@ fn get_tls_connector() -> Result<RustlsTlsConnector> {
#[instrument(skip_all, level = "info", err)] #[instrument(skip_all, level = "info", err)]
pub async fn check_ldaps(ldaps_options: &LdapsOptions) -> Result<()> { pub async fn check_ldaps(ldaps_options: &LdapsOptions) -> Result<()> {
if !ldaps_options.enabled { if !ldaps_options.enabled {
info!("LDAPS not enabled");
return Ok(()); return Ok(());
}; };
let tls_connector = get_tls_connector()?; let tls_connector = get_tls_connector()?;

View File

@ -1,5 +1,7 @@
use sea_orm::ConnectionTrait; use sea_orm::{
use sea_query::{ColumnDef, ForeignKey, ForeignKeyAction, Iden, Table}; sea_query::{self, ColumnDef, ForeignKey, ForeignKeyAction, Iden, Table},
ConnectionTrait,
};
pub use crate::domain::{sql_migrations::Users, sql_tables::DbConnection}; pub use crate::domain::{sql_migrations::Users, sql_tables::DbConnection};

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,10 @@ use crate::{
handler::{BackendHandler, LoginHandler}, handler::{BackendHandler, LoginHandler},
opaque_handler::OpaqueHandler, opaque_handler::OpaqueHandler,
}, },
infra::{configuration::Configuration, ldap_handler::LdapHandler}, infra::{
access_control::AccessControlledBackendHandler, configuration::Configuration,
ldap_handler::LdapHandler,
},
}; };
use actix_rt::net::TcpStream; use actix_rt::net::TcpStream;
use actix_server::ServerBuilder; use actix_server::ServerBuilder;
@ -64,7 +67,7 @@ async fn handle_ldap_stream<Stream, Backend>(
) -> Result<Stream> ) -> Result<Stream>
where where
Backend: BackendHandler + LoginHandler + OpaqueHandler + 'static, Backend: BackendHandler + LoginHandler + OpaqueHandler + 'static,
Stream: tokio::io::AsyncRead + tokio::io::AsyncWrite, Stream: tokio::io::AsyncRead + tokio::io::AsyncWrite + std::marker::Unpin,
{ {
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
let (r, w) = tokio::io::split(stream); let (r, w) = tokio::io::split(stream);
@ -73,7 +76,7 @@ where
let mut resp = FramedWrite::new(w, LdapCodec); let mut resp = FramedWrite::new(w, LdapCodec);
let mut session = LdapHandler::new( let mut session = LdapHandler::new(
backend_handler, AccessControlledBackendHandler::new(backend_handler),
ldap_base_dn, ldap_base_dn,
ignored_user_attributes, ignored_user_attributes,
ignored_group_attributes, ignored_group_attributes,
@ -145,7 +148,7 @@ pub fn build_ldap_server<Backend>(
server_builder: ServerBuilder, server_builder: ServerBuilder,
) -> Result<ServerBuilder> ) -> Result<ServerBuilder>
where where
Backend: BackendHandler + LoginHandler + OpaqueHandler + 'static, Backend: BackendHandler + LoginHandler + OpaqueHandler + Clone + 'static,
{ {
let context = ( let context = (
backend_handler, backend_handler,

View File

@ -21,17 +21,29 @@ async fn send_email(to: Mailbox, subject: &str, body: String, options: &MailOpti
.reply_to(reply_to) .reply_to(reply_to)
.to(to) .to(to)
.subject(subject) .subject(subject)
.body(body)?; .singlepart(
let creds = Credentials::new( lettre::message::SinglePart::builder()
options.user.clone(), .header(lettre::message::header::ContentType::TEXT_PLAIN)
options.password.unsecure().to_string(), .body(body),
); )?;
let relay_factory = match options.smtp_encryption { let mut mailer = match options.smtp_encryption {
SmtpEncryption::TLS => AsyncSmtpTransport::<Tokio1Executor>::relay, SmtpEncryption::None => {
SmtpEncryption::STARTTLS => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay, AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&options.server)
}
SmtpEncryption::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&options.server)?,
SmtpEncryption::StartTls => {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&options.server)?
}
}; };
let mailer = relay_factory(&options.server)?.credentials(creds).build(); if options.user.as_str() != "" {
mailer.send(email).await?; let creds = Credentials::new(
options.user.clone(),
options.password.unsecure().to_string(),
);
mailer = mailer.credentials(creds)
}
mailer.port(options.port).build().send(email).await?;
Ok(()) Ok(())
} }
@ -52,7 +64,9 @@ compromised. You should reset your password and contact an administrator.
To reset your password please visit the following URL: {}/reset-password/step2/{} To reset your password please visit the following URL: {}/reset-password/step2/{}
Please contact an administrator if you did not initiate the process.", Please contact an administrator if you did not initiate the process.",
username, domain, token username,
domain.trim_end_matches('/'),
token
); );
send_email(to, "[LLDAP] Password reset requested", body, options).await send_email(to, "[LLDAP] Password reset requested", body, options).await
} }

View File

@ -1,3 +1,4 @@
pub mod access_control;
pub mod auth_service; pub mod auth_service;
pub mod cli; pub mod cli;
pub mod configuration; pub mod configuration;

View File

@ -7,10 +7,9 @@ use crate::domain::{
}; };
use async_trait::async_trait; use async_trait::async_trait;
use sea_orm::{ use sea_orm::{
sea_query::Cond, ActiveModelTrait, ColumnTrait, EntityTrait, FromQueryResult, IntoActiveModel, sea_query::{Cond, Expr},
QueryFilter, QuerySelect, ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QuerySelect,
}; };
use sea_query::Expr;
use std::collections::HashSet; use std::collections::HashSet;
use tracing::{debug, instrument}; use tracing::{debug, instrument};
@ -24,11 +23,6 @@ fn gen_random_string(len: usize) -> String {
.collect() .collect()
} }
#[derive(FromQueryResult)]
struct OnlyJwtHash {
jwt_hash: i64,
}
#[async_trait] #[async_trait]
impl TcpBackendHandler for SqlBackendHandler { impl TcpBackendHandler for SqlBackendHandler {
#[instrument(skip_all, level = "debug")] #[instrument(skip_all, level = "debug")]
@ -37,11 +31,11 @@ impl TcpBackendHandler for SqlBackendHandler {
.select_only() .select_only()
.column(JwtStorageColumn::JwtHash) .column(JwtStorageColumn::JwtHash)
.filter(JwtStorageColumn::Blacklisted.eq(true)) .filter(JwtStorageColumn::Blacklisted.eq(true))
.into_model::<OnlyJwtHash>() .into_tuple::<(i64,)>()
.all(&self.sql_pool) .all(&self.sql_pool)
.await? .await?
.into_iter() .into_iter()
.map(|m| m.jwt_hash as u64) .map(|m| m.0 as u64)
.collect::<HashSet<u64>>()) .collect::<HashSet<u64>>())
} }
@ -61,7 +55,7 @@ impl TcpBackendHandler for SqlBackendHandler {
let new_token = model::jwt_refresh_storage::Model { let new_token = model::jwt_refresh_storage::Model {
refresh_token_hash: refresh_token_hash as i64, refresh_token_hash: refresh_token_hash as i64,
user_id: user.clone(), user_id: user.clone(),
expiry_date: chrono::Utc::now() + duration, expiry_date: chrono::Utc::now().naive_utc() + duration,
} }
.into_active_model(); .into_active_model();
new_token.insert(&self.sql_pool).await?; new_token.insert(&self.sql_pool).await?;
@ -91,11 +85,11 @@ impl TcpBackendHandler for SqlBackendHandler {
.add(JwtStorageColumn::UserId.eq(user)) .add(JwtStorageColumn::UserId.eq(user))
.add(JwtStorageColumn::Blacklisted.eq(false)), .add(JwtStorageColumn::Blacklisted.eq(false)),
) )
.into_model::<OnlyJwtHash>() .into_tuple::<(i64,)>()
.all(&self.sql_pool) .all(&self.sql_pool)
.await? .await?
.into_iter() .into_iter()
.map(|t| t.jwt_hash as u64) .map(|t| t.0 as u64)
.collect::<HashSet<u64>>(); .collect::<HashSet<u64>>();
model::JwtStorage::update_many() model::JwtStorage::update_many()
.col_expr(JwtStorageColumn::Blacklisted, Expr::value(true)) .col_expr(JwtStorageColumn::Blacklisted, Expr::value(true))
@ -131,7 +125,7 @@ impl TcpBackendHandler for SqlBackendHandler {
let new_token = model::password_reset_tokens::Model { let new_token = model::password_reset_tokens::Model {
token: token.clone(), token: token.clone(),
user_id: user.clone(), user_id: user.clone(),
expiry_date: chrono::Utc::now() + duration, expiry_date: chrono::Utc::now().naive_utc() + duration,
} }
.into_active_model(); .into_active_model();
new_token.insert(&self.sql_pool).await?; new_token.insert(&self.sql_pool).await?;

View File

@ -4,7 +4,7 @@ use std::collections::HashSet;
use crate::domain::{error::Result, types::UserId}; use crate::domain::{error::Result, types::UserId};
#[async_trait] #[async_trait]
pub trait TcpBackendHandler { pub trait TcpBackendHandler: Sync {
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: &UserId) -> Result<(String, chrono::Duration)>; async fn create_refresh_token(&self, user: &UserId) -> Result<(String, chrono::Duration)>;
async fn check_token(&self, refresh_token_hash: u64, user: &UserId) -> Result<bool>; async fn check_token(&self, refresh_token_hash: u64, user: &UserId) -> Result<bool>;
@ -20,38 +20,3 @@ pub trait TcpBackendHandler {
async fn delete_password_reset_token(&self, token: &str) -> Result<()>; async fn delete_password_reset_token(&self, token: &str) -> Result<()>;
} }
#[cfg(test)]
use crate::domain::{handler::*, types::*};
#[cfg(test)]
mockall::mock! {
pub TestTcpBackendHandler{}
impl Clone for TestTcpBackendHandler {
fn clone(&self) -> Self;
}
#[async_trait]
impl LoginHandler for TestTcpBackendHandler {
async fn bind(&self, request: BindRequest) -> Result<()>;
}
#[async_trait]
impl GroupBackendHandler for TestTcpBackendHandler {
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>;
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>;
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
async fn delete_group(&self, group_id: GroupId) -> Result<()>;
}
#[async_trait]
impl UserBackendHandler for TestBackendHandler {
async fn list_users(&self, filters: Option<UserRequestFilter>, get_groups: bool) -> Result<Vec<UserAndGroups>>;
async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
async fn delete_user(&self, user_id: &UserId) -> Result<()>;
async fn get_user_groups(&self, user_id: &UserId) -> Result<HashSet<GroupDetails>>;
async fn add_user_to_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
}
#[async_trait]
impl BackendHandler for TestTcpBackendHandler {}
}

View File

@ -5,6 +5,7 @@ use crate::{
opaque_handler::OpaqueHandler, opaque_handler::OpaqueHandler,
}, },
infra::{ infra::{
access_control::{AccessControlledBackendHandler, ReadonlyBackendHandler},
auth_service, auth_service,
configuration::{Configuration, MailOptions}, configuration::{Configuration, MailOptions},
logging::CustomRootSpanBuilder, logging::CustomRootSpanBuilder,
@ -12,12 +13,12 @@ use crate::{
}, },
}; };
use actix_files::{Files, NamedFile}; use actix_files::{Files, NamedFile};
use actix_http::HttpServiceBuilder; use actix_http::{header, HttpServiceBuilder};
use actix_server::ServerBuilder; use actix_server::ServerBuilder;
use actix_service::map_config; use actix_service::map_config;
use actix_web::{dev::AppConfig, web, App, HttpResponse}; use actix_web::{dev::AppConfig, guard, middleware, web, App, HttpResponse, Responder};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use hmac::{Hmac, NewMac}; use hmac::Hmac;
use sha2::Sha512; use sha2::Sha512;
use std::collections::HashSet; use std::collections::HashSet;
use std::path::PathBuf; use std::path::PathBuf;
@ -37,6 +38,8 @@ pub enum TcpError {
BadRequest(String), BadRequest(String),
#[error("Internal server error: `{0}`")] #[error("Internal server error: `{0}`")]
InternalServerError(String), InternalServerError(String),
#[error("Not found: `{0}`")]
NotFoundError(String),
#[error("Unauthorized: `{0}`")] #[error("Unauthorized: `{0}`")]
UnauthorizedError(String), UnauthorizedError(String),
} }
@ -57,12 +60,23 @@ pub(crate) fn error_to_http_response(error: TcpError) -> HttpResponse {
| DomainError::EntityNotFound(_) => HttpResponse::BadRequest(), | DomainError::EntityNotFound(_) => HttpResponse::BadRequest(),
}, },
TcpError::BadRequest(_) => HttpResponse::BadRequest(), TcpError::BadRequest(_) => HttpResponse::BadRequest(),
TcpError::NotFoundError(_) => HttpResponse::NotFound(),
TcpError::InternalServerError(_) => HttpResponse::InternalServerError(), TcpError::InternalServerError(_) => HttpResponse::InternalServerError(),
TcpError::UnauthorizedError(_) => HttpResponse::Unauthorized(), TcpError::UnauthorizedError(_) => HttpResponse::Unauthorized(),
} }
.body(error.to_string()) .body(error.to_string())
} }
async fn wasm_handler() -> actix_web::Result<impl Responder> {
Ok(
actix_files::NamedFile::open_async("./app/pkg/lldap_app_bg.wasm.gz")
.await?
.customize()
.insert_header(header::ContentEncoding::Gzip)
.insert_header((header::CONTENT_TYPE, "application/wasm")),
)
}
fn http_config<Backend>( fn http_config<Backend>(
cfg: &mut web::ServiceConfig, cfg: &mut web::ServiceConfig,
backend_handler: Backend, backend_handler: Backend,
@ -71,23 +85,37 @@ fn http_config<Backend>(
server_url: String, server_url: String,
mail_options: MailOptions, mail_options: MailOptions,
) where ) where
Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Sync + 'static, Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Clone + 'static,
{ {
let enable_password_reset = mail_options.enable_password_reset;
cfg.app_data(web::Data::new(AppState::<Backend> { cfg.app_data(web::Data::new(AppState::<Backend> {
backend_handler, backend_handler: AccessControlledBackendHandler::new(backend_handler),
jwt_key: Hmac::new_varkey(jwt_secret.unsecure().as_bytes()).unwrap(), jwt_key: hmac::Mac::new_from_slice(jwt_secret.unsecure().as_bytes()).unwrap(),
jwt_blacklist: RwLock::new(jwt_blacklist), jwt_blacklist: RwLock::new(jwt_blacklist),
server_url, server_url,
mail_options, mail_options,
})) }))
.route("/health", web::get().to(|| HttpResponse::Ok().finish())) .route(
.service(web::scope("/auth").configure(auth_service::configure_server::<Backend>)) "/health",
web::get().to(|| async { HttpResponse::Ok().finish() }),
)
.service(
web::scope("/auth")
.configure(|cfg| auth_service::configure_server::<Backend>(cfg, enable_password_reset)),
)
// API endpoint. // API endpoint.
.service( .service(
web::scope("/api") web::scope("/api")
.wrap(auth_service::CookieToHeaderTranslatorFactory) .wrap(auth_service::CookieToHeaderTranslatorFactory)
.configure(super::graphql::api::configure_endpoint::<Backend>), .configure(super::graphql::api::configure_endpoint::<Backend>),
) )
.service(
web::resource("/pkg/lldap_app_bg.wasm").route(
web::route()
.wrap(middleware::Compress::default())
.to(wasm_handler),
),
)
// Serve the /pkg path with the compiled WASM app. // Serve the /pkg path with the compiled WASM app.
.service(Files::new("/pkg", "./app/pkg")) .service(Files::new("/pkg", "./app/pkg"))
// Serve static files // Serve static files
@ -95,28 +123,45 @@ fn http_config<Backend>(
// Serve static fonts // Serve static fonts
.service(Files::new("/static/fonts", "./app/static/fonts")) .service(Files::new("/static/fonts", "./app/static/fonts"))
// Default to serve index.html for unknown routes, to support routing. // Default to serve index.html for unknown routes, to support routing.
.service( .default_service(web::route().guard(guard::Get()).to(index));
web::scope("/")
.route("", web::get().to(index)) // this is necessary because the below doesn't match a request for "/"
.route(".*", web::get().to(index)),
);
} }
pub(crate) struct AppState<Backend> { pub(crate) struct AppState<Backend> {
pub backend_handler: Backend, pub backend_handler: AccessControlledBackendHandler<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 server_url: String,
pub mail_options: MailOptions, pub mail_options: MailOptions,
} }
impl<Backend: BackendHandler> AppState<Backend> {
pub fn get_readonly_handler(&self) -> &impl ReadonlyBackendHandler {
self.backend_handler.unsafe_get_handler()
}
}
impl<Backend: TcpBackendHandler> AppState<Backend> {
pub fn get_tcp_handler(&self) -> &impl TcpBackendHandler {
self.backend_handler.unsafe_get_handler()
}
}
impl<Backend: OpaqueHandler> AppState<Backend> {
pub fn get_opaque_handler(&self) -> &impl OpaqueHandler {
self.backend_handler.unsafe_get_handler()
}
}
impl<Backend: LoginHandler> AppState<Backend> {
pub fn get_login_handler(&self) -> &impl LoginHandler {
self.backend_handler.unsafe_get_handler()
}
}
pub async fn build_tcp_server<Backend>( pub async fn build_tcp_server<Backend>(
config: &Configuration, config: &Configuration,
backend_handler: Backend, backend_handler: Backend,
server_builder: ServerBuilder, server_builder: ServerBuilder,
) -> Result<ServerBuilder> ) -> Result<ServerBuilder>
where where
Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Sync + 'static, Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Clone + 'static,
{ {
let jwt_secret = config.jwt_secret.clone(); let jwt_secret = config.jwt_secret.clone();
let jwt_blacklist = backend_handler let jwt_blacklist = backend_handler
@ -125,6 +170,7 @@ where
.context("while getting the jwt blacklist")?; .context("while getting the jwt blacklist")?;
let server_url = config.http_url.clone(); let server_url = config.http_url.clone();
let mail_options = config.smtp_options.clone(); let mail_options = config.smtp_options.clone();
let verbose = config.verbose;
info!("Starting the API/web server on port {}", config.http_port); info!("Starting the API/web server on port {}", config.http_port);
server_builder server_builder
.bind( .bind(
@ -136,10 +182,13 @@ where
let jwt_blacklist = jwt_blacklist.clone(); let jwt_blacklist = jwt_blacklist.clone();
let server_url = server_url.clone(); let server_url = server_url.clone();
let mail_options = mail_options.clone(); let mail_options = mail_options.clone();
HttpServiceBuilder::new() HttpServiceBuilder::default()
.finish(map_config( .finish(map_config(
App::new() App::new()
.wrap(tracing_actix_web::TracingLogger::<CustomRootSpanBuilder>::new()) .wrap(actix_web::middleware::Condition::new(
verbose,
tracing_actix_web::TracingLogger::<CustomRootSpanBuilder>::new(),
))
.configure(move |cfg| { .configure(move |cfg| {
http_config( http_config(
cfg, cfg,

View File

@ -1,12 +1,16 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![forbid(non_ascii_idents)] #![forbid(non_ascii_idents)]
#![allow(clippy::nonstandard_macro_braces)] // TODO: Remove next line once ubuntu upgrades rustc to >=1.67.1
#![allow(clippy::uninlined_format_args)]
use std::time::Duration; use std::time::Duration;
use crate::{ use crate::{
domain::{ domain::{
handler::{CreateUserRequest, GroupBackendHandler, GroupRequestFilter, UserBackendHandler}, handler::{
CreateUserRequest, GroupBackendHandler, GroupListerBackendHandler, GroupRequestFilter,
UserBackendHandler,
},
sql_backend_handler::SqlBackendHandler, sql_backend_handler::SqlBackendHandler,
sql_opaque_handler::register_password, sql_opaque_handler::register_password,
}, },
@ -158,6 +162,8 @@ fn run_healthcheck(opts: RunOpts) -> Result<()> {
.enable_all() .enable_all()
.build()?; .build()?;
info!("Starting healthchecks");
use tokio::time::timeout; use tokio::time::timeout;
let delay = Duration::from_millis(3000); let delay = Duration::from_millis(3000);
let (ldap, ldaps, api) = runtime.block_on(async { let (ldap, ldaps, api) = runtime.block_on(async {
@ -168,17 +174,53 @@ fn run_healthcheck(opts: RunOpts) -> Result<()> {
) )
}); });
let mut failure = false; let failure = [ldap, ldaps, api]
[ldap, ldaps, api]
.into_iter() .into_iter()
.filter_map(Result::err) .flat_map(|res| {
.for_each(|e| { if let Err(e) = &res {
failure = true; error!("Error running the health check: {:#}", e);
error!("{:#}", e) }
}); res
})
.any(|r| r.is_err());
if failure {
error!("Healthcheck failed");
}
std::process::exit(i32::from(failure)) std::process::exit(i32::from(failure))
} }
async fn create_schema(database_url: String) -> Result<()> {
let sql_pool = {
let mut sql_opt = sea_orm::ConnectOptions::new(database_url.clone());
sql_opt
.max_connections(1)
.sqlx_logging(true)
.sqlx_logging_level(log::LevelFilter::Debug);
Database::connect(sql_opt).await?
};
domain::sql_tables::init_table(&sql_pool)
.await
.context("while creating base tables")?;
infra::jwt_sql_tables::init_table(&sql_pool)
.await
.context("while creating jwt tables")?;
Ok(())
}
fn create_schema_command(opts: RunOpts) -> Result<()> {
debug!("CLI: {:#?}", &opts);
let config = infra::configuration::init(opts)?;
infra::logging::init(&config)?;
let database_url = config.database_url;
actix::run(
create_schema(database_url).unwrap_or_else(|e| error!("Could not create schema: {:#}", e)),
)?;
info!("Schema created successfully.");
Ok(())
}
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 {
@ -186,5 +228,6 @@ fn main() -> Result<()> {
Command::Run(opts) => run_server_command(opts), Command::Run(opts) => run_server_command(opts),
Command::HealthCheck(opts) => run_healthcheck(opts), Command::HealthCheck(opts) => run_healthcheck(opts),
Command::SendTestEmail(opts) => send_test_email_command(opts), Command::SendTestEmail(opts) => send_test_email_command(opts),
Command::CreateSchema(opts) => create_schema_command(opts),
} }
} }

25
set-password/Cargo.toml Normal file
View File

@ -0,0 +1,25 @@
[package]
name = "lldap_set_password"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "*"
rand = "0.8"
serde = "1"
serde_json = "1"
[dependencies.clap]
features = ["std", "color", "suggestions", "derive", "env"]
version = "4"
[dependencies.lldap_auth]
path = "../auth"
features = ["opaque_client"]
[dependencies.reqwest]
version = "*"
default-features = false
features = ["json", "blocking", "rustls-tls"]

133
set-password/src/main.rs Normal file
View File

@ -0,0 +1,133 @@
use anyhow::{bail, ensure, Context, Result};
use clap::Parser;
use lldap_auth::{opaque, registration};
use serde::Serialize;
/// Set the password for a user in LLDAP.
#[derive(Debug, Parser, Clone)]
pub struct CliOpts {
/// Base LLDAP url, e.g. "https://lldap/".
#[clap(short, long)]
pub base_url: String,
/// Admin username.
#[clap(long, default_value = "admin")]
pub admin_username: String,
/// Admin password.
#[clap(long)]
pub admin_password: Option<String>,
/// Connection token (JWT).
#[clap(short, long)]
pub token: Option<String>,
/// Username.
#[clap(short, long)]
pub username: String,
/// New password for the user.
#[clap(short, long)]
pub password: String,
}
fn get_token(base_url: &str, username: &str, password: &str) -> Result<String> {
let client = reqwest::blocking::Client::new();
let response = client
.post(format!("{base_url}/auth/simple/login"))
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(
serde_json::to_string(&lldap_auth::login::ClientSimpleLoginRequest {
username: username.to_string(),
password: password.to_string(),
})
.expect("Failed to encode the username/password as json to log in"),
)
.send()?
.error_for_status()?;
Ok(serde_json::from_str::<lldap_auth::login::ServerLoginResponse>(&response.text()?)?.token)
}
fn call_server(url: &str, token: &str, body: impl Serialize) -> Result<String> {
let client = reqwest::blocking::Client::new();
let request = client
.post(url)
.header("Content-Type", "application/json")
.bearer_auth(token)
.body(serde_json::to_string(&body)?);
let response = request.send()?.error_for_status()?;
Ok(response.text()?)
}
pub fn register_start(
base_url: &str,
token: &str,
request: registration::ClientRegistrationStartRequest,
) -> Result<registration::ServerRegistrationStartResponse> {
let request = Some(request);
let data = call_server(
&format!("{base_url}/auth/opaque/register/start"),
token,
request,
)?;
serde_json::from_str(&data).context("Could not parse response")
}
pub fn register_finish(
base_url: &str,
token: &str,
request: registration::ClientRegistrationFinishRequest,
) -> Result<()> {
let request = Some(request);
call_server(
&format!("{base_url}/auth/opaque/register/finish"),
token,
request,
)
.map(|_| ())
}
fn main() -> Result<()> {
let opts = CliOpts::parse();
ensure!(
opts.password.len() >= 8,
"New password is too short, expected at least 8 characters"
);
ensure!(
opts.base_url.starts_with("http://") || opts.base_url.starts_with("https://"),
"Base URL should start with `http://` or `https://`"
);
let token = match (opts.token.as_ref(), opts.admin_password.as_ref()) {
(Some(token), _) => token.clone(),
(None, Some(password)) => {
get_token(&opts.base_url, &opts.admin_username, password).context("While logging in")?
}
(None, None) => bail!("Either the token or the admin password is required"),
};
let mut rng = rand::rngs::OsRng;
let registration_start_request =
opaque::client::registration::start_registration(&opts.password, &mut rng)
.context("Could not initiate password change")?;
let start_request = registration::ClientRegistrationStartRequest {
username: opts.username.to_string(),
registration_start_request: registration_start_request.message,
};
let res = register_start(&opts.base_url, &token, start_request)?;
let registration_finish = opaque::client::registration::finish_registration(
registration_start_request.state,
res.registration_response,
&mut rng,
)
.context("Error during password change")?;
let req = registration::ClientRegistrationFinishRequest {
server_data: res.server_data,
registration_upload: registration_finish.message,
};
register_finish(&opts.base_url, &token, req)?;
println!("Successfully changed {}'s password", &opts.username);
Ok(())
}