From 69c307e7d6986673f0af65e8a3c573a164a34cde Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 8 Mar 2026 13:03:29 +0700 Subject: [PATCH] docs: add README and dex custom theme Co-Authored-By: Claude Sonnet 4.6 --- README.md | 148 +++++++++++++++++++++++++++++++++++ dextpl/approval.html | 44 +++++++++++ dextpl/device.html | 23 ++++++ dextpl/device_success.html | 8 ++ dextpl/error.html | 8 ++ dextpl/footer.html | 3 + dextpl/header.html | 20 +++++ dextpl/login.html | 19 +++++ dextpl/main.css | 153 +++++++++++++++++++++++++++++++++++++ dextpl/oob.html | 9 +++ dextpl/password.html | 43 +++++++++++ main.go | 63 ++++++++------- 12 files changed, 508 insertions(+), 33 deletions(-) create mode 100644 README.md create mode 100644 dextpl/approval.html create mode 100644 dextpl/device.html create mode 100644 dextpl/device_success.html create mode 100644 dextpl/error.html create mode 100644 dextpl/footer.html create mode 100644 dextpl/header.html create mode 100644 dextpl/login.html create mode 100644 dextpl/main.css create mode 100644 dextpl/oob.html create mode 100644 dextpl/password.html diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b168b1 --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ +# chldapasswd + +A self-hosted web portal for LDAP account management. Users can log in, view their profile, change their password, recover a lost password, and manage mail aliases — all without requiring direct LDAP access. + +## Features + +- **Password change** — authenticated users can update their LDAP password (SHA-512 crypt, minimum 12 chars, requires upper/lower/digit) +- **Lost password recovery** — sends a one-time reset link via email (token expires in 1 hour) +- **Profile view** — displays LDAP attributes after login +- **Mail alias management** — create/delete auto-generated email aliases stored as `mailAlias` in LDAP, exposed via an addy.io-compatible API +- **HTTP Basic Auth endpoint** (`/auth`) — validates credentials against LDAP, forwards `X-Remote-User` header; suitable for use with nginx `auth_request` +- **Docker registry anonymous read** — optionally allows unauthenticated `GET`/`HEAD` on registry image paths via `X-Special-Auth` header +- **Altcha PoW CAPTCHA** — proof-of-work challenge on sensitive forms, no third-party service required +- **CSRF protection** — token-based on state-changing forms +- **Rate limiting** — per-IP on login, password change, lost password, and alias API endpoints +- **Security headers** — CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy + +## Building + +```sh +go build -ldflags="-s -w" -o chldapasswd +``` + +Requires Go 1.23+. A Drone CI pipeline builds and pushes `nemunaire/chldapasswd:latest` on each push to `master`. + +## Usage + +``` +chldapasswd [flags] [serve] +chldapasswd [flags] generate-lost-password-link +``` + +### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `-bind` | `127.0.0.1:8080` | Listen address | +| `-baseurl` | `/` | URL prefix (for reverse-proxy subpath deployment) | +| `-config` | _(none)_ | Path to a JSON config file for LDAP settings | +| `-public-url` | _(none)_ | Base URL used in password reset emails | +| `-brand-name` | `chldapasswd` | Brand name shown in the UI | +| `-brand-logo` | _(none)_ | URL of a logo image shown in the UI | +| `-addy-api-secret` | _(none)_ | HMAC secret for the alias API | +| `-dev` | `false` | Development mode: disables HSTS and cookie `Secure` flag | + +### Environment variables + +All LDAP and SMTP settings can be provided via environment variables (they override CLI flags and config file values): + +| Variable | Description | +|----------|-------------| +| `LDAP_HOST` | LDAP server hostname | +| `LDAP_PORT` | LDAP server port | +| `LDAP_STARTTLS` | Enable STARTTLS (`1`/`on`/`true`) | +| `LDAP_SSL` | Use LDAPS (`1`/`on`/`true`) | +| `LDAP_BASEDN` | Base DN for searches | +| `LDAP_SERVICEDN` | DN of the service account | +| `LDAP_SERVICE_PASSWORD` | Password of the service account | +| `LDAP_SERVICE_PASSWORD_FILE` | Path to a file containing the service password | +| `SMTP_HOST` | SMTP server (leave empty to use local `sendmail`) | +| `SMTP_PORT` | SMTP port | +| `SMTP_USER` | SMTP username | +| `SMTP_PASSWORD` | SMTP password | +| `SMTP_PASSWORD_FILE` | Path to a file containing the SMTP password | +| `SMTP_FROM` | Sender address for recovery emails | +| `PUBLIC_URL` | Public base URL (overrides `-public-url`) | +| `BRAND_NAME` | Brand name (overrides `-brand-name`) | +| `BRAND_LOGO` | Brand logo URL (overrides `-brand-logo`) | +| `ADDY_API_SECRET` | HMAC secret for the alias API | +| `ALIAS_ALLOWED_DOMAINS` | Comma-separated list of domains users may create aliases under | +| `DOCKER_REGISTRY_SECRET` | Shared secret for anonymous Docker registry read access | + +### JSON config file + +The `-config` flag accepts a JSON file whose fields map directly to the `LDAP` struct: + +```json +{ + "Host": "auth.example.com", + "Port": 636, + "Ssl": true, + "BaseDN": "dc=example,dc=com", + "ServiceDN": "cn=svc,ou=services,dc=example,dc=com", + "ServicePassword": "secret", + "MailHost": "smtp.example.com", + "MailPort": 587, + "MailUser": "mailer", + "MailPassword": "secret", + "MailFrom": "noreply@example.com" +} +``` + +## HTTP endpoints + +| Method | Path | Description | +|--------|------|-------------| +| `GET/POST` | `/` or `/change` | Password change form | +| `GET/POST` | `/login` | Login form — shows user profile on success | +| `GET/POST` | `/lost` | Lost password form | +| `GET/POST` | `/reset` | Password reset via token (from email link) | +| `GET/POST` | `/auth` | HTTP Basic Auth validation for reverse-proxy use | +| `POST` | `/api/v1/aliases` | Create a mail alias (addy.io-compatible) | +| `DELETE` | `/api/v1/aliases/{alias}` | Delete a mail alias | +| `GET` | `/altcha-challenge` | Fetch a PoW challenge for the Altcha widget | + +## Mail alias API + +The alias API is compatible with the addy.io API format. Tokens are HMAC-SHA224 signed and encoded in Base32: + +```sh +# Create alias +curl -X POST https://auth.example.com/api/v1/aliases \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"domain": "example.com"}' + +# Delete alias +curl -X DELETE https://auth.example.com/api/v1/aliases/abc123%40example.com \ + -H "Authorization: Bearer " +``` + +The token for a given `uid` is: `base32(uid + ":" + HMAC-SHA224(apiSecret, uid))`. + +Alias creation is disabled unless `ALIAS_ALLOWED_DOMAINS` is set. + +## Templates + +HTML templates are embedded from the `static/` directory at build time. To customise the UI, edit the files in `static/` before building. + +The `dextpl/` directory contains a matching theme for [Dex](https://dexidp.io/) to keep a consistent look across the SSO stack. + +## Docker + +```sh +docker run -d \ + -e LDAP_HOST=auth.example.com \ + -e LDAP_PORT=636 \ + -e LDAP_SSL=true \ + -e LDAP_BASEDN=dc=example,dc=com \ + -e LDAP_SERVICEDN=cn=svc,ou=services,dc=example,dc=com \ + -e LDAP_SERVICE_PASSWORD=secret \ + -e SMTP_HOST=smtp.example.com \ + -e SMTP_PORT=587 \ + -e SMTP_FROM=noreply@example.com \ + -e PUBLIC_URL=https://auth.example.com \ + -p 8080:8080 \ + nemunaire/chldapasswd +``` diff --git a/dextpl/approval.html b/dextpl/approval.html new file mode 100644 index 0000000..1c037d2 --- /dev/null +++ b/dextpl/approval.html @@ -0,0 +1,44 @@ +{{ template "header.html" . }} + +
+

