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:
+71
-6
@@ -242,6 +242,39 @@ func printStatus(st []client.Status) {
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
func cmdPasswd(ctx context.Context, args []string) error {
|
||||
fs := flag.NewFlagSet("passwd", flag.ExitOnError)
|
||||
path := configFlag(fs)
|
||||
_ = fs.Parse(args)
|
||||
cfg, api, err := session(*path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Changing the account password for %s on %s.\n", cfg.Username, cfg.ServerURL)
|
||||
fmt.Println("This is not any vault's password; vault passwords are set in KeePass.")
|
||||
current, err := readPassword("Current password: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next, err := readNewAccountPassword()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = api.ChangePassword(ctx, current, next, "")
|
||||
if client.Code(err) == "totp_required" || client.Code(err) == "totp_invalid" {
|
||||
code, cerr := prompt("One-time code: ")
|
||||
if cerr != nil {
|
||||
return cerr
|
||||
}
|
||||
err = api.ChangePassword(ctx, current, next, code)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("changed; this account's other sessions were signed out")
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdTOTP(ctx context.Context, args []string) error {
|
||||
fs := flag.NewFlagSet("totp", flag.ExitOnError)
|
||||
path := configFlag(fs)
|
||||
@@ -566,14 +599,19 @@ func cmdUsers(ctx context.Context, args []string) error {
|
||||
|
||||
// ---- terminal helpers ----
|
||||
|
||||
// stdin is read through one buffered reader for the whole process. A
|
||||
// reader per prompt would discard whatever it had buffered past the first
|
||||
// line, which loses typed-ahead input and breaks piped input entirely.
|
||||
var stdin = bufio.NewReader(os.Stdin)
|
||||
|
||||
func prompt(label string) (string, error) {
|
||||
fmt.Print(label)
|
||||
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
line, err := stdin.ReadString('\n')
|
||||
return strings.TrimSpace(line), err
|
||||
}
|
||||
|
||||
// readPassword takes a password without echoing it. It prefers the
|
||||
// environment and a file so that scripts never need a terminal at all.
|
||||
// readPassword takes the account's existing password without echoing it. It
|
||||
// prefers the environment and a file so that scripts never need a terminal.
|
||||
func readPassword(label string) (string, error) {
|
||||
if v := os.Getenv("KEEPORG_PASSWORD"); v != "" {
|
||||
return v, nil
|
||||
@@ -582,27 +620,54 @@ func readPassword(label string) (string, error) {
|
||||
b, err := os.ReadFile(f)
|
||||
return strings.TrimSpace(string(b)), err
|
||||
}
|
||||
return readNewSecret(label)
|
||||
}
|
||||
|
||||
// readNewSecret asks for a secret that is being set rather than proven, so
|
||||
// it never reads KEEPORG_PASSWORD: that variable holds the password you are
|
||||
// signing in with, and taking it here would quietly set the new password to
|
||||
// the old one.
|
||||
func readNewSecret(label string) (string, error) {
|
||||
restore, hidden := hideInput()
|
||||
defer restore()
|
||||
fmt.Print(label)
|
||||
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
line, err := stdin.ReadString('\n')
|
||||
if hidden {
|
||||
fmt.Println()
|
||||
}
|
||||
return strings.TrimSpace(line), err
|
||||
}
|
||||
|
||||
// readNewAccountPassword asks twice for an account password.
|
||||
func readNewAccountPassword() (string, error) {
|
||||
first, err := readNewSecret("New password: ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(first) < 10 {
|
||||
return "", errors.New("an account password must be at least 10 characters")
|
||||
}
|
||||
again, err := readNewSecret("Repeat: ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if first != again {
|
||||
return "", errors.New("the two passwords do not match")
|
||||
}
|
||||
return first, nil
|
||||
}
|
||||
|
||||
// readNewPassword asks twice, so a vault is never created with a password
|
||||
// its owner cannot reproduce.
|
||||
func readNewPassword(label string) (string, error) {
|
||||
first, err := readPassword(label)
|
||||
first, err := readNewSecret(label)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(first) < 12 {
|
||||
return "", errors.New("a vault password must be at least 12 characters")
|
||||
}
|
||||
again, err := readPassword("Repeat: ")
|
||||
again, err := readNewSecret("Repeat: ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ func main() {
|
||||
{"run", "keep the mirror in sync until stopped", cmdRun},
|
||||
{"sync", "reconcile once and exit", cmdSync},
|
||||
{"status", "show each mirrored database", cmdStatus},
|
||||
{"passwd", "change your account password", cmdPasswd},
|
||||
{"totp", "enrol a second factor for this account", cmdTOTP},
|
||||
{"whoami", "show the signed-in account and its groups", cmdWhoami},
|
||||
{"groups", "list groups you can see", cmdGroups},
|
||||
|
||||
+83
-1
@@ -90,7 +90,11 @@ func main() {
|
||||
showVer = flag.Bool("version", false, "print the version and exit")
|
||||
healthOnly = flag.Bool("healthcheck", false,
|
||||
"probe a running server on this address and exit 0 if it is healthy")
|
||||
writeCfg = flag.String("write-config", "", "write a commented example config to this path and exit")
|
||||
writeCfg = flag.String("write-config", "",
|
||||
"write a commented example config to this path and exit")
|
||||
resetAdmin = flag.Bool("reset-admin", false,
|
||||
"reset the built-in administrator's password, print a new one and "+
|
||||
"exit; stop the server first")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
@@ -139,6 +143,13 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
if *resetAdmin {
|
||||
if err := resetAdministrator(cfg, lg); err != nil {
|
||||
lg.Fatalf("reset-admin: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
srv, err := server.New(cfg, lg)
|
||||
if err != nil {
|
||||
lg.Fatalf("start: %v", err)
|
||||
@@ -225,6 +236,77 @@ func bootstrap(srv *server.Server, lg *log.Logger) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// resetAdministrator gives the server's administrator a new password, for
|
||||
// when the one printed at first start was not kept. It writes the state
|
||||
// document directly, so the server must be stopped: a running one holds the
|
||||
// state in memory and would write over this on its next change.
|
||||
//
|
||||
// Any second factor on that account is cleared too. Someone who has lost
|
||||
// the password has often lost the authenticator with it, and a recovery
|
||||
// that leaves you locked out is not one.
|
||||
func resetAdministrator(cfg server.Config, lg *log.Logger) error {
|
||||
cfg.Defaults()
|
||||
path := filepath.Join(cfg.DataDir, "state.json")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return fmt.Errorf("no server state at %s: %w", path, err)
|
||||
}
|
||||
st, err := store.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
password := os.Getenv("KEEPORG_ADMIN_PASSWORD")
|
||||
generated := false
|
||||
if password == "" {
|
||||
password = secret.Token()[:20]
|
||||
generated = true
|
||||
}
|
||||
hash, err := secret.Hash(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var name string
|
||||
if err := st.Update(func(s *store.State) error {
|
||||
u := s.UserByName("admin")
|
||||
if u == nil {
|
||||
// Whichever account can administer the server will do.
|
||||
for _, candidate := range s.Users {
|
||||
if candidate.ServerAdmin {
|
||||
u = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if u == nil {
|
||||
u = &store.User{ID: secret.ID(), Username: "admin", CreatedAt: time.Now()}
|
||||
s.Users[u.ID] = u
|
||||
}
|
||||
u.PassHash = hash
|
||||
u.TOTPSecret, u.TOTPConfirmed = "", false
|
||||
u.ServerAdmin, u.Disabled = true, false
|
||||
for token, sess := range s.Sessions {
|
||||
if sess.UserID == u.ID {
|
||||
delete(s.Sessions, token)
|
||||
}
|
||||
}
|
||||
name = u.Username
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if generated {
|
||||
lg.Printf("reset %q: password %q (shown once). Any second factor on that "+
|
||||
"account was cleared and its sessions were revoked.", name, password)
|
||||
} else {
|
||||
lg.Printf("reset %q to the password from KEEPORG_ADMIN_PASSWORD. Any second "+
|
||||
"factor on that account was cleared and its sessions were revoked.", name)
|
||||
}
|
||||
lg.Print("if keeporgd was running while this ran, restart it now so it reloads the change")
|
||||
return nil
|
||||
}
|
||||
|
||||
// healthcheck asks a server listening on this configuration whether it is
|
||||
// serving, and says why not when it is not.
|
||||
func healthcheck(cfg server.Config) error {
|
||||
|
||||
+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}` |
|
||||
|
||||
@@ -139,6 +139,14 @@ func (a *API) Users(ctx context.Context) ([]User, error) {
|
||||
return out.Users, err
|
||||
}
|
||||
|
||||
// ChangePassword replaces the account password. The server requires the
|
||||
// current one, and the second factor when one is enrolled, then signs out
|
||||
// this account's other sessions.
|
||||
func (a *API) ChangePassword(ctx context.Context, current, next, totp string) error {
|
||||
return a.do(ctx, http.MethodPost, "/api/v1/auth/password", map[string]string{
|
||||
"current_password": current, "new_password": next, "totp": totp}, nil)
|
||||
}
|
||||
|
||||
// EnrollTOTP starts second-factor enrolment and returns the shared secret
|
||||
// and its otpauth URI.
|
||||
func (a *API) EnrollTOTP(ctx context.Context) (secret, uri string, err error) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -301,6 +302,65 @@ func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// changePassword replaces a user's password after proving the current one.
|
||||
// It is the one place a password changes, so the rules live here: the
|
||||
// current password is required even though the caller already holds a
|
||||
// session, the second factor is required when one is enrolled, and every
|
||||
// other session of that user is revoked, because a password change is what
|
||||
// someone does when they think a session is no longer theirs.
|
||||
func changePassword(st *store.State, username, current, totpCode, next, keepToken string) error {
|
||||
u, err := checkPassword(st, username, current, totpCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(next) < MinPasswordLen {
|
||||
return fmt.Errorf("the new password must be at least %d characters", MinPasswordLen)
|
||||
}
|
||||
if next == current {
|
||||
return errors.New("the new password is the same as the old one")
|
||||
}
|
||||
hash, err := secret.Hash(next)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.PassHash = hash
|
||||
for token, sess := range st.Sessions {
|
||||
if sess.UserID == u.ID && token != keepToken {
|
||||
delete(st.Sessions, token)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
var req struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
TOTP string `json:"totp"`
|
||||
}
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
return changePassword(st, u.Username, req.CurrentPassword, req.TOTP,
|
||||
req.NewPassword, bearer(r))
|
||||
})
|
||||
switch {
|
||||
case errors.Is(err, errTOTPRequired):
|
||||
fail(w, http.StatusUnauthorized, "totp_required", "second factor required")
|
||||
case errors.Is(err, errTOTPInvalid):
|
||||
fail(w, http.StatusUnauthorized, "totp_invalid", "second factor rejected")
|
||||
case errors.Is(err, errBadCredentials):
|
||||
fail(w, http.StatusUnauthorized, "bad_credentials", "the current password is wrong")
|
||||
case err != nil:
|
||||
fail(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
default:
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "password changed; other sessions on this account were signed out",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- invites ----
|
||||
|
||||
func (s *Server) handleInvitePage(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -30,14 +30,27 @@ type harness struct {
|
||||
const adminPassword = "admin-password-1"
|
||||
|
||||
func newHarness(t *testing.T) *harness {
|
||||
return newHarnessWith(t, nil)
|
||||
}
|
||||
|
||||
// newHarnessWith builds a server whose configuration the caller may adjust,
|
||||
// for the cases that need OIDC or a different work factor.
|
||||
func newHarnessWith(t *testing.T, adjust func(*Config)) *harness {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
srv, err := New(Config{
|
||||
// The listener is created first so the server's external URL is the one
|
||||
// it actually answers on, which is what an OIDC redirect needs.
|
||||
ts := httptest.NewUnstartedServer(nil)
|
||||
cfg := Config{
|
||||
DataDir: dir,
|
||||
ExternalURL: "http://keeporg.test",
|
||||
ExternalURL: "http://" + ts.Listener.Addr().String(),
|
||||
MasterKeyFile: filepath.Join(t.TempDir(), "master.key"),
|
||||
VaultKDF: testKDF,
|
||||
}, log.New(io.Discard, "", 0))
|
||||
}
|
||||
if adjust != nil {
|
||||
adjust(&cfg)
|
||||
}
|
||||
srv, err := New(cfg, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -54,7 +67,8 @@ func newHarness(t *testing.T) *harness {
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
ts.Config = &http.Server{Handler: srv.Handler()}
|
||||
ts.Start()
|
||||
t.Cleanup(ts.Close)
|
||||
return &harness{t: t, srv: srv, http: ts, dataDir: dir}
|
||||
}
|
||||
|
||||
+24
-1
@@ -165,9 +165,18 @@ func (s *Server) handleOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderDone(w, "Single sign-on unavailable", "The identity provider could not be reached.")
|
||||
return
|
||||
}
|
||||
// With a user code this approves a waiting client; without one it signs
|
||||
// a browser in to the web interface.
|
||||
userCode := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("code")))
|
||||
state := secret.Token()
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
if userCode == "" {
|
||||
st.Devices[state] = &store.DeviceAuth{
|
||||
DeviceCode: state, Web: true, Label: "web", OIDCState: state,
|
||||
CreatedAt: time.Now(), ExpiresAt: time.Now().Add(deviceTTL),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
d := st.DeviceByUserCode(userCode)
|
||||
if d == nil || time.Now().After(d.ExpiresAt) {
|
||||
return errors.New("unknown or expired device code")
|
||||
@@ -223,6 +232,7 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderDone(w, "Sign-in failed", "The identity provider rejected the sign-in.")
|
||||
return
|
||||
}
|
||||
var webToken string
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
var dev *store.DeviceAuth
|
||||
for _, d := range st.Devices {
|
||||
@@ -238,12 +248,25 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
|
||||
return err
|
||||
}
|
||||
dev.OIDCState = ""
|
||||
dev.SessionToken = s.newSession(st, u.ID, dev.Label).Token
|
||||
token := s.newSession(st, u.ID, dev.Label).Token
|
||||
if dev.Web {
|
||||
// Nothing is waiting to poll for this one; the browser in front
|
||||
// of us is the client.
|
||||
webToken = token
|
||||
delete(st.Devices, dev.DeviceCode)
|
||||
return nil
|
||||
}
|
||||
dev.SessionToken = token
|
||||
return nil
|
||||
}); err != nil {
|
||||
s.renderDone(w, "Sign-in failed", err.Error())
|
||||
return
|
||||
}
|
||||
if webToken != "" {
|
||||
s.setWebCookie(w, webToken, s.cfg.SessionTTL)
|
||||
http.Redirect(w, r, "/web/vaults", http.StatusFound)
|
||||
return
|
||||
}
|
||||
s.renderDone(w, "Device approved", "You can close this page and return to the application.")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
)
|
||||
|
||||
// fakeIdP is the smallest thing that behaves like an OIDC provider: a
|
||||
// discovery document, an authorization endpoint that redirects straight
|
||||
// back, a token endpoint and a userinfo endpoint.
|
||||
type fakeIdP struct {
|
||||
*httptest.Server
|
||||
subject, email, username string
|
||||
// tokenAuth records the client credentials the server presented.
|
||||
tokenAuth [2]string
|
||||
authCalls int
|
||||
}
|
||||
|
||||
func newFakeIdP(t *testing.T, subject, email, username string) *fakeIdP {
|
||||
idp := &fakeIdP{subject: subject, email: email, username: username}
|
||||
mux := http.NewServeMux()
|
||||
idp.Server = httptest.NewServer(mux)
|
||||
t.Cleanup(idp.Close)
|
||||
|
||||
mux.HandleFunc("GET /.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"issuer": idp.URL,
|
||||
"authorization_endpoint": idp.URL + "/authorize",
|
||||
"token_endpoint": idp.URL + "/token",
|
||||
"userinfo_endpoint": idp.URL + "/userinfo",
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("GET /authorize", func(w http.ResponseWriter, r *http.Request) {
|
||||
idp.authCalls++
|
||||
q := r.URL.Query()
|
||||
back, err := url.Parse(q.Get("redirect_uri"))
|
||||
if err != nil {
|
||||
http.Error(w, "bad redirect_uri", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
rq := back.Query()
|
||||
rq.Set("code", "authorization-code")
|
||||
rq.Set("state", q.Get("state"))
|
||||
back.RawQuery = rq.Encode()
|
||||
http.Redirect(w, r, back.String(), http.StatusFound)
|
||||
})
|
||||
mux.HandleFunc("POST /token", func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, _ := r.BasicAuth()
|
||||
idp.tokenAuth = [2]string{user, pass}
|
||||
_ = r.ParseForm()
|
||||
if r.FormValue("code") != "authorization-code" {
|
||||
http.Error(w, "bad code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "an-access-token", "token_type": "Bearer", "expires_in": 300,
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("GET /userinfo", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer an-access-token" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"sub": idp.subject, "email": idp.email, "preferred_username": idp.username,
|
||||
})
|
||||
})
|
||||
return idp
|
||||
}
|
||||
|
||||
// withOIDC configures a harness against this provider.
|
||||
func (idp *fakeIdP) withOIDC(autoCreate bool, defaultGroup string) func(*Config) {
|
||||
return func(c *Config) {
|
||||
c.OIDC = OIDCConfig{
|
||||
Issuer: idp.URL, ClientID: "keeporg", ClientSecret: "a-client-secret",
|
||||
AutoCreate: autoCreate, DefaultGroupID: defaultGroup,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCSignsInToTheWebInterface(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-1", "sso@example.com", "ssouser")
|
||||
h := newHarnessWith(t, idp.withOIDC(true, "everyone"))
|
||||
b := newBrowser(t, h)
|
||||
|
||||
// The sign-in page offers it, and following that link is the whole flow.
|
||||
status, page := b.get("/web")
|
||||
if status != http.StatusOK || !strings.Contains(page, "/oidc/login") {
|
||||
t.Fatalf("the sign-in page does not offer single sign-on:\n%s", page)
|
||||
}
|
||||
status, page = b.get("/oidc/login")
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("oidc login: %d\n%s", status, page)
|
||||
}
|
||||
if !strings.Contains(page, "Your vaults") {
|
||||
t.Fatalf("single sign-on did not land on the vault list:\n%s", page)
|
||||
}
|
||||
if b.cookie() == "" {
|
||||
t.Fatal("no session cookie after signing in with single sign-on")
|
||||
}
|
||||
if idp.authCalls != 1 {
|
||||
t.Fatalf("the provider's authorize endpoint was called %d times", idp.authCalls)
|
||||
}
|
||||
if idp.tokenAuth != [2]string{"keeporg", "a-client-secret"} {
|
||||
t.Fatalf("the server authenticated to the token endpoint as %v", idp.tokenAuth)
|
||||
}
|
||||
|
||||
// The account was provisioned, linked to the subject, and put in the
|
||||
// configured group.
|
||||
var linked *store.User
|
||||
h.srv.Store().Read(func(st *store.State) {
|
||||
linked = st.UserByOIDC(idp.URL, "subject-1")
|
||||
})
|
||||
if linked == nil {
|
||||
t.Fatal("no account was linked to the identity")
|
||||
}
|
||||
if linked.Username != "ssouser" || linked.Email != "sso@example.com" {
|
||||
t.Fatalf("provisioned account = %+v", linked)
|
||||
}
|
||||
var inGroup bool
|
||||
h.srv.Store().Read(func(st *store.State) {
|
||||
for _, m := range st.Memberships {
|
||||
if m.UserID == linked.ID && m.GroupID == "everyone" {
|
||||
inGroup = true
|
||||
}
|
||||
}
|
||||
})
|
||||
if !inGroup {
|
||||
t.Fatal("the new account did not join the default group")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCApprovesAWaitingClient(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-2", "dev@example.com", "devuser")
|
||||
h := newHarnessWith(t, idp.withOIDC(true, "everyone"))
|
||||
|
||||
status, start := h.call("POST", "/api/v1/auth/device/start", "", map[string]string{"label": "laptop"})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("device start: %d %v", status, start)
|
||||
}
|
||||
userCode := start["user_code"].(string)
|
||||
|
||||
b := newBrowser(t, h)
|
||||
status, page := b.get("/oidc/login?code=" + url.QueryEscape(userCode))
|
||||
if status != http.StatusOK || !strings.Contains(page, "Device approved") {
|
||||
t.Fatalf("device approval through single sign-on: %d\n%s", status, page)
|
||||
}
|
||||
// A device approval must not also hand the browser a session cookie.
|
||||
if b.cookie() != "" {
|
||||
t.Fatal("approving a device set a web session cookie")
|
||||
}
|
||||
|
||||
status, body := h.call("POST", "/api/v1/auth/device/poll", "",
|
||||
map[string]string{"device_code": start["device_code"].(string)})
|
||||
if status != http.StatusOK || body["token"] == "" {
|
||||
t.Fatalf("poll after single sign-on: %d %v", status, body)
|
||||
}
|
||||
if u := body["user"].(map[string]any); u["username"] != "devuser" {
|
||||
t.Fatalf("the session belongs to %v", u["username"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCLinksAnExistingAccountByUsername(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-3", "", "admin")
|
||||
h := newHarnessWith(t, idp.withOIDC(false, ""))
|
||||
b := newBrowser(t, h)
|
||||
|
||||
if _, page := b.get("/oidc/login"); !strings.Contains(page, "Your vaults") {
|
||||
t.Fatalf("an existing account was not matched:\n%s", page)
|
||||
}
|
||||
var u *store.User
|
||||
h.srv.Store().Read(func(st *store.State) { u = st.Users["admin"] })
|
||||
if u.OIDCSubject != "subject-3" {
|
||||
t.Fatalf("the existing account was not linked: %+v", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCRefusesAnUnknownIdentityWhenProvisioningIsOff(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-4", "stranger@example.com", "stranger")
|
||||
h := newHarnessWith(t, idp.withOIDC(false, ""))
|
||||
b := newBrowser(t, h)
|
||||
|
||||
_, page := b.get("/oidc/login")
|
||||
if !strings.Contains(page, "no account is linked") {
|
||||
t.Fatalf("an unknown identity was let in:\n%s", page)
|
||||
}
|
||||
if b.cookie() != "" {
|
||||
t.Fatal("a session cookie was issued to an unknown identity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCCallbackRejectsAnUnknownState(t *testing.T) {
|
||||
idp := newFakeIdP(t, "subject-5", "", "someone")
|
||||
h := newHarnessWith(t, idp.withOIDC(true, ""))
|
||||
b := newBrowser(t, h)
|
||||
|
||||
_, page := b.get("/oidc/callback?code=authorization-code&state=not-a-real-state")
|
||||
if !strings.Contains(page, "expired") {
|
||||
t.Fatalf("a forged callback was accepted:\n%s", page)
|
||||
}
|
||||
if b.cookie() != "" {
|
||||
t.Fatal("a forged callback issued a session cookie")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignInPageHidesSingleSignOnWhenItIsNotConfigured(t *testing.T) {
|
||||
b := newBrowser(t, newHarness(t))
|
||||
_, page := b.get("/web")
|
||||
if strings.Contains(page, "oidc/login") {
|
||||
t.Fatal("single sign-on is offered although no provider is configured")
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,9 @@ 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;
|
||||
@@ -157,6 +160,24 @@ by whoever controls the server. Any other setting keeps the key off the server.<
|
||||
<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(`
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
"cc.tiredsysadmin.keeporg/internal/totp"
|
||||
)
|
||||
|
||||
const newAdminPassword = "a-much-better-password"
|
||||
|
||||
func TestChangePasswordRequiresTheCurrentOne(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
tok, _, _ := h.login("admin", adminPassword, "")
|
||||
|
||||
status, body := h.call("POST", "/api/v1/auth/password", tok, map[string]string{
|
||||
"current_password": "not-the-password", "new_password": newAdminPassword})
|
||||
if status != http.StatusUnauthorized || body["code"] != "bad_credentials" {
|
||||
t.Fatalf("wrong current password: %d %v", status, body)
|
||||
}
|
||||
// And the password really did not change.
|
||||
if _, s, _ := h.login("admin", adminPassword, ""); s != http.StatusOK {
|
||||
t.Fatal("the old password stopped working after a failed change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePasswordRejectsAWeakOrUnchangedPassword(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
tok, _, _ := h.login("admin", adminPassword, "")
|
||||
for _, next := range []string{"short", adminPassword} {
|
||||
status, body := h.call("POST", "/api/v1/auth/password", tok, map[string]string{
|
||||
"current_password": adminPassword, "new_password": next})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("new password %q: %d %v", next, status, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePasswordSwapsThePasswordAndRevokesOtherSessions(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
keep, _, _ := h.login("admin", adminPassword, "")
|
||||
elsewhere, _, _ := h.login("admin", adminPassword, "")
|
||||
|
||||
status, body := h.call("POST", "/api/v1/auth/password", keep, map[string]string{
|
||||
"current_password": adminPassword, "new_password": newAdminPassword})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("change: %d %v", status, body)
|
||||
}
|
||||
|
||||
if _, s, _ := h.login("admin", adminPassword, ""); s != http.StatusUnauthorized {
|
||||
t.Fatal("the old password still works")
|
||||
}
|
||||
if _, s, _ := h.login("admin", newAdminPassword, ""); s != http.StatusOK {
|
||||
t.Fatal("the new password does not work")
|
||||
}
|
||||
// The session that made the change survives; the other one does not.
|
||||
if s, _ := h.call("GET", "/api/v1/auth/whoami", keep, nil); s != http.StatusOK {
|
||||
t.Fatalf("the calling session was revoked: %d", s)
|
||||
}
|
||||
if s, _ := h.call("GET", "/api/v1/auth/whoami", elsewhere, nil); s != http.StatusUnauthorized {
|
||||
t.Fatalf("another session survived the password change: %d", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangePasswordRequiresTheSecondFactorWhenEnrolled(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
tok, _, _ := h.login("admin", adminPassword, "")
|
||||
_, enroll := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil)
|
||||
sec := enroll["secret"].(string)
|
||||
code, _ := totp.Code(sec, time.Now())
|
||||
if s, _ := h.call("POST", "/api/v1/auth/totp/confirm", tok,
|
||||
map[string]string{"code": code}); s != http.StatusOK {
|
||||
t.Fatal("could not enrol the second factor")
|
||||
}
|
||||
|
||||
status, body := h.call("POST", "/api/v1/auth/password", tok, map[string]string{
|
||||
"current_password": adminPassword, "new_password": newAdminPassword})
|
||||
if status != http.StatusUnauthorized || body["code"] != "totp_required" {
|
||||
t.Fatalf("change without a code: %d %v", status, body)
|
||||
}
|
||||
code, _ = totp.Code(sec, time.Now())
|
||||
if s, b := h.call("POST", "/api/v1/auth/password", tok, map[string]string{
|
||||
"current_password": adminPassword, "new_password": newAdminPassword,
|
||||
"totp": code}); s != http.StatusOK {
|
||||
t.Fatalf("change with a code: %d %v", s, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebChangesThePassword(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
b := newBrowser(t, h)
|
||||
csrf := b.signIn()
|
||||
|
||||
// The form is on the page, and it is guarded like the others.
|
||||
_, page := b.get("/web/vaults")
|
||||
if !strings.Contains(page, "Change your account password") {
|
||||
t.Fatalf("no password form on the vault page:\n%s", page)
|
||||
}
|
||||
if status, _ := b.post("/web/password", url.Values{
|
||||
"current_password": {adminPassword}, "new_password": {newAdminPassword},
|
||||
"new_password2": {newAdminPassword}}); status != http.StatusForbidden {
|
||||
t.Fatalf("a form without a CSRF token returned %d", status)
|
||||
}
|
||||
|
||||
_, page = b.post("/web/password", url.Values{"csrf": {csrf},
|
||||
"current_password": {adminPassword}, "new_password": {newAdminPassword},
|
||||
"new_password2": {"something-else"}})
|
||||
if !strings.Contains(page, "do not match") {
|
||||
t.Fatalf("mismatch not reported:\n%s", page)
|
||||
}
|
||||
|
||||
_, page = b.post("/web/password", url.Values{"csrf": {csrf},
|
||||
"current_password": {adminPassword}, "new_password": {newAdminPassword},
|
||||
"new_password2": {newAdminPassword}})
|
||||
if !strings.Contains(page, "Password changed") {
|
||||
t.Fatalf("no confirmation:\n%s", page)
|
||||
}
|
||||
if _, s, _ := h.login("admin", newAdminPassword, ""); s != http.StatusOK {
|
||||
t.Fatal("the new password does not work")
|
||||
}
|
||||
}
|
||||
|
||||
// An account that exists only through single sign-on has no password to
|
||||
// change, and must not be able to acquire one this way.
|
||||
func TestChangePasswordRefusesAnSSOOnlyAccount(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
if err := h.srv.Store().Update(func(st *store.State) error {
|
||||
st.Users["sso"] = &store.User{ID: "sso", Username: "ssouser",
|
||||
OIDCIssuer: "https://id.example", OIDCSubject: "s"}
|
||||
st.Sessions["sso-token"] = &store.Session{Token: "sso-token", UserID: "sso",
|
||||
ExpiresAt: time.Now().Add(time.Hour)}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, body := h.call("POST", "/api/v1/auth/password", "sso-token", map[string]string{
|
||||
"current_password": "", "new_password": newAdminPassword})
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Fatalf("an account with no password set one: %d %v", status, body)
|
||||
}
|
||||
}
|
||||
@@ -174,6 +174,7 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
|
||||
m.HandleFunc("POST /api/v1/auth/logout", s.authed(s.handleLogout))
|
||||
m.HandleFunc("GET /api/v1/auth/whoami", s.authed(s.handleWhoami))
|
||||
m.HandleFunc("POST /api/v1/auth/password", s.authed(s.handleChangePassword))
|
||||
m.HandleFunc("POST /api/v1/auth/totp/enroll", s.authed(s.handleTOTPEnroll))
|
||||
m.HandleFunc("POST /api/v1/auth/totp/confirm", s.authed(s.handleTOTPConfirm))
|
||||
m.HandleFunc("POST /api/v1/auth/device/start", s.handleDeviceStart)
|
||||
@@ -188,6 +189,7 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("POST /web/signout", s.handleWebSignOut)
|
||||
m.HandleFunc("GET /web/vaults", s.handleWebVaults)
|
||||
m.HandleFunc("POST /web/vaults", s.handleWebCreateVault)
|
||||
m.HandleFunc("POST /web/password", s.handleWebChangePassword)
|
||||
|
||||
// Browser-facing pages: device approval, invite registration, OIDC.
|
||||
m.HandleFunc("GET /device", s.handleDevicePage)
|
||||
|
||||
@@ -176,6 +176,45 @@ func (s *Server) handleWebCreateVault(w http.ResponseWriter, r *http.Request) {
|
||||
"with the password you just set.")
|
||||
}
|
||||
|
||||
func (s *Server) handleWebChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
sess := s.webUser(r)
|
||||
if sess == nil {
|
||||
s.renderSignIn(w, "")
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil || !s.checkCSRF(r, sess) {
|
||||
fail(w, http.StatusForbidden, "csrf", "stale form, reload the page")
|
||||
return
|
||||
}
|
||||
next := r.FormValue("new_password")
|
||||
if next != r.FormValue("new_password2") {
|
||||
s.renderVaults(w, sess, "The two new passwords do not match.", "")
|
||||
return
|
||||
}
|
||||
cookie, _ := r.Cookie(webCookie)
|
||||
keep := ""
|
||||
if cookie != nil {
|
||||
keep = cookie.Value
|
||||
}
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
return changePassword(st, sess.user.Username, r.FormValue("current_password"),
|
||||
r.FormValue("totp"), next, keep)
|
||||
})
|
||||
switch {
|
||||
case errors.Is(err, errTOTPRequired):
|
||||
s.renderVaults(w, sess, "Enter your one-time code to change the password.", "")
|
||||
case errors.Is(err, errTOTPInvalid):
|
||||
s.renderVaults(w, sess, "That one-time code was rejected.", "")
|
||||
case errors.Is(err, errBadCredentials):
|
||||
s.renderVaults(w, sess, "The current password is wrong.", "")
|
||||
case err != nil:
|
||||
s.renderVaults(w, sess, err.Error(), "")
|
||||
default:
|
||||
s.renderVaults(w, sess, "",
|
||||
"Password changed. Other sessions on this account were signed out.")
|
||||
}
|
||||
}
|
||||
|
||||
// humanError keeps internal wording out of the browser.
|
||||
func humanError(err error) string {
|
||||
if errors.Is(err, errForbidden) {
|
||||
|
||||
+12
-1
@@ -155,7 +155,13 @@ type Session struct {
|
||||
// client polls until a session token appears.
|
||||
type DeviceAuth struct {
|
||||
DeviceCode string `json:"device_code"`
|
||||
// UserCode is the short code a person reads out to approve a client.
|
||||
// A browser signing in to the web interface has none: there is no
|
||||
// second device to approve.
|
||||
UserCode string `json:"user_code"`
|
||||
// Web marks a sign-in that ends in a cookie rather than in a token a
|
||||
// waiting client polls for.
|
||||
Web bool `json:"web,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
SessionToken string `json:"session_token,omitempty"`
|
||||
OIDCState string `json:"oidc_state,omitempty"`
|
||||
@@ -227,8 +233,13 @@ func (s *State) UserByOIDC(issuer, subject string) *User {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeviceByUserCode finds a pending device login by the code shown to the user.
|
||||
// DeviceByUserCode finds a pending device login by the code shown to the
|
||||
// user. An empty code matches nothing, so a web sign-in, which has no user
|
||||
// code, is never mistaken for a device waiting to be approved.
|
||||
func (s *State) DeviceByUserCode(code string) *DeviceAuth {
|
||||
if code == "" {
|
||||
return nil
|
||||
}
|
||||
for _, d := range s.Devices {
|
||||
if d.UserCode == code {
|
||||
return d
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
keeporg (0.3.0) unstable; urgency=medium
|
||||
|
||||
* New `keeporg passwd` command, which changes the account password.
|
||||
* Prompts no longer discard typed-ahead or piped input. Every prompt made
|
||||
its own buffered reader, so the first one swallowed the rest of stdin
|
||||
and any command that asked twice was unusable from a script.
|
||||
|
||||
-- tiredsysadmin <rcmd@garage.rcmd.space> Fri, 30 Oct 2026 10:45:31 +0100
|
||||
|
||||
keeporg (0.2.0) unstable; urgency=medium
|
||||
|
||||
* Server-side merging. A vault the server can open has its two concurrent
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
keeporgd (0.5.0) unstable; urgency=medium
|
||||
|
||||
* Single sign-on now works from the web interface. The link on the
|
||||
sign-in page started a flow that only a waiting client could finish, so
|
||||
it always failed; a browser sign-in now ends in a session cookie.
|
||||
* New endpoint for changing an account password. It requires the current
|
||||
password, and the one-time code when a second factor is enrolled, and
|
||||
revokes that account's other sessions.
|
||||
* New keeporgd -reset-admin, for when the password printed at first start
|
||||
was not kept. It prints a new one, clears any second factor on that
|
||||
account and revokes its sessions. The server must be stopped first.
|
||||
|
||||
-- tiredsysadmin <rcmd@garage.rcmd.space> Fri, 30 Oct 2026 10:45:31 +0100
|
||||
|
||||
keeporgd (0.4.0) unstable; urgency=medium
|
||||
|
||||
* Read the master key from the file named by KEEPORG_MASTER_KEY_FILE, the
|
||||
|
||||
+5
-5
@@ -1,10 +1,10 @@
|
||||
# Single source of truth for all platform versions (semver).
|
||||
# Bump with: make bump PLATFORM=<server|linux|macos|windows|bsd|android> PART=<major|minor|patch>
|
||||
VERSION_SERVER := 0.4.0
|
||||
VERSION_LINUX := 0.2.0
|
||||
VERSION_MACOS := 0.2.0
|
||||
VERSION_WINDOWS := 0.2.0
|
||||
VERSION_BSD := 0.2.0
|
||||
VERSION_SERVER := 0.5.0
|
||||
VERSION_LINUX := 0.3.0
|
||||
VERSION_MACOS := 0.3.0
|
||||
VERSION_WINDOWS := 0.3.0
|
||||
VERSION_BSD := 0.3.0
|
||||
VERSION_ANDROID := 0.2.0
|
||||
# Monotonic Android versionCode, never decreases.
|
||||
ANDROID_VERSION_CODE := 2
|
||||
|
||||
Reference in New Issue
Block a user