diff --git a/cli/command_server_start.go b/cli/command_server_start.go index 3d816bed3..65beb4833 100644 --- a/cli/command_server_start.go +++ b/cli/command_server_start.go @@ -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") diff --git a/go.mod b/go.mod index 124ae1eb4..28450b67e 100644 --- a/go.mod +++ b/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 diff --git a/internal/apiclient/apiclient.go b/internal/apiclient/apiclient.go index 1a98908f6..a3b85c48e 100644 --- a/internal/apiclient/apiclient.go +++ b/internal/apiclient/apiclient.go @@ -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 } diff --git a/internal/server/server.go b/internal/server/server.go index c76cbb9db..6e968f311 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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