Grant Access

+ +
+
+ {{ if .Scopes }} +
{{ .Client }} would like to:
+
    + {{ range $scope := .Scopes }} +
  • {{ $scope }}
  • + {{ end }} +
+ {{ else }} +
{{ .Client }} has not requested any personal information
+ {{ end }} +
+
+ +
+
+
+ + + +
+
+
+
+ + + +
+
+
+ +
+ +{{ template "footer.html" . }} diff --git a/dextpl/device.html b/dextpl/device.html new file mode 100644 index 0000000..944861c --- /dev/null +++ b/dextpl/device.html @@ -0,0 +1,23 @@ +{{ template "header.html" . }} + +
+

Enter User Code

+
+
+ {{ if( .UserCode )}} + + {{ else }} + + {{ end }} +
+ + {{ if .Invalid }} +
+ Invalid or Expired User Code +
+ {{ end }} + +
+
+ +{{ template "footer.html" . }} diff --git a/dextpl/device_success.html b/dextpl/device_success.html new file mode 100644 index 0000000..53b09ce --- /dev/null +++ b/dextpl/device_success.html @@ -0,0 +1,8 @@ +{{ template "header.html" . }} + +
+

Login Successful for {{ .ClientName }}

+

Return to your device to continue

+
+ +{{ template "footer.html" . }} diff --git a/dextpl/error.html b/dextpl/error.html new file mode 100644 index 0000000..418f76f --- /dev/null +++ b/dextpl/error.html @@ -0,0 +1,8 @@ +{{ template "header.html" . }} + +
+

