Files
keeporg/internal/server/pages.go
T
root 7eed722ff5 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.
2026-10-30 10:47:51 +01:00

219 lines
11 KiB
Go

package server
import (
"html/template"
"net/http"
)
// The server has no browsable UI. These three pages exist only because a
// browser is the right place to approve a device, redeem an invite and run an
// OIDC round trip.
var pages = template.Must(template.New("layout").Parse(`
{{define "layout"}}<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.Title}} &middot; KeepOrg</title>
<style>
:root{color-scheme:light dark;--fg:#1a1a1a;--bg:#fafafa;--card:#fff;--line:#d8d8d8;--accent:#2d6a4f}
@media(prefers-color-scheme:dark){:root{--fg:#e8e8e8;--bg:#161616;--card:#1f1f1f;--line:#3a3a3a;--accent:#74c69d}}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--bg);color:var(--fg);
font:16px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;padding:24px}
body.wide{place-items:start center}
main{width:100%;max-width:26rem;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:28px}
body.wide main{max-width:54rem}
h1{font-size:1.25rem;margin:0 0 .25rem}
p.sub{margin:0 0 1.25rem;opacity:.7;font-size:.9rem}
label{display:block;font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;opacity:.7;margin:.9rem 0 .25rem}
input{width:100%;padding:.6rem .7rem;font:inherit;color:var(--fg);background:transparent;
border:1px solid var(--line);border-radius:8px}
button{width:100%;margin-top:1.4rem;padding:.7rem;font:inherit;font-weight:600;color:#fff;
background:var(--accent);border:0;border-radius:8px;cursor:pointer}
.err{margin:0 0 1rem;padding:.6rem .7rem;border-radius:8px;background:#f8d7da;color:#842029;font-size:.9rem}
.alt{display:block;margin-top:1rem;text-align:center;font-size:.9rem;color:var(--accent)}
code{font-size:1.1rem;letter-spacing:.08em}
details{margin-top:1.5rem;border-top:1px solid var(--line);padding-top:1rem}
summary{cursor:pointer;font-size:.9rem;font-weight:600}
details p.sub{margin:.6rem 0 0}
header{display:flex;align-items:baseline;justify-content:space-between;gap:1rem;flex-wrap:wrap}
header form{margin:0}
header button{width:auto;margin:0;padding:.35rem .7rem;font-size:.85rem;font-weight:500;
background:transparent;color:var(--accent);border:1px solid var(--line)}
table{width:100%;border-collapse:collapse;margin:.5rem 0 1.5rem;font-size:.9rem}
th{text-align:left;font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;opacity:.6;
padding:.4rem .5rem;border-bottom:1px solid var(--line)}
td{padding:.5rem;border-bottom:1px solid var(--line);vertical-align:top}
td.name{font-weight:600}
.tag{display:inline-block;padding:.1rem .4rem;border:1px solid var(--line);border-radius:5px;
font-size:.75rem;opacity:.85}
.ok{margin:0 0 1rem;padding:.6rem .7rem;border-radius:8px;background:#d1e7dd;color:#0f5132;font-size:.9rem}
.note{margin:1rem 0 0;padding:.7rem .8rem;border-left:3px solid var(--accent);background:rgba(0,0,0,.03);
font-size:.85rem}
@media(prefers-color-scheme:dark){.note{background:rgba(255,255,255,.04)}
.ok{background:#15372a;color:#9ad8b8}.err{background:#3b1f23;color:#f2b8c0}}
select{width:100%;padding:.6rem .7rem;font:inherit;color:var(--fg);background:var(--card);
border:1px solid var(--line);border-radius:8px}
fieldset{border:1px solid var(--line);border-radius:10px;padding:1rem 1.2rem 1.4rem;margin:0}
legend{padding:0 .4rem;font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;opacity:.7}
.empty{opacity:.7;font-size:.9rem;margin:.5rem 0 1.5rem}
</style></head><body{{if .Wide}} class="wide"{{end}}><main>{{template "body" .}}</main></body></html>{{end}}
`))
var deviceTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{define "body"}}<h1>Approve this device</h1>
<p class="sub">Sign in to let the client on your device sync your databases.</p>
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
<form method="post" action="/device">
<label for="code">Device code</label><input id="code" name="code" value="{{.Code}}" required autocapitalize="characters">
<label for="username">Username</label><input id="username" name="username" autocomplete="username" required>
<label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required>
<label for="totp">One-time code <small>(if enabled)</small></label><input id="totp" name="totp" inputmode="numeric" autocomplete="one-time-code">
<button type="submit">Approve</button>
</form>
{{if .OIDC}}<a class="alt" href="/oidc/login?code={{.Code}}">Sign in with single sign-on &rarr;</a>{{end}}
{{end}}`))
var inviteTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{define "body"}}<h1>Create your account</h1>
<p class="sub">You were invited to a KeepOrg server.</p>
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
<form method="post" action="/invite/{{.Token}}">
<label for="username">Username</label><input id="username" name="username" autocomplete="username" required>
<label for="email">Email <small>(optional)</small></label><input id="email" name="email" type="email" autocomplete="email">
<label for="password">Password</label><input id="password" name="password" type="password" autocomplete="new-password" minlength="10" required>
<label for="password2">Repeat password</label><input id="password2" name="password2" type="password" autocomplete="new-password" minlength="10" required>
<button type="submit">Create account</button>
</form>
{{end}}`))
var signInTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{define "body"}}<h1>KeepOrg</h1>
<p class="sub">Sign in to create and manage your vaults.</p>
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
<form method="post" action="/web/signin">
<label for="username">Username</label><input id="username" name="username" autocomplete="username" required autofocus>
<label for="password">Password</label><input id="password" name="password" type="password" autocomplete="current-password" required>
<label for="totp">One-time code <small>(if enabled)</small></label><input id="totp" name="totp" inputmode="numeric" autocomplete="one-time-code">
<button type="submit">Sign in</button>
</form>
{{if .OIDC}}<a class="alt" href="/oidc/login">Sign in with single sign-on &rarr;</a>{{end}}
{{end}}`))
var vaultsTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{define "body"}}
<header>
<div><h1>Your vaults</h1><p class="sub">{{.User}}</p></div>
<form method="post" action="/web/signout">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button type="submit">Sign out</button>
</form>
</header>
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
{{if .Notice}}<p class="ok">{{.Notice}}</p>{{end}}
{{if .Vaults}}
<table>
<thead><tr><th>Vault</th><th>Access</th><th>On conflict</th><th>Version</th><th>Last saved</th></tr></thead>
<tbody>
{{range .Vaults}}<tr>
<td class="name">{{.Name}}.kdbx</td>
<td>{{.Perm}}</td>
<td><span class="tag">{{.Strategy}}</span></td>
<td>{{.Version}}</td>
<td>{{.Updated}}{{if .By}}<br><small>by {{.By}}</small>{{end}}</td>
</tr>{{end}}
</tbody></table>
{{else}}
<p class="empty">No vaults yet. Create one below, then open it from the KeepOrg client
on your computer or phone.</p>
{{end}}
{{if .Groups}}
<fieldset><legend>Create a vault</legend>
<form method="post" action="/web/vaults">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label for="name">Name</label>
<input id="name" name="name" required placeholder="Team Vault">
<label for="group">Owned by</label>
<select id="group" name="group" required>
{{range .Groups}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select>
<label for="strategy">When two people save at once</label>
<select id="strategy" name="strategy">
<option value="merge">Merge both edits (the server holds the key)</option>
<option value="server-wins">Keep the server copy</option>
<option value="client-wins">Keep the newest save</option>
<option value="manual">Leave both copies for a human</option>
</select>
<label for="password">Vault password</label>
<input id="password" name="password" type="password" autocomplete="new-password" minlength="12" required>
<label for="password2">Repeat vault password</label>
<input id="password2" name="password2" type="password" autocomplete="new-password" minlength="12" required>
<button type="submit">Create vault</button>
</form>
<p class="note">This password encrypts the vault itself and is what everyone with
access types into KeePass. It is not your account password, and it cannot be
recovered: write it down before you continue.<br><br>
Merging requires the server to keep this password, so a merge vault can be read
by whoever controls the server. Any other setting keeps the key off the server.</p>
</fieldset>
{{else}}
<p class="empty">You do not administer a group yet, so there is nowhere to put a
vault. Ask an administrator to add you to one.</p>
{{end}}
<details>
<summary>Change your account password</summary>
<p class="sub">This is the password you sign in with, not any vault's password.
Changing it signs out your other sessions.</p>
<form method="post" action="/web/password">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label for="current_password">Current password</label>
<input id="current_password" name="current_password" type="password" autocomplete="current-password" required>
<label for="cp_totp">One-time code <small>(if enabled)</small></label>
<input id="cp_totp" name="totp" inputmode="numeric" autocomplete="one-time-code">
<label for="new_password">New password</label>
<input id="new_password" name="new_password" type="password" autocomplete="new-password" minlength="10" required>
<label for="new_password2">Repeat new password</label>
<input id="new_password2" name="new_password2" type="password" autocomplete="new-password" minlength="10" required>
<button type="submit">Change password</button>
</form>
</details>
{{end}}`))
var doneTmpl = template.Must(template.Must(pages.Clone()).Parse(`
{{define "body"}}<h1>{{.Title}}</h1><p class="sub">{{.Message}}</p>{{end}}`))
func (s *Server) renderDevice(w http.ResponseWriter, code, errMsg string) {
render(w, deviceTmpl, map[string]any{
"Title": "Approve device", "Code": code, "Error": errMsg, "OIDC": s.oidc != nil,
})
}
func (s *Server) renderInvite(w http.ResponseWriter, token, errMsg string) {
render(w, inviteTmpl, map[string]any{"Title": "Create account", "Token": token, "Error": errMsg})
}
func (s *Server) renderSignIn(w http.ResponseWriter, errMsg string) {
render(w, signInTmpl, map[string]any{
"Title": "Sign in", "Error": errMsg, "OIDC": s.oidc != nil,
})
}
func (s *Server) renderVaults(w http.ResponseWriter, sess *webSession, errMsg, notice string) {
vaults, groups := s.vaultRows(sess.user)
render(w, vaultsTmpl, map[string]any{
"Title": "Vaults", "Wide": true, "User": sess.user.Username, "CSRF": sess.csrf,
"Vaults": vaults, "Groups": groups, "Error": errMsg, "Notice": notice,
})
}
func (s *Server) renderDone(w http.ResponseWriter, title, msg string) {
render(w, doneTmpl, map[string]any{"Title": title, "Message": msg})
}
func render(w http.ResponseWriter, t *template.Template, data map[string]any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Referrer-Policy", "no-referrer")
_ = t.ExecuteTemplate(w, "layout", data)
}