syncthing/lib/api/api_csrf.go
Jakob Borg aa901790b9
lib/api: Save session & CSRF tokens to database, add option to stay logged in (fixes #9151) (#9284)
This adds a "token manager" which handles storing and checking expired
tokens, used for both sessions and CSRF tokens. It removes the old,
corresponding functionality for CSRFs which saved things in a file. The
result is less crap in the state directory, and active login sessions
now survive a Syncthing restart (this really annoyed me).

It also adds a boolean on login to create a longer-lived session cookie,
which is now possible and useful. Thus we can remain logged in over
browser restarts, which was also annoying... :)

<img width="1001" alt="Screenshot 2023-12-12 at 09 56 34"
src="https://github.com/syncthing/syncthing/assets/125426/55cb20c8-78fc-453e-825d-655b94c8623b">

Best viewed with whitespace-insensitive diff, as a bunch of the auth
functions became methods instead of closures which changed indentation.
2024-01-04 10:07:12 +00:00

108 lines
3.0 KiB
Go

// Copyright (C) 2014 The Syncthing Authors.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
package api
import (
"net/http"
"strings"
"time"
"github.com/syncthing/syncthing/lib/db"
)
const (
maxCSRFTokenLifetime = time.Hour
maxActiveCSRFTokens = 25
)
type csrfManager struct {
unique string
prefix string
apiKeyValidator apiKeyValidator
next http.Handler
tokens *tokenManager
}
type apiKeyValidator interface {
IsValidAPIKey(key string) bool
}
// Check for CSRF token on /rest/ URLs. If a correct one is not given, reject
// the request with 403. For / and /index.html, set a new CSRF cookie if none
// is currently set.
func newCsrfManager(unique string, prefix string, apiKeyValidator apiKeyValidator, next http.Handler, miscDB *db.NamespacedKV) *csrfManager {
m := &csrfManager{
unique: unique,
prefix: prefix,
apiKeyValidator: apiKeyValidator,
next: next,
tokens: newTokenManager("csrfTokens", miscDB, maxCSRFTokenLifetime, maxActiveCSRFTokens),
}
return m
}
func (m *csrfManager) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Allow requests carrying a valid API key
if hasValidAPIKeyHeader(r, m.apiKeyValidator) {
// Set the access-control-allow-origin header for CORS requests
// since a valid API key has been provided
w.Header().Add("Access-Control-Allow-Origin", "*")
m.next.ServeHTTP(w, r)
return
}
if strings.HasPrefix(r.URL.Path, "/rest/debug") {
// Debugging functions are only available when explicitly
// enabled, and can be accessed without a CSRF token
m.next.ServeHTTP(w, r)
return
}
// Allow requests for anything not under the protected path prefix,
// and set a CSRF cookie if there isn't already a valid one.
if !strings.HasPrefix(r.URL.Path, m.prefix) {
cookie, err := r.Cookie("CSRF-Token-" + m.unique)
if err != nil || !m.tokens.Check(cookie.Value) {
l.Debugln("new CSRF cookie in response to request for", r.URL)
cookie = &http.Cookie{
Name: "CSRF-Token-" + m.unique,
Value: m.tokens.New(),
}
http.SetCookie(w, cookie)
}
m.next.ServeHTTP(w, r)
return
}
if isNoAuthPath(r.URL.Path) {
// REST calls that don't require authentication also do not
// need a CSRF token.
m.next.ServeHTTP(w, r)
return
}
// Verify the CSRF token
token := r.Header.Get("X-CSRF-Token-" + m.unique)
if !m.tokens.Check(token) {
http.Error(w, "CSRF Error", http.StatusForbidden)
return
}
m.next.ServeHTTP(w, r)
}
func hasValidAPIKeyHeader(r *http.Request, validator apiKeyValidator) bool {
if key := r.Header.Get("X-API-Key"); validator.IsValidAPIKey(key) {
return true
}
if auth := r.Header.Get("Authorization"); strings.HasPrefix(strings.ToLower(auth), "bearer ") {
bearerToken := auth[len("bearer "):]
return validator.IsValidAPIKey(bearerToken)
}
return false
}