Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a00cf39558 | ||
|
|
c4e480a0c4 | ||
|
|
95378a1199 | ||
|
|
e004696362 | ||
|
|
2b45aa33e1 | ||
|
|
19aa02e80c | ||
|
|
fe52818ab6 |
@@ -197,6 +197,9 @@ to put one that the operator would ever see.
|
||||
| `VERDANDE_GMAIL_CLIENT_ID` | — | OAuth client from Google Cloud, if anyone will connect a mailbox. |
|
||||
| `VERDANDE_GMAIL_CLIENT_SECRET` | — | |
|
||||
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. Sends nothing about your instance. |
|
||||
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under, so it can ask to be restarted from its own settings. |
|
||||
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
|
||||
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
|
||||
| `VERDANDE_DEV` | `false` | Human-readable logs at debug level. |
|
||||
|
||||
## What lives in `/data`
|
||||
|
||||
@@ -60,6 +60,18 @@ own mailbox from **Settings → Gmail**. The redirect URI is derived from
|
||||
| Variable | Default | What it does |
|
||||
|---|---|---|
|
||||
| `VERDANDE_UPDATE_CHECK` | `false` | Ask GitHub whether a newer release exists. |
|
||||
| `VERDANDE_PANEL_URL` | — | The Yggdrasil panel this instance runs under. |
|
||||
| `VERDANDE_PANEL_TOKEN` | — | An API token from that panel, belonging to somebody with control of this server. |
|
||||
| `VERDANDE_PANEL_SERVER_ID` | — | Which server to restart — the id in the panel's URL. |
|
||||
|
||||
All three together let **Indstillinger → Notifikationer** restart this instance,
|
||||
which is also how it picks up a new version: a container cannot replace its own
|
||||
image, so the panel recreates it and pulls `:latest` on the way. Any one of the
|
||||
three alone does nothing, and the page names the ones that are missing.
|
||||
|
||||
The token is a panel credential with control over that server. Anybody who can
|
||||
read this environment can restart it — which is true of anybody with the host
|
||||
anyway — and it is never sent to a browser.
|
||||
|
||||
Off unless you turn it on. When on, verdande asks the public GitHub releases API
|
||||
at most every six hours and sends nothing about your instance — no identifier, no
|
||||
|
||||
@@ -388,6 +388,195 @@ paths:
|
||||
"204": { description: Off. }
|
||||
"422": { $ref: "#/components/responses/Validation" }
|
||||
|
||||
/api/v1/auth/passkeys:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: The keys registered on this account.
|
||||
description: |
|
||||
A passkey is a key on a device rather than a secret in a head. The private
|
||||
half never leaves the device, and the signature is bound to the origin that
|
||||
asked for it — so a convincing copy of the sign-in page at another address
|
||||
gets a signature it cannot use.
|
||||
|
||||
One row per credential, because a person has a laptop and a phone and losing
|
||||
one must not mean losing the way in. The name is theirs to write: a list
|
||||
nobody can read is a list nobody revokes from.
|
||||
|
||||
Sessions only, like `/tokens` — a leaked API token must not be able to add
|
||||
its own way in, which would turn a theft into a tenancy.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
passkeys:
|
||||
type: array
|
||||
items: { $ref: "#/components/schemas/Passkey" }
|
||||
available:
|
||||
type: boolean
|
||||
description: |
|
||||
Whether this deployment can offer passkeys at all. False when
|
||||
`VERDANDE_BASE_URL` is an IP address or otherwise not a
|
||||
domain an authenticator will accept — the interface asks once
|
||||
rather than discovering it from a 503 it caused by offering a
|
||||
button.
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/api/v1/auth/passkeys/register/begin:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Start registering a key. Returns the options and a challenge id.
|
||||
description: |
|
||||
Registration is done by somebody already signed in: you cannot be handed a
|
||||
way into an account you have not proved you own.
|
||||
|
||||
The challenge is kept server-side rather than in a cookie, because the whole
|
||||
point of it is that the server chose it and remembers choosing it. It is
|
||||
answerable once and expires after five minutes.
|
||||
responses:
|
||||
"200":
|
||||
description: Pass `options` to `navigator.credentials.create()`.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
challenge_id: { type: string }
|
||||
options: { type: object, additionalProperties: true }
|
||||
"503":
|
||||
description: |
|
||||
`VERDANDE_BASE_URL` is not an address an authenticator will accept. 503
|
||||
rather than 404: this version has passkeys, this deployment cannot offer
|
||||
them, and those are different things to fix.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Error" }
|
||||
|
||||
/api/v1/auth/passkeys/register/finish:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Finish registering a key.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
challenge_id: { type: string }
|
||||
name:
|
||||
type: string
|
||||
description: What to call it in the list — "min bærbare".
|
||||
credential:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: The response from `navigator.credentials.create()`.
|
||||
responses:
|
||||
"201":
|
||||
description: Registered.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Passkey" }
|
||||
"400":
|
||||
description: The attempt expired, was already used, or did not verify.
|
||||
"503":
|
||||
description: Passkeys are unavailable on this instance.
|
||||
|
||||
/api/v1/auth/passkeys/{passkeyID}:
|
||||
parameters:
|
||||
- { name: passkeyID, in: path, required: true, schema: { type: string } }
|
||||
patch:
|
||||
tags: [auth]
|
||||
summary: Rename a key.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name: { type: string }
|
||||
responses:
|
||||
"204": { description: Renamed. }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
delete:
|
||||
tags: [auth]
|
||||
summary: Remove a key.
|
||||
description: |
|
||||
Scoped in the SQL rather than checked first: an id read in one step and
|
||||
deleted in the next is a window, and the reason somebody is on this screen
|
||||
is usually that they think a device is gone.
|
||||
responses:
|
||||
"204": { description: Removed. }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/auth/passkey/login/begin:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Start signing in with a key. No session, and no email.
|
||||
description: |
|
||||
A discoverable login: nobody has said who they are yet, and that is the
|
||||
point. The device knows which account its key belongs to, so there is no
|
||||
address to type and no list of accounts for anybody to probe.
|
||||
responses:
|
||||
"200":
|
||||
description: Pass `options` to `navigator.credentials.get()`.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
challenge_id: { type: string }
|
||||
options: { type: object, additionalProperties: true }
|
||||
"429": { $ref: "#/components/responses/RateLimited" }
|
||||
"503":
|
||||
description: Passkeys are unavailable on this instance.
|
||||
|
||||
/api/v1/auth/passkey/login/finish:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Finish signing in with a key.
|
||||
description: |
|
||||
A key that verified its owner — a PIN, a fingerprint, a face — is both
|
||||
factors at once, and completes the login outright. One that only proved
|
||||
possession still stops at the TOTP step, exactly as a password does.
|
||||
|
||||
A counter that has gone backwards means the credential has been cloned. It
|
||||
is refused rather than logged: that counter is the only signal this design
|
||||
gives.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
challenge_id: { type: string }
|
||||
credential:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: The response from `navigator.credentials.get()`.
|
||||
responses:
|
||||
"200":
|
||||
description: Signed in, or `totp_required` when the key was possession only.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
user: { $ref: "#/components/schemas/User" }
|
||||
totp_required: { type: boolean }
|
||||
"401":
|
||||
description: |
|
||||
One message for an unknown key and for a bad signature. Telling them
|
||||
apart is a way to learn which credentials exist here.
|
||||
"429": { $ref: "#/components/responses/RateLimited" }
|
||||
"503":
|
||||
description: Passkeys are unavailable on this instance.
|
||||
|
||||
/api/v1/auth/sessions:
|
||||
get:
|
||||
tags: [auth]
|
||||
@@ -1148,6 +1337,67 @@ paths:
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"404": { $ref: "#/components/responses/NotFound" }
|
||||
|
||||
/api/v1/panel:
|
||||
get:
|
||||
tags: [auth]
|
||||
summary: Whether this instance can restart itself. Administrators only.
|
||||
description: |
|
||||
A container cannot replace its own image from the inside, so "update" is
|
||||
really "ask whoever owns the container to recreate it" — Yggdrasil pulls
|
||||
`:latest` on the way, which is what makes a restart an update at all.
|
||||
|
||||
This says whether the three settings are present. The token is never sent:
|
||||
the page needs to know whether the button can work, not what the credential
|
||||
is. When it cannot, the missing variables are named — an operator reading
|
||||
"not configured" should not have to go and find out which of three.
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
configured: { type: boolean }
|
||||
panel_url: { type: string }
|
||||
missing:
|
||||
type: array
|
||||
items: { type: string }
|
||||
description: The environment variables that are not set.
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
|
||||
/api/v1/panel/restart:
|
||||
post:
|
||||
tags: [auth]
|
||||
summary: Ask the panel to recreate this container. Administrators only.
|
||||
description: |
|
||||
The panel does the work and records who asked; this removes the second
|
||||
browser tab, not the panel's authority.
|
||||
|
||||
Answers 202 rather than waiting: the panel stops this container as part of
|
||||
answering, so the reply may never arrive. A request cut off mid-flight is
|
||||
the successful case wearing a failure's clothes — the interface polls
|
||||
`/healthz` rather than believing either answer.
|
||||
|
||||
Sessions only, like the rest of this group. A leaked API token that could
|
||||
restart the server is a denial of service in one request.
|
||||
responses:
|
||||
"202":
|
||||
description: The panel accepted, or was already restarting this container.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
restarting: { type: boolean }
|
||||
note: { type: string }
|
||||
"403": { $ref: "#/components/responses/Forbidden" }
|
||||
"502":
|
||||
description: |
|
||||
The panel refused. Usually a token without control of this server.
|
||||
"503":
|
||||
description: The three settings are not all present.
|
||||
|
||||
/api/v1/errors:
|
||||
get:
|
||||
tags: [auth]
|
||||
@@ -3127,6 +3377,32 @@ components:
|
||||
same thing once decoded, and telling "put it nowhere" apart from "I
|
||||
did not mention it" is what makes this a PATCH.
|
||||
|
||||
Passkey:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
name:
|
||||
type: string
|
||||
description: What the owner called it. "min bærbare" is what makes a list reviewable.
|
||||
aaguid:
|
||||
type: string
|
||||
description: |
|
||||
Identifies the authenticator model, which is what lets a list say
|
||||
something recognisable beside a key somebody named months ago.
|
||||
discoverable:
|
||||
type: boolean
|
||||
description: |
|
||||
Whether the credential lives on the device. Only these can start a login
|
||||
with no email typed first.
|
||||
user_verified:
|
||||
type: boolean
|
||||
description: |
|
||||
Whether the authenticator verified the person — a PIN, a fingerprint, a
|
||||
face. One that did is both factors at once; one that only proved
|
||||
possession is a first factor and still meets the TOTP step.
|
||||
created_at: { type: string, format: date-time }
|
||||
last_used_at: { type: string, format: date-time }
|
||||
|
||||
ProjectGroup:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 167 KiB |
@@ -3,21 +3,30 @@ module github.com/kristianwind/verdande
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/teambition/rrule-go v1.8.2
|
||||
golang.org/x/crypto v0.55.0
|
||||
modernc.org/sqlite v1.56.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/coder/websocket v1.8.15 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/x v0.2.6 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/teambition/rrule-go v1.8.2 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
modernc.org/libc v1.74.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -2,12 +2,27 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
|
||||
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
|
||||
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
|
||||
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -18,6 +33,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
@@ -25,10 +42,17 @@ github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
|
||||
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
@@ -39,6 +63,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
|
||||
@@ -280,3 +280,33 @@ func codeAt(t *testing.T, secret string, now time.Time) string {
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// The relying party is derived from the instance's own base URL, and the two ways
|
||||
// that goes wrong are both silent until a browser refuses.
|
||||
func TestPasskeysRefuseAnAddressNoBrowserWillAccept(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
base string
|
||||
ok bool
|
||||
why string
|
||||
}{
|
||||
{"https://verdande.example.dk", true, "a real domain"},
|
||||
{"http://localhost:5173", true, "localhost, which every browser excepts"},
|
||||
{"https://verdande.example.dk:8443", true, "a port is dropped from the RP ID"},
|
||||
|
||||
// An IP address is not a domain. The browser throws SecurityError long
|
||||
// after the server has happily issued a challenge, so it is refused here
|
||||
// and the endpoints answer 503 instead.
|
||||
{"http://127.0.0.1:8080", false, "an IPv4 literal"},
|
||||
{"http://[::1]:8080", false, "an IPv6 literal"},
|
||||
{"", false, "no address at all"},
|
||||
{"not a url", false, "not an address"},
|
||||
} {
|
||||
_, err := NewWebAuthn(c.base, "verdande")
|
||||
if c.ok && err != nil {
|
||||
t.Errorf("NewWebAuthn(%q) refused %s: %v", c.base, c.why, err)
|
||||
}
|
||||
if !c.ok && err == nil {
|
||||
t.Errorf("NewWebAuthn(%q) accepted %s, which a browser will not", c.base, c.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
// Passkeys, wrapped so the rest of the app never touches the protocol types.
|
||||
//
|
||||
// A password and a TOTP code are both shared secrets: the server keeps something
|
||||
// that can be stolen from it, and the person keeps something that can be talked
|
||||
// out of them. A passkey is neither. The private half never leaves the device, and
|
||||
// the signature is bound to the origin that asked for it — so a convincing copy of
|
||||
// the sign-in page at another address gets a signature it cannot use.
|
||||
//
|
||||
// That binding is also the one thing that can be got wrong here, and it is
|
||||
// invisible when it is: the RP ID has to be the registrable domain of the address
|
||||
// people actually visit. Get it wrong and registration works, login works on your
|
||||
// machine, and everybody behind a different hostname is quietly locked out.
|
||||
|
||||
// WebAuthn is the relying party — this instance, as an authenticator sees it.
|
||||
type WebAuthn struct{ *webauthn.WebAuthn }
|
||||
|
||||
// NewWebAuthn derives the relying party from the instance's own base URL.
|
||||
//
|
||||
// Derived rather than configured, because a second setting that has to agree with
|
||||
// VERDANDE_BASE_URL is a second setting that will one day disagree with it — and
|
||||
// the failure is a login that cannot be completed, reported as "the button does
|
||||
// nothing".
|
||||
func NewWebAuthn(baseURL, displayName string) (*WebAuthn, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(baseURL))
|
||||
if err != nil || parsed.Host == "" {
|
||||
return nil, fmt.Errorf("auth: %q is not a usable base URL for passkeys", baseURL)
|
||||
}
|
||||
|
||||
// The RP ID is the host without the port. A port in it is rejected by every
|
||||
// browser, and localhost:5173 during development is exactly where somebody
|
||||
// would first put one.
|
||||
rpID := parsed.Hostname()
|
||||
|
||||
// An IP address is not a domain, and no browser will accept one as an RP ID —
|
||||
// `SecurityError: This is an invalid domain`, thrown in the browser, long after
|
||||
// the server has happily issued a challenge. Refused here instead, so the
|
||||
// endpoints answer 503 and the interface never offers a button that cannot
|
||||
// work. `localhost` is the exception every browser makes, and it is what
|
||||
// development runs on.
|
||||
if ip := net.ParseIP(rpID); ip != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"auth: passkeys need a domain name, and %q is an IP address — "+
|
||||
"set VERDANDE_BASE_URL to the address people actually visit", rpID)
|
||||
}
|
||||
|
||||
w, err := webauthn.New(&webauthn.Config{
|
||||
RPID: rpID,
|
||||
RPDisplayName: displayName,
|
||||
// The origin is the full scheme://host:port, which is what the browser
|
||||
// sends and what the signature is checked against.
|
||||
RPOrigins: []string{strings.TrimSuffix(parsed.Scheme+"://"+parsed.Host, "/")},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: passkeys: %w", err)
|
||||
}
|
||||
return &WebAuthn{w}, nil
|
||||
}
|
||||
|
||||
// A PasskeyUser is what the library needs to know about an account.
|
||||
//
|
||||
// The handle is the account id rather than anything a person chose. It is written
|
||||
// into the credential on the device and cannot be changed afterwards, so it must
|
||||
// not be an email address: changing your email would orphan every key you own.
|
||||
type PasskeyUser struct {
|
||||
ID string
|
||||
Email string
|
||||
Name string
|
||||
Credentials []webauthn.Credential
|
||||
}
|
||||
|
||||
func (u *PasskeyUser) WebAuthnID() []byte { return []byte(u.ID) }
|
||||
func (u *PasskeyUser) WebAuthnName() string { return u.Email }
|
||||
func (u *PasskeyUser) WebAuthnDisplayName() string { return u.Name }
|
||||
func (u *PasskeyUser) WebAuthnCredentials() []webauthn.Credential { return u.Credentials }
|
||||
|
||||
// EncodeCredentialID renders a credential id for storage and for a URL. Raw bytes
|
||||
// go in a BLOB well enough, but the id is also compared against what the browser
|
||||
// sends back as base64url, and one encoding on both sides is one thing to be
|
||||
// wrong about.
|
||||
func EncodeCredentialID(raw []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
func DecodeCredentialID(encoded string) ([]byte, error) {
|
||||
return base64.RawURLEncoding.DecodeString(encoded)
|
||||
}
|
||||
@@ -38,6 +38,32 @@ type Config struct {
|
||||
// the deal its operator made by self-hosting.
|
||||
UpdateCheck bool
|
||||
|
||||
// The Yggdrasil panel this instance runs under, so it can ask to be restarted
|
||||
// from its own interface rather than from another browser tab.
|
||||
//
|
||||
// A container cannot replace its own image from the inside. Restarting is the
|
||||
// panel's job — it recreates the container and pulls `:latest` on the way — so
|
||||
// what this needs is a way to ask. All three are required together; any one
|
||||
// alone cannot do anything.
|
||||
//
|
||||
// The token is a panel credential with control over that server. Anybody who
|
||||
// can read this environment can restart it, which is true of anybody with the
|
||||
// host anyway. It is never sent to a browser: the settings page is told only
|
||||
// whether it is set.
|
||||
// GmailSyncBudget bounds one "fetch now", well under whatever proxy sits in
|
||||
// front of this server. Configurable so a test can prove the budget is applied
|
||||
// without spending the real one on every run.
|
||||
GmailSyncBudget time.Duration
|
||||
|
||||
// GmailEndpoints redirects the Gmail calls at a server the tests control. Empty
|
||||
// in production, where it means Google.
|
||||
GmailTokenURL string
|
||||
GmailAPIURL string
|
||||
|
||||
PanelURL string
|
||||
PanelToken string
|
||||
PanelServerID string
|
||||
|
||||
// TrashRetention is how long a soft-deleted task or project stays recoverable.
|
||||
TrashRetention time.Duration
|
||||
|
||||
@@ -69,6 +95,10 @@ func Load() (*Config, error) {
|
||||
Dev: envBool("VERDANDE_DEV", false),
|
||||
|
||||
GmailClientID: env("VERDANDE_GMAIL_CLIENT_ID", ""),
|
||||
GmailSyncBudget: 25 * time.Second,
|
||||
PanelURL: strings.TrimSuffix(env("VERDANDE_PANEL_URL", ""), "/"),
|
||||
PanelToken: env("VERDANDE_PANEL_TOKEN", ""),
|
||||
PanelServerID: env("VERDANDE_PANEL_SERVER_ID", ""),
|
||||
GmailClientSecret: env("VERDANDE_GMAIL_CLIENT_SECRET", ""),
|
||||
UpdateCheck: envBool("VERDANDE_UPDATE_CHECK", false),
|
||||
}
|
||||
|
||||
@@ -25,20 +25,50 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
tokenEndpoint = "https://oauth2.googleapis.com/token"
|
||||
apiBase = "https://gmail.googleapis.com/gmail/v1"
|
||||
authEndpoint = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
defaultTokenEndpoint = "https://oauth2.googleapis.com/token"
|
||||
defaultAPIBase = "https://gmail.googleapis.com/gmail/v1"
|
||||
|
||||
// Read-only, and only mail. Not profile, not contacts, not send.
|
||||
Scope = "https://www.googleapis.com/auth/gmail.readonly"
|
||||
)
|
||||
|
||||
// Endpoints is where Google is.
|
||||
//
|
||||
// A field rather than a constant so a test can point it at a server it controls.
|
||||
// There is no other way to exercise what happens when Gmail is slow or refuses —
|
||||
// and "the sync comes back before whatever sits in front of this server gives up
|
||||
// on it" is exactly the kind of promise worth a test and impossible to make
|
||||
// against the real thing.
|
||||
//
|
||||
// The zero value is Google, so nothing outside a test has to know it exists.
|
||||
type Endpoints struct {
|
||||
Token string
|
||||
API string
|
||||
}
|
||||
|
||||
func (e Endpoints) token() string {
|
||||
if e.Token != "" {
|
||||
return e.Token
|
||||
}
|
||||
return defaultTokenEndpoint
|
||||
}
|
||||
|
||||
func (e Endpoints) api() string {
|
||||
if e.API != "" {
|
||||
return e.API
|
||||
}
|
||||
return defaultAPIBase
|
||||
}
|
||||
|
||||
// Config is the OAuth client the operator registered in Google Cloud. It belongs to
|
||||
// the instance rather than to a person: one registration, any number of users.
|
||||
type Config struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
// Endpoints is Google unless a test says otherwise.
|
||||
Endpoints Endpoints
|
||||
}
|
||||
|
||||
func (c Config) Configured() bool {
|
||||
@@ -128,7 +158,7 @@ func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error)
|
||||
}
|
||||
|
||||
func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint,
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Endpoints.token(),
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return Token{}, err
|
||||
@@ -171,6 +201,7 @@ func (c Config) token(ctx context.Context, form url.Values) (Token, error) {
|
||||
|
||||
type Client struct {
|
||||
accessToken string
|
||||
endpoints Endpoints
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
@@ -178,6 +209,12 @@ func NewClient(accessToken string) *Client {
|
||||
return &Client{accessToken: accessToken, http: &http.Client{Timeout: 30 * time.Second}}
|
||||
}
|
||||
|
||||
// At points the client at another server. Tests only; the zero value is Google.
|
||||
func (c *Client) At(e Endpoints) *Client {
|
||||
c.endpoints = e
|
||||
return c
|
||||
}
|
||||
|
||||
// Message is the part of a Gmail message a task is made from.
|
||||
type Message struct {
|
||||
ID string
|
||||
@@ -196,7 +233,7 @@ func (c *Client) Profile(ctx context.Context) (string, error) {
|
||||
var parsed struct {
|
||||
EmailAddress string `json:"emailAddress"`
|
||||
}
|
||||
if err := c.get(ctx, apiBase+"/users/me/profile", &parsed); err != nil {
|
||||
if err := c.get(ctx, c.endpoints.api()+"/users/me/profile", &parsed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parsed.EmailAddress, nil
|
||||
@@ -220,7 +257,7 @@ func (c *Client) List(ctx context.Context, query string, max int) ([]string, err
|
||||
ID string `json:"id"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := c.get(ctx, apiBase+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
|
||||
if err := c.get(ctx, c.endpoints.api()+"/users/me/messages?"+v.Encode(), &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -251,7 +288,7 @@ func (c *Client) Get(ctx context.Context, id string) (Message, error) {
|
||||
} `json:"headers"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
if err := c.get(ctx, apiBase+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
|
||||
if err := c.get(ctx, c.endpoints.api()+"/users/me/messages/"+url.PathEscape(id)+"?"+v.Encode(), &parsed); err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
|
||||
|
||||
@@ -573,6 +573,34 @@ func inboxName(locale string) string {
|
||||
// 11:49" long after the line explaining it has gone, which tells an operator that
|
||||
// something broke and gives them no way to find out what. The row outlives the
|
||||
// container; Indstillinger → Fejl is where it is read.
|
||||
// upstream reports a failure that came from somebody else's service.
|
||||
//
|
||||
// Separate from `internal` because the cause is different and so is the fix: a 500
|
||||
// is this server broken, and this is Google — or whoever — saying no. The status
|
||||
// says so (502), and the message is passed through rather than replaced, because
|
||||
// "invalid_grant" or "insufficient authentication scopes" is the whole diagnosis
|
||||
// and a generic sentence in its place throws it away.
|
||||
//
|
||||
// It is recorded like a 500 for exactly the reason the error log exists: a Gmail
|
||||
// sync that has been failing for a week is invisible otherwise. The background job
|
||||
// writes a log line the next restart erases, and nobody is watching the one screen
|
||||
// that would have said so.
|
||||
func (s *Server) upstream(w http.ResponseWriter, r *http.Request, code, what string, err error) {
|
||||
s.log.Warn(what, "err", err)
|
||||
|
||||
userID := ""
|
||||
if u := userFrom(r.Context()); u != nil {
|
||||
userID = u.ID
|
||||
}
|
||||
s.db.RecordError(r.Context(), store.ServerError{
|
||||
Method: r.Method, Path: r.URL.Path, Status: http.StatusBadGateway,
|
||||
What: what, Message: err.Error(), UserID: userID,
|
||||
RequestID: middleware.GetReqID(r.Context()),
|
||||
})
|
||||
|
||||
writeError(w, http.StatusBadGateway, code, err.Error())
|
||||
}
|
||||
|
||||
func (s *Server) internal(w http.ResponseWriter, r *http.Request, what string, err error) {
|
||||
s.log.Error(what, "err", err)
|
||||
|
||||
|
||||
@@ -46,7 +46,9 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
cfg := &config.Config{
|
||||
BaseURL: "http://127.0.0.1",
|
||||
// `localhost`, not a loopback IP: passkeys refuse an IP address as a relying
|
||||
// party id, and every browser makes the same exception for localhost.
|
||||
BaseURL: "http://localhost",
|
||||
DataDir: t.TempDir(),
|
||||
SessionTTL: 24 * time.Hour,
|
||||
InviteTTL: 7 * 24 * time.Hour,
|
||||
|
||||
@@ -51,6 +51,13 @@ const (
|
||||
// is not "that already exists" either, and it is the one refusal on the user
|
||||
// page that a person needs explained rather than merely reported.
|
||||
CodeLastAdmin = "last_admin"
|
||||
|
||||
// Gmail said no. Its own words are the diagnosis — "invalid_grant",
|
||||
// "insufficient authentication scopes" — and they are useless behind a generic
|
||||
// sentence, which is what `internal_error` gave: a person clicked Fetch now,
|
||||
// read "something went wrong", and had nowhere to go. The interface wraps the
|
||||
// message in one that says where it came from.
|
||||
CodeGmailFailed = "gmail_failed"
|
||||
)
|
||||
|
||||
// errLastAdmin is returned to the caller that already wrote the response, so it
|
||||
|
||||
@@ -2,6 +2,7 @@ package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -10,6 +11,11 @@ import (
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// defaultGmailSyncBudget bounds one "fetch now" when nothing else says otherwise.
|
||||
// Well under the hundred seconds a proxy is willing to wait, so this server is the
|
||||
// one that answers a slow mailbox.
|
||||
const defaultGmailSyncBudget = 25 * time.Second
|
||||
|
||||
// gmailConfig is the OAuth client this instance authorises with.
|
||||
//
|
||||
// The environment wins when it is set. A value in the environment is a deliberate
|
||||
@@ -17,11 +23,20 @@ import (
|
||||
// applied on every recreate — and a form that could quietly override it would make
|
||||
// the manifest a lie. The stored pair is for the ordinary case, where nobody wants
|
||||
// to redeploy a container to paste a client id.
|
||||
// gmailClient is the API client, pointed wherever the config says Google is.
|
||||
func (s *Server) gmailClient(accessToken string) *gmail.Client {
|
||||
return gmail.NewClient(accessToken).At(gmail.Endpoints{
|
||||
Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) gmailConfig(ctx context.Context) gmail.Config {
|
||||
endpoints := gmail.Endpoints{Token: s.cfg.GmailTokenURL, API: s.cfg.GmailAPIURL}
|
||||
cfg := gmail.Config{
|
||||
ClientID: s.cfg.GmailClientID,
|
||||
ClientSecret: s.cfg.GmailClientSecret,
|
||||
RedirectURL: s.cfg.GmailRedirectURL(),
|
||||
Endpoints: endpoints,
|
||||
}
|
||||
if cfg.ClientID != "" && cfg.ClientSecret != "" {
|
||||
return cfg
|
||||
@@ -150,7 +165,10 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
email, err := gmail.NewClient(token.AccessToken).Profile(r.Context())
|
||||
// Best effort: a mailbox that connects but cannot name itself is still
|
||||
// connected, and the status endpoint asks again when it finds no address —
|
||||
// so this failing is a delay rather than a dead end.
|
||||
email, err := s.gmailClient(token.AccessToken).Profile(r.Context())
|
||||
if err != nil {
|
||||
s.log.Warn("gmail profile", "err", err)
|
||||
}
|
||||
@@ -182,9 +200,36 @@ func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
|
||||
user := userFrom(r.Context())
|
||||
|
||||
created, err := s.SyncGmail(r.Context(), user)
|
||||
// A budget of its own, well under what sits in front of this server.
|
||||
//
|
||||
// The sync fetches up to twenty-five messages one at a time, each with its own
|
||||
// thirty-second timeout — so a slow mailbox can hold the request open for
|
||||
// minutes. Cloudflare gives up at a hundred seconds and answers with its own
|
||||
// 502 page, which is HTML: the browser gets no JSON, no error code and no
|
||||
// message, and shows "something went wrong". Nothing reaches the error log
|
||||
// either, because this handler never returned.
|
||||
//
|
||||
// Whatever was created by the deadline is kept: every task is committed as it
|
||||
// is made, so a short budget is a shorter run rather than a lost one. The
|
||||
// background job keeps the full one; it has nobody waiting.
|
||||
// An unset budget is not a budget of nothing: a zero here would expire the
|
||||
// context before the first call and make every sync a silent no-op.
|
||||
budget := s.cfg.GmailSyncBudget
|
||||
if budget <= 0 {
|
||||
budget = defaultGmailSyncBudget
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), budget)
|
||||
defer cancel()
|
||||
|
||||
created, err := s.SyncGmail(ctx, user)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
// Not a failure: the mailbox is slow or busy, some tasks were made, and
|
||||
// the rest arrive on the next run ten minutes from now.
|
||||
writeJSON(w, http.StatusOK, map[string]any{"created": created, "partial": true})
|
||||
return
|
||||
}
|
||||
s.upstream(w, r, CodeGmailFailed, "sync gmail", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"created": created})
|
||||
@@ -229,7 +274,7 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
}
|
||||
}
|
||||
|
||||
client := gmail.NewClient(access)
|
||||
client := s.gmailClient(access)
|
||||
ids, err := client.List(ctx, query, 25)
|
||||
if err != nil {
|
||||
if err == gmail.ErrUnauthorized {
|
||||
@@ -269,6 +314,11 @@ func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
// A spent budget ends the run rather than turning into twenty-five failed
|
||||
// fetches logged one at a time.
|
||||
if ctx.Err() != nil {
|
||||
return created, ctx.Err()
|
||||
}
|
||||
msg, err := client.Get(ctx, id)
|
||||
if err != nil {
|
||||
s.log.Warn("gmail get message", "err", err, "id", id)
|
||||
@@ -425,3 +475,39 @@ func (s *Server) handleSetGmailClient(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// gmailProfile fetches the mailbox address and stores it, refreshing the access
|
||||
// token first if it has expired.
|
||||
//
|
||||
// Shared by the connect callback and the status endpoint. The callback is the
|
||||
// natural place to learn the address, and it is also the one moment that cannot be
|
||||
// retried — so the status endpoint asks again when it finds nothing, and the answer
|
||||
// corrects itself on the next look.
|
||||
func (s *Server) gmailProfile(ctx context.Context, user *store.User, settings map[string]any) (string, error) {
|
||||
str := func(key string) string {
|
||||
v, _ := settings[key].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
access := str("access_token")
|
||||
expires, _ := settings["expires_at"].(float64)
|
||||
if access == "" || int64(expires) <= time.Now().Unix() {
|
||||
token, err := s.gmailConfig(ctx).Refresh(ctx, str("refresh_token"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
access = token.AccessToken
|
||||
settings["access_token"] = access
|
||||
settings["expires_at"] = token.ExpiresAt.Unix()
|
||||
}
|
||||
|
||||
email, err := s.gmailClient(access).Profile(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
settings["email"] = email
|
||||
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return email, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// A Gmail failure has to survive being reported.
|
||||
//
|
||||
// It used to be hidden twice over. The handler answered 502 with the code
|
||||
// `internal_error`, which the interface maps to "something went wrong" — so
|
||||
// Google's own words, the only thing that says what to do, were replaced by a
|
||||
// sentence that says nothing. And `writeError` records nothing, so the error log
|
||||
// was empty: a person clicked Fetch now, read a generic failure, and there was
|
||||
// nowhere to look it up afterwards.
|
||||
func TestAGmailFailureSaysWhatGmailSaidAndIsRecorded(t *testing.T) {
|
||||
// A Gmail that refuses, the way an expired grant does.
|
||||
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest)
|
||||
}))
|
||||
defer google.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.GmailClientID = "test-client"
|
||||
cfg.GmailClientSecret = "test-secret"
|
||||
cfg.GmailTokenURL = google.URL + "/token"
|
||||
cfg.GmailAPIURL = google.URL
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Connected, with a refresh token that will be refused.
|
||||
if err := ts.db.SetUserSettings(t.Context(), user.ID, "gmail", map[string]any{
|
||||
"refresh_token": "spent", "trigger": "starred",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/gmail/sync", nil)
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("sync: status %d, want 502 — this is Gmail saying no, not this server breaking",
|
||||
resp.StatusCode)
|
||||
}
|
||||
if body["code"] != "gmail_failed" {
|
||||
t.Errorf("code = %v, want gmail_failed — `internal_error` is mapped to a generic "+
|
||||
"sentence, which throws the diagnosis away", body["code"])
|
||||
}
|
||||
if msg, _ := body["error"].(string); msg == "" {
|
||||
t.Error("the message is empty; Gmail's own words are the whole diagnosis")
|
||||
}
|
||||
|
||||
// And it is in the error log, which is the point of having one: a sync that has
|
||||
// been failing for a week is invisible otherwise.
|
||||
_, logged := ts.do(t, "GET", "/api/v1/errors", nil)
|
||||
rows, _ := logged["errors"].([]any)
|
||||
if len(rows) == 0 {
|
||||
t.Fatal("nothing was recorded; the failure is as invisible as it was before")
|
||||
}
|
||||
row := rows[0].(map[string]any)
|
||||
if row["what"] != "sync gmail" {
|
||||
t.Errorf("what = %v, want \"sync gmail\"", row["what"])
|
||||
}
|
||||
if row["status"] != float64(http.StatusBadGateway) {
|
||||
t.Errorf("status = %v, want 502", row["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// A slow mailbox must not hold the request open until something in front of this
|
||||
// server gives up on it.
|
||||
//
|
||||
// This is what was actually happening in production: each Gmail call has its own
|
||||
// thirty-second timeout, and the sync makes up to twenty-five of them one after the
|
||||
// other. Cloudflare stopped waiting at a hundred seconds and answered with its own
|
||||
// 502 page — HTML, so the browser got no code, no message and no error-log row, and
|
||||
// showed "something went wrong". The handler had never returned.
|
||||
func TestASlowMailboxDoesNotHangTheRequest(t *testing.T) {
|
||||
// A Gmail that never answers.
|
||||
blocked := make(chan struct{})
|
||||
defer close(blocked)
|
||||
google := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-blocked:
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
}))
|
||||
defer google.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.GmailClientID = "test-client"
|
||||
cfg.GmailClientSecret = "test-secret"
|
||||
cfg.GmailTokenURL = google.URL + "/token"
|
||||
cfg.GmailAPIURL = google.URL
|
||||
// A second rather than the real twenty-five: what is being tested is that a
|
||||
// budget is applied at all, and spending the production one on every run
|
||||
// would put half a minute into the suite to learn the same thing.
|
||||
cfg.GmailSyncBudget = time.Second
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
user, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A token that has not expired, so the run goes straight to the message list
|
||||
// rather than stopping at a refresh.
|
||||
if err := ts.db.SetUserSettings(t.Context(), user.ID, "gmail", map[string]any{
|
||||
"refresh_token": "r", "access_token": "a", "trigger": "starred",
|
||||
"expires_at": time.Now().Add(time.Hour).Unix(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Its own client, because the shared one gives up after ten seconds — which is
|
||||
// shorter than the budget being tested and would prove nothing either way.
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/gmail/sync", nil)
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("the request never came back (%v after %s); the sync has no budget of "+
|
||||
"its own, and whatever sits in front of this server answers for it",
|
||||
err, time.Since(started).Round(time.Second))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
took := time.Since(started)
|
||||
|
||||
// Cloudflare stops waiting at a hundred seconds. Anything close to that is the
|
||||
// bug, whatever status eventually arrives.
|
||||
// Comfortably over the one-second budget and far under any proxy's patience.
|
||||
if took > 10*time.Second {
|
||||
t.Errorf("the sync took %s; the budget is not being applied",
|
||||
took.Round(time.Second))
|
||||
}
|
||||
if resp.StatusCode >= 500 && resp.StatusCode != http.StatusBadGateway {
|
||||
t.Errorf("status %d — a slow mailbox is not this server breaking", resp.StatusCode)
|
||||
}
|
||||
t.Logf("came back in %s with %d", took.Round(time.Millisecond), resp.StatusCode)
|
||||
}
|
||||
|
||||
func mustParse(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
@@ -379,9 +379,27 @@ func (s *Server) handleGetGmail(w http.ResponseWriter, r *http.Request) {
|
||||
v, _ := values[key].(string)
|
||||
return v
|
||||
}
|
||||
// The mailbox address, fetched again if it is missing.
|
||||
//
|
||||
// It is stored at connect time, and the call that fetches it can fail — a
|
||||
// slow token, a scope Google had not applied yet. That failure used to be a
|
||||
// log line and an empty string, so the page said "an unknown account" and went
|
||||
// on saying it forever: the only way back was to disconnect and reconnect,
|
||||
// which nobody would guess. Asking again here costs one request on a page
|
||||
// nobody opens often, and it means the wrong answer lasts until the next look
|
||||
// rather than until somebody reconnects.
|
||||
email := str("email")
|
||||
if email == "" && str("refresh_token") != "" {
|
||||
if fetched, err := s.gmailProfile(r.Context(), userFrom(r.Context()), values); err == nil {
|
||||
email = fetched
|
||||
} else {
|
||||
s.log.Warn("gmail profile", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, gmailSettings{
|
||||
Connected: str("refresh_token") != "",
|
||||
Email: str("email"),
|
||||
Email: email,
|
||||
Trigger: str("trigger"),
|
||||
Label: str("label"),
|
||||
})
|
||||
|
||||
@@ -1172,7 +1172,7 @@ func TestTheGmailClientCanBeSetFromTheInterface(t *testing.T) {
|
||||
}
|
||||
// The single most likely thing to be wrong, so it is spelled out rather than
|
||||
// left for somebody to reconstruct.
|
||||
if empty["redirect_uri"] != "http://127.0.0.1/oauth/gmail/callback" {
|
||||
if empty["redirect_uri"] != "http://localhost/oauth/gmail/callback" {
|
||||
t.Errorf("redirect_uri = %v", empty["redirect_uri"])
|
||||
}
|
||||
|
||||
@@ -1280,3 +1280,39 @@ func TestTheGmailClientIsAdminsOnlyAndSessionsOnly(t *testing.T) {
|
||||
t.Errorf("with an admin's API token: status %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Rotating the mail-to-task address, which reported "something went wrong" in
|
||||
// production with nothing in the error log — the combination that means the 500 did
|
||||
// not come through `s.internal()`.
|
||||
func TestTheMailAddressCanBeRotated(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, before := ts.do(t, "GET", "/api/v1/mail-address", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("get: status %d, body %v", resp.StatusCode, before)
|
||||
}
|
||||
first, _ := before["address"].(string)
|
||||
if first == "" {
|
||||
t.Fatal("no address to begin with")
|
||||
}
|
||||
|
||||
resp, after := ts.do(t, "POST", "/api/v1/mail-address/rotate", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("rotate: status %d, body %v", resp.StatusCode, after)
|
||||
}
|
||||
second, _ := after["address"].(string)
|
||||
if second == "" || second == first {
|
||||
t.Errorf("the address did not change: %q → %q", first, second)
|
||||
}
|
||||
|
||||
// And twice in a row, which is what somebody does when the first one appeared
|
||||
// to fail.
|
||||
resp, again := ts.do(t, "POST", "/api/v1/mail-address/rotate", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("second rotate: status %d, body %v", resp.StatusCode, again)
|
||||
}
|
||||
if third, _ := again["address"].(string); third == second {
|
||||
t.Error("the second rotation returned the same address")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Asking the panel to restart this instance.
|
||||
//
|
||||
// A container cannot replace its own image from the inside, so "update" is really
|
||||
// "ask whoever owns the container to recreate it" — Yggdrasil pulls `:latest` on
|
||||
// the way, which is what makes a restart an update at all.
|
||||
//
|
||||
// The alternative was a second browser tab: read the version here, go to the
|
||||
// panel, find the server, press Restart. This removes the tab, not the panel's
|
||||
// authority — the panel still does the work and still records who asked.
|
||||
//
|
||||
// Administrators only, and sessions only. A leaked API token that could restart
|
||||
// the server is a denial of service in one request, and the whole point of the
|
||||
// session rule elsewhere in this file is that a stolen token must not be able to
|
||||
// do things only a person at a keyboard should.
|
||||
|
||||
type panelStatusJSON struct {
|
||||
// Configured is all three settings being present. The token itself is never
|
||||
// sent: the page needs to know whether the button can work, not what the
|
||||
// credential is.
|
||||
Configured bool `json:"configured"`
|
||||
PanelURL string `json:"panel_url,omitempty"`
|
||||
// Why it cannot work, when it cannot. An operator reading "not configured"
|
||||
// should not have to go and find out which of three things is missing.
|
||||
Missing []string `json:"missing,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) panelStatus() panelStatusJSON {
|
||||
var missing []string
|
||||
if s.cfg.PanelURL == "" {
|
||||
missing = append(missing, "VERDANDE_PANEL_URL")
|
||||
}
|
||||
if s.cfg.PanelToken == "" {
|
||||
missing = append(missing, "VERDANDE_PANEL_TOKEN")
|
||||
}
|
||||
if s.cfg.PanelServerID == "" {
|
||||
missing = append(missing, "VERDANDE_PANEL_SERVER_ID")
|
||||
}
|
||||
return panelStatusJSON{
|
||||
Configured: len(missing) == 0,
|
||||
PanelURL: s.cfg.PanelURL,
|
||||
Missing: missing,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handlePanelStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.panelStatus())
|
||||
}
|
||||
|
||||
func (s *Server) handleRestartFromPanel(w http.ResponseWriter, r *http.Request) {
|
||||
status := s.panelStatus()
|
||||
if !status.Configured {
|
||||
writeError(w, http.StatusServiceUnavailable, CodeInternal,
|
||||
fmt.Sprintf("this instance cannot restart itself: %v are not set", status.Missing))
|
||||
return
|
||||
}
|
||||
|
||||
user := userFrom(r.Context())
|
||||
s.log.Info("restart requested from the interface", "user", user.ID, "server", s.cfg.PanelServerID)
|
||||
|
||||
// A short timeout of its own. The panel stops this container as part of
|
||||
// answering, so the reply may never arrive — that is not a failure, and a
|
||||
// request that hung waiting for it would look like one.
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
url := fmt.Sprintf("%s/api/servers/%s/restart", s.cfg.PanelURL, s.cfg.PanelServerID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
|
||||
if err != nil {
|
||||
s.internal(w, r, "build panel request", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+s.cfg.PanelToken)
|
||||
|
||||
resp, err := (&http.Client{Timeout: 10 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
// Including "the panel killed us mid-reply", which is the successful case
|
||||
// wearing a failure's clothes. The interface polls /healthz rather than
|
||||
// believing either answer, so this only has to be honest about what it saw.
|
||||
s.log.Warn("panel restart call did not complete", "err", err)
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
"restarting": true,
|
||||
"note": "the panel did not answer; it may already be restarting this container",
|
||||
})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
s.log.Error("panel refused the restart", "status", resp.StatusCode)
|
||||
writeError(w, http.StatusBadGateway, CodeInternal,
|
||||
fmt.Sprintf("the panel refused: HTTP %d — check VERDANDE_PANEL_TOKEN has control of this server", resp.StatusCode))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"restarting": true})
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
)
|
||||
|
||||
// Restarting is administrators only, and sessions only. A leaked API token that
|
||||
// could restart the server is a denial of service in one request.
|
||||
func TestRestartingIsAdminsOnlyAndSessionsOnly(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
for _, path := range []string{"/api/v1/panel", "/api/v1/panel/restart"} {
|
||||
method := "GET"
|
||||
if path != "/api/v1/panel" {
|
||||
method = "POST"
|
||||
}
|
||||
if resp, _ := other.do(t, method, path, nil); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s as an ordinary user: status %d, want 403", path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := ts.apiToken(t, admin.ID)
|
||||
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/panel/restart", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("restart with an admin's API token: status %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Unconfigured, it says which of the three settings are missing rather than
|
||||
// "not configured" — an operator should not have to go and find out which.
|
||||
func TestAnUnconfiguredPanelSaysWhatIsMissing(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, status := ts.do(t, "GET", "/api/v1/panel", nil)
|
||||
if status["configured"] != false {
|
||||
t.Fatalf("configured = %v on an instance with no panel settings", status["configured"])
|
||||
}
|
||||
missing, _ := status["missing"].([]any)
|
||||
if len(missing) != 3 {
|
||||
t.Errorf("missing = %v, want all three settings named", missing)
|
||||
}
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
|
||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Errorf("restart with nothing configured: status %d, want 503", resp.StatusCode)
|
||||
}
|
||||
if body["error"] == nil {
|
||||
t.Error("the refusal says nothing about why")
|
||||
}
|
||||
}
|
||||
|
||||
// Configured, it calls the panel with a bearer token and reports what came back.
|
||||
func TestARestartCallsThePanelWithItsToken(t *testing.T) {
|
||||
var gotPath, gotAuth string
|
||||
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath, gotAuth = r.URL.Path, r.Header.Get("Authorization")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer panel.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.PanelURL = panel.URL
|
||||
cfg.PanelToken = "hemmelig"
|
||||
cfg.PanelServerID = "abc-123"
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, status := ts.do(t, "GET", "/api/v1/panel", nil)
|
||||
if status["configured"] != true {
|
||||
t.Fatalf("configured = %v with all three set", status["configured"])
|
||||
}
|
||||
// The token is a credential and must never reach a browser.
|
||||
for key, value := range status {
|
||||
if s, ok := value.(string); ok && s == "hemmelig" {
|
||||
t.Errorf("the panel token was sent to the client in %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
resp, _ := ts.do(t, "POST", "/api/v1/panel/restart", nil)
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("restart: status %d, want 202", resp.StatusCode)
|
||||
}
|
||||
if gotPath != "/api/servers/abc-123/restart" {
|
||||
t.Errorf("called %q", gotPath)
|
||||
}
|
||||
if gotAuth != "Bearer hemmelig" {
|
||||
t.Errorf("Authorization = %q", gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
// A panel that refuses is reported as the panel refusing, not as this server
|
||||
// breaking: the fix is a token with control of that server, and saying "internal
|
||||
// error" would send somebody looking in the wrong place.
|
||||
func TestAPanelThatRefusesIsReportedAsSuch(t *testing.T) {
|
||||
panel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
}))
|
||||
defer panel.Close()
|
||||
|
||||
ts := newTestServerWith(t, func(cfg *config.Config) {
|
||||
cfg.PanelURL = panel.URL
|
||||
cfg.PanelToken = "forkert"
|
||||
cfg.PanelServerID = "abc-123"
|
||||
})
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/panel/restart", nil)
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Errorf("a refusing panel: status %d, want 502", resp.StatusCode)
|
||||
}
|
||||
if msg, _ := body["error"].(string); msg == "" {
|
||||
t.Error("the refusal does not say what to check")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// Passkeys: a key on a device instead of a secret in a head.
|
||||
//
|
||||
// Four endpoints, in two pairs. Registration is done by somebody already signed in
|
||||
// — you cannot be handed a way into an account you have not proved you own.
|
||||
// Logging in is done by somebody who is not, and is the one place here that is
|
||||
// reachable without a session.
|
||||
//
|
||||
// Each ceremony is two requests because the browser has to talk to the
|
||||
// authenticator in between: the server issues a challenge, the device signs it,
|
||||
// the server checks the signature against what it remembers issuing. The challenge
|
||||
// lives in the database rather than in a cookie, because the whole point of it is
|
||||
// that the server chose it and remembers choosing it.
|
||||
|
||||
const challengeTTL = 5 * time.Minute
|
||||
|
||||
// passkeyAvailable answers 503 rather than 404 when the relying party could not be
|
||||
// built. 404 would read as "this version does not have passkeys"; the truth is
|
||||
// that this deployment cannot offer them, which is a different thing to fix.
|
||||
func (s *Server) passkeyAvailable(w http.ResponseWriter, r *http.Request) bool {
|
||||
if s.passkeys != nil {
|
||||
return true
|
||||
}
|
||||
writeError(w, http.StatusServiceUnavailable, CodeInternal,
|
||||
"passkeys are unavailable on this instance: VERDANDE_BASE_URL is not an address an authenticator will accept")
|
||||
return false
|
||||
}
|
||||
|
||||
type passkeyJSON struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
// AAGUID identifies the authenticator model, which is what lets the list say
|
||||
// something recognisable beside a key somebody named three months ago.
|
||||
AAGUID string `json:"aaguid,omitempty"`
|
||||
Discoverable bool `json:"discoverable"`
|
||||
UserVerified bool `json:"user_verified"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastUsedAt string `json:"last_used_at,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleListPasskeys(w http.ResponseWriter, r *http.Request) {
|
||||
keys, err := s.db.ListPasskeys(r.Context(), userFrom(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "list passkeys", err)
|
||||
return
|
||||
}
|
||||
out := make([]passkeyJSON, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
j := passkeyJSON{
|
||||
ID: k.ID, Name: k.Name, AAGUID: k.AAGUID,
|
||||
Discoverable: k.Discoverable, UserVerified: k.UserVerified,
|
||||
CreatedAt: k.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
if !k.LastUsedAt.IsZero() {
|
||||
j.LastUsedAt = k.LastUsedAt.Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, j)
|
||||
}
|
||||
// Whether this deployment can offer them at all, alongside the ones that exist.
|
||||
// The interface asks one question rather than discovering the answer from a 503
|
||||
// it triggered by offering a button — an address an authenticator refuses is a
|
||||
// deployment fact, not an error somebody caused.
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"passkeys": out, "available": s.passkeys != nil,
|
||||
})
|
||||
}
|
||||
|
||||
// --- registering a key ------------------------------------------------------------
|
||||
|
||||
func (s *Server) handleBeginPasskeyRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.passkeyAvailable(w, r) {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
account, err := s.passkeyUser(r, user.ID, user.Email, user.Name)
|
||||
if err != nil {
|
||||
s.internal(w, r, "load passkeys", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Resident keys, and the authenticator asked to verify its owner. Both are
|
||||
// what make this a *passkey* rather than a second factor: only a discoverable
|
||||
// credential can start a login with no email typed, and only a verified one
|
||||
// stands in for the password rather than beside it.
|
||||
creation, session, err := s.passkeys.BeginRegistration(account,
|
||||
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
|
||||
webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
|
||||
ResidentKey: protocol.ResidentKeyRequirementPreferred,
|
||||
UserVerification: protocol.VerificationPreferred,
|
||||
}),
|
||||
// Excluding what they already have, so a second registration on the same
|
||||
// device replaces nothing and reports itself instead of silently making a
|
||||
// duplicate.
|
||||
webauthn.WithExclusions(account.excludeList()),
|
||||
)
|
||||
if err != nil {
|
||||
s.internal(w, r, "begin passkey registration", err)
|
||||
return
|
||||
}
|
||||
s.issueChallenge(w, r, user.ID, "register", session, creation)
|
||||
}
|
||||
|
||||
type finishRegistrationRequest struct {
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
Name string `json:"name"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
|
||||
func (s *Server) handleFinishPasskeyRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.passkeyAvailable(w, r) {
|
||||
return
|
||||
}
|
||||
var req finishRegistrationRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
user := userFrom(r.Context())
|
||||
|
||||
session, ok := s.takeChallenge(w, r, req.ChallengeID, "register", user.ID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
parsed, err := protocol.ParseCredentialCreationResponseBody(strings.NewReader(string(req.Credential)))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "that response could not be read")
|
||||
return
|
||||
}
|
||||
|
||||
account, err := s.passkeyUser(r, user.ID, user.Email, user.Name)
|
||||
if err != nil {
|
||||
s.internal(w, r, "load passkeys", err)
|
||||
return
|
||||
}
|
||||
|
||||
credential, err := s.passkeys.CreateCredential(account, *session, parsed)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "that key could not be verified")
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = "Passkey"
|
||||
}
|
||||
key := &store.Passkey{
|
||||
UserID: user.ID,
|
||||
CredentialID: auth.EncodeCredentialID(credential.ID),
|
||||
PublicKey: credential.PublicKey,
|
||||
AAGUID: auth.EncodeCredentialID(credential.Authenticator.AAGUID),
|
||||
SignCount: credential.Authenticator.SignCount,
|
||||
Discoverable: credential.Flags.BackupEligible || parsed.Response.AttestationObject.AuthData.Flags.HasBackupState(),
|
||||
UserVerified: credential.Flags.UserVerified,
|
||||
Name: name,
|
||||
}
|
||||
if err := s.db.CreatePasskey(r.Context(), key); err != nil {
|
||||
s.internal(w, r, "store passkey", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, passkeyJSON{
|
||||
ID: key.ID, Name: key.Name, AAGUID: key.AAGUID,
|
||||
Discoverable: key.Discoverable, UserVerified: key.UserVerified,
|
||||
CreatedAt: key.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// --- signing in with a key ---------------------------------------------------------
|
||||
|
||||
func (s *Server) handleBeginPasskeyLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.passkeyAvailable(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
// Discoverable: nobody has said who they are yet, and that is the point. The
|
||||
// device knows which account its key belongs to, so there is no email to type
|
||||
// and no list of accounts for anybody to probe.
|
||||
assertion, session, err := s.passkeys.BeginDiscoverableLogin()
|
||||
if err != nil {
|
||||
s.internal(w, r, "begin passkey login", err)
|
||||
return
|
||||
}
|
||||
s.issueChallenge(w, r, "", "login", session, assertion)
|
||||
}
|
||||
|
||||
type finishLoginRequest struct {
|
||||
ChallengeID string `json:"challenge_id"`
|
||||
Credential json.RawMessage `json:"credential"`
|
||||
}
|
||||
|
||||
func (s *Server) handleFinishPasskeyLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.passkeyAvailable(w, r) {
|
||||
return
|
||||
}
|
||||
var req finishLoginRequest
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
session, ok := s.takeChallenge(w, r, req.ChallengeID, "login", "")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
parsed, err := protocol.ParseCredentialRequestResponseBody(strings.NewReader(string(req.Credential)))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "that response could not be read")
|
||||
return
|
||||
}
|
||||
|
||||
// Which account, resolved from the credential the device signed with. The
|
||||
// handler is given the raw id and returns the owner; the library checks the
|
||||
// signature against that owner's stored public key.
|
||||
var matched *store.Passkey
|
||||
credential, err := s.passkeys.ValidateDiscoverableLogin(
|
||||
func(rawID, userHandle []byte) (webauthn.User, error) {
|
||||
key, err := s.db.PasskeyByCredentialID(r.Context(), auth.EncodeCredentialID(rawID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matched = key
|
||||
owner, err := s.db.UserByID(r.Context(), key.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.passkeyUser(r, owner.ID, owner.Email, owner.Name)
|
||||
}, *session, parsed)
|
||||
if err != nil || matched == nil {
|
||||
// One message, as with a wrong password: telling "no such key" apart from
|
||||
// "bad signature" is a way to learn which credentials exist here.
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key was not accepted")
|
||||
return
|
||||
}
|
||||
|
||||
// A counter that goes backwards means the credential has been cloned. Not
|
||||
// every authenticator keeps one, so zero on both sides is silence rather than
|
||||
// suspicion — but a real counter that has gone down is the one signal this
|
||||
// design gives, and it is refused rather than logged.
|
||||
if credential.Authenticator.CloneWarning {
|
||||
s.log.Warn("passkey clone warning", "passkey", matched.ID, "user", matched.UserID)
|
||||
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key was not accepted")
|
||||
return
|
||||
}
|
||||
|
||||
owner, err := s.db.UserByID(r.Context(), matched.UserID)
|
||||
if err != nil {
|
||||
s.internal(w, r, "load account", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.db.TouchPasskey(r.Context(), matched.ID, credential.Authenticator.SignCount); err != nil {
|
||||
s.log.Warn("record passkey use", "err", err)
|
||||
}
|
||||
s.loginLimiter.reset(clientIP(r))
|
||||
|
||||
// A verified key is both factors at once: the device proved possession and the
|
||||
// person proved they were there, which is exactly what a password plus a code
|
||||
// is for. One that only proved possession still stops at the code.
|
||||
pending := owner.TOTPEnabled && !credential.Flags.UserVerified
|
||||
ttl := s.cfg.SessionTTL
|
||||
if pending {
|
||||
ttl = 10 * time.Minute
|
||||
}
|
||||
token, _, err := s.db.CreateSession(r.Context(), owner.ID, r.UserAgent(), clientIP(r), ttl, pending)
|
||||
if err != nil {
|
||||
s.internal(w, r, "create session", err)
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, token, ttl)
|
||||
|
||||
if pending {
|
||||
writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true})
|
||||
return
|
||||
}
|
||||
uj := toUserJSON(owner)
|
||||
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
|
||||
}
|
||||
|
||||
// --- managing them ------------------------------------------------------------------
|
||||
|
||||
func (s *Server) handleRenamePasskey(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := decodeJSON(w, r, &req); err != nil {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
writeFieldErrors(w, map[string]string{"name": "required"})
|
||||
return
|
||||
}
|
||||
if err := s.db.RenamePasskey(r.Context(), chi.URLParam(r, "passkeyID"),
|
||||
userFrom(r.Context()).ID, name); err != nil {
|
||||
s.storeError(w, r, "rename passkey", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeletePasskey(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.db.DeletePasskey(r.Context(), chi.URLParam(r, "passkeyID"),
|
||||
userFrom(r.Context()).ID); err != nil {
|
||||
s.storeError(w, r, "delete passkey", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- shared plumbing -----------------------------------------------------------------
|
||||
|
||||
// passkeyAccount is the library's view of an account, plus the rows behind it.
|
||||
type passkeyAccount struct {
|
||||
*auth.PasskeyUser
|
||||
stored []store.Passkey
|
||||
}
|
||||
|
||||
func (a *passkeyAccount) excludeList() []protocol.CredentialDescriptor {
|
||||
out := make([]protocol.CredentialDescriptor, 0, len(a.stored))
|
||||
for _, c := range a.PasskeyUser.Credentials {
|
||||
out = append(out, c.Descriptor())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) passkeyUser(r *http.Request, userID, email, name string) (*passkeyAccount, error) {
|
||||
keys, err := s.db.ListPasskeys(r.Context(), userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
credentials := make([]webauthn.Credential, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
raw, err := auth.DecodeCredentialID(k.CredentialID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
credentials = append(credentials, webauthn.Credential{
|
||||
ID: raw,
|
||||
PublicKey: k.PublicKey,
|
||||
Authenticator: webauthn.Authenticator{
|
||||
SignCount: k.SignCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
return &passkeyAccount{
|
||||
PasskeyUser: &auth.PasskeyUser{
|
||||
ID: userID, Email: email, Name: name, Credentials: credentials,
|
||||
},
|
||||
stored: keys,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// issueChallenge stores the server's side of a ceremony and hands the browser the
|
||||
// options plus the id it will answer with.
|
||||
func (s *Server) issueChallenge(w http.ResponseWriter, r *http.Request, userID, purpose string, session *webauthn.SessionData, options any) {
|
||||
encoded, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
s.internal(w, r, "encode webauthn session", err)
|
||||
return
|
||||
}
|
||||
id, err := s.db.StoreChallenge(r.Context(), userID, string(session.Challenge), purpose, encoded, challengeTTL)
|
||||
if err != nil {
|
||||
s.internal(w, r, "store challenge", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"challenge_id": id, "options": options})
|
||||
}
|
||||
|
||||
// takeChallenge reads a challenge once and checks it belongs where it is being
|
||||
// used. A registration challenge answered by a login — or one account's answered
|
||||
// by another — is refused rather than merely failing later.
|
||||
func (s *Server) takeChallenge(w http.ResponseWriter, r *http.Request, id, purpose, userID string) (*webauthn.SessionData, bool) {
|
||||
owner, raw, err := s.db.TakeChallenge(r.Context(), id, purpose)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest,
|
||||
"that attempt has expired or was already used; start again")
|
||||
return nil, false
|
||||
}
|
||||
if userID != "" && owner != userID {
|
||||
writeError(w, http.StatusBadRequest, CodeBadRequest, "that attempt belongs to another account")
|
||||
return nil, false
|
||||
}
|
||||
var session webauthn.SessionData
|
||||
if err := json.Unmarshal(raw, &session); err != nil {
|
||||
s.internal(w, r, "decode webauthn session", err)
|
||||
return nil, false
|
||||
}
|
||||
return &session, true
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/store"
|
||||
)
|
||||
|
||||
// What can be tested without an authenticator, which is most of the surface that
|
||||
// actually goes wrong.
|
||||
//
|
||||
// The signature itself cannot be produced here — that needs a real device or a
|
||||
// virtual one, and Playwright's CDP virtual authenticator is where that belongs.
|
||||
// What is testable is everything around it: who may reach these endpoints, that a
|
||||
// challenge is answerable exactly once, that one account's challenge cannot be
|
||||
// answered by another, and that an expired one is refused. Those are the parts a
|
||||
// mistake would make silently permissive.
|
||||
|
||||
func TestPasskeyRegistrationIsSessionsOnly(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := ts.apiToken(t, admin.ID)
|
||||
|
||||
// A leaked API token must not be able to add its own way in: that would turn a
|
||||
// theft into a tenancy.
|
||||
for _, c := range []struct{ method, path string }{
|
||||
{"GET", "/api/v1/auth/passkeys"},
|
||||
{"POST", "/api/v1/auth/passkeys/register/begin"},
|
||||
{"POST", "/api/v1/auth/passkeys/register/finish"},
|
||||
} {
|
||||
req, _ := http.NewRequest(c.method, ts.URL+c.path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("%s %s with an API token: status %d, want 403", c.method, c.path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Beginning a registration hands back options and a challenge id, and the options
|
||||
// carry the things that make this a passkey rather than a second factor.
|
||||
func TestBeginningARegistrationAsksForADiscoverableVerifiedKey(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
resp, body := ts.do(t, "POST", "/api/v1/auth/passkeys/register/begin", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("begin: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
if body["challenge_id"] == "" || body["challenge_id"] == nil {
|
||||
t.Fatal("no challenge id came back")
|
||||
}
|
||||
|
||||
options, _ := body["options"].(map[string]any)
|
||||
publicKey, _ := options["publicKey"].(map[string]any)
|
||||
if publicKey == nil {
|
||||
t.Fatalf("no publicKey in the options: %v", options)
|
||||
}
|
||||
selection, _ := publicKey["authenticatorSelection"].(map[string]any)
|
||||
if selection["residentKey"] != "preferred" {
|
||||
t.Errorf("residentKey = %v — only a discoverable credential can start a login "+
|
||||
"with no email typed", selection["residentKey"])
|
||||
}
|
||||
if selection["userVerification"] != "preferred" {
|
||||
t.Errorf("userVerification = %v — a key that only proves possession cannot "+
|
||||
"stand in for the password", selection["userVerification"])
|
||||
}
|
||||
// The user handle must be the account id rather than the email: it is written
|
||||
// into the credential and cannot be changed, so an email there would orphan
|
||||
// every key the moment somebody changed their address.
|
||||
user, _ := publicKey["user"].(map[string]any)
|
||||
if user["name"] != "[email protected]" {
|
||||
t.Errorf("user.name = %v", user["name"])
|
||||
}
|
||||
if id, _ := user["id"].(string); id == "" {
|
||||
t.Error("no user handle in the options")
|
||||
}
|
||||
}
|
||||
|
||||
// A challenge is answerable exactly once, and only by whom it was issued to.
|
||||
func TestAChallengeIsSpentWhenItIsUsed(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
_, begun := ts.do(t, "POST", "/api/v1/auth/passkeys/register/begin", nil)
|
||||
challenge := begun["challenge_id"].(string)
|
||||
|
||||
// Somebody else's challenge, answered by this account. Refused before the
|
||||
// signature is even looked at.
|
||||
resp, _ := other.do(t, "POST", "/api/v1/auth/passkeys/register/finish", map[string]any{
|
||||
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("another account's challenge: status %d, want 400", resp.StatusCode)
|
||||
}
|
||||
|
||||
// And it is spent: the rightful owner cannot use it either now, because taking
|
||||
// it is what deleted it. That is deliberate — a challenge that survives a
|
||||
// failed attempt is a challenge somebody can keep trying.
|
||||
resp, _ = ts.do(t, "POST", "/api/v1/auth/passkeys/register/finish", map[string]any{
|
||||
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("a spent challenge: status %d, want 400", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Logging in with a key needs no session and says nothing about who exists here.
|
||||
func TestPasskeyLoginRevealsNothingAboutWhoHasAKey(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
// No session at all — a fresh client, which is the whole point.
|
||||
fresh := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
|
||||
|
||||
resp, body := fresh.do(t, "POST", "/api/v1/auth/passkey/login/begin", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("begin login: status %d, body %v", resp.StatusCode, body)
|
||||
}
|
||||
challenge := body["challenge_id"].(string)
|
||||
|
||||
options, _ := body["options"].(map[string]any)
|
||||
publicKey, _ := options["publicKey"].(map[string]any)
|
||||
// A discoverable login must not list credentials: that list would say which
|
||||
// keys exist on this instance to anybody who asked.
|
||||
if allowed, present := publicKey["allowCredentials"]; present && allowed != nil {
|
||||
if list, _ := allowed.([]any); len(list) > 0 {
|
||||
t.Errorf("the login options list %d credentials; a discoverable login must not",
|
||||
len(list))
|
||||
}
|
||||
}
|
||||
|
||||
// A garbage answer is refused with the same message a wrong key would get.
|
||||
resp, _ = fresh.do(t, "POST", "/api/v1/auth/passkey/login/finish", map[string]any{
|
||||
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
|
||||
})
|
||||
if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("a junk assertion: status %d, want 400 or 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A key belongs to its owner, and nobody else can rename or remove it.
|
||||
func TestAPasskeyCanOnlyBeManagedByItsOwner(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
other := ts.newUser(t, "[email protected]", "Anden")
|
||||
|
||||
admin, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Written straight to the store: registering one properly needs a device.
|
||||
key := &store.Passkey{
|
||||
UserID: admin.ID, CredentialID: "abc", PublicKey: []byte{1, 2, 3}, Name: "Min bærbare",
|
||||
}
|
||||
if err := ts.db.CreatePasskey(t.Context(), key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, listed := ts.do(t, "GET", "/api/v1/auth/passkeys", nil)
|
||||
if keys, _ := listed["passkeys"].([]any); len(keys) != 1 {
|
||||
t.Fatalf("the owner sees %d keys, want 1", len(keys))
|
||||
}
|
||||
_, theirs := other.do(t, "GET", "/api/v1/auth/passkeys", nil)
|
||||
if keys, _ := theirs["passkeys"].([]any); len(keys) != 0 {
|
||||
t.Errorf("somebody else sees %d of this account's keys", len(keys))
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
what, method string
|
||||
body any
|
||||
}{
|
||||
{"rename", "PATCH", map[string]any{"name": "min nu"}},
|
||||
{"delete", "DELETE", nil},
|
||||
} {
|
||||
resp, _ := other.do(t, c.method, "/api/v1/auth/passkeys/"+key.ID, c.body)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("%s somebody else's key: status %d, want 404", c.what, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
if resp, _ := ts.do(t, "PATCH", "/api/v1/auth/passkeys/"+key.ID,
|
||||
map[string]any{"name": "Telefonen"}); resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("the owner renaming: status %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := ts.do(t, "DELETE", "/api/v1/auth/passkeys/"+key.ID, nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("the owner deleting: status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/kristianwind/verdande/internal/auth"
|
||||
"github.com/kristianwind/verdande/internal/config"
|
||||
"github.com/kristianwind/verdande/internal/mail"
|
||||
"github.com/kristianwind/verdande/internal/mcp"
|
||||
@@ -39,6 +40,12 @@ type Server struct {
|
||||
mcp *mcp.Server
|
||||
updates *update.Checker
|
||||
|
||||
// Nil when the base URL is not something an authenticator will accept — a bare
|
||||
// hostname with no scheme, say. The endpoints answer 503 rather than the
|
||||
// process refusing to start: passkeys are one way in among three, and an
|
||||
// instance should not be unreachable because one of them cannot be offered.
|
||||
passkeys *auth.WebAuthn
|
||||
|
||||
// Password guessing is the attack this application is actually exposed to, so
|
||||
// the endpoints that check a secret are limited separately and more tightly
|
||||
// than the rest of the API.
|
||||
@@ -63,6 +70,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
resetLimiter: newLimiter(5, time.Hour),
|
||||
}
|
||||
s.csp = contentSecurityPolicy(scriptHashes(web))
|
||||
if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
|
||||
s.passkeys = w
|
||||
} else {
|
||||
log.Warn("passkeys are unavailable", "err", err, "base_url", cfg.BaseURL)
|
||||
}
|
||||
s.mcp = s.buildMCP()
|
||||
s.updates = update.New(Version, cfg.UpdateCheck)
|
||||
|
||||
@@ -132,6 +144,14 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Method(http.MethodPost, "/password/reset",
|
||||
s.rateLimit(s.resetLimiter, http.HandlerFunc(s.handleResetPassword)))
|
||||
|
||||
// Signing in with a passkey. Rate-limited like the password path: a
|
||||
// signature cannot be guessed, but the endpoint can still be used to
|
||||
// hammer the database.
|
||||
r.Method(http.MethodPost, "/passkey/login/begin",
|
||||
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleBeginPasskeyLogin)))
|
||||
r.Method(http.MethodPost, "/passkey/login/finish",
|
||||
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleFinishPasskeyLogin)))
|
||||
|
||||
// The second step of a login: a session that has passed the password
|
||||
// but not yet the code, and can reach nothing else.
|
||||
r.Group(func(r chi.Router) {
|
||||
@@ -160,6 +180,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Use(s.requireSession)
|
||||
r.Get("/sessions", s.handleListSessions)
|
||||
r.Delete("/sessions/{sessionID}", s.handleDeleteSession)
|
||||
|
||||
// Registering a key, and the list of them. Sessions only, for
|
||||
// the same reason: a leaked token must not be able to add its
|
||||
// own way in — that would turn a theft into a tenancy.
|
||||
r.Get("/passkeys", s.handleListPasskeys)
|
||||
r.Post("/passkeys/register/begin", s.handleBeginPasskeyRegistration)
|
||||
r.Post("/passkeys/register/finish", s.handleFinishPasskeyRegistration)
|
||||
r.Patch("/passkeys/{passkeyID}", s.handleRenamePasskey)
|
||||
r.Delete("/passkeys/{passkeyID}", s.handleDeletePasskey)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -318,6 +347,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.requireSession, s.requireAdmin)
|
||||
r.Get("/errors", s.handleListErrors)
|
||||
// Restarting this instance from its own interface. The panel does
|
||||
// the work — a container cannot replace its own image — and pulls
|
||||
// `:latest` on the way, which is what makes a restart an update.
|
||||
r.Get("/panel", s.handlePanelStatus)
|
||||
r.Post("/panel/restart", s.handleRestartFromPanel)
|
||||
// A backup file is a complete copy of the database, so this whole
|
||||
// group is sessions-only as well as administrators-only: a leaked
|
||||
// token must not be able to download everybody's data.
|
||||
|
||||
@@ -20,6 +20,56 @@ import (
|
||||
// away whatever letters it happens to use. A word list rather than a language
|
||||
// detector: this repository ships one static binary, and a list of thirty common
|
||||
// words catches the sentences a person would actually write.
|
||||
// The same words, inside the code rather than the markup.
|
||||
//
|
||||
// `confirm()`, `alert()` and `app.toast()` take a string argument, and a string
|
||||
// argument lives in the script block where the scanner above never looks. Six of
|
||||
// them survived the whole i18n sweep that way — every destructive confirmation in
|
||||
// the app, which is the worst possible set to leave in one language, because it is
|
||||
// the text somebody reads in the half-second before they agree to lose something.
|
||||
func TestNoDanishProseInDialogsAndToasts(t *testing.T) {
|
||||
// The argument of one of the three, quoted or backticked. Deliberately narrow:
|
||||
// a general scan of every string literal in a Svelte script block would drown
|
||||
// in class names, keys and query fragments.
|
||||
call := regexp.MustCompile(`(?:confirm|alert|app\.toast)\(\s*['` + "`" + `]([^'` + "`" + `]{4,})['` + "`" + `]`)
|
||||
danish := regexp.MustCompile(`(?i)\b(og|er|ikke|det|den|der|til|kan|som|har|med|af|en|et|du|din|dine|vises|ingen|nyere|fundet|ude|valgt|slettet|gemt|opgave|projekt|indstillinger|fjern|opret|vælg|luk|tilføj|ryd|gem|slet|omdøb|bliver|holder|alle)\b`)
|
||||
|
||||
var found []string
|
||||
root := filepath.Join("..", "..", "web", "src")
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
if !strings.HasSuffix(path, ".svelte") && !strings.HasSuffix(path, ".js") {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(path, "locales") {
|
||||
return nil
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
for _, m := range call.FindAllStringSubmatch(string(body), -1) {
|
||||
if danish.MatchString(m[1]) {
|
||||
found = append(found, rel+": "+m[1])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk %s: %v", root, err)
|
||||
}
|
||||
|
||||
if len(found) > 0 {
|
||||
t.Errorf("%d dialog or toast string(s) are written in Danish inside the code. "+
|
||||
"They are what somebody reads before agreeing to lose something, and an "+
|
||||
"English interface shows them in Danish:\n %s",
|
||||
len(found), strings.Join(found, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoDanishProseOutsideTheDictionaries(t *testing.T) {
|
||||
// Text nodes of three characters or more, and the three attributes a person
|
||||
// reads. Anything inside {…} is already an expression — usually a t() call —
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
-- Passkeys: a key on a device instead of a secret in a head.
|
||||
--
|
||||
-- The account already has two ways in — a password, and a TOTP code on top of it.
|
||||
-- Both are shared secrets: the server keeps something that can be stolen from it,
|
||||
-- and the person keeps something that can be phished out of them. A passkey is
|
||||
-- neither. The private half never leaves the device, the public half is all this
|
||||
-- table holds, and the signature is bound to the origin — so a convincing copy of
|
||||
-- the sign-in page at a different address gets nothing.
|
||||
--
|
||||
-- One row per credential rather than per account, because a person has more than
|
||||
-- one device and losing a laptop should not mean losing the way in. The name is
|
||||
-- theirs to write: "min bærbare" is what makes a list of keys reviewable, and a
|
||||
-- list nobody can read is a list nobody revokes from.
|
||||
--
|
||||
-- `sign_count` is the authenticator's own counter. It is stored to be compared:
|
||||
-- a counter that goes backwards means the credential has been cloned, which is
|
||||
-- the one thing this design cannot otherwise detect. Not every authenticator keeps
|
||||
-- one — the spec allows zero — so zero means "not offered" rather than "suspicious".
|
||||
CREATE TABLE passkeys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
-- The credential id the authenticator generated, raw bytes, base64url as
|
||||
-- stored. Unique across the instance: it is what a login is looked up by,
|
||||
-- before anybody has said who they are.
|
||||
credential_id TEXT NOT NULL,
|
||||
public_key BLOB NOT NULL,
|
||||
-- The AAGUID identifies the authenticator model, which is what lets a list say
|
||||
-- "iCloud Keychain" beside a key somebody named three months ago and forgot.
|
||||
aaguid TEXT NOT NULL DEFAULT '',
|
||||
sign_count INTEGER NOT NULL DEFAULT 0,
|
||||
-- Whether the credential lives on the device itself (a resident key). Only
|
||||
-- those can start a login with no email typed first, so the interface has to
|
||||
-- know which it has before it offers that.
|
||||
discoverable INTEGER NOT NULL DEFAULT 0,
|
||||
-- Whether the authenticator verified the person — a PIN, a fingerprint, a
|
||||
-- face. A key that only proves possession is a first factor; one that verified
|
||||
-- its owner is both, and that is what decides whether a password is still
|
||||
-- asked for.
|
||||
user_verified INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_passkeys_credential ON passkeys (credential_id);
|
||||
CREATE INDEX idx_passkeys_user ON passkeys (user_id);
|
||||
|
||||
-- The challenge a registration or a login is answered against.
|
||||
--
|
||||
-- Kept server-side rather than in a cookie: the whole point of the challenge is
|
||||
-- that the server chose it and remembers choosing it. One row per attempt, deleted
|
||||
-- when it is answered, and swept when it expires — a challenge that outlives its
|
||||
-- ceremony is a replay waiting to be tried.
|
||||
--
|
||||
-- user_id is null for a login that has not said who it is yet, which is the case
|
||||
-- a discoverable credential exists for.
|
||||
CREATE TABLE webauthn_challenges (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT REFERENCES users (id) ON DELETE CASCADE,
|
||||
challenge TEXT NOT NULL,
|
||||
-- 'register' or 'login'. A challenge issued for one must not be answerable by
|
||||
-- the other: registering is done by somebody already signed in, and logging in
|
||||
-- is not.
|
||||
purpose TEXT NOT NULL,
|
||||
-- The library's own session state, which carries the allowed credentials and
|
||||
-- the verification requirement it will check the answer against.
|
||||
session BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
CHECK (purpose IN ('register', 'login'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_webauthn_challenges_expiry ON webauthn_challenges (expires_at);
|
||||
@@ -0,0 +1,206 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A Passkey is one credential on one device.
|
||||
//
|
||||
// One row per credential rather than per account: a person has a laptop and a
|
||||
// phone, and losing one must not mean losing the way in. The name is theirs to
|
||||
// write — "min bærbare" is what makes the list reviewable, and a list nobody can
|
||||
// read is a list nobody revokes from.
|
||||
type Passkey struct {
|
||||
ID string
|
||||
UserID string
|
||||
CredentialID string
|
||||
PublicKey []byte
|
||||
AAGUID string
|
||||
SignCount uint32
|
||||
Discoverable bool
|
||||
UserVerified bool
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
LastUsedAt time.Time
|
||||
}
|
||||
|
||||
func (db *DB) CreatePasskey(ctx context.Context, p *Passkey) error {
|
||||
if p.ID == "" {
|
||||
p.ID = NewID()
|
||||
}
|
||||
p.CreatedAt = time.Now().UTC()
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO passkeys (id, user_id, credential_id, public_key, aaguid, sign_count,
|
||||
discoverable, user_verified, name, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
p.ID, p.UserID, p.CredentialID, p.PublicKey, p.AAGUID, p.SignCount,
|
||||
boolToInt(p.Discoverable), boolToInt(p.UserVerified), p.Name, p.CreatedAt.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) ListPasskeys(ctx context.Context, userID string) ([]Passkey, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, user_id, credential_id, public_key, aaguid, sign_count,
|
||||
discoverable, user_verified, name, created_at, COALESCE(last_used_at, 0)
|
||||
FROM passkeys WHERE user_id = ? ORDER BY created_at`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanPasskeys(rows)
|
||||
}
|
||||
|
||||
// PasskeyByCredentialID is how a login finds the account, before anybody has said
|
||||
// who they are. The credential id is unique across the instance, which is what
|
||||
// makes that possible.
|
||||
func (db *DB) PasskeyByCredentialID(ctx context.Context, credentialID string) (*Passkey, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, user_id, credential_id, public_key, aaguid, sign_count,
|
||||
discoverable, user_verified, name, created_at, COALESCE(last_used_at, 0)
|
||||
FROM passkeys WHERE credential_id = ?`, credentialID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
list, err := scanPasskeys(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &list[0], nil
|
||||
}
|
||||
|
||||
// TouchPasskey records a use and the authenticator's counter.
|
||||
//
|
||||
// The counter is stored to be compared. One that goes backwards means the
|
||||
// credential has been cloned, which is the single thing this design cannot
|
||||
// otherwise detect — and not every authenticator keeps one, so zero means "not
|
||||
// offered" rather than "suspicious".
|
||||
func (db *DB) TouchPasskey(ctx context.Context, id string, signCount uint32) error {
|
||||
_, err := db.ExecContext(ctx,
|
||||
`UPDATE passkeys SET sign_count = ?, last_used_at = ? WHERE id = ?`,
|
||||
signCount, time.Now().Unix(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) RenamePasskey(ctx context.Context, id, userID, name string) error {
|
||||
res, err := db.ExecContext(ctx,
|
||||
`UPDATE passkeys SET name = ? WHERE id = ? AND user_id = ?`, name, id, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePasskey removes one key. Scoped in the SQL rather than checked first: an
|
||||
// id read in one step and deleted in the next is a window, and the reason somebody
|
||||
// is on this screen at all is usually that they think a device is gone.
|
||||
func (db *DB) DeletePasskey(ctx context.Context, id, userID string) error {
|
||||
res, err := db.ExecContext(ctx,
|
||||
`DELETE FROM passkeys WHERE id = ? AND user_id = ?`, id, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountPasskeys is what stops somebody removing their last way in when it is the
|
||||
// only one they have.
|
||||
func (db *DB) CountPasskeys(ctx context.Context, userID string) (int, error) {
|
||||
var n int
|
||||
err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM passkeys WHERE user_id = ?`, userID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func scanPasskeys(rows *sql.Rows) ([]Passkey, error) {
|
||||
out := []Passkey{}
|
||||
for rows.Next() {
|
||||
var p Passkey
|
||||
var discoverable, verified int
|
||||
var created, used int64
|
||||
if err := rows.Scan(&p.ID, &p.UserID, &p.CredentialID, &p.PublicKey, &p.AAGUID,
|
||||
&p.SignCount, &discoverable, &verified, &p.Name, &created, &used); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Discoverable = discoverable == 1
|
||||
p.UserVerified = verified == 1
|
||||
p.CreatedAt = time.Unix(created, 0).UTC()
|
||||
if used > 0 {
|
||||
p.LastUsedAt = time.Unix(used, 0).UTC()
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// --- challenges -------------------------------------------------------------------
|
||||
|
||||
// StoreChallenge keeps the server's side of one ceremony.
|
||||
//
|
||||
// Server-side rather than in a cookie: the whole point of a challenge is that the
|
||||
// server chose it and remembers choosing it. It is deleted when answered and swept
|
||||
// when it expires — a challenge that outlives its ceremony is a replay waiting to
|
||||
// be tried.
|
||||
func (db *DB) StoreChallenge(ctx context.Context, userID, challenge, purpose string, session []byte, ttl time.Duration) (string, error) {
|
||||
id := NewID()
|
||||
now := time.Now()
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO webauthn_challenges (id, user_id, challenge, purpose, session, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
id, nullString(userID), challenge, purpose, session, now.Unix(), now.Add(ttl).Unix())
|
||||
return id, err
|
||||
}
|
||||
|
||||
// TakeChallenge reads a challenge and removes it in the same transaction.
|
||||
//
|
||||
// Taken rather than read, because a challenge is answerable exactly once. Two
|
||||
// requests racing the same one would otherwise both succeed, which is the whole
|
||||
// shape of a replay.
|
||||
func (db *DB) TakeChallenge(ctx context.Context, id, purpose string) (userID string, session []byte, err error) {
|
||||
err = db.Tx(ctx, func(tx *sql.Tx) error {
|
||||
var owner sql.NullString
|
||||
var expires int64
|
||||
row := tx.QueryRowContext(ctx,
|
||||
`SELECT user_id, session, expires_at FROM webauthn_challenges WHERE id = ? AND purpose = ?`,
|
||||
id, purpose)
|
||||
if err := row.Scan(&owner, &session, &expires); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM webauthn_challenges WHERE id = ?`, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if time.Now().Unix() > expires {
|
||||
return ErrNotFound
|
||||
}
|
||||
userID = owner.String
|
||||
return nil
|
||||
})
|
||||
return userID, session, err
|
||||
}
|
||||
|
||||
// PurgeChallenges is run by the nightly sweep. An answered challenge deletes
|
||||
// itself; an abandoned one is what this is for.
|
||||
func (db *DB) PurgeChallenges(ctx context.Context) (int64, error) {
|
||||
res, err := db.ExecContext(ctx,
|
||||
`DELETE FROM webauthn_challenges WHERE expires_at < ?`, time.Now().Unix())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
@@ -11,7 +11,11 @@ gameskill:
|
||||
invites and reminders, then start it and open the URL to create the first
|
||||
admin account.
|
||||
author: "kristianwind"
|
||||
version: 2
|
||||
# Bumped whenever the variables below change. The panel keys its catalogue on
|
||||
# this: a manifest with new fields and an unchanged version is a manifest the
|
||||
# panel has no reason to re-read, so the fields never appear and the failure is
|
||||
# silent — the server's settings page simply does not offer them.
|
||||
version: 3
|
||||
icon: "app"
|
||||
|
||||
# The image is distroless — no shell — so keep_entrypoint runs the binary the
|
||||
@@ -78,6 +82,24 @@ gameskill:
|
||||
type: bool
|
||||
default: false
|
||||
|
||||
# Restarting from verdande's own settings, which is also how it picks up a new
|
||||
# version: a container cannot replace its own image, so the panel recreates it
|
||||
# and pulls :latest on the way. All three are needed together; the page names
|
||||
# whichever are missing.
|
||||
- key: VERDANDE_PANEL_URL
|
||||
name: "Panel address, so verdande can ask to be restarted from its own settings"
|
||||
type: string
|
||||
default: ""
|
||||
- key: VERDANDE_PANEL_TOKEN
|
||||
name: "Panel API token — from an account with control of this server"
|
||||
type: string
|
||||
secret: true
|
||||
default: ""
|
||||
- key: VERDANDE_PANEL_SERVER_ID
|
||||
name: "This server's id in the panel (the one in its URL)"
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
install:
|
||||
# The image has no shell, so the install step borrows one. It only has to make
|
||||
# the directory layout exist; verdande creates and migrates its own database on
|
||||
|
||||
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 243 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 167 KiB |
@@ -50,9 +50,16 @@ function watchForTrouble(page) {
|
||||
page.on('pageerror', (error) => trouble.push(String(error)));
|
||||
page.on('response', (response) => {
|
||||
const url = response.url();
|
||||
if (url.includes('/api/') && !response.ok()) {
|
||||
trouble.push(`${response.request().method()} ${new URL(url).pathname} → ${response.status()}`);
|
||||
}
|
||||
if (!url.includes('/api/') || response.ok()) return;
|
||||
|
||||
// A 401 from /auth/me is the app asking "am I signed in?" and being told no,
|
||||
// which is the correct answer on the sign-in page and after signing out.
|
||||
// Every other non-ok response is worth failing over — that is what this
|
||||
// watcher is for.
|
||||
const path = new URL(url).pathname;
|
||||
if (path.endsWith('/auth/me') && response.status() === 401) return;
|
||||
|
||||
trouble.push(`${response.request().method()} ${path} → ${response.status()}`);
|
||||
});
|
||||
return trouble;
|
||||
}
|
||||
@@ -577,7 +584,7 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async
|
||||
await invitee.goto(link);
|
||||
|
||||
await expect(invitee.getByText('Du er inviteret')).toBeVisible();
|
||||
await invitee.getByLabel('Navn').fill('Nabo');
|
||||
await invitee.getByLabel('Navn', { exact: true }).fill('Nabo');
|
||||
await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||
await invitee.getByRole('button', { name: 'Opret konto' }).click();
|
||||
|
||||
@@ -734,7 +741,7 @@ test('en administrator kan invitere en bruger til instansen', async ({ browser,
|
||||
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const invitee = await context.newPage();
|
||||
await invitee.goto(link);
|
||||
await invitee.getByLabel('Navn').fill('Anders');
|
||||
await invitee.getByLabel('Navn', { exact: true }).fill('Anders');
|
||||
await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||
await invitee.getByRole('button', { name: 'Opret konto' }).click();
|
||||
|
||||
@@ -807,7 +814,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) =>
|
||||
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const andreas = await context.newPage();
|
||||
await andreas.goto(link);
|
||||
await andreas.getByLabel('Navn').fill('andreas');
|
||||
await andreas.getByLabel('Navn', { exact: true }).fill('andreas');
|
||||
await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||
await andreas.getByRole('button', { name: 'Opret konto' }).click();
|
||||
await expect(andreas.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
@@ -964,7 +971,7 @@ test('historik-siden viser hændelser fra et projekt, administratoren ikke er me
|
||||
expect(link).toContain('/invite?token=');
|
||||
|
||||
await theirs.goto(link);
|
||||
await theirs.getByLabel('Navn').fill('Sigrid');
|
||||
await theirs.getByLabel('Navn', { exact: true }).fill('Sigrid');
|
||||
await theirs.getByLabel(/Adgangskode/).fill('et langt kodeord til test');
|
||||
await theirs.getByRole('button', { name: 'Opret konto' }).click();
|
||||
await expect(theirs.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
@@ -1150,8 +1157,13 @@ test('kontoens sprog skifter hele fladen, ikke kun parseren', async ({ page }) =
|
||||
/januar|februar|marts|maj|juni|juli|oktober|december/i
|
||||
);
|
||||
|
||||
// The Inbox keeps its name: it is a project row created at signup, not a
|
||||
// string in the interface, and the person can rename it like any other.
|
||||
// The Inbox is named by the server at account creation, before anybody has seen
|
||||
// a settings page — so it is the one project name nobody chose, and it follows
|
||||
// the language like a label. Rename it and that becomes your name for it in
|
||||
// every language, the same rule as any other project.
|
||||
await expect(
|
||||
page.getByRole('navigation', { name: 'Main menu' }).getByRole('link', { name: 'Inbox' })
|
||||
).toBeVisible();
|
||||
|
||||
// Danish again, for everything that runs after this.
|
||||
await page.goto('/indstillinger');
|
||||
@@ -1447,6 +1459,97 @@ test('en opgave lukket ved et uheld kan hentes tilbage', async ({ page }) => {
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A passkey, registered and then signed in with, end to end.
|
||||
*
|
||||
* The Go tests cover who may reach these endpoints and that a challenge is spent
|
||||
* when it is used. What they cannot do is produce a signature — that needs an
|
||||
* authenticator. Chrome has a virtual one behind CDP, and it is the only way to
|
||||
* find out whether the encoding is right: every boundary here is a base64url
|
||||
* conversion, and one wrong byte produces a signature that will not verify,
|
||||
* reported as "that key was not accepted" — which reads as a broken key rather
|
||||
* than a broken encoder.
|
||||
*
|
||||
* Chromium only, and deliberately so: this is testing our ceremony, not WebKit's
|
||||
* authenticator.
|
||||
*/
|
||||
test('en passkey kan registreres og logges ind med', async ({ browser, browserName }) => {
|
||||
test.skip(browserName !== 'chromium', 'den virtuelle authenticator findes kun i Chromium');
|
||||
|
||||
// Its own context, at `localhost` rather than the loopback address the rest of
|
||||
// the suite uses. WebAuthn refuses an IP as a relying party id — the browser
|
||||
// throws `SecurityError: This is an invalid domain` — and localhost is the
|
||||
// exception every browser makes. It is not the shared baseURL because
|
||||
// resolving `localhost` is slow enough here to cost the whole suite minutes.
|
||||
//
|
||||
// Cookies are bound to an origin, so the signed-in state from the setup project
|
||||
// does not carry across: this signs in with a password first, which is also the
|
||||
// honest order — you register a key on an account you have already proved you
|
||||
// own.
|
||||
const origin = 'http://localhost:8097';
|
||||
const context = await browser.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
const page = await context.newPage();
|
||||
const trouble = watchForTrouble(page);
|
||||
|
||||
await page.goto(`${origin}/`);
|
||||
await page.getByLabel(/E-mail/).fill(USER.email);
|
||||
await page.getByLabel(/Adgangskode/).fill(USER.password);
|
||||
await page.getByRole('button', { name: 'Log ind', exact: true }).click();
|
||||
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
|
||||
// A virtual authenticator that verifies its owner, so the key counts as both
|
||||
// factors — which is what lets the login complete without a password.
|
||||
const client = await page.context().newCDPSession(page);
|
||||
await client.send('WebAuthn.enable');
|
||||
const { authenticatorId } = await client.send('WebAuthn.addVirtualAuthenticator', {
|
||||
options: {
|
||||
protocol: 'ctap2',
|
||||
transport: 'internal',
|
||||
hasResidentKey: true,
|
||||
hasUserVerification: true,
|
||||
isUserVerified: true,
|
||||
automaticPresenceSimulation: true
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(`${origin}/indstillinger`);
|
||||
await page.getByLabel('Navn på nøglen').fill('Testnøglen');
|
||||
await page.getByRole('button', { name: 'Tilføj en nøgle' }).click();
|
||||
|
||||
// It is in the list, and it counts as both factors — the virtual authenticator
|
||||
// verifies, so the login below needs no password.
|
||||
const key = page.locator('li').filter({ hasText: 'Testnøglen' });
|
||||
await expect(key).toBeVisible();
|
||||
await expect(key.getByText('begge faktorer')).toBeVisible();
|
||||
|
||||
// It survives a reload, so this is the server's row and not the page's state.
|
||||
await page.reload();
|
||||
await expect(page.locator('li').filter({ hasText: 'Testnøglen' })).toBeVisible();
|
||||
|
||||
// Now sign out and back in with it. No email typed: the device knows which
|
||||
// account its key belongs to, which is also why this page cannot be used to
|
||||
// find out who has an account here.
|
||||
await page.getByRole('navigation', { name: 'Hovedmenu' }).getByText('Log ud').click();
|
||||
// `exact`, because "Log ind" is a substring of "Log ind med en passkey" and
|
||||
// Playwright matches an accessible name loosely unless told otherwise.
|
||||
await expect(page.getByRole('button', { name: 'Log ind', exact: true })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Log ind med en passkey' }).click();
|
||||
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'I dag' })).toBeVisible();
|
||||
|
||||
// And the key records that it was used, which is what makes the list worth
|
||||
// reading when somebody wonders whether a device is still in play.
|
||||
await page.goto(`${origin}/indstillinger`);
|
||||
await expect(
|
||||
page.locator('li').filter({ hasText: 'Testnøglen' }).getByText(/sidst brugt/)
|
||||
).toBeVisible();
|
||||
|
||||
await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
|
||||
await context.close();
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The sidebar never scrolls sideways.
|
||||
*
|
||||
@@ -1465,7 +1568,9 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
|
||||
await page.goto('/indstillinger');
|
||||
|
||||
// A name long enough to overflow the column at its default width.
|
||||
await page.getByLabel('Navn').fill('Kristian Vinterberg-Skovgaard');
|
||||
// `exact`, or this also matches "Navn på nøglen" in the passkey panel — and an
|
||||
// ambiguous locator waits for one of them to go away rather than failing.
|
||||
await page.getByLabel('Navn', { exact: true }).fill('Kristian Vinterberg-Skovgaard');
|
||||
await page.getByRole('button', { name: 'Gem' }).first().click();
|
||||
await expect(page.getByText('Kristian Vinterberg-Skovgaard').first()).toBeVisible();
|
||||
|
||||
@@ -1480,7 +1585,7 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => {
|
||||
expect(page_sideways, 'siden kan scrolles vandret').toBeLessThanOrEqual(1);
|
||||
|
||||
// Put the name back, so the tests after this one see the account they expect.
|
||||
await page.getByLabel('Navn').fill(USER.name);
|
||||
await page.getByLabel('Navn', { exact: true }).fill(USER.name);
|
||||
await page.getByRole('button', { name: 'Gem' }).first().click();
|
||||
|
||||
expect(trouble).toEqual([]);
|
||||
@@ -1535,6 +1640,16 @@ test('et projekt kan have flere sektioner @forms', async ({ page }) => {
|
||||
// inside a board was exactly true.
|
||||
await page.getByRole('button', { name: 'Board', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Uden sektion' })).toBeVisible();
|
||||
|
||||
// A board gets the width a reading column withholds. This was `.view:has(.board)`
|
||||
// for as long as the board has existed and never once matched: `.board` is inside
|
||||
// a child component, and Svelte scopes a selector to the component that wrote it.
|
||||
// The rule read correctly, compiled, and could not fire.
|
||||
const wide = await page.locator('.view').evaluate((el) => el.getBoundingClientRect().width);
|
||||
await page.getByRole('button', { name: 'Liste', exact: true }).click();
|
||||
const narrow = await page.locator('.view').evaluate((el) => el.getBoundingClientRect().width);
|
||||
expect(wide, 'board-visningen er ikke bredere end listen').toBeGreaterThan(narrow);
|
||||
await page.getByRole('button', { name: 'Board', exact: true }).click();
|
||||
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
||||
const field = page.getByLabel('Ny sektion');
|
||||
await expect(field, 'boardet har ingen måde at lave en sektion på').toBeVisible();
|
||||
|
||||
@@ -34,6 +34,9 @@ if (process.env.TEST_WORKER_INDEX === undefined) {
|
||||
}
|
||||
|
||||
const PORT = 8097;
|
||||
// `localhost`, not `127.0.0.1`. WebAuthn refuses an IP address as a relying party
|
||||
// id — `SecurityError: This is an invalid domain` — and localhost is the exception
|
||||
// every browser makes. The passkey test cannot run against a loopback address.
|
||||
const baseURL = `http://127.0.0.1:${PORT}`;
|
||||
|
||||
export default defineConfig({
|
||||
@@ -118,7 +121,15 @@ export default defineConfig({
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
VERDANDE_ADDR: `:${PORT}`,
|
||||
VERDANDE_BASE_URL: baseURL,
|
||||
// `localhost`, while the suite itself drives `127.0.0.1`. These are two
|
||||
// different things: the suite's address is where the browser goes, and
|
||||
// this one is what the server calls itself — which is now also the
|
||||
// WebAuthn relying party id, and WebAuthn refuses an IP address.
|
||||
//
|
||||
// The passkey test navigates to localhost explicitly so its origin
|
||||
// matches. Everything else stays on the loopback address, where it has
|
||||
// always been.
|
||||
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
|
||||
VERDANDE_DATA_DIR: dataDir,
|
||||
// Off: an update check would reach out to GitHub from a test run.
|
||||
VERDANDE_UPDATE_CHECK: 'false'
|
||||
|
||||
@@ -51,6 +51,9 @@ const SHOTS = [
|
||||
{ name: 'upcoming', path: '/upcoming', theme: 'dark', prepare: showMonth },
|
||||
{ name: 'week', path: '/upcoming', theme: 'dark', prepare: showWeek },
|
||||
{ name: 'board', path: (ids) => `/projekt/${ids.work}`, theme: 'dark' },
|
||||
// The same project as a list, which is where sections read as bands rather than
|
||||
// as columns — and the shape most people actually work in.
|
||||
{ name: 'sections', path: (ids) => `/projekt/${ids.work}`, theme: 'dark', prepare: showList },
|
||||
{ name: 'detail', path: '/', theme: 'dark', prepare: openFirstTask },
|
||||
{ name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
|
||||
{ name: 'themes', path: '/indstillinger', theme: 'dark' },
|
||||
@@ -59,6 +62,11 @@ const SHOTS = [
|
||||
{ name: 'delegated', path: '/uddelegeret', theme: 'dark' }
|
||||
];
|
||||
|
||||
async function showList(page) {
|
||||
await page.getByRole('button', { name: 'Liste', exact: true }).click();
|
||||
await page.waitForTimeout(600);
|
||||
}
|
||||
|
||||
async function showMonth(page) {
|
||||
await page.getByRole('button', { name: 'Måned', exact: true }).click();
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
@@ -55,6 +55,9 @@ const MESSAGES = {
|
||||
ai_not_configured: 'error.aiNotConfigured',
|
||||
totp_not_enabled: 'error.totpNotEnabled',
|
||||
inbox_protected: 'error.inboxProtected',
|
||||
// The fallback for when Gmail refused and said nothing usable. When it did say
|
||||
// something, `humanMessage` quotes it instead — see below.
|
||||
gmail_failed: 'error.gmailRefused',
|
||||
last_admin: 'error.lastAdmin'
|
||||
};
|
||||
|
||||
@@ -67,6 +70,15 @@ const MESSAGES = {
|
||||
*/
|
||||
export function humanMessage(err) {
|
||||
if (!(err instanceof ApiError)) return t(MESSAGES.offline);
|
||||
|
||||
// Gmail's own words are the diagnosis — "invalid_grant", "insufficient
|
||||
// authentication scopes" — and a generic sentence in their place throws away
|
||||
// the only thing that says what to do. Wrapped so it reads as a quotation
|
||||
// rather than as a stray English string in a Danish interface.
|
||||
if (err.code === 'gmail_failed' && err.message) {
|
||||
return t('error.gmailSaid', { what: err.message });
|
||||
}
|
||||
|
||||
const key = MESSAGES[err.code];
|
||||
if (key) return t(key);
|
||||
return err.message ?? t(MESSAGES.internal_error);
|
||||
@@ -181,6 +193,15 @@ export const api = {
|
||||
totpConfirm: (code) => post('/auth/totp/confirm', { code }),
|
||||
totpDisable: (password) => post('/auth/totp/disable', { password }),
|
||||
listSessions: () => get('/auth/sessions'),
|
||||
|
||||
// --- passkeys
|
||||
listPasskeys: () => get('/auth/passkeys'),
|
||||
beginPasskeyRegistration: () => post('/auth/passkeys/register/begin'),
|
||||
finishPasskeyRegistration: (data) => post('/auth/passkeys/register/finish', data),
|
||||
renamePasskey: (id, name) => patch(`/auth/passkeys/${id}`, { name }),
|
||||
deletePasskey: (id) => del(`/auth/passkeys/${id}`),
|
||||
beginPasskeyLogin: () => post('/auth/passkey/login/begin'),
|
||||
finishPasskeyLogin: (data) => post('/auth/passkey/login/finish', data),
|
||||
endSession: (id) => del(`/auth/sessions/${id}`),
|
||||
|
||||
// --- users, administrators only
|
||||
@@ -190,6 +211,8 @@ export const api = {
|
||||
deleteUser: (id) => del(`/users/${id}`),
|
||||
revokeInvite: (id) => del(`/invites/${id}`),
|
||||
listErrors: () => get('/errors'),
|
||||
panelStatus: () => get('/panel'),
|
||||
restartFromPanel: () => post('/panel/restart'),
|
||||
|
||||
// The nightly backup, which ran since the beginning with nothing to show it.
|
||||
// The OAuth client itself, which is the instance's registration with Google
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
);
|
||||
} catch {
|
||||
app.replace(id, previous);
|
||||
app.toast('Kunne ikke flytte opgaven.');
|
||||
app.toast(t('task.moveFailed'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { app, sidebar } from '$lib/stores.svelte.js';
|
||||
import { app, sidebar, projectName } from '$lib/stores.svelte.js';
|
||||
import { api } from '$lib/api.js';
|
||||
import { TASK, PROJECT, GROUP, startDrag, carries, dragged, accept } from '$lib/dnd.js';
|
||||
import { COLORS, colorVar } from '$lib/colors.js';
|
||||
@@ -410,7 +410,7 @@
|
||||
ondrop={(e) => (carries(e, TASK) ? onTaskDrop(e, project) : sortable && onDrop(e, project))}
|
||||
>
|
||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"></span>
|
||||
{project.name}
|
||||
{projectName(project)}
|
||||
{#if project.shared}
|
||||
<span class="count">{project.member_count}</span>
|
||||
{/if}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { available, signIn } from '$lib/passkey.js';
|
||||
|
||||
// loading | login | setup | totp | forgot | sent | invite | reset | done
|
||||
let mode = $state('loading');
|
||||
@@ -31,6 +32,41 @@
|
||||
let fields = $state({});
|
||||
let busy = $state(false);
|
||||
|
||||
// --- passkey -----------------------------------------------------------------
|
||||
|
||||
let passkeyReady = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
available().then((ok) => (passkeyReady = ok));
|
||||
});
|
||||
|
||||
/**
|
||||
* No email is asked for and none is sent: the device knows which account its
|
||||
* key belongs to. That is not only convenience — it means this page cannot be
|
||||
* used to find out who has an account here.
|
||||
*/
|
||||
async function signInWithPasskey() {
|
||||
error = '';
|
||||
busy = true;
|
||||
try {
|
||||
const result = await signIn();
|
||||
if (result.totp_required) {
|
||||
mode = 'totp';
|
||||
} else {
|
||||
app.user = result.user;
|
||||
await app.load();
|
||||
}
|
||||
} catch (e) {
|
||||
// A cancelled prompt is not a failure. Somebody changed their mind, and
|
||||
// an error for that reads as though the key was rejected.
|
||||
if (e?.name !== 'NotAllowedError' && e?.message !== 'cancelled') {
|
||||
error = humanMessage(e);
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The token from an emailed link, if this is one.
|
||||
*
|
||||
@@ -267,6 +303,15 @@
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={busy}>{t('auth.signIn')}</button>
|
||||
|
||||
<!-- Only when the browser has somewhere to keep a key. Offering it where
|
||||
it cannot work is a button that fails for reasons nobody can act on. -->
|
||||
{#if passkeyReady}
|
||||
<button type="button" class="passkey" disabled={busy} onclick={signInWithPasskey}>
|
||||
{busy ? t('passkey.signingIn') : t('passkey.signIn')}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button type="button" class="link" onclick={() => (mode = 'forgot')}>
|
||||
{t('auth.forgot')}
|
||||
</button>
|
||||
@@ -381,6 +426,22 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Set apart from the primary button but not made secondary: it is an equal way
|
||||
in, not a fallback. */
|
||||
.passkey {
|
||||
width: 100%;
|
||||
padding: var(--s2) var(--s4);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink);
|
||||
transition: border-color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.passkey:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--ink-faint);
|
||||
font-size: var(--text-sm);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* necessarily the last one sent.
|
||||
*/
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { app, projectName } from '$lib/stores.svelte.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
|
||||
@@ -276,7 +276,7 @@
|
||||
}
|
||||
|
||||
async function removeComment(comment) {
|
||||
if (!confirm('Slet kommentaren?')) return;
|
||||
if (!confirm(t('detail.deleteComment'))) return;
|
||||
const previous = comments;
|
||||
comments = comments.filter((c) => c.id !== comment.id);
|
||||
try {
|
||||
@@ -436,7 +436,7 @@
|
||||
<label for="project">{t('detail.project')}</label>
|
||||
<select id="project" bind:value={projectId} onchange={saveProject}>
|
||||
{#each app.projects as project (project.id)}
|
||||
<option value={project.id}>{project.name}</option>
|
||||
<option value={project.id}>{projectName(project)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -613,5 +613,57 @@ export const da = {
|
||||
'done.title': 'Færdige',
|
||||
'done.hint': 'Alt du har lukket, nyeste først. Klik cirklen for at åbne en igen.',
|
||||
'done.empty': 'Du har ikke lukket noget endnu.',
|
||||
'done.unknownDay': 'Uden dato'
|
||||
'done.unknownDay': 'Uden dato',
|
||||
|
||||
// --- passkeys ----------------------------------------------------------------------
|
||||
'passkey.title': 'Passkeys',
|
||||
'passkey.hint':
|
||||
'En nøgle på en enhed i stedet for en hemmelighed i et hoved. Den private halvdel forlader aldrig enheden, og signaturen er bundet til adressen — så en overbevisende kopi af login-siden et andet sted får ingenting.',
|
||||
'passkey.unsupported': 'Denne browser understøtter ikke passkeys.',
|
||||
'passkey.none': 'Ingen nøgler endnu.',
|
||||
'passkey.name': 'Navn på nøglen',
|
||||
'passkey.namePlaceholder': 'fx min bærbare',
|
||||
'passkey.add': 'Tilføj en nøgle',
|
||||
'passkey.registering': 'Venter på enheden…',
|
||||
'passkey.remove': 'Fjern',
|
||||
'passkey.removeQuestion': 'Fjern "{name}"? Enheden kan ikke logge ind med den bagefter.',
|
||||
'passkey.bothFactors': 'begge faktorer',
|
||||
'passkey.possessionOnly': 'kun besiddelse — koden bliver stadig krævet',
|
||||
'passkey.lastUsed': 'sidst brugt {when}',
|
||||
'passkey.neverUsed': 'aldrig brugt',
|
||||
'passkey.signIn': 'Log ind med en passkey',
|
||||
'passkey.signingIn': 'Venter på enheden…',
|
||||
|
||||
'passkey.unavailable':
|
||||
'Passkeys kan ikke bruges her — enten understøtter browseren dem ikke, eller også er serverens adresse en IP frem for et domænenavn, hvilket ingen authenticator accepterer.',
|
||||
|
||||
// --- restarting from the interface ---------------------------------------------
|
||||
'update.restart': 'Genstart og hent nyeste version',
|
||||
'update.restarting': 'Genstarter…',
|
||||
'update.restartQuestion':
|
||||
'Genstart nu? Serveren er nede i omkring et halvt minut, og henter den nyeste version på vejen op.',
|
||||
'update.asking': 'Beder panelet om at genstarte…',
|
||||
'update.waiting': 'Venter på at serveren kommer op igen…',
|
||||
'update.stillDown': 'Serveren er ikke kommet op endnu. Kig i panelet.',
|
||||
'update.notConfigured':
|
||||
'Denne instans kan ikke genstarte sig selv: {missing} er ikke sat. En container kan ikke udskifte sit eget image — panelet gør arbejdet, og det her er kun måden at bede om det.',
|
||||
|
||||
'error.gmailSaid': 'Gmail svarede: {what}',
|
||||
|
||||
'error.gmailRefused': 'Gmail afviste kaldet. Prøv at forbinde postkassen igen.',
|
||||
|
||||
'int.newAddressQuestion': 'Ny adresse? Mail til den gamle bliver afvist.',
|
||||
'int.copied': 'Kopieret.',
|
||||
'int.copyFailed': 'Kunne ikke kopiere — markér teksten i stedet.',
|
||||
|
||||
'detail.deleteComment': 'Slet kommentaren?',
|
||||
'int.disconnectQuestion': 'Afbryd forbindelsen til Gmail? Tokens bliver glemt.',
|
||||
'int.newLinkQuestion': 'Nyt feed-link? Alle eksisterende abonnementer holder op med at virke.',
|
||||
'data.deleteTemplate': 'Slet skabelonen "{name}"?',
|
||||
'tokens.revokeQuestion': 'Tilbagekald "{name}"? Alt, der bruger den, holder op med at virke.',
|
||||
'account.signOutThisDevice': 'Det er denne enhed. Du bliver logget ud.',
|
||||
|
||||
'project.deleteQuestion': 'Slet "{name}" med alle dets opgaver?',
|
||||
'project.deleteRecoverable':
|
||||
'Det havner i papirkurven og kan hentes tilbage under Indstillinger → Data og skabeloner i {days} dage.'
|
||||
};
|
||||
|
||||
@@ -608,5 +608,57 @@ export const en = {
|
||||
'done.title': 'Done',
|
||||
'done.hint': 'Everything you have closed, newest first. Click the circle to reopen one.',
|
||||
'done.empty': 'You have not closed anything yet.',
|
||||
'done.unknownDay': 'No date'
|
||||
'done.unknownDay': 'No date',
|
||||
|
||||
// --- passkeys ----------------------------------------------------------------------
|
||||
'passkey.title': 'Passkeys',
|
||||
'passkey.hint':
|
||||
'A key on a device instead of a secret in a head. The private half never leaves the device, and the signature is bound to the address that asked for it — so a convincing copy of the sign-in page somewhere else gets nothing.',
|
||||
'passkey.unsupported': 'This browser does not support passkeys.',
|
||||
'passkey.none': 'No keys yet.',
|
||||
'passkey.name': 'Name for the key',
|
||||
'passkey.namePlaceholder': 'e.g. my laptop',
|
||||
'passkey.add': 'Add a key',
|
||||
'passkey.registering': 'Waiting for the device…',
|
||||
'passkey.remove': 'Remove',
|
||||
'passkey.removeQuestion': 'Remove "{name}"? That device cannot sign in with it afterwards.',
|
||||
'passkey.bothFactors': 'both factors',
|
||||
'passkey.possessionOnly': 'possession only — the code is still required',
|
||||
'passkey.lastUsed': 'last used {when}',
|
||||
'passkey.neverUsed': 'never used',
|
||||
'passkey.signIn': 'Sign in with a passkey',
|
||||
'passkey.signingIn': 'Waiting for the device…',
|
||||
|
||||
'passkey.unavailable':
|
||||
'Passkeys cannot be used here — either this browser does not support them, or the server’s address is an IP rather than a domain name, which no authenticator will accept.',
|
||||
|
||||
// --- restarting from the interface ---------------------------------------------
|
||||
'update.restart': 'Restart and pull the latest version',
|
||||
'update.restarting': 'Restarting…',
|
||||
'update.restartQuestion':
|
||||
'Restart now? The server is down for about half a minute, and pulls the newest version on the way back up.',
|
||||
'update.asking': 'Asking the panel to restart…',
|
||||
'update.waiting': 'Waiting for the server to come back…',
|
||||
'update.stillDown': 'The server has not come back yet. Have a look in the panel.',
|
||||
'update.notConfigured':
|
||||
'This instance cannot restart itself: {missing} are not set. A container cannot replace its own image — the panel does the work, and this is only the way to ask.',
|
||||
|
||||
'error.gmailSaid': 'Gmail said: {what}',
|
||||
|
||||
'error.gmailRefused': 'Gmail refused the call. Try connecting the mailbox again.',
|
||||
|
||||
'int.newAddressQuestion': 'A new address? Mail to the old one is refused.',
|
||||
'int.copied': 'Copied.',
|
||||
'int.copyFailed': 'Could not copy — select the text instead.',
|
||||
|
||||
'detail.deleteComment': 'Delete the comment?',
|
||||
'int.disconnectQuestion': 'Disconnect Gmail? The tokens are forgotten.',
|
||||
'int.newLinkQuestion': 'A new feed link? Every existing subscription stops working.',
|
||||
'data.deleteTemplate': 'Delete the template "{name}"?',
|
||||
'tokens.revokeQuestion': 'Revoke "{name}"? Everything using it stops working.',
|
||||
'account.signOutThisDevice': 'That is this device. You will be signed out.',
|
||||
|
||||
'project.deleteQuestion': 'Delete "{name}" with all its tasks?',
|
||||
'project.deleteRecoverable':
|
||||
'It goes to the trash and can be restored under Settings → Data and templates for {days} days.'
|
||||
};
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* The browser half of a passkey ceremony.
|
||||
*
|
||||
* WebAuthn passes binary as ArrayBuffers and JSON cannot carry those, so every
|
||||
* boundary between this and the server is a base64url conversion. Getting one of
|
||||
* them wrong produces a signature that will not verify, reported as "that key was
|
||||
* not accepted" — which reads as a broken key rather than a broken encoder. That
|
||||
* is the whole reason this is one file and not three call sites.
|
||||
*/
|
||||
import { api } from './api.js';
|
||||
|
||||
/** Whether this browser can do it at all. */
|
||||
export function supported() {
|
||||
return typeof window !== 'undefined' && Boolean(window.PublicKeyCredential);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a key can be offered without the person typing anything first.
|
||||
*
|
||||
* Not the same question as `supported()`: a browser can do WebAuthn and still have
|
||||
* nowhere to put a discoverable credential. Asking first is what stops the sign-in
|
||||
* page offering a button that cannot work.
|
||||
*/
|
||||
export async function available() {
|
||||
if (!supported()) return false;
|
||||
try {
|
||||
return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const toBuffer = (value) => {
|
||||
const normalised = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalised.padEnd(normalised.length + ((4 - (normalised.length % 4)) % 4), '=');
|
||||
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
|
||||
};
|
||||
|
||||
const toBase64URL = (buffer) =>
|
||||
btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
/** Registers a key on the signed-in account. Returns the row the server stored. */
|
||||
export async function register(name) {
|
||||
const { challenge_id, options } = await api.beginPasskeyRegistration();
|
||||
const publicKey = {
|
||||
...options.publicKey,
|
||||
challenge: toBuffer(options.publicKey.challenge),
|
||||
user: { ...options.publicKey.user, id: toBuffer(options.publicKey.user.id) },
|
||||
excludeCredentials: (options.publicKey.excludeCredentials ?? []).map((c) => ({
|
||||
...c,
|
||||
id: toBuffer(c.id)
|
||||
}))
|
||||
};
|
||||
|
||||
const credential = await navigator.credentials.create({ publicKey });
|
||||
if (!credential) throw new Error('cancelled');
|
||||
|
||||
return api.finishPasskeyRegistration({
|
||||
challenge_id,
|
||||
name,
|
||||
credential: {
|
||||
id: credential.id,
|
||||
rawId: toBase64URL(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: toBase64URL(credential.response.clientDataJSON),
|
||||
attestationObject: toBase64URL(credential.response.attestationObject)
|
||||
},
|
||||
clientExtensionResults: credential.getClientExtensionResults()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs in with a key.
|
||||
*
|
||||
* No email is asked for and none is sent: the device knows which account its key
|
||||
* belongs to. That is not only convenience — it means the sign-in page cannot be
|
||||
* used to find out who has an account here.
|
||||
*/
|
||||
export async function signIn() {
|
||||
const { challenge_id, options } = await api.beginPasskeyLogin();
|
||||
const publicKey = {
|
||||
...options.publicKey,
|
||||
challenge: toBuffer(options.publicKey.challenge),
|
||||
allowCredentials: (options.publicKey.allowCredentials ?? []).map((c) => ({
|
||||
...c,
|
||||
id: toBuffer(c.id)
|
||||
}))
|
||||
};
|
||||
|
||||
const credential = await navigator.credentials.get({ publicKey });
|
||||
if (!credential) throw new Error('cancelled');
|
||||
|
||||
return api.finishPasskeyLogin({
|
||||
challenge_id,
|
||||
credential: {
|
||||
id: credential.id,
|
||||
rawId: toBase64URL(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: toBase64URL(credential.response.clientDataJSON),
|
||||
authenticatorData: toBase64URL(credential.response.authenticatorData),
|
||||
signature: toBase64URL(credential.response.signature),
|
||||
userHandle: credential.response.userHandle
|
||||
? toBase64URL(credential.response.userHandle)
|
||||
: null
|
||||
},
|
||||
clientExtensionResults: credential.getClientExtensionResults()
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -620,6 +620,28 @@ class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What to call a project on screen.
|
||||
*
|
||||
* Almost always its own name — somebody wrote it and it is theirs. The Inbox is the
|
||||
* exception: the server names it at account creation, before anybody has seen a
|
||||
* settings page, so it is the one project name nobody chose. Leaving it as stored
|
||||
* meant an account created in Danish said "Indbakke" in an English interface, which
|
||||
* reads as a string somebody forgot to translate. It was.
|
||||
*
|
||||
* Only while it still carries a name the server gave it. Rename it and that is your
|
||||
* name for it, in every language — the same rule as any other project.
|
||||
*/
|
||||
const SYSTEM_INBOX_NAMES = new Set(['indbakke', 'inbox']);
|
||||
|
||||
export function projectName(project) {
|
||||
if (!project) return '';
|
||||
if (project.is_inbox && SYSTEM_INBOX_NAMES.has(project.name.trim().toLowerCase())) {
|
||||
return t('nav.inbox');
|
||||
}
|
||||
return project.name;
|
||||
}
|
||||
|
||||
/** Enough of a task's title to recognise it, without a toast the width of the page. */
|
||||
function truncate(text, max = 40) {
|
||||
const trimmed = text.trim();
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
import { page } from '$app/stores';
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { app, projectName } from '$lib/stores.svelte.js';
|
||||
import { colorVar } from '$lib/colors.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
@@ -165,7 +165,7 @@
|
||||
<a href="/projekt/{project.id}">
|
||||
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
|
||||
></span>
|
||||
{project.name}
|
||||
{projectName(project)}
|
||||
<!-- Counted by the server in the same request. Open tasks, not all
|
||||
of them: "12" beside a project finished last year is a number
|
||||
that means nothing. -->
|
||||
|
||||
@@ -3,12 +3,67 @@
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app, theme, THEMES } from '$lib/stores.svelte.js';
|
||||
import { t, plural, i18n } from '$lib/i18n.svelte.js';
|
||||
import { supported, register } from '$lib/passkey.js';
|
||||
import { ago } from '$lib/when.js';
|
||||
|
||||
// --- profile ---------------------------------------------------------------
|
||||
|
||||
let name = $state('');
|
||||
let timezone = $state('');
|
||||
let locale = $state('da');
|
||||
|
||||
// --- passkeys -------------------------------------------------------------------
|
||||
|
||||
let passkeys = $state([]);
|
||||
let passkeyName = $state('');
|
||||
let passkeyError = $state('');
|
||||
let registering = $state(false);
|
||||
let passkeySupported = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
passkeySupported = supported();
|
||||
if (!passkeySupported) return;
|
||||
api
|
||||
.listPasskeys()
|
||||
.then((r) => {
|
||||
passkeys = r.passkeys;
|
||||
// The server has the other half of the answer: this browser can do
|
||||
// passkeys, and this deployment may still be on an address no
|
||||
// authenticator will accept.
|
||||
passkeySupported = r.available;
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
async function addPasskey(event) {
|
||||
event.preventDefault();
|
||||
passkeyError = '';
|
||||
registering = true;
|
||||
try {
|
||||
passkeys = [...passkeys, await register(passkeyName.trim())];
|
||||
passkeyName = '';
|
||||
} catch (e) {
|
||||
// A cancelled prompt is not a failure — the person changed their mind,
|
||||
// and an error message for that reads as though something broke.
|
||||
if (e?.name !== 'NotAllowedError' && e?.message !== 'cancelled') {
|
||||
passkeyError = humanMessage(e);
|
||||
}
|
||||
} finally {
|
||||
registering = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removePasskey(key) {
|
||||
if (!confirm(t('passkey.removeQuestion', { name: key.name }))) return;
|
||||
const previous = passkeys;
|
||||
passkeys = passkeys.filter((k) => k.id !== key.id);
|
||||
try {
|
||||
await api.deletePasskey(key.id);
|
||||
} catch (e) {
|
||||
passkeys = previous;
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
let profileErrors = $state({});
|
||||
let profileSaved = $state(false);
|
||||
let savingProfile = $state(false);
|
||||
@@ -160,7 +215,7 @@
|
||||
|
||||
async function endSession(session) {
|
||||
if (session.current) {
|
||||
if (!confirm('Det er denne enhed. Du bliver logget ud.')) return;
|
||||
if (!confirm(t('account.signOutThisDevice'))) return;
|
||||
await api.endSession(session.id);
|
||||
// A full reload rather than clearing the store by hand: the session is
|
||||
// gone, so every open socket and every cached answer in this tab is now
|
||||
@@ -187,16 +242,6 @@
|
||||
* older than a week gets its date instead: "for 23 dage siden" is a number
|
||||
* nobody can place.
|
||||
*/
|
||||
function ago(iso) {
|
||||
const then = new Date(iso);
|
||||
const seconds = Math.round((Date.now() - then) / 1000);
|
||||
|
||||
if (seconds < 60) return 'lige nu';
|
||||
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
|
||||
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
|
||||
if (seconds < 604800) return `for ${Math.floor(seconds / 86400)} dage siden`;
|
||||
return then.toLocaleDateString('da-DK', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel">
|
||||
@@ -413,6 +458,47 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Between two-factor and the device list, because it belongs to both: a passkey
|
||||
is a way in, and it is a thing on a device you might later want to revoke. -->
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>{t('passkey.title')}</h2>
|
||||
<p class="hint">{t('passkey.hint')}</p>
|
||||
</header>
|
||||
|
||||
{#if !passkeySupported}
|
||||
<p class="hint">{t('passkey.unavailable')}</p>
|
||||
{:else}
|
||||
{#if passkeys.length}
|
||||
<ul class="list">
|
||||
{#each passkeys as key (key.id)}
|
||||
<li>
|
||||
<div class="what">
|
||||
<span class="primary-line">{key.name}</span>
|
||||
<span class="secondary">
|
||||
{key.user_verified ? t('passkey.bothFactors') : t('passkey.possessionOnly')}
|
||||
·
|
||||
{key.last_used_at ? t('passkey.lastUsed', { when: ago(key.last_used_at) }) : t('passkey.neverUsed')}
|
||||
</span>
|
||||
</div>
|
||||
<button class="secondary" onclick={() => removePasskey(key)}>{t('passkey.remove')}</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="empty">{t('passkey.none')}</p>
|
||||
{/if}
|
||||
|
||||
<form class="row" onsubmit={addPasskey}>
|
||||
<input bind:value={passkeyName} placeholder={t('passkey.namePlaceholder')} aria-label={t('passkey.name')} />
|
||||
<button class="primary" type="submit" disabled={registering}>
|
||||
{registering ? t('passkey.registering') : t('passkey.add')}
|
||||
</button>
|
||||
</form>
|
||||
{#if passkeyError}<p class="error">{passkeyError}</p>{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>{t('account.devices')}</h2>
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
}
|
||||
|
||||
async function deleteTemplate(template) {
|
||||
if (!confirm(`Slet skabelonen "${template.name}"?`)) return;
|
||||
if (!confirm(t('data.deleteTemplate', { name: template.name }))) return;
|
||||
|
||||
const previous = templates;
|
||||
templates = templates.filter((t) => t.id !== template.id);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* audit log must not have.
|
||||
*/
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { app, projectName } from '$lib/stores.svelte.js';
|
||||
import { eventName, eventDetail } from '$lib/events.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { ago } from '$lib/when.js';
|
||||
@@ -130,7 +130,7 @@
|
||||
<select bind:value={filterProject}>
|
||||
<option value="">{t('history.all')}</option>
|
||||
{#each projects as project (project.id)}
|
||||
<option value={project.id}>{project.name}</option>
|
||||
<option value={project.id}>{projectName(project)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
}
|
||||
|
||||
async function disconnectGmail() {
|
||||
if (!confirm('Afbryd forbindelsen til Gmail? Tokens bliver glemt.')) return;
|
||||
if (!confirm(t('int.disconnectQuestion'))) return;
|
||||
try {
|
||||
await api.disconnectGmail();
|
||||
gmail = await api.getGmail();
|
||||
@@ -153,7 +153,7 @@
|
||||
});
|
||||
|
||||
async function rotateFeed() {
|
||||
if (!confirm('Nyt feed-link? Alle eksisterende abonnementer holder op med at virke.')) return;
|
||||
if (!confirm(t('int.newLinkQuestion'))) return;
|
||||
try {
|
||||
feedURL = (await api.rotateFeed()).url;
|
||||
} catch (e) {
|
||||
@@ -162,7 +162,7 @@
|
||||
}
|
||||
|
||||
async function rotateMail() {
|
||||
if (!confirm('Ny adresse? Mail til den gamle bliver afvist.')) return;
|
||||
if (!confirm(t('int.newAddressQuestion'))) return;
|
||||
try {
|
||||
const r = await api.rotateMailAddress();
|
||||
mailAddress = r.address;
|
||||
@@ -175,9 +175,9 @@
|
||||
async function copy(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
app.toast('Kopieret.');
|
||||
app.toast(t('int.copied'));
|
||||
} catch {
|
||||
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
|
||||
app.toast(t('int.copyFailed'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -89,6 +89,55 @@
|
||||
|
||||
let version = $state(null);
|
||||
|
||||
// --- restarting from here ---------------------------------------------------
|
||||
|
||||
let panel = $state(null);
|
||||
let restarting = $state(false);
|
||||
let restartNote = $state('');
|
||||
|
||||
$effect(() => {
|
||||
api
|
||||
.panelStatus()
|
||||
.then((p) => (panel = p))
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
/**
|
||||
* Asks the panel to recreate this container, then waits for it to come back.
|
||||
*
|
||||
* The reply may never arrive — the panel stops this container as part of
|
||||
* answering — so the answer is not believed either way. `/healthz` is polled
|
||||
* until it responds, which is the only thing that actually says "it is up".
|
||||
*/
|
||||
async function restart() {
|
||||
if (!confirm(t('update.restartQuestion'))) return;
|
||||
restarting = true;
|
||||
restartNote = t('update.asking');
|
||||
try {
|
||||
await api.restartFromPanel();
|
||||
} catch (e) {
|
||||
// A request cut off mid-flight is the successful case wearing a
|
||||
// failure's clothes. The poll below decides.
|
||||
restartNote = humanMessage(e);
|
||||
}
|
||||
|
||||
restartNote = t('update.waiting');
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
try {
|
||||
const response = await fetch('/healthz', { cache: 'no-store' });
|
||||
if (response.ok) {
|
||||
location.reload();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Still down, which is expected for the first few seconds.
|
||||
}
|
||||
}
|
||||
restarting = false;
|
||||
restartNote = t('update.stillDown');
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
api.version().then((v) => (version = v)).catch(() => {});
|
||||
});
|
||||
@@ -182,6 +231,20 @@
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<!-- Restarting is what makes a new version arrive: a container cannot replace
|
||||
its own image, and the panel pulls `:latest` when it recreates one. The
|
||||
button is here rather than in another browser tab. -->
|
||||
{#if panel?.configured}
|
||||
<div class="row">
|
||||
<button class="secondary" onclick={restart} disabled={restarting}>
|
||||
{restarting ? t('update.restarting') : t('update.restart')}
|
||||
</button>
|
||||
{#if restartNote}<span class="saved">{restartNote}</span>{/if}
|
||||
</div>
|
||||
{:else if panel}
|
||||
<p class="hint">{t('update.notConfigured', { missing: panel.missing.join(', ') })}</p>
|
||||
{/if}
|
||||
|
||||
{#if version.update_available}
|
||||
{#if version.notes}
|
||||
<p class="notes">{version.notes}</p>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
} catch {
|
||||
// Insecure context, or permission refused. The token is on screen and
|
||||
// selectable, so this is a convenience that failed, not the feature.
|
||||
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
|
||||
app.toast(t('int.copyFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,12 +69,12 @@
|
||||
await navigator.clipboard.writeText(connectorURL);
|
||||
connectorCopied = true;
|
||||
} catch {
|
||||
app.toast('Kunne ikke kopiere — markér teksten i stedet.');
|
||||
app.toast(t('int.copyFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(token) {
|
||||
if (!confirm(`Tilbagekald "${token.name}"? Alt, der bruger den, holder op med at virke.`))
|
||||
if (!confirm(t('tokens.revokeQuestion', { name: token.name })))
|
||||
return;
|
||||
|
||||
const previous = tokens;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app, completedView } from '$lib/stores.svelte.js';
|
||||
import { app, completedView, projectName } from '$lib/stores.svelte.js';
|
||||
import { COLORS, colorVar } from '$lib/colors.js';
|
||||
import { eventName, eventDetail } from '$lib/events.js';
|
||||
import { TASK, carries, dragged, accept } from '$lib/dnd.js';
|
||||
@@ -188,9 +188,9 @@
|
||||
async function remove() {
|
||||
const days = 30;
|
||||
const confirmed = confirm(
|
||||
`Slet "${project.name}" med alle dets opgaver?\n\n` +
|
||||
`Det havner i papirkurven og kan hentes tilbage under ` +
|
||||
`Indstillinger → Data og skabeloner i ${days} dage.`
|
||||
t('project.deleteQuestion', { name: projectName(project) }) +
|
||||
'\n\n' +
|
||||
t('project.deleteRecoverable', { days })
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -402,7 +402,12 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="view">
|
||||
<!-- Wide for a board and a month grid, narrow for a list. Decided here rather than
|
||||
in CSS: this was `.view:has(.board)` for as long as the board has existed and
|
||||
never once matched, because `.board` is inside a child component and Svelte
|
||||
scopes a selector to the component that wrote it. The rule read correctly,
|
||||
compiled, and could not fire. Kommende has done it this way all along. -->
|
||||
<div class="view" class:wide={mode !== 'list'}>
|
||||
{#if project}
|
||||
<header>
|
||||
<!-- A heading first, a field second. Swapping the h1 out for an input
|
||||
@@ -429,10 +434,10 @@
|
||||
<h1 class:renameable={isOwner}>
|
||||
{#if isOwner}
|
||||
<button onclick={() => (editing = true)} title={t('project.clickToRename')}>
|
||||
{project.name}
|
||||
{projectName(project)}
|
||||
</button>
|
||||
{:else}
|
||||
{project.name}
|
||||
{projectName(project)}
|
||||
{/if}
|
||||
</h1>
|
||||
{/if}
|
||||
@@ -816,6 +821,12 @@
|
||||
padding: var(--s6) var(--s4) var(--s8);
|
||||
}
|
||||
|
||||
/* A board and a month grid need the room a reading column deliberately
|
||||
withholds. Same allowance Kommende makes. */
|
||||
.view.wide {
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
/* A board and a month grid need the room; a list is easier to read narrow. */
|
||||
|
||||
/* Wraps, and the title keeps a floor.
|
||||
@@ -1262,19 +1273,39 @@
|
||||
margin-bottom: var(--s2);
|
||||
}
|
||||
|
||||
/* A band rather than a rule.
|
||||
*
|
||||
* Small uppercase text over a hairline is a heading you have to look for. A
|
||||
* project with four sections is four groups, and the eye should be able to
|
||||
* count them without reading a word — so the heading gets a ground of its own,
|
||||
* and the rows below sit against the page.
|
||||
*
|
||||
* `--surface`, not a tint of the accent: the colour on this page belongs to the
|
||||
* project, and a section is not a second thing to colour. What is wanted is
|
||||
* separation, and a step in lightness gives that in all five themes without
|
||||
* anybody measuring anything.
|
||||
*/
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin-bottom: var(--s2);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--s2) var(--s3);
|
||||
margin-bottom: var(--s1);
|
||||
/* Pulled out to the padding of the rows below, so the band lines up with the
|
||||
text it heads rather than with the rows' own inset. */
|
||||
margin-left: calc(var(--s2) * -1);
|
||||
margin-right: calc(var(--s2) * -1);
|
||||
}
|
||||
|
||||
.section-head h2 {
|
||||
border-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* Hidden until the section is hovered or something in it has focus: a heading
|
||||
|
||||