{{ .ErrType }}

+

{{ .ErrMsg }}

+
+ +{{ template "footer.html" . }} diff --git a/dextpl/footer.html b/dextpl/footer.html new file mode 100644 index 0000000..5b6e2d6 --- /dev/null +++ b/dextpl/footer.html @@ -0,0 +1,3 @@ + + + diff --git a/dextpl/header.html b/dextpl/header.html new file mode 100644 index 0000000..8cf744e --- /dev/null +++ b/dextpl/header.html @@ -0,0 +1,20 @@ + + + + + + {{ issuer }} + + + + + + + +
+
+ +
+
+ +
diff --git a/dextpl/login.html b/dextpl/login.html new file mode 100644 index 0000000..f432dd0 --- /dev/null +++ b/dextpl/login.html @@ -0,0 +1,19 @@ +{{ template "header.html" . }} + +
+

Log in to {{ issuer }}

+
+ {{ range $c := .Connectors }} + + {{ end }} +
+
+ +{{ template "footer.html" . }} diff --git a/dextpl/main.css b/dextpl/main.css new file mode 100644 index 0000000..f5c61d7 --- /dev/null +++ b/dextpl/main.css @@ -0,0 +1,153 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +.dex-container { + color: #333; + margin: 45px auto; + max-width: 500px; + min-width: 320px; + text-align: center; +} + +.dex-btn { + border-radius: 4px; + border: 0; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.25), 0 0 1px rgba(0, 0, 0, 0.25); + cursor: pointer; + font-size: 16px; + padding: 0; +} + +.dex-btn:focus { + outline: none; +} + +.dex-btn:active { + box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + outline: none; +} + +.dex-btn:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.dex-btn-icon { + background-position: center; + background-repeat: no-repeat; + background-size: 24px; + border-radius: 4px 0 0 4px; + float: left; + height: 36px; + margin-right: 5px; + width: 36px; +} + +.dex-btn-icon--google { + background-color: #FFFFFF; + background-image: url(../static/img/google-icon.svg);; +} + +.dex-btn-icon--local { + background-color: #84B6EF; + background-image: url(../static/img/email-icon.svg); +} + +.dex-btn-icon--gitea { + background-color: #F5F5F5; + background-image: url(../static/img/gitea-icon.svg); +} + +.dex-btn-icon--github { + background-color: #F5F5F5; + background-image: url(../static/img/github-icon.svg); +} + +.dex-btn-icon--gitlab { + background-color: #F5F5F5; + background-image: url(../static/img/gitlab-icon.svg); + background-size: contain; +} + +.dex-btn-icon--keystone { + background-color: #F5F5F5; + background-image: url(../static/img/keystone-icon.svg); + background-size: contain; +} + +.dex-btn-icon--oidc { + background-color: #EBEBEE; + background-image: url(../static/img/oidc-icon.svg); + background-size: contain; +} + +.dex-btn-icon--bitbucket-cloud { + background-color: #205081; + background-image: url(../static/img/bitbucket-icon.svg); +} + +.dex-btn-icon--atlassian-crowd { + background-color: #CFDCEA; + background-image: url(../static/img/atlassian-crowd-icon.svg); +} + +.dex-btn-icon--ldap { + background-color: #84B6EF; + background-image: url(../static/img/ldap-icon.svg); +} + +.dex-btn-icon--saml { + background-color: #84B6EF; + background-image: url(../static/img/saml-icon.svg); +} + +.dex-btn-icon--linkedin { + background-image: url(../static/img/linkedin-icon.svg); + background-size: contain; +} + +.dex-btn-icon--microsoft { + background-image: url(../static/img/microsoft-icon.svg); +} + +.dex-btn-text { + font-weight: 600; + line-height: 36px; + padding: 6px 12px; + text-align: center; +} + +.dex-subtle-text { + color: #999; + font-size: 12px; +} + +.dex-separator { + color: #999; +} + +.dex-list { + color: #999; + display: inline-block; + font-size: 12px; + list-style: circle; + text-align: left; +} + +.dex-error-box { + background-color: #DD1327; + color: #fff; + font-size: 14px; + font-weight: normal; + max-width: 320px; + padding: 4px 0; +} + +.dex-error-box { + margin: 20px auto; +} diff --git a/dextpl/oob.html b/dextpl/oob.html new file mode 100644 index 0000000..ba84d81 --- /dev/null +++ b/dextpl/oob.html @@ -0,0 +1,9 @@ +{{ template "header.html" . }} + +
+

Login Successful

+

Please copy this code, switch to your application and paste it there:

+ +
+ +{{ template "footer.html" . }} diff --git a/dextpl/password.html b/dextpl/password.html new file mode 100644 index 0000000..a6d8b66 --- /dev/null +++ b/dextpl/password.html @@ -0,0 +1,43 @@ +{{ template "header.html" . }} + +
+

Log in to Your Account

+
+
+
+ +
+ +
+
+
+ +
+ +
+ + {{ if .Invalid }} +
+ Invalid {{ .UsernamePrompt }} and password. +
+ {{ end }} + + + +
+ {{ if .BackLink }} + + {{ end }} +
+ + + + +{{ template "footer.html" . }} diff --git a/main.go b/main.go index 32a1124..5701f36 100644 --- a/main.go +++ b/main.go @@ -17,7 +17,7 @@ import ( "syscall" ) -var myPublicURL = "https://ldap.nemunai.re" +var myPublicURL = "" var devMode bool var brandName = "chldapasswd" var brandLogo = "" @@ -35,7 +35,7 @@ var myLDAP = LDAP{ Port: 389, BaseDN: "dc=example,dc=com", MailPort: 587, - MailFrom: "noreply@nemunai.re", + MailFrom: "noreply@example.com", } type ResponseWriterPrefix struct { @@ -79,37 +79,34 @@ func StripPrefix(prefix string, h http.Handler) http.Handler { } func main() { - var bind = flag.String("bind", "127.0.0.1:8080", "Bind port/socket") - var baseURL = flag.String("baseurl", "/", "URL prepended to each URL") - var configfile = flag.String("config", "", "path to the configuration file") - var publicURL = flag.String("public-url", myPublicURL, "Public base URL used in password reset emails") - var dev = flag.Bool("dev", false, "Development mode: disables HSTS and cookie Secure flag for local HTTP testing") - var bname = flag.String("brand-name", "chldapasswd", "Brand name displayed in the UI") - var blogo = flag.String("brand-logo", "", "URL of brand logo displayed in the UI (added to CSP img-src)") - flag.Parse() + baseURL := "/" + bind := "127.0.0.1:8080" - myPublicURL = *publicURL - devMode = *dev - brandName = *bname - brandLogo = *blogo if val, ok := os.LookupEnv("BRAND_NAME"); ok { brandName = val } if val, ok := os.LookupEnv("BRAND_LOGO"); ok { brandLogo = val } + + var configfile = flag.String("config", "", "path to the configuration file") + flag.StringVar(&baseURL, "baseurl", baseURL, "URL prepended to each URL") + flag.StringVar(&bind, "bind", bind, "Bind port/socket") + flag.StringVar(&brandName, "brand-name", brandName, "Brand name displayed in the UI") + flag.StringVar(&brandLogo, "brand-logo", brandLogo, "URL of brand logo displayed in the UI (added to CSP img-src)") + flag.BoolVar(&devMode, "dev", devMode, "Development mode: disables HSTS and cookie Secure flag for local HTTP testing") + flag.StringVar(&myPublicURL, "public-url", myPublicURL, "Public base URL used in password reset emails") + flag.Parse() + if devMode { log.Println("WARNING: running in development mode — security features relaxed, do not use in production") } // Sanitize options - log.Println("Checking paths...") - if *baseURL != "/" { - tmp := path.Clean(*baseURL) - baseURL = &tmp + if baseURL != "/" { + baseURL = path.Clean(baseURL) } else { - tmp := "" - baseURL = &tmp + baseURL = "" } // Load config file @@ -231,20 +228,20 @@ func main() { signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) // Register handlers - http.HandleFunc(fmt.Sprintf("GET %s/altcha.min.js", *baseURL), serveAltchaJS) - http.HandleFunc(fmt.Sprintf("GET %s/style.css", *baseURL), serveStyleCSS) - http.HandleFunc(fmt.Sprintf("GET %s/altcha-challenge", *baseURL), serveAltchaChallenge) - http.HandleFunc(fmt.Sprintf("%s/{$}", *baseURL), changePassword) - http.HandleFunc(fmt.Sprintf("POST %s/api/v1/aliases", *baseURL), addyAliasAPI) - http.HandleFunc(fmt.Sprintf("DELETE %s/api/v1/aliases/{alias}", *baseURL), addyAliasAPIDelete) - http.HandleFunc(fmt.Sprintf("%s/auth", *baseURL), httpBasicAuth) - http.HandleFunc(fmt.Sprintf("%s/login", *baseURL), tryLogin) - http.HandleFunc(fmt.Sprintf("%s/change", *baseURL), changePassword) - http.HandleFunc(fmt.Sprintf("%s/reset", *baseURL), resetPassword) - http.HandleFunc(fmt.Sprintf("%s/lost", *baseURL), lostPassword) + http.HandleFunc(fmt.Sprintf("GET %s/altcha.min.js", baseURL), serveAltchaJS) + http.HandleFunc(fmt.Sprintf("GET %s/style.css", baseURL), serveStyleCSS) + http.HandleFunc(fmt.Sprintf("GET %s/altcha-challenge", baseURL), serveAltchaChallenge) + http.HandleFunc(fmt.Sprintf("%s/{$}", baseURL), changePassword) + http.HandleFunc(fmt.Sprintf("POST %s/api/v1/aliases", baseURL), addyAliasAPI) + http.HandleFunc(fmt.Sprintf("DELETE %s/api/v1/aliases/{alias}", baseURL), addyAliasAPIDelete) + http.HandleFunc(fmt.Sprintf("%s/auth", baseURL), httpBasicAuth) + http.HandleFunc(fmt.Sprintf("%s/login", baseURL), tryLogin) + http.HandleFunc(fmt.Sprintf("%s/change", baseURL), changePassword) + http.HandleFunc(fmt.Sprintf("%s/reset", baseURL), resetPassword) + http.HandleFunc(fmt.Sprintf("%s/lost", baseURL), lostPassword) srv := &http.Server{ - Addr: *bind, + Addr: bind, Handler: securityHeaders(http.DefaultServeMux), } @@ -253,7 +250,7 @@ func main() { log.Fatal(srv.ListenAndServe()) }() log.Printf("Using LDAP server at %s:%d (baseDN: %s)", myLDAP.Host, myLDAP.Port, myLDAP.BaseDN) - log.Printf("Ready, listening on %s", *bind) + log.Printf("Ready, listening on %s", bind) // Wait shutdown signal <-interrupt