package main import ( "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "log" "net/http" "os" "path/filepath" "strings" "sync" "time" "unicode/utf8" ) // API tokens: read-only access to /api/v1/ for scripts and automation tools // (n8n…), sent as "Authorization: Bearer lst_…". They are created and revoked // in Settings > API by an administrator, and work whatever the AUTH_MODE, // including without authentication. Only a SHA-256 of each token is kept, in // DATA_DIR/api_tokens.json: a token is shown once, when it is created. const ( tokenPrefix = "lst_" maxTokens = 100 lastUsedPeriod = time.Minute // last use of a token is written to disk at most this often (or on a new IP) ) var errTokenNotFound = errors.New("token not found") type apiToken struct { ID string `json:"id"` Name string `json:"name"` Hint string `json:"hint"` // start of the token, to recognize it Hash string `json:"hash"` // hex SHA-256 of the whole token Created time.Time `json:"created"` LastUsed time.Time `json:"last_used,omitzero"` LastIP string `json:"last_ip,omitempty"` } // apiTokenInfo is what the UI sees of a token (never its hash). type apiTokenInfo struct { ID string `json:"id"` Name string `json:"name"` Hint string `json:"hint"` Created time.Time `json:"created"` LastUsed time.Time `json:"last_used,omitzero"` LastIP string `json:"last_ip,omitempty"` } type TokenStore struct { path string mu sync.Mutex tokens []apiToken } func LoadTokenStore(path string) (*TokenStore, error) { s := &TokenStore{path: path} b, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return s, nil } if err != nil { return nil, err } if err := json.Unmarshal(b, &s.tokens); err != nil { return nil, fmt.Errorf("%s: %w", path, err) } return s, nil } // save writes the file atomically, readable by its owner only; the caller holds the lock. func (s *TokenStore) save() error { b, err := json.MarshalIndent(s.tokens, "", " ") if err != nil { return err } if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { return err } tmp := s.path + ".tmp" if err := os.WriteFile(tmp, b, 0o600); err != nil { return err } return os.Rename(tmp, s.path) } func hashToken(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } func (s *TokenStore) List() []apiTokenInfo { s.mu.Lock() defer s.mu.Unlock() out := make([]apiTokenInfo, len(s.tokens)) for i, t := range s.tokens { out[i] = apiTokenInfo{ID: t.ID, Name: t.Name, Hint: t.Hint, Created: t.Created, LastUsed: t.LastUsed, LastIP: t.LastIP} } return out } // Create makes a new token and returns it in clear, the only time it exists so. func (s *TokenStore) Create(name string) (apiTokenInfo, string, error) { name = strings.TrimSpace(name) if name == "" || utf8.RuneCountInString(name) > 80 { return apiTokenInfo{}, "", &codedError{code: "token_name", msg: "give the token a name of 1 to 80 characters"} } b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return apiTokenInfo{}, "", err } secret := tokenPrefix + base64.RawURLEncoding.EncodeToString(b) t := apiToken{ID: newID(), Name: name, Hint: secret[:len(tokenPrefix)+6], Hash: hashToken(secret), Created: time.Now().UTC()} s.mu.Lock() defer s.mu.Unlock() if len(s.tokens) >= maxTokens { return apiTokenInfo{}, "", &codedError{code: "token_max", msg: fmt.Sprintf("at most %d API tokens", maxTokens)} } s.tokens = append(s.tokens, t) if err := s.save(); err != nil { s.tokens = s.tokens[:len(s.tokens)-1] return apiTokenInfo{}, "", err } return apiTokenInfo{ID: t.ID, Name: t.Name, Hint: t.Hint, Created: t.Created}, secret, nil } func (s *TokenStore) Delete(id string) error { s.mu.Lock() defer s.mu.Unlock() for i, t := range s.tokens { if t.ID == id { s.tokens = append(s.tokens[:i:i], s.tokens[i+1:]...) return s.save() } } return errTokenNotFound } // Check tells whether token is a valid API token, and notes its use. func (s *TokenStore) Check(token, ip string) (name string, ok bool) { if !strings.HasPrefix(token, tokenPrefix) { return "", false } h := []byte(hashToken(token)) s.mu.Lock() defer s.mu.Unlock() for i := range s.tokens { t := &s.tokens[i] if subtle.ConstantTimeCompare(h, []byte(t.Hash)) != 1 { continue } now := time.Now().UTC() stale := now.Sub(t.LastUsed) >= lastUsedPeriod || t.LastIP != ip t.LastUsed, t.LastIP = now, ip if stale { if err := s.save(); err != nil { log.Printf("api tokens: %v", err) } } return t.Name, true } return "", false } // bearerToken returns the token of an "Authorization: Bearer …" header. func bearerToken(r *http.Request) (string, bool) { scheme, token, ok := strings.Cut(r.Header.Get("Authorization"), " ") if !ok || !strings.EqualFold(scheme, "Bearer") { return "", false } return strings.TrimSpace(token), true } // tokenAuth serves the requests carrying an API token itself, read-only and // limited to /api/v1/, before the session login (auth); api is the handler // behind the login. func tokenAuth(tokens *TokenStore, auth, api http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token, ok := bearerToken(r) if !ok { auth.ServeHTTP(w, r) return } ip := clientIP(r) if _, valid := tokens.Check(token, ip); !valid { log.Printf("api: invalid or revoked token from %s", ip) time.Sleep(loginFailDelay) w.Header().Set("WWW-Authenticate", `Bearer realm="logstream"`) writeErr(w, http.StatusUnauthorized, &codedError{code: "token", msg: "invalid or revoked API token"}) return } if !strings.HasPrefix(r.URL.Path, "/api/v1/") || !isSafeMethod(r.Method) { writeErr(w, http.StatusForbidden, &codedError{code: "token_scope", msg: "API tokens only give read access to /api/v1/"}) return } api.ServeHTTP(w, asViewer(r)) }) } // GET /api/tokens: the API tokens (Settings > API), for administrators. func (a *API) listTokens(w http.ResponseWriter, r *http.Request) { if isViewer(r) { writeErr(w, http.StatusForbidden, &codedError{code: "read_only", msg: "read-only account: changes are reserved to administrators"}) return } writeJSON(w, http.StatusOK, a.tokens.List()) } // POST /api/tokens {"name": "n8n"}: returns the new token, with "token" in clear. func (a *API) createToken(w http.ResponseWriter, r *http.Request) { var body struct { Name string `json:"name"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil { writeErr(w, http.StatusBadRequest, err) return } info, secret, err := a.tokens.Create(body.Name) var ce *codedError switch { case errors.As(err, &ce): writeErr(w, http.StatusBadRequest, err) return case err != nil: writeErr(w, http.StatusInternalServerError, err) return } log.Printf("api token %q created from %s", info.Name, clientIP(r)) writeJSON(w, http.StatusCreated, struct { apiTokenInfo Token string `json:"token"` }{info, secret}) } // DELETE /api/tokens/{id}: revokes a token at once. func (a *API) deleteToken(w http.ResponseWriter, r *http.Request) { err := a.tokens.Delete(r.PathValue("id")) switch { case errors.Is(err, errTokenNotFound): writeErr(w, http.StatusNotFound, err) case err != nil: writeErr(w, http.StatusInternalServerError, err) default: log.Printf("api token %s revoked from %s", r.PathValue("id"), clientIP(r)) w.WriteHeader(http.StatusNoContent) } }