Fix single sign-on from the web, and make passwords changeable
Why: two questions about operating this turned up two gaps. "Where do I get the admin password" had no answer once the line scrolled out of the log, and "how do I set up OIDC" ran straight into a sign-in link that could not work. What: - /oidc/login with no parameters now signs a browser in and ends in a session cookie. It previously required the user code of a waiting client, so the link on the web sign-in page always failed. With a code it still approves a device, and the two paths no longer overlap: an empty user code matches no pending device. - POST /api/v1/auth/password, `keeporg passwd`, and a form on /web/vaults. Changing a password requires the current one, and the one-time code when a second factor is enrolled, and revokes that account's other sessions. Until now nothing could change a password, which made the advice printed at first start impossible to follow. - keeporgd -reset-admin, for when that first password was not kept: a new one printed once, any second factor cleared, sessions revoked. It writes the state document directly and says to stop the server first. - The client read stdin through a new buffered reader per prompt, so the first prompt swallowed the rest and anything asking twice could not be scripted. One shared reader now. A prompt for a password being set no longer reads $KEEPORG_PASSWORD either; that variable holds the password you sign in with, and taking it here would have set the new password to the old one. - HANDOVER gains a first-sign-in section and an OIDC setup section covering the redirect URI, the config block, both entry points and how an identity becomes an account. Proved: OIDC now has tests, against a stub provider that serves discovery, authorization, token and userinfo. They cover the web sign-in that was broken, device approval, linking an existing account by username, the refusal when auto_create is off, and a forged callback state. Eight more cover the password change. Checked by hand with the real binaries: the first-start password logs once and works, -reset-admin replaces it while the old one stops working, and `keeporg passwd` changes it from a pipe. Server 0.5.0, desktop clients 0.3.0.
This commit is contained in:
+90
-6
@@ -282,9 +282,64 @@ Three ways in, one kind of credential out: an opaque bearer token stored in
|
||||
OIDC is implemented against the discovery document with the standard library
|
||||
only. The server is a confidential client: it exchanges the authorization
|
||||
code over TLS with the token endpoint and reads the identity from the
|
||||
userinfo endpoint, so there is no local JWT verification to get wrong. An
|
||||
identity links to an existing account by `preferred_username` or `email`, or
|
||||
provisions one when `oidc.auto_create` is set.
|
||||
userinfo endpoint, so there is no local JWT verification to get wrong.
|
||||
|
||||
### Setting up OIDC
|
||||
|
||||
At the identity provider, register a confidential client with one redirect
|
||||
URI, which is `external_url` plus `/oidc/callback`:
|
||||
|
||||
```
|
||||
https://keeporg.example.com/oidc/callback
|
||||
```
|
||||
|
||||
Then put the issuer and the client credentials in `/etc/keeporg/server.json`
|
||||
and restart:
|
||||
|
||||
```json
|
||||
"oidc": {
|
||||
"issuer": "https://id.example.com/realms/main",
|
||||
"client_id": "keeporg",
|
||||
"client_secret": "...",
|
||||
"scopes": "openid profile email",
|
||||
"auto_create": false,
|
||||
"default_group_id": ""
|
||||
}
|
||||
```
|
||||
|
||||
`issuer` is the base URL that answers at
|
||||
`<issuer>/.well-known/openid-configuration`; everything else is read from
|
||||
there. All four fields above the blank ones are required — with any of
|
||||
`issuer`, `client_id` or `client_secret` missing, single sign-on is simply
|
||||
not offered.
|
||||
|
||||
Two entry points use it, and both end at the same callback:
|
||||
|
||||
- **A browser**, from the link on `/web`. `/oidc/login` with no parameters
|
||||
starts a sign-in that ends in a session cookie.
|
||||
- **A client**, from the device flow. `keeporg login -browser=sso` prints a
|
||||
short code; the device page at `/device?code=…` offers the same link, this
|
||||
time as `/oidc/login?code=<user code>`, and approving it releases the
|
||||
token the waiting client is polling for.
|
||||
|
||||
How an identity becomes an account:
|
||||
|
||||
1. If some account is already linked to this issuer and `sub`, that one.
|
||||
2. Otherwise an unlinked local account whose username equals
|
||||
`preferred_username`, or whose email matches `email`, is linked to it.
|
||||
This is how someone invited by link later moves to single sign-on.
|
||||
3. Otherwise, with `auto_create` set, a new account is provisioned and put
|
||||
in `default_group_id` when one is given. Without it, sign-in is refused
|
||||
with "no account is linked to this identity; redeem an invite link
|
||||
first", which is the setting to keep if membership should stay by
|
||||
invitation.
|
||||
|
||||
An account created this way has no password, so it cannot sign in with one
|
||||
and cannot set one through the password form.
|
||||
|
||||
The whole flow is covered by tests against a stub provider in
|
||||
`internal/server/oidc_test.go`, which is the quickest way to see the
|
||||
expected request sequence.
|
||||
|
||||
## The web interface
|
||||
|
||||
@@ -354,9 +409,38 @@ sudo systemctl enable --now keeporgd
|
||||
sudo journalctl -u keeporgd # the first administrator's password is logged once
|
||||
```
|
||||
|
||||
On first start with an empty state the server creates the `admin` account and
|
||||
an `everyone` group, and logs the generated password once. Set
|
||||
`KEEPORG_ADMIN_PASSWORD` to choose it instead.
|
||||
### The first sign-in
|
||||
|
||||
On first start with an empty state the server creates the `admin` account
|
||||
and an `everyone` group, and **logs the generated password once**:
|
||||
|
||||
```
|
||||
keeporgd: created the first administrator: username "admin" password "13e4…"
|
||||
(shown once, change it or enrol TOTP now)
|
||||
```
|
||||
|
||||
Read it with `journalctl -u keeporgd`, or `podman logs <container>`. It is
|
||||
never written anywhere else: the state file holds only a PBKDF2 verifier.
|
||||
Set `KEEPORG_ADMIN_PASSWORD` before the first start to choose it instead, in
|
||||
which case nothing is printed. The variable is read only when the server has
|
||||
no accounts at all, so leaving it set afterwards does nothing.
|
||||
|
||||
Change it once you are in, with `keeporg passwd`, or the form at the bottom
|
||||
of `/web/vaults`. A change requires the current password, and the one-time
|
||||
code when a second factor is enrolled, and signs out that account's other
|
||||
sessions.
|
||||
|
||||
**If the password was lost**, stop the server and run:
|
||||
|
||||
```sh
|
||||
sudo -u keeporg keeporgd -data /var/lib/keeporg -reset-admin
|
||||
```
|
||||
|
||||
It prints a new one, clears any second factor on that account, and revokes
|
||||
its sessions. The server must be stopped: a running one holds the state in
|
||||
memory and would write over the change. Nothing else is touched, and no
|
||||
vault is affected — account passwords and vault passwords are different
|
||||
things.
|
||||
|
||||
`/etc/keeporg/server.json`:
|
||||
|
||||
|
||||
+6
-2
@@ -5,8 +5,11 @@ Every `/api/` endpoint takes and returns JSON and needs
|
||||
endpoints. Errors carry `{"code": "...", "error": "..."}`; `code` is stable
|
||||
and meant to be matched on, `error` is for humans.
|
||||
|
||||
HTML is served at `/web` (sign in, list vaults, create one) and at `/device`,
|
||||
`/invite/{token}` and `/oidc/callback`. The web pages use the same session
|
||||
HTML is served at `/web` (sign in, list vaults, create one, change your
|
||||
password) and at `/device`, `/invite/{token}`, `/oidc/login` and
|
||||
`/oidc/callback`. `/oidc/login` with no parameters signs a browser in and
|
||||
ends in a session cookie; with `?code=<user code>` it approves a waiting
|
||||
client instead and ends in a token that client polls for. The web pages use the same session
|
||||
tokens in a cookie that is `HttpOnly`, `SameSite=Strict` and `Secure` behind
|
||||
https, and every form carries a per-session CSRF token.
|
||||
|
||||
@@ -17,6 +20,7 @@ https, and every form carries a per-session CSRF token.
|
||||
| POST | `/api/v1/auth/login` | `{username, password, totp, label}` | `{token, expires_at, user}`; `401 totp_required` when a second factor is enrolled and missing, `401 totp_invalid`, `401 bad_credentials` |
|
||||
| POST | `/api/v1/auth/logout` | – | revokes the calling token |
|
||||
| GET | `/api/v1/auth/whoami` | – | the account and its group memberships |
|
||||
| POST | `/api/v1/auth/password` | `{current_password, new_password, totp}` | changes the account password; the current one is required, and the second factor when enrolled. Every other session of that account is revoked |
|
||||
| POST | `/api/v1/auth/totp/enroll` | – | `{secret, uri}`; not active until confirmed |
|
||||
| POST | `/api/v1/auth/totp/confirm` | `{code}` | `{totp_enabled: true}` |
|
||||
| POST | `/api/v1/auth/device/start` | `{label}` | `{device_code, user_code, verification_url, interval, expires_in}` |
|
||||
|
||||
Reference in New Issue
Block a user