mirror of
https://github.com/kopia/kopia.git
synced 2026-05-14 01:37:07 -04:00
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:
@@ -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
1
go.mod
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user