server: avoid password hashing by using short-lived JWT tokens (#857)

Tokens encode the authenticated user, last for 1 minute and are signed
with HMAC-SHA-256. This improves HTTP server performance by a lot:

BEFORE: 168383 files (6.4 GB) - 3m38s
AFTER: 168383 files (6.4 GB) - 1m37s
This commit is contained in:
Jarek Kowalski
2021-03-01 06:17:06 -08:00
committed by GitHub
parent ac9f85967a
commit 9620b57e35
4 changed files with 113 additions and 32 deletions

View File

@@ -41,6 +41,8 @@
serverStartHtpasswdFile = serverStartCommand.Flag("htpasswd-file", "Path to htpasswd file that contains allowed user@hostname entries").Hidden().ExistingFile()
serverStartAllowRepoUsers = serverStartCommand.Flag("allow-repository-users", "Allow users defined in the repository to connect").Bool()
serverAuthCookieSingingKey = serverStartCommand.Flag("auth-cookie-signing-key", "Force particular auth cookie signing key").Envar("KOPIA_AUTH_COOKIE_SIGNING_KEY").Hidden().String()
serverStartShutdownWhenStdinClosed = serverStartCommand.Flag("shutdown-on-stdin", "Shut down the server when stdin handle has closed.").Hidden().Bool()
)
@@ -59,12 +61,13 @@ func runServer(ctx context.Context, rep repo.Repository) error {
}
srv, err := server.New(ctx, server.Options{
ConfigFile: repositoryConfigFileName(),
ConnectOptions: connectOptions(),
RefreshInterval: *serverStartRefreshInterval,
MaxConcurrency: *serverStartMaxConcurrency,
Authenticator: authn,
Authorizer: auth.LegacyAuthorizerForUser,
ConfigFile: repositoryConfigFileName(),
ConnectOptions: connectOptions(),
RefreshInterval: *serverStartRefreshInterval,
MaxConcurrency: *serverStartMaxConcurrency,
Authenticator: authn,
Authorizer: auth.LegacyAuthorizerForUser,
AuthCookieSigningKey: *serverAuthCookieSingingKey,
})
if err != nil {
return errors.Wrap(err, "unable to initialize server")

1
go.mod
View File

@@ -12,6 +12,7 @@ require (
github.com/aws/aws-sdk-go v1.34.29
github.com/bgentry/speakeasy v0.1.0
github.com/chmduquesne/rollinghash v4.0.0+incompatible
github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/efarrer/iothrottler v0.0.1
github.com/fatih/color v1.9.0
github.com/foomo/htpasswd v0.0.0-20200116085101-e3a90e78da9c

View File

@@ -8,6 +8,7 @@
"io"
"io/ioutil"
"net/http"
"net/http/cookiejar"
"github.com/pkg/errors"
@@ -149,9 +150,15 @@ func NewKopiaAPIClient(options Options) (*KopiaAPIClient, error) {
transport = loggingTransport{transport}
}
cj, err := cookiejar.New(nil)
if err != nil {
return nil, errors.Wrap(err, "unable to create cookie jar")
}
return &KopiaAPIClient{
options.BaseURL + "/api/v1/",
&http.Client{
Jar: cj,
Transport: transport,
},
}, nil
@@ -182,7 +189,7 @@ func (t loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return nil, errors.Wrap(err, "round-trip error")
}
log(req.Context()).Debugf("%v %v took %v and returned %v", req.Method, req.URL, clock.Since(t0), resp.Status)
log(req.Context()).Debugf("%v %v took %v and returned %v with cookies %v", req.Method, req.URL, clock.Since(t0), resp.Status, resp.Cookies())
return resp, nil
}

View File

@@ -10,10 +10,13 @@
"sync"
"time"
"github.com/dgrijalva/jwt-go"
"github.com/google/uuid"
"github.com/gorilla/mux"
"github.com/pkg/errors"
"github.com/kopia/kopia/internal/auth"
"github.com/kopia/kopia/internal/clock"
"github.com/kopia/kopia/internal/serverapi"
"github.com/kopia/kopia/internal/uitask"
"github.com/kopia/kopia/repo"
@@ -26,7 +29,13 @@
var log = logging.GetContextLoggerFunc("kopia/server")
const maintenanceAttemptFrequency = 10 * time.Minute
const (
maintenanceAttemptFrequency = 10 * time.Minute
kopiaAuthCookie = "Kopia-Auth"
kopiaAuthCookieTTL = 1 * time.Minute
kopiaAuthCookieAudience = "kopia"
kopiaAuthCookieIssuer = "kopia-server"
)
type apiRequestFunc func(ctx context.Context, r *http.Request, body []byte) (interface{}, *apiError)
@@ -50,6 +59,8 @@ type Server struct {
taskmgr *uitask.Manager
authCookieSigningKey []byte
grpcServerState
}
@@ -119,26 +130,77 @@ func (s *Server) APIHandlers(legacyAPI bool) http.Handler {
}
func (s *Server) isAuthenticated(w http.ResponseWriter, r *http.Request) bool {
if s.authenticator != nil {
username, password, ok := r.BasicAuth()
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="Kopia"`)
http.Error(w, "Missing credentials.\n", http.StatusUnauthorized)
if s.authenticator == nil {
return true
}
return false
}
username, password, ok := r.BasicAuth()
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="Kopia"`)
http.Error(w, "Missing credentials.\n", http.StatusUnauthorized)
if !s.authenticator(r.Context(), s.rep, username, password) {
w.Header().Set("WWW-Authenticate", `Basic realm="Kopia"`)
http.Error(w, "Access denied.\n", http.StatusUnauthorized)
return false
}
return false
if c, err := r.Cookie(kopiaAuthCookie); err == nil && c != nil {
if s.isAuthCookieValid(username, c.Value) {
// found a short-term JWT cookie that matches given username, trust it.
// this avoids potentially expensive password hashing inside the authenticator.
return true
}
}
if !s.authenticator(r.Context(), s.rep, username, password) {
w.Header().Set("WWW-Authenticate", `Basic realm="Kopia"`)
http.Error(w, "Access denied.\n", http.StatusUnauthorized)
return false
}
now := clock.Now()
ac, err := s.generateShortTermAuthCookie(username, now)
if err != nil {
log(r.Context()).Warningf("unable to generate short-term auth cookie: %v", err)
} else {
http.SetCookie(w, &http.Cookie{
Name: kopiaAuthCookie,
Value: ac,
Expires: now.Add(kopiaAuthCookieTTL),
})
}
return true
}
func (s *Server) isAuthCookieValid(username, cookieValue string) bool {
tok, err := jwt.ParseWithClaims(cookieValue, &jwt.StandardClaims{}, func(t *jwt.Token) (interface{}, error) {
return s.authCookieSigningKey, nil
})
if err != nil {
return false
}
sc, ok := tok.Claims.(*jwt.StandardClaims)
if !ok {
return false
}
return sc.Subject == username
}
func (s *Server) generateShortTermAuthCookie(username string, now time.Time) (string, error) {
return jwt.NewWithClaims(jwt.SigningMethodHS256, &jwt.StandardClaims{
Subject: username,
NotBefore: now.Add(-time.Minute).Unix(),
ExpiresAt: now.Add(kopiaAuthCookieTTL).Unix(),
IssuedAt: now.Unix(),
Audience: kopiaAuthCookieAudience,
Id: uuid.New().String(),
Issuer: kopiaAuthCookieIssuer,
}).SignedString(s.authCookieSigningKey)
}
func (s *Server) requireAuth(f http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.isAuthenticated(w, r) {
@@ -475,12 +537,13 @@ func (s *Server) syncSourcesLocked(ctx context.Context) error {
// Options encompasses all API server options.
type Options struct {
ConfigFile string
ConnectOptions *repo.ConnectOptions
RefreshInterval time.Duration
MaxConcurrency int
Authenticator auth.Authenticator
Authorizer auth.AuthorizerFunc
ConfigFile string
ConnectOptions *repo.ConnectOptions
RefreshInterval time.Duration
MaxConcurrency int
Authenticator auth.Authenticator
Authorizer auth.AuthorizerFunc
AuthCookieSigningKey string
}
// New creates a Server.
@@ -490,14 +553,21 @@ func New(ctx context.Context, options Options) (*Server, error) {
return nil, errors.Errorf("missing authorizer")
}
if options.AuthCookieSigningKey == "" {
// generate random signing key
options.AuthCookieSigningKey = uuid.New().String()
log(ctx).Debugf("generated random auth cookie signing key: %v", options.AuthCookieSigningKey)
}
s := &Server{
options: options,
sourceManagers: map[snapshot.SourceInfo]*sourceManager{},
uploadSemaphore: make(chan struct{}, 1),
grpcServerState: makeGRPCServerState(options.MaxConcurrency),
authenticator: options.Authenticator,
authorizer: options.Authorizer,
taskmgr: uitask.NewManager(),
options: options,
sourceManagers: map[snapshot.SourceInfo]*sourceManager{},
uploadSemaphore: make(chan struct{}, 1),
grpcServerState: makeGRPCServerState(options.MaxConcurrency),
authenticator: options.Authenticator,
authorizer: options.Authorizer,
taskmgr: uitask.NewManager(),
authCookieSigningKey: []byte(options.AuthCookieSigningKey),
}
return s, nil