130 lines
5.2 KiB
Markdown
130 lines
5.2 KiB
Markdown
|
|
# 📦 `tools-gatekeeper`
|
||
|
|
|
||
|
|
A lightweight web server that protects **private S3 files** using **token-based authentication**.
|
||
|
|
|
||
|
|
> Originally tested with [DigitalOcean Spaces](https://www.digitalocean.com/products/spaces/) and served via [Cloudflare CDN](https://www.cloudflare.com/cdn/).
|
||
|
|
> Compatible with **any S3-compatible provider**.
|
||
|
|
|
||
|
|
## ⚙️ Configuration
|
||
|
|
You can configure the Gatekeeper through **environment variables**.
|
||
|
|
|
||
|
|
> **Required variables** are marked with an asterisk `*`
|
||
|
|
|
||
|
|
| Environment Variable | Description |
|
||
|
|
| -------------------- | -------------------------------------------------------------------- |
|
||
|
|
| `*S3_ACCESS_KEY` | S3-compatible API access key |
|
||
|
|
| `*S3_SECRET_KEY` | S3-compatible API secret key |
|
||
|
|
| `*S3_ENDPOINT` | S3 Endpoint (e.g. `https://nyc3.digitaloceanspaces.com`) |
|
||
|
|
| `*S3_REGION` | S3 Region (e.g. `us-east-1`) |
|
||
|
|
| `S3_BUCKET` | S3 Bucket Name |
|
||
|
|
| `HTTP_ADDRESS` | Address and port to accept requests on, defaults to `localhost:8080` |
|
||
|
|
| `HTTP_CORS_ORIGIN` | Enables CORS for the given origin. Leave blank to disable. |
|
||
|
|
| `HTTP_KEY` | Shared secret used for token verification. |
|
||
|
|
|
||
|
|
## 🔰 Generating Tokens
|
||
|
|
Gatekeeper protects all files under the `/private` path in your bucket.
|
||
|
|
Any request to `/private/...` must include a valid token in the query parameter:
|
||
|
|
|
||
|
|
In this example we'll be hosting a anime website and our directory structure looks like this:
|
||
|
|
```
|
||
|
|
. cdn.anime4all.net
|
||
|
|
|__ /site
|
||
|
|
| |__ /images
|
||
|
|
| | |__ index-hero.png
|
||
|
|
| | |__ index-carousel-1.png
|
||
|
|
| | |__ index-carousel-2.png
|
||
|
|
| | |__ index-carousel-3.png
|
||
|
|
| |__ /assets
|
||
|
|
| |__ /favicon.png
|
||
|
|
| |__ /index.css
|
||
|
|
| |__ /index.js
|
||
|
|
|__ /private
|
||
|
|
|__ /wallpapers
|
||
|
|
| |__ codegeass_16x9_1080.png
|
||
|
|
| |__ cowboybebop_4x3_480.png
|
||
|
|
| |__ evangelion_4x3_480.png
|
||
|
|
|__ /avatars
|
||
|
|
|__ miku.png
|
||
|
|
|__ teto.png
|
||
|
|
|__ neru.png
|
||
|
|
```
|
||
|
|
|
||
|
|
To access our super cool avatars and wallpapers, users must be logged in — those files live inside the /private directory and require a valid token.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Step 1. Create Payload
|
||
|
|
Our gatekeeper expects the token payload to at minimum include two fields:
|
||
|
|
|
||
|
|
* `pattern:` Our filename matching pattern
|
||
|
|
* `expires:` The UNIX Timestamp for when our token expires
|
||
|
|
|
||
|
|
> **Note:** You can include arbitrary fields like `user_id` for your own logging purposes or whatnot.
|
||
|
|
> The gatekeeper will ignore them.
|
||
|
|
|
||
|
|
Our website displays all avatars on a single page so we'll give our user full access to the private avatars directory, which matches the pattern `/private/avatars/*`.
|
||
|
|
If we wanted to give them access to a single file we could alternatively do `/private/avatars/teto.png`.
|
||
|
|
|
||
|
|
> **Note:** The gatekeeper uses the [path.Match](https://pkg.go.dev/path#Match) function internally for pattern matching. You can read it's documentation to learn more.
|
||
|
|
|
||
|
|
```go
|
||
|
|
payload, err := json.Marshal(map[string]any{
|
||
|
|
"pattern": "/private/avatars/*", // Allow all files in avatars folder
|
||
|
|
"expires": time.Now().Unix() + 3600 // Token expires in 1 hour
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
panic(err)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
Best practice is to ensure your server clock is correct and that the token lifetime isn't longer than it should be!
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Step 2. Generating Signature
|
||
|
|
After we stringified our JSON payload we will sign its bytes with HMAC-SHA256 and our secret key which we share between the gatekeeper and our webserver. In GO we can do this using the hmac standard library.
|
||
|
|
```go
|
||
|
|
signer := hmac.New(sha256.New, []byte("your-shared-secret-key")) // envvar: HTTP_SIGNATURE
|
||
|
|
signer.Write(payload)
|
||
|
|
signature := signer.Sum(nil)
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Step 3. Compiling Token
|
||
|
|
Finally we encode both the payload and signature into base64 (URL encoding is fine, but raw encoding is shorter),
|
||
|
|
and concatenate them together using a period as the delimiter.
|
||
|
|
|
||
|
|
```go
|
||
|
|
encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
|
||
|
|
encodedSignature := base64.RawURLEncoding.EncodeToString(signature)
|
||
|
|
token := fmt.Sprintf("%s.%s", encodedPayload, encodedSignature)
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Step 4. Rendering
|
||
|
|
Our anime site also uses Go Templates (not php lol) so we generate a URL with our hostname, path, and our freshly baked token in the query parameter:
|
||
|
|
```html
|
||
|
|
<!DOCTYPE html>
|
||
|
|
<html>
|
||
|
|
|
||
|
|
<head>
|
||
|
|
<link rel="icon" type="image/png" href="https://cdn.anime4all/site/assets/favicon.png">
|
||
|
|
<title>anime4all - Avatars</title>
|
||
|
|
</head>
|
||
|
|
|
||
|
|
<body>
|
||
|
|
<div class="container-navigation">
|
||
|
|
<a href="/">Homepage</a>
|
||
|
|
<a href="/avatars">Avatars</a>
|
||
|
|
<a href="/wallpapers">Wallpapers</a>
|
||
|
|
</div>
|
||
|
|
<div class="container-avatars">
|
||
|
|
<img src="https://cdn.anime4all.net/private/avatars/miku.png?token={{.token}}" alt="Avatar of 'Hatsune Miku'">
|
||
|
|
<img src="https://cdn.anime4all.net/private/avatars/teto.png?token={{.token}}" alt="Avatar of 'Kasane Teto'">
|
||
|
|
<img src="https://cdn.anime4all.net/private/avatars/neru.png?token={{.token}}" alt="Avatar of 'Akita Neru'">
|
||
|
|
</div>
|
||
|
|
</body>
|
||
|
|
|
||
|
|
</html>
|
||
|
|
```
|