Compare commits

..

2 Commits

Author SHA1 Message Date
Valentin Tolmer 8c052c091e fix hipb typo 2023-04-13 09:32:53 +02:00
Valentin Tolmer 278fb1630d server: implement haveibeenpwned endpoint
See #39.
2023-04-04 20:34:37 +02:00
24 changed files with 505 additions and 374 deletions
+81 -116
View File
@@ -84,7 +84,7 @@ jobs:
build-ui: build-ui:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: pre_job needs: pre_job
if: ${{ needs.pre_job.outputs.should_skip != 'true' || github.event_name == 'release' }} if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
container: container:
image: nitnelave/rust-dev:latest image: nitnelave/rust-dev:latest
steps: steps:
@@ -123,7 +123,7 @@ jobs:
build-bin: build-bin:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: pre_job needs: pre_job
if: ${{ needs.pre_job.outputs.should_skip != 'true' || github.event_name == 'release' }} if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
strategy: strategy:
matrix: matrix:
target: [armv7-unknown-linux-gnueabihf, aarch64-unknown-linux-musl, x86_64-unknown-linux-musl] target: [armv7-unknown-linux-gnueabihf, aarch64-unknown-linux-musl, x86_64-unknown-linux-musl]
@@ -180,13 +180,11 @@ jobs:
ports: ports:
- 3306:3306 - 3306:3306
env: env:
MARIADB_USER: lldapuser MYSQL_USER: lldapuser
MARIADB_PASSWORD: lldappass MYSQL_PASSWORD: lldappass
MARIADB_DATABASE: lldap MYSQL_DATABASE: lldap
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1 MYSQL_ROOT_PASSWORD: rootpass
options: >- options: --name mariadb
--name mariadb
--health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
postgresql: postgresql:
image: postgres:latest image: postgres:latest
@@ -196,12 +194,7 @@ jobs:
POSTGRES_USER: lldapuser POSTGRES_USER: lldapuser
POSTGRES_PASSWORD: lldappass POSTGRES_PASSWORD: lldappass
POSTGRES_DB: lldap POSTGRES_DB: lldap
options: >- options: --name postgresql
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
--name postgresql
steps: steps:
- name: Download artifacts - name: Download artifacts
@@ -263,27 +256,17 @@ jobs:
POSTGRES_USER: lldapuser POSTGRES_USER: lldapuser
POSTGRES_PASSWORD: lldappass POSTGRES_PASSWORD: lldappass
POSTGRES_DB: lldap POSTGRES_DB: lldap
options: >- options: --name postgresql
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
--name postgresql
mariadb: mariadb:
image: mariadb:latest image: mariadb:latest
ports: ports:
- 3306:3306 - 3306:3306
env: env:
MARIADB_USER: lldapuser MYSQL_USER: lldapuser
MARIADB_PASSWORD: lldappass MYSQL_PASSWORD: lldappass
MARIADB_DATABASE: lldap MYSQL_DATABASE: lldap
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 1 MYSQL_ROOT_PASSWORD: rootpass
options: >- options: --name mariadb
--name mariadb
--health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
mysql: mysql:
image: mysql:latest image: mysql:latest
ports: ports:
@@ -292,10 +275,8 @@ jobs:
MYSQL_USER: lldapuser MYSQL_USER: lldapuser
MYSQL_PASSWORD: lldappass MYSQL_PASSWORD: lldappass
MYSQL_DATABASE: lldap MYSQL_DATABASE: lldap
MYSQL_ALLOW_EMPTY_PASSWORD: 1 MYSQL_ROOT_PASSWORD: rootpass
options: >- options: --name mysql
--name mysql
--health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
steps: steps:
@@ -333,7 +314,7 @@ jobs:
- name: Create dummy user - name: Create dummy user
run: | run: |
TOKEN=$(curl -X POST -H "Content-Type: application/json" -d '{"username": "admin", "password": "ldappass"}' http://localhost:17170/auth/simple/login | jq -r .token) TOKEN=$(curl -X POST -H "Content-Type: application/json" -d '{"username": "admin", "password": "ldappass"}' http://localhost:17170/auth/simple/login | cut -c 11-277)
echo "$TOKEN" echo "$TOKEN"
curl 'http://localhost:17170/api/graphql' -H 'Content-Type: application/json' -H "Authorization: Bearer ${TOKEN//[$'\t\r\n ']}" --data-binary '{"query":"mutation{\n createUser(user:\n {\n id: \"dummyuser\",\n email: \"dummyuser@example.com\"\n }\n )\n {\n id\n email\n }\n}\n\n\n"}' --compressed curl 'http://localhost:17170/api/graphql' -H 'Content-Type: application/json' -H "Authorization: Bearer ${TOKEN//[$'\t\r\n ']}" --data-binary '{"query":"mutation{\n createUser(user:\n {\n id: \"dummyuser\",\n email: \"dummyuser@example.com\"\n }\n )\n {\n id\n email\n }\n}\n\n\n"}' --compressed
bin/lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password ldappass --token $TOKEN --username dummyuser --password dummypassword bin/lldap_set_password --base-url http://localhost:17170 --admin-username admin --admin-password ldappass --token $TOKEN --username dummyuser --password dummypassword
@@ -347,7 +328,7 @@ jobs:
- name: Export and Converting to Postgress - name: Export and Converting to Postgress
run: | run: |
curl -L https://raw.githubusercontent.com/lldap/lldap/main/scripts/sqlite_dump_commands.sh -o helper.sh curl -L https://raw.githubusercontent.com/nitnelave/lldap/main/scripts/sqlite_dump_commands.sh -o helper.sh
chmod +x ./helper.sh chmod +x ./helper.sh
./helper.sh | sqlite3 ./users.db > ./dump.sql ./helper.sh | sqlite3 ./users.db > ./dump.sql
sed -i -r -e "s/X'([[:xdigit:]]+'[^'])/'\\\x\\1/g" -e '1s/^/BEGIN;\n/' -e '$aCOMMIT;' ./dump.sql sed -i -r -e "s/X'([[:xdigit:]]+'[^'])/'\\\x\\1/g" -e '1s/^/BEGIN;\n/' -e '$aCOMMIT;' ./dump.sql
@@ -365,7 +346,7 @@ jobs:
- name: Export and Converting to mariadb - name: Export and Converting to mariadb
run: | run: |
curl -L https://raw.githubusercontent.com/lldap/lldap/main/scripts/sqlite_dump_commands.sh -o helper.sh curl -L https://raw.githubusercontent.com/nitnelave/lldap/main/scripts/sqlite_dump_commands.sh -o helper.sh
chmod +x ./helper.sh chmod +x ./helper.sh
./helper.sh | sqlite3 ./users.db > ./dump.sql ./helper.sh | sqlite3 ./users.db > ./dump.sql
cp ./dump.sql ./dump-no-sed.sql cp ./dump.sql ./dump-no-sed.sql
@@ -384,7 +365,7 @@ jobs:
- name: Export and Converting to mysql - name: Export and Converting to mysql
run: | run: |
curl -L https://raw.githubusercontent.com/lldap/lldap/main/scripts/sqlite_dump_commands.sh -o helper.sh curl -L https://raw.githubusercontent.com/nitnelave/lldap/main/scripts/sqlite_dump_commands.sh -o helper.sh
chmod +x ./helper.sh chmod +x ./helper.sh
./helper.sh | sqlite3 ./users.db > ./dump.sql ./helper.sh | sqlite3 ./users.db > ./dump.sql
sed -i -r -e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' -e '1s/^/START TRANSACTION;\n/' -e '$aCOMMIT;' ./dump.sql sed -i -r -e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' -e '1s/^/START TRANSACTION;\n/' -e '$aCOMMIT;' ./dump.sql
@@ -444,34 +425,6 @@ jobs:
needs: [build-ui, build-bin] needs: [build-ui, build-bin]
name: Build Docker image name: Build Docker image
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
container: ["debian","alpine"]
include:
- container: alpine
platforms: linux/amd64,linux/arm64
tags: |
type=ref,event=pr
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{version}},suffix=
type=semver,pattern=v{{major}},suffix=
type=semver,pattern=v{{major}}.{{minor}},suffix=
type=raw,value=latest,enable={{ is_default_branch }}
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }},suffix=
type=raw,value=latest,enable={{ is_default_branch }},suffix=
- container: debian
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
type=ref,event=pr
type=semver,pattern=v{{version}}
type=semver,pattern=v{{major}}
type=semver,pattern=v{{major}}.{{minor}}
type=raw,value=latest,enable={{ is_default_branch }}
type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
permissions: permissions:
contents: read contents: read
packages: write packages: write
@@ -493,66 +446,86 @@ jobs:
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v2
- uses: docker/setup-buildx-action@v2 - uses: docker/setup-buildx-action@v2
- name: Docker ${{ matrix.container }} meta - name: Docker meta
id: meta id: meta
uses: docker/metadata-action@v4 uses: docker/metadata-action@v4
with: with:
# list of Docker images to use as base name for tags # list of Docker images to use as base name for tags
images: | images: |
nitnelave/lldap nitnelave/lldap
lldap/lldap # generate Docker tags based on the following events/attributes
ghcr.io/lldap/lldap tags: |
# Wanted Docker tags type=ref,event=branch
# vX-alpine type=ref,event=pr
# vX.Y-alpine type=semver,pattern={{version}}
# vX.Y.Z-alpine type=semver,pattern={{major}}.{{minor}}
# latest type=semver,pattern={{major}}
# latest-alpine type=sha
# stable
# stable-alpine
#################
# vX-debian
# vX.Y-debian
# vX.Y.Z-debian
# latest-debian
# stable-debian
#################
# Check matrix for tag list definition
flavor: |
latest=false
suffix=-${{ matrix.container }}
tags: ${{ matrix.tags }}
# Docker login to nitnelave/lldap and lldap/lldap - name: parse tag
- name: Login to Nitnelave/LLDAP Docker Hub uses: gacts/github-slug@v1
id: slug
- name: Login to Docker Hub
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
registry: ghcr.io
username: nitnelave
password: ${{ secrets.GITHUB_TOKEN }}
######################################## ########################################
#### docker image build #### #### docker image :latest tag build ####
######################################## ########################################
- name: Build ${{ matrix.container }} Docker Image - name: Build and push latest alpine
if: github.event_name != 'release'
uses: docker/build-push-action@v4 uses: docker/build-push-action@v4
with: with:
context: . context: .
push: ${{ github.event_name != 'pull_request' }} push: ${{ github.event_name != 'pull_request' }}
platforms: ${{ matrix.platforms }} platforms: linux/amd64,linux/arm64
file: ./.github/workflows/Dockerfile.ci.${{ matrix.container }} file: ./.github/workflows/Dockerfile.ci.alpine
tags: | tags: nitnelave/lldap:latest, nitnelave/lldap:latest-alpine
${{ steps.meta.outputs.tags }} cache-from: type=gha,mode=max
labels: ${{ steps.meta.outputs.labels }} cache-to: type=gha,mode=max
- name: Build and push latest debian
if: github.event_name != 'release'
uses: docker/build-push-action@v4
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
file: ./.github/workflows/Dockerfile.ci.debian
tags: nitnelave/lldap:latest-debian
cache-from: type=gha,mode=max
cache-to: type=gha,mode=max
########################################
#### docker image :semver tag build ####
########################################
- name: Build and push release alpine
if: github.event_name == 'release'
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
# Tag as latest, stable, semver, major, major.minor and major.minor.patch.
file: ./.github/workflows/Dockerfile.ci.alpine
tags: nitnelave/lldap:stable, nitnelave/lldap:stable-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-alpine.${{ steps.slug.outputs.version-minor }}-alpine, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}-alpine
cache-from: type=gha,mode=max
cache-to: type=gha,mode=max
- name: Build and push release debian
if: github.event_name == 'release'
uses: docker/build-push-action@v4
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.debian
tags: nitnelave/lldap:stable-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-semantic }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}-debian, nitnelave/lldap:v${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}-debian
cache-from: type=gha,mode=max cache-from: type=gha,mode=max
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
@@ -564,14 +537,6 @@ jobs:
password: ${{ secrets.DOCKERHUB_PASSWORD }} password: ${{ secrets.DOCKERHUB_PASSWORD }}
repository: nitnelave/lldap repository: nitnelave/lldap
- name: Update lldap repo description
if: github.event_name != 'pull_request'
uses: peter-evans/dockerhub-description@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
repository: lldap/lldap
############################################################### ###############################################################
### Download artifacts, clean up ui, upload to release page ### ### Download artifacts, clean up ui, upload to release page ###
############################################################### ###############################################################
-7
View File
@@ -101,13 +101,6 @@ jobs:
run: cargo llvm-cov --no-run --lcov --output-path lcov.info run: cargo llvm-cov --no-run --lcov --output-path lcov.info
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
uses: codecov/codecov-action@v3 uses: codecov/codecov-action@v3
if: github.ref != 'refs/heads/main' || github.event_name != 'push'
with:
files: lcov.info
fail_ci_if_error: true
- name: Upload coverage to Codecov (main)
uses: codecov/codecov-action@v3
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
with: with:
files: lcov.info files: lcov.info
fail_ci_if_error: true fail_ci_if_error: true
-29
View File
@@ -5,35 +5,6 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.4.3] 2023-04-11
The repository has changed from `nitnelave/lldap` to `lldap/lldap`, both on GitHub
and on DockerHub (although we will keep publishing the images to
`nitnelave/lldap` for the foreseeable future). All data on GitHub has been
migrated, and the new docker images are available both on DockerHub and on the
GHCR under `lldap/lldap`.
### Added
- EC private keys are not supported for LDAPS.
### Changed
- SMTP user no longer has a default value (and instead defaults to unauthenticated).
### Fixed
- WASM payload is now delivered uncompressed to Safari due to a Safari bug.
- Password reset no longer redirects to login page.
- NextCloud config should add the "mail" attribute.
- GraphQL parameters are now urldecoded, to support special characters in usernames.
- Healthcheck correctly checks the server certificate.
### New services
- Home Assistant
- Shaarli
## [0.4.2] - 2023-03-27 ## [0.4.2] - 2023-03-27
### Added ### Added
Generated
+2 -7
View File
@@ -2404,7 +2404,6 @@ dependencies = [
"tracing-forest", "tracing-forest",
"tracing-log", "tracing-log",
"tracing-subscriber", "tracing-subscriber",
"urlencoding",
"uuid 1.3.0", "uuid 1.3.0",
"webpki-roots", "webpki-roots",
] ]
@@ -2419,6 +2418,7 @@ dependencies = [
"gloo-console", "gloo-console",
"gloo-file", "gloo-file",
"gloo-net", "gloo-net",
"gloo-timers",
"graphql_client 0.10.0", "graphql_client 0.10.0",
"http", "http",
"image", "image",
@@ -2428,6 +2428,7 @@ dependencies = [
"rand 0.8.5", "rand 0.8.5",
"serde", "serde",
"serde_json", "serde_json",
"sha1",
"url-escape", "url-escape",
"validator", "validator",
"validator_derive", "validator_derive",
@@ -4394,12 +4395,6 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "urlencoding"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8db7427f936968176eaa7cdf81b7f98b980b18495ec28f1b5791ac3bfe3eea9"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "0.8.2" version = "0.8.2"
+2 -2
View File
@@ -23,8 +23,8 @@
src="https://img.shields.io/badge/unsafe-forbidden-success.svg" src="https://img.shields.io/badge/unsafe-forbidden-success.svg"
alt="Unsafe forbidden"/> alt="Unsafe forbidden"/>
</a> </a>
<a href="https://app.codecov.io/gh/lldap/lldap"> <a href="https://app.codecov.io/gh/nitnelave/lldap">
<img alt="Codecov" src="https://img.shields.io/codecov/c/github/lldap/lldap" /> <img alt="Codecov" src="https://img.shields.io/codecov/c/github/nitnelave/lldap" />
</a> </a>
</p> </p>
+3 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lldap_app" name = "lldap_app"
version = "0.4.3" version = "0.4.3-alpha"
authors = ["Valentin Tolmer <valentin@tolmer.fr>"] authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
edition = "2021" edition = "2021"
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"] include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
@@ -19,6 +19,7 @@ serde = "1"
serde_json = "1" serde_json = "1"
url-escape = "0.1.1" url-escape = "0.1.1"
validator = "=0.14" validator = "=0.14"
sha1 = "*"
validator_derive = "*" validator_derive = "*"
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
wasm-bindgen-futures = "*" wasm-bindgen-futures = "*"
@@ -27,6 +28,7 @@ yew-router = "0.16"
# Needed because of https://github.com/tkaitchuck/aHash/issues/95 # Needed because of https://github.com/tkaitchuck/aHash/issues/95
indexmap = "=1.6.2" indexmap = "=1.6.2"
gloo-timers = "0.2.6"
[dependencies.web-sys] [dependencies.web-sys]
version = "0.3" version = "0.3"
+2 -3
View File
@@ -1,4 +1,5 @@
use crate::{ use crate::{
components::password_field::PasswordField,
components::router::{AppRoute, Link}, components::router::{AppRoute, Link},
infra::{ infra::{
api::HostService, api::HostService,
@@ -254,14 +255,12 @@ impl Component for ChangePasswordForm {
{":"} {":"}
</label> </label>
<div class="col-sm-10"> <div class="col-sm-10">
<Field <PasswordField<FormModel>
form={&self.form} form={&self.form}
field_name="password" field_name="password"
input_type="password"
class="form-control" class="form-control"
class_invalid="is-invalid has-error" class_invalid="is-invalid has-error"
class_valid="has-success" class_valid="has-success"
autocomplete="new-password"
oninput={link.callback(|_| Msg::FormUpdate)} /> oninput={link.callback(|_| Msg::FormUpdate)} />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("password")} {&self.form.field_message("password")}
+3 -3
View File
@@ -149,9 +149,9 @@ impl Component for LoginForm {
let link = &ctx.link(); let link = &ctx.link();
if self.refreshing { if self.refreshing {
html! { html! {
<div> <div class="spinner-border" role="status">
<img src={"spinner.gif"} alt={"Loading"} /> <span class="sr-only">{"Loading..."}</span>
</div> </div>
} }
} else { } else {
html! { html! {
+1
View File
@@ -10,6 +10,7 @@ pub mod group_details;
pub mod group_table; pub mod group_table;
pub mod login; pub mod login;
pub mod logout; pub mod logout;
pub mod password_field;
pub mod remove_user_from_group; pub mod remove_user_from_group;
pub mod reset_password_step1; pub mod reset_password_step1;
pub mod reset_password_step2; pub mod reset_password_step2;
+152
View File
@@ -0,0 +1,152 @@
use crate::infra::{
api::{hash_password, HostService, PasswordHash, PasswordWasLeaked},
common_component::{CommonComponent, CommonComponentParts},
};
use anyhow::Result;
use gloo_timers::callback::Timeout;
use web_sys::{HtmlInputElement, InputEvent};
use yew::{html, Callback, Classes, Component, Context, Properties};
use yew_form::{Field, Form, Model};
pub enum PasswordFieldMsg {
OnInput(String),
OnInputIdle,
PasswordCheckResult(Result<(Option<PasswordWasLeaked>, PasswordHash)>),
}
#[derive(PartialEq)]
pub enum PasswordState {
// Whether the password was found in a leak.
Checked(PasswordWasLeaked),
// Server doesn't support checking passwords (TODO: move to config).
NotSupported,
// Requested a check, no response yet from the server.
Loading,
// User is still actively typing.
Typing,
}
pub struct PasswordField<FormModel: Model> {
common: CommonComponentParts<Self>,
timeout_task: Option<Timeout>,
password: String,
password_check_state: PasswordState,
_marker: std::marker::PhantomData<FormModel>,
}
impl<FormModel: Model> CommonComponent<PasswordField<FormModel>> for PasswordField<FormModel> {
fn handle_msg(
&mut self,
ctx: &Context<Self>,
msg: <Self as Component>::Message,
) -> anyhow::Result<bool> {
match msg {
PasswordFieldMsg::OnInput(password) => {
self.password = password;
if self.password_check_state != PasswordState::NotSupported {
self.password_check_state = PasswordState::Typing;
if self.password.len() >= 8 {
let link = ctx.link().clone();
self.timeout_task = Some(Timeout::new(500, move || {
link.send_message(PasswordFieldMsg::OnInputIdle)
}));
}
}
}
PasswordFieldMsg::PasswordCheckResult(result) => {
self.timeout_task = None;
// If there's an error from the backend, don't retry.
self.password_check_state = PasswordState::NotSupported;
if let (Some(check), hash) = result? {
if hash == hash_password(&self.password) {
self.password_check_state = PasswordState::Checked(check)
}
}
}
PasswordFieldMsg::OnInputIdle => {
self.timeout_task = None;
if self.password_check_state != PasswordState::NotSupported {
self.password_check_state = PasswordState::Loading;
self.common.call_backend(
ctx,
HostService::check_password_haveibeenpwned(hash_password(&self.password)),
PasswordFieldMsg::PasswordCheckResult,
);
}
}
}
Ok(true)
}
fn mut_common(&mut self) -> &mut CommonComponentParts<PasswordField<FormModel>> {
&mut self.common
}
}
#[derive(Properties, PartialEq, Clone)]
pub struct PasswordFieldProperties<FormModel: Model> {
pub field_name: String,
pub form: Form<FormModel>,
#[prop_or_else(|| { "form-control".into() })]
pub class: Classes,
#[prop_or_else(|| { "is-invalid".into() })]
pub class_invalid: Classes,
#[prop_or_else(|| { "is-valid".into() })]
pub class_valid: Classes,
#[prop_or_else(Callback::noop)]
pub oninput: Callback<String>,
}
impl<FormModel: Model> Component for PasswordField<FormModel> {
type Message = PasswordFieldMsg;
type Properties = PasswordFieldProperties<FormModel>;
fn create(_: &Context<Self>) -> Self {
Self {
common: CommonComponentParts::<Self>::create(),
timeout_task: None,
password: String::new(),
password_check_state: PasswordState::Typing,
_marker: std::marker::PhantomData,
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
CommonComponentParts::<Self>::update(self, ctx, msg)
}
fn view(&self, ctx: &Context<Self>) -> yew::Html {
let link = &ctx.link();
html! {
<div>
<Field<FormModel>
autocomplete={"new-password"}
input_type={"password"}
field_name={ctx.props().field_name.clone()}
form={ctx.props().form.clone()}
class={ctx.props().class.clone()}
class_invalid={ctx.props().class_invalid.clone()}
class_valid={ctx.props().class_valid.clone()}
oninput={link.callback(|e: InputEvent| {
use wasm_bindgen::JsCast;
let target = e.target().unwrap();
let input = target.dyn_into::<HtmlInputElement>().unwrap();
PasswordFieldMsg::OnInput(input.value())
})} />
{
match self.password_check_state {
PasswordState::Checked(PasswordWasLeaked(true)) => html! { <i class="bi bi-x"></i> },
PasswordState::Checked(PasswordWasLeaked(false)) => html! { <i class="bi bi-check"></i> },
PasswordState::NotSupported | PasswordState::Typing => html!{},
PasswordState::Loading =>
html! {
<div class="spinner-border spinner-border-sm" role="status">
<span class="sr-only">{"Loading..."}</span>
</div>
},
}
}
</div>
}
}
}
+5 -4
View File
@@ -1,5 +1,8 @@
use crate::{ use crate::{
components::router::{AppRoute, Link}, components::{
password_field::PasswordField,
router::{AppRoute, Link},
},
infra::{ infra::{
api::HostService, api::HostService,
common_component::{CommonComponent, CommonComponentParts}, common_component::{CommonComponent, CommonComponentParts},
@@ -176,14 +179,12 @@ impl Component for ResetPasswordStep2Form {
{"New password*:"} {"New password*:"}
</label> </label>
<div class="col-sm-10"> <div class="col-sm-10">
<Field <PasswordField<FormModel>
form={&self.form} form={&self.form}
field_name="password" field_name="password"
class="form-control" class="form-control"
class_invalid="is-invalid has-error" class_invalid="is-invalid has-error"
class_valid="has-success" class_valid="has-success"
autocomplete="new-password"
input_type="password"
oninput={link.callback(|_| Msg::FormUpdate)} /> oninput={link.callback(|_| Msg::FormUpdate)} />
<div class="invalid-feedback"> <div class="invalid-feedback">
{&self.form.field_message("password")} {&self.form.field_message("password")}
+45 -1
View File
@@ -1,4 +1,4 @@
use super::cookies::set_cookie; use crate::infra::cookies::set_cookie;
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use gloo_net::http::{Method, Request}; use gloo_net::http::{Method, Request};
use graphql_client::GraphQLQuery; use graphql_client::GraphQLQuery;
@@ -74,6 +74,19 @@ fn set_cookies_from_jwt(response: login::ServerLoginResponse) -> Result<(String,
.context("Error setting cookie") .context("Error setting cookie")
} }
#[derive(PartialEq)]
pub struct PasswordHash(String);
#[derive(PartialEq)]
pub struct PasswordWasLeaked(pub bool);
pub fn hash_password(password: &str) -> PasswordHash {
use sha1::{Digest, Sha1};
let mut hasher = Sha1::new();
hasher.update(password);
PasswordHash(format!("{:X}", hasher.finalize()))
}
impl HostService { impl HostService {
pub async fn graphql_query<QueryType>( pub async fn graphql_query<QueryType>(
variables: QueryType::Variables, variables: QueryType::Variables,
@@ -194,4 +207,35 @@ impl HostService {
!= http::StatusCode::NOT_FOUND, != http::StatusCode::NOT_FOUND,
) )
} }
pub async fn check_password_haveibeenpwned(
password_hash: PasswordHash,
) -> Result<(Option<PasswordWasLeaked>, PasswordHash)> {
use lldap_auth::password_reset::*;
let hash_prefix = &password_hash.0[0..5];
match call_server_json_with_error_message::<PasswordHashList, _>(
&format!("/auth/password/check/{}", hash_prefix),
NO_BODY,
"Could not validate token",
)
.await
{
Ok(r) => {
for PasswordHashCount { hash, count } in r.hashes {
if password_hash.0[5..] == hash && count != 0 {
return Ok((Some(PasswordWasLeaked(true)), password_hash));
}
}
Ok((Some(PasswordWasLeaked(false)), password_hash))
}
Err(e) => {
if e.to_string().contains("[501]:") {
// Unimplemented, no API key.
Ok((None, password_hash))
} else {
Err(e)
}
}
}
}
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

+11
View File
@@ -102,6 +102,17 @@ pub mod password_reset {
pub user_id: String, pub user_id: String,
pub token: String, pub token: String,
} }
#[derive(Serialize, Deserialize, Clone)]
pub struct PasswordHashCount {
pub hash: String,
pub count: u64,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct PasswordHashList {
pub hashes: Vec<PasswordHashCount>,
}
} }
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Serialize, Deserialize)]
-23
View File
@@ -1,23 +0,0 @@
# Home Assistant Configuration
Home Assistant configures ldap auth via the [Command Line Auth Provider](https://www.home-assistant.io/docs/authentication/providers/#command-line). The wiki mentions a script that can be used for LDAP authentication, but it doesn't work in the container version (it is lacking both `ldapsearch` and `curl` ldap protocol support). Thankfully LLDAP has a graphql API to save the day!
## Graphql-based Auth Script
The [auth script](lldap-ha-auth.sh) attempts to authenticate a user against an LLDAP server, using credentials provided via `username` and `password` environment variables. The first argument must be the URL of your LLDAP server, accessible from Home Assistant. You can provide an additional optional argument to confine allowed logins to a single group. The script will output the user's display name as the `name` variable, if not empty.
1. Copy the [auth script](lldap-ha-auth.sh) to your home assistant instance. In this example, we use `/config/lldap-auth.sh`.
2. Add the following to your configuration.yaml in Home assistant:
```yaml
homeassistant:
auth_providers:
# Ensure you have the homeassistant provider enabled if you want to continue using your existing accounts
- type: homeassistant
- type: command_line
command: /config/lldap-auth.sh
# Only allow users in the 'homeassistant_user' group to login.
# Change to ["https://lldap.example.com"] to allow all users
args: ["https://lldap.example.com", "homeassistant_user"]
meta: true
```
3. Reload your config or restart Home Assistant
-70
View File
@@ -1,70 +0,0 @@
#!/bin/bash
# Usernames should be validated using a regular expression to be of
# a known format. Special characters will be escaped anyway, but it is
# generally not recommended to allow more than necessary.
# This pattern is set by default. In your config file, you can either
# overwrite it with a different one or use "unset USERNAME_PATTERN" to
# disable validation completely.
USERNAME_PATTERN='^[a-z|A-Z|0-9|_|-|.]+$'
# When the timeout (in seconds) is exceeded (e.g. due to slow networking),
# authentication fails.
TIMEOUT=3
# Log messages to stderr.
log() {
echo "$1" >&2
}
# Get server address
if [ -z "$1" ]; then
log "Usage: lldap-auth.sh <LLDAP server address> <Optional group to filter>"
exit 2
fi
SERVER_URL="${1%/}"
# Check username and password are present and not malformed.
if [ -z "$username" ] || [ -z "$password" ]; then
log "Need username and password environment variables."
exit 2
elif [ ! -z "$USERNAME_PATTERN" ]; then
username_match=$(echo "$username" | sed -r "s/$USERNAME_PATTERN/x/")
if [ "$username_match" != "x" ]; then
log "Username '$username' has an invalid format."
exit 2
fi
fi
RESPONSE=$(curl -f -s -X POST -m "$TIMEOUT" -H "Content-type: application/json" -d '{"username":"'"$username"'","password":"'"$password"'"}' "$SERVER_URL/auth/simple/login")
if [[ $? -ne 0 ]]; then
log "Auth failed"
exit 1
fi
TOKEN=$(jq -e -r .token <<< $RESPONSE)
if [[ $? -ne 0 ]]; then
log "Failed to parse token"
exit 1
fi
RESPONSE=$(curl -f -s -m "$TIMEOUT" -H "Content-type: application/json" -H "Authorization: Bearer ${TOKEN}" -d '{"variables":{"id":"'"$username"'"},"query":"query($id:String!){user(userId:$id){displayName groups{displayName}}}"}' "$SERVER_URL/api/graphql")
if [[ $? -ne 0 ]]; then
log "Failed to get user"
exit 1
fi
USER_JSON=$(jq -e .data.user <<< $RESPONSE)
if [[ $? -ne 0 ]]; then
log "Failed to parse user json"
exit 1
fi
if [[ ! -z "$2" ]] && ! jq -e '.groups|map(.displayName)|index("'"$2"'")' <<< $USER_JSON > /dev/null 2>&1; then
log "User is not in group '$2'"
exit 1
fi
DISPLAY_NAME=$(jq -r .displayName <<< $USER_JSON)
[[ ! -z "$DISPLAY_NAME" ]] && echo "name = $DISPLAY_NAME"
+4 -9
View File
@@ -2,7 +2,7 @@
authors = ["Valentin Tolmer <valentin@tolmer.fr>"] authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
edition = "2021" edition = "2021"
name = "lldap" name = "lldap"
version = "0.4.3" version = "0.4.3-alpha"
[dependencies] [dependencies]
actix = "0.13" actix = "0.13"
@@ -31,9 +31,8 @@ lber = "0.4.1"
ldap3_proto = ">=0.3.1" ldap3_proto = ">=0.3.1"
log = "*" log = "*"
orion = "0.17" orion = "0.17"
rustls-pemfile = "1" rustls = "0.20"
serde = "*" serde = "*"
serde_bytes = "0.11"
serde_json = "1" serde_json = "1"
sha2 = "0.10" sha2 = "0.10"
thiserror = "*" thiserror = "*"
@@ -45,7 +44,8 @@ tracing = "*"
tracing-actix-web = "0.7" tracing-actix-web = "0.7"
tracing-attributes = "^0.1.21" tracing-attributes = "^0.1.21"
tracing-log = "*" tracing-log = "*"
urlencoding = "2" rustls-pemfile = "1"
serde_bytes = "0.11"
webpki-roots = "*" webpki-roots = "*"
[dependencies.chrono] [dependencies.chrono]
@@ -59,7 +59,6 @@ version = "4"
[dependencies.figment] [dependencies.figment]
features = ["env", "toml"] features = ["env", "toml"]
version = "*" version = "*"
[dependencies.tracing-subscriber] [dependencies.tracing-subscriber]
version = "0.3" version = "0.3"
features = ["env-filter", "tracing-log"] features = ["env-filter", "tracing-log"]
@@ -114,9 +113,5 @@ version = "0.11"
default-features = false default-features = false
features = ["rustls-tls-webpki-roots"] features = ["rustls-tls-webpki-roots"]
[dependencies.rustls]
version = "0.20"
features = ["dangerous_configuration"]
[dev-dependencies] [dev-dependencies]
mockall = "0.11" mockall = "0.11"
+144 -20
View File
@@ -1,21 +1,22 @@
use std::collections::{hash_map::DefaultHasher, HashSet}; use std::collections::{hash_map::DefaultHasher, HashSet};
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::pin::Pin; use std::pin::Pin;
use std::task::{Context, Poll}; use std::task::Poll;
use actix_web::{ use actix_web::{
cookie::{Cookie, SameSite}, cookie::{Cookie, SameSite},
dev::{Service, ServiceRequest, ServiceResponse, Transform}, dev::{Service, ServiceRequest, ServiceResponse, Transform},
error::{ErrorBadRequest, ErrorUnauthorized}, error::{ErrorBadRequest, ErrorUnauthorized},
web, HttpRequest, HttpResponse, web, FromRequest, HttpRequest, HttpResponse,
}; };
use actix_web_httpauth::extractors::bearer::BearerAuth; use actix_web_httpauth::extractors::bearer::BearerAuth;
use anyhow::Result; use anyhow::{bail, Context, Result};
use chrono::prelude::*; use chrono::prelude::*;
use futures::future::{ok, Ready}; use futures::future::{ok, Ready};
use futures_util::FutureExt; use futures_util::FutureExt;
use hmac::Hmac; use hmac::Hmac;
use jwt::{SignWithKey, VerifyWithKey}; use jwt::{SignWithKey, VerifyWithKey};
use secstr::SecUtf8;
use sha2::Sha512; use sha2::Sha512;
use time::ext::NumericalDuration; use time::ext::NumericalDuration;
use tracing::{debug, info, instrument, warn}; use tracing::{debug, info, instrument, warn};
@@ -205,6 +206,24 @@ where
.unwrap_or_else(error_to_http_response) .unwrap_or_else(error_to_http_response)
} }
async fn check_password_reset_token<'a, Backend>(
backend_handler: &Backend,
token: &Option<&'a str>,
) -> TcpResult<Option<(&'a str, UserId)>>
where
Backend: TcpBackendHandler + 'static,
{
let token = match token {
None => return Ok(None),
Some(token) => token,
};
let user_id = backend_handler
.get_user_id_for_password_reset_token(token)
.await
.map_err(|_| TcpError::UnauthorizedError("Invalid or expired token".to_string()))?;
Ok(Some((token, user_id)))
}
#[instrument(skip_all, level = "debug")] #[instrument(skip_all, level = "debug")]
async fn get_password_reset_step2<Backend>( async fn get_password_reset_step2<Backend>(
data: web::Data<AppState<Backend>>, data: web::Data<AppState<Backend>>,
@@ -213,22 +232,12 @@ async fn get_password_reset_step2<Backend>(
where where
Backend: TcpBackendHandler + BackendHandler + 'static, Backend: TcpBackendHandler + BackendHandler + 'static,
{ {
let token = request let tcp_handler = data.get_tcp_handler();
.match_info() let (token, user_id) =
.get("token") check_password_reset_token(tcp_handler, &request.match_info().get("token"))
.ok_or_else(|| TcpError::BadRequest("Missing reset token".to_owned()))?; .await?
let user_id = data .ok_or_else(|| TcpError::BadRequest("Missing token".to_string()))?;
.get_tcp_handler() let _ = tcp_handler.delete_password_reset_token(token).await;
.get_user_id_for_password_reset_token(token)
.await
.map_err(|e| {
debug!("Reset token error: {e:#}");
TcpError::NotFoundError("Wrong or expired reset token".to_owned())
})?;
let _ = data
.get_tcp_handler()
.delete_password_reset_token(token)
.await;
let groups = HashSet::new(); let groups = HashSet::new();
let token = create_jwt(&data.jwt_key, user_id.to_string(), groups); let token = create_jwt(&data.jwt_key, user_id.to_string(), groups);
Ok(HttpResponse::Ok() Ok(HttpResponse::Ok()
@@ -403,6 +412,7 @@ where
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + LoginHandler + 'static, Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + LoginHandler + 'static,
{ {
let user_id = UserId::new(&request.username); let user_id = UserId::new(&request.username);
debug!(?user_id);
let bind_request = BindRequest { let bind_request = BindRequest {
name: user_id.clone(), name: user_id.clone(),
password: request.password.clone(), password: request.password.clone(),
@@ -449,6 +459,115 @@ where
.unwrap_or_else(error_to_http_response) .unwrap_or_else(error_to_http_response)
} }
// Parse the response from the HaveIBeenPwned API. Sample response:
//
// 0018A45C4D1DEF81644B54AB7F969B88D65:1
// 00D4F6E8FA6EECAD2A3AA415EEC418D38EC:2
// 011053FD0102E94D6AE2F8B83D76FAF94F6:13
fn parse_hash_list(response: &str) -> Result<password_reset::PasswordHashList> {
use password_reset::*;
let parse_line = |line: &str| -> Result<PasswordHashCount> {
let split = line.trim().split(':').collect::<Vec<_>>();
if let [hash, count] = &split[..] {
if hash.len() == 35 {
if let Ok(count) = str::parse::<u64>(count) {
return Ok(PasswordHashCount {
hash: hash.to_string(),
count,
});
}
}
}
bail!("Invalid password hash from API: {}", line)
};
Ok(PasswordHashList {
hashes: response
.split('\n')
.map(parse_line)
.collect::<Result<Vec<_>>>()?,
})
}
// TODO: Refactor that for testing.
async fn get_password_hash_list(
hash: &str,
api_key: &SecUtf8,
) -> Result<password_reset::PasswordHashList> {
use reqwest::*;
let client = Client::new();
let resp = client
.get(format!("https://api.pwnedpasswords.com/range/{}", hash))
.header(header::USER_AGENT, "LLDAP")
.header("hibp-api-key", api_key.unsecure())
.send()
.await
.context("Could not get response from HIBP")?
.text()
.await?;
parse_hash_list(&resp).context("Invalid HIBP response")
}
async fn check_password_pwned<Backend>(
data: web::Data<AppState<Backend>>,
request: HttpRequest,
payload: web::Payload,
) -> TcpResult<HttpResponse>
where
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static,
{
let has_reset_token = check_password_reset_token(
data.get_tcp_handler(),
&request
.headers()
.get("reset-token")
.map(|v| v.to_str().unwrap()),
)
.await?
.is_some();
let inner_payload = &mut payload.into_inner();
if !has_reset_token
&& BearerAuth::from_request(&request, inner_payload)
.await
.ok()
.and_then(|bearer| check_if_token_is_valid(&data, bearer.token()).ok())
.is_none()
{
return Err(TcpError::UnauthorizedError(
"No token or invalid token".to_string(),
));
}
if data.hibp_api_key.unsecure().is_empty() {
return Err(TcpError::NotImplemented("No HIBP API key".to_string()));
}
let hash = request
.match_info()
.get("hash")
.ok_or_else(|| TcpError::BadRequest("Missing hash".to_string()))?;
if hash.len() != 5 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(TcpError::BadRequest(format!(
"Bad request: invalid hash format \"{}\"",
hash
)));
}
get_password_hash_list(hash, &data.hibp_api_key)
.await
.map(|hashes| HttpResponse::Ok().json(hashes))
.map_err(|e| TcpError::InternalServerError(e.to_string()))
}
async fn check_password_pwned_handler<Backend>(
data: web::Data<AppState<Backend>>,
request: HttpRequest,
payload: web::Payload,
) -> HttpResponse
where
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + 'static,
{
check_password_pwned(data, request, payload)
.await
.unwrap_or_else(error_to_http_response)
}
#[instrument(skip_all, level = "debug")] #[instrument(skip_all, level = "debug")]
async fn opaque_register_start<Backend>( async fn opaque_register_start<Backend>(
request: actix_web::HttpRequest, request: actix_web::HttpRequest,
@@ -565,7 +684,7 @@ where
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
type Future = Pin<Box<dyn core::future::Future<Output = Result<Self::Response, Self::Error>>>>; type Future = Pin<Box<dyn core::future::Future<Output = Result<Self::Response, Self::Error>>>>;
fn poll_ready(&self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { fn poll_ready(&self, cx: &mut std::task::Context<'_>) -> Poll<Result<(), Self::Error>> {
self.service.poll_ready(cx) self.service.poll_ready(cx)
} }
@@ -636,6 +755,11 @@ where
web::resource("/simple/login").route(web::post().to(simple_login_handler::<Backend>)), web::resource("/simple/login").route(web::post().to(simple_login_handler::<Backend>)),
) )
.service(web::resource("/refresh").route(web::get().to(get_refresh_handler::<Backend>))) .service(web::resource("/refresh").route(web::get().to(get_refresh_handler::<Backend>)))
.service(
web::resource("/password/check/{hash}")
.wrap(CookieToHeaderTranslatorFactory)
.route(web::get().to(check_password_pwned_handler::<Backend>)),
)
.service(web::resource("/logout").route(web::get().to(get_logout_handler::<Backend>))) .service(web::resource("/logout").route(web::get().to(get_logout_handler::<Backend>)))
.service( .service(
web::scope("/opaque/register") web::scope("/opaque/register")
+4
View File
@@ -81,6 +81,10 @@ pub struct RunOpts {
#[clap(short, long, env = "LLDAP_DATABASE_URL")] #[clap(short, long, env = "LLDAP_DATABASE_URL")]
pub database_url: Option<String>, pub database_url: Option<String>,
/// HaveIBeenPwned API key, to check passwords against leaks.
#[clap(long, env = "LLDAP_HIBP_API_KEY")]
pub hibp_api_key: Option<String>,
#[clap(flatten)] #[clap(flatten)]
pub smtp_opts: SmtpOpts, pub smtp_opts: SmtpOpts,
+6
View File
@@ -98,6 +98,8 @@ pub struct Configuration {
pub ldaps_options: LdapsOptions, pub ldaps_options: LdapsOptions,
#[builder(default = r#"String::from("http://localhost")"#)] #[builder(default = r#"String::from("http://localhost")"#)]
pub http_url: String, pub http_url: String,
#[builder(default = r#"SecUtf8::from("")"#)]
pub hibp_api_key: SecUtf8,
#[serde(skip)] #[serde(skip)]
#[builder(field(private), default = "None")] #[builder(field(private), default = "None")]
server_setup: Option<ServerSetup>, server_setup: Option<ServerSetup>,
@@ -213,6 +215,10 @@ impl ConfigOverrider for RunOpts {
if let Some(database_url) = self.database_url.as_ref() { if let Some(database_url) = self.database_url.as_ref() {
config.database_url = database_url.to_string(); config.database_url = database_url.to_string();
} }
if let Some(api_key) = self.hibp_api_key.as_ref() {
config.hibp_api_key = SecUtf8::from(api_key.clone());
}
self.smtp_opts.override_config(config); self.smtp_opts.override_config(config);
self.ldaps_opts.override_config(config); self.ldaps_opts.override_config(config);
} }
-2
View File
@@ -124,12 +124,10 @@ impl<Handler: BackendHandler> Query<Handler> {
} }
pub async fn user(context: &Context<Handler>, user_id: String) -> FieldResult<User<Handler>> { pub async fn user(context: &Context<Handler>, user_id: String) -> FieldResult<User<Handler>> {
use anyhow::Context;
let span = debug_span!("[GraphQL query] user"); let span = debug_span!("[GraphQL query] user");
span.in_scope(|| { span.in_scope(|| {
debug!(?user_id); debug!(?user_id);
}); });
let user_id = urlencoding::decode(&user_id).context("Invalid user parameter")?;
let user_id = UserId::new(&user_id); let user_id = UserId::new(&user_id);
let handler = context let handler = context
.get_readable_handler(&user_id) .get_readable_handler(&user_id)
+14 -49
View File
@@ -1,4 +1,4 @@
use crate::infra::{configuration::LdapsOptions, ldap_server::read_certificates}; use crate::infra::configuration::LdapsOptions;
use anyhow::{anyhow, bail, ensure, Context, Result}; use anyhow::{anyhow, bail, ensure, Context, Result};
use futures_util::SinkExt; use futures_util::SinkExt;
use ldap3_proto::{ use ldap3_proto::{
@@ -65,7 +65,6 @@ where
invalid_answer invalid_answer
); );
info!("Success"); info!("Success");
resp.close().await?;
Ok(()) Ok(())
} }
@@ -86,44 +85,15 @@ fn get_root_certificates() -> rustls::RootCertStore {
root_store root_store
} }
fn get_tls_connector(ldaps_options: &LdapsOptions) -> Result<RustlsTlsConnector> { fn get_tls_connector() -> Result<RustlsTlsConnector> {
let mut client_config = rustls::ClientConfig::builder() use rustls::ClientConfig;
.with_safe_defaults() let client_config = std::sync::Arc::new(
.with_root_certificates(get_root_certificates()) ClientConfig::builder()
.with_no_client_auth(); .with_safe_defaults()
let (certs, _private_key) = read_certificates(ldaps_options)?; .with_root_certificates(get_root_certificates())
// Check that the server cert is the one in the config file. .with_no_client_auth(),
struct CertificateVerifier { );
certificate: rustls::Certificate, Ok(client_config.into())
certificate_path: String,
}
impl rustls::client::ServerCertVerifier for CertificateVerifier {
fn verify_server_cert(
&self,
end_entity: &rustls::Certificate,
_intermediates: &[rustls::Certificate],
_server_name: &rustls::ServerName,
_scts: &mut dyn Iterator<Item = &[u8]>,
_ocsp_response: &[u8],
_now: std::time::SystemTime,
) -> std::result::Result<rustls::client::ServerCertVerified, rustls::Error> {
if end_entity != &self.certificate {
return Err(rustls::Error::InvalidCertificateData(format!(
"Server certificate doesn't match the one in the config file {}",
&self.certificate_path
)));
}
Ok(rustls::client::ServerCertVerified::assertion())
}
}
let mut dangerous_config = rustls::client::DangerousClientConfig {
cfg: &mut client_config,
};
dangerous_config.set_certificate_verifier(std::sync::Arc::new(CertificateVerifier {
certificate: certs.first().expect("empty certificate chain").clone(),
certificate_path: ldaps_options.cert_file.clone(),
}));
Ok(std::sync::Arc::new(client_config).into())
} }
#[instrument(skip_all, level = "info", err)] #[instrument(skip_all, level = "info", err)]
@@ -132,20 +102,15 @@ pub async fn check_ldaps(ldaps_options: &LdapsOptions) -> Result<()> {
info!("LDAPS not enabled"); info!("LDAPS not enabled");
return Ok(()); return Ok(());
}; };
let tls_connector = let tls_connector = get_tls_connector()?;
get_tls_connector(ldaps_options).context("while preparing the tls connection")?;
let url = format!("localhost:{}", ldaps_options.port); let url = format!("localhost:{}", ldaps_options.port);
check_ldap_endpoint( check_ldap_endpoint(
tls_connector tls_connector
.connect( .connect(
rustls::ServerName::try_from("localhost") rustls::ServerName::try_from(url.as_str())?,
.context("while parsing the server name")?, TcpStream::connect(&url).await?,
TcpStream::connect(&url)
.await
.context("while connecting TCP")?,
) )
.await .await?,
.context("while connecting TLS")?,
) )
.await .await
} }
+16 -25
View File
@@ -4,8 +4,7 @@ use crate::{
opaque_handler::OpaqueHandler, opaque_handler::OpaqueHandler,
}, },
infra::{ infra::{
access_control::AccessControlledBackendHandler, access_control::AccessControlledBackendHandler, configuration::Configuration,
configuration::{Configuration, LdapsOptions},
ldap_handler::LdapHandler, ldap_handler::LdapHandler,
}, },
}; };
@@ -95,7 +94,7 @@ where
} }
fn read_private_key(key_file: &str) -> Result<PrivateKey> { fn read_private_key(key_file: &str) -> Result<PrivateKey> {
use rustls_pemfile::{ec_private_keys, pkcs8_private_keys, rsa_private_keys}; use rustls_pemfile::{pkcs8_private_keys, rsa_private_keys};
use std::{fs::File, io::BufReader}; use std::{fs::File, io::BufReader};
pkcs8_private_keys(&mut BufReader::new(File::open(key_file)?)) pkcs8_private_keys(&mut BufReader::new(File::open(key_file)?))
.map_err(anyhow::Error::from) .map_err(anyhow::Error::from)
@@ -113,36 +112,29 @@ fn read_private_key(key_file: &str) -> Result<PrivateKey> {
.ok_or_else(|| anyhow!("No PKCS1 key")) .ok_or_else(|| anyhow!("No PKCS1 key"))
}) })
}) })
.or_else(|_| {
ec_private_keys(&mut BufReader::new(File::open(key_file)?))
.map_err(anyhow::Error::from)
.and_then(|keys| keys.into_iter().next().ok_or_else(|| anyhow!("No EC key")))
})
.with_context(|| { .with_context(|| {
format!( format!(
"Cannot read either PKCS1, PKCS8 or EC private key from {}", "Cannot read either PKCS1 or PKCS8 private key from {}",
key_file key_file
) )
}) })
.map(rustls::PrivateKey) .map(rustls::PrivateKey)
} }
pub fn read_certificates( fn get_tls_acceptor(config: &Configuration) -> Result<RustlsTlsAcceptor> {
ldaps_options: &LdapsOptions, use rustls::{Certificate, ServerConfig};
) -> Result<(Vec<rustls::Certificate>, rustls::PrivateKey)> { use rustls_pemfile::certs;
use std::{fs::File, io::BufReader}; use std::{fs::File, io::BufReader};
let certs = rustls_pemfile::certs(&mut BufReader::new(File::open(&ldaps_options.cert_file)?))? // Load TLS key and cert files
.into_iter() let certs = certs(&mut BufReader::new(File::open(
.map(rustls::Certificate) &config.ldaps_options.cert_file,
.collect::<Vec<_>>(); )?))?
let private_key = read_private_key(&ldaps_options.key_file)?; .into_iter()
Ok((certs, private_key)) .map(Certificate)
} .collect::<Vec<_>>();
let private_key = read_private_key(&config.ldaps_options.key_file)?;
fn get_tls_acceptor(ldaps_options: &LdapsOptions) -> Result<RustlsTlsAcceptor> {
let (certs, private_key) = read_certificates(ldaps_options)?;
let server_config = std::sync::Arc::new( let server_config = std::sync::Arc::new(
rustls::ServerConfig::builder() ServerConfig::builder()
.with_safe_defaults() .with_safe_defaults()
.with_no_client_auth() .with_no_client_auth()
.with_single_cert(certs, private_key)?, .with_single_cert(certs, private_key)?,
@@ -193,8 +185,7 @@ where
if config.ldaps_options.enabled { if config.ldaps_options.enabled {
let tls_context = ( let tls_context = (
context_for_tls, context_for_tls,
get_tls_acceptor(&config.ldaps_options) get_tls_acceptor(config).context("while setting up the SSL certificate")?,
.context("while setting up the SSL certificate")?,
); );
let tls_binder = move || { let tls_binder = move || {
let tls_context = tls_context.clone(); let tls_context = tls_context.clone();
+10 -3
View File
@@ -19,6 +19,7 @@ use actix_service::map_config;
use actix_web::{dev::AppConfig, guard, web, App, HttpResponse, Responder}; use actix_web::{dev::AppConfig, guard, web, App, HttpResponse, Responder};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use hmac::Hmac; use hmac::Hmac;
use secstr::SecUtf8;
use sha2::Sha512; use sha2::Sha512;
use std::collections::HashSet; use std::collections::HashSet;
use std::path::PathBuf; use std::path::PathBuf;
@@ -38,10 +39,10 @@ pub enum TcpError {
BadRequest(String), BadRequest(String),
#[error("Internal server error: `{0}`")] #[error("Internal server error: `{0}`")]
InternalServerError(String), InternalServerError(String),
#[error("Not found: `{0}`")]
NotFoundError(String),
#[error("Unauthorized: `{0}`")] #[error("Unauthorized: `{0}`")]
UnauthorizedError(String), UnauthorizedError(String),
#[error("Not implemented: `{0}`")]
NotImplemented(String),
} }
pub type TcpResult<T> = std::result::Result<T, TcpError>; pub type TcpResult<T> = std::result::Result<T, TcpError>;
@@ -60,9 +61,9 @@ pub(crate) fn error_to_http_response(error: TcpError) -> HttpResponse {
| DomainError::EntityNotFound(_) => HttpResponse::BadRequest(), | DomainError::EntityNotFound(_) => HttpResponse::BadRequest(),
}, },
TcpError::BadRequest(_) => HttpResponse::BadRequest(), TcpError::BadRequest(_) => HttpResponse::BadRequest(),
TcpError::NotFoundError(_) => HttpResponse::NotFound(),
TcpError::InternalServerError(_) => HttpResponse::InternalServerError(), TcpError::InternalServerError(_) => HttpResponse::InternalServerError(),
TcpError::UnauthorizedError(_) => HttpResponse::Unauthorized(), TcpError::UnauthorizedError(_) => HttpResponse::Unauthorized(),
TcpError::NotImplemented(_) => HttpResponse::NotImplemented(),
} }
.body(error.to_string()) .body(error.to_string())
} }
@@ -88,6 +89,7 @@ fn http_config<Backend>(
jwt_blacklist: HashSet<u64>, jwt_blacklist: HashSet<u64>,
server_url: String, server_url: String,
mail_options: MailOptions, mail_options: MailOptions,
hibp_api_key: SecUtf8,
) where ) where
Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Clone + 'static, Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Clone + 'static,
{ {
@@ -98,6 +100,7 @@ fn http_config<Backend>(
jwt_blacklist: RwLock::new(jwt_blacklist), jwt_blacklist: RwLock::new(jwt_blacklist),
server_url, server_url,
mail_options, mail_options,
hibp_api_key,
})) }))
.route( .route(
"/health", "/health",
@@ -133,6 +136,7 @@ pub(crate) struct AppState<Backend> {
pub jwt_blacklist: RwLock<HashSet<u64>>, pub jwt_blacklist: RwLock<HashSet<u64>>,
pub server_url: String, pub server_url: String,
pub mail_options: MailOptions, pub mail_options: MailOptions,
pub hibp_api_key: SecUtf8,
} }
impl<Backend: BackendHandler> AppState<Backend> { impl<Backend: BackendHandler> AppState<Backend> {
@@ -173,6 +177,7 @@ where
let mail_options = config.smtp_options.clone(); let mail_options = config.smtp_options.clone();
let verbose = config.verbose; let verbose = config.verbose;
info!("Starting the API/web server on port {}", config.http_port); info!("Starting the API/web server on port {}", config.http_port);
let hibp_api_key = config.hibp_api_key.clone();
server_builder server_builder
.bind( .bind(
"http", "http",
@@ -183,6 +188,7 @@ where
let jwt_blacklist = jwt_blacklist.clone(); let jwt_blacklist = jwt_blacklist.clone();
let server_url = server_url.clone(); let server_url = server_url.clone();
let mail_options = mail_options.clone(); let mail_options = mail_options.clone();
let hibp_api_key = hibp_api_key.clone();
HttpServiceBuilder::default() HttpServiceBuilder::default()
.finish(map_config( .finish(map_config(
App::new() App::new()
@@ -198,6 +204,7 @@ where
jwt_blacklist, jwt_blacklist,
server_url, server_url,
mail_options, mail_options,
hibp_api_key,
) )
}), }),
|_| AppConfig::default(), |_| AppConfig::default(),