Server 2.1.0: pair cars by QR code instead of typing the token
Modelled on the OAuth device authorization grant (RFC 8628) and on
only when APPROVAL_PASSWORD is set (at least 12 characters):
- POST /v1/pair/start opens a request for an installation and returns
a short user code and the /pair URL for the car's QR code;
POST /v1/pair/poll returns pending, the car's own token (once),
denied or expired. Requests live 10 minutes, in memory.
- GET/POST /pair is a script-free approval page for the phone
(strict CSP, no framing). Five wrong passwords lock approvals for a
minute.
- Each car gets its own 43-character token; only its SHA-256 is stored
(measurement "device"), and the device is persisted before the car
can collect the token. Tokens are loaded at startup, so a restart
keeps paired cars working; the server refuses to start if they cannot
be loaded.
- GET /v1/devices and POST /v1/devices/{id}/revoke, authenticated with
the approval password, list and revoke cars.
- The shared INGEST_TOKEN keeps working.
31 tests (race detector clean), govulncheck clean. Tested end to end
against InfluxDB 2.7.12: pairing, wrong password, single collection,
persistence across restarts, revocation (also across a restart), and
that InfluxDB holds only the hash.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
+26
-1
@@ -22,13 +22,16 @@ Environment only; the server refuses to start when a required value is missing.
|
|||||||
| `INGEST_TOKEN` | required, at least 24 characters: `openssl rand -hex 32` |
|
| `INGEST_TOKEN` | required, at least 24 characters: `openssl rand -hex 32` |
|
||||||
| `ABRP_API_KEY`, `ABRP_USER_TOKEN` | optional; both needed to forward |
|
| `ABRP_API_KEY`, `ABRP_USER_TOKEN` | optional; both needed to forward |
|
||||||
| `ABRP_MIN_INTERVAL_SECONDS` | default 5 |
|
| `ABRP_MIN_INTERVAL_SECONDS` | default 5 |
|
||||||
|
| `APPROVAL_PASSWORD` | optional, at least 12 characters; switches pairing on (see below) |
|
||||||
|
| `PUBLIC_URL` | the address a phone uses, for the pairing QR code; derived from the request if empty |
|
||||||
| `LISTEN_ADDR` | default `:8080` |
|
| `LISTEN_ADDR` | default `:8080` |
|
||||||
|
|
||||||
The ABRP credentials never reach the car.
|
The ABRP credentials never reach the car.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
All but `/v1/health` need `Authorization: Bearer <INGEST_TOKEN>`.
|
The ingest endpoints accept `Authorization: Bearer <token>` with either the shared `INGEST_TOKEN`
|
||||||
|
or a paired car's own token.
|
||||||
|
|
||||||
- `POST /v1/telemetry` — `{ installationId, samples: [...] }`, up to 500 samples in ABRP's `tlm`
|
- `POST /v1/telemetry` — `{ installationId, samples: [...] }`, up to 500 samples in ABRP's `tlm`
|
||||||
field names and units (`utc` in epoch seconds; flags as 0/1 or booleans). Unknown fields are
|
field names and units (`utc` in epoch seconds; flags as 0/1 or booleans). Unknown fields are
|
||||||
@@ -41,6 +44,27 @@ All but `/v1/health` need `Authorization: Bearer <INGEST_TOKEN>`.
|
|||||||
|
|
||||||
A bad request is a 400 naming the field; a database failure is a 500 without detail.
|
A bad request is a 400 naming the field; a database failure is a 500 without detail.
|
||||||
|
|
||||||
|
## Pairing a car by QR code
|
||||||
|
|
||||||
|
So nobody types a token in the car. Modelled on the OAuth device authorization grant (RFC 8628);
|
||||||
|
on only when `APPROVAL_PASSWORD` is set.
|
||||||
|
|
||||||
|
1. The car calls `POST /v1/pair/start` `{installationId}` and shows the returned
|
||||||
|
`verificationUriComplete` as a QR code, plus the short `userCode` (e.g. `K7F-3QX`).
|
||||||
|
2. The phone opens `/pair?code=…`, the owner checks the code and approves with the approval
|
||||||
|
password. Five wrong passwords lock approvals for a minute.
|
||||||
|
3. The car polls `POST /v1/pair/poll` `{deviceCode}`: 202 pending, 200 with its own token
|
||||||
|
(handed out once), 403 denied, 410 expired (after 10 minutes) or unknown.
|
||||||
|
|
||||||
|
Each car gets its own 43-character token; only its SHA-256 is stored (measurement `device` in
|
||||||
|
the same bucket), and the device is persisted before the car can collect the token, so a
|
||||||
|
restart never loses a paired car. Manage cars with the approval password:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -H "Authorization: Bearer $APPROVAL_PASSWORD" https://car.stinnesbeck.com/v1/devices
|
||||||
|
curl -X POST -H "Authorization: Bearer $APPROVAL_PASSWORD" https://car.stinnesbeck.com/v1/devices/<id>/revoke
|
||||||
|
```
|
||||||
|
|
||||||
## Storage
|
## Storage
|
||||||
|
|
||||||
One bucket, `installationId` tag on every point:
|
One bucket, `installationId` tag on every point:
|
||||||
@@ -51,6 +75,7 @@ One bucket, `installationId` tag on every point:
|
|||||||
| `consumption_chunk` | `recordedAt` | `date` | km, kwh, hours, socDrop, sohPercent |
|
| `consumption_chunk` | `recordedAt` | `date` | km, kwh, hours, socDrop, sohPercent |
|
||||||
| `charging_session` | `startedAt` | `sessionId` | endedAtMs, duration, energy, cost, start/end SOC |
|
| `charging_session` | `startedAt` | `sessionId` | endedAtMs, duration, energy, cost, start/end SOC |
|
||||||
| `charging_curve` | point time | `sessionId` | powerKw, socPercent |
|
| `charging_curve` | point time | `sessionId` | powerKw, socPercent |
|
||||||
|
| `device` | paired at | `deviceId` | tokenHash, installationId, revoked |
|
||||||
|
|
||||||
A point with the same measurement, tags and timestamp replaces the earlier one, so the car's
|
A point with the same measurement, tags and timestamp replaces the earlier one, so the car's
|
||||||
retries are idempotent: a cached chunk is re-sent with its original `recordedAt`. Days and the
|
retries are idempotent: a cached chunk is re-sent with its original `recordedAt`. Days and the
|
||||||
|
|||||||
+17
-4
@@ -17,10 +17,17 @@ import (
|
|||||||
// PUT /v1/charging-sessions/{sessionId} one finished DC charging session
|
// PUT /v1/charging-sessions/{sessionId} one finished DC charging session
|
||||||
// GET /v1/consumption/history/{installationId} the car reads its own totals back
|
// GET /v1/consumption/history/{installationId} the car reads its own totals back
|
||||||
// GET /v1/health unauthenticated liveness probe
|
// GET /v1/health unauthenticated liveness probe
|
||||||
|
//
|
||||||
|
// A car authenticates with the shared INGEST_TOKEN or with its own token from pairing
|
||||||
|
// (pairing_http.go).
|
||||||
type API struct {
|
type API struct {
|
||||||
Store Store
|
Store Store
|
||||||
IngestToken string
|
IngestToken string
|
||||||
Forwarder *Forwarder
|
Forwarder *Forwarder
|
||||||
|
Pairing *Pairing
|
||||||
|
// PublicURL is the server's address as a phone reaches it, for the pairing QR code;
|
||||||
|
// derived from the request when empty.
|
||||||
|
PublicURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxBodyBytes = 512 * 1024
|
const maxBodyBytes = 512 * 1024
|
||||||
@@ -34,18 +41,24 @@ func (a *API) Routes() http.Handler {
|
|||||||
mux.HandleFunc("PUT /v1/consumption/daily", a.ingest(a.consumption))
|
mux.HandleFunc("PUT /v1/consumption/daily", a.ingest(a.consumption))
|
||||||
mux.HandleFunc("PUT /v1/charging-sessions/{sessionId}", a.ingest(a.chargingSession))
|
mux.HandleFunc("PUT /v1/charging-sessions/{sessionId}", a.ingest(a.chargingSession))
|
||||||
mux.HandleFunc("GET /v1/consumption/history/{installationId}", a.ingest(a.history))
|
mux.HandleFunc("GET /v1/consumption/history/{installationId}", a.ingest(a.history))
|
||||||
|
a.pairingRoutes(mux)
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|
||||||
// hasIngestToken compares in constant time. main refuses to start with a token under 24
|
// hasIngestToken accepts the shared token (compared in constant time; main refuses to start
|
||||||
// characters, so an empty or short configured token can never match here.
|
// with one under 24 characters, so an empty or short one never matches) or a paired car's
|
||||||
|
// own, unrevoked token.
|
||||||
func (a *API) hasIngestToken(r *http.Request) bool {
|
func (a *API) hasIngestToken(r *http.Request) bool {
|
||||||
header := r.Header.Get("Authorization")
|
header := r.Header.Get("Authorization")
|
||||||
supplied, ok := strings.CutPrefix(header, "Bearer ")
|
supplied, ok := strings.CutPrefix(header, "Bearer ")
|
||||||
if !ok || len(a.IngestToken) < minIngestTokenLength {
|
if !ok || supplied == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return subtle.ConstantTimeCompare([]byte(supplied), []byte(a.IngestToken)) == 1
|
if len(a.IngestToken) >= minIngestTokenLength &&
|
||||||
|
subtle.ConstantTimeCompare([]byte(supplied), []byte(a.IngestToken)) == 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return a.Pairing.Authorized(supplied)
|
||||||
}
|
}
|
||||||
|
|
||||||
type handler func(w http.ResponseWriter, r *http.Request, body []byte) error
|
type handler func(w http.ResponseWriter, r *http.Request, body []byte) error
|
||||||
|
|||||||
+23
-2
@@ -16,6 +16,7 @@ type fakeStore struct {
|
|||||||
telemetry []TelemetrySample
|
telemetry []TelemetrySample
|
||||||
chunks []ConsumptionChunk
|
chunks []ConsumptionChunk
|
||||||
sessions []ChargingSession
|
sessions []ChargingSession
|
||||||
|
devices []Device
|
||||||
fail error
|
fail error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,17 +47,37 @@ func (f *fakeStore) WriteChargingSession(_ context.Context, s ChargingSession) e
|
|||||||
return f.fail
|
return f.fail
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) SaveDevice(_ context.Context, d Device) error {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.fail != nil {
|
||||||
|
return f.fail
|
||||||
|
}
|
||||||
|
f.devices = append(f.devices, d)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) LoadDevices(context.Context) ([]Device, error) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
return f.devices, f.fail
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeStore) ReadConsumptionChunks(context.Context, string) ([]ConsumptionChunk, error) {
|
func (f *fakeStore) ReadConsumptionChunks(context.Context, string) ([]ConsumptionChunk, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
return f.chunks, f.fail
|
return f.chunks, f.fail
|
||||||
}
|
}
|
||||||
|
|
||||||
const testToken = "ingest-test-token-0123456789abcdef"
|
const (
|
||||||
|
testToken = "ingest-test-token-0123456789abcdef"
|
||||||
|
testApprovalPassword = "approval-password-123"
|
||||||
|
)
|
||||||
|
|
||||||
func newTestAPI() (*API, *fakeStore) {
|
func newTestAPI() (*API, *fakeStore) {
|
||||||
store := &fakeStore{}
|
store := &fakeStore{}
|
||||||
return &API{Store: store, IngestToken: testToken, Forwarder: &Forwarder{}}, store
|
return &API{Store: store, IngestToken: testToken, Forwarder: &Forwarder{},
|
||||||
|
Pairing: NewPairing(testApprovalPassword, nil), PublicURL: "https://car.example.com"}, store
|
||||||
}
|
}
|
||||||
|
|
||||||
func do(t *testing.T, h http.Handler, method, path, token, body string) *httptest.ResponseRecorder {
|
func do(t *testing.T, h http.Handler, method, path, token, body string) *httptest.ResponseRecorder {
|
||||||
|
|||||||
+27
-3
@@ -16,7 +16,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const minIngestTokenLength = 24
|
const (
|
||||||
|
minIngestTokenLength = 24
|
||||||
|
minApprovalPasswordLength = 12
|
||||||
|
)
|
||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
listenAddr string
|
listenAddr string
|
||||||
@@ -28,6 +31,8 @@ type config struct {
|
|||||||
abrpAPIKey string
|
abrpAPIKey string
|
||||||
abrpUserToken string
|
abrpUserToken string
|
||||||
abrpMinInterval time.Duration
|
abrpMinInterval time.Duration
|
||||||
|
approvalPass string
|
||||||
|
publicURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadConfig reads the environment and fails closed: a missing InfluxDB setting or a
|
// loadConfig reads the environment and fails closed: a missing InfluxDB setting or a
|
||||||
@@ -42,6 +47,8 @@ func loadConfig(getenv func(string) string) (config, error) {
|
|||||||
ingestToken: getenv("INGEST_TOKEN"),
|
ingestToken: getenv("INGEST_TOKEN"),
|
||||||
abrpAPIKey: getenv("ABRP_API_KEY"),
|
abrpAPIKey: getenv("ABRP_API_KEY"),
|
||||||
abrpUserToken: getenv("ABRP_USER_TOKEN"),
|
abrpUserToken: getenv("ABRP_USER_TOKEN"),
|
||||||
|
approvalPass: getenv("APPROVAL_PASSWORD"),
|
||||||
|
publicURL: getenv("PUBLIC_URL"),
|
||||||
}
|
}
|
||||||
if c.listenAddr == "" {
|
if c.listenAddr == "" {
|
||||||
c.listenAddr = ":8080"
|
c.listenAddr = ":8080"
|
||||||
@@ -57,6 +64,11 @@ func loadConfig(getenv func(string) string) (config, error) {
|
|||||||
if len(c.ingestToken) < minIngestTokenLength {
|
if len(c.ingestToken) < minIngestTokenLength {
|
||||||
return config{}, fmt.Errorf("INGEST_TOKEN must be at least %d characters", minIngestTokenLength)
|
return config{}, fmt.Errorf("INGEST_TOKEN must be at least %d characters", minIngestTokenLength)
|
||||||
}
|
}
|
||||||
|
// Pairing is optional; when it is on, its password guards token issuance and must not
|
||||||
|
// be guessable.
|
||||||
|
if c.approvalPass != "" && len(c.approvalPass) < minApprovalPasswordLength {
|
||||||
|
return config{}, fmt.Errorf("APPROVAL_PASSWORD must be at least %d characters", minApprovalPasswordLength)
|
||||||
|
}
|
||||||
c.abrpMinInterval = 5 * time.Second
|
c.abrpMinInterval = 5 * time.Second
|
||||||
if raw := getenv("ABRP_MIN_INTERVAL_SECONDS"); raw != "" {
|
if raw := getenv("ABRP_MIN_INTERVAL_SECONDS"); raw != "" {
|
||||||
seconds, err := strconv.ParseFloat(raw, 64)
|
seconds, err := strconv.ParseFloat(raw, 64)
|
||||||
@@ -84,7 +96,18 @@ func main() {
|
|||||||
MinInterval: cfg.abrpMinInterval,
|
MinInterval: cfg.abrpMinInterval,
|
||||||
Client: &http.Client{Timeout: 10 * time.Second},
|
Client: &http.Client{Timeout: 10 * time.Second},
|
||||||
}
|
}
|
||||||
api := &API{Store: store, IngestToken: cfg.ingestToken, Forwarder: forwarder}
|
// Paired cars' tokens must be known before the first request; starting without them
|
||||||
|
// would answer every paired car with 401, so a failed load stops the start instead.
|
||||||
|
loadCtx, cancelLoad := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
devices, err := store.LoadDevices(loadCtx)
|
||||||
|
cancelLoad()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("paired devices could not be loaded", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
pairing := NewPairing(cfg.approvalPass, devices)
|
||||||
|
api := &API{Store: store, IngestToken: cfg.ingestToken, Forwarder: forwarder,
|
||||||
|
Pairing: pairing, PublicURL: cfg.publicURL}
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: cfg.listenAddr,
|
Addr: cfg.listenAddr,
|
||||||
@@ -98,7 +121,8 @@ func main() {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
go func() {
|
go func() {
|
||||||
slog.Info("listening", "addr", cfg.listenAddr, "bucket", cfg.influxBucket, "abrp", forwarder.configured())
|
slog.Info("listening", "addr", cfg.listenAddr, "bucket", cfg.influxBucket,
|
||||||
|
"abrp", forwarder.configured(), "pairing", pairing.Enabled(), "pairedDevices", len(devices))
|
||||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
slog.Error("server failed", "error", err)
|
slog.Error("server failed", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pairing lets a car get its own ingest token without anyone typing it: the car asks for a
|
||||||
|
// pairing request and shows its code as a QR code, a phone opens the approval page, the
|
||||||
|
// owner signs in with the approval password and approves, and the car — polling meanwhile —
|
||||||
|
// receives a token once. Modelled on the OAuth device authorization grant (RFC 8628).
|
||||||
|
//
|
||||||
|
// Pending requests live in memory only: they last ten minutes, and the server runs as one
|
||||||
|
// replica. Approved devices are persisted (see Device) so their tokens survive a restart.
|
||||||
|
|
||||||
|
const (
|
||||||
|
pairingTTL = 10 * time.Minute
|
||||||
|
pairingInterval = 5 * time.Second
|
||||||
|
maxPendingPairing = 50
|
||||||
|
// After this many wrong approval passwords in a window, approvals pause for the window.
|
||||||
|
maxFailedLogins = 5
|
||||||
|
failedLoginsWindow = time.Minute
|
||||||
|
// Unambiguous characters only: no 0/O, 1/I/L, so a code reads right off a car screen.
|
||||||
|
userCodeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errUnknownCode = errors.New("unknown or expired code")
|
||||||
|
errNotPending = errors.New("request already decided")
|
||||||
|
errTooManyFailed = errors.New("too many wrong passwords, try again in a minute")
|
||||||
|
errWrongPassword = errors.New("wrong password")
|
||||||
|
errTooMany = errors.New("too many pending pairing requests")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Device is a paired car. Only the SHA-256 of its token is kept.
|
||||||
|
type Device struct {
|
||||||
|
ID string
|
||||||
|
InstallationID string
|
||||||
|
TokenHash string
|
||||||
|
CreatedAt time.Time
|
||||||
|
Revoked bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type pairingState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
pairingPending pairingState = iota
|
||||||
|
pairingApproved
|
||||||
|
pairingDenied
|
||||||
|
)
|
||||||
|
|
||||||
|
type pairingRequest struct {
|
||||||
|
deviceCode string
|
||||||
|
userCode string
|
||||||
|
installationID string
|
||||||
|
expires time.Time
|
||||||
|
state pairingState
|
||||||
|
token string // set on approval, handed out once
|
||||||
|
deviceID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pairing holds pending requests and the known devices. The clock is injectable for tests.
|
||||||
|
type Pairing struct {
|
||||||
|
ApprovalPassword string
|
||||||
|
Now func() time.Time
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
byDevice map[string]*pairingRequest
|
||||||
|
byUser map[string]*pairingRequest
|
||||||
|
devices map[string]Device // by token hash
|
||||||
|
failures []time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPairing(password string, devices []Device) *Pairing {
|
||||||
|
p := &Pairing{
|
||||||
|
ApprovalPassword: password,
|
||||||
|
Now: time.Now,
|
||||||
|
byDevice: map[string]*pairingRequest{},
|
||||||
|
byUser: map[string]*pairingRequest{},
|
||||||
|
devices: map[string]Device{},
|
||||||
|
}
|
||||||
|
for _, d := range devices {
|
||||||
|
p.devices[d.TokenHash] = d
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pairing) Enabled() bool { return p != nil && p.ApprovalPassword != "" }
|
||||||
|
|
||||||
|
func randomString(n int) string {
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
panic(err) // crypto/rand does not fail on supported platforms
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUserCode() string {
|
||||||
|
b := make([]byte, 6)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
code := make([]byte, 0, 7)
|
||||||
|
for i, v := range b {
|
||||||
|
if i == 3 {
|
||||||
|
code = append(code, '-')
|
||||||
|
}
|
||||||
|
// 256 % 31 leaves a slight bias; irrelevant for a 10-minute code that also needs
|
||||||
|
// the approval password.
|
||||||
|
code = append(code, userCodeAlphabet[int(v)%len(userCodeAlphabet)])
|
||||||
|
}
|
||||||
|
return string(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeUserCode accepts what a person types: any case, with or without the dash.
|
||||||
|
func normalizeUserCode(code string) string {
|
||||||
|
code = strings.ToUpper(strings.TrimSpace(code))
|
||||||
|
code = strings.ReplaceAll(code, "-", "")
|
||||||
|
code = strings.ReplaceAll(code, " ", "")
|
||||||
|
if len(code) != 6 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return code[:3] + "-" + code[3:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashToken(token string) string {
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pairing) expireLocked(now time.Time) {
|
||||||
|
for code, r := range p.byDevice {
|
||||||
|
if now.After(r.expires) {
|
||||||
|
delete(p.byDevice, code)
|
||||||
|
delete(p.byUser, r.userCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start opens a pairing request for a car.
|
||||||
|
func (p *Pairing) Start(installationID string) (*pairingRequest, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
now := p.Now()
|
||||||
|
p.expireLocked(now)
|
||||||
|
if len(p.byDevice) >= maxPendingPairing {
|
||||||
|
return nil, errTooMany
|
||||||
|
}
|
||||||
|
r := &pairingRequest{
|
||||||
|
deviceCode: randomString(32),
|
||||||
|
installationID: installationID,
|
||||||
|
expires: now.Add(pairingTTL),
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
r.userCode = newUserCode()
|
||||||
|
if _, taken := p.byUser[r.userCode]; !taken {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.byDevice[r.deviceCode] = r
|
||||||
|
p.byUser[r.userCode] = r
|
||||||
|
out := *r
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup returns the pending request for a user code, for the approval page.
|
||||||
|
func (p *Pairing) Lookup(userCode string) (pairingRequest, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.expireLocked(p.Now())
|
||||||
|
r, ok := p.byUser[normalizeUserCode(userCode)]
|
||||||
|
if !ok {
|
||||||
|
return pairingRequest{}, errUnknownCode
|
||||||
|
}
|
||||||
|
return *r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkPassword counts failures and refuses everything while too many are recent.
|
||||||
|
func (p *Pairing) checkPasswordLocked(password string, now time.Time) error {
|
||||||
|
recent := p.failures[:0]
|
||||||
|
for _, t := range p.failures {
|
||||||
|
if now.Sub(t) < failedLoginsWindow {
|
||||||
|
recent = append(recent, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.failures = recent
|
||||||
|
if len(p.failures) >= maxFailedLogins {
|
||||||
|
return errTooManyFailed
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(password), []byte(p.ApprovalPassword)) != 1 {
|
||||||
|
p.failures = append(p.failures, now)
|
||||||
|
return errWrongPassword
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decide approves or denies a pending request. On approval it returns the new device, which
|
||||||
|
// the caller must persist before the car can collect the token.
|
||||||
|
func (p *Pairing) Decide(userCode, password string, approve bool) (Device, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
now := p.Now()
|
||||||
|
if err := p.checkPasswordLocked(password, now); err != nil {
|
||||||
|
return Device{}, err
|
||||||
|
}
|
||||||
|
p.expireLocked(now)
|
||||||
|
r, ok := p.byUser[normalizeUserCode(userCode)]
|
||||||
|
if !ok {
|
||||||
|
return Device{}, errUnknownCode
|
||||||
|
}
|
||||||
|
if r.state != pairingPending {
|
||||||
|
return Device{}, errNotPending
|
||||||
|
}
|
||||||
|
if !approve {
|
||||||
|
r.state = pairingDenied
|
||||||
|
return Device{}, nil
|
||||||
|
}
|
||||||
|
token := randomString(32) // 43 characters
|
||||||
|
d := Device{
|
||||||
|
ID: randomString(9),
|
||||||
|
InstallationID: r.installationID,
|
||||||
|
TokenHash: hashToken(token),
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
r.state = pairingApproved
|
||||||
|
r.token = token
|
||||||
|
r.deviceID = d.ID
|
||||||
|
p.devices[d.TokenHash] = d
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forget undoes an approval whose device could not be persisted, so no token is handed out
|
||||||
|
// that would stop working at the next restart.
|
||||||
|
func (p *Pairing) Forget(userCode string, d Device) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
delete(p.devices, d.TokenHash)
|
||||||
|
if r, ok := p.byUser[normalizeUserCode(userCode)]; ok {
|
||||||
|
r.state = pairingPending
|
||||||
|
r.token = ""
|
||||||
|
r.deviceID = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll is what the car calls: pending, the token (exactly once), denied, or unknown.
|
||||||
|
func (p *Pairing) Poll(deviceCode string) (state pairingState, token, deviceID string, err error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.expireLocked(p.Now())
|
||||||
|
r, ok := p.byDevice[deviceCode]
|
||||||
|
if !ok {
|
||||||
|
return 0, "", "", errUnknownCode
|
||||||
|
}
|
||||||
|
switch r.state {
|
||||||
|
case pairingApproved, pairingDenied:
|
||||||
|
// Decided: hand the outcome out once, then forget the request.
|
||||||
|
delete(p.byDevice, r.deviceCode)
|
||||||
|
delete(p.byUser, r.userCode)
|
||||||
|
}
|
||||||
|
return r.state, r.token, r.deviceID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorized reports whether a token belongs to a paired, unrevoked device.
|
||||||
|
func (p *Pairing) Authorized(token string) bool {
|
||||||
|
if p == nil || token == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
d, ok := p.devices[hashToken(token)]
|
||||||
|
return ok && !d.Revoked
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pairing) Devices() []Device {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
out := make([]Device, 0, len(p.devices))
|
||||||
|
for _, d := range p.devices {
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke marks a device revoked and returns it for persisting.
|
||||||
|
func (p *Pairing) Revoke(id string) (Device, bool) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
for hash, d := range p.devices {
|
||||||
|
if d.ID == id {
|
||||||
|
d.Revoked = true
|
||||||
|
p.devices[hash] = d
|
||||||
|
return d, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Device{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAdmin verifies the approval password for the device-management API, with the same
|
||||||
|
// failure counting as the approval page.
|
||||||
|
func (p *Pairing) CheckAdmin(password string) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return p.checkPasswordLocked(password, p.Now())
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTP side of pairing.
|
||||||
|
//
|
||||||
|
// POST /v1/pair/start car: open a request {installationId} → codes and URL
|
||||||
|
// POST /v1/pair/poll car: collect the outcome {deviceCode} → 202 / 200 token / 403 / 410
|
||||||
|
// GET /pair[?code=XXX-XXX] phone: approval page
|
||||||
|
// POST /pair phone: approve or deny (needs the approval password)
|
||||||
|
// GET /v1/devices list paired cars Bearer <APPROVAL_PASSWORD>
|
||||||
|
// POST /v1/devices/{id}/revoke revoke a car's token Bearer <APPROVAL_PASSWORD>
|
||||||
|
//
|
||||||
|
// Start and poll need no token — the car does not have one yet. What they hand out is
|
||||||
|
// worthless until a person who knows the approval password approves it.
|
||||||
|
|
||||||
|
func (a *API) pairingRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("POST /v1/pair/start", a.pairStart)
|
||||||
|
mux.HandleFunc("POST /v1/pair/poll", a.pairPoll)
|
||||||
|
mux.HandleFunc("GET /pair", a.pairPage)
|
||||||
|
mux.HandleFunc("POST /pair", a.pairDecide)
|
||||||
|
mux.HandleFunc("GET /v1/devices", a.listDevices)
|
||||||
|
mux.HandleFunc("POST /v1/devices/{id}/revoke", a.revokeDevice)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) pairingDisabled(w http.ResponseWriter) bool {
|
||||||
|
if a.Pairing.Enabled() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "pairing is not enabled"})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSmallJSON(r *http.Request) (object, error) {
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, 4097))
|
||||||
|
if err != nil || len(body) > 4096 {
|
||||||
|
return nil, invalid("body is too large")
|
||||||
|
}
|
||||||
|
return decodeObject(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicURL is where the phone should go: PUBLIC_URL when set, otherwise what the request
|
||||||
|
// came in on (behind Traefik, the forwarded scheme and host).
|
||||||
|
func (a *API) publicURL(r *http.Request) string {
|
||||||
|
if a.PublicURL != "" {
|
||||||
|
return strings.TrimRight(a.PublicURL, "/")
|
||||||
|
}
|
||||||
|
scheme := "http"
|
||||||
|
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
host := r.Header.Get("X-Forwarded-Host")
|
||||||
|
if host == "" {
|
||||||
|
host = r.Host
|
||||||
|
}
|
||||||
|
return scheme + "://" + host
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) pairStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if a.pairingDisabled(w) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
o, err := readSmallJSON(r)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := validUUID(o["installationId"], "installationId")
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req, err := a.Pairing.Start(id)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base := a.publicURL(r) + "/pair"
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"deviceCode": req.deviceCode,
|
||||||
|
"userCode": req.userCode,
|
||||||
|
"verificationUri": base,
|
||||||
|
"verificationUriComplete": base + "?code=" + url.QueryEscape(req.userCode),
|
||||||
|
"expiresIn": int(pairingTTL / time.Second),
|
||||||
|
"interval": int(pairingInterval / time.Second),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) pairPoll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if a.pairingDisabled(w) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
o, err := readSmallJSON(r)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code, _ := o["deviceCode"].(string)
|
||||||
|
state, token, deviceID, err := a.Pairing.Poll(code)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
writeJSON(w, http.StatusGone, map[string]string{"error": "expired_token"})
|
||||||
|
case state == pairingPending:
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]string{"status": "authorization_pending"})
|
||||||
|
case state == pairingDenied:
|
||||||
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": "access_denied"})
|
||||||
|
default:
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"token": token, "deviceId": deviceID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Approval page ----------
|
||||||
|
|
||||||
|
type pageData struct {
|
||||||
|
Code string
|
||||||
|
Message string
|
||||||
|
Error string
|
||||||
|
Done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var pairTemplate = template.Must(template.New("pair").Parse(`<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Pair a car — Nils' MG4</title>
|
||||||
|
<style>
|
||||||
|
:root{color-scheme:dark}
|
||||||
|
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#000;color:#F4F3EF;font:16px/1.5 system-ui,sans-serif}
|
||||||
|
main{width:min(92vw,420px);background:#121212;border:1px solid #363B3D;border-radius:16px;padding:28px}
|
||||||
|
h1{margin:0 0 4px;font-size:22px}p{color:#BDC4C6;margin:8px 0 18px}
|
||||||
|
.code{font:600 34px/1.2 ui-monospace,monospace;letter-spacing:4px;color:#FF9A9E;text-align:center;margin:18px 0}
|
||||||
|
label{display:block;font-size:14px;color:#BDC4C6;margin:14px 0 6px}
|
||||||
|
input{box-sizing:border-box;width:100%;padding:14px;border-radius:10px;border:1px solid #363B3D;background:#000;color:#F4F3EF;font-size:18px}
|
||||||
|
.row{display:flex;gap:10px;margin-top:20px}
|
||||||
|
button{flex:1;padding:14px;border-radius:10px;border:0;font-size:17px;font-weight:600;cursor:pointer}
|
||||||
|
.approve{background:#E0424C;color:#1A0B0C}.deny{background:#2A1416;color:#FF9A9E;border:1px solid #3A1215}
|
||||||
|
.error{color:#FFB4AB}.ok{color:#A4DCC1}
|
||||||
|
</style></head><body><main>
|
||||||
|
<h1>Pair a car</h1>
|
||||||
|
{{if .Done}}
|
||||||
|
<p class="ok">{{.Message}}</p>
|
||||||
|
{{else}}
|
||||||
|
<p>Check that this code matches the one on the car's screen, then approve with the approval password.</p>
|
||||||
|
<form method="post" action="/pair" autocomplete="on">
|
||||||
|
{{if .Code}}<div class="code">{{.Code}}</div><input type="hidden" name="code" value="{{.Code}}">
|
||||||
|
{{else}}<label for="code">Code shown on the car</label>
|
||||||
|
<input id="code" name="code" autocapitalize="characters" autocomplete="off" placeholder="XXX-XXX" required>{{end}}
|
||||||
|
<input type="text" name="username" value="mg4-approval" autocomplete="username" hidden readonly>
|
||||||
|
<label for="password">Approval password</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password" required {{if .Code}}autofocus{{end}}>
|
||||||
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||||
|
<div class="row"><button class="approve" name="action" value="approve">Approve</button>
|
||||||
|
<button class="deny" name="action" value="deny" formnovalidate>Deny</button></div>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</main></body></html>`))
|
||||||
|
|
||||||
|
func renderPage(w http.ResponseWriter, status int, data pageData) {
|
||||||
|
h := w.Header()
|
||||||
|
h.Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
// No scripts at all, no framing, forms only back to this page.
|
||||||
|
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'")
|
||||||
|
h.Set("X-Content-Type-Options", "nosniff")
|
||||||
|
h.Set("Referrer-Policy", "no-referrer")
|
||||||
|
h.Set("Cache-Control", "no-store")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if err := pairTemplate.Execute(w, data); err != nil {
|
||||||
|
slog.Warn("pair page not rendered", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) pairPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.Pairing.Enabled() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := pageData{}
|
||||||
|
if raw := r.URL.Query().Get("code"); raw != "" {
|
||||||
|
if req, err := a.Pairing.Lookup(raw); err == nil && req.state == pairingPending {
|
||||||
|
data.Code = req.userCode
|
||||||
|
} else {
|
||||||
|
data.Error = "This code is unknown or has expired. Start pairing again on the car."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderPage(w, http.StatusOK, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) pairDecide(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.Pairing.Enabled() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 4096)
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
renderPage(w, http.StatusBadRequest, pageData{Error: "The form could not be read."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code := normalizeUserCode(r.PostForm.Get("code"))
|
||||||
|
approve := r.PostForm.Get("action") != "deny"
|
||||||
|
device, err := a.Pairing.Decide(code, r.PostForm.Get("password"), approve)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errWrongPassword):
|
||||||
|
renderPage(w, http.StatusUnauthorized, pageData{Code: code, Error: "Wrong password."})
|
||||||
|
return
|
||||||
|
case errors.Is(err, errTooManyFailed):
|
||||||
|
renderPage(w, http.StatusTooManyRequests, pageData{Code: code, Error: err.Error() + "."})
|
||||||
|
return
|
||||||
|
case errors.Is(err, errUnknownCode), errors.Is(err, errNotPending):
|
||||||
|
renderPage(w, http.StatusNotFound, pageData{Error: "This code is unknown, expired or already used. Start pairing again on the car."})
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
renderPage(w, http.StatusInternalServerError, pageData{Code: code, Error: "Something went wrong."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !approve {
|
||||||
|
renderPage(w, http.StatusOK, pageData{Done: true, Message: "Denied. The car was not paired."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Persist before the car can collect the token: a token that vanished at the next
|
||||||
|
// restart would look like a pairing that worked.
|
||||||
|
if err := a.Store.SaveDevice(r.Context(), device); err != nil {
|
||||||
|
a.Pairing.Forget(code, device)
|
||||||
|
slog.Error("device not saved", "error", err)
|
||||||
|
renderPage(w, http.StatusInternalServerError, pageData{Code: code, Error: "The car could not be saved. Try again."})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("car paired", "device", device.ID, "installation", device.InstallationID)
|
||||||
|
renderPage(w, http.StatusOK, pageData{Done: true, Message: "Approved. The car finishes pairing on its own within a few seconds — you can close this page."})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Device management ----------
|
||||||
|
|
||||||
|
func (a *API) adminAuthorized(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
if a.pairingDisabled(w) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
supplied, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||||
|
if !ok {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch err := a.Pairing.CheckAdmin(supplied); {
|
||||||
|
case errors.Is(err, errTooManyFailed):
|
||||||
|
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": err.Error()})
|
||||||
|
return false
|
||||||
|
case err != nil:
|
||||||
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type deviceJSON struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
InstallationID string `json:"installationId"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
Revoked bool `json:"revoked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) listDevices(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAuthorized(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := []deviceJSON{}
|
||||||
|
for _, d := range a.Pairing.Devices() {
|
||||||
|
out = append(out, deviceJSON{d.ID, d.InstallationID, d.CreatedAt, d.Revoked})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"devices": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) revokeDevice(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.adminAuthorized(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d, ok := a.Pairing.Revoke(r.PathValue("id"))
|
||||||
|
if !ok {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such device"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.Store.SaveDevice(r.Context(), d); err != nil {
|
||||||
|
slog.Error("revocation not saved", "error", err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "revoked until restart only; saving failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("car revoked", "device", d.ID)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func startPairing(t *testing.T, h http.Handler) (deviceCode, userCode, uriComplete string) {
|
||||||
|
t.Helper()
|
||||||
|
rec := do(t, h, "POST", "/v1/pair/start", "", `{"installationId":"`+testInstallation+`"}`)
|
||||||
|
if rec.Code != 201 {
|
||||||
|
t.Fatalf("start: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
var got map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return got["deviceCode"].(string), got["userCode"].(string), got["verificationUriComplete"].(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func poll(t *testing.T, h http.Handler, deviceCode string) (int, map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
rec := do(t, h, "POST", "/v1/pair/poll", "", `{"deviceCode":"`+deviceCode+`"}`)
|
||||||
|
var got map[string]string
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||||
|
return rec.Code, got
|
||||||
|
}
|
||||||
|
|
||||||
|
func submit(t *testing.T, h http.Handler, code, password, action string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
form := url.Values{"code": {code}, "password": {password}, "action": {action}}
|
||||||
|
req := httptest.NewRequest("POST", "/pair", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPairingHappyPath(t *testing.T) {
|
||||||
|
api, store := newTestAPI()
|
||||||
|
h := api.Routes()
|
||||||
|
deviceCode, userCode, uri := startPairing(t, h)
|
||||||
|
if !regexpUserCode.MatchString(userCode) {
|
||||||
|
t.Fatalf("user code %q", userCode)
|
||||||
|
}
|
||||||
|
if uri != "https://car.example.com/pair?code="+userCode {
|
||||||
|
t.Fatalf("verification URI %q", uri)
|
||||||
|
}
|
||||||
|
if status, _ := poll(t, h, deviceCode); status != 202 {
|
||||||
|
t.Fatalf("before approval: want 202, got %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The phone opens the link: the page shows the code and asks for the password.
|
||||||
|
page := do(t, h, "GET", "/pair?code="+strings.ToLower(strings.ReplaceAll(userCode, "-", "")), "", "")
|
||||||
|
if page.Code != 200 || !strings.Contains(page.Body.String(), userCode) {
|
||||||
|
t.Fatalf("page: %d", page.Code)
|
||||||
|
}
|
||||||
|
if csp := page.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "frame-ancestors 'none'") {
|
||||||
|
t.Fatalf("CSP %q", csp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Approved") {
|
||||||
|
t.Fatalf("approve: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if len(store.devices) != 1 || store.devices[0].InstallationID != testInstallation {
|
||||||
|
t.Fatalf("device not persisted: %+v", store.devices)
|
||||||
|
}
|
||||||
|
status, got := poll(t, h, deviceCode)
|
||||||
|
if status != 200 || len(got["token"]) < minIngestTokenLength || got["deviceId"] == "" {
|
||||||
|
t.Fatalf("after approval: %d %v", status, got)
|
||||||
|
}
|
||||||
|
// Only the hash is stored, never the token.
|
||||||
|
if store.devices[0].TokenHash == got["token"] || store.devices[0].TokenHash != hashToken(got["token"]) {
|
||||||
|
t.Fatal("stored token hash is wrong")
|
||||||
|
}
|
||||||
|
// The token is handed out once.
|
||||||
|
if status, _ := poll(t, h, deviceCode); status != 410 {
|
||||||
|
t.Fatalf("second collection: want 410, got %d", status)
|
||||||
|
}
|
||||||
|
// And it works on the ingest endpoints.
|
||||||
|
rec := do(t, h, "POST", "/v1/telemetry", got["token"], string(telemetryBody(`{"utc":1790000000,"soc":80}`)))
|
||||||
|
if rec.Code != 204 {
|
||||||
|
t.Fatalf("paired token on telemetry: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPairingWrongPasswordAndLockout(t *testing.T) {
|
||||||
|
api, store := newTestAPI()
|
||||||
|
h := api.Routes()
|
||||||
|
deviceCode, userCode, _ := startPairing(t, h)
|
||||||
|
for i := 0; i < maxFailedLogins; i++ {
|
||||||
|
if rec := submit(t, h, userCode, "wrong", "approve"); rec.Code != 401 {
|
||||||
|
t.Fatalf("attempt %d: want 401, got %d", i, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Locked now — even the right password waits.
|
||||||
|
if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 429 {
|
||||||
|
t.Fatalf("locked: want 429, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if status, _ := poll(t, h, deviceCode); status != 202 || len(store.devices) != 0 {
|
||||||
|
t.Fatal("a locked-out attempt changed the request")
|
||||||
|
}
|
||||||
|
// After the window the right password works again.
|
||||||
|
api.Pairing.Now = func() time.Time { return time.Now().Add(failedLoginsWindow + time.Second) }
|
||||||
|
if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 200 {
|
||||||
|
t.Fatalf("after the window: want 200, got %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPairingDenyAndExpiry(t *testing.T) {
|
||||||
|
api, _ := newTestAPI()
|
||||||
|
h := api.Routes()
|
||||||
|
deviceCode, userCode, _ := startPairing(t, h)
|
||||||
|
if rec := submit(t, h, userCode, testApprovalPassword, "deny"); rec.Code != 200 || !strings.Contains(rec.Body.String(), "Denied") {
|
||||||
|
t.Fatalf("deny: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if status, _ := poll(t, h, deviceCode); status != 403 {
|
||||||
|
t.Fatalf("denied: want 403, got %d", status)
|
||||||
|
}
|
||||||
|
if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 404 {
|
||||||
|
t.Fatalf("a decided code was approvable: %d", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceCode, userCode, _ = startPairing(t, h)
|
||||||
|
api.Pairing.Now = func() time.Time { return time.Now().Add(pairingTTL + time.Second) }
|
||||||
|
if status, _ := poll(t, h, deviceCode); status != 410 {
|
||||||
|
t.Fatalf("expired: want 410, got %d", status)
|
||||||
|
}
|
||||||
|
if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 404 {
|
||||||
|
t.Fatalf("an expired code was approvable: %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPairingNotPersistedMeansNoToken(t *testing.T) {
|
||||||
|
api, store := newTestAPI()
|
||||||
|
h := api.Routes()
|
||||||
|
deviceCode, userCode, _ := startPairing(t, h)
|
||||||
|
store.fail = errors.New("influx down")
|
||||||
|
if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 500 {
|
||||||
|
t.Fatalf("want 500, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if status, _ := poll(t, h, deviceCode); status != 202 {
|
||||||
|
t.Fatalf("a token was handed out that was never saved: %d", status)
|
||||||
|
}
|
||||||
|
store.fail = nil
|
||||||
|
if rec := submit(t, h, userCode, testApprovalPassword, "approve"); rec.Code != 200 {
|
||||||
|
t.Fatalf("retry: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if status, _ := poll(t, h, deviceCode); status != 200 {
|
||||||
|
t.Fatalf("after retry: %d", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevokedTokenIsRejected(t *testing.T) {
|
||||||
|
api, store := newTestAPI()
|
||||||
|
h := api.Routes()
|
||||||
|
deviceCode, userCode, _ := startPairing(t, h)
|
||||||
|
submit(t, h, userCode, testApprovalPassword, "approve")
|
||||||
|
_, got := poll(t, h, deviceCode)
|
||||||
|
token := got["token"]
|
||||||
|
|
||||||
|
if rec := do(t, h, "GET", "/v1/devices", "wrong-password", ""); rec.Code != 401 {
|
||||||
|
t.Fatalf("devices without the password: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := do(t, h, "GET", "/v1/devices", token, ""); rec.Code != 401 {
|
||||||
|
t.Fatal("a car token opened the device list")
|
||||||
|
}
|
||||||
|
rec := do(t, h, "GET", "/v1/devices", testApprovalPassword, "")
|
||||||
|
if rec.Code != 200 || !strings.Contains(rec.Body.String(), got["deviceId"]) || strings.Contains(rec.Body.String(), store.devices[0].TokenHash) {
|
||||||
|
t.Fatalf("list: %d %s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if rec := do(t, h, "POST", "/v1/devices/"+got["deviceId"]+"/revoke", testApprovalPassword, ""); rec.Code != 204 {
|
||||||
|
t.Fatalf("revoke: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if last := store.devices[len(store.devices)-1]; !last.Revoked {
|
||||||
|
t.Fatal("revocation not persisted")
|
||||||
|
}
|
||||||
|
if rec := do(t, h, "POST", "/v1/telemetry", token, string(telemetryBody(`{"utc":1790000000}`))); rec.Code != 401 {
|
||||||
|
t.Fatalf("revoked token accepted: %d", rec.Code)
|
||||||
|
}
|
||||||
|
// The shared ingest token is unaffected.
|
||||||
|
if rec := do(t, h, "POST", "/v1/telemetry", testToken, string(telemetryBody(`{"utc":1790000000,"soc":1}`))); rec.Code != 204 {
|
||||||
|
t.Fatalf("shared token: %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPairedDevicesSurviveARestart(t *testing.T) {
|
||||||
|
devices := []Device{{ID: "d1", TokenHash: hashToken("restored-token-0123456789abcdef"), CreatedAt: time.Now()},
|
||||||
|
{ID: "d2", TokenHash: hashToken("revoked-token-0123456789abcdef"), CreatedAt: time.Now(), Revoked: true}}
|
||||||
|
p := NewPairing(testApprovalPassword, devices)
|
||||||
|
if !p.Authorized("restored-token-0123456789abcdef") || p.Authorized("revoked-token-0123456789abcdef") {
|
||||||
|
t.Fatal("restored devices not honoured")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPairingOffWithoutAPassword(t *testing.T) {
|
||||||
|
api, _ := newTestAPI()
|
||||||
|
api.Pairing = NewPairing("", nil)
|
||||||
|
h := api.Routes()
|
||||||
|
if rec := do(t, h, "POST", "/v1/pair/start", "", `{"installationId":"`+testInstallation+`"}`); rec.Code != 404 {
|
||||||
|
t.Fatalf("start: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := do(t, h, "GET", "/pair", "", ""); rec.Code != 404 {
|
||||||
|
t.Fatalf("page: %d", rec.Code)
|
||||||
|
}
|
||||||
|
if rec := do(t, h, "GET", "/v1/devices", "", ""); rec.Code != 404 {
|
||||||
|
t.Fatalf("devices: %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserCodesNormalise(t *testing.T) {
|
||||||
|
for in, want := range map[string]string{"k7f-3qx": "K7F-3QX", " K7F3QX ": "K7F-3QX", "k7f 3qx": "K7F-3QX", "K7F-3Q": "", "": ""} {
|
||||||
|
if got := normalizeUserCode(in); got != want {
|
||||||
|
t.Errorf("%q: got %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApprovalPasswordMustBeLongEnough(t *testing.T) {
|
||||||
|
env := map[string]string{"INFLUX_URL": "u", "INFLUX_TOKEN": "t", "INFLUX_ORG": "o",
|
||||||
|
"INGEST_TOKEN": "0123456789abcdef01234567", "APPROVAL_PASSWORD": "short"}
|
||||||
|
if _, err := loadConfig(func(k string) string { return env[k] }); err == nil {
|
||||||
|
t.Fatal("short approval password accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var regexpUserCode = regexp.MustCompile(`^[` + userCodeAlphabet + `]{3}-[` + userCodeAlphabet + `]{3}$`)
|
||||||
@@ -17,6 +17,8 @@ type Store interface {
|
|||||||
WriteConsumptionChunk(ctx context.Context, chunk ConsumptionChunk) error
|
WriteConsumptionChunk(ctx context.Context, chunk ConsumptionChunk) error
|
||||||
WriteChargingSession(ctx context.Context, session ChargingSession) error
|
WriteChargingSession(ctx context.Context, session ChargingSession) error
|
||||||
ReadConsumptionChunks(ctx context.Context, installationID string) ([]ConsumptionChunk, error)
|
ReadConsumptionChunks(ctx context.Context, installationID string) ([]ConsumptionChunk, error)
|
||||||
|
SaveDevice(ctx context.Context, d Device) error
|
||||||
|
LoadDevices(ctx context.Context) ([]Device, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// InfluxStore keeps everything in one InfluxDB 2 bucket; every point carries an
|
// InfluxStore keeps everything in one InfluxDB 2 bucket; every point carries an
|
||||||
@@ -26,6 +28,7 @@ type Store interface {
|
|||||||
// consumption_chunk time = recordedAt tags: date km, kwh, hours, socDrop, sohPercent
|
// consumption_chunk time = recordedAt tags: date km, kwh, hours, socDrop, sohPercent
|
||||||
// charging_session time = startedAt tags: sessionId summary fields
|
// charging_session time = startedAt tags: sessionId summary fields
|
||||||
// charging_curve time = point time tags: sessionId powerKw, socPercent
|
// charging_curve time = point time tags: sessionId powerKw, socPercent
|
||||||
|
// device time = createdAt tags: deviceId tokenHash, installationId, revoked
|
||||||
//
|
//
|
||||||
// Idempotency comes from InfluxDB itself: a point with the same measurement, tags and
|
// Idempotency comes from InfluxDB itself: a point with the same measurement, tags and
|
||||||
// timestamp replaces the earlier one. The car re-sends a cached chunk with its original
|
// timestamp replaces the earlier one. The car re-sends a cached chunk with its original
|
||||||
@@ -160,3 +163,38 @@ func floatValue(v any) float64 {
|
|||||||
f, _ := v.(float64)
|
f, _ := v.(float64)
|
||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveDevice writes a paired car. Re-saving it (a revocation) overwrites the same point,
|
||||||
|
// because measurement, tag and timestamp stay the same.
|
||||||
|
func (s *InfluxStore) SaveDevice(ctx context.Context, d Device) error {
|
||||||
|
return s.write.WritePoint(ctx, influxdb2.NewPoint("device",
|
||||||
|
map[string]string{"deviceId": d.ID},
|
||||||
|
map[string]any{"tokenHash": d.TokenHash, "installationId": d.InstallationID, "revoked": d.Revoked},
|
||||||
|
d.CreatedAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InfluxStore) LoadDevices(ctx context.Context) ([]Device, error) {
|
||||||
|
query := fmt.Sprintf(`from(bucket: %s)
|
||||||
|
|> range(start: 0)
|
||||||
|
|> filter(fn: (r) => r._measurement == "device")
|
||||||
|
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")`, fluxString(s.bucket))
|
||||||
|
result, err := s.query.Query(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer result.Close()
|
||||||
|
var devices []Device
|
||||||
|
for result.Next() {
|
||||||
|
r := result.Record()
|
||||||
|
id, _ := r.ValueByKey("deviceId").(string)
|
||||||
|
hash, _ := r.ValueByKey("tokenHash").(string)
|
||||||
|
installation, _ := r.ValueByKey("installationId").(string)
|
||||||
|
revoked, _ := r.ValueByKey("revoked").(bool)
|
||||||
|
if id == "" || hash == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
devices = append(devices, Device{ID: id, InstallationID: installation, TokenHash: hash,
|
||||||
|
CreatedAt: r.Time(), Revoked: revoked})
|
||||||
|
}
|
||||||
|
return devices, result.Err()
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user