diff --git a/README.md b/README.md deleted file mode 100644 index 2a3b8a3..0000000 --- a/README.md +++ /dev/null @@ -1,148 +0,0 @@ -# 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` | `https://ldap.nemunai.re` | 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": "ldap.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://ldap.example.com/api/v1/aliases \ - -H "Authorization: Bearer " \ - -H "Content-Type: application/json" \ - -d '{"domain": "example.com"}' - -# Delete alias -curl -X DELETE https://ldap.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=ldap.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://ldap.example.com \ - -p 8080:8080 \ - nemunaire/chldapasswd -``` diff --git a/dextpl/approval.html b/dextpl/approval.html deleted file mode 100644 index 1c037d2..0000000 --- a/dextpl/approval.html +++ /dev/null @@ -1,44 +0,0 @@ -{{ 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 deleted file mode 100644 index 944861c..0000000 --- a/dextpl/device.html +++ /dev/null @@ -1,23 +0,0 @@ -{{ 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 deleted file mode 100644 index 53b09ce..0000000 --- a/dextpl/device_success.html +++ /dev/null @@ -1,8 +0,0 @@ -{{ 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 deleted file mode 100644 index 418f76f..0000000 --- a/dextpl/error.html +++ /dev/null @@ -1,8 +0,0 @@ -{{ template "header.html" . }} - -
-

{{ .ErrType }}

-

{{ .ErrMsg }}

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

Log in to {{ issuer }}

-
- {{ range $c := .Connectors }} - - {{ end }} -
-
- -{{ template "footer.html" . }} diff --git a/dextpl/main.css b/dextpl/main.css deleted file mode 100644 index f5c61d7..0000000 --- a/dextpl/main.css +++ /dev/null @@ -1,153 +0,0 @@ -* { - 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 deleted file mode 100644 index ba84d81..0000000 --- a/dextpl/oob.html +++ /dev/null @@ -1,9 +0,0 @@ -{{ 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 deleted file mode 100644 index a6d8b66..0000000 --- a/dextpl/password.html +++ /dev/null @@ -1,43 +0,0 @@ -{{ template "header.html" . }} - -
-

Log in to Your Account

-
-
-
- -
- -
-
-
- -
- -
- - {{ if .Invalid }} -
- Invalid {{ .UsernamePrompt }} and password. -
- {{ end }} - - - -
- {{ if .BackLink }} - - {{ end }} -
- - - - -{{ template "footer.html" . }} diff --git a/login.go b/login.go index 60e3f8f..88767c9 100644 --- a/login.go +++ b/login.go @@ -165,13 +165,12 @@ func tryLogin(w http.ResponseWriter, r *http.Request) { } displayTmpl(w, "profile.html", map[string]any{ - "login": loginName, - "fields": fields, - "emails": emails, - "aliases": aliases, - "api_token": apiToken, - "alias_domains": allowedAliasDomains, - "card_wide": true, + "login": loginName, + "fields": fields, + "emails": emails, + "aliases": aliases, + "api_token": apiToken, + "card_wide": true, }) } diff --git a/static/profile.html b/static/profile.html index 2353d21..680c197 100644 --- a/static/profile.html +++ b/static/profile.html @@ -3,7 +3,7 @@ @@ -22,7 +22,7 @@ {{end}}
- {{if or .emails .aliases .alias_domains}} + {{if or .emails .aliases}} {{end}} @@ -85,48 +72,5 @@ if (r.ok) document.getElementById(btn.dataset.elem).remove(); }); } - function createAlias(btn) { - var domain = document.getElementById('alias-domain').value; - var errEl = document.getElementById('alias-create-error'); - errEl.textContent = ''; - btn.disabled = true; - fetch('/api/v1/aliases', { - method: 'POST', - headers: {'Authorization': 'Bearer ' + btn.dataset.token, 'Content-Type': 'application/json'}, - body: JSON.stringify({domain: domain}) - }).then(function(r) { - btn.disabled = false; - if (!r.ok) { return r.text().then(function(t) { errEl.textContent = t; }); } - return r.json().then(function(data) { - var list = document.querySelector('.alias-list'); - if (!list) { - var h = document.createElement('h3'); - h.className = 'section-title'; - h.textContent = 'Disposable aliases'; - document.querySelector('.alias-create').before(h); - list = document.createElement('ul'); - list.className = 'alias-list'; - document.querySelector('.alias-create').before(list); - } - var idx = list.children.length; - var li = document.createElement('li'); - li.id = 'alias-' + idx; - li.className = 'alias-item'; - var span = document.createElement('span'); - span.className = 'alias-value'; - span.textContent = data.data.email; - var del = document.createElement('button'); - del.className = 'btn btn-danger btn-sm'; - del.dataset.alias = encodeURIComponent(data.data.email); - del.dataset.token = btn.dataset.token; - del.dataset.elem = li.id; - del.textContent = 'Delete'; - del.onclick = function() { deleteAlias(del); }; - li.appendChild(span); - li.appendChild(del); - list.appendChild(li); - }); - }); - } {{template "footer" .}} diff --git a/static/style.css b/static/style.css index 93bca27..30250b5 100644 --- a/static/style.css +++ b/static/style.css @@ -500,28 +500,6 @@ body { word-break: break-all; } -.alias-create { - display: flex; - align-items: center; - gap: 0.5rem; - margin-top: 1rem; - flex-wrap: wrap; -} - -.alias-domain-select { - font-size: 0.875rem; - padding: 0.25rem 0.5rem; - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--surface); - color: var(--text); -} - -.alias-create-error { - font-size: 0.8rem; - color: var(--danger); -} - /* ============================================================ Profile: API tab ============================================================ */