Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74cbcfe15f | |||
| 38a3804b8a | |||
| c846345a1c | |||
| da627b2bc7 | |||
| 511c6026ac | |||
| 85b401d05e | |||
| b54ff1b002 |
@@ -227,27 +227,28 @@ After editing `config.json` by hand, run `sudo systemctl reload ghostwire`.
|
|||||||
|
|
||||||
Base path `/api/v1`. The web interface signs in with a session cookie; every
|
Base path `/api/v1`. The web interface signs in with a session cookie; every
|
||||||
user is an admin. Apps and scripts use `Authorization: Bearer <token>`; create
|
user is an admin. Apps and scripts use `Authorization: Bearer <token>`; create
|
||||||
the token under Settings → Pair iOS app, or in the iOS app under Settings →
|
the token under Settings → Pair iOS app. A token belongs to the user who made
|
||||||
Access → API tokens. A token belongs to the user who made
|
|
||||||
it and is revoked when that user is deleted. A read-only token may only use
|
it and is revoked when that user is deleted. A read-only token may only use
|
||||||
GET. Full-access tokens can do everything the web interface does except backup
|
GET. Full-access tokens can do everything the web interface does except the
|
||||||
and restore. Users, passwords and API tokens need a full-access token even for
|
endpoints marked "signed in": users, passwords, API tokens, the sign-in rules,
|
||||||
reading.
|
backup and restore.
|
||||||
|
|
||||||
For a user with two-step sign-in, `POST /auth/login` answers
|
For a user with two-step sign-in, `POST /auth/login` answers
|
||||||
`{"mfa": true, "ticket": "…", "methods": ["key", "totp", "recovery"]}`
|
`{"mfa": true, "ticket": "…", "methods": ["key", "totp", "recovery"]}`
|
||||||
instead of starting a session; the ticket is good for 5 minutes, and one of
|
instead of starting a session; the ticket is good for 5 minutes, and one of
|
||||||
the `/auth/login/…` steps turns it into the session. `PATCH /settings`
|
the `/auth/login/…` steps turns it into the session. `PATCH /settings`
|
||||||
`{"signin": {"requireMfa": true}}` requires two-step sign-in for every user.
|
`{"signin": {"requireMfa": true}}` requires two-step sign-in for every user;
|
||||||
|
only a signed-in user can change it.
|
||||||
|
|
||||||
`POST /users` and `POST /users/{id}/reset-password` take
|
`POST /users` and `POST /users/{id}/reset-password` take
|
||||||
`{"password": "…", "mustChangePassword": true}`; with `true` (the default) the
|
`{"password": "…", "mustChangePassword": true}`; with `true` (the default) the
|
||||||
user can do nothing but choose a new password at the next sign-in.
|
user can do nothing but choose a new password at the next sign-in.
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /auth/login · /auth/logout GET /auth/me POST /auth/password (own password)
|
POST /auth/login · /auth/logout GET /auth/me
|
||||||
GET /users POST /users PATCH /users/{id} DELETE /users/{id}
|
signed in: POST /auth/password (own password)
|
||||||
POST /users/{id}/reset-password POST /users/{id}/reset-mfa
|
signed in: GET|POST /users · PATCH|DELETE /users/{id}
|
||||||
|
signed in: POST /users/{id}/reset-password · /users/{id}/reset-mfa
|
||||||
GET /auth/options (public: is passkey sign-in offered here)
|
GET /auth/options (public: is passkey sign-in offered here)
|
||||||
POST /auth/login/totp · /auth/login/recovery {"ticket", "code"}
|
POST /auth/login/totp · /auth/login/recovery {"ticket", "code"}
|
||||||
POST /auth/login/key/begin {"ticket"} · /auth/login/key/finish?ticket= (body: the WebAuthn credential)
|
POST /auth/login/key/begin {"ticket"} · /auth/login/key/finish?ticket= (body: the WebAuthn credential)
|
||||||
@@ -265,8 +266,7 @@ GET /peers/{id}/latency (24 h, one point per 5 minutes)
|
|||||||
GET /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup
|
GET /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup
|
||||||
GET /settings PATCH /settings POST /restart
|
GET /settings PATCH /settings POST /restart
|
||||||
GET /logs?level=&limit=&audit=1 GET /logs/download
|
GET /logs?level=&limit=&audit=1 GET /logs/download
|
||||||
GET /tokens POST /tokens DELETE /tokens/{id}
|
signed in: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore
|
||||||
signed in: GET /backup · POST /restore
|
|
||||||
public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens)
|
public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -296,9 +296,9 @@ override a drop in another table, so if ufw or firewalld is active, allow UDP
|
|||||||
## iOS app
|
## iOS app
|
||||||
|
|
||||||
The native iPhone app (SwiftUI, iOS 17+) lives in its own project,
|
The native iPhone app (SwiftUI, iOS 17+) lives in its own project,
|
||||||
GHOSTWIRE-Companion. It does everything the web interface does except
|
GHOSTWIRE-Companion. It manages peers, the server and the app settings and
|
||||||
backup and restore, and adding an authenticator app or passkeys for two-step
|
shows stats and logs. Users, passwords, API tokens, two-step sign-in, backup
|
||||||
sign-in. Pair it in the web interface under
|
and restore stay in the web interface. Pair it in the web interface under
|
||||||
Settings → Pair iOS app: scan the QR code, or tap "Copy pairing code" and paste
|
Settings → Pair iOS app: scan the QR code, or tap "Copy pairing code" and paste
|
||||||
it into the app's "Enter manually". Self-signed certificates are pinned during
|
it into the app's "Enter manually". Self-signed certificates are pinned during
|
||||||
pairing.
|
pairing.
|
||||||
|
|||||||
@@ -97,17 +97,6 @@ func (a *App) guard(adminOnly bool, h http.HandlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fullAccess refuses read-only tokens, also for GET.
|
|
||||||
func fullAccess(h http.HandlerFunc) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if who(r).Scope == "ro" {
|
|
||||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "this token is read-only"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h(w, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyResult saves-then-applies: the config is already stored, so a kernel
|
// applyResult saves-then-applies: the config is already stored, so a kernel
|
||||||
// error is reported but does not undo the change.
|
// error is reported but does not undo the change.
|
||||||
func (a *App) apply() string {
|
func (a *App) apply() string {
|
||||||
@@ -121,8 +110,6 @@ func (a *App) routes() http.Handler {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
g := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(false, h)) }
|
g := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(false, h)) }
|
||||||
adm := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(true, h)) }
|
adm := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(true, h)) }
|
||||||
// full is for signed-in users and full-access tokens, even for reading.
|
|
||||||
full := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(false, fullAccess(h))) }
|
|
||||||
|
|
||||||
mux.HandleFunc("POST /api/v1/auth/login", a.login)
|
mux.HandleFunc("POST /api/v1/auth/login", a.login)
|
||||||
mux.HandleFunc("POST /api/v1/auth/logout", a.logout)
|
mux.HandleFunc("POST /api/v1/auth/logout", a.logout)
|
||||||
@@ -146,13 +133,13 @@ func (a *App) routes() http.Handler {
|
|||||||
adm("DELETE /api/v1/auth/mfa/keys/{id}", a.keyRemove)
|
adm("DELETE /api/v1/auth/mfa/keys/{id}", a.keyRemove)
|
||||||
adm("POST /api/v1/auth/mfa/recovery-codes", a.newRecoveryCodesHandler)
|
adm("POST /api/v1/auth/mfa/recovery-codes", a.newRecoveryCodesHandler)
|
||||||
g("GET /api/v1/auth/me", a.me)
|
g("GET /api/v1/auth/me", a.me)
|
||||||
full("POST /api/v1/auth/password", a.changePassword)
|
adm("POST /api/v1/auth/password", a.changePassword)
|
||||||
full("GET /api/v1/users", a.listUsers)
|
adm("GET /api/v1/users", a.listUsers)
|
||||||
full("POST /api/v1/users", a.createUser)
|
adm("POST /api/v1/users", a.createUser)
|
||||||
full("PATCH /api/v1/users/{id}", a.patchUser)
|
adm("PATCH /api/v1/users/{id}", a.patchUser)
|
||||||
full("POST /api/v1/users/{id}/reset-password", a.resetPassword)
|
adm("POST /api/v1/users/{id}/reset-password", a.resetPassword)
|
||||||
full("DELETE /api/v1/users/{id}", a.deleteUser)
|
adm("DELETE /api/v1/users/{id}", a.deleteUser)
|
||||||
full("POST /api/v1/users/{id}/reset-mfa", a.resetMFA)
|
adm("POST /api/v1/users/{id}/reset-mfa", a.resetMFA)
|
||||||
|
|
||||||
g("GET /api/v1/status", a.status)
|
g("GET /api/v1/status", a.status)
|
||||||
g("GET /api/v1/stats", a.allStats)
|
g("GET /api/v1/stats", a.allStats)
|
||||||
@@ -182,13 +169,14 @@ func (a *App) routes() http.Handler {
|
|||||||
mux.HandleFunc("POST /api/v1/setup/{token}", a.setupRedeem)
|
mux.HandleFunc("POST /api/v1/setup/{token}", a.setupRedeem)
|
||||||
|
|
||||||
// Full-access tokens (the iOS app) may change app settings, read logs and
|
// Full-access tokens (the iOS app) may change app settings, read logs and
|
||||||
// manage users and tokens. Backups need a signed-in user.
|
// restart. Users, passwords, API tokens, the sign-in rules and backups
|
||||||
|
// need a signed-in user.
|
||||||
g("GET /api/v1/settings", a.getSettings)
|
g("GET /api/v1/settings", a.getSettings)
|
||||||
g("PATCH /api/v1/settings", a.patchSettings)
|
g("PATCH /api/v1/settings", a.patchSettings)
|
||||||
g("POST /api/v1/restart", a.restart)
|
g("POST /api/v1/restart", a.restart)
|
||||||
full("GET /api/v1/tokens", a.listTokens)
|
adm("GET /api/v1/tokens", a.listTokens)
|
||||||
full("POST /api/v1/tokens", a.createToken)
|
adm("POST /api/v1/tokens", a.createToken)
|
||||||
full("DELETE /api/v1/tokens/{id}", a.deleteToken)
|
adm("DELETE /api/v1/tokens/{id}", a.deleteToken)
|
||||||
g("GET /api/v1/logs", a.logs)
|
g("GET /api/v1/logs", a.logs)
|
||||||
g("GET /api/v1/logs/download", a.downloadLog)
|
g("GET /api/v1/logs/download", a.downloadLog)
|
||||||
adm("GET /api/v1/backup", a.backup)
|
adm("GET /api/v1/backup", a.backup)
|
||||||
@@ -232,7 +220,7 @@ func (a *App) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("login failed", "user", in.Username, "remote", ip, "reason", err.Error())
|
slog.Warn("login failed", "user", in.Username, "remote", ip, "reason", err.Error())
|
||||||
code := http.StatusUnauthorized
|
code := http.StatusUnauthorized
|
||||||
if errors.Is(err, errLocked) {
|
if errors.Is(err, errLocked) || errors.Is(err, errBusy) {
|
||||||
code = http.StatusTooManyRequests
|
code = http.StatusTooManyRequests
|
||||||
}
|
}
|
||||||
writeJSON(w, code, map[string]string{"error": err.Error()})
|
writeJSON(w, code, map[string]string{"error": err.Error()})
|
||||||
@@ -270,9 +258,6 @@ func (a *App) me(w http.ResponseWriter, r *http.Request) {
|
|||||||
"id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope,
|
"id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope,
|
||||||
"mustChangePassword": p.MustChangePassword, "mfaSetupRequired": p.MFASetupRequired, "version": version, "session": p.Session,
|
"mustChangePassword": p.MustChangePassword, "mfaSetupRequired": p.MFASetupRequired, "version": version, "session": p.Session,
|
||||||
}
|
}
|
||||||
if p.TokenID != "" {
|
|
||||||
out["tokenId"] = p.TokenID // lets an app find its own token in /tokens
|
|
||||||
}
|
|
||||||
if _, u := a.store.Get().userByID(p.UserID); u != nil {
|
if _, u := a.store.Get().userByID(p.UserID); u != nil {
|
||||||
out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created
|
out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created
|
||||||
}
|
}
|
||||||
@@ -1014,7 +999,6 @@ func (a *App) getSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
"decoy": cfg.Decoy,
|
"decoy": cfg.Decoy,
|
||||||
"signin": cfg.SignIn,
|
"signin": cfg.SignIn,
|
||||||
"geo": a.geoStatus(),
|
"geo": a.geoStatus(),
|
||||||
"adminUsername": a.username(cfg, who(r).UserID), // kept for older iOS app versions
|
|
||||||
"fingerprint": a.tls.Fingerprint(),
|
"fingerprint": a.tls.Fingerprint(),
|
||||||
"logPath": a.logPath,
|
"logPath": a.logPath,
|
||||||
})
|
})
|
||||||
@@ -1026,8 +1010,8 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, err)
|
writeErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := m["adminUsername"]; ok {
|
if _, ok := m["signin"]; ok && !who(r).IsAdmin {
|
||||||
writeErr(w, badRequest("usernames are changed under /users"))
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "API tokens cannot change the sign-in rules; sign in to the web interface"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var restart bool
|
var restart bool
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ td { padding: 12px; border-bottom: 1px solid var(--line-2); vertical-align: midd
|
|||||||
tr:last-child td { border-bottom: 0; }
|
tr:last-child td { border-bottom: 0; }
|
||||||
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
td .note { font-size: 12px; color: var(--ink-3); }
|
td .note { font-size: 12px; color: var(--ink-3); }
|
||||||
|
a.pname { color: var(--ink); font-weight: 600; text-decoration: none; }
|
||||||
|
a.pname:hover, a.pname:focus-visible { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
|
||||||
.empty { padding: 24px 12px; margin: 0; text-align: center; color: var(--ink-3); }
|
.empty { padding: 24px 12px; margin: 0; text-align: center; color: var(--ink-3); }
|
||||||
|
|
||||||
/* forms */
|
/* forms */
|
||||||
@@ -171,10 +173,28 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-d
|
|||||||
.kv dt { color: var(--ink-2); }
|
.kv dt { color: var(--ink-2); }
|
||||||
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
/* checks */
|
/* health: public addresses, then one tile per check */
|
||||||
.chk { display: flex; gap: 10px; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
|
.hchead { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||||
.chk:last-child { border-bottom: 0; }
|
.hchead > span { font-size: 13px; color: var(--ink-2); }
|
||||||
.chk b { font-weight: 500; min-width: 160px; }
|
.hchead > span.bad { color: var(--bad-ink); font-weight: 500; }
|
||||||
|
.hcaddrs { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; margin-top: 16px; }
|
||||||
|
.hcaddr { background: var(--ground); border-radius: 10px; padding: 14px 16px; min-width: 0; }
|
||||||
|
.hcaddr .l { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ink-2); }
|
||||||
|
.hcaddr .v { margin-top: 4px; font-size: 15px; font-weight: 500; overflow-wrap: anywhere; }
|
||||||
|
.hcaddr .v.mono { font-size: 20px; }
|
||||||
|
.hcaddr .n { font-family: var(--sans); font-size: 12px; font-weight: 400; color: var(--ink-2); }
|
||||||
|
.hcaddr.bad { background: #fdf6f5; box-shadow: inset 0 0 0 1px #e6b3b0; }
|
||||||
|
.hcaddr.bad .v { color: var(--bad-ink); }
|
||||||
|
.hctiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px; margin-top: 12px; }
|
||||||
|
.hctile { border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||||
|
.hctile .l { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ink-2); }
|
||||||
|
.hctile .l > span:first-child { flex: 1; min-width: 0; }
|
||||||
|
.hctile .s { font-size: 15px; font-weight: 500; }
|
||||||
|
.hctile .r { font-size: 11.5px; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||||
|
.hctile .p { font-size: 12.5px; color: var(--bad-ink); overflow-wrap: anywhere; }
|
||||||
|
.hctile.bad { border-color: #e6b3b0; background: #fdf6f5; }
|
||||||
|
.hctile.bad .s { color: var(--bad-ink); }
|
||||||
|
@media (max-width: 640px) { .hctiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
|
|
||||||
/* activity */
|
/* activity */
|
||||||
.ev { display: flex; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
|
.ev { display: flex; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
|
||||||
|
|||||||
@@ -257,13 +257,26 @@
|
|||||||
else if (okMsg) toast(okMsg);
|
else if (okMsg) toast(okMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dialog shows a modal dialog. Extensions such as Bitwarden move elements
|
||||||
|
// around in <body>; a moved dialog stays open but drops out of the top
|
||||||
|
// layer to the bottom of the page, so it is shown as a modal again. That
|
||||||
|
// goes through close(), whose close event arrives after the dialog is open
|
||||||
|
// again and is kept from the listeners added by callers.
|
||||||
function dialog(build) {
|
function dialog(build) {
|
||||||
const d = h('dialog');
|
const d = h('dialog');
|
||||||
const close = () => d.close();
|
const close = () => d.close();
|
||||||
d.addEventListener('close', () => d.remove());
|
const moved = new MutationObserver(() => {
|
||||||
|
if (d.open && d.isConnected && !d.matches(':modal')) { d.close(); d.showModal(); }
|
||||||
|
});
|
||||||
|
d.addEventListener('close', (e) => {
|
||||||
|
if (d.open) { e.stopImmediatePropagation(); return; }
|
||||||
|
moved.disconnect();
|
||||||
|
d.remove();
|
||||||
|
});
|
||||||
d.append(build(close));
|
d.append(build(close));
|
||||||
document.body.append(d);
|
document.body.append(d);
|
||||||
d.showModal();
|
d.showModal();
|
||||||
|
moved.observe(document.body, { childList: true, subtree: true });
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1111,7 +1124,7 @@
|
|||||||
return hit && keep;
|
return hit && keep;
|
||||||
});
|
});
|
||||||
tbody.replaceChildren(...sorted(rows).map((p) => h('tr', null,
|
tbody.replaceChildren(...sorted(rows).map((p) => h('tr', null,
|
||||||
h('td', null, h('a', { href: '#/peers/' + p.id }, h('strong', null, p.name)), p.note ? h('div', { class: 'note' }, p.note) : null),
|
h('td', null, h('a', { class: 'pname', href: '#/peers/' + p.id }, p.name), p.note ? h('div', { class: 'note' }, p.note) : null),
|
||||||
h('td', { class: 'mono' }, p.ipv4),
|
h('td', { class: 'mono' }, p.ipv4),
|
||||||
h('td', null, badge(peerState(p))),
|
h('td', null, badge(peerState(p))),
|
||||||
h('td', { class: 'mono muted' }, p.stats.endpoint || '–',
|
h('td', { class: 'mono muted' }, p.stats.endpoint || '–',
|
||||||
@@ -1497,6 +1510,69 @@
|
|||||||
|
|
||||||
const DNS_PRESETS = [['Quad9', '9.9.9.9, 149.112.112.112']];
|
const DNS_PRESETS = [['Quad9', '9.9.9.9, 149.112.112.112']];
|
||||||
|
|
||||||
|
// healthParts turns the server's checks into the Health card: the public
|
||||||
|
// address per IP family (from its uplink and public address checks), then
|
||||||
|
// one tile per other check with a plain-word status, the raw setting and,
|
||||||
|
// when it fails, what is wrong.
|
||||||
|
function healthParts(checks) {
|
||||||
|
const by = Object.fromEntries(checks.map((c) => [c.name, c]));
|
||||||
|
const addrs = [];
|
||||||
|
for (const fam of ['IPv4', 'IPv6']) {
|
||||||
|
const up = by[fam + ' uplink'], pub = by['Public ' + fam];
|
||||||
|
if (!up) continue;
|
||||||
|
const m = pub && pub.ok ? /^(\S+) \((.+)\)$/.exec(pub.detail) : null;
|
||||||
|
addrs.push({
|
||||||
|
label: 'Public ' + fam + (up.ok ? ' · ' + up.detail : ''),
|
||||||
|
ok: up.ok && (!pub || pub.ok),
|
||||||
|
value: m ? m[1] : (pub ? pub.detail : up.detail),
|
||||||
|
note: m ? m[2] : null,
|
||||||
|
mono: !!(pub && pub.ok),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const sysctl = (d) => d.replace(/^net\.ipv[46]\.(conf\.)?/, '');
|
||||||
|
const tiles = checks.filter((c) => !/^(IPv[46] uplink|Public IPv[46])$/.test(c.name)).map((c) => {
|
||||||
|
const t = { label: c.name, ok: c.ok, status: c.ok ? 'OK' : 'Problem', raw: null, problem: c.ok ? null : c.detail };
|
||||||
|
switch (c.name) {
|
||||||
|
case 'WireGuard interface': t.status = c.detail; t.problem = null; break;
|
||||||
|
case 'IPv4 forwarding': case 'IPv6 forwarding': t.status = c.ok ? 'On' : 'Off'; t.raw = sysctl(c.detail); t.problem = null; break;
|
||||||
|
case 'IPv6 router announcements': {
|
||||||
|
const [setting, ...why] = c.detail.split(': ');
|
||||||
|
t.label = 'Router announcements';
|
||||||
|
t.status = !c.ok ? 'Ignored' : setting.endsWith('=0') ? 'Not used' : 'Accepted';
|
||||||
|
t.raw = sysctl(setting);
|
||||||
|
t.problem = c.ok || !why.length ? null : why.join(': ');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'nftables rules':
|
||||||
|
t.status = c.ok ? 'Present' : 'Missing';
|
||||||
|
if (/^table /.test(c.detail)) { t.raw = c.detail.replace(/ (present|missing)$/, ''); t.problem = null; }
|
||||||
|
break;
|
||||||
|
case 'Last apply':
|
||||||
|
if (c.ok) { const iso = c.detail.replace(/^applied /, ''); t.status = ago(iso); t.title = fmtStamp(iso); } else t.status = 'Failed';
|
||||||
|
break;
|
||||||
|
case 'Latency check': t.status = c.ok ? 'Tunnel ping works' : 'Failing'; break;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
return { addrs, tiles, failing: checks.filter((c) => !c.ok).length, total: checks.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthCard(checks) {
|
||||||
|
const hp = healthParts(checks);
|
||||||
|
const dot = (ok) => [h('span', { class: ok ? 'dot ok' : 'dot bad' }), h('span', { class: 'sr' }, ok ? 'OK: ' : 'Problem: ')];
|
||||||
|
return h('section', { class: 'card', 'aria-labelledby': 'hc' },
|
||||||
|
h('div', { class: 'hchead' }, h('h2', { id: 'hc' }, 'Health'),
|
||||||
|
h('span', { class: hp.failing ? 'bad' : null }, hp.failing ? hp.failing + ' of ' + hp.total + ' checks failing' : 'All ' + hp.total + ' checks pass')),
|
||||||
|
hp.addrs.length ? h('div', { class: 'hcaddrs' }, hp.addrs.map((a) => h('div', { class: a.ok ? 'hcaddr' : 'hcaddr bad' },
|
||||||
|
h('div', { class: 'l' }, dot(a.ok), a.label),
|
||||||
|
h('div', { class: a.mono ? 'v mono' : 'v' }, a.value, a.note ? h('span', { class: 'n' }, ' ' + a.note) : null)))) : null,
|
||||||
|
h('div', { class: 'hctiles' }, hp.tiles.map((t) => h('div', { class: t.ok ? 'hctile' : 'hctile bad', title: t.title || null },
|
||||||
|
h('div', { class: 'l' }, h('span', null, t.label), dot(t.ok)),
|
||||||
|
h('div', { class: 's' }, t.status),
|
||||||
|
t.raw ? h('div', { class: 'r mono' }, t.raw) : null,
|
||||||
|
t.problem ? h('div', { class: 'p' }, t.problem) : null))));
|
||||||
|
}
|
||||||
|
|
||||||
async function viewServer(wrap) {
|
async function viewServer(wrap) {
|
||||||
const [srv, st] = await Promise.all([api('GET', '/server'), api('GET', '/status')]);
|
const [srv, st] = await Promise.all([api('GET', '/server'), api('GET', '/status')]);
|
||||||
const orig = JSON.parse(JSON.stringify(srv));
|
const orig = JSON.parse(JSON.stringify(srv));
|
||||||
@@ -1567,11 +1643,7 @@
|
|||||||
fill(wrap,
|
fill(wrap,
|
||||||
h('div', null, h('h1', null, 'Server'), h('p', { class: 'sub' }, 'WireGuard interface, address plan, client defaults and firewall')),
|
h('div', null, h('h1', null, 'Server'), h('p', { class: 'sub' }, 'WireGuard interface, address plan, client defaults and firewall')),
|
||||||
result,
|
result,
|
||||||
h('section', { class: 'card', 'aria-labelledby': 'hc' },
|
healthCard(st.checks),
|
||||||
h('h2', { id: 'hc' }, 'Health'),
|
|
||||||
h('div', { style: { marginTop: '8px' } }, st.checks.map((c) => h('div', { class: 'chk' },
|
|
||||||
h('span', { class: c.ok ? 'dot ok' : 'dot bad' }), h('span', { class: 'sr' }, c.ok ? 'OK: ' : 'Problem: '),
|
|
||||||
h('b', null, c.name), h('span', { class: c.ok ? 'muted' : null }, c.detail))))),
|
|
||||||
|
|
||||||
h('section', { class: 'card', 'aria-labelledby': 'if' },
|
h('section', { class: 'card', 'aria-labelledby': 'if' },
|
||||||
h('h2', { id: 'if' }, 'Interface'),
|
h('h2', { id: 'if' }, 'Interface'),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -26,12 +27,23 @@ const (
|
|||||||
argonKeyLen = 32
|
argonKeyLen = 32
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Every argon2 run takes argonMemory (64 MiB). argonSlots caps how many run
|
||||||
|
// at once, so a burst of sign-ins cannot run the server out of memory: two
|
||||||
|
// slots are 128 MiB at most.
|
||||||
|
var argonSlots = make(chan struct{}, 2)
|
||||||
|
|
||||||
|
func argonKey(pw, salt []byte, t, m uint32, p uint8, n uint32) []byte {
|
||||||
|
argonSlots <- struct{}{}
|
||||||
|
defer func() { <-argonSlots }()
|
||||||
|
return argon2.IDKey(pw, salt, t, m, p, n)
|
||||||
|
}
|
||||||
|
|
||||||
func hashPassword(pw string) (string, error) {
|
func hashPassword(pw string) (string, error) {
|
||||||
salt := make([]byte, 16)
|
salt := make([]byte, 16)
|
||||||
if _, err := rand.Read(salt); err != nil {
|
if _, err := rand.Read(salt); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
key := argonKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||||
b64 := base64.RawStdEncoding
|
b64 := base64.RawStdEncoding
|
||||||
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||||
argon2.Version, argonMemory, argonTime, argonThreads, b64.EncodeToString(salt), b64.EncodeToString(key)), nil
|
argon2.Version, argonMemory, argonTime, argonThreads, b64.EncodeToString(salt), b64.EncodeToString(key)), nil
|
||||||
@@ -54,7 +66,7 @@ func verifyPassword(encoded, pw string) bool {
|
|||||||
if err1 != nil || err2 != nil {
|
if err1 != nil || err2 != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
got := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(len(want)))
|
got := argonKey([]byte(pw), salt, t, m, p, uint32(len(want)))
|
||||||
return subtle.ConstantTimeCompare(got, want) == 1
|
return subtle.ConstantTimeCompare(got, want) == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,13 +152,17 @@ type Auth struct {
|
|||||||
sessions map[string]*session
|
sessions map[string]*session
|
||||||
used map[string]tokenUse
|
used map[string]tokenUse
|
||||||
logins map[string]tokenUse // last sign-in per user ID
|
logins map[string]tokenUse // last sign-in per user ID
|
||||||
fails map[string]*failState
|
fails map[string]*failState // by lockKey
|
||||||
|
waiting int // sign-ins waiting for or running a password check
|
||||||
mfa mfaState
|
mfa mfaState
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxFailures = 5
|
maxFailures = 5
|
||||||
lockoutTime = 15 * time.Minute
|
lockoutTime = 15 * time.Minute
|
||||||
|
// maxWaiting sign-ins may wait for a password check; more are turned
|
||||||
|
// away until the queue is shorter.
|
||||||
|
maxWaiting = 16
|
||||||
)
|
)
|
||||||
|
|
||||||
func newAuth(s *Store) *Auth {
|
func newAuth(s *Store) *Auth {
|
||||||
@@ -155,23 +171,51 @@ func newAuth(s *Store) *Auth {
|
|||||||
|
|
||||||
func cookieName() string { return appName + "_session" }
|
func cookieName() string { return appName + "_session" }
|
||||||
|
|
||||||
var errLocked = errors.New("too many failed attempts, try again later")
|
var (
|
||||||
|
errLocked = errors.New("too many failed attempts, try again later")
|
||||||
|
errBusy = errors.New("too many sign-ins at once, try again in a moment")
|
||||||
|
)
|
||||||
|
|
||||||
|
// lockKey is what failed sign-ins are counted by: the IPv4 address, or the
|
||||||
|
// /64 network of an IPv6 address, since one device can pick any address in
|
||||||
|
// its /64.
|
||||||
|
func lockKey(ip string) string {
|
||||||
|
a, err := netip.ParseAddr(ip)
|
||||||
|
if err != nil || a.Unmap().Is4() {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
p, _ := a.Prefix(64)
|
||||||
|
return p.String()
|
||||||
|
}
|
||||||
|
|
||||||
// Login checks the credentials and returns a new session id, or, for a user
|
// Login checks the credentials and returns a new session id, or, for a user
|
||||||
// with two-step sign-in, a ticket for the second step.
|
// with two-step sign-in, a ticket for the second step.
|
||||||
func (a *Auth) Login(user, pw, ip string) (sessionID, ticket string, err error) {
|
func (a *Auth) Login(user, pw, ip string) (sessionID, ticket string, err error) {
|
||||||
a.mu.Lock()
|
|
||||||
f := a.fails[ip]
|
|
||||||
if f != nil && time.Now().Before(f.until) {
|
|
||||||
a.mu.Unlock()
|
|
||||||
return "", "", errLocked
|
|
||||||
}
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
cfg := a.store.Get()
|
cfg := a.store.Get()
|
||||||
if !cfg.passwordSet() {
|
if !cfg.passwordSet() {
|
||||||
return "", "", errors.New("no password is set; run: " + appName + " passwd")
|
return "", "", errors.New("no password is set; run: " + appName + " passwd")
|
||||||
}
|
}
|
||||||
|
// The attempt counts as failed before the password is checked, so
|
||||||
|
// parallel attempts cannot get past the lockout; a right password takes
|
||||||
|
// it back.
|
||||||
|
a.mu.Lock()
|
||||||
|
if a.lockedLocked(ip) {
|
||||||
|
a.mu.Unlock()
|
||||||
|
return "", "", errLocked
|
||||||
|
}
|
||||||
|
if a.waiting >= maxWaiting {
|
||||||
|
a.mu.Unlock()
|
||||||
|
return "", "", errBusy
|
||||||
|
}
|
||||||
|
a.waiting++
|
||||||
|
undo := a.failLocked(ip)
|
||||||
|
a.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.waiting--
|
||||||
|
a.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
// An unknown username costs as much time as a wrong password, so the
|
// An unknown username costs as much time as a wrong password, so the
|
||||||
// answer time does not tell which usernames exist.
|
// answer time does not tell which usernames exist.
|
||||||
u := cfg.userByName(strings.TrimSpace(user))
|
u := cfg.userByName(strings.TrimSpace(user))
|
||||||
@@ -185,21 +229,13 @@ func (a *Auth) Login(user, pw, ip string) (sessionID, ticket string, err error)
|
|||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
if !okUser || !okPw {
|
if !okUser || !okPw {
|
||||||
if f == nil {
|
|
||||||
f = &failState{}
|
|
||||||
a.fails[ip] = f
|
|
||||||
}
|
|
||||||
f.count++
|
|
||||||
if f.count >= maxFailures {
|
|
||||||
f.count = 0
|
|
||||||
f.until = time.Now().Add(lockoutTime)
|
|
||||||
}
|
|
||||||
return "", "", errors.New("wrong username or password")
|
return "", "", errors.New("wrong username or password")
|
||||||
}
|
}
|
||||||
|
undo()
|
||||||
if u.hasMFA() {
|
if u.hasMFA() {
|
||||||
return "", a.newTicketLocked(u, ip), nil
|
return "", a.newTicketLocked(u, ip), nil
|
||||||
}
|
}
|
||||||
delete(a.fails, ip)
|
delete(a.fails, lockKey(ip))
|
||||||
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
|
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
|
||||||
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip}), "", nil
|
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip}), "", nil
|
||||||
}
|
}
|
||||||
@@ -321,9 +357,9 @@ func (a *Auth) sweep() {
|
|||||||
delete(a.sessions, id)
|
delete(a.sessions, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for ip, f := range a.fails {
|
for key, f := range a.fails {
|
||||||
if now.After(f.until) && f.count == 0 {
|
if now.After(f.until) && f.count == 0 {
|
||||||
delete(a.fails, ip)
|
delete(a.fails, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for id, t := range a.mfa.tickets {
|
for id, t := range a.mfa.tickets {
|
||||||
|
|||||||
+75
-6
@@ -14,6 +14,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -312,8 +313,13 @@ func TestAPI(t *testing.T) {
|
|||||||
secret := tok["token"].(string)
|
secret := tok["token"].(string)
|
||||||
|
|
||||||
// Read-only token: GET works, changes are refused, admin endpoints too.
|
// Read-only token: GET works, changes are refused, admin endpoints too.
|
||||||
bearer := func(method, path string, want int) {
|
bearer := func(method, path string, want int, body ...any) {
|
||||||
req, _ := http.NewRequest(method, srv.URL+"/api/v1"+path, nil)
|
var rd io.Reader
|
||||||
|
if len(body) > 0 {
|
||||||
|
b, _ := json.Marshal(body[0])
|
||||||
|
rd = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(method, srv.URL+"/api/v1"+path, rd)
|
||||||
req.Header.Set("Authorization", "Bearer "+secret)
|
req.Header.Set("Authorization", "Bearer "+secret)
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -329,10 +335,20 @@ func TestAPI(t *testing.T) {
|
|||||||
bearer("GET", "/tokens", 403)
|
bearer("GET", "/tokens", 403)
|
||||||
bearer("GET", "/peers/"+id+"/setup", 403) // the link would set up a device
|
bearer("GET", "/peers/"+id+"/setup", 403) // the link would set up a device
|
||||||
|
|
||||||
// A full-access token manages users and tokens, but not backups.
|
// A full-access token changes settings, but users, passwords, tokens,
|
||||||
|
// the sign-in rules and backups need a signed-in user.
|
||||||
secret = call("POST", "/tokens", map[string]string{"name": "full", "scope": "rw"}, 201)["token"].(string)
|
secret = call("POST", "/tokens", map[string]string{"name": "full", "scope": "rw"}, 201)["token"].(string)
|
||||||
bearer("GET", "/users", 200)
|
uid := call("GET", "/auth/me", nil, 200)["id"].(string)
|
||||||
bearer("GET", "/tokens", 200)
|
bearer("PATCH", "/settings", 200, map[string]any{"log": store.Get().Log})
|
||||||
|
bearer("PATCH", "/settings", 403, map[string]any{"signin": map[string]bool{"requireMfa": false}})
|
||||||
|
bearer("GET", "/users", 403)
|
||||||
|
bearer("POST", "/users", 403, map[string]any{"username": "eve", "password": "correct horse battery"})
|
||||||
|
bearer("POST", "/users/"+uid+"/reset-password", 403, map[string]any{"password": "correct horse battery"})
|
||||||
|
bearer("POST", "/users/"+uid+"/reset-mfa", 403)
|
||||||
|
bearer("POST", "/auth/password", 403, map[string]string{"current": "x", "new": "y"})
|
||||||
|
bearer("GET", "/tokens", 403)
|
||||||
|
bearer("POST", "/tokens", 403, map[string]string{"name": "more", "scope": "rw"})
|
||||||
|
bearer("DELETE", "/tokens/"+tok["id"].(string), 403)
|
||||||
bearer("GET", "/backup", 403)
|
bearer("GET", "/backup", 403)
|
||||||
|
|
||||||
call("DELETE", "/peers/"+id, nil, 200)
|
call("DELETE", "/peers/"+id, nil, 200)
|
||||||
@@ -409,6 +425,60 @@ func TestSysctlConf(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoginLockout(t *testing.T) {
|
||||||
|
store, err := openStore(filepath.Join(t.TempDir(), "config.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hash, _ := hashPassword("a long test password")
|
||||||
|
_ = store.Update(func(c *Config) error { c.Users[0].PasswordHash = hash; return nil })
|
||||||
|
a := newAuth(store)
|
||||||
|
const right, wrong = "a long test password", "a wrong password"
|
||||||
|
|
||||||
|
// Ten wrong attempts at once from one /64: five are checked, the others
|
||||||
|
// are locked out before any password check.
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
got := map[string]int{}
|
||||||
|
for i := range 10 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, _, err := a.Login("admin", wrong, fmt.Sprintf("2001:db8::%x", i+1))
|
||||||
|
mu.Lock()
|
||||||
|
got[err.Error()]++
|
||||||
|
mu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if got["wrong username or password"] != 5 || got[errLocked.Error()] != 5 {
|
||||||
|
t.Fatalf("parallel attempts: %v", got)
|
||||||
|
}
|
||||||
|
if _, _, err := a.Login("admin", right, "2001:db8::ffff"); !errors.Is(err, errLocked) {
|
||||||
|
t.Fatalf("same /64: %v, want locked", err)
|
||||||
|
}
|
||||||
|
if _, _, err := a.Login("admin", right, "2001:db8:0:1::1"); err != nil {
|
||||||
|
t.Fatalf("other /64: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A right password takes its own attempt back. With two-step sign-in
|
||||||
|
// the earlier failures stay, so wrong codes still lead to the lockout.
|
||||||
|
_ = store.Update(func(c *Config) error { c.Users[0].MFA = &UserMFA{TOTPSecret: newTOTPSecret()}; return nil })
|
||||||
|
ip := "192.0.2.7"
|
||||||
|
for range maxFailures - 1 {
|
||||||
|
_, _, _ = a.Login("admin", wrong, ip)
|
||||||
|
}
|
||||||
|
if _, tk, err := a.Login("admin", right, ip); err != nil || tk == "" {
|
||||||
|
t.Fatalf("5th attempt, right password: ticket %q, %v", tk, err)
|
||||||
|
}
|
||||||
|
if _, _, err := a.Login("admin", wrong, ip); err == nil || errors.Is(err, errLocked) {
|
||||||
|
t.Fatalf("6th attempt: %v, want wrong password", err)
|
||||||
|
}
|
||||||
|
if _, _, err := a.Login("admin", right, ip); !errors.Is(err, errLocked) {
|
||||||
|
t.Fatalf("7th attempt: %v, want locked", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWriteIfChanged(t *testing.T) {
|
func TestWriteIfChanged(t *testing.T) {
|
||||||
p := filepath.Join(t.TempDir(), "x.conf")
|
p := filepath.Join(t.TempDir(), "x.conf")
|
||||||
if ch, err := writeIfChanged(p, "a\n", 0o644); !ch || err != nil {
|
if ch, err := writeIfChanged(p, "a\n", 0o644); !ch || err != nil {
|
||||||
@@ -914,7 +984,6 @@ func TestUsers(t *testing.T) {
|
|||||||
if n := len(admin("GET", "/users", nil, 200)["users"].([]any)); n != 2 {
|
if n := len(admin("GET", "/users", nil, 200)["users"].([]any)); n != 2 {
|
||||||
t.Fatalf("users: %d, want 2", n)
|
t.Fatalf("users: %d, want 2", n)
|
||||||
}
|
}
|
||||||
admin("PATCH", "/settings", map[string]any{"adminUsername": "x"}, 400)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDecoy checks that the decoy hides the web interface but leaves the API
|
// TestDecoy checks that the decoy hides the web interface but leaves the API
|
||||||
|
|||||||
@@ -236,23 +236,34 @@ func newMFAState() mfaState {
|
|||||||
|
|
||||||
var errBadTicket = errors.New("the sign-in expired; enter your password again")
|
var errBadTicket = errors.New("the sign-in expired; enter your password again")
|
||||||
|
|
||||||
// failLocked counts a failed attempt from ip toward the lockout. a.mu must
|
// failLocked counts a failed attempt from ip toward the lockout and returns
|
||||||
// be held.
|
// a function that takes it back, for an attempt counted before it was
|
||||||
func (a *Auth) failLocked(ip string) {
|
// checked. a.mu must be held, also when calling undo.
|
||||||
f := a.fails[ip]
|
func (a *Auth) failLocked(ip string) (undo func()) {
|
||||||
|
key := lockKey(ip)
|
||||||
|
f := a.fails[key]
|
||||||
if f == nil {
|
if f == nil {
|
||||||
f = &failState{}
|
f = &failState{}
|
||||||
a.fails[ip] = f
|
a.fails[key] = f
|
||||||
}
|
}
|
||||||
f.count++
|
f.count++
|
||||||
if f.count >= maxFailures {
|
locked := f.count >= maxFailures
|
||||||
|
if locked {
|
||||||
f.count = 0
|
f.count = 0
|
||||||
f.until = time.Now().Add(lockoutTime)
|
f.until = time.Now().Add(lockoutTime)
|
||||||
}
|
}
|
||||||
|
return func() {
|
||||||
|
switch {
|
||||||
|
case locked:
|
||||||
|
f.count, f.until = maxFailures-1, time.Time{}
|
||||||
|
case f.count > 0:
|
||||||
|
f.count--
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Auth) lockedLocked(ip string) bool {
|
func (a *Auth) lockedLocked(ip string) bool {
|
||||||
f := a.fails[ip]
|
f := a.fails[lockKey(ip)]
|
||||||
return f != nil && time.Now().Before(f.until)
|
return f != nil && time.Now().Before(f.until)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +313,7 @@ func (a *Auth) finishSignIn(u *User, ip string) string {
|
|||||||
cfg := a.store.Get()
|
cfg := a.store.Get()
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
delete(a.fails, ip)
|
delete(a.fails, lockKey(ip))
|
||||||
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
|
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
|
||||||
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip})
|
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user