Compare commits
133 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91d12a7e97 | ||
|
|
e31c7351ea | ||
|
|
cf19fd41b0 | ||
|
|
500a441df7 | ||
|
|
6701027002 | ||
|
|
fab884711f | ||
|
|
1a37e1ee04 | ||
|
|
786f571e86 | ||
|
|
33cd850e65 | ||
|
|
8c3a168c7f | ||
|
|
722fc2de57 | ||
|
|
c6ffaa2abf | ||
|
|
c4a63610c0 | ||
|
|
5bf533272e | ||
|
|
22fcc5303f | ||
|
|
8101ddc85f | ||
|
|
49f4e48aae | ||
|
|
4092b2e5b1 | ||
|
|
b387ceb1c4 | ||
|
|
85d59e79ca | ||
|
|
c5017bbd42 | ||
|
|
c72c1fdf2c | ||
|
|
cbde363fde | ||
|
|
ea82b1a644 | ||
|
|
429952c46f | ||
|
|
0dad470602 | ||
|
|
2f1bf87102 | ||
|
|
1a03346a38 | ||
|
|
23a4763914 | ||
|
|
82f6292927 | ||
|
|
e39e141d6c | ||
|
|
a512b1844a | ||
|
|
5e2eea0d97 | ||
|
|
bafb1dc5cc | ||
|
|
45bbe23b3b | ||
|
|
85ee097a3b | ||
|
|
04afc9d8d9 | ||
|
|
b03a38f267 | ||
|
|
8f446bd932 | ||
|
|
1ae7987b88 | ||
|
|
936a6d696a | ||
|
|
fc7ec97051 | ||
|
|
a67128338d | ||
|
|
e757638506 | ||
|
|
a673a6aa45 | ||
|
|
9b91362730 | ||
|
|
733d363e25 | ||
|
|
da186fab38 | ||
|
|
1f632a8069 | ||
|
|
ff698df280 | ||
|
|
1efab58d0c | ||
|
|
a0b0b455ed | ||
|
|
1d8582f937 | ||
|
|
7e62cc6eda | ||
|
|
55bcced476 | ||
|
|
b7957f598b | ||
|
|
5150d8341f | ||
|
|
e5c80b9f17 | ||
|
|
875c59758b | ||
|
|
b54fe9128d | ||
|
|
ebffc1c086 | ||
|
|
5c1db3cf4a | ||
|
|
e173f34edb | ||
|
|
05c60979d7 | ||
|
|
d6c2805847 | ||
|
|
89ae7c200c | ||
|
|
f689458aa2 | ||
|
|
6b6f11db1b | ||
|
|
f1b86a16ee | ||
|
|
4f89b73fe5 | ||
|
|
c7d68af691 | ||
|
|
4537d1ae2b | ||
|
|
90611aefef | ||
|
|
bd90a3a426 | ||
|
|
e1e1d6cd20 | ||
|
|
16a544b5a0 | ||
|
|
73ac5a65d4 | ||
|
|
5420dcf2b8 | ||
|
|
cb84f7f387 | ||
|
|
c7f45b12ac | ||
|
|
f52197e76f | ||
|
|
3ac38bb96f | ||
|
|
2197fe77a5 | ||
|
|
8d7881171b | ||
|
|
f2570cdd3c | ||
|
|
be452f4649 | ||
|
|
3a6c5fdc65 | ||
|
|
0ccedc6717 | ||
|
|
b6dd1ed512 | ||
|
|
a8e5549b3f | ||
|
|
ae9b3678df | ||
|
|
2221686dc6 | ||
|
|
203bc9a8a2 | ||
|
|
ca19e61f50 | ||
|
|
26cedcb621 | ||
|
|
6228c0f87c | ||
|
|
82df8d4ca1 | ||
|
|
c850fa4273 | ||
|
|
a1fe703bf0 | ||
|
|
d20bd196bc | ||
|
|
747e37592d | ||
|
|
f6c43b691a | ||
|
|
8e8614fe2e | ||
|
|
204232659d | ||
|
|
6c9086cc78 | ||
|
|
110b7c7d5b | ||
|
|
ef0a0ffced | ||
|
|
31cf9b8e2c | ||
|
|
aa83f6cab6 | ||
|
|
b38023c48e | ||
|
|
496fbf72ea | ||
|
|
86c052f98b | ||
|
|
610ada972a | ||
|
|
b664524366 | ||
|
|
182449da03 | ||
|
|
82770a5ff0 | ||
|
|
e11a8460ff | ||
|
|
c761f08995 | ||
|
|
c564de2c92 | ||
|
|
7731b8e593 | ||
|
|
4c05058eb2 | ||
|
|
45c50923b7 | ||
|
|
f730e6a580 | ||
|
|
06a12f5351 | ||
|
|
bf20c448dc | ||
|
|
9f138ec4ac | ||
|
|
ddeb4c3ce3 | ||
|
|
9d623e59c1 | ||
|
|
e44625bc6a | ||
|
|
68013c8919 | ||
|
|
842afac7dd | ||
|
|
2bbfacf755 | ||
|
|
f152a78cb6 |
@@ -5,9 +5,6 @@
|
||||
|
||||
# Don't track cargo generated files
|
||||
target/*
|
||||
server/target/*
|
||||
app/target/*
|
||||
auth/target/*
|
||||
|
||||
# Don't track the generated JS
|
||||
app/pkg/*
|
||||
@@ -16,10 +13,27 @@ app/pkg/*
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# Don't track docs
|
||||
*.md
|
||||
LICENSE
|
||||
CHANGELOG.md
|
||||
docs/*
|
||||
example_configs/*
|
||||
|
||||
# Output of `npm install rollup`
|
||||
node_modules/*
|
||||
package-lock.json
|
||||
package.json
|
||||
|
||||
# Pre-build binaries
|
||||
*.tar.gz
|
||||
|
||||
# Various config files that shouldn't be tracked
|
||||
.env
|
||||
lldap_config.toml
|
||||
server_key
|
||||
users.db*
|
||||
screenshot.png
|
||||
recipe.json
|
||||
*.md
|
||||
cert.pem
|
||||
key.pem
|
||||
|
||||
12
.github/codecov.yml
vendored
Normal file
12
.github/codecov.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
codecov:
|
||||
require_ci_to_pass: yes
|
||||
comment:
|
||||
layout: "diff,flags"
|
||||
require_changes: true
|
||||
require_base: true
|
||||
require_head: true
|
||||
ignore:
|
||||
- "app"
|
||||
- "docs"
|
||||
- "example_configs"
|
||||
- "migration-tool"
|
||||
10
.github/dependabot.yml
vendored
Normal file
10
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Set update schedule for GitHub Actions
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
# Check for updates to GitHub Actions every weekday
|
||||
interval: "daily"
|
||||
68
.github/workflows/Dockerfile.ci
vendored
Normal file
68
.github/workflows/Dockerfile.ci
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
FROM debian:bullseye AS lldap
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG TARGETPLATFORM
|
||||
RUN apt update && apt install -y wget
|
||||
WORKDIR /dim
|
||||
COPY bin/ bin/
|
||||
COPY web/ web/
|
||||
|
||||
RUN mkdir -p target/
|
||||
RUN mkdir -p /lldap/app
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
|
||||
mv bin/amd64-bin/lldap target/lldap && \
|
||||
mv bin/amd64-bin/migration-tool target/migration-tool && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/migration-tool && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
|
||||
mv bin/aarch64-bin/lldap target/lldap && \
|
||||
mv bin/aarch64-bin/migration-tool target/migration-tool && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/migration-tool && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
RUN if [ "${TARGETPLATFORM}" = "linux/arm/v7" ]; then \
|
||||
mv bin/armhf-bin/lldap target/lldap && \
|
||||
mv bin/armhf-bin/migration-tool target/migration-tool && \
|
||||
chmod +x target/lldap && \
|
||||
chmod +x target/migration-tool && \
|
||||
ls -la target/ . && \
|
||||
pwd \
|
||||
; fi
|
||||
|
||||
# Web and App dir
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
COPY lldap_config.docker_template.toml /lldap/
|
||||
RUN cp target/lldap /lldap/ && \
|
||||
cp target/migration-tool /lldap/ && \
|
||||
cp -R web/index.html \
|
||||
web/pkg \
|
||||
web/static \
|
||||
/lldap/app/
|
||||
|
||||
RUN set -x \
|
||||
&& for file in $(cat /lldap/app/static/libraries.txt); do wget -P app/static "$file"; done \
|
||||
&& for file in $(cat /lldap/app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||
&& chmod a+r -R .
|
||||
|
||||
FROM debian:bullseye
|
||||
ENV UID=1000
|
||||
ENV GID=1000
|
||||
ENV USER=lldap
|
||||
RUN apt update && \
|
||||
apt install -y --no-install-recommends tini ca-certificates && \
|
||||
apt clean && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER
|
||||
COPY --from=lldap --chown=$CONTAINERUSER:$CONTAINERUSER /lldap /app
|
||||
COPY --from=lldap --chown=$CONTAINERUSER:$CONTAINERUSER /docker-entrypoint.sh /docker-entrypoint.sh
|
||||
WORKDIR /app
|
||||
USER $USER
|
||||
ENTRYPOINT ["tini", "--", "/docker-entrypoint.sh"]
|
||||
CMD ["run", "--config-file", "/data/lldap_config.toml"]
|
||||
410
.github/workflows/docker-build.yml
vendored
Normal file
410
.github/workflows/docker-build.yml
vendored
Normal file
@@ -0,0 +1,410 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
release:
|
||||
types:
|
||||
- 'published'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
msg:
|
||||
description: "Set message"
|
||||
default: "Manual trigger"
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_DIR: $GITHUB_WORKSPACE/.sccache
|
||||
SCCACHE_VERSION: v0.3.0
|
||||
LINK: https://github.com/mozilla/sccache/releases/download
|
||||
|
||||
# In total 5 jobs, all of the jobs are containerized
|
||||
# ---
|
||||
|
||||
# build-ui , create/compile the web
|
||||
## Use rustlang/rust:nighlty image
|
||||
### Install nodejs from nodesource repo
|
||||
### install wasm
|
||||
### install rollup
|
||||
### run app/build.sh
|
||||
### upload artifacts
|
||||
|
||||
# builds-armhf, build-aarch64, build-amd64 create binary for respective arch
|
||||
## Use rustlang/rust:nightly image
|
||||
### Add non native architecture dpkg --add-architecture XXX
|
||||
### Install dev tool gcc g++, etc per respective arch
|
||||
### Cargo build
|
||||
### Upload artifacts
|
||||
|
||||
## the CARGO_ env
|
||||
#CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
|
||||
#OPENSSL_INCLUDE_DIR: "/usr/include/openssl/"
|
||||
#OPENSSL_LIB_DIR: "/usr/lib/arm-linux-gnueabihf/"
|
||||
# This will determine which architecture lib will be used.
|
||||
|
||||
# build-ui,builds-armhf, build-aarch64, build-amd64 will upload artifacts will be used next job
|
||||
# build-docker-image job will fetch artifacts and run Dockerfile.ci then push the image.
|
||||
|
||||
# On current https://hub.docker.com/_/rust
|
||||
# 1-bullseye, 1.61-bullseye, 1.61.0-bullseye, bullseye, 1, 1.61, 1.61.0, latest
|
||||
|
||||
# cache
|
||||
## .sccache
|
||||
## cargo
|
||||
## target
|
||||
|
||||
jobs:
|
||||
build-ui:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: rust:1.61
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Ctarget-feature=-crt-static
|
||||
steps:
|
||||
- name: install runtime
|
||||
run: apt update && apt install -y gcc-x86-64-linux-gnu g++-x86-64-linux-gnu libc6-dev libssl-dev
|
||||
- name: setup node repo LTS
|
||||
run: curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
|
||||
- name: install nodejs
|
||||
run: apt install -y nodejs && npm -g install npm
|
||||
- name: smoke test
|
||||
run: rustc --version
|
||||
- name: Install sccache (ubuntu-latest)
|
||||
run: |
|
||||
SCCACHE_FILE=sccache-$SCCACHE_VERSION-x86_64-unknown-linux-musl
|
||||
mkdir -p $HOME/.local/bin
|
||||
curl -L "$LINK/$SCCACHE_VERSION/$SCCACHE_FILE.tar.gz" | tar xz
|
||||
mv -f $SCCACHE_FILE/sccache $HOME/.local/bin/sccache
|
||||
chmod +x $HOME/.local/bin/sccache
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
.sccache
|
||||
/usr/local/cargo
|
||||
target
|
||||
key: lldap-ui-${{ github.sha }}
|
||||
restore-keys: |
|
||||
lldap-ui-
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- name: install cargo wasm
|
||||
run: cargo install wasm-pack
|
||||
- name: install rollup nodejs
|
||||
run: npm install -g rollup
|
||||
- name: build frontend
|
||||
run: ./app/build.sh
|
||||
- name: check path
|
||||
run: ls -al app/
|
||||
- name: upload ui artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ui
|
||||
path: app/
|
||||
|
||||
build-armhf:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: rust:1.61
|
||||
env:
|
||||
CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER: arm-linux-gnueabihf-gcc
|
||||
OPENSSL_INCLUDE_DIR: "/usr/include/openssl/"
|
||||
OPENSSL_LIB_DIR: "/usr/lib/arm-linux-gnueabihf/"
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Ctarget-feature=-crt-static
|
||||
steps:
|
||||
- name: add armhf architecture
|
||||
run: dpkg --add-architecture armhf
|
||||
- name: install runtime
|
||||
run: apt update && apt install -y gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf libc6-armhf-cross libc6-dev-armhf-cross libssl-dev:armhf tar
|
||||
- name: smoke test
|
||||
run: rustc --version
|
||||
- name: add armhf target
|
||||
run: rustup target add armv7-unknown-linux-gnueabihf
|
||||
- name: smoke test
|
||||
run: rustc --version
|
||||
- name: Install sccache (ubuntu-latest)
|
||||
run: |
|
||||
SCCACHE_FILE=sccache-$SCCACHE_VERSION-x86_64-unknown-linux-musl
|
||||
mkdir -p $HOME/.local/bin
|
||||
curl -L "$LINK/$SCCACHE_VERSION/$SCCACHE_FILE.tar.gz" | tar xz
|
||||
mv -f $SCCACHE_FILE/sccache $HOME/.local/bin/sccache
|
||||
chmod +x $HOME/.local/bin/sccache
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
.sccache
|
||||
/usr/local/cargo
|
||||
target
|
||||
key: lldap-bin-armhf-${{ github.sha }}
|
||||
restore-keys: |
|
||||
lldap-bin-armhf-
|
||||
- name: compile armhf
|
||||
run: cargo build --target=armv7-unknown-linux-gnueabihf --release -p lldap -p migration-tool
|
||||
- name: check path
|
||||
run: ls -al target/release
|
||||
- name: upload armhf lldap artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: armhf-lldap-bin
|
||||
path: target/armv7-unknown-linux-gnueabihf/release/lldap
|
||||
- name: upload armhfmigration-tool artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: armhf-migration-tool-bin
|
||||
path: target/armv7-unknown-linux-gnueabihf/release/migration-tool
|
||||
|
||||
|
||||
build-aarch64:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: rust:1.61
|
||||
env:
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
|
||||
OPENSSL_INCLUDE_DIR: "/usr/include/openssl/"
|
||||
OPENSSL_LIB_DIR: "/usr/lib/aarch64-linux-gnu/"
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Ctarget-feature=-crt-static
|
||||
steps:
|
||||
- name: add arm64 architecture
|
||||
run: dpkg --add-architecture arm64
|
||||
- name: install runtime
|
||||
run: apt update && apt install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-arm64-cross libc6-dev-arm64-cross libssl-dev:arm64 tar
|
||||
- name: smoke test
|
||||
run: rustc --version
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- name: add arm64 target
|
||||
run: rustup target add aarch64-unknown-linux-gnu
|
||||
- name: smoke test
|
||||
run: rustc --version
|
||||
- name: Install sccache (ubuntu-latest)
|
||||
run: |
|
||||
SCCACHE_FILE=sccache-$SCCACHE_VERSION-x86_64-unknown-linux-musl
|
||||
mkdir -p $HOME/.local/bin
|
||||
curl -L "$LINK/$SCCACHE_VERSION/$SCCACHE_FILE.tar.gz" | tar xz
|
||||
mv -f $SCCACHE_FILE/sccache $HOME/.local/bin/sccache
|
||||
chmod +x $HOME/.local/bin/sccache
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
.sccache
|
||||
/usr/local/cargo
|
||||
target
|
||||
key: lldap-bin-aarch64-${{ github.sha }}
|
||||
restore-keys: |
|
||||
lldap-bin-aarch64-
|
||||
- name: compile aarch64
|
||||
run: cargo build --target=aarch64-unknown-linux-gnu --release -p lldap -p migration-tool
|
||||
- name: check path
|
||||
run: ls -al target/aarch64-unknown-linux-gnu/release/
|
||||
- name: upload aarch64 lldap artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: aarch64-lldap-bin
|
||||
path: target/aarch64-unknown-linux-gnu/release/lldap
|
||||
- name: upload aarch64 migration-tool artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: aarch64-migration-tool-bin
|
||||
path: target/aarch64-unknown-linux-gnu/release/migration-tool
|
||||
|
||||
build-amd64:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: rust:1.61
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Ctarget-feature=-crt-static
|
||||
steps:
|
||||
- name: install runtime
|
||||
run: apt update && apt install -y gcc-x86-64-linux-gnu g++-x86-64-linux-gnu libc6-dev libssl-dev tar
|
||||
- name: smoke test
|
||||
run: rustc --version
|
||||
- name: Install sccache (ubuntu-latest)
|
||||
run: |
|
||||
SCCACHE_FILE=sccache-$SCCACHE_VERSION-x86_64-unknown-linux-musl
|
||||
mkdir -p $HOME/.local/bin
|
||||
curl -L "$LINK/$SCCACHE_VERSION/$SCCACHE_FILE.tar.gz" | tar xz
|
||||
mv -f $SCCACHE_FILE/sccache $HOME/.local/bin/sccache
|
||||
chmod +x $HOME/.local/bin/sccache
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
- name: cargo & sscache cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
.sccache
|
||||
/usr/local/cargo
|
||||
target
|
||||
key: lldap-bin-amd64-${{ github.sha }}
|
||||
restore-keys: |
|
||||
lldap-bin-amd64-
|
||||
#- name: add cargo chef
|
||||
# run: cargo install cargo-chef
|
||||
#- name: chef prepare
|
||||
# run: cargo chef prepare --recipe-path recipe.json
|
||||
#- name: cook?
|
||||
# run: cargo chef cook --release --recipe-path recipe.json
|
||||
- name: compile amd64
|
||||
run: cargo build --target=x86_64-unknown-linux-gnu --release -p lldap -p migration-tool
|
||||
- name: check path
|
||||
run: ls -al target/x86_64-unknown-linux-gnu/release/
|
||||
- name: upload amd64 lldap artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: amd64-lldap-bin
|
||||
path: target/x86_64-unknown-linux-gnu/release/lldap
|
||||
- name: upload amd64 migration-tool artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: amd64-migration-tool-bin
|
||||
path: target/x86_64-unknown-linux-gnu/release/migration-tool
|
||||
|
||||
|
||||
build-docker-image:
|
||||
needs: [build-ui,build-armhf,build-aarch64,build-amd64]
|
||||
name: Build Docker image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: fetch repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Download armhf lldap artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: armhf-lldap-bin
|
||||
path: bin/armhf-bin
|
||||
- name: Download armhf migration-tool artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: armhf-migration-tool-bin
|
||||
path: bin/armhf-bin
|
||||
|
||||
- name: Download aarch64 lldap artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: aarch64-lldap-bin
|
||||
path: bin/aarch64-bin
|
||||
- name: Download aarch64 migration-tool artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: aarch64-migration-tool-bin
|
||||
path: bin/aarch64-bin
|
||||
|
||||
- name: Download amd64 lldap artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: amd64-lldap-bin
|
||||
path: bin/amd64-bin
|
||||
- name: Download amd64 migration-tool artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: amd64-migration-tool-bin
|
||||
path: bin/amd64-bin
|
||||
|
||||
- name: check bin path
|
||||
run: ls -al bin/
|
||||
|
||||
- name: Download llap ui artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: ui
|
||||
path: web
|
||||
|
||||
- name: setup qemu
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
nitnelave/lldap
|
||||
# generate Docker tags based on the following events/attributes
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
|
||||
- name: parse tag
|
||||
uses: gacts/github-slug@v1
|
||||
id: slug
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push latest
|
||||
if: github.event_name != 'release'
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
file: ./.github/workflows/Dockerfile.ci
|
||||
tags: nitnelave/lldap:latest
|
||||
#cache-from: type=gha
|
||||
#cache-to: type=gha,mode=max
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
||||
|
||||
- name: Build and push release
|
||||
if: github.event_name == 'release'
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
push: true
|
||||
# Tag as latest, stable, semver, major, major.minor and major.minor.patch.
|
||||
file: ./.github/workflows/Dockerfile.ci
|
||||
tags: nitnelave/lldap:stable, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}
|
||||
#cache-from: type=gha
|
||||
#cache-to: type=gha,mode=max
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new
|
||||
|
||||
- name: Move cache
|
||||
run: |
|
||||
rm -rf /tmp/.buildx-cache
|
||||
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
|
||||
- name: Update repo description
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: peter-evans/dockerhub-description@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
repository: nitnelave/lldap
|
||||
|
||||
63
.github/workflows/docker.yml
vendored
63
.github/workflows/docker.yml
vendored
@@ -1,63 +0,0 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
nitnelave/lldap
|
||||
# generate Docker tags based on the following events/attributes
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
platforms: linux/amd64
|
||||
tags: nitnelave/lldap:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
-
|
||||
name: Update repo description
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: peter-evans/dockerhub-description@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
repository: nitnelave/lldap
|
||||
57
.github/workflows/rust.yml
vendored
57
.github/workflows/rust.yml
vendored
@@ -10,13 +10,31 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
pre_job:
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
# Map a step output to a job output
|
||||
outputs:
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip }}
|
||||
steps:
|
||||
- id: skip_check
|
||||
uses: fkirc/skip-duplicate-actions@master
|
||||
with:
|
||||
concurrent_skipping: 'outdated_runs'
|
||||
skip_after_successful_duplicate: 'true'
|
||||
paths_ignore: '["**/*.md", "**/docs/**", "example_configs/**", "*.sh"]'
|
||||
do_not_skip: '["workflow_dispatch", "schedule"]'
|
||||
cancel_others: true
|
||||
|
||||
test:
|
||||
name: cargo test
|
||||
needs: pre_job
|
||||
if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
- uses: Swatinem/rust-cache@v1
|
||||
- name: Build
|
||||
run: cargo build --verbose --workspace
|
||||
@@ -30,18 +48,12 @@ jobs:
|
||||
|
||||
clippy:
|
||||
name: cargo clippy
|
||||
needs: pre_job
|
||||
if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install nightly toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
profile: minimal
|
||||
toolchain: nightly
|
||||
override: true
|
||||
components: rustfmt, clippy
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: Swatinem/rust-cache@v1
|
||||
|
||||
@@ -53,18 +65,12 @@ jobs:
|
||||
|
||||
format:
|
||||
name: cargo fmt
|
||||
needs: pre_job
|
||||
if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install nightly toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
profile: minimal
|
||||
toolchain: nightly
|
||||
override: true
|
||||
components: rustfmt, clippy
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: Swatinem/rust-cache@v1
|
||||
|
||||
@@ -76,27 +82,26 @@ jobs:
|
||||
|
||||
coverage:
|
||||
name: Code coverage
|
||||
needs: pre_job
|
||||
if: ${{ needs.pre_job.outputs.should_skip != 'true' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Rust
|
||||
run: rustup toolchain install nightly --component llvm-tools-preview
|
||||
run: rustup toolchain install nightly --component llvm-tools-preview && rustup component add llvm-tools-preview --toolchain stable-x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Install cargo-llvm-cov
|
||||
run: curl -LsSf https://github.com/taiki-e/cargo-llvm-cov/releases/latest/download/cargo-llvm-cov-x86_64-unknown-linux-gnu.tar.gz | tar xzf - -C ~/.cargo/bin
|
||||
- uses: taiki-e/install-action@cargo-llvm-cov
|
||||
|
||||
- uses: Swatinem/rust-cache@v1
|
||||
|
||||
- name: clean
|
||||
run: cargo llvm-cov clean --workspace
|
||||
- name: Generate code coverage for unit test
|
||||
run: cargo llvm-cov --workspace --no-report
|
||||
- name: Aggregate reports
|
||||
run: cargo llvm-cov --no-run --lcov --output-path lcov.info
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v1
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
files: lcov.info
|
||||
fail_ci_if_error: true
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,10 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target
|
||||
/serve/target/
|
||||
/app/target
|
||||
/app/pkg
|
||||
/auth/target
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
@@ -22,6 +19,12 @@ package.json
|
||||
# Server private key
|
||||
server_key
|
||||
|
||||
# Pre-build binaries
|
||||
*.tar.gz
|
||||
|
||||
# Misc
|
||||
.env
|
||||
recipe.json
|
||||
lldap_config.toml
|
||||
cert.pem
|
||||
key.pem
|
||||
|
||||
75
CHANGELOG.md
Normal file
75
CHANGELOG.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.0] - 2022-07-08
|
||||
|
||||
### Breaking
|
||||
|
||||
The `lldap_readonly` group has been renamed `lldap_password_manager` (migration happens automatically) and a new `lldap_strict_readonly` group was introduced.
|
||||
|
||||
### Added
|
||||
- A new `lldap_strict_readonly` group allows granting readonly rights to users (not able to change other's passwords, in particular).
|
||||
|
||||
### Changed
|
||||
- The `lldap_readonly` group is renamed `lldap_password_manager` since it still allows users to change (non-admin) passwords.
|
||||
|
||||
### Removed
|
||||
- The `lldap_readonly` group was removed.
|
||||
|
||||
## [0.3.0] - 2022-07-08
|
||||
|
||||
### Breaking
|
||||
As part of the update, the database will do a one-time automatic migration to
|
||||
add UUIDs and group creation times.
|
||||
|
||||
### Added
|
||||
- Added support and documentation for many services:
|
||||
- Apache Guacamole
|
||||
- Bookstack
|
||||
- Calibre
|
||||
- Dolibarr
|
||||
- Emby
|
||||
- Gitea
|
||||
- Grafana
|
||||
- Jellyfin
|
||||
- Matrix Synapse
|
||||
- NextCloud
|
||||
- Organizr
|
||||
- Portainer
|
||||
- Seafile
|
||||
- Syncthing
|
||||
- WG Portal
|
||||
- New migration tool from OpenLDAP.
|
||||
- New docker images for alternate architectures (arm64, arm/v7).
|
||||
- Added support for LDAPS.
|
||||
- New readonly group.
|
||||
- Added UUID attribute for users and groups.
|
||||
- Frontend now uses the refresh tokens to reduce the number of logins needed.
|
||||
|
||||
### Changed
|
||||
- Much improved logging format.
|
||||
- Simplified API login.
|
||||
- Allowed non-admins to run search queries on the content they can see.
|
||||
- "cn" attribute now returns the Full Name, not Username.
|
||||
- Unknown attributes now warn instead of erroring.
|
||||
- Introduced a list of attributes to silence those warnings.
|
||||
|
||||
### Deprecated
|
||||
- Deprecated "cn" as LDAP username, "uid" is the correct attribute.
|
||||
|
||||
### Fixed
|
||||
- Usernames, objectclass and attribute names are now case insensitive.
|
||||
- Handle "1.1" and other wildcard LDAP attributes.
|
||||
- Handle "memberOf" attribute.
|
||||
- Handle fully-specified scope.
|
||||
|
||||
### Security
|
||||
- Prevent SQL injections due to interaction between two libraries.
|
||||
|
||||
## [0.2.0] - 2021-11-27
|
||||
1844
Cargo.lock
generated
1844
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,12 @@
|
||||
members = [
|
||||
"server",
|
||||
"auth",
|
||||
"app"
|
||||
"app",
|
||||
"migration-tool"
|
||||
]
|
||||
|
||||
default-members = ["server"]
|
||||
|
||||
# TODO: remove when there's a new release.
|
||||
[patch.crates-io.yew_form]
|
||||
git = 'https://github.com/sassman/yew_form/'
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -31,11 +31,12 @@ RUN cargo chef prepare --recipe-path /tmp/recipe.json
|
||||
FROM chef AS builder
|
||||
COPY --from=planner /tmp/recipe.json recipe.json
|
||||
RUN cargo chef cook --release -p lldap_app --target wasm32-unknown-unknown \
|
||||
&& cargo chef cook --release -p lldap
|
||||
&& cargo chef cook --release -p lldap \
|
||||
&& cargo chef cook --release -p migration-tool
|
||||
|
||||
# Copy the source and build the app and server.
|
||||
COPY --chown=app:app . .
|
||||
RUN cargo build --release -p lldap \
|
||||
RUN cargo build --release -p lldap -p migration-tool \
|
||||
# Build the frontend.
|
||||
&& ./app/build.sh
|
||||
|
||||
@@ -44,13 +45,16 @@ FROM alpine:3.14
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/app/index.html /app/app/main.js /app/app/style.css app/
|
||||
COPY --from=builder /app/app/index_local.html app/index.html
|
||||
COPY --from=builder /app/app/static app/static
|
||||
COPY --from=builder /app/app/pkg app/pkg
|
||||
COPY --from=builder /app/target/release/lldap lldap
|
||||
COPY --from=builder /app/target/release/lldap /app/target/release/migration-tool ./
|
||||
COPY docker-entrypoint.sh lldap_config.docker_template.toml ./
|
||||
|
||||
RUN set -x \
|
||||
&& apk add --no-cache bash \
|
||||
&& for file in $(cat app/static/libraries.txt); do wget -P app/static "$file"; done \
|
||||
&& for file in $(cat app/static/fonts/fonts.txt); do wget -P app/static/fonts "$file"; done \
|
||||
&& chmod a+r -R .
|
||||
|
||||
ENV LDAP_PORT=3890
|
||||
|
||||
77
README.md
77
README.md
@@ -28,11 +28,27 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
- [About](#About)
|
||||
- [Installation](#Installation)
|
||||
- [With Docker](#With-Docker)
|
||||
- [From source](#From-source)
|
||||
- [Cross-compilation](#Cross-compilation)
|
||||
- [Client configuration](#Client-configuration)
|
||||
- [Compatible services](#compatible-services)
|
||||
- [General configuration guide](#general-configuration-guide)
|
||||
- [Sample cient configurations](#Sample-client-configurations)
|
||||
- [Comparisons with other services](#Comparisons-with-other-services)
|
||||
- [vs OpenLDAP](#vs-openldap)
|
||||
- [vs FreeIPA](#vs-freeipa)
|
||||
- [I can't log in!](#i-cant-log-in)
|
||||
- [Contributions](#Contributions)
|
||||
|
||||
## About
|
||||
|
||||
This project is a lightweight authentication server that provides an
|
||||
opinionated, simplified LDAP interface for authentication. It integrates with
|
||||
many backends, from KeyCloak to Authelia to Nextcloud and more!
|
||||
many backends, from KeyCloak to Authelia to Nextcloud and
|
||||
[more](#compatible-services)!
|
||||
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/nitnelave/lldap/master/screenshot.png"
|
||||
@@ -41,6 +57,9 @@ many backends, from KeyCloak to Authelia to Nextcloud and more!
|
||||
align="right"
|
||||
/>
|
||||
|
||||
It comes with a frontend that makes user management easy, and allows users to
|
||||
edit their own details or reset their password by email.
|
||||
|
||||
The goal is _not_ to provide a full LDAP server; if you're interested in that,
|
||||
check out OpenLDAP. This server is a user management system that is:
|
||||
* simple to setup (no messing around with `slapd`),
|
||||
@@ -63,7 +82,7 @@ truth for users, via LDAP.
|
||||
|
||||
The image is available at `nitnelave/lldap`. You should persist the `/data`
|
||||
folder, which contains your configuration, the database and the private key
|
||||
file (unless you move them in the config).
|
||||
file.
|
||||
|
||||
Configure the server by copying the `lldap_config.docker_template.toml` to
|
||||
`/data/lldap_config.toml` and updating the configuration values (especially the
|
||||
@@ -71,7 +90,10 @@ Configure the server by copying the `lldap_config.docker_template.toml` to
|
||||
Environment variables should be prefixed with `LLDAP_` to override the
|
||||
configuration.
|
||||
|
||||
Secrets can also be set through a file. The filename should be specified by the variables `LLDAP_JWT_SECRET_FILE` or `LLDAP_USER_PASS_FILE`, and the file contents are loaded into the respective configuration parameters. Note that `_FILE` variables take precedence.
|
||||
Secrets can also be set through a file. The filename should be specified by the
|
||||
variables `LLDAP_JWT_SECRET_FILE` or `LLDAP_LDAP_USER_PASS_FILE`, and the file
|
||||
contents are loaded into the respective configuration parameters. Note that
|
||||
`_FILE` variables take precedence.
|
||||
|
||||
Example for docker compose:
|
||||
|
||||
@@ -82,7 +104,7 @@ volumes:
|
||||
|
||||
services:
|
||||
lldap:
|
||||
image: nitnelave/lldap
|
||||
image: nitnelave/lldap:stable
|
||||
# Change this to the user:group you want.
|
||||
user: "33:33"
|
||||
ports:
|
||||
@@ -121,12 +143,9 @@ To bring up the server, just run `cargo run`. The default config is in
|
||||
|
||||
### Cross-compilation
|
||||
|
||||
No Docker image is provided for other architectures, due to the difficulty of
|
||||
setting up cross-compilation inside a Docker image.
|
||||
Docker images are provided for AMD64, ARM64 and ARM/V7.
|
||||
|
||||
Some pre-compiled binaries are provided for each release, starting with 0.2.
|
||||
|
||||
If you want to cross-compile, you can do so by installing
|
||||
If you want to cross-compile yourself, you can do so by installing
|
||||
[`cross`](https://github.com/rust-embedded/cross):
|
||||
|
||||
```sh
|
||||
@@ -146,6 +165,16 @@ files in an `app` folder next to the binary).
|
||||
|
||||
## Client configuration
|
||||
|
||||
### Compatible services
|
||||
|
||||
Most services that can use LDAP as an authentication provider should work out
|
||||
of the box. For new services, it's possible that they require a bit of tweaking
|
||||
on LLDAP's side to make things work. In that case, just create an issue with
|
||||
the relevant details (logs of the service, LLDAP logs with `verbose=true` in
|
||||
the config).
|
||||
|
||||
### General configuration guide
|
||||
|
||||
To configure the services that will talk to LLDAP, here are the values:
|
||||
- The LDAP user DN is from the configuration. By default,
|
||||
`cn=admin,ou=people,dc=example,dc=com`.
|
||||
@@ -160,16 +189,32 @@ Testing group membership through `memberOf` is supported, so you can have a
|
||||
filter like: `(memberOf=cn=admins,ou=groups,dc=example,dc=com)`.
|
||||
|
||||
The administrator group for LLDAP is `lldap_admin`: anyone in this group has
|
||||
admin rights in the Web UI.
|
||||
admin rights in the Web UI. Most LDAP integrations should instead use a user in
|
||||
the `lldap_strict_readonly` or `lldap_password_manager` group, to avoid granting full
|
||||
administration access to many services.
|
||||
|
||||
### Sample client configurations
|
||||
|
||||
Some specific clients have been tested to work and come with sample
|
||||
configuration files, or guides. See the [`example_configs`](example_configs)
|
||||
folder for help with:
|
||||
- [Apache Guacamole](example_configs/apacheguacamole.md)
|
||||
- [Authelia](example_configs/authelia_config.yml)
|
||||
- [KeyCloak](example_configs/keycloak.md)
|
||||
- [Bookstack](example_configs/bookstack.env.example)
|
||||
- [Calibre-Web](example_configs/calibre_web.md)
|
||||
- [Dolibarr](example_configs/dolibarr.md)
|
||||
- [Emby](example_configs/emby.md)
|
||||
- [Gitea](example_configs/gitea.md)
|
||||
- [Grafana](example_configs/grafana_ldap_config.toml)
|
||||
- [Jellyfin](example_configs/jellyfin.md)
|
||||
- [Jisti Meet](example_configs/jitsi_meet.conf)
|
||||
- [KeyCloak](example_configs/keycloak.md)
|
||||
- [Matrix](example_configs/matrix_synapse.yml)
|
||||
- [Organizr](example_configs/Organizr.md)
|
||||
- [Portainer](example_configs/portainer.md)
|
||||
- [Seafile](example_configs/seafile.md)
|
||||
- [Syncthing](example_configs/syncthing.md)
|
||||
- [WG Portal](example_configs/wg_portal.env.example)
|
||||
|
||||
## Comparisons with other services
|
||||
|
||||
@@ -191,9 +236,10 @@ you add PhpLdapAdmin), and comes packed with its own purpose-built wed UI.
|
||||
|
||||
### vs FreeIPA
|
||||
|
||||
FreeIPA is the one-stop shop for identity management: LDAP, Kerberos, NTP, DNS, Samba, you name it, it has it. In addition to user
|
||||
management, it also does security policies, single sign-on, certificate
|
||||
management, linux account management and so on.
|
||||
FreeIPA is the one-stop shop for identity management: LDAP, Kerberos, NTP, DNS,
|
||||
Samba, you name it, it has it. In addition to user management, it also does
|
||||
security policies, single sign-on, certificate management, linux account
|
||||
management and so on.
|
||||
|
||||
If you need all of that, go for it! Keep in mind that a more complex system is
|
||||
more complex to maintain, though.
|
||||
@@ -219,7 +265,8 @@ set isn't working, try the following:
|
||||
for docker) has the rights to write to the `/data` folder. If in doubt, you
|
||||
can `chmod 777 /data` (or whatever the folder) to make it world-writeable.
|
||||
- Make sure you restart the server.
|
||||
- If it's still not working, join the [Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
|
||||
- If it's still not working, join the
|
||||
[Discord server](https://discord.gg/h5PEdRMNyP) to ask for help.
|
||||
|
||||
## Contributions
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "lldap_app"
|
||||
version = "0.2.0"
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
|
||||
version = "0.4.0"
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@@ -12,7 +12,7 @@ jwt = "0.13"
|
||||
rand = "0.8"
|
||||
serde = "1"
|
||||
serde_json = "1"
|
||||
validator = "*"
|
||||
validator = "=0.14"
|
||||
validator_derive = "*"
|
||||
wasm-bindgen = "0.2"
|
||||
yew = "0.18"
|
||||
@@ -21,6 +21,9 @@ yew-router = "0.15"
|
||||
yew_form = "0.1.8"
|
||||
yew_form_derive = "*"
|
||||
|
||||
# Needed because of https://github.com/tkaitchuck/aHash/issues/95
|
||||
indexmap = "=1.6.2"
|
||||
|
||||
[dependencies.web-sys]
|
||||
version = "0.3"
|
||||
features = [
|
||||
|
||||
@@ -24,4 +24,4 @@ then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
$ROLLUP_BIN ./main.js --format iife --file ./pkg/bundle.js
|
||||
$ROLLUP_BIN ./main.js --format iife --file ./pkg/bundle.js --globals bootstrap:bootstrap
|
||||
|
||||
@@ -18,12 +18,19 @@
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css"
|
||||
as="style" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
integrity="sha384-tKLJeE1ALTUwtXlaGjJYM3sejfssWdAaWR2s97axw4xkiAdMzQjtOjgcyw0Y50KU"
|
||||
crossorigin="anonymous" as="style" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
|
||||
crossorigin="anonymous" integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/static/style.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&display=swap" rel="stylesheet">
|
||||
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
37
app/index_local.html
Normal file
37
app/index_local.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>LLDAP Administration</title>
|
||||
<script src="/pkg/bundle.js" defer></script>
|
||||
<link
|
||||
href="/static/bootstrap.min.css"
|
||||
rel="preload stylesheet"
|
||||
integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x"
|
||||
as="style" />
|
||||
<script
|
||||
src="/static/bootstrap.bundle.min.js"
|
||||
integrity="sha384-/bQdsTh/da6pkI1MST/rWKFNjaCP5gBSY4sEBT38Q/9RBh9AH40zEOg7Hlq2THRZ"></script>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/static/bootstrap-icons.css"
|
||||
integrity="sha384-tKLJeE1ALTUwtXlaGjJYM3sejfssWdAaWR2s97axw4xkiAdMzQjtOjgcyw0Y50KU"
|
||||
as="style" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN"
|
||||
href="/static/font-awesome.min.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/static/fonts.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/static/style.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -85,7 +85,7 @@ impl Component for App {
|
||||
}
|
||||
if self.user_info.is_none() {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::new_no_state("/login")));
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login)));
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -100,13 +100,14 @@ impl Component for App {
|
||||
html! {
|
||||
<div class="container shadow-sm py-3">
|
||||
{self.view_banner()}
|
||||
<div class="row justify-content-center">
|
||||
<div class="row justify-content-center" style="padding-bottom: 80px;">
|
||||
<div class="shadow-sm py-3" style="max-width: 1000px">
|
||||
<Router<AppRoute>
|
||||
render = Router::render(move |s| Self::dispatch_route(s, &link, is_admin))
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{self.view_footer()}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -137,7 +138,7 @@ impl App {
|
||||
match &self.user_info {
|
||||
None => {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::new_no_state("/login")));
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::Login)));
|
||||
}
|
||||
Some((user_name, is_admin)) => match &self.redirect_to {
|
||||
Some(url) => {
|
||||
@@ -147,7 +148,7 @@ impl App {
|
||||
None => {
|
||||
if *is_admin {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::new_no_state("/users")));
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(AppRoute::ListUsers)));
|
||||
} else {
|
||||
self.route_dispatcher
|
||||
.send(RouteRequest::ReplaceRoute(Route::from(
|
||||
@@ -271,6 +272,30 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn view_footer(&self) -> Html {
|
||||
html! {
|
||||
<footer class="text-center text-muted fixed-bottom bg-light">
|
||||
<div>
|
||||
<span>{format!("LLDAP version {}", env!("CARGO_PKG_VERSION"))}</span>
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://github.com/nitnelave/lldap" class="me-4 text-reset">
|
||||
<i class="bi-github"></i>
|
||||
</a>
|
||||
<a href="https://discord.gg/h5PEdRMNyP" class="me-4 text-reset">
|
||||
<i class="bi-discord"></i>
|
||||
</a>
|
||||
<a href="https://twitter.com/nitnelave1?ref_src=twsrc%5Etfw" class="me-4 text-reset">
|
||||
<i class="bi-twitter"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<span>{"License "}<a href="https://github.com/nitnelave/lldap/blob/main/LICENSE" class="link-secondary">{"GNU GPL"}</a></span>
|
||||
</div>
|
||||
</footer>
|
||||
}
|
||||
}
|
||||
|
||||
fn is_admin(&self) -> bool {
|
||||
match &self.user_info {
|
||||
None => false,
|
||||
|
||||
@@ -211,8 +211,8 @@ impl Component for ChangePasswordForm {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
|
||||
@@ -92,8 +92,8 @@ impl Component for CreateGroupForm {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
|
||||
@@ -185,8 +185,8 @@ impl Component for CreateUserForm {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
|
||||
@@ -190,8 +190,8 @@ impl Component for GroupDetails {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
|
||||
@@ -75,8 +75,8 @@ impl Component for GroupTable {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
|
||||
@@ -15,6 +15,7 @@ use yew_form_derive::Model;
|
||||
pub struct LoginForm {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: Form<FormModel>,
|
||||
refreshing: bool,
|
||||
}
|
||||
|
||||
/// The fields of the form, with the constraints.
|
||||
@@ -34,6 +35,7 @@ pub struct Props {
|
||||
pub enum Msg {
|
||||
Update,
|
||||
Submit,
|
||||
AuthenticationRefreshResponse(Result<(String, bool)>),
|
||||
AuthenticationStartResponse(
|
||||
(
|
||||
opaque::client::login::ClientLogin,
|
||||
@@ -99,6 +101,14 @@ impl CommonComponent<LoginForm> for LoginForm {
|
||||
.emit(user_info.context("Could not log in")?);
|
||||
Ok(true)
|
||||
}
|
||||
Msg::AuthenticationRefreshResponse(user_info) => {
|
||||
self.refreshing = false;
|
||||
self.common.cancel_task();
|
||||
if let Ok(user_info) = user_info {
|
||||
self.common.on_logged_in.emit(user_info);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,79 +122,96 @@ impl Component for LoginForm {
|
||||
type Properties = Props;
|
||||
|
||||
fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
|
||||
LoginForm {
|
||||
let mut app = LoginForm {
|
||||
common: CommonComponentParts::<Self>::create(props, link),
|
||||
form: Form::<FormModel>::new(FormModel::default()),
|
||||
refreshing: true,
|
||||
};
|
||||
if let Err(e) =
|
||||
app.common
|
||||
.call_backend(HostService::refresh, (), Msg::AuthenticationRefreshResponse)
|
||||
{
|
||||
ConsoleService::debug(&format!("Could not refresh auth: {}", e));
|
||||
app.refreshing = false;
|
||||
}
|
||||
app
|
||||
}
|
||||
|
||||
fn update(&mut self, msg: Self::Message) -> ShouldRender {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
type Field = yew_form::Field<FormModel>;
|
||||
html! {
|
||||
<form
|
||||
class="form center-block col-sm-4 col-offset-4">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">
|
||||
<i class="bi-person-fill"/>
|
||||
</span>
|
||||
if self.refreshing {
|
||||
html! {
|
||||
<div>
|
||||
<img src={"spinner.gif"} alt={"Loading"} />
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<form
|
||||
class="form center-block col-sm-4 col-offset-4">
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">
|
||||
<i class="bi-person-fill"/>
|
||||
</span>
|
||||
</div>
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
field_name="username"
|
||||
placeholder="Username"
|
||||
autocomplete="username"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
</div>
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
field_name="username"
|
||||
placeholder="Username"
|
||||
autocomplete="username"
|
||||
oninput=self.common.callback(|_| Msg::Update) />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">
|
||||
<i class="bi-lock-fill"/>
|
||||
</span>
|
||||
<div class="input-group">
|
||||
<div class="input-group-prepend">
|
||||
<span class="input-group-text">
|
||||
<i class="bi-lock-fill"/>
|
||||
</span>
|
||||
</div>
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
field_name="password"
|
||||
input_type="password"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password" />
|
||||
</div>
|
||||
<Field
|
||||
class="form-control"
|
||||
class_invalid="is-invalid has-error"
|
||||
class_valid="has-success"
|
||||
form=&self.form
|
||||
field_name="password"
|
||||
input_type="password"
|
||||
placeholder="Password"
|
||||
autocomplete="current-password" />
|
||||
</div>
|
||||
<div class="form-group mt-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
{"Login"}
|
||||
</button>
|
||||
<NavButton
|
||||
classes="btn-link btn"
|
||||
disabled=self.common.is_task_running()
|
||||
route=AppRoute::StartResetPassword>
|
||||
{"Forgot your password?"}
|
||||
</NavButton>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! { e.to_string() }
|
||||
} else { html! {} }
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
<div class="form-group mt-3">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled=self.common.is_task_running()
|
||||
onclick=self.common.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})>
|
||||
{"Login"}
|
||||
</button>
|
||||
<NavButton
|
||||
classes="btn-link btn"
|
||||
disabled=self.common.is_task_running()
|
||||
route=AppRoute::StartResetPassword>
|
||||
{"Forgot your password?"}
|
||||
</NavButton>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{ if let Some(e) = &self.common.error {
|
||||
html! { e.to_string() }
|
||||
} else { html! {} }
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,8 +55,8 @@ impl Component for LogoutButton {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
|
||||
@@ -81,8 +81,8 @@ impl Component for RemoveUserFromGroupComponent {
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
|
||||
@@ -76,8 +76,8 @@ impl Component for ResetPasswordStep1Form {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
|
||||
@@ -6,7 +6,10 @@ use crate::{
|
||||
},
|
||||
};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use lldap_auth::*;
|
||||
use lldap_auth::{
|
||||
opaque::client::registration as opaque_registration,
|
||||
password_reset::ServerPasswordResetResponse, registration,
|
||||
};
|
||||
use validator_derive::Validate;
|
||||
use yew::prelude::*;
|
||||
use yew_form::Form;
|
||||
@@ -29,7 +32,7 @@ pub struct ResetPasswordStep2Form {
|
||||
common: CommonComponentParts<Self>,
|
||||
form: Form<FormModel>,
|
||||
username: Option<String>,
|
||||
opaque_data: Option<opaque::client::registration::ClientRegistration>,
|
||||
opaque_data: Option<opaque_registration::ClientRegistration>,
|
||||
route_dispatcher: RouteAgentDispatcher,
|
||||
}
|
||||
|
||||
@@ -39,7 +42,7 @@ pub struct Props {
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
ValidateTokenResponse(Result<String>),
|
||||
ValidateTokenResponse(Result<ServerPasswordResetResponse>),
|
||||
FormUpdate,
|
||||
Submit,
|
||||
RegistrationStartResponse(Result<Box<registration::ServerRegistrationStartResponse>>),
|
||||
@@ -50,7 +53,7 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
||||
fn handle_msg(&mut self, msg: <Self as Component>::Message) -> Result<bool> {
|
||||
match msg {
|
||||
Msg::ValidateTokenResponse(response) => {
|
||||
self.username = Some(response?);
|
||||
self.username = Some(response?.user_id);
|
||||
self.common.cancel_task();
|
||||
Ok(true)
|
||||
}
|
||||
@@ -62,7 +65,7 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
let new_password = self.form.model().password;
|
||||
let registration_start_request =
|
||||
opaque::client::registration::start_registration(&new_password, &mut rng)
|
||||
opaque_registration::start_registration(&new_password, &mut rng)
|
||||
.context("Could not initiate password change")?;
|
||||
let req = registration::ClientRegistrationStartRequest {
|
||||
username: self.username.clone().unwrap(),
|
||||
@@ -80,7 +83,7 @@ impl CommonComponent<ResetPasswordStep2Form> for ResetPasswordStep2Form {
|
||||
let res = res.context("Could not initiate password change")?;
|
||||
let registration = self.opaque_data.take().expect("Missing registration data");
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
let registration_finish = opaque::client::registration::finish_registration(
|
||||
let registration_finish = opaque_registration::finish_registration(
|
||||
registration,
|
||||
res.registration_response,
|
||||
&mut rng,
|
||||
@@ -142,8 +145,8 @@ impl Component for ResetPasswordStep2Form {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
|
||||
@@ -185,8 +185,8 @@ impl Component for UserDetails {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
|
||||
@@ -96,8 +96,8 @@ impl Component for UserDetailsForm {
|
||||
)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
|
||||
@@ -81,8 +81,8 @@ impl Component for UserTable {
|
||||
CommonComponentParts::<Self>::update(self, msg)
|
||||
}
|
||||
|
||||
fn change(&mut self, _: Self::Properties) -> ShouldRender {
|
||||
false
|
||||
fn change(&mut self, props: Self::Properties) -> ShouldRender {
|
||||
self.common.change(props)
|
||||
}
|
||||
|
||||
fn view(&self) -> Html {
|
||||
|
||||
@@ -186,9 +186,13 @@ impl HostService {
|
||||
.context("Error clearing cookie")
|
||||
};
|
||||
let parse_token = move |data: String| {
|
||||
get_claims_from_jwt(&data)
|
||||
serde_json::from_str::<login::ServerLoginResponse>(&data)
|
||||
.context("Could not parse response")
|
||||
.and_then(set_cookies)
|
||||
.and_then(|r| {
|
||||
get_claims_from_jwt(r.token.as_str())
|
||||
.context("Could not parse response")
|
||||
.and_then(set_cookies)
|
||||
})
|
||||
};
|
||||
call_server(
|
||||
"/auth/opaque/login/finish",
|
||||
@@ -223,6 +227,32 @@ impl HostService {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn refresh(_request: (), callback: Callback<Result<(String, bool)>>) -> Result<FetchTask> {
|
||||
let set_cookies = |jwt_claims: JWTClaims| {
|
||||
let is_admin = jwt_claims.groups.contains("lldap_admin");
|
||||
set_cookie("user_id", &jwt_claims.user, &jwt_claims.exp)
|
||||
.map(|_| set_cookie("is_admin", &is_admin.to_string(), &jwt_claims.exp))
|
||||
.map(|_| (jwt_claims.user.clone(), is_admin))
|
||||
.context("Error clearing cookie")
|
||||
};
|
||||
let parse_token = move |data: String| {
|
||||
serde_json::from_str::<login::ServerLoginResponse>(&data)
|
||||
.context("Could not parse response")
|
||||
.and_then(|r| {
|
||||
get_claims_from_jwt(r.token.as_str())
|
||||
.context("Could not parse response")
|
||||
.and_then(set_cookies)
|
||||
})
|
||||
};
|
||||
call_server(
|
||||
"/auth/refresh",
|
||||
yew::format::Nothing,
|
||||
callback,
|
||||
"Could not start authentication: ",
|
||||
parse_token,
|
||||
)
|
||||
}
|
||||
|
||||
// The `_request` parameter is to make it the same shape as the other functions.
|
||||
pub fn logout(_request: (), callback: Callback<Result<()>>) -> Result<FetchTask> {
|
||||
call_server_empty_response_with_error_message(
|
||||
@@ -247,7 +277,7 @@ impl HostService {
|
||||
|
||||
pub fn reset_password_step2(
|
||||
token: &str,
|
||||
callback: Callback<Result<String>>,
|
||||
callback: Callback<Result<lldap_auth::password_reset::ServerPasswordResetResponse>>,
|
||||
) -> Result<FetchTask> {
|
||||
call_server_json_with_error_message(
|
||||
&format!("/auth/reset/step2/{}", token),
|
||||
|
||||
@@ -5,8 +5,7 @@ use web_sys::HtmlDocument;
|
||||
|
||||
fn get_document() -> Result<HtmlDocument> {
|
||||
web_sys::window()
|
||||
.map(|w| w.document())
|
||||
.flatten()
|
||||
.and_then(|w| w.document())
|
||||
.ok_or_else(|| anyhow!("Could not get window document"))
|
||||
.and_then(|d| {
|
||||
d.dyn_into::<web_sys::HtmlDocument>()
|
||||
@@ -16,8 +15,7 @@ fn get_document() -> Result<HtmlDocument> {
|
||||
|
||||
pub fn set_cookie(cookie_name: &str, value: &str, expiration: &DateTime<Utc>) -> Result<()> {
|
||||
let doc = web_sys::window()
|
||||
.map(|w| w.document())
|
||||
.flatten()
|
||||
.and_then(|w| w.document())
|
||||
.ok_or_else(|| anyhow!("Could not get window document"))
|
||||
.and_then(|d| {
|
||||
d.dyn_into::<web_sys::HtmlDocument>()
|
||||
|
||||
18
app/static/fonts.css
Normal file
18
app/static/fonts.css
Normal file
@@ -0,0 +1,18 @@
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Bebas Neue';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(fonts/JTUSjIg69CK48gW7PXoo9Wdhyzbi.woff2) format('woff2');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Bebas Neue';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(fonts/JTUSjIg69CK48gW7PXoo9Wlhyw.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
3
app/static/fonts/fonts.txt
Normal file
3
app/static/fonts/fonts.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/fonts/bootstrap-icons.woff2
|
||||
https://fonts.gstatic.com/s/bebasneue/v2/JTUSjIg69CK48gW7PXoo9Wdhyzbi.woff2
|
||||
https://fonts.gstatic.com/s/bebasneue/v2/JTUSjIg69CK48gW7PXoo9Wlhyw.woff2
|
||||
4
app/static/libraries.txt
Normal file
4
app/static/libraries.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css
|
||||
https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/js/bootstrap.bundle.min.js
|
||||
https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css
|
||||
https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css
|
||||
BIN
app/static/spinner.gif
Normal file
BIN
app/static/spinner.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "lldap_auth"
|
||||
version = "0.2.0"
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
|
||||
version = "0.3.0-alpha.1"
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
@@ -13,7 +13,7 @@ js = []
|
||||
[dependencies]
|
||||
rust-argon2 = "0.8"
|
||||
curve25519-dalek = "3"
|
||||
digest = "*"
|
||||
digest = "0.9"
|
||||
generic-array = "*"
|
||||
rand = "0.8"
|
||||
serde = "*"
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
use chrono::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::fmt;
|
||||
|
||||
pub mod opaque;
|
||||
|
||||
/// The messages for the 3-step OPAQUE login process.
|
||||
/// The messages for the 3-step OPAQUE and simple login process.
|
||||
pub mod login {
|
||||
use super::*;
|
||||
|
||||
@@ -35,6 +36,28 @@ pub mod login {
|
||||
pub server_data: String,
|
||||
pub credential_finalization: opaque::client::login::CredentialFinalization,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ClientSimpleLoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ClientSimpleLoginRequest {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ClientSimpleLoginRequest")
|
||||
.field("username", &self.username)
|
||||
.field("password", &"***********")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ServerLoginResponse {
|
||||
pub token: String,
|
||||
#[serde(rename = "refreshToken", skip_serializing_if = "Option::is_none")]
|
||||
pub refresh_token: Option<String>,
|
||||
}
|
||||
}
|
||||
|
||||
/// The messages for the 3-step OPAQUE registration process.
|
||||
@@ -68,6 +91,19 @@ pub mod registration {
|
||||
}
|
||||
}
|
||||
|
||||
/// The messages for the 3-step OPAQUE registration process.
|
||||
/// It is used to reset a user's password.
|
||||
pub mod password_reset {
|
||||
use super::*;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct ServerPasswordResetResponse {
|
||||
#[serde(rename = "userId")]
|
||||
pub user_id: String,
|
||||
pub token: String,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct JWTClaims {
|
||||
pub exp: DateTime<Utc>,
|
||||
|
||||
@@ -6,7 +6,8 @@ backend and [yew](https://yew.rs) for the frontend.
|
||||
Backend:
|
||||
* Listens on a port for LDAP protocol.
|
||||
* Only a small, read-only subset of the LDAP protocol is supported.
|
||||
* An extension to allow resetting the password through LDAP will be added.
|
||||
* In addition to that, an extension to allow resetting the password is also
|
||||
supported.
|
||||
* Listens on another port for HTTP traffic.
|
||||
* The authentication API, based on JWTs, is under "/auth".
|
||||
* The user management API is a GraphQL API under "/api/graphql". The schema
|
||||
@@ -46,11 +47,6 @@ Data storage:
|
||||
|
||||
### Passwords
|
||||
|
||||
Passwords are hashed using Argon2, the state of the art in terms of password
|
||||
storage. They are hashed using a secret provided in the configuration (which
|
||||
can be given as environment variable or command line argument as well): this
|
||||
should be kept secret and shouldn't change (it would invalidate all passwords).
|
||||
|
||||
Authentication is done via the OPAQUE protocol, meaning that the passwords are
|
||||
never sent to the server, but instead the client proves that they know the
|
||||
correct password (zero-knowledge proof). This is likely overkill, especially
|
||||
@@ -59,6 +55,15 @@ but it's one less potential flaw (especially since the LDAP interface can be
|
||||
restricted to an internal docker-only network while the web app is exposed to
|
||||
the Internet).
|
||||
|
||||
OPAQUE's "passwords" (user-specific blobs of data that can only be used in a
|
||||
zero-knowledge proof that the password is correct) are hashed using Argon2, the
|
||||
state of the art in terms of password storage. They are hashed using a secret
|
||||
provided in the configuration (which can be given as environment variable or
|
||||
command line argument as well): this should be kept secret and shouldn't change
|
||||
(it would invalidate all passwords). Note that even if it was compromised, the
|
||||
attacker wouldn't be able to decrypt the passwords without running an expensive
|
||||
brute-force search independently for each password.
|
||||
|
||||
### JWTs and refresh tokens
|
||||
|
||||
When logging in for the first time, users are provided with a refresh token
|
||||
40
example_configs/Organizr.md
Normal file
40
example_configs/Organizr.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Configuration for Organizr
|
||||
## System Settings > Main > Authentication
|
||||
---
|
||||
|
||||
### Host Address
|
||||
```
|
||||
ldap://localhost:3890
|
||||
```
|
||||
Replace `localhost:3890` with your LLDAP host & port
|
||||
|
||||
### Host Base DN
|
||||
```
|
||||
cn=%s,ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
### Account prefix
|
||||
```
|
||||
cn=
|
||||
```
|
||||
|
||||
### Account Suffix
|
||||
```
|
||||
,ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
### Bind Username
|
||||
```
|
||||
cn=admin,ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
### Bind Password
|
||||
```
|
||||
Your password from your LDAP config
|
||||
```
|
||||
### LDAP Backend Type
|
||||
```
|
||||
OpenLDAP
|
||||
```
|
||||
|
||||
Replace `dc=example,dc=com` with your LLDAP configured domain for all occurances
|
||||
56
example_configs/apacheguacamole.md
Normal file
56
example_configs/apacheguacamole.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Configuration for Apache Guacamole
|
||||
!! IMPORTANT - LDAP only works with LLDAP if using a [database authentication](https://guacamole.apache.org/doc/gug/ldap-auth.html#associating-ldap-with-a-database). The Apache Guacamole does support using LDAP to store user config but that is not in scope here.
|
||||
This was achieved by using the docker [jasonbean/guacamole](https://registry.hub.docker.com/r/jasonbean/guacamole/).
|
||||
|
||||
## To setup LDAP
|
||||
|
||||
### Using `guacamole.properties`
|
||||
Open and edit your Apache Guacamole properties files
|
||||
|
||||
Located at `guacamole/guacamole.properties`
|
||||
|
||||
Uncomment and insert the below into your properties file
|
||||
|
||||
```
|
||||
### http://guacamole.apache.org/doc/gug/ldap-auth.html
|
||||
### LDAP Properties
|
||||
ldap-hostname: localhost
|
||||
ldap-port: 3890
|
||||
ldap-user-base-dn: ou=people,dc=example,dc=com
|
||||
ldap-username-attribute: uid
|
||||
ldap-search-bind-dn: uid=admin,ou=people,dc=example,dc=com
|
||||
ldap-search-bind-password: replacewithyoursecret
|
||||
ldap-user-search-filter: (memberof=cn=lldap_apacheguac,ou=groups,dc=example,dc=com)
|
||||
```
|
||||
|
||||
### Using docker variables
|
||||
|
||||
```
|
||||
LDAP_HOSTNAME: localhost
|
||||
LDAP_PORT: 3890
|
||||
LDAP_ENCRYPTION_METHOD: none
|
||||
LDAP_USER_BASE_DN: ou=people,dc=example,dc=com
|
||||
LDAP_USERNAME_ATTRIBUTE: uid
|
||||
LDAP_SEARCH_BIND_DN: uid=admin,ou=people,dc=example,dc=com
|
||||
LDAP_SEARCH_BIND_PASSWORD: replacewithyoursecret
|
||||
LDAP_USER_SEARCH_FILTER: (memberof=cn=lldap_guacamole,ou=groups,dc=example,dc=com)
|
||||
```
|
||||
|
||||
### Notes
|
||||
* You set it either through `guacamole.properties` or docker variables, not both.
|
||||
* Exclude `ldap-user-search-filter/LDAP_USER_SEARCH_FILTER` if you do not want to limit users based on a group(s)
|
||||
* it is a filter that permits users with `lldap_guacamole` sample group.
|
||||
* Replace `dc=example,dc=com` with your LLDAP configured domain for all occurances
|
||||
* Apache Guacamole does not lock you out when enabling LDAP. Your `static` IDs still are able to log in.
|
||||
* setting `LDAP_ENCRYPTION_METHOD` is disabling SSL
|
||||
|
||||
## To enable LDAP
|
||||
Restart your Apache Guacamole app for changes to take effect
|
||||
|
||||
## To enable users
|
||||
Before logging in with an LLDAP user, you have to manually create it using your static ID in Apache Guacamole. This applies to each user that you want to log in with using LDAP authentication. Otherwise the user will be logged in without any permissions/connections/etc.
|
||||
|
||||
Using your static ID, create a username that matches your target LDAP username. If applicable, tick the permissions and/or connections that you want this user to see.
|
||||
|
||||
Log in with LDAP user.
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
|
||||
authentication_backend:
|
||||
# Password reset through authelia works normally.
|
||||
disable_reset_password: false
|
||||
password_reset:
|
||||
disable: false
|
||||
# How often authelia should check if there is an user update in LDAP
|
||||
refresh_interval: 1m
|
||||
ldap:
|
||||
@@ -42,6 +43,6 @@ authentication_backend:
|
||||
display_name_attribute: displayName
|
||||
# The username and password of the admin user.
|
||||
# "admin" should be the admin username you set in the LLDAP configuration
|
||||
user: cn=admin,ou=people,dc=example,dc=com
|
||||
user: uid=admin,ou=people,dc=example,dc=com
|
||||
# Password can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html
|
||||
password: 'REPLACE_ME'
|
||||
|
||||
66
example_configs/bookstack.env.example
Normal file
66
example_configs/bookstack.env.example
Normal file
@@ -0,0 +1,66 @@
|
||||
|
||||
## ADD after values in the existing .env file.
|
||||
## To keep existing documents, you might need to alter ownership/permission in the bookstack database.
|
||||
|
||||
# General auth
|
||||
AUTH_METHOD=ldap
|
||||
|
||||
# The LDAP host, Adding a port is optional
|
||||
LDAP_SERVER=ldap://lldap:3890
|
||||
|
||||
# If using LDAP over SSL you should also define the protocol:
|
||||
# LDAP_SERVER=ldaps://example.com:636
|
||||
|
||||
# The base DN from where users will be dk within
|
||||
LDAP_BASE_DN=ou=people,dc=example,dc=com
|
||||
|
||||
# The full DN and password of the user used to search the server
|
||||
# Can both be left as false to bind anonymously
|
||||
LDAP_DN=uid=admin,ou=people,dc=example,dc=com
|
||||
LDAP_PASS=YOUR-ADMIN-PASSWORD-HERE
|
||||
|
||||
# A filter to use when searching for users
|
||||
# The user-provided user-name used to replace any occurrences of '${user}'
|
||||
# If you're setting this option via other means, such as within a docker-compose.yml,
|
||||
# you may need escape the $, often using $$ or \$ instead.
|
||||
LDAP_USER_FILTER=(&(uid=${user}))
|
||||
|
||||
# Set the LDAP version to use when connecting to the server
|
||||
# Should be set to 3 in most cases.
|
||||
LDAP_VERSION=3
|
||||
|
||||
# Set the property to use as a unique identifier for this user.
|
||||
# Stored and used to match LDAP users with existing BookStack users.
|
||||
# Prefixing the value with 'BIN;' will assume the LDAP service provides the attribute value as
|
||||
# binary data and BookStack will convert the value to a hexidecimal representation.
|
||||
# Defaults to 'uid'.
|
||||
LDAP_ID_ATTRIBUTE=uid
|
||||
|
||||
# Set the default 'email' attribute. Defaults to 'mail'
|
||||
LDAP_EMAIL_ATTRIBUTE=mail
|
||||
|
||||
# Set the property to use for a user's display name. Defaults to 'cn'
|
||||
LDAP_DISPLAY_NAME_ATTRIBUTE=cn
|
||||
|
||||
# Set the attribute to use for the user's avatar image.
|
||||
# Must provide JPEG binary image data.
|
||||
# Will be used upon login or registration when the user doesn't
|
||||
# already have an avatar image set.
|
||||
# Remove this option or set to 'null' to disable LDAP avatar import.
|
||||
|
||||
#LDAP_THUMBNAIL_ATTRIBUTE=jpegphoto
|
||||
|
||||
# Force TLS to be used for LDAP communication.
|
||||
# Use this if you can but your LDAP support will need to support it and
|
||||
# you may need to import your certificate to the BookStack host machine.
|
||||
# Defaults to 'false'.
|
||||
LDAP_START_TLS=false
|
||||
|
||||
# If you need to allow untrusted LDAPS certificates, add the below and uncomment (remove the #)
|
||||
# Only set this option if debugging or you're absolutely sure it's required for your setup.
|
||||
# If using php-fpm, you may want to restart it after changing this option to avoid instability.
|
||||
#LDAP_TLS_INSECURE=true
|
||||
|
||||
# If you need to debug the details coming from your LDAP server, add the below and uncomment (remove the #)
|
||||
# Only set this option if debugging since it will block logins and potentially show private details.
|
||||
#LDAP_DUMP_USER_DETAILS=true
|
||||
97
example_configs/calibre_web.md
Normal file
97
example_configs/calibre_web.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Configuration for Calibre-Web
|
||||
|
||||
Replace `dc=example,dc=com` with your LLDAP configured domain.
|
||||
|
||||
|
||||
### Login type
|
||||
|
||||
```
|
||||
Use LDAP Authentication
|
||||
```
|
||||
|
||||
### LDAP Server Host Name or IP Address
|
||||
|
||||
```
|
||||
lldap
|
||||
```
|
||||
|
||||
### LDAP Server Port
|
||||
|
||||
```
|
||||
3890
|
||||
```
|
||||
|
||||
### LDAP Encryption
|
||||
|
||||
```
|
||||
none
|
||||
```
|
||||
|
||||
### LDAP Authentication
|
||||
|
||||
```
|
||||
simple
|
||||
```
|
||||
|
||||
### LDAP Administrator Username
|
||||
|
||||
```
|
||||
uid=admin,ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
### LDAP Administrator Password
|
||||
|
||||
```
|
||||
CHANGE_ME
|
||||
```
|
||||
|
||||
### LDAP Distinguished Name (DN)
|
||||
|
||||
```
|
||||
dc=example,dc=com
|
||||
```
|
||||
|
||||
### LDAP User Object Filter
|
||||
|
||||
```
|
||||
(&(objectclass=person)(uid=%s))
|
||||
```
|
||||
|
||||
### LDAP Server is OpenLDAP?
|
||||
|
||||
```
|
||||
yes
|
||||
```
|
||||
|
||||
### LDAP Group Object Filter
|
||||
|
||||
```
|
||||
(&(objectclass=groupOfUniqueNames)(cn=%s))
|
||||
```
|
||||
|
||||
### LDAP Group Name
|
||||
|
||||
```
|
||||
calibre_web
|
||||
```
|
||||
|
||||
Note: Create a group in lldap and add users to it that will have access to your Calibre-Web instance
|
||||
|
||||
### LDAP Group Members Field
|
||||
|
||||
```
|
||||
uniqueMember
|
||||
```
|
||||
|
||||
### LDAP Member User Filter Detection
|
||||
|
||||
```
|
||||
Custom Filter
|
||||
```
|
||||
|
||||
### LDAP Member User Filter
|
||||
|
||||
```
|
||||
(&(objectclass=person)(uid=%s))
|
||||
```
|
||||
Note: lowercase the word "person" until this bug is fixed
|
||||
89
example_configs/dolibarr.md
Normal file
89
example_configs/dolibarr.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Configuration pour Dolibarr
|
||||
|
||||
This example will help you to create user in dolibarr from your users in your lldap server from a specific group and to login with the password from the lldap server.
|
||||
|
||||
## To connect ldap->dolibarr
|
||||
|
||||
In Dolibarr, install the LDAP module from `Home` -> `Modules/Applications`
|
||||
Go to the configuration of this module and fill it like this:
|
||||
|
||||
|
||||
- Users and groups synchronization: `LDAP -> Dolibarr`
|
||||
- Contacts' synchronization: `No`
|
||||
- Type: `OpenLdap`
|
||||
- Version: `Version 3`
|
||||
- Primary server: `ldap://example.com`
|
||||
- Secondary server: `Empty`
|
||||
- Server port: port `3890`
|
||||
- Server DN: `dc=example,dc=com`
|
||||
- Use TLS: `No`
|
||||
- Administrator DN: `uid=admin,ou=people,dc=example,dc=com`
|
||||
- Administrator password: `secret`
|
||||
|
||||
Click on modify then "TEST LDAP CONNECTION".
|
||||
You should get this result on the bottom:
|
||||
```
|
||||
TCP connect to LDAP server successful (Server=ldap://example.com, Port=389)
|
||||
Connect/Authenticate to LDAP server successful (Server=ldap://example.com, Port=389, Admin=uid=admin,ou=people,dc=example,dc=com, Password=**********)
|
||||
LDAP server configured for version 3
|
||||
```
|
||||
|
||||
And two new tabs will appear on the top: `Users` and `Groups`.
|
||||
|
||||
We will use only `Users` in this example to get the users we want to import.
|
||||
The tab `Groups` would be to import groups.
|
||||
|
||||
Click on the `Users` tab and fill it like this:
|
||||
- Users' DN: `ou=people,dc=example,dc=com`
|
||||
- List of objectClass: `person`
|
||||
- Search filter: `memberOf=cn=yournamegroup,ou=groups,dc=example,dc=com`
|
||||
|
||||
(or if you don't have a group for your users, leave the search filter empty)
|
||||
|
||||
- Full name: `cn`
|
||||
- Name: `sn`
|
||||
- First name: `givenname`
|
||||
- Login `uid`
|
||||
- Email address `mail`
|
||||
|
||||
Click on "MODIFY" and then on "TEST A LDAP SEARCH".
|
||||
|
||||
You should get the number of users in the group or all users if you didn't use a filter.
|
||||
|
||||
|
||||
## To import ldap users into the dolibarr database (needed to login with those users):
|
||||
|
||||
Navigate to `Users & Groups` -> `New Users`.
|
||||
Click on the blank form "Users in LDAP database", you will get the list of the users in the group filled above. With the "GET" button, you will import the selected user.
|
||||
|
||||
|
||||
## To enable LDAP login:
|
||||
|
||||
Modify your `conf.php` in your dolibarr folder in `htdocs/conf`.
|
||||
Replace
|
||||
```
|
||||
// Authentication settings
|
||||
$dolibarr_main_authentication='dolibarr';
|
||||
```
|
||||
|
||||
with:
|
||||
```
|
||||
// Authentication settings
|
||||
// Only add "ldap" to only login using the ldap server, or/and "dolibar" to compare with local users. In any case, you need to have the user existing in dolibarr.
|
||||
$dolibarr_main_authentication='ldap,dolibarr';
|
||||
$dolibarr_main_auth_ldap_host='ldap://127.0.0.1:3890';
|
||||
$dolibarr_main_auth_ldap_port='3890';
|
||||
$dolibarr_main_auth_ldap_version='3';
|
||||
$dolibarr_main_auth_ldap_servertype='openldap';
|
||||
$dolibarr_main_auth_ldap_login_attribute='uid';
|
||||
$dolibarr_main_auth_ldap_dn='ou=people,dc=example,dc=com';
|
||||
$dolibarr_main_auth_ldap_admin_login='uid=admin,ou=people,dc=example,dc=com';
|
||||
$dolibarr_main_auth_ldap_admin_pass='secret';
|
||||
```
|
||||
|
||||
You can add this line to enable debug in case anything is wrong:
|
||||
```
|
||||
$dolibarr_main_auth_ldap_debug='true';
|
||||
```
|
||||
|
||||
|
||||
29
example_configs/emby.md
Normal file
29
example_configs/emby.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Configuration for Emby
|
||||
|
||||
Emby only uses LDAP to create users and validate passwords upon login. Emby administrators are always validated via native emby login.
|
||||
https://emby.media/introducing-ldap-support-for-emby.html
|
||||
|
||||
Replace `dc=example,dc=com` with your LLDAP configured domain.
|
||||
|
||||
### Bind DN
|
||||
```
|
||||
cn=admin,ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
### Bind Credentials
|
||||
```
|
||||
changeme (replace with your password)
|
||||
```
|
||||
|
||||
### User search base
|
||||
```
|
||||
ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
### User search filter
|
||||
|
||||
replace the `emby_user` cn with the group name for accounts that should be able to login to Emby, otherwise leave the default `(uid={0})`.
|
||||
|
||||
```
|
||||
(&(uid={0})(memberOf=cn=emby_user,ou=groups,dc=example,dc=com))
|
||||
```
|
||||
22
example_configs/gitea.md
Normal file
22
example_configs/gitea.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Configuration for Gitea
|
||||
In Gitea, go to `Site Administration > Authentication Sources` and click `Add Authentication Source`
|
||||
Select `LDAP (via BindDN)`
|
||||
|
||||
* Host: Your lldap server's ip/hostname
|
||||
* Port: Your lldap server's port (3890 by default)
|
||||
* Bind DN: `uid=admin,ou=people,dc=example,dc=com`
|
||||
* Bind Password: Your bind user's password
|
||||
* User Search Base: `ou=people,dc=example,dc=com`
|
||||
* User Filter: If you want all users to be able to log in, use<br>
|
||||
`(&(objectClass=person)(|(uid=%[1]s)(mail=%[1]s)))`.<br>
|
||||
To log in they can either use their email address or user name. If you only want members a specific group to be able to log in, in this case the group `git_user`, use<br>
|
||||
`(&(memberof=cn=git_user,ou=groups,dc=example,dc=com)(|(uid=%[1]s)(mail=%[1]s)))`<br>
|
||||
For more info on the user filter, see: https://docs.gitea.io/en-us/authentication/#ldap-via-binddn
|
||||
* Admin Filter: Use `(memberof=cn=lldap_admin,ou=groups,dc=example,dc=com)` if you want lldap admins to become Gitea admins. Leave empty otherwise.
|
||||
* Username Attribute: `uid`
|
||||
* Email Attribute: `mail`
|
||||
* Check `Enable User Synchronization`
|
||||
|
||||
Replace every instance of `dc=example,dc=com` with your configured domain.
|
||||
|
||||
After applying the above settings, users should be able to log in with either their user name or email address.
|
||||
49
example_configs/grafana_ldap_config.toml
Normal file
49
example_configs/grafana_ldap_config.toml
Normal file
@@ -0,0 +1,49 @@
|
||||
# This is only the ldap config, you also need to enable ldap support in the main config file
|
||||
# of Grafana. See https://grafana.com/docs/grafana/latest/auth/ldap/#enable-ldap
|
||||
# You can test that it is working correctly by trying usernames at: https://<your grafana instance>/admin/ldap
|
||||
|
||||
[[servers]]
|
||||
# Ldap server host (specify multiple hosts space separated)
|
||||
host = "<your ldap host>"
|
||||
# Default port is 389 or 636 if use_ssl = true
|
||||
port = 3890
|
||||
# Set to true if LDAP server should use an encrypted TLS connection (either with STARTTLS or LDAPS)
|
||||
use_ssl = false
|
||||
# If set to true, use LDAP with STARTTLS instead of LDAPS
|
||||
start_tls = false
|
||||
# set to true if you want to skip SSL cert validation
|
||||
ssl_skip_verify = false
|
||||
# set to the path to your root CA certificate or leave unset to use system defaults
|
||||
# root_ca_cert = "/path/to/certificate.crt"
|
||||
# Authentication against LDAP servers requiring client certificates
|
||||
# client_cert = "/path/to/client.crt"
|
||||
# client_key = "/path/to/client.key"
|
||||
|
||||
# Search user bind dn
|
||||
bind_dn = "cn=<your grafana user>,ou=people,dc=example,dc=org"
|
||||
# Search user bind password
|
||||
# If the password contains # or ; you have to wrap it with triple quotes. Ex """#password;"""
|
||||
bind_password = "<grafana user password>"
|
||||
|
||||
# User search filter
|
||||
search_filter = "(uid=%s)"
|
||||
# If you want to limit to only users of a specific group use this instead:
|
||||
# search_filter = "(&(uid=%s)(memberOf=cn=<your group>,ou=groups,dc=example,dc=org))"
|
||||
|
||||
# An array of base dns to search through
|
||||
search_base_dns = ["dc=example,dc=org"]
|
||||
|
||||
# Specify names of the LDAP attributes your LDAP uses
|
||||
[servers.attributes]
|
||||
member_of = "memberOf"
|
||||
email = "mail"
|
||||
name = "givenName"
|
||||
surname = "sn"
|
||||
username = "uid"
|
||||
|
||||
# If you want to map your ldap groups to grafana's groups, see: https://grafana.com/docs/grafana/latest/auth/ldap/#group-mappings
|
||||
# As a quick example, here is how you would map lldap's admin group to grafana's admin
|
||||
# [[servers.group_mappings]]
|
||||
# group_dn = "cn=lldap_admin,ou=groups,c=example,dc=org"
|
||||
# org_role = "Admin"
|
||||
# grafana_admin = true
|
||||
50
example_configs/jellyfin.md
Normal file
50
example_configs/jellyfin.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Configuration for Jellyfin
|
||||
|
||||
Replace `dc=example,dc=com` with your LLDAP configured domain.
|
||||
|
||||
### LDAP Bind User
|
||||
```
|
||||
uid=admin,ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
### LDAP Base DN for searches
|
||||
```
|
||||
ou=people,dc=example,dc=com
|
||||
```
|
||||
|
||||
### LDAP Attributes
|
||||
|
||||
```
|
||||
uid, mail
|
||||
```
|
||||
|
||||
### LDAP Name Attribute
|
||||
|
||||
```
|
||||
uid
|
||||
```
|
||||
|
||||
### User Filter
|
||||
|
||||
If you have a `media` group, you can use:
|
||||
```
|
||||
(memberof=cn=media,ou=groups,dc=example,dc=com)
|
||||
```
|
||||
|
||||
Otherwise, just use:
|
||||
```
|
||||
(uid=*)
|
||||
```
|
||||
|
||||
### Admin Filter
|
||||
|
||||
Same here. If you have `media_admin` group (doesn't have to be named like
|
||||
that), use:
|
||||
```
|
||||
(memberof=cn=media_admin,ou=groups,dc=example,dc=com)
|
||||
```
|
||||
|
||||
Otherwise, you can use LLDAP's admin group:
|
||||
```
|
||||
(memberof=cn=lldap_admin,ou=groups,dc=example,dc=com)
|
||||
```
|
||||
@@ -15,10 +15,10 @@ AUTH_TYPE=ldap
|
||||
LDAP_URL=ldap://IP:3890
|
||||
|
||||
# LDAP base DN.
|
||||
LDAP_BASE=dc=example,dc=com
|
||||
LDAP_BASE=ou=people,dc=example,dc=com
|
||||
|
||||
# LDAP user DN.
|
||||
LDAP_BINDDN=cn=admin,ou=people,dc=example,dc=com
|
||||
LDAP_BINDDN=uid=admin,ou=people,dc=example,dc=com
|
||||
|
||||
# LLDAP admin password.
|
||||
LDAP_BINDPW=password
|
||||
|
||||
@@ -25,7 +25,7 @@ The key settings are:
|
||||
- Connection URL: `ldap://<your-lldap-container>:3890`
|
||||
- Users DN: `ou=people,dc=example,dc=com` (or whatever `dc` you have)
|
||||
- Bind Type: `simple`
|
||||
- Bind DN: `cn=admin,ou=people,dc=example,dc=com` (replace with your admin user and `dc`)
|
||||
- Bind DN: `uid=admin,ou=people,dc=example,dc=com` (replace with your admin user and `dc`)
|
||||
- Bind Credential: your LLDAP admin password
|
||||
|
||||
Test the connection and authentication, it should work.
|
||||
|
||||
14
example_configs/matrix_synapse.yml
Normal file
14
example_configs/matrix_synapse.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
modules:
|
||||
- module: "ldap_auth_provider.LdapAuthProviderModule"
|
||||
config:
|
||||
enabled: true
|
||||
uri: "ldap://lldap"
|
||||
start_tls: false
|
||||
base: "ou=people,dc=example,dc=com"
|
||||
attributes:
|
||||
uid: "uid"
|
||||
mail: "mail"
|
||||
name: "cn"
|
||||
bind_dn: "uid=admin,ou=people,dc=example,dc=com"
|
||||
bind_password: "password"
|
||||
filter: "(objectClass=person)"
|
||||
64
example_configs/portainer.md
Normal file
64
example_configs/portainer.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Configuration for Portainer CE/BE
|
||||
### Settings > Authentication > LDAP > Custom
|
||||
---
|
||||
|
||||
## LDAP configuration
|
||||
|
||||
#### LDAP Server
|
||||
```
|
||||
localhost:3890 or ip-address:3890
|
||||
```
|
||||
#### Anonymous mode
|
||||
```
|
||||
off
|
||||
```
|
||||
#### Reader DN
|
||||
```
|
||||
uid=admin,ou=people,dc=example,dc=com
|
||||
```
|
||||
#### Password
|
||||
```
|
||||
xxx
|
||||
```
|
||||
* Password is the ENV you set at *LLDAP_LDAP_USER_PASS=* or `lldap_config.toml`
|
||||
|
||||
## User search configurations
|
||||
|
||||
#### Base DN
|
||||
```
|
||||
ou=people,dc=example,dc=com
|
||||
```
|
||||
#### Username attribute
|
||||
```
|
||||
uid
|
||||
```
|
||||
### Filter
|
||||
#### All available user(s)
|
||||
```
|
||||
(objectClass=person)
|
||||
```
|
||||
* Using this filter will list all user registered in LLDAP
|
||||
|
||||
#### All user(s) from specific group
|
||||
```
|
||||
(&(objectClass=person)(memberof=cn=lldap_portainer,ou=groups,dc=example,dc=com))
|
||||
```
|
||||
* Using this filter will only list user that included in `lldap_portainer` group.
|
||||
* Admin should manually configure groups and add a user to it. **lldap_portainer** only sample.
|
||||
|
||||
|
||||
|
||||
## Group search configurations
|
||||
|
||||
#### Group Base DN
|
||||
```
|
||||
ou=groups,dc=example,dc=com
|
||||
```
|
||||
#### Group Membership Attribute
|
||||
```
|
||||
cn
|
||||
```
|
||||
#### Group Filter
|
||||
```
|
||||
is optional
|
||||
```
|
||||
89
example_configs/seafile.md
Normal file
89
example_configs/seafile.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Configuration for Seafile
|
||||
Seafile's LDAP interface requires a unique, immutable user identifier in the format of `username@domain`. Since LLDAP does not provide an attribute like `userPrincipalName`, the only attribute that somewhat qualifies is therefore `mail`. However, using `mail` as the user identifier results in the issue that Seafile will treat you as an entirely new user if you change your email address through LLDAP. If this is not an issue for you, you can configure LLDAP as an authentication source in Seafile directly. A better but more elaborate way to use Seafile with LLDAP is by using Authelia as an intermediary. This document will guide you through both setups.
|
||||
|
||||
## Configuring Seafile to use LLDAP directly
|
||||
Add the following to your `seafile/conf/ccnet.conf` file:
|
||||
```
|
||||
[LDAP]
|
||||
HOST = ldap://192.168.1.100:3890
|
||||
BASE = ou=people,dc=example,dc=com
|
||||
USER_DN = uid=admin,ou=people,dc=example,dc=com
|
||||
PASSWORD = CHANGE_ME
|
||||
LOGIN_ATTR = mail
|
||||
```
|
||||
* Replace `192.168.1.100:3890` with your LLDAP server's ip/hostname and port.
|
||||
* Replace every instance of `dc=example,dc=com` with your configured domain.
|
||||
|
||||
After restarting the Seafile server, users should be able to log in with their email address and password.
|
||||
|
||||
### Filtering by group membership
|
||||
If you only want members of a specific group to be able to log in, add the following line:
|
||||
```
|
||||
FILTER = memberOf=cn=seafile_user,ou=groups,dc=example,dc=com
|
||||
```
|
||||
* Replace `seafile_user` with the name of your group.
|
||||
|
||||
## Configuring Seafile to use LLDAP with Authelia as an intermediary
|
||||
Authelia is an open-source authentication and authorization server that can use LLDAP as a backend and act as an OpenID Connect Provider. We're going to assume that you have already set up Authelia and configured it with LLDAP.
|
||||
If not, you can find an example configuration [here](authelia_config.yml).
|
||||
|
||||
1. Add the following to Authelia's `configuration.yml`:
|
||||
```
|
||||
identity_providers:
|
||||
oidc:
|
||||
hmac_secret: Your_HMAC_Secret #Replace with a random string
|
||||
issuer_private_key: |
|
||||
-----BEGIN RSA PRIVATE KEY-----
|
||||
Your_Private_Key
|
||||
#See https://www.authelia.com/configuration/identity-providers/open-id-connect/#issuer_private_key for instructions on how to generate a key
|
||||
-----END RSA PRIVATE KEY-----
|
||||
cors:
|
||||
endpoints:
|
||||
- authorization
|
||||
- token
|
||||
- revocation
|
||||
- introspection
|
||||
- userinfo
|
||||
clients:
|
||||
- id: seafile
|
||||
description: Seafile #The display name of the application. Will show up on Authelia consent screens
|
||||
secret: Your_Shared_Secret #Replace with random string
|
||||
public: false
|
||||
authorization_policy: one_factor #Can also be two_factor
|
||||
scopes:
|
||||
- openid
|
||||
- profile
|
||||
- email
|
||||
redirect_uris:
|
||||
- https://seafile.example.com/oauth/callback/
|
||||
userinfo_signing_algorithm: none
|
||||
pre_configured_consent_duration: 6M
|
||||
#On first login you must consent to sharing information between Authelia and Seafile. This option configures the amount of time after which you need to reconsent.
|
||||
# y = years, M = months, w = weeks, d = days
|
||||
```
|
||||
|
||||
2. Add the following to `seafile/conf/seahub_settings.py`
|
||||
```
|
||||
ENABLE_OAUTH = True
|
||||
OAUTH_ENABLE_INSECURE_TRANSPORT = True
|
||||
OAUTH_CLIENT_ID = 'seafile' #Must be the same as in Authelia
|
||||
OAUTH_CLIENT_SECRET = 'Your_Shared_Secret' #Must be the same as in Authelia
|
||||
OAUTH_REDIRECT_URL = 'https://seafile.example.com/oauth/callback/'
|
||||
OAUTH_PROVIDER_DOMAIN = 'auth.example.com'
|
||||
OAUTH_AUTHORIZATION_URL = 'https://auth.example.com/api/oidc/authorization'
|
||||
OAUTH_TOKEN_URL = 'https://auth.example.com/api/oidc/token'
|
||||
OAUTH_USER_INFO_URL = 'https://auth.example.com/api/oidc/userinfo'
|
||||
OAUTH_SCOPE = [
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
]
|
||||
OAUTH_ATTRIBUTE_MAP = {
|
||||
"preferred_username": (True, "email"), #Seafile will create a unique identifier of your <LLDAP's User ID >@<the value specified in OAUTH_PROVIDER_DOMAIN>. The identifier is not visible to the user and not actually used as the email address unlike the value suggests
|
||||
"name": (False, "name"),
|
||||
"id": (False, "not used"),
|
||||
"email": (False, "contact_email"),
|
||||
}
|
||||
```
|
||||
|
||||
Restart both your Authelia and Seafile server. You should see a "Single Sign-On" button on Seafile's login page. Clicking it should redirect you to Authelia. If you use the [example config for Authelia](authelia_config.yml), you should be able to log in using your LLDAP User ID.
|
||||
30
example_configs/syncthing.md
Normal file
30
example_configs/syncthing.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Configuration for Syncthing
|
||||
## Actions > Advanced > LDAP
|
||||
---
|
||||
|
||||
| Parameter | Value | Details |
|
||||
|----------------------|------------------------------------------------------------------------|-------------------------------------------------------|
|
||||
| Address | `localhost:3890` | Replace `localhost:3890` with your LLDAP host & port |
|
||||
| Bind DN | `cn=%s,ou=people,dc=example,dc=com` | |
|
||||
| Insecure Skip Verify | *unchecked* | |
|
||||
| Search Base DN | `ou=people,dc=example,dc=com` | Only used when using filters. |
|
||||
| Search Filter | `(&(uid=%s)(memberof=cn=lldap_syncthing,ou=groups,dc=example,dc=com))` | Filters on users belonging to group `lldap_syncthing` |
|
||||
| Transport | `plain` | |
|
||||
|
||||
Replace `dc=example,dc=com` with your LLDAP configured domain for all occurances
|
||||
|
||||
Leave **Search Base DN** and **Search Filter** both blank if you are not using any filters.
|
||||
|
||||
## Actions > Advanced > GUI
|
||||
|
||||
Change **Auth Mode** from `static` to `ldap`
|
||||
|
||||
|
||||
If you get locked out of the UI due to invalid LDAP settings, you can always change the settings from the `config.xml`, save the file, and force restart the app.
|
||||
|
||||
### Example
|
||||
|
||||
Change the below and restart
|
||||
|
||||
` <authMode>ldap</authMode>` to ` <authMode>static</authMode>`
|
||||
|
||||
16
example_configs/wg_portal.env.example
Normal file
16
example_configs/wg_portal.env.example
Normal file
@@ -0,0 +1,16 @@
|
||||
# Config for wg-portal (https://github.com/h44z/wg-portal)
|
||||
# Replace dc=example,dc=com with your base DN
|
||||
|
||||
# Connection to LLDAP
|
||||
# Remember that wg-portal requires host networking when ran in docker, so you cannot use docker networks to manage this
|
||||
LDAP_URL: ldap://localhost:3890
|
||||
|
||||
LDAP_BASEDN: "dc=example,dc=com"
|
||||
LDAP_USER: "uid=admin,ou=people,dc=example,dc=com"
|
||||
LDAP_PASSWORD: "CHANGEME"
|
||||
|
||||
LDAP_LOGIN_FILTER: "(&(objectClass=person)(|(mail={{login_identifier}})(uid={{login_identifier}})))"
|
||||
LDAP_SYNC_FILTER: "(&(objectClass=person)(mail=*))"
|
||||
LDAP_ADMIN_GROUP: "uid=everyone,ou=groups,dc=example,dc=com"
|
||||
LDAP_ATTR_EMAIL: "mail"
|
||||
LDAP_STARTTLS: "false"
|
||||
@@ -3,6 +3,10 @@
|
||||
## with "LLDAP_". For instance, "ldap_port" can be overridden with the
|
||||
## "LLDAP_LDAP_PORT" variable.
|
||||
|
||||
## Tune the logging to be more verbose by setting this to be true.
|
||||
## You can set it with the LLDAP_VERBOSE environment variable.
|
||||
# verbose=false
|
||||
|
||||
## The port on which to have the LDAP server.
|
||||
#ldap_port = 3890
|
||||
|
||||
@@ -20,7 +24,7 @@
|
||||
## them to re-login.
|
||||
## You should probably set it through the LLDAP_JWT_SECRET environment
|
||||
## variable from a secret ".env" file.
|
||||
## This can also be set from a file's contents by specifying the file path
|
||||
## This can also be set from a file's contents by specifying the file path
|
||||
## in the LLDAP_JWT_SECRET_FILE environment variable
|
||||
## You can generate it with (on linux):
|
||||
## LC_ALL=C tr -dc 'A-Za-z0-9!"#%&'\''()*+,-./:;<=>?@[\]^_{|}~' </dev/urandom | head -c 32; echo ''
|
||||
@@ -48,7 +52,7 @@
|
||||
## It should be minimum 8 characters long.
|
||||
## You can set it with the LLDAP_LDAP_USER_PASS environment variable.
|
||||
## This can also be set from a file's contents by specifying the file path
|
||||
## in the LLDAP_USER_PASS_FILE environment variable
|
||||
## in the LLDAP_LDAP_USER_PASS_FILE environment variable
|
||||
## Note: you can create another admin user for user administration, this
|
||||
## is just the default one.
|
||||
#ldap_user_pass = "REPLACE_WITH_PASSWORD"
|
||||
@@ -74,6 +78,14 @@ database_url = "sqlite:///data/users.db?mode=rwc"
|
||||
## Randomly generated on first run if it doesn't exist.
|
||||
key_file = "/data/private_key"
|
||||
|
||||
## Ignored attributes.
|
||||
## Some services will request attributes that are not present in LLDAP. When it
|
||||
## is the case, LLDAP will warn about the attribute being unknown. If you want
|
||||
## to ignore the attribute and the service works without, you can add it to this
|
||||
## list to silence the warning.
|
||||
#ignored_user_attributes = [ "sAMAccountName" ]
|
||||
#ignored_group_attributes = [ "mail", "userPrincipalName" ]
|
||||
|
||||
## Options to configure SMTP parameters, to send password reset emails.
|
||||
## To set these options from environment variables, use the following format
|
||||
## (example with "password"): LLDAP_SMTP_OPTIONS__PASSWORD
|
||||
@@ -95,3 +107,16 @@ key_file = "/data/private_key"
|
||||
#from="LLDAP Admin <sender@gmail.com>"
|
||||
## Same for reply-to, optional.
|
||||
#reply_to="Do not reply <noreply@localhost>"
|
||||
|
||||
## Options to configure LDAPS.
|
||||
## To set these options from environment variables, use the following format
|
||||
## (example with "port"): LLDAP_LDAPS_OPTIONS__PORT
|
||||
#[ldaps_options]
|
||||
## Whether to enable LDAPS.
|
||||
#enabled=true
|
||||
## Port on which to listen.
|
||||
#port=6360
|
||||
## Certificate file.
|
||||
#cert_file="/data/cert.pem"
|
||||
## Certificate key file.
|
||||
#key_file="/data/key.pem"
|
||||
|
||||
23
migration-tool/Cargo.toml
Normal file
23
migration-tool/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "migration-tool"
|
||||
version = "0.3.0-alpha.1"
|
||||
edition = "2021"
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "*"
|
||||
graphql_client = "0.10"
|
||||
ldap3 = "*"
|
||||
rand = "0.8"
|
||||
requestty = "*"
|
||||
serde = "1"
|
||||
serde_json = "1"
|
||||
smallvec = "*"
|
||||
|
||||
[dependencies.lldap_auth]
|
||||
path = "../auth"
|
||||
features = [ "opaque_client" ]
|
||||
|
||||
[dependencies.reqwest]
|
||||
version = "*"
|
||||
features = [ "json", "blocking" ]
|
||||
5
migration-tool/queries/add_user_to_group.graphql
Normal file
5
migration-tool/queries/add_user_to_group.graphql
Normal file
@@ -0,0 +1,5 @@
|
||||
mutation AddUserToGroup($user: String!, $group: Int!) {
|
||||
addUserToGroup(userId: $user, groupId: $group) {
|
||||
ok
|
||||
}
|
||||
}
|
||||
6
migration-tool/queries/create_group.graphql
Normal file
6
migration-tool/queries/create_group.graphql
Normal file
@@ -0,0 +1,6 @@
|
||||
mutation CreateGroup($name: String!) {
|
||||
createGroup(name: $name) {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
}
|
||||
5
migration-tool/queries/create_user.graphql
Normal file
5
migration-tool/queries/create_user.graphql
Normal file
@@ -0,0 +1,5 @@
|
||||
mutation CreateUser($user: CreateUserInput!) {
|
||||
createUser(user: $user) {
|
||||
id
|
||||
}
|
||||
}
|
||||
9
migration-tool/queries/list_groups.graphql
Normal file
9
migration-tool/queries/list_groups.graphql
Normal file
@@ -0,0 +1,9 @@
|
||||
query ListGroups {
|
||||
groups {
|
||||
id
|
||||
displayName
|
||||
users {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
5
migration-tool/queries/list_users.graphql
Normal file
5
migration-tool/queries/list_users.graphql
Normal file
@@ -0,0 +1,5 @@
|
||||
query ListUsers {
|
||||
users(filters: null) {
|
||||
id
|
||||
}
|
||||
}
|
||||
432
migration-tool/src/ldap.rs
Normal file
432
migration-tool/src/ldap.rs
Normal file
@@ -0,0 +1,432 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use ldap3::{ResultEntry, SearchEntry};
|
||||
use requestty::{prompt_one, Question};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::lldap::User;
|
||||
|
||||
pub struct LdapClient {
|
||||
domain: String,
|
||||
connection: ldap3::LdapConn,
|
||||
}
|
||||
|
||||
/// Checks if the URL starts with the protocol, and whether the host is valid (DNS and listening),
|
||||
/// potentially with the given port. Returns the address + port that managed to connect, if any.
|
||||
pub fn check_host_exists(
|
||||
url: &str,
|
||||
protocol_and_port: &[(&str, u16)],
|
||||
) -> std::result::Result<Option<String>, String> {
|
||||
for (protocol, port) in protocol_and_port {
|
||||
if url.starts_with(protocol) {
|
||||
use std::net::ToSocketAddrs;
|
||||
let trimmed_url = url.trim_start_matches(protocol);
|
||||
return match trimmed_url.to_socket_addrs() {
|
||||
Ok(_) => Ok(Some(url.to_owned())),
|
||||
Err(_) => {
|
||||
let new_url = format!("{}:{}", trimmed_url, port);
|
||||
new_url
|
||||
.to_socket_addrs()
|
||||
.map_err(|_| format!("Could not resolve host: '{}'", trimmed_url))
|
||||
.map(|_| Some(format!("{}{}", protocol, new_url)))
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn autocomplete_domain_suffix(input: String, domain: &str) -> SmallVec<[String; 1]> {
|
||||
let mut answers = SmallVec::<[String; 1]>::new();
|
||||
for part in input.split(',') {
|
||||
if !part.starts_with('d') {
|
||||
continue;
|
||||
}
|
||||
if domain.starts_with(part) {
|
||||
answers.push(input.clone() + domain.trim_start_matches(part));
|
||||
}
|
||||
}
|
||||
answers.push(input);
|
||||
answers
|
||||
}
|
||||
|
||||
/// Asks the user for the URL of the LDAP server, and checks that a connection can be established.
|
||||
/// Returns the LDAP URL.
|
||||
fn get_ldap_url() -> Result<String> {
|
||||
let ldap_protocols = &[("ldap://", 389), ("ldaps://", 636)];
|
||||
let question = Question::input("ldap_url")
|
||||
.message("LDAP_URL (ldap://...)")
|
||||
.auto_complete(|answer, _| {
|
||||
let mut answers = SmallVec::<[String; 1]>::new();
|
||||
if "ldap://".starts_with(&answer) {
|
||||
answers.push("ldap://".to_owned());
|
||||
}
|
||||
if "ldaps://".starts_with(&answer) {
|
||||
answers.push("ldaps://".to_owned());
|
||||
}
|
||||
answers.push(answer);
|
||||
answers
|
||||
})
|
||||
.validate(|url, _| {
|
||||
if let Some(url) = check_host_exists(url, ldap_protocols)? {
|
||||
ldap3::LdapConn::new(&url)
|
||||
.map_err(|e| format!("Could not connect to LDAP server: {}", e))?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err("LDAP URL should start with 'ldap://' or 'ldaps://'".to_owned())
|
||||
}
|
||||
})
|
||||
.build();
|
||||
let answer = prompt_one(question)?;
|
||||
Ok(
|
||||
check_host_exists(answer.as_string().unwrap(), ldap_protocols)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Binds the LDAP connection by asking the user for the bind DN and password, and returns the bind
|
||||
/// DN.
|
||||
fn bind_ldap(
|
||||
ldap_connection: &mut ldap3::LdapConn,
|
||||
previous_binddn: Option<String>,
|
||||
) -> Result<String> {
|
||||
let binddn = {
|
||||
let question = Question::input("ldap_binddn")
|
||||
.message("LDAP_BIND_DN (cn=...)")
|
||||
.validate(|dn, _| {
|
||||
if dn.contains(',') && dn.contains('=') {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(
|
||||
"Invalid bind DN, expected something like 'cn=admin,dc=example,dc=com'"
|
||||
.to_owned(),
|
||||
)
|
||||
}
|
||||
})
|
||||
.auto_complete(|answer, _| {
|
||||
let mut answers = SmallVec::<[String; 1]>::new();
|
||||
if let Some(binddn) = &previous_binddn {
|
||||
answers.push(binddn.clone());
|
||||
}
|
||||
answers.push(answer);
|
||||
answers
|
||||
})
|
||||
.build();
|
||||
let answer = prompt_one(question)?;
|
||||
answer.as_string().unwrap().to_owned()
|
||||
};
|
||||
let password = {
|
||||
let question = Question::password("ldap_bind_password")
|
||||
.message("LDAP_BIND_PASSWORD")
|
||||
.validate(|password, _| {
|
||||
if !password.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Empty password".to_owned())
|
||||
}
|
||||
})
|
||||
.build();
|
||||
let answer = prompt_one(question)?;
|
||||
answer.as_string().unwrap().to_owned()
|
||||
};
|
||||
if let Err(e) = ldap_connection
|
||||
.simple_bind(&binddn, &password)
|
||||
.and_then(|r| r.success())
|
||||
{
|
||||
println!("Error connecting as '{}': {}", binddn, e);
|
||||
bind_ldap(ldap_connection, Some(binddn))
|
||||
} else {
|
||||
Ok(binddn)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<ResultEntry> for User {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: ResultEntry) -> Result<Self> {
|
||||
let entry = SearchEntry::construct(value);
|
||||
let get_required_attribute = |attr| {
|
||||
entry
|
||||
.attrs
|
||||
.get(attr)
|
||||
.ok_or_else(|| anyhow!("Missing {} for user", attr))
|
||||
.and_then(|u| {
|
||||
if u.len() > 1 {
|
||||
Err(anyhow!("Too many {}s", attr))
|
||||
} else {
|
||||
Ok(u.first().unwrap().to_owned())
|
||||
}
|
||||
})
|
||||
};
|
||||
let id = get_required_attribute("uid")
|
||||
.or_else(|_| get_required_attribute("sAMAccountName"))
|
||||
.or_else(|_| get_required_attribute("userPrincipalName"))?;
|
||||
let email = get_required_attribute("mail")
|
||||
.or_else(|_| get_required_attribute("rfc822mailbox"))
|
||||
.context(format!("for user '{}'", id))?;
|
||||
|
||||
let get_optional_attribute = |attr: &str| {
|
||||
entry
|
||||
.attrs
|
||||
.get(attr)
|
||||
.and_then(|v| v.first().map(|s| s.as_str()))
|
||||
.and_then(|s| {
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s.to_owned())
|
||||
}
|
||||
})
|
||||
};
|
||||
let last_name = get_optional_attribute("sn").or_else(|| get_optional_attribute("surname"));
|
||||
let display_name = get_optional_attribute("cn")
|
||||
.or_else(|| get_optional_attribute("commonName"))
|
||||
.or_else(|| get_optional_attribute("name"))
|
||||
.or_else(|| get_optional_attribute("displayName"));
|
||||
let first_name = get_optional_attribute("givenName");
|
||||
let password =
|
||||
get_optional_attribute("userPassword").or_else(|| get_optional_attribute("password"));
|
||||
Ok(User::new(
|
||||
id,
|
||||
email,
|
||||
display_name,
|
||||
first_name,
|
||||
last_name,
|
||||
password,
|
||||
entry.dn,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
enum OuType {
|
||||
User,
|
||||
Group,
|
||||
}
|
||||
|
||||
fn detect_ou(
|
||||
ldap_connection: &mut ldap3::LdapConn,
|
||||
domain: &str,
|
||||
for_type: OuType,
|
||||
) -> Result<(Option<String>, Vec<String>), anyhow::Error> {
|
||||
let ous = ldap_connection
|
||||
.search(
|
||||
domain,
|
||||
ldap3::Scope::Subtree,
|
||||
"(objectClass=organizationalUnit)",
|
||||
vec!["dn"],
|
||||
)?
|
||||
.success()?
|
||||
.0;
|
||||
let mut detected_ou = None;
|
||||
let mut all_ous = Vec::new();
|
||||
for result_entry in ous {
|
||||
let dn = SearchEntry::construct(result_entry).dn;
|
||||
match for_type {
|
||||
OuType::User => {
|
||||
if dn.contains("user") || dn.contains("people") || dn.contains("person") {
|
||||
detected_ou = Some(dn.clone());
|
||||
}
|
||||
}
|
||||
OuType::Group => {
|
||||
if dn.contains("group") {
|
||||
detected_ou = Some(dn.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
all_ous.push(dn);
|
||||
}
|
||||
Ok((detected_ou, all_ous))
|
||||
}
|
||||
|
||||
pub fn get_users(connection: &mut LdapClient) -> Result<Vec<User>, anyhow::Error> {
|
||||
let LdapClient {
|
||||
connection: ldap_connection,
|
||||
domain,
|
||||
} = connection;
|
||||
let domain = domain.as_str();
|
||||
let (maybe_user_ou, all_ous) = detect_ou(ldap_connection, domain, OuType::User)?;
|
||||
let user_ou = {
|
||||
let question = Question::input("ldap_user_ou")
|
||||
.message(format!(
|
||||
"Where are the users located (under '{}')? {}(LDAP_USERS_DN)",
|
||||
domain,
|
||||
maybe_user_ou
|
||||
.as_ref()
|
||||
.map(|ou| format!("Detected: {}", ou))
|
||||
.unwrap_or_default()
|
||||
))
|
||||
.validate(|dn, _| {
|
||||
if dn.contains('=') {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Invalid bind DN, expected something like 'ou=people,{}'",
|
||||
domain
|
||||
))
|
||||
}
|
||||
})
|
||||
.default(maybe_user_ou.unwrap_or_default())
|
||||
.auto_complete(|s, _| {
|
||||
let mut answers = autocomplete_domain_suffix(s, domain);
|
||||
answers.extend(all_ous.clone().into_iter());
|
||||
answers
|
||||
})
|
||||
.build();
|
||||
let answer = prompt_one(question)?;
|
||||
let mut answer = answer.as_string().unwrap().to_owned();
|
||||
if !answer.ends_with(domain) {
|
||||
if !answer.is_empty() {
|
||||
answer += ",";
|
||||
}
|
||||
answer += domain;
|
||||
}
|
||||
answer
|
||||
};
|
||||
let users = ldap_connection
|
||||
.search(
|
||||
&user_ou,
|
||||
ldap3::Scope::Subtree,
|
||||
"(|(objectClass=inetOrgPerson)(objectClass=person)(objectClass=mailAccount)(objectClass=posixAccount)(objectClass=user)(objectClass=organizationalPerson))",
|
||||
vec![
|
||||
"uid",
|
||||
"sAMAccountName",
|
||||
"userPrincipalName",
|
||||
"mail",
|
||||
"rfc822mailbox",
|
||||
"givenName",
|
||||
"sn",
|
||||
"surname",
|
||||
"cn",
|
||||
"commonName",
|
||||
"displayName",
|
||||
"name",
|
||||
"userPassword",
|
||||
],
|
||||
)?
|
||||
.success()?
|
||||
.0;
|
||||
users
|
||||
.into_iter()
|
||||
.map(TryFrom::try_from)
|
||||
.collect::<Result<Vec<User>>>()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LdapGroup {
|
||||
pub name: String,
|
||||
pub members: Vec<String>,
|
||||
}
|
||||
|
||||
impl TryFrom<ResultEntry> for LdapGroup {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
// https://github.com/graphql-rust/graphql-client/issues/386
|
||||
#[allow(non_snake_case)]
|
||||
fn try_from(value: ResultEntry) -> Result<Self> {
|
||||
let entry = SearchEntry::construct(value);
|
||||
let get_required_attribute = |attr| {
|
||||
entry
|
||||
.attrs
|
||||
.get(attr)
|
||||
.ok_or_else(|| anyhow!("Missing {} for user", attr))
|
||||
.and_then(|u| {
|
||||
if u.len() > 1 {
|
||||
Err(anyhow!("Too many {}s", attr))
|
||||
} else {
|
||||
Ok(u.first().unwrap().to_owned())
|
||||
}
|
||||
})
|
||||
};
|
||||
let name = get_required_attribute("cn")
|
||||
.or_else(|_| get_required_attribute("commonName"))
|
||||
.or_else(|_| get_required_attribute("displayName"))
|
||||
.or_else(|_| get_required_attribute("name"))?;
|
||||
|
||||
let get_repeated_attribute = |attr: &str| entry.attrs.get(attr).map(|v| v.to_owned());
|
||||
let members = get_repeated_attribute("member")
|
||||
.or_else(|| get_repeated_attribute("uniqueMember"))
|
||||
.unwrap_or_default();
|
||||
Ok(LdapGroup { name, members })
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_groups(connection: &mut LdapClient) -> Result<Vec<LdapGroup>> {
|
||||
let LdapClient {
|
||||
connection: ldap_connection,
|
||||
domain,
|
||||
} = connection;
|
||||
let domain = domain.as_str();
|
||||
let (maybe_group_ou, all_ous) = detect_ou(ldap_connection, domain, OuType::Group)?;
|
||||
let group_ou = {
|
||||
let question = Question::input("ldap_group_ou")
|
||||
.message(format!(
|
||||
"Where are the groups located (under '{}')? {}(LDAP_GROUPS_DN)",
|
||||
domain,
|
||||
maybe_group_ou
|
||||
.as_ref()
|
||||
.map(|ou| format!("Detected: {}", ou))
|
||||
.unwrap_or_default()
|
||||
))
|
||||
.validate(|dn, _| {
|
||||
if dn.contains('=') {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Invalid bind DN, expected something like 'ou=groups,{}'",
|
||||
domain
|
||||
))
|
||||
}
|
||||
})
|
||||
.default(maybe_group_ou.unwrap_or_default())
|
||||
.auto_complete(|s, _| {
|
||||
let mut answers = autocomplete_domain_suffix(s, domain);
|
||||
answers.extend(all_ous.clone().into_iter());
|
||||
answers
|
||||
})
|
||||
.build();
|
||||
let answer = prompt_one(question)?;
|
||||
let mut answer = answer.as_string().unwrap().to_owned();
|
||||
if !answer.ends_with(domain) {
|
||||
if !answer.is_empty() {
|
||||
answer += ",";
|
||||
}
|
||||
answer += domain;
|
||||
}
|
||||
answer
|
||||
};
|
||||
let groups = ldap_connection
|
||||
.search(
|
||||
&group_ou,
|
||||
ldap3::Scope::Subtree,
|
||||
"(|(objectClass=group)(objectClass=groupOfNames)(objectClass=groupOfUniqueNames))",
|
||||
vec![
|
||||
"cn",
|
||||
"commonName",
|
||||
"displayName",
|
||||
"name",
|
||||
"member",
|
||||
"uniqueMember",
|
||||
],
|
||||
)?
|
||||
.success()?
|
||||
.0;
|
||||
let input_groups = groups
|
||||
.into_iter()
|
||||
.map(TryFrom::try_from)
|
||||
.collect::<Result<Vec<LdapGroup>>>()?;
|
||||
Ok(input_groups)
|
||||
}
|
||||
|
||||
pub fn get_ldap_connection() -> Result<LdapClient, anyhow::Error> {
|
||||
let url = get_ldap_url()?;
|
||||
let mut ldap_connection = ldap3::LdapConn::new(&url)?;
|
||||
println!("Server found");
|
||||
let bind_dn = bind_ldap(&mut ldap_connection, None)?;
|
||||
println!("Connection established");
|
||||
let domain = &bind_dn[(bind_dn.find(",dc=").expect("Could not find domain?!") + 1)..];
|
||||
// domain is 'dc=example,dc=com'
|
||||
Ok(LdapClient {
|
||||
connection: ldap_connection,
|
||||
domain: domain.to_owned(),
|
||||
})
|
||||
}
|
||||
506
migration-tool/src/lldap.rs
Normal file
506
migration-tool/src/lldap.rs
Normal file
@@ -0,0 +1,506 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use graphql_client::GraphQLQuery;
|
||||
use requestty::{prompt_one, Question};
|
||||
use reqwest::blocking::{Client, ClientBuilder};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::ldap::{check_host_exists, LdapGroup};
|
||||
|
||||
pub struct GraphQLClient {
|
||||
url: String,
|
||||
auth_header: reqwest::header::HeaderValue,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl GraphQLClient {
|
||||
fn new(url: String, auth_token: &str, client: Client) -> Result<Self> {
|
||||
Ok(Self {
|
||||
url: format!("{}/api/graphql", url),
|
||||
auth_header: format!("Bearer {}", auth_token).parse()?,
|
||||
client,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn post<QueryType>(
|
||||
&self,
|
||||
variables: QueryType::Variables,
|
||||
) -> Result<QueryType::ResponseData>
|
||||
where
|
||||
QueryType: GraphQLQuery + 'static,
|
||||
{
|
||||
let unwrap_graphql_response = |graphql_client::Response { data, errors }| {
|
||||
data.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"Errors: [{}]",
|
||||
errors
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
})
|
||||
};
|
||||
self.client
|
||||
.post(&self.url)
|
||||
.header(reqwest::header::AUTHORIZATION, &self.auth_header)
|
||||
// Request body.
|
||||
.json(&QueryType::build_query(variables))
|
||||
.send()
|
||||
.context("while sending a request to the LLDAP server")?
|
||||
.error_for_status()
|
||||
.context("error from an LLDAP response")?
|
||||
// Parse response as Json.
|
||||
.json::<graphql_client::Response<QueryType::ResponseData>>()
|
||||
.context("while parsing backend response")
|
||||
.and_then(unwrap_graphql_response)
|
||||
.context("GraphQL error from an LLDAP response")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct User {
|
||||
pub user_input: create_user::CreateUserInput,
|
||||
pub password: Option<String>,
|
||||
pub dn: String,
|
||||
}
|
||||
|
||||
impl User {
|
||||
// https://github.com/graphql-rust/graphql-client/issues/386
|
||||
#[allow(non_snake_case)]
|
||||
pub fn new(
|
||||
id: String,
|
||||
email: String,
|
||||
displayName: Option<String>,
|
||||
firstName: Option<String>,
|
||||
lastName: Option<String>,
|
||||
password: Option<String>,
|
||||
dn: String,
|
||||
) -> User {
|
||||
User {
|
||||
user_input: create_user::CreateUserInput {
|
||||
id,
|
||||
email,
|
||||
displayName,
|
||||
firstName,
|
||||
lastName,
|
||||
},
|
||||
password,
|
||||
dn,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/create_user.graphql",
|
||||
response_derives = "Debug",
|
||||
variables_derives = "Debug,Clone",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
struct CreateUser;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/create_group.graphql",
|
||||
response_derives = "Debug",
|
||||
variables_derives = "Debug,Clone",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
struct CreateGroup;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/list_users.graphql",
|
||||
response_derives = "Debug",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
struct ListUsers;
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/list_groups.graphql",
|
||||
response_derives = "Debug",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
struct ListGroups;
|
||||
|
||||
pub type LldapGroup = list_groups::ListGroupsGroups;
|
||||
|
||||
fn try_login(
|
||||
lldap_server: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
client: &Client,
|
||||
) -> Result<String> {
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
use lldap_auth::login::*;
|
||||
use lldap_auth::opaque::client::login::*;
|
||||
let ClientLoginStartResult { state, message } =
|
||||
start_login(password, &mut rng).context("Could not initialize login")?;
|
||||
let req = ClientLoginStartRequest {
|
||||
username: username.to_owned(),
|
||||
login_start_request: message,
|
||||
};
|
||||
let response = client
|
||||
.post(format!("{}/auth/opaque/login/start", lldap_server))
|
||||
.json(&req)
|
||||
.send()
|
||||
.context("while trying to login to LLDAP")?;
|
||||
if !response.status().is_success() {
|
||||
bail!(
|
||||
"Failed to start logging in to LLDAP: {}",
|
||||
response.status().as_str()
|
||||
);
|
||||
}
|
||||
let login_start_response = response.json::<lldap_auth::login::ServerLoginStartResponse>()?;
|
||||
let login_finish = finish_login(state, login_start_response.credential_response)?;
|
||||
let req = ClientLoginFinishRequest {
|
||||
server_data: login_start_response.server_data,
|
||||
credential_finalization: login_finish.message,
|
||||
};
|
||||
let response = client
|
||||
.post(format!("{}/auth/opaque/login/finish", lldap_server))
|
||||
.json(&req)
|
||||
.send()?;
|
||||
if !response.status().is_success() {
|
||||
bail!(
|
||||
"Failed to finish logging in to LLDAP: {}",
|
||||
response.status().as_str()
|
||||
);
|
||||
}
|
||||
Ok(response.text()?)
|
||||
}
|
||||
|
||||
pub fn get_lldap_user_and_password(
|
||||
lldap_server: &str,
|
||||
client: &Client,
|
||||
previous_username: Option<String>,
|
||||
) -> Result<String> {
|
||||
let username = {
|
||||
let question = Question::input("lldap_username")
|
||||
.message("LLDAP_USERNAME (default=admin)")
|
||||
.default("admin")
|
||||
.auto_complete(|answer, _| {
|
||||
let mut answers = SmallVec::<[String; 1]>::new();
|
||||
if let Some(username) = &previous_username {
|
||||
answers.push(username.clone());
|
||||
}
|
||||
answers.push(answer);
|
||||
answers
|
||||
})
|
||||
.build();
|
||||
let answer = prompt_one(question)?;
|
||||
answer.as_string().unwrap().to_owned()
|
||||
};
|
||||
let password = {
|
||||
let question = Question::password("lldap_password")
|
||||
.message("LLDAP_PASSWORD")
|
||||
.validate(|password, _| {
|
||||
if !password.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Empty password".to_owned())
|
||||
}
|
||||
})
|
||||
.build();
|
||||
let answer = prompt_one(question)?;
|
||||
answer.as_string().unwrap().to_owned()
|
||||
};
|
||||
match try_login(lldap_server, &username, &password, client) {
|
||||
Err(e) => {
|
||||
println!("Could not login: {:#?}", e);
|
||||
get_lldap_user_and_password(lldap_server, client, Some(username))
|
||||
}
|
||||
Ok(token) => Ok(token),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_lldap_client() -> Result<GraphQLClient> {
|
||||
let client = ClientBuilder::new()
|
||||
.connect_timeout(std::time::Duration::from_secs(2))
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()?;
|
||||
let lldap_server = get_lldap_server(&client)?;
|
||||
let token = get_lldap_user_and_password(&lldap_server, &client, None)?;
|
||||
println!("Successfully connected to LLDAP");
|
||||
GraphQLClient::new(lldap_server, &token, client)
|
||||
}
|
||||
|
||||
pub fn insert_users_into_lldap(
|
||||
users: Vec<User>,
|
||||
existing_users: &mut Vec<String>,
|
||||
graphql_client: &GraphQLClient,
|
||||
) -> Result<()> {
|
||||
let mut added_users_count = 0;
|
||||
let mut skip_all = false;
|
||||
for user in users {
|
||||
let uid = user.user_input.id.clone();
|
||||
loop {
|
||||
print!("Adding {}... ", &uid);
|
||||
match graphql_client
|
||||
.post::<CreateUser>(create_user::Variables {
|
||||
user: user.user_input.clone(),
|
||||
})
|
||||
.context(format!("while creating user '{}'", uid))
|
||||
{
|
||||
Err(e) => {
|
||||
println!("Error: {:#?}", e);
|
||||
if skip_all {
|
||||
break;
|
||||
}
|
||||
let question = requestty::Question::select("skip_user")
|
||||
.message(format!("Error while adding user {}", &uid))
|
||||
.choices(vec!["Skip", "Retry", "Skip all"])
|
||||
.default_separator()
|
||||
.choice("Abort")
|
||||
.build();
|
||||
let answer = prompt_one(question)?;
|
||||
let choice = answer.as_list_item().unwrap();
|
||||
match choice.text.as_str() {
|
||||
"Skip" => break,
|
||||
"Retry" => continue,
|
||||
"Skip all" => {
|
||||
skip_all = true;
|
||||
break;
|
||||
}
|
||||
"Abort" => return Err(e),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
Ok(response) => {
|
||||
println!("Done!");
|
||||
added_users_count += 1;
|
||||
existing_users.push(response.create_user.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{} users successfully added", added_users_count);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn insert_groups_into_lldap(
|
||||
groups: &[LdapGroup],
|
||||
lldap_groups: &mut Vec<LldapGroup>,
|
||||
graphql_client: &GraphQLClient,
|
||||
) -> Result<()> {
|
||||
let mut added_groups_count = 0;
|
||||
let mut skip_all = false;
|
||||
let existing_group_names =
|
||||
HashSet::<&str>::from_iter(lldap_groups.iter().map(|g| g.display_name.as_str()));
|
||||
let new_groups = groups
|
||||
.iter()
|
||||
.filter(|g| !existing_group_names.contains(g.name.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
for group in new_groups {
|
||||
let name = group.name.clone();
|
||||
loop {
|
||||
print!("Adding {}... ", &name);
|
||||
match graphql_client
|
||||
.post::<CreateGroup>(create_group::Variables { name: name.clone() })
|
||||
.context(format!("while creating group '{}'", &name))
|
||||
{
|
||||
Err(e) => {
|
||||
println!("Error: {:#?}", e);
|
||||
if skip_all {
|
||||
break;
|
||||
}
|
||||
let question = requestty::Question::select("skip_group")
|
||||
.message(format!("Error while adding group {}", &name))
|
||||
.choices(vec!["Skip", "Retry", "Skip all"])
|
||||
.default_separator()
|
||||
.choice("Abort")
|
||||
.build();
|
||||
let answer = prompt_one(question)?;
|
||||
let choice = answer.as_list_item().unwrap();
|
||||
match choice.text.as_str() {
|
||||
"Skip" => break,
|
||||
"Retry" => continue,
|
||||
"Skip all" => {
|
||||
skip_all = true;
|
||||
break;
|
||||
}
|
||||
"Abort" => return Err(e),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
Ok(response) => {
|
||||
println!("Done!");
|
||||
added_groups_count += 1;
|
||||
lldap_groups.push(LldapGroup {
|
||||
id: response.create_group.id,
|
||||
display_name: group.name.clone(),
|
||||
users: Vec::new(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{} groups successfully added", added_groups_count);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_lldap_users(graphql_client: &GraphQLClient) -> Result<Vec<String>> {
|
||||
Ok(graphql_client
|
||||
.post::<ListUsers>(list_users::Variables {})?
|
||||
.users
|
||||
.into_iter()
|
||||
.map(|u| u.id)
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn get_lldap_groups(graphql_client: &GraphQLClient) -> Result<Vec<LldapGroup>> {
|
||||
Ok(graphql_client
|
||||
.post::<ListGroups>(list_groups::Variables {})?
|
||||
.groups)
|
||||
}
|
||||
|
||||
#[derive(GraphQLQuery)]
|
||||
#[graphql(
|
||||
schema_path = "../schema.graphql",
|
||||
query_path = "queries/add_user_to_group.graphql",
|
||||
response_derives = "Debug",
|
||||
variables_derives = "Debug,Clone",
|
||||
custom_scalars_module = "crate::infra::graphql"
|
||||
)]
|
||||
struct AddUserToGroup;
|
||||
|
||||
pub fn insert_group_memberships_into_lldap(
|
||||
ldap_users: &[User],
|
||||
ldap_groups: &[LdapGroup],
|
||||
existing_users: &[String],
|
||||
existing_groups: &[LldapGroup],
|
||||
graphql_client: &GraphQLClient,
|
||||
) -> Result<()> {
|
||||
let existing_users = HashSet::<&str>::from_iter(existing_users.iter().map(String::as_str));
|
||||
let existing_groups = HashMap::<&str, &LldapGroup>::from_iter(
|
||||
existing_groups.iter().map(|g| (g.display_name.as_str(), g)),
|
||||
);
|
||||
let dn_resolver = HashMap::<&str, &str>::from_iter(
|
||||
ldap_users
|
||||
.iter()
|
||||
.map(|u| (u.dn.as_str(), u.user_input.id.as_str())),
|
||||
);
|
||||
let mut skip_all = false;
|
||||
let mut added_membership_count = 0;
|
||||
for group in ldap_groups {
|
||||
if let Some(lldap_group) = existing_groups.get(group.name.as_str()) {
|
||||
let lldap_members =
|
||||
HashSet::<&str>::from_iter(lldap_group.users.iter().map(|u| u.id.as_str()));
|
||||
let mut skip_group = false;
|
||||
for user in &group.members {
|
||||
let user = if let Some(id) = dn_resolver.get(user.as_str()) {
|
||||
id
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
if lldap_members.contains(user) || !existing_users.contains(user) {
|
||||
continue;
|
||||
}
|
||||
loop {
|
||||
print!("Adding '{}' to '{}'... ", &user, &group.name);
|
||||
if let Err(e) = graphql_client
|
||||
.post::<AddUserToGroup>(add_user_to_group::Variables {
|
||||
user: user.to_string(),
|
||||
group: lldap_group.id,
|
||||
})
|
||||
.context(format!(
|
||||
"while adding user '{}' to group '{}'",
|
||||
&user, &group.name
|
||||
))
|
||||
{
|
||||
println!("Error: {:#?}", e);
|
||||
if skip_all || skip_group {
|
||||
break;
|
||||
}
|
||||
let question = requestty::Question::select("skip_membership")
|
||||
.message(format!(
|
||||
"Error while adding '{}' to group '{}",
|
||||
&user, &group.name
|
||||
))
|
||||
.choices(vec!["Skip", "Retry", "Skip group", "Skip all"])
|
||||
.default_separator()
|
||||
.choice("Abort")
|
||||
.build();
|
||||
let answer = prompt_one(question)?;
|
||||
let choice = answer.as_list_item().unwrap();
|
||||
match choice.text.as_str() {
|
||||
"Skip" => break,
|
||||
"Retry" => continue,
|
||||
"Skip group" => {
|
||||
skip_group = true;
|
||||
break;
|
||||
}
|
||||
"Skip all" => {
|
||||
skip_all = true;
|
||||
break;
|
||||
}
|
||||
"Abort" => return Err(e),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
} else {
|
||||
println!("Done!");
|
||||
added_membership_count += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("{} memberships successfully added", added_membership_count);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_lldap_server(client: &Client) -> Result<String> {
|
||||
let http_protocols = &[("http://", 17170), ("https://", 17170)];
|
||||
let question = Question::input("lldap_url")
|
||||
.message("LLDAP_URL (http://...)")
|
||||
.auto_complete(|answer, _| {
|
||||
let mut answers = SmallVec::<[String; 1]>::new();
|
||||
if "http://".starts_with(&answer) {
|
||||
answers.push("http://".to_owned());
|
||||
}
|
||||
if "https://".starts_with(&answer) {
|
||||
answers.push("https://".to_owned());
|
||||
}
|
||||
answers.push(answer);
|
||||
answers
|
||||
})
|
||||
.validate(|url, _| {
|
||||
if let Some(url) = check_host_exists(url, http_protocols)? {
|
||||
client
|
||||
.get(format!("{}/api/graphql", url))
|
||||
.send()
|
||||
.map_err(|e| format!("Host did not answer: {}", e))
|
||||
.and_then(|response| {
|
||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Host doesn't seem to be an LLDAP server".to_owned())
|
||||
}
|
||||
})
|
||||
} else {
|
||||
Err(
|
||||
"Could not resolve host (make sure it starts with 'http://' or 'https://')"
|
||||
.to_owned(),
|
||||
)
|
||||
}
|
||||
})
|
||||
.build();
|
||||
let answer = prompt_one(question)?;
|
||||
Ok(
|
||||
check_host_exists(answer.as_string().unwrap(), http_protocols)
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
205
migration-tool/src/main.rs
Normal file
205
migration-tool/src/main.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use requestty::{prompt_one, Question};
|
||||
|
||||
mod ldap;
|
||||
mod lldap;
|
||||
|
||||
use ldap::LdapGroup;
|
||||
use lldap::{LldapGroup, User};
|
||||
|
||||
fn ask_generic_confirmation(name: &str, message: &str) -> Result<bool> {
|
||||
let confirm = Question::confirm(name)
|
||||
.message(message)
|
||||
.default(true)
|
||||
.build();
|
||||
let answer = prompt_one(confirm)?;
|
||||
Ok(answer.as_bool().unwrap())
|
||||
}
|
||||
|
||||
fn get_users_to_add(users: &[User], existing_users: &[String]) -> Result<Option<Vec<User>>> {
|
||||
let existing_users = HashSet::<&String>::from_iter(existing_users);
|
||||
let num_found_users = users.len();
|
||||
let input_users: Vec<_> = users
|
||||
.iter()
|
||||
.filter(|u| !existing_users.contains(&u.user_input.id))
|
||||
.map(User::clone)
|
||||
.collect();
|
||||
println!(
|
||||
"Found {} users, of which {} new users: [\n {}\n]",
|
||||
num_found_users,
|
||||
input_users.len(),
|
||||
input_users
|
||||
.iter()
|
||||
.map(|u| format!(
|
||||
"\"{}\" ({})",
|
||||
&u.user_input.id,
|
||||
if u.password.is_some() {
|
||||
"with password"
|
||||
} else {
|
||||
"no password"
|
||||
}
|
||||
))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n ")
|
||||
);
|
||||
if !input_users.is_empty()
|
||||
&& ask_generic_confirmation(
|
||||
"proceed_users",
|
||||
"Do you want to proceed to add those users to LLDAP?",
|
||||
)?
|
||||
{
|
||||
Ok(Some(input_users))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn should_insert_groups(
|
||||
input_groups: &[LdapGroup],
|
||||
existing_groups: &[LldapGroup],
|
||||
) -> Result<bool> {
|
||||
let existing_group_names =
|
||||
HashSet::<&str>::from_iter(existing_groups.iter().map(|g| g.display_name.as_str()));
|
||||
let new_groups = input_groups
|
||||
.iter()
|
||||
.filter(|g| !existing_group_names.contains(g.name.as_str()));
|
||||
let num_new_groups = new_groups.clone().count();
|
||||
println!(
|
||||
"Found {} groups, of which {} new groups: [\n {}\n]",
|
||||
input_groups.len(),
|
||||
num_new_groups,
|
||||
new_groups
|
||||
.map(|g| g.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n ")
|
||||
);
|
||||
Ok(num_new_groups != 0
|
||||
&& ask_generic_confirmation(
|
||||
"proceed_groups",
|
||||
"Do you want to proceed to add those groups to LLDAP?",
|
||||
)?)
|
||||
}
|
||||
|
||||
struct GroupList {
|
||||
ldap_groups: Vec<LdapGroup>,
|
||||
lldap_groups: Vec<LldapGroup>,
|
||||
}
|
||||
|
||||
fn migrate_groups(
|
||||
graphql_client: &lldap::GraphQLClient,
|
||||
ldap_connection: &mut ldap::LdapClient,
|
||||
) -> Result<Option<GroupList>> {
|
||||
Ok(
|
||||
if ask_generic_confirmation("should_import_groups", "Do you want to import groups?")? {
|
||||
let mut existing_groups = lldap::get_lldap_groups(graphql_client)?;
|
||||
let ldap_groups = ldap::get_groups(ldap_connection)?;
|
||||
if should_insert_groups(&ldap_groups, &existing_groups)? {
|
||||
lldap::insert_groups_into_lldap(
|
||||
&ldap_groups,
|
||||
&mut existing_groups,
|
||||
graphql_client,
|
||||
)?;
|
||||
}
|
||||
Some(GroupList {
|
||||
ldap_groups,
|
||||
lldap_groups: existing_groups,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
struct UserList {
|
||||
lldap_users: Vec<String>,
|
||||
ldap_users: Vec<User>,
|
||||
}
|
||||
|
||||
fn migrate_users(
|
||||
graphql_client: &lldap::GraphQLClient,
|
||||
ldap_connection: &mut ldap::LdapClient,
|
||||
) -> Result<Option<UserList>> {
|
||||
Ok(
|
||||
if ask_generic_confirmation("should_import_users", "Do you want to import users?")? {
|
||||
let mut existing_users = lldap::get_lldap_users(graphql_client)?;
|
||||
let users = ldap::get_users(ldap_connection)?;
|
||||
if let Some(users_to_add) = get_users_to_add(&users, &existing_users)? {
|
||||
lldap::insert_users_into_lldap(users_to_add, &mut existing_users, graphql_client)?;
|
||||
}
|
||||
Some(UserList {
|
||||
lldap_users: existing_users,
|
||||
ldap_users: users,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn migrate_memberships(
|
||||
user_list: Option<UserList>,
|
||||
group_list: Option<GroupList>,
|
||||
graphql_client: lldap::GraphQLClient,
|
||||
ldap_connection: &mut ldap::LdapClient,
|
||||
) -> Result<()> {
|
||||
let (ldap_users, existing_users) = user_list
|
||||
.map(
|
||||
|UserList {
|
||||
ldap_users,
|
||||
lldap_users,
|
||||
}| (Some(ldap_users), Some(lldap_users)),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let (ldap_groups, existing_groups) = group_list
|
||||
.map(
|
||||
|GroupList {
|
||||
ldap_groups,
|
||||
lldap_groups,
|
||||
}| (Some(ldap_groups), Some(lldap_groups)),
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let ldap_users = ldap_users
|
||||
.ok_or_else(|| anyhow!("Missing LDAP users"))
|
||||
.or_else(|_| ldap::get_users(ldap_connection))?;
|
||||
let ldap_groups = ldap_groups
|
||||
.ok_or_else(|| anyhow!("Missing LDAP groups"))
|
||||
.or_else(|_| ldap::get_groups(ldap_connection))?;
|
||||
let existing_groups = existing_groups
|
||||
.ok_or_else(|| anyhow!("Missing LLDAP groups"))
|
||||
.or_else(|_| lldap::get_lldap_groups(&graphql_client))?;
|
||||
let existing_users = existing_users
|
||||
.ok_or_else(|| anyhow!("Missing LLDAP users"))
|
||||
.or_else(|_| lldap::get_lldap_users(&graphql_client))?;
|
||||
lldap::insert_group_memberships_into_lldap(
|
||||
&ldap_users,
|
||||
&ldap_groups,
|
||||
&existing_users,
|
||||
&existing_groups,
|
||||
&graphql_client,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
println!(
|
||||
"The migration tool requires access to both the original LDAP \
|
||||
server and the HTTP API of the target LLDAP server."
|
||||
);
|
||||
if !ask_generic_confirmation("setup_ready", "Are you ready to start?")? {
|
||||
return Ok(());
|
||||
}
|
||||
let mut ldap_connection = ldap::get_ldap_connection()?;
|
||||
let graphql_client = lldap::get_lldap_client()?;
|
||||
let user_list = migrate_users(&graphql_client, &mut ldap_connection)?;
|
||||
let group_list = migrate_groups(&graphql_client, &mut ldap_connection)?;
|
||||
if ask_generic_confirmation(
|
||||
"should_import_memberships",
|
||||
"Do you want to import group memberships?",
|
||||
)? {
|
||||
migrate_memberships(user_list, group_list, graphql_client, &mut ldap_connection)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,68 +1,73 @@
|
||||
[package]
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>", "Steve Barrau <steve.barrau@gmail.com>", "Thomas Wickham <mackwic@gmail.com>"]
|
||||
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
|
||||
edition = "2021"
|
||||
name = "lldap"
|
||||
version = "0.2.0"
|
||||
version = "0.4.0"
|
||||
|
||||
[dependencies]
|
||||
actix = "0.12"
|
||||
actix-files = "0.6.0-beta.6"
|
||||
actix-http = "3.0.0-beta.9"
|
||||
actix-http = "=3.0.0-beta.9"
|
||||
actix-rt = "2.2.0"
|
||||
actix-server = "2.0.0-beta.5"
|
||||
actix-server = "=2.0.0-beta.5"
|
||||
actix-service = "2.0.0"
|
||||
actix-web = "4.0.0-beta.8"
|
||||
actix-tls = "=3.0.0-beta.5"
|
||||
actix-web = "=4.0.0-beta.8"
|
||||
actix-web-httpauth = "0.6.0-beta.2"
|
||||
anyhow = "*"
|
||||
async-trait = "0.1"
|
||||
base64 = "0.13"
|
||||
bincode = "1.3"
|
||||
chrono = { version = "*", features = [ "serde" ]}
|
||||
clap = "3.0.0-beta.4"
|
||||
cron = "*"
|
||||
derive_builder = "0.10.2"
|
||||
futures = "*"
|
||||
futures-util = "*"
|
||||
hmac = "0.10"
|
||||
http = "*"
|
||||
itertools = "0.10.1"
|
||||
juniper = "0.15.6"
|
||||
juniper_actix = "0.4.0"
|
||||
jwt = "0.13"
|
||||
ldap3_server = ">=0.1.9"
|
||||
lldap_auth = { path = "../auth" }
|
||||
ldap3_server = "=0.1.11"
|
||||
log = "*"
|
||||
native-tls = "0.2.10"
|
||||
orion = "0.16"
|
||||
serde = "*"
|
||||
serde_json = "1"
|
||||
sha2 = "0.9"
|
||||
sqlx-core = "=0.5.1"
|
||||
sqlx-core = "0.5.11"
|
||||
thiserror = "*"
|
||||
time = "0.2"
|
||||
tokio = { version = "1.2.0", features = ["full"] }
|
||||
tokio-util = "0.6.3"
|
||||
tokio-native-tls = "0.3"
|
||||
tokio-stream = "*"
|
||||
tokio-util = "0.6.3"
|
||||
tracing = "*"
|
||||
tracing-actix-web = "0.4.0-beta.7"
|
||||
tracing-attributes = "^0.1.21"
|
||||
tracing-log = "*"
|
||||
tracing-subscriber = "0.3"
|
||||
rand = { version = "0.8", features = ["small_rng", "getrandom"] }
|
||||
juniper_actix = "0.4.0"
|
||||
juniper = "0.15.6"
|
||||
itertools = "0.10.1"
|
||||
|
||||
[dependencies.opaque-ke]
|
||||
version = "0.6"
|
||||
[dependencies.chrono]
|
||||
features = ["serde"]
|
||||
version = "*"
|
||||
|
||||
[dependencies.clap]
|
||||
features = ["std", "color", "suggestions", "derive", "env"]
|
||||
version = "3.1.15"
|
||||
|
||||
[dependencies.figment]
|
||||
features = ["env", "toml"]
|
||||
version = "*"
|
||||
|
||||
[dependencies.tracing-subscriber]
|
||||
version = "0.3"
|
||||
features = ["env-filter", "tracing-log"]
|
||||
|
||||
[dependencies.lettre]
|
||||
features = ["builder", "serde", "smtp-transport", "tokio1-native-tls", "tokio1"]
|
||||
version = "0.10.0-rc.3"
|
||||
features = [
|
||||
"builder",
|
||||
"serde",
|
||||
"smtp-transport",
|
||||
"tokio1-native-tls",
|
||||
"tokio1",
|
||||
]
|
||||
|
||||
[dependencies.sqlx]
|
||||
version = "0.5.1"
|
||||
version = "0.5.11"
|
||||
features = [
|
||||
"any",
|
||||
"chrono",
|
||||
@@ -73,21 +78,43 @@ features = [
|
||||
"sqlite",
|
||||
]
|
||||
|
||||
[dependencies.lldap_auth]
|
||||
path = "../auth"
|
||||
|
||||
[dependencies.sea-query]
|
||||
version = "0.9.4"
|
||||
features = ["with-chrono"]
|
||||
version = "^0.25"
|
||||
features = ["with-chrono", "sqlx-sqlite"]
|
||||
|
||||
[dependencies.figment]
|
||||
features = ["env", "toml"]
|
||||
version = "*"
|
||||
[dependencies.sea-query-binder]
|
||||
version = "0.1"
|
||||
features = ["with-chrono", "sqlx-sqlite", "sqlx-any"]
|
||||
|
||||
[dependencies.secstr]
|
||||
features = ["serde"]
|
||||
version = "*"
|
||||
[dependencies.opaque-ke]
|
||||
version = "0.6"
|
||||
|
||||
[dependencies.openssl-sys]
|
||||
features = ["vendored"]
|
||||
version = "*"
|
||||
|
||||
[dependencies.rand]
|
||||
features = ["small_rng", "getrandom"]
|
||||
version = "0.8"
|
||||
|
||||
[dependencies.secstr]
|
||||
features = ["serde"]
|
||||
version = "*"
|
||||
|
||||
[dependencies.tokio]
|
||||
features = ["full"]
|
||||
version = "1.13.1"
|
||||
|
||||
[dependencies.uuid]
|
||||
features = ["v3"]
|
||||
version = "*"
|
||||
|
||||
[dependencies.tracing-forest]
|
||||
features = ["smallvec", "chrono", "tokio"]
|
||||
version = "^0.1.4"
|
||||
|
||||
[dev-dependencies]
|
||||
mockall = "0.9.1"
|
||||
|
||||
@@ -3,7 +3,7 @@ use thiserror::Error;
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[derive(Error, Debug)]
|
||||
pub enum DomainError {
|
||||
#[error("Authentication error for `{0}`")]
|
||||
#[error("Authentication error: `{0}`")]
|
||||
AuthenticationError(String),
|
||||
#[error("Database error: `{0}`")]
|
||||
DatabaseError(#[from] sqlx::Error),
|
||||
|
||||
@@ -3,28 +3,110 @@ use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), derive(sqlx::FromRow))]
|
||||
#[derive(
|
||||
PartialEq, Hash, Eq, Clone, Debug, Default, Serialize, Deserialize, sqlx::FromRow, sqlx::Type,
|
||||
)]
|
||||
#[serde(try_from = "&str")]
|
||||
#[sqlx(transparent)]
|
||||
pub struct Uuid(String);
|
||||
|
||||
impl Uuid {
|
||||
pub fn from_name_and_date(name: &str, creation_date: &chrono::DateTime<chrono::Utc>) -> Self {
|
||||
Uuid(
|
||||
uuid::Uuid::new_v3(
|
||||
&uuid::Uuid::NAMESPACE_X500,
|
||||
&[name.as_bytes(), creation_date.to_rfc3339().as_bytes()].concat(),
|
||||
)
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> std::convert::TryFrom<&'a str> for Uuid {
|
||||
type Error = anyhow::Error;
|
||||
fn try_from(s: &'a str) -> anyhow::Result<Self> {
|
||||
Ok(Uuid(uuid::Uuid::parse_str(s)?.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::string::ToString for Uuid {
|
||||
fn to_string(&self) -> String {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[macro_export]
|
||||
macro_rules! uuid {
|
||||
($s:literal) => {
|
||||
crate::domain::handler::Uuid::try_from($s).unwrap()
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Debug, Default, Serialize, Deserialize, sqlx::Type)]
|
||||
#[serde(from = "String")]
|
||||
#[sqlx(transparent)]
|
||||
pub struct UserId(String);
|
||||
|
||||
impl UserId {
|
||||
pub fn new(user_id: &str) -> Self {
|
||||
Self(user_id.to_lowercase())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UserId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for UserId {
|
||||
fn from(s: String) -> Self {
|
||||
Self::new(&s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct User {
|
||||
pub user_id: String,
|
||||
pub user_id: UserId,
|
||||
pub email: String,
|
||||
pub display_name: String,
|
||||
pub first_name: String,
|
||||
pub last_name: String,
|
||||
// pub avatar: ?,
|
||||
pub creation_date: chrono::DateTime<chrono::Utc>,
|
||||
pub uuid: Uuid,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Default for User {
|
||||
fn default() -> Self {
|
||||
use chrono::TimeZone;
|
||||
let epoch = chrono::Utc.timestamp(0, 0);
|
||||
User {
|
||||
user_id: String::new(),
|
||||
user_id: UserId::default(),
|
||||
email: String::new(),
|
||||
display_name: String::new(),
|
||||
first_name: String::new(),
|
||||
last_name: String::new(),
|
||||
creation_date: chrono::Utc.timestamp(0, 0),
|
||||
creation_date: epoch,
|
||||
uuid: Uuid::from_name_and_date("", &epoch),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,20 +115,23 @@ impl Default for User {
|
||||
pub struct Group {
|
||||
pub id: GroupId,
|
||||
pub display_name: String,
|
||||
pub users: Vec<String>,
|
||||
pub creation_date: chrono::DateTime<chrono::Utc>,
|
||||
pub uuid: Uuid,
|
||||
pub users: Vec<UserId>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct BindRequest {
|
||||
pub name: String,
|
||||
pub name: UserId,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||
pub enum RequestFilter {
|
||||
And(Vec<RequestFilter>),
|
||||
Or(Vec<RequestFilter>),
|
||||
Not(Box<RequestFilter>),
|
||||
pub enum UserRequestFilter {
|
||||
And(Vec<UserRequestFilter>),
|
||||
Or(Vec<UserRequestFilter>),
|
||||
Not(Box<UserRequestFilter>),
|
||||
UserId(UserId),
|
||||
Equality(String, String),
|
||||
// Check if a user belongs to a group identified by name.
|
||||
MemberOf(String),
|
||||
@@ -54,10 +139,22 @@ pub enum RequestFilter {
|
||||
MemberOfId(GroupId),
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||
pub enum GroupRequestFilter {
|
||||
And(Vec<GroupRequestFilter>),
|
||||
Or(Vec<GroupRequestFilter>),
|
||||
Not(Box<GroupRequestFilter>),
|
||||
DisplayName(String),
|
||||
Uuid(Uuid),
|
||||
GroupId(GroupId),
|
||||
// Check if the group contains a user identified by uid.
|
||||
Member(UserId),
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct CreateUserRequest {
|
||||
// Same fields as User, but no creation_date, and with password.
|
||||
pub user_id: String,
|
||||
pub user_id: UserId,
|
||||
pub email: String,
|
||||
pub display_name: Option<String>,
|
||||
pub first_name: Option<String>,
|
||||
@@ -67,7 +164,7 @@ pub struct CreateUserRequest {
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct UpdateUserRequest {
|
||||
// Same fields as CreateUserRequest, but no with an extra layer of Option.
|
||||
pub user_id: String,
|
||||
pub user_id: UserId,
|
||||
pub email: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub first_name: Option<String>,
|
||||
@@ -85,27 +182,43 @@ pub trait LoginHandler: Clone + Send {
|
||||
async fn bind(&self, request: BindRequest) -> Result<()>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct GroupId(pub i32);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct GroupIdAndName(pub GroupId, pub String);
|
||||
pub struct GroupDetails {
|
||||
pub group_id: GroupId,
|
||||
pub display_name: String,
|
||||
pub creation_date: chrono::DateTime<chrono::Utc>,
|
||||
pub uuid: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UserAndGroups {
|
||||
pub user: User,
|
||||
pub groups: Option<Vec<GroupDetails>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait BackendHandler: Clone + Send {
|
||||
async fn list_users(&self, filters: Option<RequestFilter>) -> Result<Vec<User>>;
|
||||
async fn list_groups(&self) -> Result<Vec<Group>>;
|
||||
async fn get_user_details(&self, user_id: &str) -> Result<User>;
|
||||
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupIdAndName>;
|
||||
async fn list_users(
|
||||
&self,
|
||||
filters: Option<UserRequestFilter>,
|
||||
get_groups: bool,
|
||||
) -> Result<Vec<UserAndGroups>>;
|
||||
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>;
|
||||
async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
|
||||
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>;
|
||||
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
|
||||
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
|
||||
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
|
||||
async fn delete_user(&self, user_id: &str) -> Result<()>;
|
||||
async fn delete_user(&self, user_id: &UserId) -> Result<()>;
|
||||
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
|
||||
async fn delete_group(&self, group_id: GroupId) -> Result<()>;
|
||||
async fn add_user_to_group(&self, user_id: &str, group_id: GroupId) -> Result<()>;
|
||||
async fn remove_user_from_group(&self, user_id: &str, group_id: GroupId) -> Result<()>;
|
||||
async fn get_user_groups(&self, user: &str) -> Result<HashSet<GroupIdAndName>>;
|
||||
async fn add_user_to_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
|
||||
async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
|
||||
async fn get_user_groups(&self, user_id: &UserId) -> Result<HashSet<GroupDetails>>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -116,22 +229,38 @@ mockall::mock! {
|
||||
}
|
||||
#[async_trait]
|
||||
impl BackendHandler for TestBackendHandler {
|
||||
async fn list_users(&self, filters: Option<RequestFilter>) -> Result<Vec<User>>;
|
||||
async fn list_groups(&self) -> Result<Vec<Group>>;
|
||||
async fn get_user_details(&self, user_id: &str) -> Result<User>;
|
||||
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupIdAndName>;
|
||||
async fn list_users(&self, filters: Option<UserRequestFilter>, get_groups: bool) -> Result<Vec<UserAndGroups>>;
|
||||
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>;
|
||||
async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
|
||||
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>;
|
||||
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
|
||||
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
|
||||
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
|
||||
async fn delete_user(&self, user_id: &str) -> Result<()>;
|
||||
async fn delete_user(&self, user_id: &UserId) -> Result<()>;
|
||||
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
|
||||
async fn delete_group(&self, group_id: GroupId) -> Result<()>;
|
||||
async fn get_user_groups(&self, user: &str) -> Result<HashSet<GroupIdAndName>>;
|
||||
async fn add_user_to_group(&self, user_id: &str, group_id: GroupId) -> Result<()>;
|
||||
async fn remove_user_from_group(&self, user_id: &str, group_id: GroupId) -> Result<()>;
|
||||
async fn get_user_groups(&self, user_id: &UserId) -> Result<HashSet<GroupDetails>>;
|
||||
async fn add_user_to_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
|
||||
async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
|
||||
}
|
||||
#[async_trait]
|
||||
impl LoginHandler for TestBackendHandler {
|
||||
async fn bind(&self, request: BindRequest) -> Result<()>;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn test_uuid_time() {
|
||||
use chrono::prelude::*;
|
||||
let user_id = "bob";
|
||||
let date1 = Utc.ymd(2014, 7, 8).and_hms(9, 10, 11);
|
||||
let date2 = Utc.ymd(2014, 7, 8).and_hms(9, 10, 12);
|
||||
assert_ne!(
|
||||
Uuid::from_name_and_date(user_id, &date1),
|
||||
Uuid::from_name_and_date(user_id, &date2)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::error::*;
|
||||
use crate::domain::{error::*, handler::UserId};
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub use lldap_auth::{login, registration};
|
||||
@@ -9,7 +9,7 @@ pub trait OpaqueHandler: Clone + Send {
|
||||
&self,
|
||||
request: login::ClientLoginStartRequest,
|
||||
) -> Result<login::ServerLoginStartResponse>;
|
||||
async fn login_finish(&self, request: login::ClientLoginFinishRequest) -> Result<String>;
|
||||
async fn login_finish(&self, request: login::ClientLoginFinishRequest) -> Result<UserId>;
|
||||
async fn registration_start(
|
||||
&self,
|
||||
request: registration::ClientRegistrationStartRequest,
|
||||
@@ -32,7 +32,7 @@ mockall::mock! {
|
||||
&self,
|
||||
request: login::ClientLoginStartRequest
|
||||
) -> Result<login::ServerLoginStartResponse>;
|
||||
async fn login_finish(&self, request: login::ClientLoginFinishRequest ) -> Result<String>;
|
||||
async fn login_finish(&self, request: login::ClientLoginFinishRequest ) -> Result<UserId>;
|
||||
async fn registration_start(
|
||||
&self,
|
||||
request: registration::ClientRegistrationStartRequest
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,24 +1,26 @@
|
||||
use super::{
|
||||
error::*,
|
||||
handler::{BindRequest, LoginHandler},
|
||||
handler::{BindRequest, LoginHandler, UserId},
|
||||
opaque_handler::*,
|
||||
sql_backend_handler::SqlBackendHandler,
|
||||
sql_tables::*,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use lldap_auth::opaque;
|
||||
use log::*;
|
||||
use sea_query::{Expr, Iden, Query};
|
||||
use sea_query_binder::SqlxBinder;
|
||||
use secstr::SecUtf8;
|
||||
use sqlx::Row;
|
||||
use tracing::{debug, instrument};
|
||||
|
||||
type SqlOpaqueHandler = SqlBackendHandler;
|
||||
|
||||
#[instrument(skip_all, level = "debug", err)]
|
||||
fn passwords_match(
|
||||
password_file_bytes: &[u8],
|
||||
clear_password: &str,
|
||||
server_setup: &opaque::server::ServerSetup,
|
||||
username: &str,
|
||||
username: &UserId,
|
||||
) -> Result<()> {
|
||||
use opaque::{client, server};
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
@@ -31,7 +33,7 @@ fn passwords_match(
|
||||
server_setup,
|
||||
Some(password_file),
|
||||
client_login_start_result.message,
|
||||
username,
|
||||
username.as_str(),
|
||||
)?;
|
||||
client::login::finish_login(
|
||||
client_login_start_result.state,
|
||||
@@ -47,18 +49,22 @@ impl SqlBackendHandler {
|
||||
)?)
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug", err)]
|
||||
async fn get_password_file_for_user(
|
||||
&self,
|
||||
username: &str,
|
||||
) -> Result<Option<opaque::server::ServerRegistration>> {
|
||||
// Fetch the previously registered password file from the DB.
|
||||
let password_file_bytes = {
|
||||
let query = Query::select()
|
||||
let (query, values) = Query::select()
|
||||
.column(Users::PasswordHash)
|
||||
.from(Users::Table)
|
||||
.and_where(Expr::col(Users::UserId).eq(username))
|
||||
.to_string(DbQueryBuilder {});
|
||||
if let Some(row) = sqlx::query(&query).fetch_optional(&self.sql_pool).await? {
|
||||
.cond_where(Expr::col(Users::UserId).eq(username))
|
||||
.build_sqlx(DbQueryBuilder {});
|
||||
if let Some(row) = sqlx::query_with(query.as_str(), values)
|
||||
.fetch_optional(&self.sql_pool)
|
||||
.await?
|
||||
{
|
||||
if let Some(bytes) =
|
||||
row.get::<Option<Vec<u8>>, _>(&*Users::PasswordHash.to_string())
|
||||
{
|
||||
@@ -82,21 +88,17 @@ impl SqlBackendHandler {
|
||||
|
||||
#[async_trait]
|
||||
impl LoginHandler for SqlBackendHandler {
|
||||
#[instrument(skip_all, level = "debug", err)]
|
||||
async fn bind(&self, request: BindRequest) -> Result<()> {
|
||||
if request.name == self.config.ldap_user_dn {
|
||||
if SecUtf8::from(request.password) == self.config.ldap_user_pass {
|
||||
return Ok(());
|
||||
} else {
|
||||
debug!(r#"Invalid password for LDAP bind user"#);
|
||||
return Err(DomainError::AuthenticationError(request.name));
|
||||
}
|
||||
}
|
||||
let query = Query::select()
|
||||
let (query, values) = Query::select()
|
||||
.column(Users::PasswordHash)
|
||||
.from(Users::Table)
|
||||
.and_where(Expr::col(Users::UserId).eq(request.name.as_str()))
|
||||
.to_string(DbQueryBuilder {});
|
||||
if let Ok(row) = sqlx::query(&query).fetch_one(&self.sql_pool).await {
|
||||
.cond_where(Expr::col(Users::UserId).eq(&request.name))
|
||||
.build_sqlx(DbQueryBuilder {});
|
||||
if let Ok(row) = sqlx::query_with(&query, values)
|
||||
.fetch_one(&self.sql_pool)
|
||||
.await
|
||||
{
|
||||
if let Some(password_hash) =
|
||||
row.get::<Option<Vec<u8>>, _>(&*Users::PasswordHash.to_string())
|
||||
{
|
||||
@@ -106,22 +108,26 @@ impl LoginHandler for SqlBackendHandler {
|
||||
self.config.get_server_setup(),
|
||||
&request.name,
|
||||
) {
|
||||
debug!(r#"Invalid password for "{}": {}"#, request.name, e);
|
||||
debug!(r#"Invalid password for "{}": {}"#, &request.name, e);
|
||||
} else {
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
debug!(r#"User "{}" has no password"#, request.name);
|
||||
debug!(r#"User "{}" has no password"#, &request.name);
|
||||
}
|
||||
} else {
|
||||
debug!(r#"No user found for "{}""#, request.name);
|
||||
debug!(r#"No user found for "{}""#, &request.name);
|
||||
}
|
||||
Err(DomainError::AuthenticationError(request.name))
|
||||
Err(DomainError::AuthenticationError(format!(
|
||||
" for user '{}'",
|
||||
request.name
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OpaqueHandler for SqlOpaqueHandler {
|
||||
#[instrument(skip_all, level = "debug", err)]
|
||||
async fn login_start(
|
||||
&self,
|
||||
request: login::ClientLoginStartRequest,
|
||||
@@ -150,7 +156,8 @@ impl OpaqueHandler for SqlOpaqueHandler {
|
||||
})
|
||||
}
|
||||
|
||||
async fn login_finish(&self, request: login::ClientLoginFinishRequest) -> Result<String> {
|
||||
#[instrument(skip_all, level = "debug", err)]
|
||||
async fn login_finish(&self, request: login::ClientLoginFinishRequest) -> Result<UserId> {
|
||||
let secret_key = self.get_orion_secret_key()?;
|
||||
let login::ServerData {
|
||||
username,
|
||||
@@ -165,9 +172,10 @@ impl OpaqueHandler for SqlOpaqueHandler {
|
||||
opaque::server::login::finish_login(server_login, request.credential_finalization)?
|
||||
.session_key;
|
||||
|
||||
Ok(username)
|
||||
Ok(UserId::new(&username))
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug", err)]
|
||||
async fn registration_start(
|
||||
&self,
|
||||
request: registration::ClientRegistrationStartRequest,
|
||||
@@ -189,6 +197,7 @@ impl OpaqueHandler for SqlOpaqueHandler {
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug", err)]
|
||||
async fn registration_finish(
|
||||
&self,
|
||||
request: registration::ClientRegistrationFinishRequest,
|
||||
@@ -203,24 +212,24 @@ impl OpaqueHandler for SqlOpaqueHandler {
|
||||
opaque::server::registration::get_password_file(request.registration_upload);
|
||||
{
|
||||
// Set the user password to the new password.
|
||||
let update_query = Query::update()
|
||||
let (update_query, values) = Query::update()
|
||||
.table(Users::Table)
|
||||
.values(vec![(
|
||||
Users::PasswordHash,
|
||||
password_file.serialize().into(),
|
||||
)])
|
||||
.and_where(Expr::col(Users::UserId).eq(username))
|
||||
.to_string(DbQueryBuilder {});
|
||||
sqlx::query(&update_query).execute(&self.sql_pool).await?;
|
||||
.value(Users::PasswordHash, password_file.serialize().into())
|
||||
.cond_where(Expr::col(Users::UserId).eq(username))
|
||||
.build_sqlx(DbQueryBuilder {});
|
||||
sqlx::query_with(update_query.as_str(), values)
|
||||
.execute(&self.sql_pool)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience function to set a user's password.
|
||||
#[instrument(skip_all, level = "debug", err)]
|
||||
pub(crate) async fn register_password(
|
||||
opaque_handler: &SqlOpaqueHandler,
|
||||
username: &str,
|
||||
username: &UserId,
|
||||
password: &SecUtf8,
|
||||
) -> Result<()> {
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
@@ -278,7 +287,7 @@ mod tests {
|
||||
async fn insert_user_no_password(handler: &SqlBackendHandler, name: &str) {
|
||||
handler
|
||||
.create_user(CreateUserRequest {
|
||||
user_id: name.to_string(),
|
||||
user_id: UserId::new(name),
|
||||
email: "bob@bob.bob".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
@@ -323,7 +332,12 @@ mod tests {
|
||||
attempt_login(&opaque_handler, "bob", "bob00")
|
||||
.await
|
||||
.unwrap_err();
|
||||
register_password(&opaque_handler, "bob", &secstr::SecUtf8::from("bob00")).await?;
|
||||
register_password(
|
||||
&opaque_handler,
|
||||
&UserId::new("bob"),
|
||||
&secstr::SecUtf8::from("bob00"),
|
||||
)
|
||||
.await?;
|
||||
attempt_login(&opaque_handler, "bob", "wrong_password")
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::handler::GroupId;
|
||||
use super::handler::{GroupId, UserId, Uuid};
|
||||
use sea_query::*;
|
||||
use sea_query_binder::SqlxBinder;
|
||||
use sqlx::Row;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
pub type Pool = sqlx::sqlite::SqlitePool;
|
||||
pub type PoolOptions = sqlx::sqlite::SqlitePoolOptions;
|
||||
@@ -12,28 +15,27 @@ impl From<GroupId> for Value {
|
||||
}
|
||||
}
|
||||
|
||||
impl<DB> sqlx::Type<DB> for GroupId
|
||||
where
|
||||
DB: sqlx::Database,
|
||||
i32: sqlx::Type<DB>,
|
||||
{
|
||||
fn type_info() -> <DB as sqlx::Database>::TypeInfo {
|
||||
<i32 as sqlx::Type<DB>>::type_info()
|
||||
}
|
||||
fn compatible(ty: &<DB as sqlx::Database>::TypeInfo) -> bool {
|
||||
<i32 as sqlx::Type<DB>>::compatible(ty)
|
||||
impl From<UserId> for sea_query::Value {
|
||||
fn from(user_id: UserId) -> Self {
|
||||
user_id.into_string().into()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'r, DB> sqlx::Decode<'r, DB> for GroupId
|
||||
where
|
||||
DB: sqlx::Database,
|
||||
i32: sqlx::Decode<'r, DB>,
|
||||
{
|
||||
fn decode(
|
||||
value: <DB as sqlx::database::HasValueRef<'r>>::ValueRef,
|
||||
) -> Result<Self, Box<dyn std::error::Error + Sync + Send + 'static>> {
|
||||
<i32 as sqlx::Decode<'r, DB>>::decode(value).map(GroupId)
|
||||
impl From<&UserId> for sea_query::Value {
|
||||
fn from(user_id: &UserId) -> Self {
|
||||
user_id.as_str().into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Uuid> for sea_query::Value {
|
||||
fn from(uuid: Uuid) -> Self {
|
||||
uuid.as_str().into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Uuid> for sea_query::Value {
|
||||
fn from(uuid: &Uuid) -> Self {
|
||||
uuid.as_str().into()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +52,7 @@ pub enum Users {
|
||||
PasswordHash,
|
||||
TotpSecret,
|
||||
MfaType,
|
||||
Uuid,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
@@ -57,6 +60,8 @@ pub enum Groups {
|
||||
Table,
|
||||
GroupId,
|
||||
DisplayName,
|
||||
CreationDate,
|
||||
Uuid,
|
||||
}
|
||||
|
||||
#[derive(Iden)]
|
||||
@@ -66,6 +71,41 @@ pub enum Memberships {
|
||||
GroupId,
|
||||
}
|
||||
|
||||
async fn column_exists(pool: &Pool, table_name: &str, column_name: &str) -> sqlx::Result<bool> {
|
||||
// Sqlite specific
|
||||
let query = format!(
|
||||
"SELECT COUNT(*) AS col_count FROM pragma_table_info('{}') WHERE name = '{}'",
|
||||
table_name, column_name
|
||||
);
|
||||
Ok(sqlx::query(&query)
|
||||
.fetch_one(pool)
|
||||
.await?
|
||||
.get::<i32, _>("col_count")
|
||||
> 0)
|
||||
}
|
||||
|
||||
pub async fn create_group(group_name: &str, pool: &Pool) -> sqlx::Result<()> {
|
||||
let now = chrono::Utc::now();
|
||||
let (query, values) = Query::insert()
|
||||
.into_table(Groups::Table)
|
||||
.columns(vec![
|
||||
Groups::DisplayName,
|
||||
Groups::CreationDate,
|
||||
Groups::Uuid,
|
||||
])
|
||||
.values_panic(vec![
|
||||
group_name.into(),
|
||||
now.naive_utc().into(),
|
||||
Uuid::from_name_and_date(group_name, &now).into(),
|
||||
])
|
||||
.build_sqlx(DbQueryBuilder {});
|
||||
debug!(%query);
|
||||
sqlx::query_with(query.as_str(), values)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
|
||||
// SQLite needs this pragma to be turned on. Other DB might not understand this, so ignore the
|
||||
// error.
|
||||
@@ -93,6 +133,7 @@ pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
|
||||
.col(ColumnDef::new(Users::PasswordHash).binary())
|
||||
.col(ColumnDef::new(Users::TotpSecret).string_len(64))
|
||||
.col(ColumnDef::new(Users::MfaType).string_len(64))
|
||||
.col(ColumnDef::new(Users::Uuid).string_len(36).not_null())
|
||||
.to_string(DbQueryBuilder {}),
|
||||
)
|
||||
.execute(pool)
|
||||
@@ -114,11 +155,141 @@ pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
|
||||
.unique_key()
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(Users::CreationDate).date_time().not_null())
|
||||
.col(ColumnDef::new(Users::Uuid).string_len(36).not_null())
|
||||
.to_string(DbQueryBuilder {}),
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// If the creation_date column doesn't exist, add it.
|
||||
if !column_exists(
|
||||
pool,
|
||||
&*Groups::Table.to_string(),
|
||||
&*Groups::CreationDate.to_string(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
warn!("`creation_date` column not found in `groups`, creating it");
|
||||
sqlx::query(
|
||||
&Table::alter()
|
||||
.table(Groups::Table)
|
||||
.add_column(
|
||||
ColumnDef::new(Groups::CreationDate)
|
||||
.date_time()
|
||||
.not_null()
|
||||
.default(chrono::Utc::now().naive_utc()),
|
||||
)
|
||||
.to_string(DbQueryBuilder {}),
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// If the uuid column doesn't exist, add it.
|
||||
if !column_exists(
|
||||
pool,
|
||||
&*Groups::Table.to_string(),
|
||||
&*Groups::Uuid.to_string(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
warn!("`uuid` column not found in `groups`, creating it");
|
||||
sqlx::query(
|
||||
&Table::alter()
|
||||
.table(Groups::Table)
|
||||
.add_column(
|
||||
ColumnDef::new(Groups::Uuid)
|
||||
.string_len(36)
|
||||
.not_null()
|
||||
.default(""),
|
||||
)
|
||||
.to_string(DbQueryBuilder {}),
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
for row in sqlx::query(
|
||||
&Query::select()
|
||||
.from(Groups::Table)
|
||||
.column(Groups::GroupId)
|
||||
.column(Groups::DisplayName)
|
||||
.column(Groups::CreationDate)
|
||||
.to_string(DbQueryBuilder {}),
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
{
|
||||
sqlx::query(
|
||||
&Query::update()
|
||||
.table(Groups::Table)
|
||||
.value(
|
||||
Groups::Uuid,
|
||||
Uuid::from_name_and_date(
|
||||
&row.get::<String, _>(&*Groups::DisplayName.to_string()),
|
||||
&row.get::<chrono::DateTime<chrono::Utc>, _>(
|
||||
&*Groups::CreationDate.to_string(),
|
||||
),
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
.and_where(
|
||||
Expr::col(Groups::GroupId)
|
||||
.eq(row.get::<GroupId, _>(&*Groups::GroupId.to_string())),
|
||||
)
|
||||
.to_string(DbQueryBuilder {}),
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
if !column_exists(pool, &*Users::Table.to_string(), &*Users::Uuid.to_string()).await? {
|
||||
warn!("`uuid` column not found in `users`, creating it");
|
||||
sqlx::query(
|
||||
&Table::alter()
|
||||
.table(Users::Table)
|
||||
.add_column(
|
||||
ColumnDef::new(Users::Uuid)
|
||||
.string_len(36)
|
||||
.not_null()
|
||||
.default(""),
|
||||
)
|
||||
.to_string(DbQueryBuilder {}),
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
for row in sqlx::query(
|
||||
&Query::select()
|
||||
.from(Users::Table)
|
||||
.column(Users::UserId)
|
||||
.column(Users::CreationDate)
|
||||
.to_string(DbQueryBuilder {}),
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
{
|
||||
let user_id = row.get::<UserId, _>(&*Users::UserId.to_string());
|
||||
sqlx::query(
|
||||
&Query::update()
|
||||
.table(Users::Table)
|
||||
.value(
|
||||
Users::Uuid,
|
||||
Uuid::from_name_and_date(
|
||||
user_id.as_str(),
|
||||
&row.get::<chrono::DateTime<chrono::Utc>, _>(
|
||||
&*Users::CreationDate.to_string(),
|
||||
),
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
.and_where(Expr::col(Users::UserId).eq(user_id))
|
||||
.to_string(DbQueryBuilder {}),
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
&Table::create()
|
||||
.table(Memberships::Table)
|
||||
@@ -132,16 +303,16 @@ pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("MembershipUserForeignKey")
|
||||
.table(Memberships::Table, Users::Table)
|
||||
.col(Memberships::UserId, Users::UserId)
|
||||
.from(Memberships::Table, Memberships::UserId)
|
||||
.to(Users::Table, Users::UserId)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.on_update(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("MembershipGroupForeignKey")
|
||||
.table(Memberships::Table, Groups::Table)
|
||||
.col(Memberships::GroupId, Groups::GroupId)
|
||||
.from(Memberships::Table, Memberships::GroupId)
|
||||
.to(Groups::Table, Groups::GroupId)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.on_update(ForeignKeyAction::Cascade),
|
||||
)
|
||||
@@ -150,6 +321,29 @@ pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if sqlx::query(
|
||||
&Query::select()
|
||||
.from(Groups::Table)
|
||||
.column(Groups::DisplayName)
|
||||
.cond_where(Expr::col(Groups::DisplayName).eq("lldap_readonly"))
|
||||
.to_string(DbQueryBuilder {}),
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
sqlx::query(
|
||||
&Query::update()
|
||||
.table(Groups::Table)
|
||||
.values(vec![(Groups::DisplayName, "lldap_password_manager".into())])
|
||||
.cond_where(Expr::col(Groups::DisplayName).eq("lldap_readonly"))
|
||||
.to_string(DbQueryBuilder {}),
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
create_group("lldap_strict_readonly", pool).await?
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -159,13 +353,13 @@ mod tests {
|
||||
use chrono::prelude::*;
|
||||
use sqlx::{Column, Row};
|
||||
|
||||
#[actix_rt::test]
|
||||
#[tokio::test]
|
||||
async fn test_init_table() {
|
||||
let sql_pool = PoolOptions::new().connect("sqlite::memory:").await.unwrap();
|
||||
init_table(&sql_pool).await.unwrap();
|
||||
sqlx::query(r#"INSERT INTO users
|
||||
(user_id, email, display_name, first_name, last_name, creation_date, password_hash)
|
||||
VALUES ("bôb", "böb@bob.bob", "Bob Bobbersön", "Bob", "Bobberson", "1970-01-01 00:00:00", "bob00")"#).execute(&sql_pool).await.unwrap();
|
||||
(user_id, email, display_name, first_name, last_name, creation_date, password_hash, uuid)
|
||||
VALUES ("bôb", "böb@bob.bob", "Bob Bobbersön", "Bob", "Bobberson", "1970-01-01 00:00:00", "bob00", "abc")"#).execute(&sql_pool).await.unwrap();
|
||||
let row =
|
||||
sqlx::query(r#"SELECT display_name, creation_date FROM users WHERE user_id = "bôb""#)
|
||||
.fetch_one(&sql_pool)
|
||||
@@ -179,10 +373,74 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_rt::test]
|
||||
#[tokio::test]
|
||||
async fn test_already_init_table() {
|
||||
let sql_pool = PoolOptions::new().connect("sqlite::memory:").await.unwrap();
|
||||
init_table(&sql_pool).await.unwrap();
|
||||
init_table(&sql_pool).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_migrate_tables() {
|
||||
// Test that we add the column creation_date to groups and uuid to users and groups.
|
||||
let sql_pool = PoolOptions::new().connect("sqlite::memory:").await.unwrap();
|
||||
sqlx::query(r#"CREATE TABLE users ( user_id TEXT , creation_date TEXT);"#)
|
||||
.execute(&sql_pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
r#"INSERT INTO users (user_id, creation_date)
|
||||
VALUES ("bôb", "1970-01-01 00:00:00")"#,
|
||||
)
|
||||
.execute(&sql_pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(r#"CREATE TABLE groups ( group_id INTEGER PRIMARY KEY, display_name TEXT );"#)
|
||||
.execute(&sql_pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
r#"INSERT INTO groups (display_name)
|
||||
VALUES ("lldap_admin"), ("lldap_readonly")"#,
|
||||
)
|
||||
.execute(&sql_pool)
|
||||
.await
|
||||
.unwrap();
|
||||
init_table(&sql_pool).await.unwrap();
|
||||
sqlx::query(
|
||||
r#"INSERT INTO groups (display_name, creation_date, uuid)
|
||||
VALUES ("test", "1970-01-01 00:00:00", "abc")"#,
|
||||
)
|
||||
.execute(&sql_pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
sqlx::query(r#"SELECT uuid FROM users"#)
|
||||
.fetch_all(&sql_pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|row| row.get::<Uuid, _>("uuid"))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![crate::uuid!("a02eaf13-48a7-30f6-a3d4-040ff7c52b04")]
|
||||
);
|
||||
assert_eq!(
|
||||
sqlx::query(r#"SELECT group_id, display_name FROM groups"#)
|
||||
.fetch_all(&sql_pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|row| (
|
||||
row.get::<GroupId, _>("group_id"),
|
||||
row.get::<String, _>("display_name")
|
||||
))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
(GroupId(1), "lldap_admin".to_string()),
|
||||
(GroupId(2), "lldap_password_manager".to_string()),
|
||||
(GroupId(3), "lldap_strict_readonly".to_string()),
|
||||
(GroupId(4), "test".to_string())
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
use crate::{
|
||||
domain::{
|
||||
error::DomainError,
|
||||
handler::{BackendHandler, BindRequest, GroupIdAndName, LoginHandler},
|
||||
opaque_handler::OpaqueHandler,
|
||||
},
|
||||
infra::{
|
||||
tcp_backend_handler::*,
|
||||
tcp_server::{error_to_http_response, AppState},
|
||||
},
|
||||
};
|
||||
use std::collections::{hash_map::DefaultHasher, HashSet};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use actix_web::{
|
||||
cookie::{Cookie, SameSite},
|
||||
dev::{Service, ServiceRequest, ServiceResponse, Transform},
|
||||
@@ -19,27 +13,36 @@ use actix_web_httpauth::extractors::bearer::BearerAuth;
|
||||
use anyhow::Result;
|
||||
use chrono::prelude::*;
|
||||
use futures::future::{ok, Ready};
|
||||
use futures_util::{FutureExt, TryFutureExt};
|
||||
use futures_util::FutureExt;
|
||||
use hmac::Hmac;
|
||||
use jwt::{SignWithKey, VerifyWithKey};
|
||||
use lldap_auth::{login, registration, JWTClaims};
|
||||
use log::*;
|
||||
use sha2::Sha512;
|
||||
use std::collections::{hash_map::DefaultHasher, HashSet};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
use time::ext::NumericalDuration;
|
||||
use tracing::{debug, instrument, warn};
|
||||
|
||||
use lldap_auth::{login, password_reset, registration, JWTClaims};
|
||||
|
||||
use crate::{
|
||||
domain::{
|
||||
error::DomainError,
|
||||
handler::{BackendHandler, BindRequest, GroupDetails, LoginHandler, UserId},
|
||||
opaque_handler::OpaqueHandler,
|
||||
},
|
||||
infra::{
|
||||
tcp_backend_handler::*,
|
||||
tcp_server::{error_to_http_response, AppState, TcpError, TcpResult},
|
||||
},
|
||||
};
|
||||
|
||||
type Token<S> = jwt::Token<jwt::Header, JWTClaims, S>;
|
||||
type SignedToken = Token<jwt::token::Signed>;
|
||||
|
||||
fn create_jwt(key: &Hmac<Sha512>, user: String, groups: HashSet<GroupIdAndName>) -> SignedToken {
|
||||
fn create_jwt(key: &Hmac<Sha512>, user: String, groups: HashSet<GroupDetails>) -> SignedToken {
|
||||
let claims = JWTClaims {
|
||||
exp: Utc::now() + chrono::Duration::days(1),
|
||||
iat: Utc::now(),
|
||||
user,
|
||||
groups: groups.into_iter().map(|g| g.1).collect(),
|
||||
groups: groups.into_iter().map(|g| g.display_name).collect(),
|
||||
};
|
||||
let header = jwt::Header {
|
||||
algorithm: jwt::AlgorithmType::Hs512,
|
||||
@@ -48,91 +51,106 @@ fn create_jwt(key: &Hmac<Sha512>, user: String, groups: HashSet<GroupIdAndName>)
|
||||
jwt::Token::new(header, claims).sign_with_key(key).unwrap()
|
||||
}
|
||||
|
||||
fn get_refresh_token_from_cookie(
|
||||
request: HttpRequest,
|
||||
) -> std::result::Result<(u64, String), HttpResponse> {
|
||||
match request.cookie("refresh_token") {
|
||||
None => Err(HttpResponse::Unauthorized().body("Missing refresh token")),
|
||||
Some(t) => match t.value().split_once("+") {
|
||||
None => Err(HttpResponse::Unauthorized().body("Invalid refresh token")),
|
||||
Some((token, u)) => {
|
||||
let refresh_token_hash = {
|
||||
let mut s = DefaultHasher::new();
|
||||
token.hash(&mut s);
|
||||
s.finish()
|
||||
};
|
||||
Ok((refresh_token_hash, u.to_string()))
|
||||
}
|
||||
},
|
||||
fn parse_refresh_token(token: &str) -> TcpResult<(u64, UserId)> {
|
||||
match token.split_once('+') {
|
||||
None => Err(DomainError::AuthenticationError("Invalid refresh token".to_string()).into()),
|
||||
Some((token, u)) => {
|
||||
let refresh_token_hash = {
|
||||
let mut s = DefaultHasher::new();
|
||||
token.hash(&mut s);
|
||||
s.finish()
|
||||
};
|
||||
Ok((refresh_token_hash, UserId::new(u)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_refresh_token(request: HttpRequest) -> TcpResult<(u64, UserId)> {
|
||||
match (
|
||||
request.cookie("refresh_token"),
|
||||
request.headers().get("refresh-token"),
|
||||
) {
|
||||
(Some(c), _) => parse_refresh_token(c.value()),
|
||||
(_, Some(t)) => parse_refresh_token(t.to_str().unwrap()),
|
||||
(None, None) => {
|
||||
Err(DomainError::AuthenticationError("Missing refresh token".to_string()).into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn get_refresh<Backend>(
|
||||
data: web::Data<AppState<Backend>>,
|
||||
request: HttpRequest,
|
||||
) -> HttpResponse
|
||||
) -> TcpResult<HttpResponse>
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + 'static,
|
||||
{
|
||||
let backend_handler = &data.backend_handler;
|
||||
let jwt_key = &data.jwt_key;
|
||||
let (refresh_token_hash, user) = match get_refresh_token_from_cookie(request) {
|
||||
Ok(t) => t,
|
||||
Err(http_response) => return http_response,
|
||||
};
|
||||
let res_found = data
|
||||
let (refresh_token_hash, user) = get_refresh_token(request)?;
|
||||
let found = data
|
||||
.backend_handler
|
||||
.check_token(refresh_token_hash, &user)
|
||||
.await;
|
||||
// Async closures are not supported yet.
|
||||
match res_found {
|
||||
Ok(found) => {
|
||||
if found {
|
||||
backend_handler.get_user_groups(&user).await
|
||||
} else {
|
||||
Err(DomainError::AuthenticationError(
|
||||
"Invalid refresh token".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
.await?;
|
||||
if !found {
|
||||
return Err(TcpError::DomainError(DomainError::AuthenticationError(
|
||||
"Invalid refresh token".to_string(),
|
||||
)));
|
||||
}
|
||||
.map(|groups| create_jwt(jwt_key, user.to_string(), groups))
|
||||
.map(|token| {
|
||||
HttpResponse::Ok()
|
||||
.cookie(
|
||||
Cookie::build("token", token.as_str())
|
||||
.max_age(1.days())
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.finish(),
|
||||
)
|
||||
.body(token.as_str().to_owned())
|
||||
})
|
||||
.unwrap_or_else(error_to_http_response)
|
||||
Ok(backend_handler
|
||||
.get_user_groups(&user)
|
||||
.await
|
||||
.map(|groups| create_jwt(jwt_key, user.to_string(), groups))
|
||||
.map(|token| {
|
||||
HttpResponse::Ok()
|
||||
.cookie(
|
||||
Cookie::build("token", token.as_str())
|
||||
.max_age(1.days())
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.finish(),
|
||||
)
|
||||
.json(&login::ServerLoginResponse {
|
||||
token: token.as_str().to_owned(),
|
||||
refresh_token: None,
|
||||
})
|
||||
})?)
|
||||
}
|
||||
|
||||
async fn get_password_reset_step1<Backend>(
|
||||
async fn get_refresh_handler<Backend>(
|
||||
data: web::Data<AppState<Backend>>,
|
||||
request: HttpRequest,
|
||||
) -> HttpResponse
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + 'static,
|
||||
{
|
||||
get_refresh(data, request)
|
||||
.await
|
||||
.unwrap_or_else(error_to_http_response)
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn get_password_reset_step1<Backend>(
|
||||
data: web::Data<AppState<Backend>>,
|
||||
request: HttpRequest,
|
||||
) -> TcpResult<()>
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + 'static,
|
||||
{
|
||||
let user_id = match request.match_info().get("user_id") {
|
||||
None => return HttpResponse::BadRequest().body("Missing user ID"),
|
||||
Some(id) => id,
|
||||
None => return Err(TcpError::BadRequest("Missing user ID".to_string())),
|
||||
Some(id) => UserId::new(id),
|
||||
};
|
||||
let token = match data.backend_handler.start_password_reset(user_id).await {
|
||||
Err(e) => return HttpResponse::InternalServerError().body(e.to_string()),
|
||||
Ok(None) => return HttpResponse::Ok().finish(),
|
||||
Ok(Some(token)) => token,
|
||||
let token = match data.backend_handler.start_password_reset(&user_id).await? {
|
||||
None => return Ok(()),
|
||||
Some(token) => token,
|
||||
};
|
||||
let user = match data.backend_handler.get_user_details(user_id).await {
|
||||
let user = match data.backend_handler.get_user_details(&user_id).await {
|
||||
Err(e) => {
|
||||
warn!("Error getting used details: {:#?}", e);
|
||||
return HttpResponse::Ok().finish();
|
||||
return Ok(());
|
||||
}
|
||||
Ok(u) => u,
|
||||
};
|
||||
@@ -144,36 +162,50 @@ where
|
||||
&data.mail_options,
|
||||
) {
|
||||
warn!("Error sending email: {:#?}", e);
|
||||
return Err(TcpError::InternalServerError(format!(
|
||||
"Could not send email: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
HttpResponse::Ok().finish()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_password_reset_step2<Backend>(
|
||||
async fn get_password_reset_step1_handler<Backend>(
|
||||
data: web::Data<AppState<Backend>>,
|
||||
request: HttpRequest,
|
||||
) -> HttpResponse
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + 'static,
|
||||
{
|
||||
let token = match request.match_info().get("token") {
|
||||
None => return HttpResponse::BadRequest().body("Missing token"),
|
||||
Some(token) => token,
|
||||
};
|
||||
let user_id = match data
|
||||
get_password_reset_step1(data, request)
|
||||
.await
|
||||
.map(|()| HttpResponse::Ok().finish())
|
||||
.unwrap_or_else(error_to_http_response)
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn get_password_reset_step2<Backend>(
|
||||
data: web::Data<AppState<Backend>>,
|
||||
request: HttpRequest,
|
||||
) -> TcpResult<HttpResponse>
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + 'static,
|
||||
{
|
||||
let token = request
|
||||
.match_info()
|
||||
.get("token")
|
||||
.ok_or_else(|| TcpError::BadRequest("Missing reset token".to_string()))?;
|
||||
let user_id = data
|
||||
.backend_handler
|
||||
.get_user_id_for_password_reset_token(token)
|
||||
.await
|
||||
{
|
||||
Err(_) => return HttpResponse::Unauthorized().body("Invalid or expired token"),
|
||||
Ok(user_id) => user_id,
|
||||
};
|
||||
.await?;
|
||||
let _ = data
|
||||
.backend_handler
|
||||
.delete_password_reset_token(token)
|
||||
.await;
|
||||
let groups = HashSet::new();
|
||||
let token = create_jwt(&data.jwt_key, user_id.to_string(), groups);
|
||||
HttpResponse::Ok()
|
||||
Ok(HttpResponse::Ok()
|
||||
.cookie(
|
||||
Cookie::build("token", token.as_str())
|
||||
.max_age(5.minutes())
|
||||
@@ -183,43 +215,42 @@ where
|
||||
.same_site(SameSite::Strict)
|
||||
.finish(),
|
||||
)
|
||||
.json(user_id)
|
||||
.json(&password_reset::ServerPasswordResetResponse {
|
||||
user_id: user_id.to_string(),
|
||||
token: token.as_str().to_owned(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_logout<Backend>(
|
||||
async fn get_password_reset_step2_handler<Backend>(
|
||||
data: web::Data<AppState<Backend>>,
|
||||
request: HttpRequest,
|
||||
) -> HttpResponse
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + 'static,
|
||||
{
|
||||
let (refresh_token_hash, user) = match get_refresh_token_from_cookie(request) {
|
||||
Ok(t) => t,
|
||||
Err(http_response) => return http_response,
|
||||
};
|
||||
if let Err(response) = data
|
||||
.backend_handler
|
||||
get_password_reset_step2(data, request)
|
||||
.await
|
||||
.unwrap_or_else(error_to_http_response)
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn get_logout<Backend>(
|
||||
data: web::Data<AppState<Backend>>,
|
||||
request: HttpRequest,
|
||||
) -> TcpResult<HttpResponse>
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + 'static,
|
||||
{
|
||||
let (refresh_token_hash, user) = get_refresh_token(request)?;
|
||||
data.backend_handler
|
||||
.delete_refresh_token(refresh_token_hash)
|
||||
.map_err(error_to_http_response)
|
||||
.await
|
||||
{
|
||||
return response;
|
||||
};
|
||||
match data
|
||||
.backend_handler
|
||||
.blacklist_jwts(&user)
|
||||
.map_err(error_to_http_response)
|
||||
.await
|
||||
{
|
||||
Ok(new_blacklisted_jwts) => {
|
||||
let mut jwt_blacklist = data.jwt_blacklist.write().unwrap();
|
||||
for jwt in new_blacklisted_jwts {
|
||||
jwt_blacklist.insert(jwt);
|
||||
}
|
||||
}
|
||||
Err(response) => return response,
|
||||
};
|
||||
HttpResponse::Ok()
|
||||
.await?;
|
||||
let new_blacklisted_jwts = data.backend_handler.blacklist_jwts(&user).await?;
|
||||
let mut jwt_blacklist = data.jwt_blacklist.write().unwrap();
|
||||
for jwt in new_blacklisted_jwts {
|
||||
jwt_blacklist.insert(jwt);
|
||||
}
|
||||
Ok(HttpResponse::Ok()
|
||||
.cookie(
|
||||
Cookie::build("token", "")
|
||||
.max_age(0.days())
|
||||
@@ -236,15 +267,28 @@ where
|
||||
.same_site(SameSite::Strict)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
.finish())
|
||||
}
|
||||
|
||||
pub(crate) fn error_to_api_response<T>(error: DomainError) -> ApiResult<T> {
|
||||
ApiResult::Right(error_to_http_response(error))
|
||||
async fn get_logout_handler<Backend>(
|
||||
data: web::Data<AppState<Backend>>,
|
||||
request: HttpRequest,
|
||||
) -> HttpResponse
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + 'static,
|
||||
{
|
||||
get_logout(data, request)
|
||||
.await
|
||||
.unwrap_or_else(error_to_http_response)
|
||||
}
|
||||
|
||||
pub(crate) fn error_to_api_response<T, E: Into<TcpError>>(error: E) -> ApiResult<T> {
|
||||
ApiResult::Right(error_to_http_response(error.into()))
|
||||
}
|
||||
|
||||
pub type ApiResult<M> = actix_web::Either<web::Json<M>, HttpResponse>;
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn opaque_login_start<Backend>(
|
||||
data: web::Data<AppState<Backend>>,
|
||||
request: web::Json<login::ClientLoginStartRequest>,
|
||||
@@ -259,135 +303,207 @@ where
|
||||
.unwrap_or_else(error_to_api_response)
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn get_login_successful_response<Backend>(
|
||||
data: &web::Data<AppState<Backend>>,
|
||||
name: &str,
|
||||
) -> HttpResponse
|
||||
name: &UserId,
|
||||
) -> TcpResult<HttpResponse>
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler,
|
||||
{
|
||||
// The authentication was successful, we need to fetch the groups to create the JWT
|
||||
// token.
|
||||
data.backend_handler
|
||||
.get_user_groups(name)
|
||||
.and_then(|g| async { Ok((g, data.backend_handler.create_refresh_token(name).await?)) })
|
||||
.await
|
||||
.map(|(groups, (refresh_token, max_age))| {
|
||||
let token = create_jwt(&data.jwt_key, name.to_string(), groups);
|
||||
HttpResponse::Ok()
|
||||
.cookie(
|
||||
Cookie::build("token", token.as_str())
|
||||
.max_age(1.days())
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.finish(),
|
||||
)
|
||||
.cookie(
|
||||
Cookie::build("refresh_token", refresh_token + "+" + name)
|
||||
.max_age(max_age.num_days().days())
|
||||
.path("/auth")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.finish(),
|
||||
)
|
||||
.body(token.as_str().to_owned())
|
||||
})
|
||||
.unwrap_or_else(error_to_http_response)
|
||||
let groups = data.backend_handler.get_user_groups(name).await?;
|
||||
let (refresh_token, max_age) = data.backend_handler.create_refresh_token(name).await?;
|
||||
let token = create_jwt(&data.jwt_key, name.to_string(), groups);
|
||||
let refresh_token_plus_name = refresh_token + "+" + name.as_str();
|
||||
|
||||
Ok(HttpResponse::Ok()
|
||||
.cookie(
|
||||
Cookie::build("token", token.as_str())
|
||||
.max_age(1.days())
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.finish(),
|
||||
)
|
||||
.cookie(
|
||||
Cookie::build("refresh_token", refresh_token_plus_name.clone())
|
||||
.max_age(max_age.num_days().days())
|
||||
.path("/auth")
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.finish(),
|
||||
)
|
||||
.json(&login::ServerLoginResponse {
|
||||
token: token.as_str().to_owned(),
|
||||
refresh_token: Some(refresh_token_plus_name),
|
||||
}))
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn opaque_login_finish<Backend>(
|
||||
data: web::Data<AppState<Backend>>,
|
||||
request: web::Json<login::ClientLoginFinishRequest>,
|
||||
) -> TcpResult<HttpResponse>
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static,
|
||||
{
|
||||
let name = data
|
||||
.backend_handler
|
||||
.login_finish(request.into_inner())
|
||||
.await?;
|
||||
get_login_successful_response(&data, &name).await
|
||||
}
|
||||
|
||||
async fn opaque_login_finish_handler<Backend>(
|
||||
data: web::Data<AppState<Backend>>,
|
||||
request: web::Json<login::ClientLoginFinishRequest>,
|
||||
) -> HttpResponse
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static,
|
||||
{
|
||||
let name = match data
|
||||
.backend_handler
|
||||
.login_finish(request.into_inner())
|
||||
opaque_login_finish(data, request)
|
||||
.await
|
||||
{
|
||||
Ok(n) => n,
|
||||
Err(e) => return error_to_http_response(e),
|
||||
.unwrap_or_else(error_to_http_response)
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn simple_login<Backend>(
|
||||
data: web::Data<AppState<Backend>>,
|
||||
request: web::Json<login::ClientSimpleLoginRequest>,
|
||||
) -> TcpResult<HttpResponse>
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + LoginHandler + 'static,
|
||||
{
|
||||
let user_id = UserId::new(&request.username);
|
||||
let bind_request = BindRequest {
|
||||
name: user_id.clone(),
|
||||
password: request.password.clone(),
|
||||
};
|
||||
data.backend_handler.bind(bind_request).await?;
|
||||
get_login_successful_response(&data, &user_id).await
|
||||
}
|
||||
|
||||
async fn simple_login_handler<Backend>(
|
||||
data: web::Data<AppState<Backend>>,
|
||||
request: web::Json<login::ClientSimpleLoginRequest>,
|
||||
) -> HttpResponse
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + LoginHandler + 'static,
|
||||
{
|
||||
simple_login(data, request)
|
||||
.await
|
||||
.unwrap_or_else(error_to_http_response)
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn post_authorize<Backend>(
|
||||
data: web::Data<AppState<Backend>>,
|
||||
request: web::Json<BindRequest>,
|
||||
) -> TcpResult<HttpResponse>
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + LoginHandler + 'static,
|
||||
{
|
||||
let name = request.name.clone();
|
||||
debug!(%name);
|
||||
data.backend_handler.bind(request.into_inner()).await?;
|
||||
get_login_successful_response(&data, &name).await
|
||||
}
|
||||
|
||||
async fn post_authorize<Backend>(
|
||||
async fn post_authorize_handler<Backend>(
|
||||
data: web::Data<AppState<Backend>>,
|
||||
request: web::Json<BindRequest>,
|
||||
) -> HttpResponse
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + LoginHandler + 'static,
|
||||
{
|
||||
let name = request.name.clone();
|
||||
if let Err(e) = data.backend_handler.bind(request.into_inner()).await {
|
||||
return error_to_http_response(e);
|
||||
}
|
||||
get_login_successful_response(&data, &name).await
|
||||
post_authorize(data, request)
|
||||
.await
|
||||
.unwrap_or_else(error_to_http_response)
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn opaque_register_start<Backend>(
|
||||
request: actix_web::HttpRequest,
|
||||
mut payload: actix_web::web::Payload,
|
||||
data: web::Data<AppState<Backend>>,
|
||||
) -> ApiResult<registration::ServerRegistrationStartResponse>
|
||||
) -> TcpResult<registration::ServerRegistrationStartResponse>
|
||||
where
|
||||
Backend: OpaqueHandler + 'static,
|
||||
Backend: BackendHandler + OpaqueHandler + 'static,
|
||||
{
|
||||
use actix_web::FromRequest;
|
||||
let validation_result = match BearerAuth::from_request(&request, &mut payload.0)
|
||||
let validation_result = BearerAuth::from_request(&request, &mut payload.0)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|bearer| check_if_token_is_valid(&data, bearer.token()).ok())
|
||||
{
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return ApiResult::Right(
|
||||
HttpResponse::Unauthorized().body("Not authorized to change the user's password"),
|
||||
)
|
||||
}
|
||||
};
|
||||
.ok_or_else(|| {
|
||||
TcpError::UnauthorizedError("Not authorized to change the user's password".to_string())
|
||||
})?;
|
||||
let registration_start_request =
|
||||
match web::Json::<registration::ClientRegistrationStartRequest>::from_request(
|
||||
web::Json::<registration::ClientRegistrationStartRequest>::from_request(
|
||||
&request,
|
||||
&mut payload.0,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return ApiResult::Right(
|
||||
HttpResponse::BadRequest().body(format!("Bad request: {:#?}", e)),
|
||||
)
|
||||
}
|
||||
}
|
||||
.map_err(|e| TcpError::BadRequest(format!("{:#?}", e)))?
|
||||
.into_inner();
|
||||
let user_id = ®istration_start_request.username;
|
||||
validation_result.can_access(user_id);
|
||||
data.backend_handler
|
||||
let user_id = UserId::new(®istration_start_request.username);
|
||||
let user_is_admin = data
|
||||
.backend_handler
|
||||
.get_user_groups(&user_id)
|
||||
.await?
|
||||
.iter()
|
||||
.any(|g| g.display_name == "lldap_admin");
|
||||
if !validation_result.can_change_password(&user_id, user_is_admin) {
|
||||
return Err(TcpError::UnauthorizedError(
|
||||
"Not authorized to change the user's password".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(data
|
||||
.backend_handler
|
||||
.registration_start(registration_start_request)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn opaque_register_start_handler<Backend>(
|
||||
request: actix_web::HttpRequest,
|
||||
payload: actix_web::web::Payload,
|
||||
data: web::Data<AppState<Backend>>,
|
||||
) -> ApiResult<registration::ServerRegistrationStartResponse>
|
||||
where
|
||||
Backend: BackendHandler + OpaqueHandler + 'static,
|
||||
{
|
||||
opaque_register_start(request, payload, data)
|
||||
.await
|
||||
.map(|res| ApiResult::Left(web::Json(res)))
|
||||
.unwrap_or_else(error_to_api_response)
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn opaque_register_finish<Backend>(
|
||||
data: web::Data<AppState<Backend>>,
|
||||
request: web::Json<registration::ClientRegistrationFinishRequest>,
|
||||
) -> TcpResult<HttpResponse>
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static,
|
||||
{
|
||||
data.backend_handler
|
||||
.registration_finish(request.into_inner())
|
||||
.await?;
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
}
|
||||
|
||||
async fn opaque_register_finish_handler<Backend>(
|
||||
data: web::Data<AppState<Backend>>,
|
||||
request: web::Json<registration::ClientRegistrationFinishRequest>,
|
||||
) -> HttpResponse
|
||||
where
|
||||
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static,
|
||||
{
|
||||
if let Err(e) = data
|
||||
.backend_handler
|
||||
.registration_finish(request.into_inner())
|
||||
opaque_register_finish(data, request)
|
||||
.await
|
||||
{
|
||||
return error_to_http_response(e);
|
||||
}
|
||||
HttpResponse::Ok().finish()
|
||||
.unwrap_or_else(error_to_http_response)
|
||||
}
|
||||
|
||||
pub struct CookieToHeaderTranslatorFactory;
|
||||
@@ -446,25 +562,63 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub enum Permission {
|
||||
Admin,
|
||||
PasswordManager,
|
||||
Readonly,
|
||||
Regular,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ValidationResults {
|
||||
pub user: String,
|
||||
pub is_admin: bool,
|
||||
pub user: UserId,
|
||||
pub permission: Permission,
|
||||
}
|
||||
|
||||
impl ValidationResults {
|
||||
#[cfg(test)]
|
||||
pub fn admin() -> Self {
|
||||
Self {
|
||||
user: "admin".to_string(),
|
||||
is_admin: true,
|
||||
user: UserId::new("admin"),
|
||||
permission: Permission::Admin,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_access(&self, user: &str) -> bool {
|
||||
self.is_admin || self.user == user
|
||||
#[must_use]
|
||||
pub fn is_admin(&self) -> bool {
|
||||
self.permission == Permission::Admin
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_admin_or_readonly(&self) -> bool {
|
||||
self.permission == Permission::Admin
|
||||
|| self.permission == Permission::Readonly
|
||||
|| self.permission == Permission::PasswordManager
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn can_read(&self, user: &UserId) -> bool {
|
||||
self.permission == Permission::Admin
|
||||
|| self.permission == Permission::PasswordManager
|
||||
|| self.permission == Permission::Readonly
|
||||
|| &self.user == user
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn can_change_password(&self, user: &UserId, user_is_admin: bool) -> bool {
|
||||
self.permission == Permission::Admin
|
||||
|| (self.permission == Permission::PasswordManager && !user_is_admin)
|
||||
|| &self.user == user
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn can_write(&self, user: &UserId) -> bool {
|
||||
self.permission == Permission::Admin || &self.user == user
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug", err, ret)]
|
||||
pub(crate) fn check_if_token_is_valid<Backend>(
|
||||
state: &AppState<Backend>,
|
||||
token_str: &str,
|
||||
@@ -488,10 +642,18 @@ pub(crate) fn check_if_token_is_valid<Backend>(
|
||||
if state.jwt_blacklist.read().unwrap().contains(&jwt_hash) {
|
||||
return Err(ErrorUnauthorized("JWT was logged out"));
|
||||
}
|
||||
let is_admin = token.claims().groups.contains("lldap_admin");
|
||||
let is_in_group = |name| token.claims().groups.contains(name);
|
||||
Ok(ValidationResults {
|
||||
user: token.claims().user.clone(),
|
||||
is_admin,
|
||||
user: UserId::new(&token.claims().user),
|
||||
permission: if is_in_group("lldap_admin") {
|
||||
Permission::Admin
|
||||
} else if is_in_group("lldap_password_manager") {
|
||||
Permission::PasswordManager
|
||||
} else if is_in_group("lldap_strict_readonly") {
|
||||
Permission::Readonly
|
||||
} else {
|
||||
Permission::Regular
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -499,34 +661,38 @@ pub fn configure_server<Backend>(cfg: &mut web::ServiceConfig)
|
||||
where
|
||||
Backend: TcpBackendHandler + LoginHandler + OpaqueHandler + BackendHandler + 'static,
|
||||
{
|
||||
cfg.service(web::resource("").route(web::post().to(post_authorize::<Backend>)))
|
||||
cfg.service(web::resource("").route(web::post().to(post_authorize_handler::<Backend>)))
|
||||
.service(
|
||||
web::resource("/opaque/login/start")
|
||||
.route(web::post().to(opaque_login_start::<Backend>)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/opaque/login/finish")
|
||||
.route(web::post().to(opaque_login_finish::<Backend>)),
|
||||
.route(web::post().to(opaque_login_finish_handler::<Backend>)),
|
||||
)
|
||||
.service(web::resource("/refresh").route(web::get().to(get_refresh::<Backend>)))
|
||||
.service(
|
||||
web::resource("/simple/login").route(web::post().to(simple_login_handler::<Backend>)),
|
||||
)
|
||||
.service(web::resource("/refresh").route(web::get().to(get_refresh_handler::<Backend>)))
|
||||
.service(
|
||||
web::resource("/reset/step1/{user_id}")
|
||||
.route(web::get().to(get_password_reset_step1::<Backend>)),
|
||||
.route(web::get().to(get_password_reset_step1_handler::<Backend>)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/reset/step2/{token}")
|
||||
.route(web::get().to(get_password_reset_step2::<Backend>)),
|
||||
.route(web::get().to(get_password_reset_step2_handler::<Backend>)),
|
||||
)
|
||||
.service(web::resource("/logout").route(web::get().to(get_logout::<Backend>)))
|
||||
.service(web::resource("/logout").route(web::get().to(get_logout_handler::<Backend>)))
|
||||
.service(
|
||||
web::scope("/opaque/register")
|
||||
.wrap(CookieToHeaderTranslatorFactory)
|
||||
.service(
|
||||
web::resource("/start").route(web::post().to(opaque_register_start::<Backend>)),
|
||||
web::resource("/start")
|
||||
.route(web::post().to(opaque_register_start_handler::<Backend>)),
|
||||
)
|
||||
.service(
|
||||
web::resource("/finish")
|
||||
.route(web::post().to(opaque_register_finish::<Backend>)),
|
||||
.route(web::post().to(opaque_register_finish_handler::<Backend>)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use clap::Clap;
|
||||
use clap::Parser;
|
||||
use lettre::message::Mailbox;
|
||||
|
||||
/// lldap is a lightweight LDAP server
|
||||
#[derive(Debug, Clap, Clone)]
|
||||
#[clap(version = "0.1", author = "The LLDAP team")]
|
||||
#[derive(Debug, Parser, Clone)]
|
||||
#[clap(version, author)]
|
||||
pub struct CLIOpts {
|
||||
/// Export
|
||||
#[clap(subcommand)]
|
||||
@@ -11,7 +11,7 @@ pub struct CLIOpts {
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Debug, Clap, Clone)]
|
||||
#[derive(Debug, Parser, Clone)]
|
||||
pub enum Command {
|
||||
/// Export the GraphQL schema to *.graphql.
|
||||
#[clap(name = "export_graphql_schema")]
|
||||
@@ -24,7 +24,7 @@ pub enum Command {
|
||||
SendTestEmail(TestEmailOpts),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clap, Clone)]
|
||||
#[derive(Debug, Parser, Clone)]
|
||||
pub struct GeneralConfigOpts {
|
||||
/// Change config file name.
|
||||
#[clap(
|
||||
@@ -40,7 +40,7 @@ pub struct GeneralConfigOpts {
|
||||
pub verbose: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clap, Clone)]
|
||||
#[derive(Debug, Parser, Clone)]
|
||||
pub struct RunOpts {
|
||||
#[clap(flatten)]
|
||||
pub general_config: GeneralConfigOpts,
|
||||
@@ -54,10 +54,6 @@ pub struct RunOpts {
|
||||
#[clap(long, env = "LLDAP_LDAP_PORT")]
|
||||
pub ldap_port: Option<u16>,
|
||||
|
||||
/// Change ldap ssl port. Default: 6360
|
||||
#[clap(long, env = "LLDAP_LDAPS_PORT")]
|
||||
pub ldaps_port: Option<u16>,
|
||||
|
||||
/// Change HTTP API port. Default: 17170
|
||||
#[clap(long, env = "LLDAP_HTTP_PORT")]
|
||||
pub http_port: Option<u16>,
|
||||
@@ -68,9 +64,12 @@ pub struct RunOpts {
|
||||
|
||||
#[clap(flatten)]
|
||||
pub smtp_opts: SmtpOpts,
|
||||
|
||||
#[clap(flatten)]
|
||||
pub ldaps_opts: LdapsOpts,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clap, Clone)]
|
||||
#[derive(Debug, Parser, Clone)]
|
||||
pub struct TestEmailOpts {
|
||||
#[clap(flatten)]
|
||||
pub general_config: GeneralConfigOpts,
|
||||
@@ -83,10 +82,30 @@ pub struct TestEmailOpts {
|
||||
pub smtp_opts: SmtpOpts,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clap, Clone)]
|
||||
#[derive(Debug, Parser, Clone)]
|
||||
#[clap(next_help_heading = Some("LDAPS"), setting = clap::AppSettings::DeriveDisplayOrder)]
|
||||
pub struct LdapsOpts {
|
||||
/// Enable LDAPS. Default: false.
|
||||
#[clap(long, env = "LLDAP_LDAPS_OPTIONS__ENABLED")]
|
||||
pub ldaps_enabled: Option<bool>,
|
||||
|
||||
/// Change ldap ssl port. Default: 6360
|
||||
#[clap(long, env = "LLDAP_LDAPS_OPTIONS__PORT")]
|
||||
pub ldaps_port: Option<u16>,
|
||||
|
||||
/// Ldaps certificate file. Default: cert.pem
|
||||
#[clap(long, env = "LLDAP_LDAPS_OPTIONS__CERT_FILE")]
|
||||
pub ldaps_cert_file: Option<String>,
|
||||
|
||||
/// Ldaps certificate key file. Default: key.pem
|
||||
#[clap(long, env = "LLDAP_LDAPS_OPTIONS__KEY_FILE")]
|
||||
pub ldaps_key_file: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser, Clone)]
|
||||
#[clap(next_help_heading = Some("SMTP"), setting = clap::AppSettings::DeriveDisplayOrder)]
|
||||
pub struct SmtpOpts {
|
||||
/// Sender email address.
|
||||
#[clap(long)]
|
||||
#[clap(long, env = "LLDAP_SMTP_OPTIONS__FROM")]
|
||||
pub smtp_from: Option<Mailbox>,
|
||||
|
||||
@@ -115,7 +134,7 @@ pub struct SmtpOpts {
|
||||
pub smtp_tls_required: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clap, Clone)]
|
||||
#[derive(Debug, Parser, Clone)]
|
||||
pub struct ExportGraphQLSchemaOpts {
|
||||
/// Output to a file. If not specified, the config is printed to the standard output.
|
||||
#[clap(short, long)]
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use crate::infra::cli::{GeneralConfigOpts, RunOpts, SmtpOpts, TestEmailOpts};
|
||||
use crate::{
|
||||
domain::handler::UserId,
|
||||
infra::cli::{GeneralConfigOpts, LdapsOpts, RunOpts, SmtpOpts, TestEmailOpts},
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use figment::{
|
||||
providers::{Env, Format, Serialized, Toml},
|
||||
@@ -36,31 +39,54 @@ impl std::default::Default for MailOptions {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, derive_builder::Builder)]
|
||||
#[builder(pattern = "owned")]
|
||||
pub struct LdapsOptions {
|
||||
#[builder(default = "false")]
|
||||
pub enabled: bool,
|
||||
#[builder(default = "6360")]
|
||||
pub port: u16,
|
||||
#[builder(default = r#"String::from("cert.pem")"#)]
|
||||
pub cert_file: String,
|
||||
#[builder(default = r#"String::from("key.pem")"#)]
|
||||
pub key_file: String,
|
||||
}
|
||||
|
||||
impl std::default::Default for LdapsOptions {
|
||||
fn default() -> Self {
|
||||
LdapsOptionsBuilder::default().build().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, derive_builder::Builder)]
|
||||
#[builder(pattern = "owned", build_fn(name = "private_build"))]
|
||||
pub struct Configuration {
|
||||
#[builder(default = "3890")]
|
||||
pub ldap_port: u16,
|
||||
#[builder(default = "6360")]
|
||||
pub ldaps_port: u16,
|
||||
#[builder(default = "17170")]
|
||||
pub http_port: u16,
|
||||
#[builder(default = r#"SecUtf8::from("secretjwtsecret")"#)]
|
||||
pub jwt_secret: SecUtf8,
|
||||
#[builder(default = r#"String::from("dc=example,dc=com")"#)]
|
||||
pub ldap_base_dn: String,
|
||||
#[builder(default = r#"String::from("admin")"#)]
|
||||
pub ldap_user_dn: String,
|
||||
#[builder(default = r#"UserId::new("admin")"#)]
|
||||
pub ldap_user_dn: UserId,
|
||||
#[builder(default = r#"SecUtf8::from("password")"#)]
|
||||
pub ldap_user_pass: SecUtf8,
|
||||
#[builder(default = r#"String::from("sqlite://users.db?mode=rwc")"#)]
|
||||
pub database_url: String,
|
||||
#[builder(default)]
|
||||
pub ignored_user_attributes: Vec<String>,
|
||||
#[builder(default)]
|
||||
pub ignored_group_attributes: Vec<String>,
|
||||
#[builder(default = "false")]
|
||||
pub verbose: bool,
|
||||
#[builder(default = r#"String::from("server_key")"#)]
|
||||
pub key_file: String,
|
||||
#[builder(default)]
|
||||
pub smtp_options: MailOptions,
|
||||
#[builder(default)]
|
||||
pub ldaps_options: LdapsOptions,
|
||||
#[builder(default = r#"String::from("http://localhost")"#)]
|
||||
pub http_url: String,
|
||||
#[serde(skip)]
|
||||
@@ -141,10 +167,6 @@ impl ConfigOverrider for RunOpts {
|
||||
config.ldap_port = port;
|
||||
}
|
||||
|
||||
if let Some(port) = self.ldaps_port {
|
||||
config.ldaps_port = port;
|
||||
}
|
||||
|
||||
if let Some(port) = self.http_port {
|
||||
config.http_port = port;
|
||||
}
|
||||
@@ -153,6 +175,7 @@ impl ConfigOverrider for RunOpts {
|
||||
config.http_url = url.to_string();
|
||||
}
|
||||
self.smtp_opts.override_config(config);
|
||||
self.ldaps_opts.override_config(config);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +186,23 @@ impl ConfigOverrider for TestEmailOpts {
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigOverrider for LdapsOpts {
|
||||
fn override_config(&self, config: &mut Configuration) {
|
||||
if let Some(enabled) = self.ldaps_enabled {
|
||||
config.ldaps_options.enabled = enabled;
|
||||
}
|
||||
if let Some(port) = self.ldaps_port {
|
||||
config.ldaps_options.port = port;
|
||||
}
|
||||
if let Some(path) = self.ldaps_cert_file.as_ref() {
|
||||
config.ldaps_options.cert_file = path.clone();
|
||||
}
|
||||
if let Some(path) = self.ldaps_key_file.as_ref() {
|
||||
config.ldaps_options.key_file = path.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigOverrider for GeneralConfigOpts {
|
||||
fn override_config(&self, config: &mut Configuration) {
|
||||
if self.verbose {
|
||||
|
||||
@@ -7,6 +7,7 @@ use chrono::Local;
|
||||
use cron::Schedule;
|
||||
use sea_query::{Expr, Query};
|
||||
use std::{str::FromStr, time::Duration};
|
||||
use tracing::{debug, error, info, instrument};
|
||||
|
||||
// Define actor
|
||||
pub struct Scheduler {
|
||||
@@ -19,7 +20,7 @@ impl Actor for Scheduler {
|
||||
type Context = Context<Self>;
|
||||
|
||||
fn started(&mut self, context: &mut Context<Self>) {
|
||||
log::info!("DB Cleanup Cron started");
|
||||
info!("DB Cleanup Cron started");
|
||||
|
||||
context.run_later(self.duration_until_next(), move |this, ctx| {
|
||||
this.schedule_task(ctx)
|
||||
@@ -27,7 +28,7 @@ impl Actor for Scheduler {
|
||||
}
|
||||
|
||||
fn stopped(&mut self, _ctx: &mut Context<Self>) {
|
||||
log::info!("DB Cleanup stopped");
|
||||
info!("DB Cleanup stopped");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +39,6 @@ impl Scheduler {
|
||||
}
|
||||
|
||||
fn schedule_task(&self, ctx: &mut Context<Self>) {
|
||||
log::info!("Cleaning DB");
|
||||
let future = actix::fut::wrap_future::<_, Self>(Self::cleanup_db(self.sql_pool.clone()));
|
||||
ctx.spawn(future);
|
||||
|
||||
@@ -47,17 +47,16 @@ impl Scheduler {
|
||||
});
|
||||
}
|
||||
|
||||
#[instrument(skip_all)]
|
||||
async fn cleanup_db(sql_pool: Pool) {
|
||||
if let Err(e) = sqlx::query(
|
||||
&Query::delete()
|
||||
.from_table(JwtRefreshStorage::Table)
|
||||
.and_where(Expr::col(JwtRefreshStorage::ExpiryDate).lt(Local::now().naive_utc()))
|
||||
.to_string(DbQueryBuilder {}),
|
||||
)
|
||||
.execute(&sql_pool)
|
||||
.await
|
||||
{
|
||||
log::error!("DB error while cleaning up JWT refresh tokens: {}", e);
|
||||
info!("Cleaning DB");
|
||||
let query = Query::delete()
|
||||
.from_table(JwtRefreshStorage::Table)
|
||||
.and_where(Expr::col(JwtRefreshStorage::ExpiryDate).lt(Local::now().naive_utc()))
|
||||
.to_string(DbQueryBuilder {});
|
||||
debug!(%query);
|
||||
if let Err(e) = sqlx::query(&query).execute(&sql_pool).await {
|
||||
error!("DB error while cleaning up JWT refresh tokens: {}", e);
|
||||
};
|
||||
if let Err(e) = sqlx::query(
|
||||
&Query::delete()
|
||||
@@ -68,9 +67,9 @@ impl Scheduler {
|
||||
.execute(&sql_pool)
|
||||
.await
|
||||
{
|
||||
log::error!("DB error while cleaning up JWT storage: {}", e);
|
||||
error!("DB error while cleaning up JWT storage: {}", e);
|
||||
};
|
||||
log::info!("DB cleaned!");
|
||||
info!("DB cleaned!");
|
||||
}
|
||||
|
||||
fn duration_until_next(&self) -> Duration {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use crate::domain::handler::{
|
||||
BackendHandler, CreateUserRequest, GroupId, UpdateGroupRequest, UpdateUserRequest,
|
||||
BackendHandler, CreateUserRequest, GroupId, UpdateGroupRequest, UpdateUserRequest, UserId,
|
||||
};
|
||||
use juniper::{graphql_object, FieldResult, GraphQLInputObject, GraphQLObject};
|
||||
use tracing::{debug, debug_span, Instrument};
|
||||
|
||||
use super::api::Context;
|
||||
|
||||
@@ -63,22 +64,30 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||
context: &Context<Handler>,
|
||||
user: CreateUserInput,
|
||||
) -> FieldResult<super::query::User<Handler>> {
|
||||
if !context.validation_result.is_admin {
|
||||
let span = debug_span!("[GraphQL mutation] create_user");
|
||||
span.in_scope(|| {
|
||||
debug!(?user.id);
|
||||
});
|
||||
if !context.validation_result.is_admin() {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized user creation".into());
|
||||
}
|
||||
let user_id = UserId::new(&user.id);
|
||||
context
|
||||
.handler
|
||||
.create_user(CreateUserRequest {
|
||||
user_id: user.id.clone(),
|
||||
user_id: user_id.clone(),
|
||||
email: user.email,
|
||||
display_name: user.display_name,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
})
|
||||
.instrument(span.clone())
|
||||
.await?;
|
||||
Ok(context
|
||||
.handler
|
||||
.get_user_details(&user.id)
|
||||
.get_user_details(&user_id)
|
||||
.instrument(span)
|
||||
.await
|
||||
.map(Into::into)?)
|
||||
}
|
||||
@@ -87,13 +96,19 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||
context: &Context<Handler>,
|
||||
name: String,
|
||||
) -> FieldResult<super::query::Group<Handler>> {
|
||||
if !context.validation_result.is_admin {
|
||||
let span = debug_span!("[GraphQL mutation] create_group");
|
||||
span.in_scope(|| {
|
||||
debug!(?name);
|
||||
});
|
||||
if !context.validation_result.is_admin() {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized group creation".into());
|
||||
}
|
||||
let group_id = context.handler.create_group(&name).await?;
|
||||
Ok(context
|
||||
.handler
|
||||
.get_group_details(group_id)
|
||||
.instrument(span)
|
||||
.await
|
||||
.map(Into::into)?)
|
||||
}
|
||||
@@ -102,18 +117,25 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||
context: &Context<Handler>,
|
||||
user: UpdateUserInput,
|
||||
) -> FieldResult<Success> {
|
||||
if !context.validation_result.can_access(&user.id) {
|
||||
let span = debug_span!("[GraphQL mutation] update_user");
|
||||
span.in_scope(|| {
|
||||
debug!(?user.id);
|
||||
});
|
||||
let user_id = UserId::new(&user.id);
|
||||
if !context.validation_result.can_write(&user_id) {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized user update".into());
|
||||
}
|
||||
context
|
||||
.handler
|
||||
.update_user(UpdateUserRequest {
|
||||
user_id: user.id,
|
||||
user_id,
|
||||
email: user.email,
|
||||
display_name: user.display_name,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
})
|
||||
.instrument(span)
|
||||
.await?;
|
||||
Ok(Success::new())
|
||||
}
|
||||
@@ -122,10 +144,16 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||
context: &Context<Handler>,
|
||||
group: UpdateGroupInput,
|
||||
) -> FieldResult<Success> {
|
||||
if !context.validation_result.is_admin {
|
||||
let span = debug_span!("[GraphQL mutation] update_group");
|
||||
span.in_scope(|| {
|
||||
debug!(?group.id);
|
||||
});
|
||||
if !context.validation_result.is_admin() {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized group update".into());
|
||||
}
|
||||
if group.id == 1 {
|
||||
span.in_scope(|| debug!("Cannot change admin group details"));
|
||||
return Err("Cannot change admin group details".into());
|
||||
}
|
||||
context
|
||||
@@ -134,6 +162,7 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||
group_id: GroupId(group.id),
|
||||
display_name: group.display_name,
|
||||
})
|
||||
.instrument(span)
|
||||
.await?;
|
||||
Ok(Success::new())
|
||||
}
|
||||
@@ -143,12 +172,18 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||
user_id: String,
|
||||
group_id: i32,
|
||||
) -> FieldResult<Success> {
|
||||
if !context.validation_result.is_admin {
|
||||
let span = debug_span!("[GraphQL mutation] add_user_to_group");
|
||||
span.in_scope(|| {
|
||||
debug!(?user_id, ?group_id);
|
||||
});
|
||||
if !context.validation_result.is_admin() {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized group membership modification".into());
|
||||
}
|
||||
context
|
||||
.handler
|
||||
.add_user_to_group(&user_id, GroupId(group_id))
|
||||
.add_user_to_group(&UserId::new(&user_id), GroupId(group_id))
|
||||
.instrument(span)
|
||||
.await?;
|
||||
Ok(Success::new())
|
||||
}
|
||||
@@ -158,38 +193,67 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
|
||||
user_id: String,
|
||||
group_id: i32,
|
||||
) -> FieldResult<Success> {
|
||||
if !context.validation_result.is_admin {
|
||||
let span = debug_span!("[GraphQL mutation] remove_user_from_group");
|
||||
span.in_scope(|| {
|
||||
debug!(?user_id, ?group_id);
|
||||
});
|
||||
if !context.validation_result.is_admin() {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized group membership modification".into());
|
||||
}
|
||||
let user_id = UserId::new(&user_id);
|
||||
if context.validation_result.user == user_id && group_id == 1 {
|
||||
span.in_scope(|| debug!("Cannot remove admin rights for current user"));
|
||||
return Err("Cannot remove admin rights for current user".into());
|
||||
}
|
||||
context
|
||||
.handler
|
||||
.remove_user_from_group(&user_id, GroupId(group_id))
|
||||
.instrument(span)
|
||||
.await?;
|
||||
Ok(Success::new())
|
||||
}
|
||||
|
||||
async fn delete_user(context: &Context<Handler>, user_id: String) -> FieldResult<Success> {
|
||||
if !context.validation_result.is_admin {
|
||||
let span = debug_span!("[GraphQL mutation] delete_user");
|
||||
span.in_scope(|| {
|
||||
debug!(?user_id);
|
||||
});
|
||||
let user_id = UserId::new(&user_id);
|
||||
if !context.validation_result.is_admin() {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized user deletion".into());
|
||||
}
|
||||
if context.validation_result.user == user_id {
|
||||
span.in_scope(|| debug!("Cannot delete current user"));
|
||||
return Err("Cannot delete current user".into());
|
||||
}
|
||||
context.handler.delete_user(&user_id).await?;
|
||||
context
|
||||
.handler
|
||||
.delete_user(&user_id)
|
||||
.instrument(span)
|
||||
.await?;
|
||||
Ok(Success::new())
|
||||
}
|
||||
|
||||
async fn delete_group(context: &Context<Handler>, group_id: i32) -> FieldResult<Success> {
|
||||
if !context.validation_result.is_admin {
|
||||
let span = debug_span!("[GraphQL mutation] delete_group");
|
||||
span.in_scope(|| {
|
||||
debug!(?group_id);
|
||||
});
|
||||
if !context.validation_result.is_admin() {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized group deletion".into());
|
||||
}
|
||||
if group_id == 1 {
|
||||
span.in_scope(|| debug!("Cannot delete admin group"));
|
||||
return Err("Cannot delete admin group".into());
|
||||
}
|
||||
context.handler.delete_group(GroupId(group_id)).await?;
|
||||
context
|
||||
.handler
|
||||
.delete_group(GroupId(group_id))
|
||||
.instrument(span)
|
||||
.await?;
|
||||
Ok(Success::new())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use crate::domain::handler::{BackendHandler, GroupId, GroupIdAndName};
|
||||
use crate::domain::handler::{BackendHandler, GroupDetails, GroupId, UserId};
|
||||
use juniper::{graphql_object, FieldResult, GraphQLInputObject};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, debug_span, Instrument};
|
||||
|
||||
type DomainRequestFilter = crate::domain::handler::RequestFilter;
|
||||
type DomainRequestFilter = crate::domain::handler::UserRequestFilter;
|
||||
type DomainUser = crate::domain::handler::User;
|
||||
type DomainGroup = crate::domain::handler::Group;
|
||||
type DomainUserAndGroups = crate::domain::handler::UserAndGroups;
|
||||
use super::api::Context;
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
|
||||
@@ -48,6 +50,9 @@ impl TryInto<DomainRequestFilter> for RequestFilter {
|
||||
return Err("Multiple fields specified in request filter".to_string());
|
||||
}
|
||||
if let Some(e) = self.eq {
|
||||
if e.field.to_lowercase() == "uid" {
|
||||
return Ok(DomainRequestFilter::UserId(UserId::new(&e.value)));
|
||||
}
|
||||
return Ok(DomainRequestFilter::Equality(e.field, e.value));
|
||||
}
|
||||
if let Some(c) = self.any {
|
||||
@@ -104,12 +109,19 @@ impl<Handler: BackendHandler + Sync> Query<Handler> {
|
||||
}
|
||||
|
||||
pub async fn user(context: &Context<Handler>, user_id: String) -> FieldResult<User<Handler>> {
|
||||
if !context.validation_result.can_access(&user_id) {
|
||||
let span = debug_span!("[GraphQL query] user");
|
||||
span.in_scope(|| {
|
||||
debug!(?user_id);
|
||||
});
|
||||
let user_id = UserId::new(&user_id);
|
||||
if !context.validation_result.can_read(&user_id) {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized access to user data".into());
|
||||
}
|
||||
Ok(context
|
||||
.handler
|
||||
.get_user_details(&user_id)
|
||||
.instrument(span)
|
||||
.await
|
||||
.map(Into::into)?)
|
||||
}
|
||||
@@ -118,34 +130,49 @@ impl<Handler: BackendHandler + Sync> Query<Handler> {
|
||||
context: &Context<Handler>,
|
||||
#[graphql(name = "where")] filters: Option<RequestFilter>,
|
||||
) -> FieldResult<Vec<User<Handler>>> {
|
||||
if !context.validation_result.is_admin {
|
||||
let span = debug_span!("[GraphQL query] users");
|
||||
span.in_scope(|| {
|
||||
debug!(?filters);
|
||||
});
|
||||
if !context.validation_result.is_admin_or_readonly() {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized access to user list".into());
|
||||
}
|
||||
Ok(context
|
||||
.handler
|
||||
.list_users(filters.map(TryInto::try_into).transpose()?)
|
||||
.list_users(filters.map(TryInto::try_into).transpose()?, false)
|
||||
.instrument(span)
|
||||
.await
|
||||
.map(|v| v.into_iter().map(Into::into).collect())?)
|
||||
}
|
||||
|
||||
async fn groups(context: &Context<Handler>) -> FieldResult<Vec<Group<Handler>>> {
|
||||
if !context.validation_result.is_admin {
|
||||
let span = debug_span!("[GraphQL query] groups");
|
||||
if !context.validation_result.is_admin_or_readonly() {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized access to group list".into());
|
||||
}
|
||||
Ok(context
|
||||
.handler
|
||||
.list_groups()
|
||||
.list_groups(None)
|
||||
.instrument(span)
|
||||
.await
|
||||
.map(|v| v.into_iter().map(Into::into).collect())?)
|
||||
}
|
||||
|
||||
async fn group(context: &Context<Handler>, group_id: i32) -> FieldResult<Group<Handler>> {
|
||||
if !context.validation_result.is_admin {
|
||||
let span = debug_span!("[GraphQL query] group");
|
||||
span.in_scope(|| {
|
||||
debug!(?group_id);
|
||||
});
|
||||
if !context.validation_result.is_admin_or_readonly() {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized access to group data".into());
|
||||
}
|
||||
Ok(context
|
||||
.handler
|
||||
.get_group_details(GroupId(group_id))
|
||||
.instrument(span)
|
||||
.await
|
||||
.map(Into::into)?)
|
||||
}
|
||||
@@ -158,6 +185,7 @@ pub struct User<Handler: BackendHandler> {
|
||||
_phantom: std::marker::PhantomData<Box<Handler>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl<Handler: BackendHandler> Default for User<Handler> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -170,7 +198,7 @@ impl<Handler: BackendHandler> Default for User<Handler> {
|
||||
#[graphql_object(context = Context<Handler>)]
|
||||
impl<Handler: BackendHandler + Sync> User<Handler> {
|
||||
fn id(&self) -> &str {
|
||||
&self.user.user_id
|
||||
self.user.user_id.as_str()
|
||||
}
|
||||
|
||||
fn email(&self) -> &str {
|
||||
@@ -195,9 +223,14 @@ impl<Handler: BackendHandler + Sync> User<Handler> {
|
||||
|
||||
/// The groups to which this user belongs.
|
||||
async fn groups(&self, context: &Context<Handler>) -> FieldResult<Vec<Group<Handler>>> {
|
||||
let span = debug_span!("[GraphQL query] user::groups");
|
||||
span.in_scope(|| {
|
||||
debug!(user_id = ?self.user.user_id);
|
||||
});
|
||||
Ok(context
|
||||
.handler
|
||||
.get_user_groups(&self.user.user_id)
|
||||
.instrument(span)
|
||||
.await
|
||||
.map(|set| set.into_iter().map(Into::into).collect())?)
|
||||
}
|
||||
@@ -212,11 +245,21 @@ impl<Handler: BackendHandler> From<DomainUser> for User<Handler> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<Handler: BackendHandler> From<DomainUserAndGroups> for User<Handler> {
|
||||
fn from(user: DomainUserAndGroups) -> Self {
|
||||
Self {
|
||||
user: user.user,
|
||||
_phantom: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||||
/// Represents a single group.
|
||||
pub struct Group<Handler: BackendHandler> {
|
||||
group_id: i32,
|
||||
display_name: String,
|
||||
creation_date: chrono::DateTime<chrono::Utc>,
|
||||
members: Option<Vec<String>>,
|
||||
_phantom: std::marker::PhantomData<Box<Handler>>,
|
||||
}
|
||||
@@ -231,24 +274,32 @@ impl<Handler: BackendHandler + Sync> Group<Handler> {
|
||||
}
|
||||
/// The groups to which this user belongs.
|
||||
async fn users(&self, context: &Context<Handler>) -> FieldResult<Vec<User<Handler>>> {
|
||||
if !context.validation_result.is_admin {
|
||||
let span = debug_span!("[GraphQL query] group::users");
|
||||
span.in_scope(|| {
|
||||
debug!(name = %self.display_name);
|
||||
});
|
||||
if !context.validation_result.is_admin_or_readonly() {
|
||||
span.in_scope(|| debug!("Unauthorized"));
|
||||
return Err("Unauthorized access to group data".into());
|
||||
}
|
||||
Ok(context
|
||||
.handler
|
||||
.list_users(Some(DomainRequestFilter::MemberOfId(GroupId(
|
||||
self.group_id,
|
||||
))))
|
||||
.list_users(
|
||||
Some(DomainRequestFilter::MemberOfId(GroupId(self.group_id))),
|
||||
false,
|
||||
)
|
||||
.instrument(span)
|
||||
.await
|
||||
.map(|v| v.into_iter().map(Into::into).collect())?)
|
||||
}
|
||||
}
|
||||
|
||||
impl<Handler: BackendHandler> From<GroupIdAndName> for Group<Handler> {
|
||||
fn from(group_id_and_name: GroupIdAndName) -> Self {
|
||||
impl<Handler: BackendHandler> From<GroupDetails> for Group<Handler> {
|
||||
fn from(group_details: GroupDetails) -> Self {
|
||||
Self {
|
||||
group_id: group_id_and_name.0 .0,
|
||||
display_name: group_id_and_name.1,
|
||||
group_id: group_details.group_id.0,
|
||||
display_name: group_details.display_name,
|
||||
creation_date: group_details.creation_date,
|
||||
members: None,
|
||||
_phantom: std::marker::PhantomData,
|
||||
}
|
||||
@@ -260,7 +311,8 @@ impl<Handler: BackendHandler> From<DomainGroup> for Group<Handler> {
|
||||
Self {
|
||||
group_id: group.id.0,
|
||||
display_name: group.display_name,
|
||||
members: Some(group.users.into_iter().map(Into::into).collect()),
|
||||
creation_date: group.creation_date,
|
||||
members: Some(group.users.into_iter().map(UserId::into_string).collect()),
|
||||
_phantom: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
@@ -269,7 +321,11 @@ impl<Handler: BackendHandler> From<DomainGroup> for Group<Handler> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{domain::handler::MockTestBackendHandler, infra::auth_service::ValidationResults};
|
||||
use crate::{
|
||||
domain::handler::{MockTestBackendHandler, UserRequestFilter},
|
||||
infra::auth_service::ValidationResults,
|
||||
};
|
||||
use chrono::TimeZone;
|
||||
use juniper::{
|
||||
execute, graphql_value, DefaultScalarValue, EmptyMutation, EmptySubscription, GraphQLType,
|
||||
RootNode, Variables,
|
||||
@@ -302,18 +358,23 @@ mod tests {
|
||||
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
mock.expect_get_user_details()
|
||||
.with(eq("bob"))
|
||||
.with(eq(UserId::new("bob")))
|
||||
.return_once(|_| {
|
||||
Ok(DomainUser {
|
||||
user_id: "bob".to_string(),
|
||||
user_id: UserId::new("bob"),
|
||||
email: "bob@bobbers.on".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
});
|
||||
let mut groups = HashSet::new();
|
||||
groups.insert(GroupIdAndName(GroupId(3), "Bobbersons".to_string()));
|
||||
groups.insert(GroupDetails {
|
||||
group_id: GroupId(3),
|
||||
display_name: "Bobbersons".to_string(),
|
||||
creation_date: chrono::Utc.timestamp_nanos(42),
|
||||
uuid: crate::uuid!("a1a2a3a4b1b2c1c2d1d2d3d4d5d6d7d8"),
|
||||
});
|
||||
mock.expect_get_user_groups()
|
||||
.with(eq("bob"))
|
||||
.with(eq(UserId::new("bob")))
|
||||
.return_once(|_| Ok(groups));
|
||||
|
||||
let context = Context::<MockTestBackendHandler> {
|
||||
@@ -358,23 +419,34 @@ mod tests {
|
||||
}"#;
|
||||
|
||||
let mut mock = MockTestBackendHandler::new();
|
||||
use crate::domain::handler::RequestFilter;
|
||||
mock.expect_list_users()
|
||||
.with(eq(Some(RequestFilter::Or(vec![
|
||||
RequestFilter::Equality("id".to_string(), "bob".to_string()),
|
||||
RequestFilter::Equality("email".to_string(), "robert@bobbers.on".to_string()),
|
||||
]))))
|
||||
.return_once(|_| {
|
||||
.with(
|
||||
eq(Some(UserRequestFilter::Or(vec![
|
||||
UserRequestFilter::Equality("id".to_string(), "bob".to_string()),
|
||||
UserRequestFilter::Equality(
|
||||
"email".to_string(),
|
||||
"robert@bobbers.on".to_string(),
|
||||
),
|
||||
]))),
|
||||
eq(false),
|
||||
)
|
||||
.return_once(|_, _| {
|
||||
Ok(vec![
|
||||
DomainUser {
|
||||
user_id: "bob".to_string(),
|
||||
email: "bob@bobbers.on".to_string(),
|
||||
..Default::default()
|
||||
DomainUserAndGroups {
|
||||
user: DomainUser {
|
||||
user_id: UserId::new("bob"),
|
||||
email: "bob@bobbers.on".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
groups: None,
|
||||
},
|
||||
DomainUser {
|
||||
user_id: "robert".to_string(),
|
||||
email: "robert@bobbers.on".to_string(),
|
||||
..Default::default()
|
||||
DomainUserAndGroups {
|
||||
user: DomainUser {
|
||||
user_id: UserId::new("robert"),
|
||||
email: "robert@bobbers.on".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
groups: None,
|
||||
},
|
||||
])
|
||||
});
|
||||
|
||||
@@ -55,8 +55,8 @@ pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("JwtRefreshStorageUserForeignKey")
|
||||
.table(JwtRefreshStorage::Table, Users::Table)
|
||||
.col(JwtRefreshStorage::UserId, Users::UserId)
|
||||
.from(JwtRefreshStorage::Table, JwtRefreshStorage::UserId)
|
||||
.to(Users::Table, Users::UserId)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.on_update(ForeignKeyAction::Cascade),
|
||||
)
|
||||
@@ -94,8 +94,8 @@ pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("JwtStorageUserForeignKey")
|
||||
.table(JwtStorage::Table, Users::Table)
|
||||
.col(JwtStorage::UserId, Users::UserId)
|
||||
.from(JwtStorage::Table, JwtStorage::UserId)
|
||||
.to(Users::Table, Users::UserId)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.on_update(ForeignKeyAction::Cascade),
|
||||
)
|
||||
@@ -127,8 +127,8 @@ pub async fn init_table(pool: &Pool) -> sqlx::Result<()> {
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("PasswordResetTokensUserForeignKey")
|
||||
.table(PasswordResetTokens::Table, Users::Table)
|
||||
.col(PasswordResetTokens::UserId, Users::UserId)
|
||||
.from(PasswordResetTokens::Table, PasswordResetTokens::UserId)
|
||||
.to(Users::Table, Users::UserId)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.on_update(ForeignKeyAction::Cascade),
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,34 +9,37 @@ use actix_rt::net::TcpStream;
|
||||
use actix_server::ServerBuilder;
|
||||
use actix_service::{fn_service, ServiceFactoryExt};
|
||||
use anyhow::{Context, Result};
|
||||
use futures_util::future::ok;
|
||||
use ldap3_server::{proto::LdapMsg, LdapCodec};
|
||||
use log::*;
|
||||
use tokio::net::tcp::WriteHalf;
|
||||
use native_tls::{Identity, TlsAcceptor};
|
||||
use tokio_native_tls::TlsAcceptor as NativeTlsAcceptor;
|
||||
use tokio_util::codec::{FramedRead, FramedWrite};
|
||||
use tracing::{debug, error, info, instrument};
|
||||
|
||||
async fn handle_incoming_message<Backend>(
|
||||
#[instrument(skip_all, level = "info", name = "LDAP request")]
|
||||
async fn handle_ldap_message<Backend, Writer>(
|
||||
msg: Result<LdapMsg, std::io::Error>,
|
||||
resp: &mut FramedWrite<WriteHalf<'_>, LdapCodec>,
|
||||
resp: &mut Writer,
|
||||
session: &mut LdapHandler<Backend>,
|
||||
) -> Result<bool>
|
||||
where
|
||||
Backend: BackendHandler + LoginHandler + OpaqueHandler,
|
||||
Writer: futures_util::Sink<LdapMsg> + Unpin,
|
||||
<Writer as futures_util::Sink<LdapMsg>>::Error: std::error::Error + Send + Sync + 'static,
|
||||
{
|
||||
use futures_util::SinkExt;
|
||||
let msg = msg.context("while receiving LDAP op")?;
|
||||
debug!("Received LDAP message: {:?}", &msg);
|
||||
debug!(?msg);
|
||||
match session.handle_ldap_message(msg.op).await {
|
||||
None => return Ok(false),
|
||||
Some(result) => {
|
||||
if result.is_empty() {
|
||||
debug!("No response");
|
||||
}
|
||||
for result_op in result.into_iter() {
|
||||
debug!("Replying with LDAP op: {:?}", &result_op);
|
||||
for response in result.into_iter() {
|
||||
debug!(?response);
|
||||
resp.send(LdapMsg {
|
||||
msgid: msg.msgid,
|
||||
op: result_op,
|
||||
op: response,
|
||||
ctrl: vec![],
|
||||
})
|
||||
.await
|
||||
@@ -51,6 +54,63 @@ where
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn get_file_as_byte_vec(filename: &str) -> Result<Vec<u8>> {
|
||||
(|| -> Result<Vec<u8>> {
|
||||
use std::fs::{metadata, File};
|
||||
use std::io::Read;
|
||||
let mut f = File::open(&filename).context("file not found")?;
|
||||
let metadata = metadata(&filename).context("unable to read metadata")?;
|
||||
let mut buffer = vec![0; metadata.len() as usize];
|
||||
f.read(&mut buffer).context("buffer overflow")?;
|
||||
Ok(buffer)
|
||||
})()
|
||||
.context(format!("while reading file {}", filename))
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "info", name = "LDAP session")]
|
||||
async fn handle_ldap_stream<Stream, Backend>(
|
||||
stream: Stream,
|
||||
backend_handler: Backend,
|
||||
ldap_base_dn: String,
|
||||
ignored_user_attributes: Vec<String>,
|
||||
ignored_group_attributes: Vec<String>,
|
||||
) -> Result<Stream>
|
||||
where
|
||||
Backend: BackendHandler + LoginHandler + OpaqueHandler + 'static,
|
||||
Stream: tokio::io::AsyncRead + tokio::io::AsyncWrite,
|
||||
{
|
||||
use tokio_stream::StreamExt;
|
||||
let (r, w) = tokio::io::split(stream);
|
||||
// Configure the codec etc.
|
||||
let mut requests = FramedRead::new(r, LdapCodec);
|
||||
let mut resp = FramedWrite::new(w, LdapCodec);
|
||||
|
||||
let mut session = LdapHandler::new(
|
||||
backend_handler,
|
||||
ldap_base_dn,
|
||||
ignored_user_attributes,
|
||||
ignored_group_attributes,
|
||||
);
|
||||
|
||||
while let Some(msg) = requests.next().await {
|
||||
if !handle_ldap_message(msg, &mut resp, &mut session)
|
||||
.await
|
||||
.context("while handling incoming messages")?
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(requests.into_inner().unsplit(resp.into_inner()))
|
||||
}
|
||||
|
||||
fn get_tls_acceptor(config: &Configuration) -> Result<NativeTlsAcceptor> {
|
||||
// Load TLS key and cert files
|
||||
let cert_file = get_file_as_byte_vec(&config.ldaps_options.cert_file)?;
|
||||
let key_file = get_file_as_byte_vec(&config.ldaps_options.key_file)?;
|
||||
let identity = Identity::from_pkcs8(&cert_file, &key_file)?;
|
||||
Ok(TlsAcceptor::new(identity)?.into())
|
||||
}
|
||||
|
||||
pub fn build_ldap_server<Backend>(
|
||||
config: &Configuration,
|
||||
backend_handler: Backend,
|
||||
@@ -59,44 +119,75 @@ pub fn build_ldap_server<Backend>(
|
||||
where
|
||||
Backend: BackendHandler + LoginHandler + OpaqueHandler + 'static,
|
||||
{
|
||||
use futures_util::StreamExt;
|
||||
let context = (
|
||||
backend_handler,
|
||||
config.ldap_base_dn.clone(),
|
||||
config.ignored_user_attributes.clone(),
|
||||
config.ignored_group_attributes.clone(),
|
||||
);
|
||||
|
||||
let ldap_base_dn = config.ldap_base_dn.clone();
|
||||
let ldap_user_dn = config.ldap_user_dn.clone();
|
||||
server_builder
|
||||
.bind("ldap", ("0.0.0.0", config.ldap_port), move || {
|
||||
let backend_handler = backend_handler.clone();
|
||||
let ldap_base_dn = ldap_base_dn.clone();
|
||||
let ldap_user_dn = ldap_user_dn.clone();
|
||||
fn_service(move |mut stream: TcpStream| {
|
||||
let backend_handler = backend_handler.clone();
|
||||
let ldap_base_dn = ldap_base_dn.clone();
|
||||
let ldap_user_dn = ldap_user_dn.clone();
|
||||
let context_for_tls = context.clone();
|
||||
|
||||
let binder = move || {
|
||||
let context = context.clone();
|
||||
fn_service(move |stream: TcpStream| {
|
||||
let context = context.clone();
|
||||
async move {
|
||||
let (handler, base_dn, ignored_user_attributes, ignored_group_attributes) = context;
|
||||
handle_ldap_stream(
|
||||
stream,
|
||||
handler,
|
||||
base_dn,
|
||||
ignored_user_attributes,
|
||||
ignored_group_attributes,
|
||||
)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.map_err(|err: anyhow::Error| error!("[LDAP] Service Error: {:#}", err))
|
||||
};
|
||||
|
||||
info!("Starting the LDAP server on port {}", config.ldap_port);
|
||||
let server_builder = server_builder
|
||||
.bind("ldap", ("0.0.0.0", config.ldap_port), binder)
|
||||
.with_context(|| format!("while binding to the port {}", config.ldap_port));
|
||||
if config.ldaps_options.enabled {
|
||||
let tls_context = (
|
||||
context_for_tls,
|
||||
get_tls_acceptor(config).context("while setting up the SSL certificate")?,
|
||||
);
|
||||
let tls_binder = move || {
|
||||
let tls_context = tls_context.clone();
|
||||
fn_service(move |stream: TcpStream| {
|
||||
let tls_context = tls_context.clone();
|
||||
async move {
|
||||
// Configure the codec etc.
|
||||
let (r, w) = stream.split();
|
||||
let mut requests = FramedRead::new(r, LdapCodec);
|
||||
let mut resp = FramedWrite::new(w, LdapCodec);
|
||||
|
||||
let mut session = LdapHandler::new(backend_handler, ldap_base_dn, ldap_user_dn);
|
||||
|
||||
while let Some(msg) = requests.next().await {
|
||||
if !handle_incoming_message(msg, &mut resp, &mut session)
|
||||
.await
|
||||
.context("while handling incoming messages")?
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stream)
|
||||
let (
|
||||
(handler, base_dn, ignored_user_attributes, ignored_group_attributes),
|
||||
tls_acceptor,
|
||||
) = tls_context;
|
||||
let tls_stream = tls_acceptor.accept(stream).await?;
|
||||
handle_ldap_stream(
|
||||
tls_stream,
|
||||
handler,
|
||||
base_dn,
|
||||
ignored_user_attributes,
|
||||
ignored_group_attributes,
|
||||
)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.map_err(|err: anyhow::Error| error!("Service Error: {:#}", err))
|
||||
.and_then(move |_| {
|
||||
// finally
|
||||
ok(())
|
||||
})
|
||||
.map_err(|err: anyhow::Error| error!("[LDAPS] Service Error: {:#}", err))
|
||||
};
|
||||
|
||||
info!(
|
||||
"Starting the LDAPS server on port {}",
|
||||
config.ldaps_options.port
|
||||
);
|
||||
server_builder.and_then(|s| {
|
||||
s.bind("ldaps", ("0.0.0.0", config.ldaps_options.port), tls_binder)
|
||||
.with_context(|| format!("while binding to the port {}", config.ldaps_options.port))
|
||||
})
|
||||
.with_context(|| format!("while binding to the port {}", config.ldap_port))
|
||||
} else {
|
||||
server_builder
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,50 @@
|
||||
use crate::infra::configuration::Configuration;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use actix_web::{
|
||||
dev::{ServiceRequest, ServiceResponse},
|
||||
Error,
|
||||
};
|
||||
use tracing::{error, info, Span};
|
||||
use tracing_actix_web::{root_span, RootSpanBuilder};
|
||||
use tracing_subscriber::{filter::EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
/// We will define a custom root span builder to capture additional fields, specific
|
||||
/// to our application, on top of the ones provided by `DefaultRootSpanBuilder` out of the box.
|
||||
pub struct CustomRootSpanBuilder;
|
||||
|
||||
impl RootSpanBuilder for CustomRootSpanBuilder {
|
||||
fn on_request_start(request: &ServiceRequest) -> Span {
|
||||
let span = root_span!(request);
|
||||
span.in_scope(|| {
|
||||
info!(uri = %request.uri());
|
||||
});
|
||||
span
|
||||
}
|
||||
|
||||
fn on_request_end<B>(_: Span, outcome: &Result<ServiceResponse<B>, Error>) {
|
||||
match &outcome {
|
||||
Ok(response) => {
|
||||
if let Some(error) = response.response().error() {
|
||||
error!(?error);
|
||||
} else {
|
||||
info!(status_code = &response.response().status().as_u16());
|
||||
}
|
||||
}
|
||||
Err(error) => error!(?error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(config: &Configuration) -> anyhow::Result<()> {
|
||||
let max_log_level = log_level_from_config(config);
|
||||
let sqlx_max_log_level = sqlx_log_level_from_config(config);
|
||||
let filter = tracing_subscriber::filter::Targets::new()
|
||||
.with_target("lldap", max_log_level)
|
||||
.with_target("sqlx", sqlx_max_log_level);
|
||||
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
EnvFilter::new(if config.verbose {
|
||||
"sqlx=warn,debug"
|
||||
} else {
|
||||
"sqlx=warn,info"
|
||||
})
|
||||
});
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::fmt::layer().with_filter(filter))
|
||||
.with(env_filter)
|
||||
.with(tracing_forest::ForestLayer::default())
|
||||
.init();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn log_level_from_config(config: &Configuration) -> tracing::Level {
|
||||
if config.verbose {
|
||||
tracing::Level::DEBUG
|
||||
} else {
|
||||
tracing::Level::INFO
|
||||
}
|
||||
}
|
||||
|
||||
fn sqlx_log_level_from_config(config: &Configuration) -> tracing::Level {
|
||||
if config.verbose {
|
||||
tracing::Level::INFO
|
||||
} else {
|
||||
tracing::Level::WARN
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use lettre::{
|
||||
message::Mailbox, transport::smtp::authentication::Credentials, Message, SmtpTransport,
|
||||
Transport,
|
||||
};
|
||||
use log::debug;
|
||||
use tracing::debug;
|
||||
|
||||
fn send_email(to: Mailbox, subject: &str, body: String, options: &MailOptions) -> Result<()> {
|
||||
let from = options
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use super::{jwt_sql_tables::*, tcp_backend_handler::*};
|
||||
use crate::domain::{error::*, sql_backend_handler::SqlBackendHandler};
|
||||
use crate::domain::{error::*, handler::UserId, sql_backend_handler::SqlBackendHandler};
|
||||
use async_trait::async_trait;
|
||||
use futures_util::StreamExt;
|
||||
use sea_query::{Expr, Iden, Query, SimpleExpr};
|
||||
use sqlx::Row;
|
||||
use sea_query_binder::SqlxBinder;
|
||||
use sqlx::{query_as_with, query_with, Row};
|
||||
use std::collections::HashSet;
|
||||
use tracing::{debug, instrument};
|
||||
|
||||
fn gen_random_string(len: usize) -> String {
|
||||
use rand::{distributions::Alphanumeric, rngs::SmallRng, Rng, SeedableRng};
|
||||
@@ -18,13 +20,15 @@ fn gen_random_string(len: usize) -> String {
|
||||
|
||||
#[async_trait]
|
||||
impl TcpBackendHandler for SqlBackendHandler {
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>> {
|
||||
let query = Query::select()
|
||||
let (query, values) = Query::select()
|
||||
.column(JwtStorage::JwtHash)
|
||||
.from(JwtStorage::Table)
|
||||
.to_string(DbQueryBuilder {});
|
||||
.build_sqlx(DbQueryBuilder {});
|
||||
|
||||
sqlx::query(&query)
|
||||
debug!(%query);
|
||||
query_with(&query, values)
|
||||
.map(|row: DbRow| row.get::<i64, _>(&*JwtStorage::JwtHash.to_string()) as u64)
|
||||
.fetch(&self.sql_pool)
|
||||
.collect::<Vec<sqlx::Result<u64>>>()
|
||||
@@ -34,7 +38,9 @@ impl TcpBackendHandler for SqlBackendHandler {
|
||||
.map_err(|e| anyhow::anyhow!(e))
|
||||
}
|
||||
|
||||
async fn create_refresh_token(&self, user: &str) -> Result<(String, chrono::Duration)> {
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn create_refresh_token(&self, user: &UserId) -> Result<(String, chrono::Duration)> {
|
||||
debug!(?user);
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
// TODO: Initialize the rng only once. Maybe Arc<Cell>?
|
||||
@@ -45,7 +51,7 @@ impl TcpBackendHandler for SqlBackendHandler {
|
||||
s.finish()
|
||||
};
|
||||
let duration = chrono::Duration::days(30);
|
||||
let query = Query::insert()
|
||||
let (query, values) = Query::insert()
|
||||
.into_table(JwtRefreshStorage::Table)
|
||||
.columns(vec![
|
||||
JwtRefreshStorage::RefreshTokenHash,
|
||||
@@ -57,71 +63,90 @@ impl TcpBackendHandler for SqlBackendHandler {
|
||||
user.into(),
|
||||
(chrono::Utc::now() + duration).naive_utc().into(),
|
||||
])
|
||||
.to_string(DbQueryBuilder {});
|
||||
sqlx::query(&query).execute(&self.sql_pool).await?;
|
||||
.build_sqlx(DbQueryBuilder {});
|
||||
debug!(%query);
|
||||
query_with(&query, values).execute(&self.sql_pool).await?;
|
||||
Ok((refresh_token, duration))
|
||||
}
|
||||
|
||||
async fn check_token(&self, refresh_token_hash: u64, user: &str) -> Result<bool> {
|
||||
let query = Query::select()
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn check_token(&self, refresh_token_hash: u64, user: &UserId) -> Result<bool> {
|
||||
debug!(?user);
|
||||
let (query, values) = Query::select()
|
||||
.expr(SimpleExpr::Value(1.into()))
|
||||
.from(JwtRefreshStorage::Table)
|
||||
.and_where(Expr::col(JwtRefreshStorage::RefreshTokenHash).eq(refresh_token_hash as i64))
|
||||
.and_where(Expr::col(JwtRefreshStorage::UserId).eq(user))
|
||||
.to_string(DbQueryBuilder {});
|
||||
Ok(sqlx::query(&query)
|
||||
.build_sqlx(DbQueryBuilder {});
|
||||
debug!(%query);
|
||||
Ok(query_with(&query, values)
|
||||
.fetch_optional(&self.sql_pool)
|
||||
.await?
|
||||
.is_some())
|
||||
}
|
||||
async fn blacklist_jwts(&self, user: &str) -> Result<HashSet<u64>> {
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn blacklist_jwts(&self, user: &UserId) -> Result<HashSet<u64>> {
|
||||
debug!(?user);
|
||||
use sqlx::Result;
|
||||
let query = Query::select()
|
||||
let (query, values) = Query::select()
|
||||
.column(JwtStorage::JwtHash)
|
||||
.from(JwtStorage::Table)
|
||||
.and_where(Expr::col(JwtStorage::UserId).eq(user))
|
||||
.and_where(Expr::col(JwtStorage::Blacklisted).eq(true))
|
||||
.to_string(DbQueryBuilder {});
|
||||
let result = sqlx::query(&query)
|
||||
.build_sqlx(DbQueryBuilder {});
|
||||
let result = query_with(&query, values)
|
||||
.map(|row: DbRow| row.get::<i64, _>(&*JwtStorage::JwtHash.to_string()) as u64)
|
||||
.fetch(&self.sql_pool)
|
||||
.collect::<Vec<sqlx::Result<u64>>>()
|
||||
.await
|
||||
.into_iter()
|
||||
.collect::<Result<HashSet<u64>>>();
|
||||
let query = Query::update()
|
||||
let (query, values) = Query::update()
|
||||
.table(JwtStorage::Table)
|
||||
.values(vec![(JwtStorage::Blacklisted, true.into())])
|
||||
.and_where(Expr::col(JwtStorage::UserId).eq(user))
|
||||
.to_string(DbQueryBuilder {});
|
||||
sqlx::query(&query).execute(&self.sql_pool).await?;
|
||||
.build_sqlx(DbQueryBuilder {});
|
||||
debug!(%query);
|
||||
query_with(&query, values).execute(&self.sql_pool).await?;
|
||||
Ok(result?)
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn delete_refresh_token(&self, refresh_token_hash: u64) -> Result<()> {
|
||||
let query = Query::delete()
|
||||
let (query, values) = Query::delete()
|
||||
.from_table(JwtRefreshStorage::Table)
|
||||
.and_where(Expr::col(JwtRefreshStorage::RefreshTokenHash).eq(refresh_token_hash))
|
||||
.to_string(DbQueryBuilder {});
|
||||
sqlx::query(&query).execute(&self.sql_pool).await?;
|
||||
.and_where(Expr::col(JwtRefreshStorage::RefreshTokenHash).eq(refresh_token_hash as i64))
|
||||
.build_sqlx(DbQueryBuilder {});
|
||||
debug!(%query);
|
||||
query_with(&query, values).execute(&self.sql_pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_password_reset(&self, user: &str) -> Result<Option<String>> {
|
||||
let query = Query::select()
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn start_password_reset(&self, user: &UserId) -> Result<Option<String>> {
|
||||
debug!(?user);
|
||||
let (query, values) = Query::select()
|
||||
.column(Users::UserId)
|
||||
.from(Users::Table)
|
||||
.and_where(Expr::col(Users::UserId).eq(user))
|
||||
.to_string(DbQueryBuilder {});
|
||||
.build_sqlx(DbQueryBuilder {});
|
||||
|
||||
debug!(%query);
|
||||
// Check that the user exists.
|
||||
if sqlx::query(&query).fetch_one(&self.sql_pool).await.is_err() {
|
||||
if query_with(&query, values)
|
||||
.fetch_one(&self.sql_pool)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
debug!("User not found");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let token = gen_random_string(100);
|
||||
let duration = chrono::Duration::minutes(10);
|
||||
|
||||
let query = Query::insert()
|
||||
let (query, values) = Query::insert()
|
||||
.into_table(PasswordResetTokens::Table)
|
||||
.columns(vec![
|
||||
PasswordResetTokens::Token,
|
||||
@@ -133,31 +158,38 @@ impl TcpBackendHandler for SqlBackendHandler {
|
||||
user.into(),
|
||||
(chrono::Utc::now() + duration).naive_utc().into(),
|
||||
])
|
||||
.to_string(DbQueryBuilder {});
|
||||
sqlx::query(&query).execute(&self.sql_pool).await?;
|
||||
.build_sqlx(DbQueryBuilder {});
|
||||
debug!(%query);
|
||||
query_with(&query, values).execute(&self.sql_pool).await?;
|
||||
Ok(Some(token))
|
||||
}
|
||||
|
||||
async fn get_user_id_for_password_reset_token(&self, token: &str) -> Result<String> {
|
||||
let query = Query::select()
|
||||
#[instrument(skip_all, level = "debug", ret)]
|
||||
async fn get_user_id_for_password_reset_token(&self, token: &str) -> Result<UserId> {
|
||||
let (query, values) = Query::select()
|
||||
.column(PasswordResetTokens::UserId)
|
||||
.from(PasswordResetTokens::Table)
|
||||
.and_where(Expr::col(PasswordResetTokens::Token).eq(token))
|
||||
.and_where(
|
||||
Expr::col(PasswordResetTokens::ExpiryDate).gt(chrono::Utc::now().naive_utc()),
|
||||
)
|
||||
.to_string(DbQueryBuilder {});
|
||||
.build_sqlx(DbQueryBuilder {});
|
||||
debug!(%query);
|
||||
|
||||
let (user_id,) = sqlx::query_as(&query).fetch_one(&self.sql_pool).await?;
|
||||
let (user_id,) = query_as_with(&query, values)
|
||||
.fetch_one(&self.sql_pool)
|
||||
.await?;
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
#[instrument(skip_all, level = "debug")]
|
||||
async fn delete_password_reset_token(&self, token: &str) -> Result<()> {
|
||||
let query = Query::delete()
|
||||
let (query, values) = Query::delete()
|
||||
.from_table(PasswordResetTokens::Table)
|
||||
.and_where(Expr::col(PasswordResetTokens::Token).eq(token))
|
||||
.to_string(DbQueryBuilder {});
|
||||
sqlx::query(&query).execute(&self.sql_pool).await?;
|
||||
.build_sqlx(DbQueryBuilder {});
|
||||
debug!(%query);
|
||||
query_with(&query, values).execute(&self.sql_pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::domain::error::Result;
|
||||
use crate::domain::{error::Result, handler::UserId};
|
||||
|
||||
#[async_trait]
|
||||
pub trait TcpBackendHandler {
|
||||
async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>>;
|
||||
async fn create_refresh_token(&self, user: &str) -> Result<(String, chrono::Duration)>;
|
||||
async fn check_token(&self, refresh_token_hash: u64, user: &str) -> Result<bool>;
|
||||
async fn blacklist_jwts(&self, user: &str) -> Result<HashSet<u64>>;
|
||||
async fn create_refresh_token(&self, user: &UserId) -> Result<(String, chrono::Duration)>;
|
||||
async fn check_token(&self, refresh_token_hash: u64, user: &UserId) -> Result<bool>;
|
||||
async fn blacklist_jwts(&self, user: &UserId) -> Result<HashSet<u64>>;
|
||||
async fn delete_refresh_token(&self, refresh_token_hash: u64) -> Result<()>;
|
||||
|
||||
/// Request a token to reset a user's password.
|
||||
/// If the user doesn't exist, returns `Ok(None)`, otherwise `Ok(Some(token))`.
|
||||
async fn start_password_reset(&self, user: &str) -> Result<Option<String>>;
|
||||
async fn start_password_reset(&self, user: &UserId) -> Result<Option<String>>;
|
||||
|
||||
/// Get the user ID associated with a password reset token.
|
||||
async fn get_user_id_for_password_reset_token(&self, token: &str) -> Result<String>;
|
||||
async fn get_user_id_for_password_reset_token(&self, token: &str) -> Result<UserId>;
|
||||
|
||||
async fn delete_password_reset_token(&self, token: &str) -> Result<()>;
|
||||
}
|
||||
@@ -35,29 +35,29 @@ mockall::mock! {
|
||||
}
|
||||
#[async_trait]
|
||||
impl BackendHandler for TestTcpBackendHandler {
|
||||
async fn list_users(&self, filters: Option<RequestFilter>) -> Result<Vec<User>>;
|
||||
async fn list_groups(&self) -> Result<Vec<Group>>;
|
||||
async fn get_user_details(&self, user_id: &str) -> Result<User>;
|
||||
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupIdAndName>;
|
||||
async fn get_user_groups(&self, user: &str) -> Result<HashSet<GroupIdAndName>>;
|
||||
async fn list_users(&self, filters: Option<UserRequestFilter>, get_groups: bool) -> Result<Vec<UserAndGroups>>;
|
||||
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>>;
|
||||
async fn get_user_details(&self, user_id: &UserId) -> Result<User>;
|
||||
async fn get_group_details(&self, group_id: GroupId) -> Result<GroupDetails>;
|
||||
async fn get_user_groups(&self, user: &UserId) -> Result<HashSet<GroupDetails>>;
|
||||
async fn create_user(&self, request: CreateUserRequest) -> Result<()>;
|
||||
async fn update_user(&self, request: UpdateUserRequest) -> Result<()>;
|
||||
async fn update_group(&self, request: UpdateGroupRequest) -> Result<()>;
|
||||
async fn delete_user(&self, user_id: &str) -> Result<()>;
|
||||
async fn delete_user(&self, user_id: &UserId) -> Result<()>;
|
||||
async fn create_group(&self, group_name: &str) -> Result<GroupId>;
|
||||
async fn delete_group(&self, group_id: GroupId) -> Result<()>;
|
||||
async fn add_user_to_group(&self, user_id: &str, group_id: GroupId) -> Result<()>;
|
||||
async fn remove_user_from_group(&self, user_id: &str, group_id: GroupId) -> Result<()>;
|
||||
async fn add_user_to_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
|
||||
async fn remove_user_from_group(&self, user_id: &UserId, group_id: GroupId) -> Result<()>;
|
||||
}
|
||||
#[async_trait]
|
||||
impl TcpBackendHandler for TestTcpBackendHandler {
|
||||
async fn get_jwt_blacklist(&self) -> anyhow::Result<HashSet<u64>>;
|
||||
async fn create_refresh_token(&self, user: &str) -> Result<(String, chrono::Duration)>;
|
||||
async fn check_token(&self, refresh_token_hash: u64, user: &str) -> Result<bool>;
|
||||
async fn blacklist_jwts(&self, user: &str) -> Result<HashSet<u64>>;
|
||||
async fn create_refresh_token(&self, user: &UserId) -> Result<(String, chrono::Duration)>;
|
||||
async fn check_token(&self, refresh_token_hash: u64, user: &UserId) -> Result<bool>;
|
||||
async fn blacklist_jwts(&self, user: &UserId) -> Result<HashSet<u64>>;
|
||||
async fn delete_refresh_token(&self, refresh_token_hash: u64) -> Result<()>;
|
||||
async fn start_password_reset(&self, user: &str) -> Result<Option<String>>;
|
||||
async fn get_user_id_for_password_reset_token(&self, token: &str) -> Result<String>;
|
||||
async fn start_password_reset(&self, user: &UserId) -> Result<Option<String>>;
|
||||
async fn get_user_id_for_password_reset_token(&self, token: &str) -> Result<UserId>;
|
||||
async fn delete_password_reset_token(&self, token: &str) -> Result<()>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::{
|
||||
infra::{
|
||||
auth_service,
|
||||
configuration::{Configuration, MailOptions},
|
||||
logging::CustomRootSpanBuilder,
|
||||
tcp_backend_handler::*,
|
||||
},
|
||||
};
|
||||
@@ -14,33 +15,52 @@ use actix_files::{Files, NamedFile};
|
||||
use actix_http::HttpServiceBuilder;
|
||||
use actix_server::ServerBuilder;
|
||||
use actix_service::map_config;
|
||||
use actix_web::{dev::AppConfig, web, App, HttpRequest, HttpResponse};
|
||||
use actix_web::{dev::AppConfig, web, App, HttpResponse};
|
||||
use anyhow::{Context, Result};
|
||||
use hmac::{Hmac, NewMac};
|
||||
use sha2::Sha512;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::RwLock;
|
||||
use tracing::info;
|
||||
|
||||
async fn index(req: HttpRequest) -> actix_web::Result<NamedFile> {
|
||||
async fn index() -> actix_web::Result<NamedFile> {
|
||||
let mut path = PathBuf::new();
|
||||
path.push("app");
|
||||
let file = req.match_info().query("filename");
|
||||
path.push(if file.is_empty() { "index.html" } else { file });
|
||||
path.push("index.html");
|
||||
Ok(NamedFile::open(path)?)
|
||||
}
|
||||
|
||||
pub(crate) fn error_to_http_response(error: DomainError) -> HttpResponse {
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum TcpError {
|
||||
#[error("`{0}`")]
|
||||
DomainError(#[from] DomainError),
|
||||
#[error("Bad request: `{0}`")]
|
||||
BadRequest(String),
|
||||
#[error("Internal server error: `{0}`")]
|
||||
InternalServerError(String),
|
||||
#[error("Unauthorized: `{0}`")]
|
||||
UnauthorizedError(String),
|
||||
}
|
||||
|
||||
pub type TcpResult<T> = std::result::Result<T, TcpError>;
|
||||
|
||||
pub(crate) fn error_to_http_response(error: TcpError) -> HttpResponse {
|
||||
match error {
|
||||
DomainError::AuthenticationError(_) | DomainError::AuthenticationProtocolError(_) => {
|
||||
HttpResponse::Unauthorized()
|
||||
}
|
||||
DomainError::DatabaseError(_)
|
||||
| DomainError::InternalError(_)
|
||||
| DomainError::UnknownCryptoError(_) => HttpResponse::InternalServerError(),
|
||||
DomainError::Base64DecodeError(_) | DomainError::BinarySerializationError(_) => {
|
||||
HttpResponse::BadRequest()
|
||||
}
|
||||
TcpError::DomainError(ref de) => match de {
|
||||
DomainError::AuthenticationError(_) | DomainError::AuthenticationProtocolError(_) => {
|
||||
HttpResponse::Unauthorized()
|
||||
}
|
||||
DomainError::DatabaseError(_)
|
||||
| DomainError::InternalError(_)
|
||||
| DomainError::UnknownCryptoError(_) => HttpResponse::InternalServerError(),
|
||||
DomainError::Base64DecodeError(_) | DomainError::BinarySerializationError(_) => {
|
||||
HttpResponse::BadRequest()
|
||||
}
|
||||
},
|
||||
TcpError::BadRequest(_) => HttpResponse::BadRequest(),
|
||||
TcpError::InternalServerError(_) => HttpResponse::InternalServerError(),
|
||||
TcpError::UnauthorizedError(_) => HttpResponse::Unauthorized(),
|
||||
}
|
||||
.body(error.to_string())
|
||||
}
|
||||
@@ -62,11 +82,6 @@ fn http_config<Backend>(
|
||||
server_url,
|
||||
mail_options,
|
||||
}))
|
||||
// Serve index.html and main.js, and default to index.html.
|
||||
.route(
|
||||
"/{filename:(index\\.html|main\\.js|style\\.css)?}",
|
||||
web::get().to(index),
|
||||
)
|
||||
.service(web::scope("/auth").configure(auth_service::configure_server::<Backend>))
|
||||
// API endpoint.
|
||||
.service(
|
||||
@@ -76,8 +91,16 @@ fn http_config<Backend>(
|
||||
)
|
||||
// Serve the /pkg path with the compiled WASM app.
|
||||
.service(Files::new("/pkg", "./app/pkg"))
|
||||
// Serve static files
|
||||
.service(Files::new("/static", "./app/static"))
|
||||
// Serve static fonts
|
||||
.service(Files::new("/static/fonts", "./app/static/fonts"))
|
||||
// Default to serve index.html for unknown routes, to support routing.
|
||||
.service(web::scope("/").route("/.*", web::get().to(index)));
|
||||
.service(
|
||||
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> {
|
||||
@@ -103,6 +126,7 @@ where
|
||||
.context("while getting the jwt blacklist")?;
|
||||
let server_url = config.http_url.clone();
|
||||
let mail_options = config.smtp_options.clone();
|
||||
info!("Starting the API/web server on port {}", config.http_port);
|
||||
server_builder
|
||||
.bind("http", ("0.0.0.0", config.http_port), move || {
|
||||
let backend_handler = backend_handler.clone();
|
||||
@@ -112,16 +136,18 @@ where
|
||||
let mail_options = mail_options.clone();
|
||||
HttpServiceBuilder::new()
|
||||
.finish(map_config(
|
||||
App::new().configure(move |cfg| {
|
||||
http_config(
|
||||
cfg,
|
||||
backend_handler,
|
||||
jwt_secret,
|
||||
jwt_blacklist,
|
||||
server_url,
|
||||
mail_options,
|
||||
)
|
||||
}),
|
||||
App::new()
|
||||
.wrap(tracing_actix_web::TracingLogger::<CustomRootSpanBuilder>::new())
|
||||
.configure(move |cfg| {
|
||||
http_config(
|
||||
cfg,
|
||||
backend_handler,
|
||||
jwt_secret,
|
||||
jwt_blacklist,
|
||||
server_url,
|
||||
mail_options,
|
||||
)
|
||||
}),
|
||||
|_| AppConfig::default(),
|
||||
))
|
||||
.tcp()
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use crate::{
|
||||
domain::{
|
||||
handler::{BackendHandler, CreateUserRequest},
|
||||
handler::{BackendHandler, CreateUserRequest, GroupRequestFilter},
|
||||
sql_backend_handler::SqlBackendHandler,
|
||||
sql_opaque_handler::register_password,
|
||||
sql_tables::PoolOptions,
|
||||
@@ -12,9 +12,10 @@ use crate::{
|
||||
infra::{cli::*, configuration::Configuration, db_cleaner::Scheduler, mail},
|
||||
};
|
||||
use actix::Actor;
|
||||
use actix_server::ServerBuilder;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use futures_util::TryFutureExt;
|
||||
use log::*;
|
||||
use tracing::*;
|
||||
|
||||
mod domain;
|
||||
mod infra;
|
||||
@@ -45,7 +46,10 @@ async fn create_admin_user(handler: &SqlBackendHandler, config: &Configuration)
|
||||
.context("Error adding admin user to group")
|
||||
}
|
||||
|
||||
async fn run_server(config: Configuration) -> Result<()> {
|
||||
#[instrument(skip_all)]
|
||||
async fn set_up_server(config: Configuration) -> Result<ServerBuilder> {
|
||||
info!("Starting LLDAP version {}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
let sql_pool = PoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&config.database_url)
|
||||
@@ -62,6 +66,23 @@ async fn run_server(config: Configuration) -> Result<()> {
|
||||
.map_err(|e| anyhow!("Error setting up admin login/account: {:#}", e))
|
||||
.context("while creating the admin user")?;
|
||||
}
|
||||
if backend_handler
|
||||
.list_groups(Some(GroupRequestFilter::DisplayName(
|
||||
"lldap_password_manager".to_string(),
|
||||
)))
|
||||
.await?
|
||||
.is_empty()
|
||||
{
|
||||
warn!("Could not find password_manager group, trying to create it");
|
||||
backend_handler
|
||||
.create_group("lldap_password_manager")
|
||||
.await
|
||||
.context("while creating password_manager group")?;
|
||||
backend_handler
|
||||
.create_group("lldap_strict_readonly")
|
||||
.await
|
||||
.context("while creating readonly group")?;
|
||||
}
|
||||
let server_builder = infra::ldap_server::build_ldap_server(
|
||||
&config,
|
||||
backend_handler.clone(),
|
||||
@@ -76,7 +97,12 @@ async fn run_server(config: Configuration) -> Result<()> {
|
||||
// Run every hour.
|
||||
let scheduler = Scheduler::new("0 0 * * * * *", sql_pool);
|
||||
scheduler.start();
|
||||
server_builder
|
||||
Ok(server_builder)
|
||||
}
|
||||
|
||||
async fn run_server(config: Configuration) -> Result<()> {
|
||||
set_up_server(config)
|
||||
.await?
|
||||
.workers(1)
|
||||
.run()
|
||||
.await
|
||||
@@ -90,8 +116,6 @@ fn run_server_command(opts: RunOpts) -> Result<()> {
|
||||
let config = infra::configuration::init(opts)?;
|
||||
infra::logging::init(&config)?;
|
||||
|
||||
info!("Starting LLDAP....");
|
||||
|
||||
actix::run(
|
||||
run_server(config).unwrap_or_else(|e| error!("Could not bring up the servers: {:#}", e)),
|
||||
)?;
|
||||
|
||||
Reference in New Issue
Block a user