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.
219 lines
11 KiB
Go
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}} · 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 →</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 →</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)
|
|
}
|