21 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
Valentin Tolmer
86b2b5148d server: remove default value for SMTP user
Otherwise, not setting the user would default to "admin", which breaks
the unauthenticated workflow. No user specified should mean unauthenticated.

Fixes #520.
2023-04-04 16:27:44 +02:00
Valentin Tolmer
b9e0e4a6dc version: bump cargo.lock 2023-04-04 16:27:44 +02:00
nitnelave
1b8849ead1 version: bump to 0.4.3-alpha (#522) 2023-04-04 13:00:17 +02:00
amiga23
1fe635384f docs: Add email attribute to nextcloud config
Otherwise nextcloud will not set the email address in users profile
2023-04-04 12:14:41 +02:00
Hobbabobba
df16d66753 added Shaarli configuration example (#519)
* Create shaarli.md

* added Shaarli doc

* fixed uid
2023-04-03 18:54:39 +02:00
nitnelave
65e2c24928 github: Add CODEOWNERS 2023-03-31 10:42:53 +02:00
Austin Alvarado
c4b8621e2a app: Fix password reset redirection (#513)
* Fix password reset redirection
* Add password reset enable flag
2023-03-30 09:47:41 -06:00
Valentin Tolmer
88a9f8a97b github: fix github_ref reference 2023-03-28 20:59:38 +02:00
Valentin Tolmer
fc91d59b99 github: Don't skip rebuilding a docker image on main because it was built on a branch 2023-03-28 19:34:43 +02:00
Valentin Tolmer
aad4711056 app: server uncompressed WASM to webkit browsers 2023-03-28 17:33:13 +02:00
Dedy Martadinata S
c7c6d95334 docker: Add DB migration tests in the CI 2023-03-28 13:59:23 +02:00
Valentin Tolmer
84b4c66309 cargo: Update Cargo.lock with latest release 2023-03-28 12:10:04 +02:00
Valentin Tolmer
923d77072b gitattributes: Tag folders as docs, generated or ignored for linguist 2023-03-28 12:10:04 +02:00
Austin Alvarado
758aa7f7f7 docs: Fix md links 2023-03-27 18:08:27 +02:00
Valentin Tolmer
866a74fa29 github: Reduce actions trigger on metadata updates 2023-03-27 16:52:34 +02:00
Valentin Tolmer
36a51070b3 docker: ignore README 2023-03-27 16:52:34 +02:00
Valentin Tolmer
585b65e11d README: Add details about other DBs, migrations 2023-03-27 14:12:00 +02:00
Valentin Tolmer
2c8fe2a481 Revert "workflows: allow action to upload artifacts"
This reverts commit 1b67bad270.
2023-03-27 13:53:21 +02:00
Valentin Tolmer
1b67bad270 workflows: allow action to upload artifacts 2023-03-27 12:45:11 +02:00
28 changed files with 686 additions and 75 deletions

View File

@@ -2,6 +2,7 @@
.git/*
.github/*
.gitignore
.gitattributes
# Don't track cargo generated files
target/*
@@ -17,6 +18,7 @@ Dockerfile
*.md
LICENSE
CHANGELOG.md
README.md
docs/*
example_configs/*

10
.gitattributes vendored Normal file
View File

@@ -0,0 +1,10 @@
example-configs/** linguist-documentation
docs/** linguist-documentation
*.md linguist-documentation
lldap_config.docker_template.toml linguist-documentation
schema.graphql linguist-generated
.github/** -linguist-detectable
.devcontainer/** -linguist-detectable
.config/** -linguist-detectable

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @nitnelave

View File

@@ -4,12 +4,18 @@ on:
push:
branches:
- 'main'
paths-ignore:
- 'docs/**'
- 'example_configs/**'
release:
types:
- 'published'
pull_request:
branches:
- 'main'
paths-ignore:
- 'docs/**'
- 'example_configs/**'
workflow_dispatch:
inputs:
msg:
@@ -60,8 +66,25 @@ env:
# cache based on Cargo.lock per cargo target
jobs:
pre_job:
continue-on-error: true
runs-on: ubuntu-latest
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: ${{ github.ref != 'refs/heads/main' }}
paths_ignore: '["**/*.md", "**/docs/**", "example_configs/**", "*.sh", ".gitignore", "lldap_config.docker_template.toml"]'
do_not_skip: '["workflow_dispatch", "schedule"]'
cancel_others: true
build-ui:
runs-on: ubuntu-latest
needs: pre_job
if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
container:
image: nitnelave/rust-dev:latest
steps:
@@ -99,6 +122,8 @@ jobs:
build-bin:
runs-on: ubuntu-latest
needs: pre_job
if: ${{ needs.pre_job.outputs.should_skip != 'true' }}
strategy:
matrix:
target: [armv7-unknown-linux-gnueabihf, aarch64-unknown-linux-musl, x86_64-unknown-linux-musl]
@@ -145,9 +170,9 @@ jobs:
name: ${{ matrix.target }}-lldap_set_password-bin
path: target/${{ matrix.target }}/release/lldap_set_password
lldap-database-integration-test:
lldap-database-init-test:
needs: [build-ui,build-bin]
name: LLDAP test
name: LLDAP database init test
runs-on: ubuntu-latest
services:
mariadb:
@@ -159,6 +184,7 @@ jobs:
MYSQL_PASSWORD: lldappass
MYSQL_DATABASE: lldap
MYSQL_ROOT_PASSWORD: rootpass
options: --name mariadb
postgresql:
image: postgres:latest
@@ -168,6 +194,7 @@ jobs:
POSTGRES_USER: lldapuser
POSTGRES_PASSWORD: lldappass
POSTGRES_DB: lldap
options: --name postgresql
steps:
- name: Download artifacts
@@ -175,8 +202,7 @@ jobs:
with:
name: x86_64-unknown-linux-musl-lldap-bin
path: bin/
- name: Where is the bin?
run: ls -alR bin
- name: Set executables to LLDAP
run: chmod +x bin/lldap
@@ -212,6 +238,188 @@ jobs:
LLDAP_ldap_port: 3892
LLDAP_http_port: 17172
- name: Check DB container logs
run: |
docker logs -n 20 mariadb
docker logs -n 20 postgresql
lldap-database-migration-test:
needs: [build-ui,build-bin]
name: LLDAP database migration test
runs-on: ubuntu-latest
services:
postgresql:
image: postgres:latest
ports:
- 5432:5432
env:
POSTGRES_USER: lldapuser
POSTGRES_PASSWORD: lldappass
POSTGRES_DB: lldap
options: --name postgresql
mariadb:
image: mariadb:latest
ports:
- 3306:3306
env:
MYSQL_USER: lldapuser
MYSQL_PASSWORD: lldappass
MYSQL_DATABASE: lldap
MYSQL_ROOT_PASSWORD: rootpass
options: --name mariadb
mysql:
image: mysql:latest
ports:
- 3307:3306
env:
MYSQL_USER: lldapuser
MYSQL_PASSWORD: lldappass
MYSQL_DATABASE: lldap
MYSQL_ROOT_PASSWORD: rootpass
options: --name mysql
steps:
- name: Download LLDAP artifacts
uses: actions/download-artifact@v3
with:
name: x86_64-unknown-linux-musl-lldap-bin
path: bin/
- name: Download LLDAP set password
uses: actions/download-artifact@v3
with:
name: x86_64-unknown-linux-musl-lldap_set_password-bin
path: bin/
- name: Set executables to LLDAP and LLDAP set password
run: |
chmod +x bin/lldap
chmod +x bin/lldap_set_password
- name: Install sqlite3 and ldap-utils for exporting and searching dummy user
run: sudo apt update && sudo apt install -y sqlite3 ldap-utils
- name: Run lldap with sqlite DB and healthcheck
run: |
bin/lldap run &
sleep 10s
bin/lldap healthcheck
env:
LLDAP_database_url: sqlite://users.db?mode=rwc
LLDAP_ldap_port: 3890
LLDAP_http_port: 17170
LLDAP_LDAP_USER_PASS: ldappass
LLDAP_JWT_SECRET: somejwtsecret
- name: Create dummy user
run: |
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"
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
- name: Test Dummy User, This will be checked again after importing
run: |
ldapsearch -H ldap://localhost:3890 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
- name: Stop LLDAP sqlite
run: pkill lldap
- name: Export and Converting to Postgress
run: |
curl -L https://raw.githubusercontent.com/nitnelave/lldap/main/scripts/sqlite_dump_commands.sh -o helper.sh
chmod +x ./helper.sh
./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
- name: Create schema on postgres
run: |
bin/lldap create_schema -d postgres://lldapuser:lldappass@localhost:5432/lldap
- name: Copy converted db to postgress and import
run: |
docker ps -a
docker cp ./dump.sql postgresql:/tmp/dump.sql
docker exec postgresql bash -c "psql -U lldapuser -d lldap < /tmp/dump.sql"
rm ./dump.sql
- name: Export and Converting to mariadb
run: |
curl -L https://raw.githubusercontent.com/nitnelave/lldap/main/scripts/sqlite_dump_commands.sh -o helper.sh
chmod +x ./helper.sh
./helper.sh | sqlite3 ./users.db > ./dump.sql
cp ./dump.sql ./dump-no-sed.sql
sed -i -r -e "s/([^']'[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{9})\+00:00'([^'])/\1'\2/g" \-e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' -e '1s/^/START TRANSACTION;\n/' -e '$aCOMMIT;' ./dump.sql
sed -i '1 i\SET FOREIGN_KEY_CHECKS = 0;' ./dump.sql
- name: Create schema on mariadb
run: bin/lldap create_schema -d mysql://lldapuser:lldappass@localhost:3306/lldap
- name: Copy converted db to mariadb and import
run: |
docker ps -a
docker cp ./dump.sql mariadb:/tmp/dump.sql
docker exec mariadb bash -c "mariadb -ulldapuser -plldappass -f lldap < /tmp/dump.sql"
rm ./dump.sql
- name: Export and Converting to mysql
run: |
curl -L https://raw.githubusercontent.com/nitnelave/lldap/main/scripts/sqlite_dump_commands.sh -o helper.sh
chmod +x ./helper.sh
./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 '1 i\SET FOREIGN_KEY_CHECKS = 0;' ./dump.sql
- name: Create schema on mysql
run: bin/lldap create_schema -d mysql://lldapuser:lldappass@localhost:3307/lldap
- name: Copy converted db to mysql and import
run: |
docker ps -a
docker cp ./dump.sql mysql:/tmp/dump.sql
docker exec mysql bash -c "mysql -ulldapuser -plldappass -f lldap < /tmp/dump.sql"
rm ./dump.sql
- name: Run lldap with postgres DB and healthcheck again
run: |
bin/lldap run &
sleep 10s
bin/lldap healthcheck
env:
LLDAP_database_url: postgres://lldapuser:lldappass@localhost:5432/lldap
LLDAP_ldap_port: 3891
LLDAP_http_port: 17171
LLDAP_LDAP_USER_PASS: ldappass
LLDAP_JWT_SECRET: somejwtsecret
- name: Run lldap with mariaDB and healthcheck again
run: |
bin/lldap run &
sleep 10s
bin/lldap healthcheck
env:
LLDAP_database_url: mysql://lldapuser:lldappass@localhost:3306/lldap
LLDAP_ldap_port: 3892
LLDAP_http_port: 17172
LLDAP_JWT_SECRET: somejwtsecret
- name: Run lldap with mysql and healthcheck again
run: |
bin/lldap run &
sleep 10s
bin/lldap healthcheck
env:
LLDAP_database_url: mysql://lldapuser:lldappass@localhost:3307/lldap
LLDAP_ldap_port: 3893
LLDAP_http_port: 17173
LLDAP_JWT_SECRET: somejwtsecret
- name: Test Dummy User
run: |
ldapsearch -H ldap://localhost:3891 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
ldapsearch -H ldap://localhost:3892 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
ldapsearch -H ldap://localhost:3893 -LLL -D "uid=dummyuser,ou=people,dc=example,dc=com" -w 'dummypassword' -s "One" -b "ou=people,dc=example,dc=com"
build-docker-image:
needs: [build-ui, build-bin]
@@ -337,6 +545,8 @@ jobs:
name: Create release artifacts
if: github.event_name == 'release'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v3

View File

@@ -13,7 +13,6 @@ 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:
@@ -22,7 +21,7 @@ jobs:
with:
concurrent_skipping: 'outdated_runs'
skip_after_successful_duplicate: 'true'
paths_ignore: '["**/*.md", "**/docs/**", "example_configs/**", "*.sh"]'
paths_ignore: '["**/*.md", "**/docs/**", "example_configs/**", "*.sh", ".dockerignore", ".gitignore", "lldap_config.docker_template.toml", "Dockerfile"]'
do_not_skip: '["workflow_dispatch", "schedule"]'
cancel_others: true

22
Cargo.lock generated
View File

@@ -2345,7 +2345,7 @@ checksum = "f051f77a7c8e6957c0696eac88f26b0117e54f52d3fc682ab19397a8812846a4"
[[package]]
name = "lldap"
version = "0.4.2-alpha"
version = "0.4.3-alpha"
dependencies = [
"actix",
"actix-files",
@@ -2404,13 +2404,13 @@ dependencies = [
"tracing-forest",
"tracing-log",
"tracing-subscriber",
"uuid 0.8.2",
"uuid 1.3.0",
"webpki-roots",
]
[[package]]
name = "lldap_app"
version = "0.4.2-alpha"
version = "0.4.3-alpha"
dependencies = [
"anyhow",
"base64 0.13.1",
@@ -2418,6 +2418,7 @@ dependencies = [
"gloo-console",
"gloo-file",
"gloo-net",
"gloo-timers",
"graphql_client 0.10.0",
"http",
"image",
@@ -2427,6 +2428,7 @@ dependencies = [
"rand 0.8.5",
"serde",
"serde_json",
"sha1",
"url-escape",
"validator",
"validator_derive",
@@ -2441,7 +2443,7 @@ dependencies = [
[[package]]
name = "lldap_auth"
version = "0.3.0-alpha.1"
version = "0.3.0"
dependencies = [
"chrono",
"curve25519-dalek",
@@ -2530,12 +2532,6 @@ dependencies = [
"digest 0.10.6",
]
[[package]]
name = "md5"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
[[package]]
name = "memchr"
version = "2.5.0"
@@ -2544,7 +2540,7 @@ checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
[[package]]
name = "migration-tool"
version = "0.4.2-alpha"
version = "0.4.2"
dependencies = [
"anyhow",
"base64 0.13.1",
@@ -4404,9 +4400,6 @@ name = "uuid"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
dependencies = [
"md5",
]
[[package]]
name = "uuid"
@@ -4415,6 +4408,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1674845326ee10d37ca60470760d4288a6f80f304007d92e5c53bab78c9cfd79"
dependencies = [
"getrandom 0.2.8",
"md-5",
]
[[package]]

View File

@@ -77,6 +77,9 @@ For more features (OAuth/OpenID support, reverse proxy, ...) you can install
other components (KeyCloak, Authelia, ...) using this server as the source of
truth for users, via LDAP.
By default, the data is stored in SQLite, but you can swap the backend with
MySQL/MariaDB or PostgreSQL.
## Installation
### With Docker
@@ -268,6 +271,7 @@ folder for help with:
- [Portainer](example_configs/portainer.md)
- [Rancher](example_configs/rancher.md)
- [Seafile](example_configs/seafile.md)
- [Shaarli](example_configs/shaarli.md)
- [Syncthing](example_configs/syncthing.md)
- [Vaultwarden](example_configs/vaultwarden.md)
- [WeKan](example_configs/wekan.md)
@@ -276,6 +280,12 @@ folder for help with:
- [XBackBone](example_configs/xbackbone_config.php)
- [Zendto](example_configs/zendto.md)
## Migrating from SQLite
If you started with an SQLite database and would like to migrate to
MySQL/MariaDB or PostgreSQL, check out the [DB
migration docs](/docs/database_migration.md).
## Comparisons with other services
### vs OpenLDAP

View File

@@ -1,6 +1,6 @@
[package]
name = "lldap_app"
version = "0.4.2"
version = "0.4.3-alpha"
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
edition = "2021"
include = ["src/**/*", "queries/**/*", "Cargo.toml", "../schema.graphql"]
@@ -19,6 +19,7 @@ serde = "1"
serde_json = "1"
url-escape = "0.1.1"
validator = "=0.14"
sha1 = "*"
validator_derive = "*"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "*"
@@ -27,6 +28,7 @@ yew-router = "0.16"
# Needed because of https://github.com/tkaitchuck/aHash/issues/95
indexmap = "=1.6.2"
gloo-timers = "0.2.6"
[dependencies.web-sys]
version = "0.3"

View File

@@ -14,4 +14,4 @@ fi
wasm-pack build --target web --release
gzip -9 -f pkg/lldap_app_bg.wasm
gzip -9 -k -f pkg/lldap_app_bg.wasm

View File

@@ -177,7 +177,13 @@ impl App {
Some(AppRoute::StartResetPassword | AppRoute::FinishResetPassword { token: _ }),
_,
_,
) if self.password_reset_enabled == Some(false) => Some(AppRoute::Login),
) => {
if self.password_reset_enabled == Some(false) {
Some(AppRoute::Login)
} else {
None
}
}
(None, _, _) | (_, None, _) => Some(AppRoute::Login),
// User is logged in, a URL was given, don't redirect.
(_, Some(_), Some(_)) => None,

View File

@@ -1,4 +1,5 @@
use crate::{
components::password_field::PasswordField,
components::router::{AppRoute, Link},
infra::{
api::HostService,
@@ -254,14 +255,12 @@ impl Component for ChangePasswordForm {
{":"}
</label>
<div class="col-sm-10">
<Field
<PasswordField<FormModel>
form={&self.form}
field_name="password"
input_type="password"
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="new-password"
oninput={link.callback(|_| Msg::FormUpdate)} />
<div class="invalid-feedback">
{&self.form.field_message("password")}

View File

@@ -149,9 +149,9 @@ impl Component for LoginForm {
let link = &ctx.link();
if self.refreshing {
html! {
<div>
<img src={"spinner.gif"} alt={"Loading"} />
</div>
<div class="spinner-border" role="status">
<span class="sr-only">{"Loading..."}</span>
</div>
}
} else {
html! {

View File

@@ -10,6 +10,7 @@ pub mod group_details;
pub mod group_table;
pub mod login;
pub mod logout;
pub mod password_field;
pub mod remove_user_from_group;
pub mod reset_password_step1;
pub mod reset_password_step2;

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>
}
}
}

View File

@@ -1,5 +1,8 @@
use crate::{
components::router::{AppRoute, Link},
components::{
password_field::PasswordField,
router::{AppRoute, Link},
},
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
@@ -176,14 +179,12 @@ impl Component for ResetPasswordStep2Form {
{"New password*:"}
</label>
<div class="col-sm-10">
<Field
<PasswordField<FormModel>
form={&self.form}
field_name="password"
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="new-password"
input_type="password"
oninput={link.callback(|_| Msg::FormUpdate)} />
<div class="invalid-feedback">
{&self.form.field_message("password")}

View File

@@ -1,4 +1,4 @@
use super::cookies::set_cookie;
use crate::infra::cookies::set_cookie;
use anyhow::{anyhow, Context, Result};
use gloo_net::http::{Method, Request};
use graphql_client::GraphQLQuery;
@@ -74,6 +74,19 @@ fn set_cookies_from_jwt(response: login::ServerLoginResponse) -> Result<(String,
.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 {
pub async fn graphql_query<QueryType>(
variables: QueryType::Variables,
@@ -194,4 +207,35 @@ impl HostService {
!= 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)
}
}
}
}
}

View File

@@ -1,6 +1,10 @@
import init, { run_app } from '/pkg/lldap_app.js';
async function main() {
await init('/pkg/lldap_app_bg.wasm');
run_app();
if(navigator.userAgent.indexOf('AppleWebKit') != -1) {
await init('/pkg/lldap_app_bg.wasm');
} else {
await init('/pkg/lldap_app_bg.wasm.gz');
}
run_app();
}
main()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -102,6 +102,17 @@ pub mod password_reset {
pub user_id: 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)]

View File

@@ -26,9 +26,9 @@ Frontend:
Data storage:
* The data (users, groups, memberships, active JWTs, ...) is stored in SQL.
* Currently only SQLite is supported (see
https://github.com/launchbadge/sqlx/issues/1225 for what blocks us from
supporting more SQL backends).
* The main SQL DBs are supported: SQLite by default, MySQL, MariaDB, PostgreSQL
(see [DB Migration](/database_migration.md) for how to migrate off of
SQLite).
### Code organization

View File

@@ -65,7 +65,8 @@ a transaction:
```
sed -i -r -e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' \
-e '1s/^/START TRANSACTION;\n/' \
-e '$aCOMMIT;' /path/to/dump.sql
-e '$aCOMMIT;' \
-e '1 i\SET FOREIGN_KEY_CHECKS = 0;' /path/to/dump.sql
```
### To MariaDB
@@ -77,7 +78,8 @@ strings. Use the following command to remove those and perform the additional My
sed -i -r -e "s/([^']'[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{9})\+00:00'([^'])/\1'\2/g" \
-e 's/^INSERT INTO "?([a-zA-Z0-9_]+)"?/INSERT INTO `\1`/' \
-e '1s/^/START TRANSACTION;\n/' \
-e '$aCOMMIT;' /path/to/dump.sql
-e '$aCOMMIT;' \
-e '1 i\SET FOREIGN_KEY_CHECKS = 0;' /path/to/dump.sql
```
## Insert data
@@ -102,4 +104,6 @@ or
Modify your `database_url` in `lldap_config.toml` (or `LLDAP_DATABASE_URL` in the env)
to point to your new database (the same value used when generating schema). Restart
LLDAP and check the logs to ensure there were no errors.
LLDAP and check the logs to ensure there were no errors.
#### More details/examples can be seen in the CI process [here](https://raw.githubusercontent.com/nitnelave/lldap/main/.github/workflows/docker-build-static.yml), look for the job `lldap-database-migration-test`

View File

@@ -62,6 +62,7 @@ occ ldap:set-config s01 ldapGroupFilterMode 0
occ ldap:set-config s01 ldapGroupDisplayName cn
occ ldap:set-config s01 ldapGroupFilterObjectclass groupOfUniqueNames
occ ldap:set-config s01 ldapGroupMemberAssocAttr uniqueMember
occ ldap:set-config s01 ldapEmailAttribute "mail"
occ ldap:set-config s01 ldapLoginFilterEmail 0
occ ldap:set-config s01 ldapLoginFilterUsername 1
occ ldap:set-config s01 ldapMatchingRuleInChainState unknown

View File

@@ -0,0 +1,11 @@
# Configuration for shaarli
LDAP configuration is in ```/data/config.json.php```
Just add the following lines:
```
"ldap": {
"host": "ldap://lldap_server:3890",
"dn": "uid=%s,ou=people,dc=example,dc=com"
}
```

View File

@@ -2,7 +2,7 @@
authors = ["Valentin Tolmer <valentin@tolmer.fr>"]
edition = "2021"
name = "lldap"
version = "0.4.2"
version = "0.4.3-alpha"
[dependencies]
actix = "0.13"
@@ -59,7 +59,6 @@ version = "4"
[dependencies.figment]
features = ["env", "toml"]
version = "*"
[dependencies.tracing-subscriber]
version = "0.3"
features = ["env-filter", "tracing-log"]

View File

@@ -1,24 +1,25 @@
use std::collections::{hash_map::DefaultHasher, HashSet};
use std::hash::{Hash, Hasher};
use std::pin::Pin;
use std::task::{Context, Poll};
use std::task::Poll;
use actix_web::{
cookie::{Cookie, SameSite},
dev::{Service, ServiceRequest, ServiceResponse, Transform},
error::{ErrorBadRequest, ErrorUnauthorized},
web, HttpRequest, HttpResponse,
web, FromRequest, HttpRequest, HttpResponse,
};
use actix_web_httpauth::extractors::bearer::BearerAuth;
use anyhow::Result;
use anyhow::{bail, Context, Result};
use chrono::prelude::*;
use futures::future::{ok, Ready};
use futures_util::FutureExt;
use hmac::Hmac;
use jwt::{SignWithKey, VerifyWithKey};
use secstr::SecUtf8;
use sha2::Sha512;
use time::ext::NumericalDuration;
use tracing::{debug, instrument, warn};
use tracing::{debug, info, instrument, warn};
use lldap_auth::{login, password_reset, registration, JWTClaims};
@@ -183,6 +184,7 @@ where
.await
{
warn!("Error sending email: {:#?}", e);
info!("Reset token: {}", token);
return Err(TcpError::InternalServerError(format!(
"Could not send email: {}",
e
@@ -204,6 +206,24 @@ where
.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")]
async fn get_password_reset_step2<Backend>(
data: web::Data<AppState<Backend>>,
@@ -212,22 +232,12 @@ async fn get_password_reset_step2<Backend>(
where
Backend: TcpBackendHandler + BackendHandler + 'static,
{
let token = request
.match_info()
.get("token")
.ok_or_else(|| TcpError::BadRequest("Missing reset token".to_owned()))?;
let user_id = data
.get_tcp_handler()
.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 tcp_handler = data.get_tcp_handler();
let (token, user_id) =
check_password_reset_token(tcp_handler, &request.match_info().get("token"))
.await?
.ok_or_else(|| TcpError::BadRequest("Missing token".to_string()))?;
let _ = tcp_handler.delete_password_reset_token(token).await;
let groups = HashSet::new();
let token = create_jwt(&data.jwt_key, user_id.to_string(), groups);
Ok(HttpResponse::Ok()
@@ -402,6 +412,7 @@ where
Backend: TcpBackendHandler + BackendHandler + OpaqueHandler + LoginHandler + 'static,
{
let user_id = UserId::new(&request.username);
debug!(?user_id);
let bind_request = BindRequest {
name: user_id.clone(),
password: request.password.clone(),
@@ -448,6 +459,115 @@ where
.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")]
async fn opaque_register_start<Backend>(
request: actix_web::HttpRequest,
@@ -564,7 +684,7 @@ where
#[allow(clippy::type_complexity)]
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)
}
@@ -635,6 +755,11 @@ where
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("/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::scope("/opaque/register")

View File

@@ -81,6 +81,10 @@ pub struct RunOpts {
#[clap(short, long, env = "LLDAP_DATABASE_URL")]
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)]
pub smtp_opts: SmtpOpts,
@@ -132,6 +136,10 @@ pub enum SmtpEncryption {
#[derive(Debug, Parser, Clone)]
#[clap(next_help_heading = Some("SMTP"))]
pub struct SmtpOpts {
/// Enable password reset.
#[clap(long, env = "LLDAP_SMTP_OPTIONS__ENABLE_PASSWORD_RESET")]
pub smtp_enable_password_reset: Option<bool>,
/// Sender email address.
#[clap(long, env = "LLDAP_SMTP_OPTIONS__FROM")]
pub smtp_from: Option<Mailbox>,

View File

@@ -25,7 +25,7 @@ pub struct MailOptions {
pub server: String,
#[builder(default = "587")]
pub port: u16,
#[builder(default = r#""admin".to_string()"#)]
#[builder(default = r#"String::default()"#)]
pub user: String,
#[builder(default = r#"SecUtf8::from("")"#)]
pub password: SecUtf8,
@@ -98,6 +98,8 @@ pub struct Configuration {
pub ldaps_options: LdapsOptions,
#[builder(default = r#"String::from("http://localhost")"#)]
pub http_url: String,
#[builder(default = r#"SecUtf8::from("")"#)]
pub hibp_api_key: SecUtf8,
#[serde(skip)]
#[builder(field(private), default = "None")]
server_setup: Option<ServerSetup>,
@@ -213,6 +215,10 @@ impl ConfigOverrider for RunOpts {
if let Some(database_url) = self.database_url.as_ref() {
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.ldaps_opts.override_config(config);
}
@@ -276,6 +282,9 @@ impl ConfigOverrider for SmtpOpts {
if let Some(tls_required) = self.smtp_tls_required {
config.smtp_options.tls_required = Some(tls_required);
}
if let Some(enable_password_reset) = self.smtp_enable_password_reset {
config.smtp_options.enable_password_reset = enable_password_reset;
}
}
}

View File

@@ -16,9 +16,10 @@ use actix_files::{Files, NamedFile};
use actix_http::{header, HttpServiceBuilder};
use actix_server::ServerBuilder;
use actix_service::map_config;
use actix_web::{dev::AppConfig, guard, middleware, web, App, HttpResponse, Responder};
use actix_web::{dev::AppConfig, guard, web, App, HttpResponse, Responder};
use anyhow::{Context, Result};
use hmac::Hmac;
use secstr::SecUtf8;
use sha2::Sha512;
use std::collections::HashSet;
use std::path::PathBuf;
@@ -38,10 +39,10 @@ pub enum TcpError {
BadRequest(String),
#[error("Internal server error: `{0}`")]
InternalServerError(String),
#[error("Not found: `{0}`")]
NotFoundError(String),
#[error("Unauthorized: `{0}`")]
UnauthorizedError(String),
#[error("Not implemented: `{0}`")]
NotImplemented(String),
}
pub type TcpResult<T> = std::result::Result<T, TcpError>;
@@ -60,14 +61,18 @@ pub(crate) fn error_to_http_response(error: TcpError) -> HttpResponse {
| DomainError::EntityNotFound(_) => HttpResponse::BadRequest(),
},
TcpError::BadRequest(_) => HttpResponse::BadRequest(),
TcpError::NotFoundError(_) => HttpResponse::NotFound(),
TcpError::InternalServerError(_) => HttpResponse::InternalServerError(),
TcpError::UnauthorizedError(_) => HttpResponse::Unauthorized(),
TcpError::NotImplemented(_) => HttpResponse::NotImplemented(),
}
.body(error.to_string())
}
async fn wasm_handler() -> actix_web::Result<impl Responder> {
Ok(actix_files::NamedFile::open_async("./app/pkg/lldap_app_bg.wasm").await?)
}
async fn wasm_handler_compressed() -> actix_web::Result<impl Responder> {
Ok(
actix_files::NamedFile::open_async("./app/pkg/lldap_app_bg.wasm.gz")
.await?
@@ -84,6 +89,7 @@ fn http_config<Backend>(
jwt_blacklist: HashSet<u64>,
server_url: String,
mail_options: MailOptions,
hibp_api_key: SecUtf8,
) where
Backend: TcpBackendHandler + BackendHandler + LoginHandler + OpaqueHandler + Clone + 'static,
{
@@ -94,6 +100,7 @@ fn http_config<Backend>(
jwt_blacklist: RwLock::new(jwt_blacklist),
server_url,
mail_options,
hibp_api_key,
}))
.route(
"/health",
@@ -110,12 +117,9 @@ fn http_config<Backend>(
.configure(super::graphql::api::configure_endpoint::<Backend>),
)
.service(
web::resource("/pkg/lldap_app_bg.wasm").route(
web::route()
.wrap(middleware::Compress::default())
.to(wasm_handler),
),
web::resource("/pkg/lldap_app_bg.wasm.gz").route(web::route().to(wasm_handler_compressed)),
)
.service(web::resource("/pkg/lldap_app_bg.wasm").route(web::route().to(wasm_handler)))
// Serve the /pkg path with the compiled WASM app.
.service(Files::new("/pkg", "./app/pkg"))
// Serve static files
@@ -132,6 +136,7 @@ pub(crate) struct AppState<Backend> {
pub jwt_blacklist: RwLock<HashSet<u64>>,
pub server_url: String,
pub mail_options: MailOptions,
pub hibp_api_key: SecUtf8,
}
impl<Backend: BackendHandler> AppState<Backend> {
@@ -172,6 +177,7 @@ where
let mail_options = config.smtp_options.clone();
let verbose = config.verbose;
info!("Starting the API/web server on port {}", config.http_port);
let hibp_api_key = config.hibp_api_key.clone();
server_builder
.bind(
"http",
@@ -182,6 +188,7 @@ where
let jwt_blacklist = jwt_blacklist.clone();
let server_url = server_url.clone();
let mail_options = mail_options.clone();
let hibp_api_key = hibp_api_key.clone();
HttpServiceBuilder::default()
.finish(map_config(
App::new()
@@ -197,6 +204,7 @@ where
jwt_blacklist,
server_url,
mail_options,
hibp_api_key,
)
}),
|_| AppConfig::default(),