Add bearer token auth and clean up debug logging
- Bearer token auth via --token flag or DREAMDICT_TOKEN env var - Timing-safe comparison with crypto/subtle - /health exempt for monitoring without credentials - 401 response for missing/invalid tokens - Auth disabled when no token configured (backwards compatible) - Remove debug logging from cetempublico loader Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
26
README.md
26
README.md
@@ -158,10 +158,34 @@ $ curl localhost:7777/health
|
||||
| `--port` | `DREAMDICT_PORT` | `7777` | Listen port |
|
||||
| `--host` | `DREAMDICT_HOST` | `127.0.0.1` | Listen host |
|
||||
| `--db` | `DREAMDICT_DB` | `./dict.db` | Path to SQLite database |
|
||||
| `--token` | `DREAMDICT_TOKEN` | _(none)_ | Bearer token for API auth |
|
||||
| `--log-level` | | `info` | `debug`, `info`, `warn`, `error` |
|
||||
|
||||
Flags take precedence over environment variables.
|
||||
|
||||
### Authentication
|
||||
|
||||
When `--token` or `DREAMDICT_TOKEN` is set, all endpoints except `/health` require a bearer token:
|
||||
|
||||
```bash
|
||||
# Start with auth enabled
|
||||
DREAMDICT_TOKEN=mysecret go run ./cmd/server --db ./dict.db
|
||||
|
||||
# Authenticated request
|
||||
curl -H 'Authorization: Bearer mysecret' 'localhost:7777/define?word=casa&lang=pt-PT'
|
||||
|
||||
# Health is always public (for monitoring)
|
||||
curl localhost:7777/health
|
||||
```
|
||||
|
||||
Requests without a valid token receive a `401` response:
|
||||
|
||||
```json
|
||||
{"error": "unauthorized", "message": "invalid or missing bearer token"}
|
||||
```
|
||||
|
||||
When no token is configured, auth is disabled and all endpoints are open.
|
||||
|
||||
## Import CLI
|
||||
|
||||
```bash
|
||||
@@ -265,6 +289,8 @@ chown -R dreamdict:dreamdict /opt/dreamdict
|
||||
cp deploy/dreamdict.service /etc/systemd/system/
|
||||
echo 'DREAMDICT_PORT=7777' > /etc/dreamdict/dreamdict.env
|
||||
echo 'DREAMDICT_DB=/opt/dreamdict/dict.db' >> /etc/dreamdict/dreamdict.env
|
||||
echo 'DREAMDICT_TOKEN=changeme' >> /etc/dreamdict/dreamdict.env
|
||||
chmod 600 /etc/dreamdict/dreamdict.env
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now dreamdict
|
||||
```
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -22,6 +23,7 @@ func main() {
|
||||
port := flag.Int("port", 0, "Listen port (default 7777)")
|
||||
host := flag.String("host", "", "Listen host (default 127.0.0.1)")
|
||||
dbPath := flag.String("db", "", "Path to dict.db")
|
||||
token := flag.String("token", "", "Bearer token for API auth (or DREAMDICT_TOKEN env)")
|
||||
logLevel := flag.String("log-level", "info", "Log level: debug, info, warn, error")
|
||||
flag.Parse()
|
||||
|
||||
@@ -51,6 +53,10 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
if *token == "" {
|
||||
*token = os.Getenv("DREAMDICT_TOKEN")
|
||||
}
|
||||
|
||||
var level slog.Level
|
||||
switch *logLevel {
|
||||
case "debug":
|
||||
@@ -102,9 +108,16 @@ func main() {
|
||||
|
||||
serverStart = time.Now()
|
||||
addr := fmt.Sprintf("%s:%d", *host, *port)
|
||||
|
||||
var handler http.Handler = mux
|
||||
if *token != "" {
|
||||
handler = authMiddleware(*token, mux)
|
||||
slog.Info("bearer token auth enabled")
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: loggingMiddleware(mux),
|
||||
Handler: loggingMiddleware(handler),
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
@@ -172,6 +185,22 @@ func loggingMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func authMiddleware(token string, next http.Handler) http.Handler {
|
||||
expected := "Bearer " + token
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Health endpoint is exempt so monitoring works without credentials
|
||||
if r.URL.Path == "/health" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte(expected)) != 1 {
|
||||
writeError(w, 401, "unauthorized", "invalid or missing bearer token")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
@@ -40,8 +40,6 @@ func (CETEMPublicoLoader) Load(db *sql.DB, dataDir string) error {
|
||||
slog.Warn("cetempublico: no data file found, skipping", "searched", candidates)
|
||||
return nil
|
||||
}
|
||||
slog.Info("cetempublico: using file", "path", path)
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cetempublico: begin tx: %w", err)
|
||||
@@ -71,7 +69,6 @@ func (CETEMPublicoLoader) Load(db *sql.DB, dataDir string) error {
|
||||
|
||||
isLatin1 := !utf8.Valid(rawBytes)
|
||||
if isLatin1 {
|
||||
slog.Info("cetempublico: detected ISO-8859-1 encoding, converting")
|
||||
rawBytes = []byte(latin1ToUTF8(rawBytes))
|
||||
}
|
||||
|
||||
@@ -89,7 +86,7 @@ func (CETEMPublicoLoader) Load(db *sql.DB, dataDir string) error {
|
||||
}
|
||||
}
|
||||
|
||||
var count, skipped, inserted int
|
||||
var count, inserted int
|
||||
processLine := func(line string) error {
|
||||
// Support TSV and space-separated, with either column order:
|
||||
// "word\tcount", "word count", or "count\tword", "count word"
|
||||
@@ -113,13 +110,11 @@ func (CETEMPublicoLoader) Load(db *sql.DB, dataDir string) error {
|
||||
}
|
||||
|
||||
if word == "" || containsDigit(word) || containsSpace(word) || containsNonLatin(word) {
|
||||
skipped++
|
||||
return nil
|
||||
}
|
||||
|
||||
freqVal, err := strconv.ParseFloat(freqStr, 64)
|
||||
if err != nil || freqVal <= 0 {
|
||||
skipped++
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -153,9 +148,6 @@ func (CETEMPublicoLoader) Load(db *sql.DB, dataDir string) error {
|
||||
inserted++
|
||||
count++
|
||||
}
|
||||
if count+skipped <= 5 {
|
||||
slog.Info("cetempublico: sample", "word", word, "freq", freq, "total", count, "inserted", inserted)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -179,7 +171,7 @@ func (CETEMPublicoLoader) Load(db *sql.DB, dataDir string) error {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("cetempublico: commit: %w", err)
|
||||
}
|
||||
slog.Info("cetempublico loaded", "updated_words", count, "inserted_new", inserted, "skipped", skipped)
|
||||
slog.Info("cetempublico loaded", "updated_words", count, "inserted_new", inserted)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user