Add an Account page for sign-in methods and signed-in devices
/web/account, offered to everyone, lists every browser and client signed in as you, with when each was last used, and signs any of them out, or all but the current one. It sets up, replaces and removes the second factor, and holds the password form, moved from the vault page. Administrators can sign a user out everywhere from the People page. The API gains GET and DELETE /api/v1/auth/sessions and DELETE /api/v1/auth/totp. Sessions are named by a digest of their token, never the token, and record when they were last used. Replacing or removing an active second factor now needs the password and a current code. Server version 0.10.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
318bd68908
commit
f8f634fbf7
+33
-5
@@ -436,11 +436,12 @@ browsing, by design.
|
||||
|
||||
| Page | What it does |
|
||||
| --- | --- |
|
||||
| `/web/vaults` | the vaults you can reach, the create-a-vault form, your own account password |
|
||||
| `/web/vaults` | the vaults you can reach, the create-a-vault form |
|
||||
| `/web/account` | everyone's own settings: account details, every signed-in browser and client with a button to sign each out (or all but this one), two-factor setup, replacement and removal, the password |
|
||||
| `/web/vaults/{id}/delete` | what deleting a vault destroys, and the password that confirms it |
|
||||
| `/web/groups` | the group tree you can see, and a form to add a group inside one you administer |
|
||||
| `/web/groups/{id}` | one group: members and their roles, the vaults it reaches and where that access comes from, grants given and revoked, deletion |
|
||||
| `/web/users` | the accounts you may see; for a server administrator, disable, enable, clear the second factor, promote, demote, delete |
|
||||
| `/web/users` | the accounts you may see; for a server administrator, disable, enable, clear the second factor, sign out everywhere, promote, demote, delete |
|
||||
| `/web/invites` | outstanding invite links with their use counts and expiry, a form to make one, a button to revoke one |
|
||||
|
||||
The navigation offers the last three only to somebody who administers a
|
||||
@@ -478,6 +479,29 @@ No page carries any JavaScript. Each control is its own small form with the
|
||||
CSRF token and one action in it, which is what makes the interface work in a
|
||||
locked-down browser and keeps its attack surface down to that token.
|
||||
|
||||
**The account page.** `/web/account`, offered to everyone, is where a
|
||||
person manages how they sign in. The rules are in `account.go`, shared with
|
||||
the `/api/v1/auth/sessions` and `/api/v1/auth/totp` endpoints:
|
||||
|
||||
- *Signed-in devices* lists every live session — browsers, named from their
|
||||
User-Agent at sign-in ("Firefox on macOS"), and clients, named by the
|
||||
label they sent — with when each was last used. Any of them can be signed
|
||||
out, or all but the current one. Pages and responses refer to a session by
|
||||
`sessionID`, a digest of its token, so the list never carries a token.
|
||||
`LastSeenAt` is refreshed by `markSeen` at most every five minutes, because
|
||||
each refresh rewrites the state document.
|
||||
- *Two-factor authentication* turns on with nothing more than the session,
|
||||
since it only adds a check. Replacing or removing an active factor asks
|
||||
for the password and a current code: before this, `totp/enroll` reset the
|
||||
factor on any live session, so a borrowed laptop could quietly disarm it.
|
||||
- *Password* moved here from the vault page; changing it still signs out the
|
||||
other sessions. An account that signs in only through single sign-on sees
|
||||
neither form, and is told that its provider manages both.
|
||||
|
||||
A server administrator also has *Sign out everywhere* on `/web/users`, for a
|
||||
colleague's lost phone: every session of that account goes, and the account
|
||||
stays usable.
|
||||
|
||||
**Deleting a vault asks for the password again.** Every other control on
|
||||
these pages is undoable or additive; this one erases every stored version of
|
||||
a file. A live session is what an unlocked laptop leaves lying around, so the
|
||||
@@ -874,9 +898,13 @@ what an arm64 Linux build host needs.
|
||||
`targetSdk` is its own change.
|
||||
- **No key-file or Yubikey handling**, and none needed: those live in the
|
||||
KeePass application.
|
||||
- **Sessions are not listable or revocable individually** beyond
|
||||
`keeporg logout` on the device itself. Adding `GET`/`DELETE
|
||||
/api/v1/auth/sessions` is a small, well-bounded job.
|
||||
- **No QR code for two-factor setup.** The account page shows the key and
|
||||
an `otpauth:` link, which a phone opens straight into its authenticator;
|
||||
scanning from a desktop screen needs a QR encoder, which the standard
|
||||
library does not have.
|
||||
- **The CLI has no `sessions` command yet.** The API is there
|
||||
(`/api/v1/auth/sessions`); the web page is the way to see and revoke
|
||||
devices meanwhile.
|
||||
- **Android does not restart its watcher at boot.** Add a
|
||||
`RECEIVE_BOOT_COMPLETED` receiver if that matters more than the permission
|
||||
it costs.
|
||||
|
||||
+11
-1
@@ -21,13 +21,23 @@ https, and every form carries a per-session CSRF token.
|
||||
| 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/enroll` | `{password, totp}`, only when a factor is already on | `{secret, uri}`; not active until confirmed. Replacing an active factor without the password and current code is `403 reauth_required` |
|
||||
| POST | `/api/v1/auth/totp/confirm` | `{code}` | `{totp_enabled: true}` |
|
||||
| DELETE | `/api/v1/auth/totp` | `{password, totp}` | `{totp_enabled: false}`; `403 reauth_required`, `403 bad_credentials`, `403 totp_invalid` |
|
||||
| GET | `/api/v1/auth/sessions` | – | `{sessions: [{id, label, browser, current, created_at, last_seen_at, expires_at}]}`, the caller's own |
|
||||
| DELETE | `/api/v1/auth/sessions/{id}` | – | signs that session out; `404` when it is not the caller's |
|
||||
| DELETE | `/api/v1/auth/sessions` | – | `{revoked: n}`: every session of the caller but this one |
|
||||
| POST | `/api/v1/auth/device/start` | `{label, return_url}` | `{device_code, user_code, verification_url, interval, expires_in}`; `400` when `return_url` is not a `keeporg:` URL |
|
||||
| POST | `/api/v1/auth/device/poll` | `{device_code}` | `202 {status: "pending"}`, then `200 {token, ...}` once; `410 expired` |
|
||||
|
||||
A device code is single use: the first successful poll consumes it.
|
||||
|
||||
A session `id` is a one-way digest of its token, never the token itself, so
|
||||
a list of sessions cannot be used to sign in as any of them. Failed
|
||||
re-authentication answers `403`, not `401`, so a client that drops its
|
||||
token on `401` does not sign itself out over a mistyped password.
|
||||
`last_seen_at` is refreshed at most every five minutes.
|
||||
|
||||
`return_url` is optional (server 0.9 and later). When it is given, the page
|
||||
that ends the approval, after a password or single sign-on, carries a
|
||||
"Return to KeepOrg" link to it. Only the `keeporg:` scheme is accepted, so
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
"cc.tiredsysadmin.keeporg/internal/totp"
|
||||
)
|
||||
|
||||
// A person's own account: the sessions it holds on each device, and its
|
||||
// second factor. As with administration, every change is a function over the
|
||||
// state taking the caller, and the JSON handlers below and the settings page
|
||||
// in webaccount.go are both thin wrappers over them.
|
||||
|
||||
var (
|
||||
// errNoSuchSession is a session that does not exist or is not the
|
||||
// caller's; the two are not told apart.
|
||||
errNoSuchSession = errors.New("no such session")
|
||||
// errNoPassword is an account that signs in only through single
|
||||
// sign-on. Its second factor belongs at the identity provider, and there
|
||||
// is no password to change.
|
||||
errNoPassword = errors.New("this account signs in through single sign-on and has no password here")
|
||||
// errReauth asks for the password and, once enrolled, the one-time code,
|
||||
// before a change that would weaken how the account signs in. A live
|
||||
// session is what an unlocked laptop leaves lying around.
|
||||
errReauth = errors.New("confirm with your password and one-time code")
|
||||
)
|
||||
|
||||
// sessionID is a session's public name: a one-way digest of its token, so a
|
||||
// page or a response can point at a session without carrying the bearer
|
||||
// token that would sign in as it.
|
||||
func sessionID(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
// sessionView is one signed-in device as its owner sees it.
|
||||
type sessionView struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Browser bool `json:"browser"`
|
||||
Current bool `json:"current"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// sessionsOf lists a user's live sessions, the caller's own first and the
|
||||
// rest by most recent use.
|
||||
func sessionsOf(st *store.State, userID, current string) []sessionView {
|
||||
out := []sessionView{}
|
||||
now := time.Now()
|
||||
for token, sess := range st.Sessions {
|
||||
if sess.UserID != userID || now.After(sess.ExpiresAt) {
|
||||
continue
|
||||
}
|
||||
seen := sess.LastSeenAt
|
||||
if seen.IsZero() {
|
||||
seen = sess.CreatedAt
|
||||
}
|
||||
out = append(out, sessionView{
|
||||
ID: sessionID(token), Label: sess.Label, Browser: sess.IsBrowser(),
|
||||
Current: token == current, CreatedAt: sess.CreatedAt,
|
||||
LastSeenAt: seen, ExpiresAt: sess.ExpiresAt,
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Current != out[j].Current {
|
||||
return out[i].Current
|
||||
}
|
||||
return out[i].LastSeenAt.After(out[j].LastSeenAt)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// revokeSession signs one of the caller's sessions out. The device holding
|
||||
// it is refused from its next request on.
|
||||
func revokeSession(st *store.State, userID, id string) error {
|
||||
for token, sess := range st.Sessions {
|
||||
if sess.UserID == userID && sessionID(token) == id {
|
||||
delete(st.Sessions, token)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errNoSuchSession
|
||||
}
|
||||
|
||||
// revokeOtherSessions signs out every session of the caller but keep, and
|
||||
// says how many went.
|
||||
func revokeOtherSessions(st *store.State, userID, keep string) int {
|
||||
n := 0
|
||||
for token, sess := range st.Sessions {
|
||||
if sess.UserID == userID && token != keep {
|
||||
delete(st.Sessions, token)
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// reauth checks the caller's password, and their one-time code when one is
|
||||
// enrolled, for a change that weakens or replaces how the account signs in.
|
||||
func reauth(st *store.State, u *store.User, password, code string) error {
|
||||
if u.PassHash == "" {
|
||||
return errNoPassword
|
||||
}
|
||||
if password == "" {
|
||||
return errReauth
|
||||
}
|
||||
_, err := checkPassword(st, u.Username, password, code)
|
||||
return err
|
||||
}
|
||||
|
||||
// beginTOTP starts enrolling a second factor and returns its secret. Until
|
||||
// confirmTOTP sees a code from it, the account signs in as before. Replacing
|
||||
// a factor that is already active needs the password and the current code,
|
||||
// because a new secret disarms the old one.
|
||||
func beginTOTP(st *store.State, userID, password, code string) (string, error) {
|
||||
u := st.Users[userID]
|
||||
if u == nil {
|
||||
return "", errNoSuchSession
|
||||
}
|
||||
if u.PassHash == "" {
|
||||
return "", errNoPassword
|
||||
}
|
||||
if u.TOTPConfirmed {
|
||||
if err := reauth(st, u, password, code); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
u.TOTPSecret, u.TOTPConfirmed = totp.NewSecret(), false
|
||||
return u.TOTPSecret, nil
|
||||
}
|
||||
|
||||
// confirmTOTP turns the second factor on, once a code from it checks out.
|
||||
func confirmTOTP(st *store.State, userID, code string) error {
|
||||
u := st.Users[userID]
|
||||
if u == nil || u.TOTPSecret == "" {
|
||||
return errors.New("start setting up a second factor first")
|
||||
}
|
||||
if u.TOTPConfirmed {
|
||||
return errors.New("the second factor is already on")
|
||||
}
|
||||
if !totp.Check(u.TOTPSecret, code, time.Now()) {
|
||||
return errTOTPInvalid
|
||||
}
|
||||
u.TOTPConfirmed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// cancelTOTP abandons an enrolment that was never confirmed.
|
||||
func cancelTOTP(st *store.State, userID string) {
|
||||
if u := st.Users[userID]; u != nil && !u.TOTPConfirmed {
|
||||
u.TOTPSecret = ""
|
||||
}
|
||||
}
|
||||
|
||||
// disableTOTP turns the second factor off, behind the password and a
|
||||
// current code.
|
||||
func disableTOTP(st *store.State, userID, password, code string) error {
|
||||
u := st.Users[userID]
|
||||
if u == nil {
|
||||
return errNoSuchSession
|
||||
}
|
||||
if !u.TOTPConfirmed {
|
||||
return errors.New("no second factor is on")
|
||||
}
|
||||
if err := reauth(st, u, password, code); err != nil {
|
||||
return err
|
||||
}
|
||||
u.TOTPSecret, u.TOTPConfirmed = "", false
|
||||
return nil
|
||||
}
|
||||
|
||||
// failAccount maps an account error onto a response. Re-authentication
|
||||
// failures are 403, not 401: a client that drops its token on 401 must not
|
||||
// sign itself out over a mistyped password.
|
||||
func failAccount(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, errNoSuchSession):
|
||||
fail(w, http.StatusNotFound, "not_found", err.Error())
|
||||
case errors.Is(err, errReauth), errors.Is(err, errTOTPRequired):
|
||||
fail(w, http.StatusForbidden, "reauth_required", errReauth.Error())
|
||||
case errors.Is(err, errTOTPInvalid):
|
||||
fail(w, http.StatusForbidden, "totp_invalid", "the one-time code was rejected")
|
||||
case errors.Is(err, errBadCredentials):
|
||||
fail(w, http.StatusForbidden, "bad_credentials", "the password is wrong")
|
||||
case errors.Is(err, errNoPassword):
|
||||
fail(w, http.StatusConflict, "no_password", err.Error())
|
||||
default:
|
||||
fail(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- API ----
|
||||
|
||||
func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
var out []sessionView
|
||||
s.st.Read(func(st *store.State) { out = sessionsOf(st, u.ID, bearer(r)) })
|
||||
writeJSON(w, http.StatusOK, map[string]any{"sessions": out})
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeSession(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
return revokeSession(st, u.ID, r.PathValue("id"))
|
||||
}); err != nil {
|
||||
failAccount(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeOtherSessions(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
var n int
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
n = revokeOtherSessions(st, u.ID, bearer(r))
|
||||
return nil
|
||||
}); err != nil {
|
||||
fail(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"revoked": n})
|
||||
}
|
||||
|
||||
// reauthRequest is the body of a change that may need re-authentication.
|
||||
// It is optional where nothing is enrolled yet.
|
||||
type reauthRequest struct {
|
||||
Password string `json:"password"`
|
||||
TOTP string `json:"totp"`
|
||||
}
|
||||
|
||||
func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
var req reauthRequest
|
||||
if r.ContentLength > 0 && !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
var sec string
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
var err error
|
||||
sec, err = beginTOTP(st, u.ID, req.Password, req.TOTP)
|
||||
return err
|
||||
}); err != nil {
|
||||
failAccount(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"secret": sec,
|
||||
"uri": totp.URI("KeepOrg", u.Username, sec),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
return confirmTOTP(st, u.ID, req.Code)
|
||||
}); err != nil {
|
||||
if errors.Is(err, errTOTPInvalid) {
|
||||
fail(w, http.StatusBadRequest, "totp_invalid", "code rejected")
|
||||
return
|
||||
}
|
||||
failAccount(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"totp_enabled": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
var req reauthRequest
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
return disableTOTP(st, u.ID, req.Password, req.TOTP)
|
||||
}); err != nil {
|
||||
failAccount(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"totp_enabled": false})
|
||||
}
|
||||
|
||||
// browserLabel names a browser session from its User-Agent, as "Firefox on
|
||||
// macOS". It is a label for a person to recognise, not a fingerprint, so a
|
||||
// handful of substrings is enough.
|
||||
func browserLabel(ua string) string {
|
||||
browser := "Browser"
|
||||
for _, b := range []struct{ token, name string }{
|
||||
{"Edg/", "Edge"}, {"OPR/", "Opera"}, {"Firefox/", "Firefox"},
|
||||
{"Chrome/", "Chrome"}, {"Safari/", "Safari"},
|
||||
} {
|
||||
if strings.Contains(ua, b.token) {
|
||||
browser = b.name
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, o := range []struct{ token, name string }{
|
||||
{"Android", "Android"}, {"iPhone", "iOS"}, {"iPad", "iPadOS"},
|
||||
{"CrOS", "ChromeOS"}, {"Mac OS X", "macOS"}, {"Windows", "Windows"},
|
||||
{"Linux", "Linux"}, {"BSD", "BSD"},
|
||||
} {
|
||||
if strings.Contains(ua, o.token) {
|
||||
return browser + " on " + o.name
|
||||
}
|
||||
}
|
||||
return browser
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/secret"
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
"cc.tiredsysadmin.keeporg/internal/totp"
|
||||
)
|
||||
|
||||
func sessionList(t *testing.T, h *harness, token string) []map[string]any {
|
||||
t.Helper()
|
||||
status, body := h.call("GET", "/api/v1/auth/sessions", token, nil)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("list sessions: %d %v", status, body)
|
||||
}
|
||||
var out []map[string]any
|
||||
for _, s := range body["sessions"].([]any) {
|
||||
out = append(out, s.(map[string]any))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestSessionsAreListedAndRevokedByTheirOwner(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
laptop, _, _ := h.login("admin", adminPassword, "")
|
||||
phone, _, _ := h.login("admin", adminPassword, "")
|
||||
|
||||
list := sessionList(t, h, laptop)
|
||||
if len(list) != 2 || list[0]["current"] != true {
|
||||
t.Fatalf("sessions as the laptop sees them: %v", list)
|
||||
}
|
||||
for _, s := range list {
|
||||
if id := s["id"].(string); id == laptop || id == phone || strings.Contains(laptop+phone, id) && len(id) > 16 {
|
||||
t.Fatalf("a session id gives its token away: %v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// The laptop signs the phone out; the phone is refused from then on.
|
||||
phoneID := sessionID(phone)
|
||||
if status, body := h.call("DELETE", "/api/v1/auth/sessions/"+phoneID, laptop, nil); status != http.StatusOK {
|
||||
t.Fatalf("revoke: %d %v", status, body)
|
||||
}
|
||||
if status, _ := h.call("GET", "/api/v1/auth/whoami", phone, nil); status != http.StatusUnauthorized {
|
||||
t.Fatalf("the revoked phone still works: %d", status)
|
||||
}
|
||||
|
||||
// Somebody else's session is not found, and stays signed in.
|
||||
other := h.makeUser("bob", "bob-password-1")
|
||||
if status, _ := h.call("DELETE", "/api/v1/auth/sessions/"+sessionID(other), laptop, nil); status != http.StatusNotFound {
|
||||
t.Fatalf("revoking another account's session: %d", status)
|
||||
}
|
||||
if status, _ := h.call("GET", "/api/v1/auth/whoami", other, nil); status != http.StatusOK {
|
||||
t.Fatal("another account's session was revoked")
|
||||
}
|
||||
|
||||
// Sign out everything else.
|
||||
h.login("admin", adminPassword, "")
|
||||
h.login("admin", adminPassword, "")
|
||||
status, body := h.call("DELETE", "/api/v1/auth/sessions", laptop, nil)
|
||||
if status != http.StatusOK || body["revoked"] != float64(2) {
|
||||
t.Fatalf("revoke others: %d %v", status, body)
|
||||
}
|
||||
if list := sessionList(t, h, laptop); len(list) != 1 {
|
||||
t.Fatalf("after revoking the others: %v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacingOrRemovingTheSecondFactorNeedsThePassword(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
tok, _, _ := h.login("admin", adminPassword, "")
|
||||
|
||||
// Turning it on needs nothing beyond the session: it only adds a check.
|
||||
status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("enroll: %d %v", status, body)
|
||||
}
|
||||
sec := body["secret"].(string)
|
||||
code, _ := totp.Code(sec, time.Now())
|
||||
if status, _ := h.call("POST", "/api/v1/auth/totp/confirm", tok, map[string]string{"code": code}); status != http.StatusOK {
|
||||
t.Fatalf("confirm: %d", status)
|
||||
}
|
||||
|
||||
// A session alone can no longer swap the secret or turn it off.
|
||||
if status, body := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil); status != http.StatusForbidden || body["code"] != "reauth_required" {
|
||||
t.Fatalf("re-enroll without a password: %d %v", status, body)
|
||||
}
|
||||
if status, body := h.call("DELETE", "/api/v1/auth/totp", tok,
|
||||
map[string]string{"password": adminPassword}); status != http.StatusForbidden || body["code"] != "reauth_required" {
|
||||
t.Fatalf("disable without a code: %d %v", status, body)
|
||||
}
|
||||
if status, body := h.call("DELETE", "/api/v1/auth/totp", tok,
|
||||
map[string]string{"password": "wrong-password", "totp": code}); status != http.StatusForbidden || body["code"] != "bad_credentials" {
|
||||
t.Fatalf("disable with a wrong password: %d %v", status, body)
|
||||
}
|
||||
var on bool
|
||||
h.srv.Store().Read(func(st *store.State) { on = st.Users["admin"].TOTPConfirmed })
|
||||
if !on {
|
||||
t.Fatal("a refused request turned the second factor off")
|
||||
}
|
||||
|
||||
if status, body := h.call("DELETE", "/api/v1/auth/totp", tok,
|
||||
map[string]string{"password": adminPassword, "totp": code}); status != http.StatusOK {
|
||||
t.Fatalf("disable: %d %v", status, body)
|
||||
}
|
||||
if _, status, _ := h.login("admin", adminPassword, ""); status != http.StatusOK {
|
||||
t.Fatal("still asked for a code after turning it off")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecondFactorIsRefusedForSSOOnlyAccounts(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)
|
||||
}
|
||||
if status, body := h.call("POST", "/api/v1/auth/totp/enroll", "sso-token", nil); status != http.StatusConflict {
|
||||
t.Fatalf("enroll on an SSO-only account: %d %v", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
var sessionButton = regexp.MustCompile(`name="session" value="([0-9a-f]+)"`)
|
||||
|
||||
func TestAccountPageSignsOutOtherDevices(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
device, _, _ := h.login("admin", adminPassword, "")
|
||||
b := newBrowser(t, h)
|
||||
csrf := b.signIn()
|
||||
|
||||
_, page := b.get("/web/account")
|
||||
for _, want := range []string{"Signed-in devices", "this browser", "test", "Two-factor authentication", "Change password"} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Fatalf("account page lacks %q:\n%s", want, page)
|
||||
}
|
||||
}
|
||||
if strings.Contains(page, device) || strings.Contains(page, b.cookie()) {
|
||||
t.Fatal("the page carries a session token")
|
||||
}
|
||||
ids := sessionButton.FindAllStringSubmatch(page, -1)
|
||||
if len(ids) != 1 || ids[0][1] != sessionID(device) {
|
||||
t.Fatalf("revoke buttons %v, want only the device's", ids)
|
||||
}
|
||||
|
||||
_, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"revoke"}, "session": {ids[0][1]}})
|
||||
if !strings.Contains(page, "That device is signed out") {
|
||||
t.Fatalf("no confirmation:\n%s", page)
|
||||
}
|
||||
if status, _ := h.call("GET", "/api/v1/databases", device, nil); status != http.StatusUnauthorized {
|
||||
t.Fatalf("the device still syncs after being signed out: %d", status)
|
||||
}
|
||||
|
||||
// Revoking this browser's own session from the list signs it out.
|
||||
_, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"revoke"},
|
||||
"session": {sessionID(b.cookie())}})
|
||||
if !strings.Contains(page, "Sign in to create") || b.cookie() != "" {
|
||||
t.Fatalf("revoking this browser left it signed in:\n%s", page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountPageSetsUpTheSecondFactor(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
b := newBrowser(t, h)
|
||||
csrf := b.signIn()
|
||||
|
||||
_, page := b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-begin"}})
|
||||
if !strings.Contains(page, `href="otpauth://totp/`) {
|
||||
t.Fatalf("no authenticator link:\n%s", page)
|
||||
}
|
||||
var sec string
|
||||
h.srv.Store().Read(func(st *store.State) { sec = st.Users["admin"].TOTPSecret })
|
||||
if !strings.Contains(page, groupKey(sec)) {
|
||||
t.Fatalf("the key is not shown:\n%s", page)
|
||||
}
|
||||
|
||||
_, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-confirm"}, "code": {"000000"}})
|
||||
if !strings.Contains(page, "rejected") {
|
||||
t.Fatalf("a wrong code was not refused:\n%s", page)
|
||||
}
|
||||
code, _ := totp.Code(sec, time.Now())
|
||||
_, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-confirm"}, "code": {code}})
|
||||
if !strings.Contains(page, "Two-factor authentication is on") {
|
||||
t.Fatalf("not turned on:\n%s", page)
|
||||
}
|
||||
if _, status, _ := h.login("admin", adminPassword, ""); status != http.StatusUnauthorized {
|
||||
t.Fatal("a password alone still signs in")
|
||||
}
|
||||
|
||||
// Turning it off from the page needs the password and a code.
|
||||
_, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-disable"}, "password": {adminPassword}})
|
||||
if !strings.Contains(page, "Confirm with your password") {
|
||||
t.Fatalf("turned off without a code:\n%s", page)
|
||||
}
|
||||
_, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"totp-disable"},
|
||||
"password": {adminPassword}, "totp": {code}})
|
||||
if !strings.Contains(page, "Two-factor authentication is off") {
|
||||
t.Fatalf("not turned off:\n%s", page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdministratorSignsSomebodyOutEverywhere(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
bob := h.makeUser("bob", "bob-password-1")
|
||||
b := newBrowser(t, h)
|
||||
csrf := b.signIn()
|
||||
var bobID string
|
||||
h.srv.Store().Read(func(st *store.State) { bobID = st.UserByName("bob").ID })
|
||||
|
||||
_, page := b.post("/web/users", url.Values{"csrf": {csrf}, "user": {bobID}, "action": {"sign-out"}})
|
||||
if !strings.Contains(page, "signed out on every device") {
|
||||
t.Fatalf("no confirmation:\n%s", page)
|
||||
}
|
||||
if status, _ := h.call("GET", "/api/v1/auth/whoami", bob, nil); status != http.StatusUnauthorized {
|
||||
t.Fatal("bob's session survived")
|
||||
}
|
||||
if _, status, _ := h.login("bob", "bob-password-1", ""); status != http.StatusOK {
|
||||
t.Fatal("signing out everywhere locked the account")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLastSeenIsRecordedCoarsely(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
tok, _, _ := h.login("admin", adminPassword, "")
|
||||
old := time.Now().Add(-time.Hour)
|
||||
_ = h.srv.Store().Update(func(st *store.State) error {
|
||||
st.Sessions[tok].LastSeenAt = old
|
||||
return nil
|
||||
})
|
||||
h.call("GET", "/api/v1/auth/whoami", tok, nil)
|
||||
var seen time.Time
|
||||
h.srv.Store().Read(func(st *store.State) { seen = st.Sessions[tok].LastSeenAt })
|
||||
if time.Since(seen) > time.Minute {
|
||||
t.Fatalf("a stale last-seen was not refreshed: %v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserLabel(t *testing.T) {
|
||||
for ua, want := range map[string]string{
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5; rv:128.0) Gecko/20100101 Firefox/128.0": "Firefox on macOS",
|
||||
"Mozilla/5.0 (Linux; Android 15) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Mobile Safari/537.36": "Chrome on Android",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36 Edg/130.0": "Edge on Windows",
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile Safari/604.1": "Safari on iOS",
|
||||
"": "Browser",
|
||||
} {
|
||||
if got := browserLabel(ua); got != want {
|
||||
t.Errorf("browserLabel(%q) = %q, want %q", ua, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// makeUser adds a password account and returns a session token for it.
|
||||
func (h *harness) makeUser(name, password string) string {
|
||||
h.t.Helper()
|
||||
hash, err := secret.Hash(password)
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
if err := h.srv.Store().Update(func(st *store.State) error {
|
||||
st.Users[name] = &store.User{ID: name, Username: name, PassHash: hash, CreatedAt: time.Now()}
|
||||
return nil
|
||||
}); err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
tok, status, body := h.login(name, password, "")
|
||||
if status != http.StatusOK {
|
||||
h.t.Fatalf("login %s: %d %v", name, status, body)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
@@ -324,6 +324,7 @@ const (
|
||||
accountPromote accountAction = "promote"
|
||||
accountDemote accountAction = "demote"
|
||||
accountClearTOTP accountAction = "clear-totp"
|
||||
accountSignOut accountAction = "sign-out"
|
||||
accountDelete accountAction = "delete"
|
||||
)
|
||||
|
||||
@@ -361,6 +362,10 @@ func adminAccount(st *store.State, u *store.User, ref string, act accountAction)
|
||||
target.ServerAdmin = false
|
||||
case accountClearTOTP:
|
||||
target.TOTPSecret, target.TOTPConfirmed = "", false
|
||||
case accountSignOut:
|
||||
// For a lost phone or laptop: every device must sign in again,
|
||||
// and the account stays usable.
|
||||
revokeSessions(st, target.ID)
|
||||
case accountDelete:
|
||||
delete(st.Users, target.ID)
|
||||
revokeSessions(st, target.ID)
|
||||
|
||||
@@ -136,54 +136,6 @@ func (s *Server) handleWhoami(w http.ResponseWriter, r *http.Request, u *store.U
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleTOTPEnroll(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
sec := totp.NewSecret()
|
||||
if err := s.st.Update(func(st *store.State) error {
|
||||
target := st.Users[u.ID]
|
||||
if target == nil {
|
||||
return errBadCredentials
|
||||
}
|
||||
target.TOTPSecret = sec
|
||||
target.TOTPConfirmed = false
|
||||
return nil
|
||||
}); err != nil {
|
||||
fail(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"secret": sec,
|
||||
"uri": totp.URI("KeepOrg", u.Username, sec),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request, u *store.User) {
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
target := st.Users[u.ID]
|
||||
if target == nil || target.TOTPSecret == "" {
|
||||
return errBadCredentials
|
||||
}
|
||||
if !totp.Check(target.TOTPSecret, req.Code, time.Now()) {
|
||||
return errTOTPInvalid
|
||||
}
|
||||
target.TOTPConfirmed = true
|
||||
return nil
|
||||
})
|
||||
switch {
|
||||
case errors.Is(err, errTOTPInvalid):
|
||||
fail(w, http.StatusBadRequest, "totp_invalid", "code rejected")
|
||||
case err != nil:
|
||||
fail(w, http.StatusInternalServerError, "internal", err.Error())
|
||||
default:
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"totp_enabled": true})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- device login: the browser approves, the client polls ----
|
||||
|
||||
func (s *Server) handleDeviceStart(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -248,15 +248,15 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
|
||||
return err
|
||||
}
|
||||
dev.OIDCState = ""
|
||||
token := s.newSession(st, u.ID, dev.Label).Token
|
||||
if dev.Web {
|
||||
token := s.newBrowserSession(st, u.ID, r).Token
|
||||
// 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
|
||||
dev.SessionToken = s.newSession(st, u.ID, dev.Label).Token
|
||||
returnURL = dev.ReturnURL
|
||||
return nil
|
||||
}); err != nil {
|
||||
|
||||
@@ -74,6 +74,7 @@ td button{width:auto;margin:0 .25rem .25rem 0;padding:.3rem .5rem;font-size:.8re
|
||||
background:transparent;color:var(--accent);border:1px solid var(--line)}
|
||||
td button.danger,a.danger{color:#b02a37}
|
||||
button.destroy{background:#b02a37}
|
||||
button.secondary{margin-top:.6rem;background:transparent;color:var(--accent);border:1px solid var(--line)}
|
||||
@media(prefers-color-scheme:dark){td button.danger,a.danger{color:#f2b8c0}
|
||||
button.destroy{background:#a3313c}}
|
||||
.inline{display:flex;gap:.6rem;align-items:flex-end;flex-wrap:wrap;margin:0}
|
||||
@@ -186,23 +187,8 @@ by whoever controls the server. Any other setting keeps the key off the server.<
|
||||
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>
|
||||
<p class="note">Your password, two-factor authentication and signed-in devices
|
||||
are on the <a href="/web/account">Account</a> page.</p>
|
||||
{{end}}`))
|
||||
|
||||
var deleteVaultTmpl = template.Must(template.Must(pages.Clone()).Parse(`
|
||||
|
||||
@@ -216,6 +216,12 @@ groups you administer.{{end}}</p>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if not .Self}}
|
||||
<form method="post" action="/web/users">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="user" value="{{.ID}}">
|
||||
<input type="hidden" name="action" value="sign-out">
|
||||
<button type="submit">Sign out everywhere</button>
|
||||
</form>
|
||||
<form method="post" action="/web/users">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="user" value="{{.ID}}">
|
||||
|
||||
@@ -95,25 +95,25 @@ func TestWebChangesThePassword(t *testing.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)
|
||||
// The form is on the account page, and it is guarded like the others.
|
||||
_, page := b.get("/web/account")
|
||||
if !strings.Contains(page, "Change password") {
|
||||
t.Fatalf("no password form on the account page:\n%s", page)
|
||||
}
|
||||
if status, _ := b.post("/web/password", url.Values{
|
||||
if status, _ := b.post("/web/account", url.Values{"action": {"password"},
|
||||
"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},
|
||||
_, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"password"},
|
||||
"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},
|
||||
_, page = b.post("/web/account", url.Values{"csrf": {csrf}, "action": {"password"},
|
||||
"current_password": {adminPassword}, "new_password": {newAdminPassword},
|
||||
"new_password2": {newAdminPassword}})
|
||||
if !strings.Contains(page, "Password changed") {
|
||||
|
||||
@@ -177,6 +177,10 @@ func (s *Server) routes() {
|
||||
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("DELETE /api/v1/auth/totp", s.authed(s.handleTOTPDisable))
|
||||
m.HandleFunc("GET /api/v1/auth/sessions", s.authed(s.handleListSessions))
|
||||
m.HandleFunc("DELETE /api/v1/auth/sessions", s.authed(s.handleRevokeOtherSessions))
|
||||
m.HandleFunc("DELETE /api/v1/auth/sessions/{id}", s.authed(s.handleRevokeSession))
|
||||
m.HandleFunc("POST /api/v1/auth/device/start", s.handleDeviceStart)
|
||||
m.HandleFunc("POST /api/v1/auth/device/poll", s.handleDevicePoll)
|
||||
|
||||
@@ -191,7 +195,8 @@ func (s *Server) routes() {
|
||||
m.HandleFunc("POST /web/signout", s.webForm(s.handleWebSignOut))
|
||||
m.HandleFunc("GET /web/vaults", s.webPage(s.handleWebVaults))
|
||||
m.HandleFunc("POST /web/vaults", s.webForm(s.handleWebCreateVault))
|
||||
m.HandleFunc("POST /web/password", s.webForm(s.handleWebChangePassword))
|
||||
m.HandleFunc("GET /web/account", s.webPage(s.handleWebAccount))
|
||||
m.HandleFunc("POST /web/account", s.webForm(s.handleWebAccountAction))
|
||||
m.HandleFunc("GET /web/vaults/{id}/delete", s.webPage(s.handleWebDeleteVaultPage))
|
||||
m.HandleFunc("POST /web/vaults/{id}/delete", s.webForm(s.handleWebDeleteVault))
|
||||
m.HandleFunc("GET /web/groups", s.webPage(s.handleWebGroups))
|
||||
@@ -293,7 +298,10 @@ func (s *Server) userFromRequest(r *http.Request) (*store.User, error) {
|
||||
if tok == "" {
|
||||
return nil, errUnauthorized
|
||||
}
|
||||
var out *store.User
|
||||
var (
|
||||
out *store.User
|
||||
seen time.Time
|
||||
)
|
||||
s.st.Read(func(st *store.State) {
|
||||
sess := st.Sessions[tok]
|
||||
if sess == nil || time.Now().After(sess.ExpiresAt) {
|
||||
@@ -301,15 +309,35 @@ func (s *Server) userFromRequest(r *http.Request) (*store.User, error) {
|
||||
}
|
||||
if u := st.Users[sess.UserID]; u != nil && !u.Disabled {
|
||||
cp := *u
|
||||
out = &cp
|
||||
out, seen = &cp, sess.LastSeenAt
|
||||
}
|
||||
})
|
||||
if out == nil {
|
||||
return nil, errUnauthorized
|
||||
}
|
||||
s.markSeen(tok, seen)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// seenResolution is how stale a session's LastSeenAt may get before a
|
||||
// request refreshes it. Each refresh rewrites the state document, so it is
|
||||
// minutes, not seconds; the devices list only needs to tell today from last
|
||||
// month.
|
||||
const seenResolution = 5 * time.Minute
|
||||
|
||||
// markSeen records that a session was used, when its record is stale.
|
||||
func (s *Server) markSeen(token string, last time.Time) {
|
||||
if time.Since(last) < seenResolution {
|
||||
return
|
||||
}
|
||||
_ = s.st.Update(func(st *store.State) error {
|
||||
if sess := st.Sessions[token]; sess != nil {
|
||||
sess.LastSeenAt = time.Now()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// bearer extracts a token from the Authorization header, or from the
|
||||
// access_token query parameter, which EventSource clients need because they
|
||||
// cannot set headers.
|
||||
@@ -322,18 +350,28 @@ func bearer(r *http.Request) string {
|
||||
}
|
||||
|
||||
func (s *Server) newSession(st *store.State, userID, label string) *store.Session {
|
||||
now := time.Now()
|
||||
sess := &store.Session{
|
||||
Token: secret.Token(),
|
||||
UserID: userID,
|
||||
Label: label,
|
||||
CSRF: secret.Token(),
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(s.cfg.SessionTTL),
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(s.cfg.SessionTTL),
|
||||
LastSeenAt: now,
|
||||
}
|
||||
st.Sessions[sess.Token] = sess
|
||||
return sess
|
||||
}
|
||||
|
||||
// newBrowserSession is a session for a web cookie, named after the browser
|
||||
// so that a list of them can be told apart.
|
||||
func (s *Server) newBrowserSession(st *store.State, userID string, r *http.Request) *store.Session {
|
||||
sess := s.newSession(st, userID, browserLabel(r.UserAgent()))
|
||||
sess.Browser = true
|
||||
return sess
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
var users, dbs int
|
||||
s.st.Read(func(st *store.State) { users, dbs = len(st.Users), len(st.Databases) })
|
||||
|
||||
+11
-33
@@ -22,6 +22,7 @@ const webCookie = "keeporg_web"
|
||||
type webSession struct {
|
||||
user *store.User
|
||||
csrf string
|
||||
token string
|
||||
}
|
||||
|
||||
// webUser resolves the session cookie, or nil when the browser is not
|
||||
@@ -31,7 +32,10 @@ func (s *Server) webUser(r *http.Request) *webSession {
|
||||
if err != nil || c.Value == "" {
|
||||
return nil
|
||||
}
|
||||
var out *webSession
|
||||
var (
|
||||
out *webSession
|
||||
seen time.Time
|
||||
)
|
||||
s.st.Read(func(st *store.State) {
|
||||
sess := st.Sessions[c.Value]
|
||||
if sess == nil || time.Now().After(sess.ExpiresAt) {
|
||||
@@ -39,9 +43,13 @@ func (s *Server) webUser(r *http.Request) *webSession {
|
||||
}
|
||||
if u := st.Users[sess.UserID]; u != nil && !u.Disabled {
|
||||
cp := *u
|
||||
out = &webSession{user: &cp, csrf: sess.CSRF}
|
||||
out = &webSession{user: &cp, csrf: sess.CSRF, token: c.Value}
|
||||
seen = sess.LastSeenAt
|
||||
}
|
||||
})
|
||||
if out != nil {
|
||||
s.markSeen(c.Value, seen)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -115,7 +123,7 @@ func (s *Server) handleWebSignIn(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token = s.newSession(st, u.ID, "web").Token
|
||||
token = s.newBrowserSession(st, u.ID, r).Token
|
||||
return nil
|
||||
})
|
||||
switch {
|
||||
@@ -187,36 +195,6 @@ func (s *Server) handleWebCreateVault(w http.ResponseWriter, r *http.Request, se
|
||||
"with the password you just set.")
|
||||
}
|
||||
|
||||
func (s *Server) handleWebChangePassword(w http.ResponseWriter, r *http.Request, sess *webSession) {
|
||||
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.")
|
||||
}
|
||||
}
|
||||
|
||||
// webDeleteVault is what the confirmation page shows before a vault goes.
|
||||
type webDeleteVault struct {
|
||||
ID string
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cc.tiredsysadmin.keeporg/internal/store"
|
||||
"cc.tiredsysadmin.keeporg/internal/totp"
|
||||
)
|
||||
|
||||
// The settings page at /web/account: who you are, how you sign in, and every
|
||||
// device and browser signed in as you, each of which can be signed out from
|
||||
// here. The rules are the functions in account.go, shared with the API.
|
||||
|
||||
// webAccount is what the settings page shows.
|
||||
type webAccount struct {
|
||||
Username string
|
||||
Email string
|
||||
ServerAdmin bool
|
||||
Since string
|
||||
Groups []webMembership
|
||||
HasPassword bool
|
||||
SSO string // the identity provider's host, when linked
|
||||
TOTPOn bool
|
||||
// TOTPKey and TOTPLink are set while an enrolment awaits its first code.
|
||||
TOTPKey string
|
||||
TOTPLink template.URL
|
||||
Sessions []webSessionRow
|
||||
}
|
||||
|
||||
type webMembership struct {
|
||||
Name string
|
||||
Role string
|
||||
}
|
||||
|
||||
type webSessionRow struct {
|
||||
ID string
|
||||
Label string
|
||||
Browser bool
|
||||
Current bool
|
||||
Created string
|
||||
LastSeen string
|
||||
Expires string
|
||||
}
|
||||
|
||||
func (s *Server) accountView(sess *webSession) webAccount {
|
||||
var v webAccount
|
||||
s.st.Read(func(st *store.State) {
|
||||
u := st.Users[sess.user.ID]
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
v = webAccount{
|
||||
Username: u.Username, Email: u.Email, ServerAdmin: u.ServerAdmin,
|
||||
Since: u.CreatedAt.UTC().Format("2006-01-02"),
|
||||
HasPassword: u.PassHash != "", TOTPOn: u.TOTPConfirmed,
|
||||
}
|
||||
if u.OIDCIssuer != "" {
|
||||
v.SSO = u.OIDCIssuer
|
||||
if iss, err := url.Parse(u.OIDCIssuer); err == nil && iss.Host != "" {
|
||||
v.SSO = iss.Host
|
||||
}
|
||||
}
|
||||
if u.TOTPSecret != "" && !u.TOTPConfirmed {
|
||||
v.TOTPKey = groupKey(u.TOTPSecret)
|
||||
// The otpauth: scheme is what authenticator apps register for;
|
||||
// html/template would neutralise it, and the URI is built here
|
||||
// from our own values.
|
||||
v.TOTPLink = template.URL(totp.URI("KeepOrg", u.Username, u.TOTPSecret))
|
||||
}
|
||||
for _, m := range st.Memberships {
|
||||
if m.UserID != u.ID {
|
||||
continue
|
||||
}
|
||||
if g := st.Groups[m.GroupID]; g != nil {
|
||||
v.Groups = append(v.Groups, webMembership{Name: g.Name, Role: string(m.Role)})
|
||||
}
|
||||
}
|
||||
sort.Slice(v.Groups, func(i, j int) bool { return v.Groups[i].Name < v.Groups[j].Name })
|
||||
for _, sv := range sessionsOf(st, u.ID, sess.token) {
|
||||
v.Sessions = append(v.Sessions, webSessionRow{
|
||||
ID: sv.ID, Label: sv.Label, Browser: sv.Browser, Current: sv.Current,
|
||||
Created: sv.CreatedAt.UTC().Format("2006-01-02 15:04 UTC"),
|
||||
LastSeen: ago(sv.LastSeenAt),
|
||||
Expires: sv.ExpiresAt.UTC().Format("2006-01-02"),
|
||||
})
|
||||
}
|
||||
})
|
||||
return v
|
||||
}
|
||||
|
||||
// groupKey spaces a base32 secret in fours, for typing into an app by hand.
|
||||
func groupKey(k string) string {
|
||||
var b strings.Builder
|
||||
for i, r := range k {
|
||||
if i > 0 && i%4 == 0 {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ago says how long ago t was, coarsely.
|
||||
func ago(t time.Time) string {
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < 2*time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%d minutes ago", int(d.Minutes()))
|
||||
case d < 2*time.Hour:
|
||||
return "an hour ago"
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%d hours ago", int(d.Hours()))
|
||||
case d < 48*time.Hour:
|
||||
return "yesterday"
|
||||
case d < 30*24*time.Hour:
|
||||
return fmt.Sprintf("%d days ago", int(d.Hours()/24))
|
||||
}
|
||||
return t.UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
func (s *Server) handleWebAccount(w http.ResponseWriter, r *http.Request, sess *webSession) {
|
||||
s.renderAccount(w, sess, "", "")
|
||||
}
|
||||
|
||||
// handleWebAccountAction applies one control of the settings page.
|
||||
func (s *Server) handleWebAccountAction(w http.ResponseWriter, r *http.Request, sess *webSession) {
|
||||
var notice string
|
||||
err := s.st.Update(func(st *store.State) error {
|
||||
switch r.FormValue("action") {
|
||||
case "revoke":
|
||||
if err := revokeSession(st, sess.user.ID, r.FormValue("session")); err != nil {
|
||||
return err
|
||||
}
|
||||
notice = "That device is signed out. It has to sign in again to sync."
|
||||
case "revoke-others":
|
||||
n := revokeOtherSessions(st, sess.user.ID, sess.token)
|
||||
notice = fmt.Sprintf("Signed out %d other %s.", n, plural(n, "session", "sessions"))
|
||||
case "password":
|
||||
next := r.FormValue("new_password")
|
||||
if next != r.FormValue("new_password2") {
|
||||
return errors.New("the two new passwords do not match")
|
||||
}
|
||||
if err := changePassword(st, sess.user.Username, r.FormValue("current_password"),
|
||||
r.FormValue("totp"), next, sess.token); err != nil {
|
||||
return err
|
||||
}
|
||||
notice = "Password changed. Your other sessions were signed out."
|
||||
case "totp-begin":
|
||||
if _, err := beginTOTP(st, sess.user.ID, r.FormValue("password"), r.FormValue("totp")); err != nil {
|
||||
return err
|
||||
}
|
||||
notice = "Add the key below to your authenticator app, then enter the code it shows."
|
||||
case "totp-confirm":
|
||||
if err := confirmTOTP(st, sess.user.ID, r.FormValue("code")); err != nil {
|
||||
return err
|
||||
}
|
||||
notice = "Two-factor authentication is on. Signing in now asks for a one-time code."
|
||||
case "totp-cancel":
|
||||
cancelTOTP(st, sess.user.ID)
|
||||
notice = "Setup cancelled. Nothing changed."
|
||||
case "totp-disable":
|
||||
if err := disableTOTP(st, sess.user.ID, r.FormValue("password"), r.FormValue("totp")); err != nil {
|
||||
return err
|
||||
}
|
||||
notice = "Two-factor authentication is off."
|
||||
default:
|
||||
return errBadAction
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
s.renderAccount(w, sess, accountError(err), "")
|
||||
return
|
||||
}
|
||||
// Signing out one's own browser from the list ends this session too.
|
||||
if s.webUser(r) == nil {
|
||||
s.setWebCookie(w, "", -time.Second)
|
||||
http.Redirect(w, r, "/web", http.StatusFound)
|
||||
return
|
||||
}
|
||||
s.renderAccount(w, sess, "", notice)
|
||||
}
|
||||
|
||||
func accountError(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, errReauth), errors.Is(err, errTOTPRequired):
|
||||
return "Confirm with your password and your current one-time code."
|
||||
case errors.Is(err, errTOTPInvalid):
|
||||
return "That one-time code was rejected. Check the clock on the device that shows it."
|
||||
case errors.Is(err, errBadCredentials):
|
||||
return "The password is wrong."
|
||||
case errors.Is(err, errNoSuchSession):
|
||||
return "That session is already signed out."
|
||||
}
|
||||
return humanError(err)
|
||||
}
|
||||
|
||||
func plural(n int, one, many string) string {
|
||||
if n == 1 {
|
||||
return one
|
||||
}
|
||||
return many
|
||||
}
|
||||
|
||||
func (s *Server) renderAccount(w http.ResponseWriter, sess *webSession, errMsg, notice string) {
|
||||
d := s.shell(sess, "Account", "Account")
|
||||
d["A"], d["Error"], d["Notice"] = s.accountView(sess), errMsg, notice
|
||||
render(w, accountTmpl, d)
|
||||
}
|
||||
|
||||
var accountTmpl = template.Must(template.Must(pages.Clone()).Parse(`
|
||||
{{define "body"}}
|
||||
<h1>Account</h1>
|
||||
<p class="sub">How you sign in, and everywhere you are signed in.</p>
|
||||
{{if .Error}}<p class="err">{{.Error}}</p>{{end}}
|
||||
{{if .Notice}}<p class="ok">{{.Notice}}</p>{{end}}
|
||||
{{with .A}}
|
||||
<fieldset><legend>You</legend>
|
||||
<table><tbody>
|
||||
<tr><th>Username</th><td class="name">{{.Username}}</td></tr>
|
||||
{{if .Email}}<tr><th>Email</th><td>{{.Email}}</td></tr>{{end}}
|
||||
<tr><th>Signs in with</th><td>
|
||||
{{if .HasPassword}}<span class="tag">password</span>{{end}}
|
||||
{{if .TOTPOn}}<span class="tag">one-time code</span>{{end}}
|
||||
{{if .SSO}}<span class="tag">single sign-on</span> <small>via {{.SSO}}</small>{{end}}
|
||||
</td></tr>
|
||||
<tr><th>Role</th><td>{{if .ServerAdmin}}server administrator{{else}}member{{end}}</td></tr>
|
||||
<tr><th>Groups</th><td>{{range $i, $g := .Groups}}{{if $i}}, {{end}}{{$g.Name}} <small>({{$g.Role}})</small>{{else}}none{{end}}</td></tr>
|
||||
<tr><th>Member since</th><td>{{.Since}}</td></tr>
|
||||
</tbody></table>
|
||||
</fieldset>
|
||||
|
||||
<fieldset><legend>Signed-in devices</legend>
|
||||
<p class="sub">Every browser and KeepOrg client signed in as you. Signing one out
|
||||
stops it syncing at once; it has to sign in again.</p>
|
||||
<div class="scroll"><table>
|
||||
<thead><tr><th>Device</th><th>Last active</th><th>Signed in</th><th>Expires</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Sessions}}<tr>
|
||||
<td class="name">{{if .Label}}{{.Label}}{{else}}unnamed{{end}}<br>
|
||||
<span class="tag">{{if .Browser}}browser{{else}}app{{end}}</span>
|
||||
{{if .Current}}<span class="tag">this browser</span>{{end}}</td>
|
||||
<td>{{.LastSeen}}</td>
|
||||
<td>{{.Created}}</td>
|
||||
<td>{{.Expires}}</td>
|
||||
<td>{{if not .Current}}<form method="post" action="/web/account">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="action" value="revoke">
|
||||
<input type="hidden" name="session" value="{{.ID}}">
|
||||
<button class="danger" type="submit">Sign out</button>
|
||||
</form>{{end}}</td>
|
||||
</tr>{{end}}
|
||||
</tbody></table></div>
|
||||
{{if gt (len .Sessions) 1}}
|
||||
<form method="post" action="/web/account">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="action" value="revoke-others">
|
||||
<button class="destroy" type="submit">Sign out every other device</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</fieldset>
|
||||
|
||||
{{if .HasPassword}}
|
||||
<fieldset><legend>Two-factor authentication</legend>
|
||||
{{if .TOTPOn}}
|
||||
<p class="sub">On. Signing in with your password also asks for a one-time code
|
||||
from your authenticator app.</p>
|
||||
<form method="post" action="/web/account">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<div class="inline">
|
||||
<div><label for="tf_password">Password</label>
|
||||
<input id="tf_password" name="password" type="password" autocomplete="current-password" required></div>
|
||||
<div><label for="tf_totp">Current one-time code</label>
|
||||
<input id="tf_totp" name="totp" inputmode="numeric" autocomplete="one-time-code" required></div>
|
||||
</div>
|
||||
<div class="inline">
|
||||
<button type="submit" name="action" value="totp-begin">Move to a new authenticator</button>
|
||||
<button class="destroy" type="submit" name="action" value="totp-disable">Turn off</button>
|
||||
</div>
|
||||
</form>
|
||||
{{else if .TOTPKey}}
|
||||
<p class="sub">Add this key to an authenticator app such as Aegis, 2FAS or
|
||||
Google Authenticator, then enter the six-digit code it shows.</p>
|
||||
<p><code>{{.TOTPKey}}</code></p>
|
||||
<p><a href="{{.TOTPLink}}">Open in an authenticator app on this device</a></p>
|
||||
<form method="post" action="/web/account">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="action" value="totp-confirm">
|
||||
<label for="code">Code from the app</label>
|
||||
<input id="code" name="code" inputmode="numeric" autocomplete="one-time-code" required>
|
||||
<button type="submit">Turn on</button>
|
||||
</form>
|
||||
<form method="post" action="/web/account">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="action" value="totp-cancel">
|
||||
<button class="secondary" type="submit">Cancel</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="sub">Off. With it on, a stolen password is not enough to sign in:
|
||||
each sign-in also needs a code from an app on your phone.</p>
|
||||
<form method="post" action="/web/account">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="action" value="totp-begin">
|
||||
<button type="submit">Set up two-factor authentication</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</fieldset>
|
||||
|
||||
<fieldset><legend>Password</legend>
|
||||
<p class="sub">The password you sign in with, not any vault's password.
|
||||
Changing it signs out your other devices.</p>
|
||||
<form method="post" action="/web/account">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="action" value="password">
|
||||
<label for="current_password">Current password</label>
|
||||
<input id="current_password" name="current_password" type="password" autocomplete="current-password" required>
|
||||
{{if .TOTPOn}}<label for="cp_totp">One-time code</label>
|
||||
<input id="cp_totp" name="totp" inputmode="numeric" autocomplete="one-time-code" required>{{end}}
|
||||
<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>
|
||||
</fieldset>
|
||||
{{else}}
|
||||
<p class="note">You sign in through single sign-on{{if .SSO}} at {{.SSO}}{{end}},
|
||||
so your password and second factor are managed there, not here.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}`))
|
||||
@@ -55,6 +55,7 @@ func (s *Server) shell(sess *webSession, title, current string) map[string]any {
|
||||
navItem{Label: "People", Href: "/web/users"},
|
||||
navItem{Label: "Invites", Href: "/web/invites"})
|
||||
}
|
||||
nav = append(nav, navItem{Label: "Account", Href: "/web/account"})
|
||||
for i := range nav {
|
||||
nav[i].Current = nav[i].Label == current
|
||||
}
|
||||
@@ -403,6 +404,8 @@ func accountNotice(username string, act accountAction) string {
|
||||
return username + " is no longer a server administrator."
|
||||
case accountClearTOTP:
|
||||
return username + " can sign in without a one-time code and should enrol a new one."
|
||||
case accountSignOut:
|
||||
return username + " is signed out on every device and browser."
|
||||
case accountDelete:
|
||||
return username + " was deleted, along with their group memberships and sessions."
|
||||
}
|
||||
|
||||
@@ -146,10 +146,20 @@ type Session struct {
|
||||
Label string `json:"label,omitempty"`
|
||||
// CSRF guards the browser forms of a session held in a cookie.
|
||||
CSRF string `json:"csrf,omitempty"`
|
||||
// Browser marks a session held in a web cookie rather than by a client
|
||||
// application. Sessions from before the field have the label "web".
|
||||
Browser bool `json:"browser,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
// LastSeenAt is when the session last made a request, to a resolution
|
||||
// of a few minutes: recording every request would rewrite the state
|
||||
// document on each one.
|
||||
LastSeenAt time.Time `json:"last_seen_at,omitempty"`
|
||||
}
|
||||
|
||||
// IsBrowser reports whether the session belongs to a web browser.
|
||||
func (s *Session) IsBrowser() bool { return s.Browser || s.Label == "web" }
|
||||
|
||||
// DeviceAuth backs the browser-assisted login used for OIDC on desktop and
|
||||
// Android: the client shows a short user code, the browser approves it, the
|
||||
// client polls until a session token appears.
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
keeporgd (0.10.0) unstable; urgency=medium
|
||||
|
||||
* New Account page in the web interface: every signed-in browser and
|
||||
client with a button to sign each out, or all but the current one;
|
||||
two-factor setup, replacement and removal; the password form, moved
|
||||
from the vault page. Matching API: GET and DELETE
|
||||
/api/v1/auth/sessions, DELETE /api/v1/auth/totp. Replacing or removing
|
||||
an active second factor now needs the password and a current code.
|
||||
Administrators can sign a user out everywhere.
|
||||
|
||||
-- Azer Abdullaev <github@reactorcoremeltdown.anonaddy.com> Mon, 02 Nov 2026 13:27:12 +0100
|
||||
|
||||
keeporgd (0.9.0) unstable; urgency=medium
|
||||
|
||||
* A client starting a browser sign-in may pass return_url, a keeporg:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# 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.9.0
|
||||
VERSION_SERVER := 0.10.0
|
||||
VERSION_LINUX := 0.5.0
|
||||
VERSION_MACOS := 0.5.0
|
||||
VERSION_WINDOWS := 0.5.0
|
||||
|
||||
Reference in New Issue
Block a user