diff --git a/.env.example b/.env.example index 004ca25..540ede4 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,5 @@ INFLUXDB_SERVER_URL= INFLUXDB_AUTH_TOKEN= INFLUXDB_ORG= INFLUXDB_BUCKET=frog_fleet + +API_KEY_DB_PATH=./keys.db diff --git a/Dockerfile b/Dockerfile index e0f5731..e65bfd6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.22-alpine AS builder +FROM golang:1.25-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download diff --git a/README.md b/README.md index ec833b3..1bb855f 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Health check. Returns `🐸`. Returns CO2, temperature, humidity, and location measurements from the sensor network for a given time range. +Requires an API key passed as `Authorization: Bearer ` or `X-API-Key: `. + #### Query parameters | Parameter | Required | Description | @@ -47,7 +49,7 @@ GET /data?start=2024-01-01T00:00:00Z&stop=2024-01-02T00:00:00Z&fields=co2,lat,lo ## Running locally -**Prerequisites:** [Go](https://go.dev/doc/install) 1.17+ +**Prerequisites:** [Go](https://go.dev/doc/install) 1.25+ 1. Clone the repo: ```sh @@ -75,6 +77,41 @@ The API will be available at `http://localhost:`. | `INFLUXDB_AUTH_TOKEN` | InfluxDB API token (use a read-only token in production) | | `INFLUXDB_ORG` | InfluxDB organization name or email | | `INFLUXDB_BUCKET` | InfluxDB bucket name (`frog_fleet`) | +| `API_KEY_DB_PATH` | Path to the SQLite file holding hashed API keys | + +## API keys + +Access to `/data` requires an API key. Keys live in a SQLite file at `API_KEY_DB_PATH`; only the SHA-256 of each key is stored. + +Key management is built into the API binary as a `keygen` subcommand. + +Locally: + +```sh +# Issue a new key (the raw key is printed once — record it immediately) +go run . keygen issue --owner you@example.com + +# List existing keys +go run . keygen list + +# Revoke a key by ID +go run . keygen revoke --id 3 +``` + +In production (on Fly.io), run the same subcommands against the deployed binary over SSH: + +```sh +fly ssh console -C "/api keygen issue --owner researcher@uni.edu" +fly ssh console -C "/api keygen list" +fly ssh console -C "/api keygen revoke --id 7" +``` + +Callers pass the key in either header: + +```sh +curl -H "Authorization: Bearer rbnt_..." "$API_URL/data?start=2024-01-01T00:00:00Z" +curl -H "X-API-Key: rbnt_..." "$API_URL/data?start=2024-01-01T00:00:00Z" +``` ## Contributing diff --git a/TODO.md b/TODO.md index 18693ce..ccdc078 100644 --- a/TODO.md +++ b/TODO.md @@ -23,7 +23,7 @@ Path from local prototype to deployed public API. - [ ] Initialize the InfluxDB client once in `main` rather than per request - [ ] Add a `/healthz` endpoint - [ ] Add CORS headers if a browser client will call the API -- [ ] Update `go.mod` from `go 1.17` to `1.22` or later +- [x] Update `go.mod` from `go 1.17` to `1.22` or later - [ ] Refresh dependencies (`go get -u ./... && go mod tidy`) - [ ] Replace `log.Println` with `log/slog` for structured logging @@ -32,4 +32,4 @@ Path from local prototype to deployed public API. - [ ] GitHub Actions workflow running `go test ./...` and `go vet` on pull requests - [ ] Handler-level tests with a mocked database - [ ] "Deploying" section in the README -- [ ] Rate limiting or API keys if abuse becomes a concern +- [x] Rate limiting or API keys if abuse becomes a concern (API keys added; rate limiting still TODO) diff --git a/fly.toml b/fly.toml index 2dbe011..f8738aa 100644 --- a/fly.toml +++ b/fly.toml @@ -11,6 +11,13 @@ auto_stop_machines = false auto_start_machines = true min_machines_running = 1 +[[mounts]] +source = "ribbit_api_data" +destination = "/var/lib/ribbit" + +[env] +API_KEY_DB_PATH = "/var/lib/ribbit/keys.db" + [[vm]] size = "shared-cpu-1x" memory = "256mb" diff --git a/go.mod b/go.mod index 1a9a0ad..dc2dbcc 100644 --- a/go.mod +++ b/go.mod @@ -1,19 +1,30 @@ module github.com/Ribbit-Network/api -go 1.17 +go 1.25.0 require ( github.com/influxdata/influxdb-client-go/v2 v2.6.0 github.com/joho/godotenv v1.4.0 - github.com/stretchr/testify v1.5.1 + github.com/stretchr/testify v1.11.1 + modernc.org/sqlite v1.50.1 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/deepmap/oapi-codegen v1.8.2 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/net v0.0.0-20210119194325-5f4716e94777 // indirect + golang.org/x/sys v0.42.0 // indirect gopkg.in/yaml.v2 v2.3.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 9783574..6222ee2 100644 --- a/go.sum +++ b/go.sum @@ -5,13 +5,21 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/deepmap/oapi-codegen v1.8.2 h1:SegyeYGcdi0jLLrpbCMoJxnUUn8GBXHsvr4rbzjuhfU= github.com/deepmap/oapi-codegen v1.8.2/go.mod h1:YLgSKSDv/bZQB7N4ws6luhozi3cEdRktEqrX88CvjIw= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +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/getkin/kin-openapi v0.61.0/go.mod h1:7Yn5whZr5kJi6t+kShccXS8ae1APpYTW6yheSwk8Yi4= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/golangci/lint-1 v0.0.0-20181222135242-d2cdd8c08219/go.mod h1:/X8TswGSh1pIozq4ZwCfxS0WA5JGXguxk94ar/4c87Y= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/influxdata/influxdb-client-go/v2 v2.6.0 h1:bIOaGTgvvv1Na2hG+nIvqyv7PK2UiU2WrJN1ck1ykyM= github.com/influxdata/influxdb-client-go/v2 v2.6.0/go.mod h1:Y/0W1+TZir7ypoQZYd2IrnVOKB3Tq6oegAQeSVN/+EU= github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU= @@ -34,16 +42,23 @@ github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/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/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +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/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= @@ -51,12 +66,16 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y5/NXf5fzg6ufd1/Oew= golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -68,6 +87,9 @@ golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -77,6 +99,8 @@ golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxb golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= @@ -84,3 +108,33 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +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.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w= +modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..ff5bb37 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,170 @@ +// Package auth provides API-key authentication for the Ribbit Network API. +// +// Keys are 256-bit random tokens formatted as "rbnt_". Only the +// SHA-256 of the raw key is stored. SHA-256 is appropriate here (rather than +// bcrypt/argon2) because the keys carry full entropy — slowing per-request +// verification adds latency without making brute-force any less infeasible. +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "time" +) + +const ( + keyPrefix = "rbnt_" + rawKeyBytes = 32 + prefixDisplay = 12 // characters of the raw key kept for display (e.g. "rbnt_AbCdEf") +) + +var ( + ErrInvalidKey = errors.New("invalid api key") + ErrNotFound = errors.New("api key not found") +) + +type Key struct { + ID int64 + Prefix string + Owner string + CreatedAt time.Time + RevokedAt *time.Time +} + +type Store struct { + db *sql.DB +} + +func NewStore(db *sql.DB) (*Store, error) { + s := &Store{db: db} + if err := s.migrate(); err != nil { + return nil, fmt.Errorf("migrate: %w", err) + } + return s, nil +} + +func (s *Store) migrate() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key_hash TEXT NOT NULL UNIQUE, + prefix TEXT NOT NULL, + owner TEXT NOT NULL, + created_at INTEGER NOT NULL, + revoked_at INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash); + `) + return err +} + +// Issue generates a new key, stores its hash, and returns the raw key. +// The raw key is only returned here — callers must surface it to the user +// immediately, because it cannot be recovered later. +func (s *Store) Issue(owner string) (string, *Key, error) { + raw, err := generate() + if err != nil { + return "", nil, err + } + hash := hashKey(raw) + prefix := raw[:prefixDisplay] + now := time.Now().UTC() + + res, err := s.db.Exec( + `INSERT INTO api_keys (key_hash, prefix, owner, created_at) VALUES (?, ?, ?, ?)`, + hash, prefix, owner, now.Unix(), + ) + if err != nil { + return "", nil, fmt.Errorf("insert: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return "", nil, fmt.Errorf("last insert id: %w", err) + } + return raw, &Key{ID: id, Prefix: prefix, Owner: owner, CreatedAt: now}, nil +} + +// Verify returns nil iff the raw key matches a non-revoked record. +func (s *Store) Verify(raw string) error { + if raw == "" { + return ErrInvalidKey + } + hash := hashKey(raw) + var revokedAt sql.NullInt64 + err := s.db.QueryRow( + `SELECT revoked_at FROM api_keys WHERE key_hash = ?`, hash, + ).Scan(&revokedAt) + if errors.Is(err, sql.ErrNoRows) { + return ErrInvalidKey + } + if err != nil { + return fmt.Errorf("verify: %w", err) + } + if revokedAt.Valid { + return ErrInvalidKey + } + return nil +} + +func (s *Store) List() ([]Key, error) { + rows, err := s.db.Query( + `SELECT id, prefix, owner, created_at, revoked_at FROM api_keys ORDER BY id`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Key + for rows.Next() { + var k Key + var created int64 + var revoked sql.NullInt64 + if err := rows.Scan(&k.ID, &k.Prefix, &k.Owner, &created, &revoked); err != nil { + return nil, err + } + k.CreatedAt = time.Unix(created, 0).UTC() + if revoked.Valid { + t := time.Unix(revoked.Int64, 0).UTC() + k.RevokedAt = &t + } + out = append(out, k) + } + return out, rows.Err() +} + +func (s *Store) Revoke(id int64) error { + res, err := s.db.Exec( + `UPDATE api_keys SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`, + time.Now().UTC().Unix(), id, + ) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrNotFound + } + return nil +} + +func generate() (string, error) { + b := make([]byte, rawKeyBytes) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("rand: %w", err) + } + return keyPrefix + base64.RawURLEncoding.EncodeToString(b), nil +} + +func hashKey(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..9aaac25 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,148 @@ +package auth + +import ( + "database/sql" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + _ "modernc.org/sqlite" + + "github.com/stretchr/testify/require" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + db, err := sql.Open("sqlite", ":memory:") + require.NoError(t, err) + t.Cleanup(func() { db.Close() }) + + s, err := NewStore(db) + require.NoError(t, err) + return s +} + +func TestIssueAndVerify(t *testing.T) { + s := newTestStore(t) + + raw, k, err := s.Issue("test@example.com") + require.NoError(t, err) + require.True(t, strings.HasPrefix(raw, "rbnt_")) + require.Equal(t, "test@example.com", k.Owner) + require.NotZero(t, k.ID) + + require.NoError(t, s.Verify(raw)) +} + +func TestVerify_RejectsUnknownKey(t *testing.T) { + s := newTestStore(t) + require.ErrorIs(t, s.Verify("rbnt_doesnotexist"), ErrInvalidKey) + require.ErrorIs(t, s.Verify(""), ErrInvalidKey) +} + +func TestVerify_RejectsRevokedKey(t *testing.T) { + s := newTestStore(t) + raw, k, err := s.Issue("test@example.com") + require.NoError(t, err) + + require.NoError(t, s.Revoke(k.ID)) + require.ErrorIs(t, s.Verify(raw), ErrInvalidKey) +} + +func TestRevoke_UnknownID(t *testing.T) { + s := newTestStore(t) + require.ErrorIs(t, s.Revoke(9999), ErrNotFound) +} + +func TestList(t *testing.T) { + s := newTestStore(t) + _, _, err := s.Issue("a@example.com") + require.NoError(t, err) + _, _, err = s.Issue("b@example.com") + require.NoError(t, err) + + keys, err := s.List() + require.NoError(t, err) + require.Len(t, keys, 2) + require.Equal(t, "a@example.com", keys[0].Owner) + require.Equal(t, "b@example.com", keys[1].Owner) +} + +// stubVerifier lets middleware tests run without a real store. +type stubVerifier struct{ valid string } + +func (s stubVerifier) Verify(raw string) error { + if raw == s.valid { + return nil + } + return ErrInvalidKey +} + +func okHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) +} + +func TestMiddleware_MissingKey(t *testing.T) { + h := Require(stubVerifier{valid: "good"})(okHandler()) + + req := httptest.NewRequest(http.MethodGet, "/data", nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + require.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestMiddleware_BadKey(t *testing.T) { + h := Require(stubVerifier{valid: "good"})(okHandler()) + + req := httptest.NewRequest(http.MethodGet, "/data", nil) + req.Header.Set("Authorization", "Bearer wrong") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + require.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestMiddleware_GoodKey_Bearer(t *testing.T) { + h := Require(stubVerifier{valid: "good"})(okHandler()) + + req := httptest.NewRequest(http.MethodGet, "/data", nil) + req.Header.Set("Authorization", "Bearer good") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "ok", rec.Body.String()) +} + +// erroringVerifier simulates a store outage (e.g. SQLite unavailable). +type erroringVerifier struct{ err error } + +func (e erroringVerifier) Verify(string) error { return e.err } + +func TestMiddleware_NonAuthError_Returns500(t *testing.T) { + h := Require(erroringVerifier{err: errors.New("db is down")})(okHandler()) + + req := httptest.NewRequest(http.MethodGet, "/data", nil) + req.Header.Set("Authorization", "Bearer anything") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + require.Equal(t, http.StatusInternalServerError, rec.Code) +} + +func TestMiddleware_GoodKey_XAPIKey(t *testing.T) { + h := Require(stubVerifier{valid: "good"})(okHandler()) + + req := httptest.NewRequest(http.MethodGet, "/data", nil) + req.Header.Set("X-API-Key", "good") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..6c40251 --- /dev/null +++ b/internal/auth/middleware.go @@ -0,0 +1,49 @@ +package auth + +import ( + "errors" + "log" + "net/http" + "strings" +) + +type Verifier interface { + Verify(raw string) error +} + +// Require returns middleware that rejects requests without a valid API key. +// Keys are accepted in either "Authorization: Bearer " or "X-API-Key: ". +func Require(v Verifier) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := extractKey(r) + if key == "" { + unauthorized(w, "missing api key") + return + } + switch err := v.Verify(key); { + case err == nil: + next.ServeHTTP(w, r) + case errors.Is(err, ErrInvalidKey): + unauthorized(w, "invalid api key") + default: + log.Printf("auth: verify error: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + } + }) + } +} + +func extractKey(r *http.Request) string { + if h := r.Header.Get("Authorization"); h != "" { + if rest, ok := strings.CutPrefix(h, "Bearer "); ok { + return strings.TrimSpace(rest) + } + } + return strings.TrimSpace(r.Header.Get("X-API-Key")) +} + +func unauthorized(w http.ResponseWriter, msg string) { + w.Header().Set("WWW-Authenticate", `Bearer realm="ribbit-api"`) + http.Error(w, msg, http.StatusUnauthorized) +} diff --git a/keygen.go b/keygen.go new file mode 100644 index 0000000..a8ee6ab --- /dev/null +++ b/keygen.go @@ -0,0 +1,100 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + "text/tabwriter" +) + +func runKeygen(args []string) { + if len(args) < 1 { + keygenUsage() + os.Exit(2) + } + + switch args[0] { + case "issue": + keygenIssue(args[1:]) + case "list": + keygenList(args[1:]) + case "revoke": + keygenRevoke(args[1:]) + default: + keygenUsage() + os.Exit(2) + } +} + +func keygenUsage() { + fmt.Fprintln(os.Stderr, "usage: api keygen [flags]") +} + +func keygenIssue(args []string) { + fs := flag.NewFlagSet("issue", flag.ExitOnError) + owner := fs.String("owner", "", "owner label (e.g. an email or team name)") + _ = fs.Parse(args) + + if *owner == "" { + log.Fatal("--owner is required") + } + store, err := openKeyStore() + if err != nil { + log.Fatal(err) + } + + raw, k, err := store.Issue(*owner) + if err != nil { + log.Fatalf("issue: %v", err) + } + + fmt.Println("API key issued. Store it now — it will not be shown again.") + fmt.Printf(" id: %d\n", k.ID) + fmt.Printf(" owner: %s\n", k.Owner) + fmt.Printf(" key: %s\n", raw) +} + +func keygenList(args []string) { + fs := flag.NewFlagSet("list", flag.ExitOnError) + _ = fs.Parse(args) + + store, err := openKeyStore() + if err != nil { + log.Fatal(err) + } + keys, err := store.List() + if err != nil { + log.Fatalf("list: %v", err) + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "ID\tPREFIX\tOWNER\tCREATED\tREVOKED") + for _, k := range keys { + revoked := "-" + if k.RevokedAt != nil { + revoked = k.RevokedAt.Format("2006-01-02") + } + fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\n", + k.ID, k.Prefix, k.Owner, k.CreatedAt.Format("2006-01-02"), revoked) + } + _ = w.Flush() +} + +func keygenRevoke(args []string) { + fs := flag.NewFlagSet("revoke", flag.ExitOnError) + id := fs.Int64("id", 0, "key id to revoke") + _ = fs.Parse(args) + + if *id == 0 { + log.Fatal("--id is required") + } + store, err := openKeyStore() + if err != nil { + log.Fatal(err) + } + if err := store.Revoke(*id); err != nil { + log.Fatalf("revoke: %v", err) + } + fmt.Printf("revoked key id=%d\n", *id) +} diff --git a/main.go b/main.go index 6a7f143..22284c7 100644 --- a/main.go +++ b/main.go @@ -1,11 +1,15 @@ package main import ( + "database/sql" "fmt" "log" "net/http" "os" + _ "modernc.org/sqlite" + + "github.com/Ribbit-Network/api/internal/auth" "github.com/Ribbit-Network/api/internal/data" "github.com/joho/godotenv" ) @@ -15,8 +19,22 @@ func main() { log.Println("no .env file, relying on environment variables") } + if len(os.Args) > 1 && os.Args[1] == "keygen" { + runKeygen(os.Args[2:]) + return + } + runServer() +} + +func runServer() { + store, err := openKeyStore() + if err != nil { + log.Fatal(err) + } + requireKey := auth.Require(store) + http.HandleFunc("/", handle) - http.HandleFunc("/data", data.Handle) + http.Handle("/data", requireKey(http.HandlerFunc(data.Handle))) port := os.Getenv("PORT") if port == "" { @@ -30,6 +48,18 @@ func main() { } } +func openKeyStore() (*auth.Store, error) { + path := os.Getenv("API_KEY_DB_PATH") + if path == "" { + return nil, fmt.Errorf("API_KEY_DB_PATH is required") + } + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("open key db: %w", err) + } + return auth.NewStore(db) +} + func handle(w http.ResponseWriter, _ *http.Request) { _, _ = fmt.Fprintln(w, "🐸") }