Merge pull request #47740 from liggitt/websocket-protocol

Automatic merge from submit-queue

Add token authentication method for websocket browser clients

Closes #47967

Browser clients do not have the ability to set an `Authorization` header programatically on websocket requests. All they have control over is the URL and the websocket subprotocols sent (see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)

This PR adds support for specifying a bearer token via a websocket subprotocol, with the format `base64url.bearer.authorization.k8s.io.<encoded-token>`

1. The client must specify at least one other subprotocol, since the server must echo a selected subprotocol back
2. `<encoded-token>` is `base64url-without-padding(token)`

This enables web consoles to use websocket-based APIs (like watch, exec, logs, etc) using bearer token authentication.

For example, to authenticate with the bearer token `mytoken`, the client could do:
```js
var ws = new WebSocket(
  "wss://<server>/api/v1/namespaces/myns/pods/mypod/logs?follow=true",
  [
    "base64url.bearer.authorization.k8s.io.bXl0b2tlbg",
    "base64.binary.k8s.io"
  ]
);
```

This results in the following headers:
```
Sec-WebSocket-Protocol: base64url.bearer.authorization.k8s.io.bXl0b2tlbg, base64.binary.k8s.io
```

Which this authenticator would recognize as the token `mytoken`, and if authentication succeeded, hand off to the rest of the API server with the headers
```
Sec-WebSocket-Protocol: base64.binary.k8s.io
```

Base64-encoding the token is required, since bearer tokens can contain characters a websocket protocol may not (`/` and `=`)

```release-note
Websocket requests may now authenticate to the API server by passing a bearer token in a websocket subprotocol of the form `base64url.bearer.authorization.k8s.io.<base64url-encoded-bearer-token>`
```
This commit is contained in:
Kubernetes Submit Queue 2017-06-24 00:34:41 -07:00 committed by GitHub
commit 714f97d7ba
8 changed files with 385 additions and 16 deletions

View File

@ -21,6 +21,7 @@ go_library(
"//vendor/k8s.io/apiserver/pkg/authentication/request/bearertoken:go_default_library",
"//vendor/k8s.io/apiserver/pkg/authentication/request/headerrequest:go_default_library",
"//vendor/k8s.io/apiserver/pkg/authentication/request/union:go_default_library",
"//vendor/k8s.io/apiserver/pkg/authentication/request/websocket:go_default_library",
"//vendor/k8s.io/apiserver/pkg/authentication/request/x509:go_default_library",
"//vendor/k8s.io/apiserver/pkg/authentication/token/tokenfile:go_default_library",
"//vendor/k8s.io/apiserver/plugin/pkg/authenticator/password/keystone:go_default_library",

View File

@ -28,6 +28,7 @@ import (
"k8s.io/apiserver/pkg/authentication/request/bearertoken"
"k8s.io/apiserver/pkg/authentication/request/headerrequest"
"k8s.io/apiserver/pkg/authentication/request/union"
"k8s.io/apiserver/pkg/authentication/request/websocket"
"k8s.io/apiserver/pkg/authentication/request/x509"
"k8s.io/apiserver/pkg/authentication/token/tokenfile"
"k8s.io/apiserver/plugin/pkg/authenticator/password/keystone"
@ -126,7 +127,7 @@ func (config AuthenticatorConfig) New() (authenticator.Request, *spec.SecurityDe
if err != nil {
return nil, nil, err
}
authenticators = append(authenticators, tokenAuth)
authenticators = append(authenticators, bearertoken.New(tokenAuth), websocket.NewProtocolAuthenticator(tokenAuth))
hasTokenAuth = true
}
if len(config.ServiceAccountKeyFiles) > 0 {
@ -134,13 +135,13 @@ func (config AuthenticatorConfig) New() (authenticator.Request, *spec.SecurityDe
if err != nil {
return nil, nil, err
}
authenticators = append(authenticators, serviceAccountAuth)
authenticators = append(authenticators, bearertoken.New(serviceAccountAuth), websocket.NewProtocolAuthenticator(serviceAccountAuth))
hasTokenAuth = true
}
if config.BootstrapToken {
if config.BootstrapTokenAuthenticator != nil {
// TODO: This can sometimes be nil because of
authenticators = append(authenticators, bearertoken.New(config.BootstrapTokenAuthenticator))
authenticators = append(authenticators, bearertoken.New(config.BootstrapTokenAuthenticator), websocket.NewProtocolAuthenticator(config.BootstrapTokenAuthenticator))
hasTokenAuth = true
}
}
@ -155,7 +156,7 @@ func (config AuthenticatorConfig) New() (authenticator.Request, *spec.SecurityDe
if err != nil {
return nil, nil, err
}
authenticators = append(authenticators, oidcAuth)
authenticators = append(authenticators, bearertoken.New(oidcAuth), websocket.NewProtocolAuthenticator(oidcAuth))
hasTokenAuth = true
}
if len(config.WebhookTokenAuthnConfigFile) > 0 {
@ -163,13 +164,13 @@ func (config AuthenticatorConfig) New() (authenticator.Request, *spec.SecurityDe
if err != nil {
return nil, nil, err
}
authenticators = append(authenticators, webhookTokenAuth)
authenticators = append(authenticators, bearertoken.New(webhookTokenAuth), websocket.NewProtocolAuthenticator(webhookTokenAuth))
hasTokenAuth = true
}
// always add anytoken last, so that every other token authenticator gets to try first
if config.AnyToken {
authenticators = append(authenticators, bearertoken.New(anytoken.AnyTokenAuthenticator{}))
authenticators = append(authenticators, bearertoken.New(anytoken.AnyTokenAuthenticator{}), websocket.NewProtocolAuthenticator(anytoken.AnyTokenAuthenticator{}))
hasTokenAuth = true
}
@ -234,17 +235,17 @@ func newAuthenticatorFromBasicAuthFile(basicAuthFile string) (authenticator.Requ
}
// newAuthenticatorFromTokenFile returns an authenticator.Request or an error
func newAuthenticatorFromTokenFile(tokenAuthFile string) (authenticator.Request, error) {
func newAuthenticatorFromTokenFile(tokenAuthFile string) (authenticator.Token, error) {
tokenAuthenticator, err := tokenfile.NewCSV(tokenAuthFile)
if err != nil {
return nil, err
}
return bearertoken.New(tokenAuthenticator), nil
return tokenAuthenticator, nil
}
// newAuthenticatorFromOIDCIssuerURL returns an authenticator.Request or an error.
func newAuthenticatorFromOIDCIssuerURL(issuerURL, clientID, caFile, usernameClaim, groupsClaim string) (authenticator.Request, error) {
func newAuthenticatorFromOIDCIssuerURL(issuerURL, clientID, caFile, usernameClaim, groupsClaim string) (authenticator.Token, error) {
tokenAuthenticator, err := oidc.New(oidc.OIDCOptions{
IssuerURL: issuerURL,
ClientID: clientID,
@ -256,11 +257,11 @@ func newAuthenticatorFromOIDCIssuerURL(issuerURL, clientID, caFile, usernameClai
return nil, err
}
return bearertoken.New(tokenAuthenticator), nil
return tokenAuthenticator, nil
}
// newServiceAccountAuthenticator returns an authenticator.Request or an error
func newServiceAccountAuthenticator(keyfiles []string, lookup bool, serviceAccountGetter serviceaccount.ServiceAccountTokenGetter) (authenticator.Request, error) {
func newServiceAccountAuthenticator(keyfiles []string, lookup bool, serviceAccountGetter serviceaccount.ServiceAccountTokenGetter) (authenticator.Token, error) {
allPublicKeys := []interface{}{}
for _, keyfile := range keyfiles {
publicKeys, err := serviceaccount.ReadPublicKeys(keyfile)
@ -271,7 +272,7 @@ func newServiceAccountAuthenticator(keyfiles []string, lookup bool, serviceAccou
}
tokenAuthenticator := serviceaccount.JWTTokenAuthenticator(allPublicKeys, lookup, serviceAccountGetter)
return bearertoken.New(tokenAuthenticator), nil
return tokenAuthenticator, nil
}
// newAuthenticatorFromClientCAFile returns an authenticator.Request or an error
@ -297,11 +298,11 @@ func newAuthenticatorFromKeystoneURL(keystoneURL string, keystoneCAFile string)
return basicauth.New(keystoneAuthenticator), nil
}
func newWebhookTokenAuthenticator(webhookConfigFile string, ttl time.Duration) (authenticator.Request, error) {
func newWebhookTokenAuthenticator(webhookConfigFile string, ttl time.Duration) (authenticator.Token, error) {
webhookTokenAuthenticator, err := webhook.New(webhookConfigFile, ttl)
if err != nil {
return nil, err
}
return bearertoken.New(webhookTokenAuthenticator), nil
return webhookTokenAuthenticator, nil
}

View File

@ -23,6 +23,7 @@ go_library(
"//vendor/k8s.io/apiserver/pkg/authentication/request/bearertoken:go_default_library",
"//vendor/k8s.io/apiserver/pkg/authentication/request/headerrequest:go_default_library",
"//vendor/k8s.io/apiserver/pkg/authentication/request/union:go_default_library",
"//vendor/k8s.io/apiserver/pkg/authentication/request/websocket:go_default_library",
"//vendor/k8s.io/apiserver/pkg/authentication/request/x509:go_default_library",
"//vendor/k8s.io/apiserver/pkg/authentication/token/tokenfile:go_default_library",
"//vendor/k8s.io/apiserver/pkg/authentication/user:go_default_library",

View File

@ -29,6 +29,7 @@ import (
"k8s.io/apiserver/pkg/authentication/request/bearertoken"
"k8s.io/apiserver/pkg/authentication/request/headerrequest"
unionauth "k8s.io/apiserver/pkg/authentication/request/union"
"k8s.io/apiserver/pkg/authentication/request/websocket"
"k8s.io/apiserver/pkg/authentication/request/x509"
webhooktoken "k8s.io/apiserver/plugin/pkg/authenticator/token/webhook"
authenticationclient "k8s.io/client-go/kubernetes/typed/authentication/v1beta1"
@ -87,7 +88,7 @@ func (c DelegatingAuthenticatorConfig) New() (authenticator.Request, *spec.Secur
if err != nil {
return nil, nil, err
}
authenticators = append(authenticators, bearertoken.New(tokenAuth))
authenticators = append(authenticators, bearertoken.New(tokenAuth), websocket.NewProtocolAuthenticator(tokenAuth))
securityDefinitions["BearerToken"] = &spec.SecurityScheme{
SecuritySchemeProps: spec.SecuritySchemeProps{

View File

@ -0,0 +1,31 @@
package(default_visibility = ["//visibility:public"])
licenses(["notice"])
load(
"@io_bazel_rules_go//go:def.bzl",
"go_library",
"go_test",
)
go_library(
name = "go_default_library",
srcs = ["protocol.go"],
tags = ["automanaged"],
deps = [
"//vendor/k8s.io/apiserver/pkg/authentication/authenticator:go_default_library",
"//vendor/k8s.io/apiserver/pkg/authentication/user:go_default_library",
"//vendor/k8s.io/apiserver/pkg/util/wsstream:go_default_library",
],
)
go_test(
name = "go_default_test",
srcs = ["protocol_test.go"],
library = ":go_default_library",
tags = ["automanaged"],
deps = [
"//vendor/k8s.io/apiserver/pkg/authentication/authenticator:go_default_library",
"//vendor/k8s.io/apiserver/pkg/authentication/user:go_default_library",
],
)

View File

@ -0,0 +1,109 @@
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package websocket
import (
"encoding/base64"
"errors"
"net/http"
"net/textproto"
"strings"
"unicode/utf8"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/apiserver/pkg/util/wsstream"
)
const bearerProtocolPrefix = "base64url.bearer.authorization.k8s.io."
var protocolHeader = textproto.CanonicalMIMEHeaderKey("Sec-WebSocket-Protocol")
var invalidToken = errors.New("invalid bearer token")
// ProtocolAuthenticator allows a websocket connection to provide a bearer token as a subprotocol
// in the format "base64url.bearer.authorization.<base64url-without-padding(bearer-token)>"
type ProtocolAuthenticator struct {
// auth is the token authenticator to use to validate the token
auth authenticator.Token
}
func NewProtocolAuthenticator(auth authenticator.Token) *ProtocolAuthenticator {
return &ProtocolAuthenticator{auth}
}
func (a *ProtocolAuthenticator) AuthenticateRequest(req *http.Request) (user.Info, bool, error) {
// Only accept websocket connections
if !wsstream.IsWebSocketRequest(req) {
return nil, false, nil
}
token := ""
sawTokenProtocol := false
filteredProtocols := []string{}
for _, protocolHeader := range req.Header[protocolHeader] {
for _, protocol := range strings.Split(protocolHeader, ",") {
protocol = strings.TrimSpace(protocol)
if !strings.HasPrefix(protocol, bearerProtocolPrefix) {
filteredProtocols = append(filteredProtocols, protocol)
continue
}
if sawTokenProtocol {
return nil, false, errors.New("multiple base64.bearer.authorization tokens specified")
}
sawTokenProtocol = true
encodedToken := strings.TrimPrefix(protocol, bearerProtocolPrefix)
decodedToken, err := base64.RawURLEncoding.DecodeString(encodedToken)
if err != nil {
return nil, false, errors.New("invalid base64.bearer.authorization token encoding")
}
if !utf8.Valid(decodedToken) {
return nil, false, errors.New("invalid base64.bearer.authorization token")
}
token = string(decodedToken)
}
}
// Must pass at least one other subprotocol so that we can remove the one containing the bearer token,
// and there is at least one to echo back to the client
if len(token) > 0 && len(filteredProtocols) == 0 {
return nil, false, errors.New("missing additional subprotocol")
}
if len(token) == 0 {
return nil, false, nil
}
user, ok, err := a.auth.AuthenticateToken(token)
// on success, remove the protocol with the token
if ok {
// https://tools.ietf.org/html/rfc6455#section-11.3.4 indicates the Sec-WebSocket-Protocol header may appear multiple times
// in a request, and is logically the same as a single Sec-WebSocket-Protocol header field that contains all values
req.Header.Set(protocolHeader, strings.Join(filteredProtocols, ","))
}
// If the token authenticator didn't error, provide a default error
if !ok && err == nil {
err = invalidToken
}
return user, ok, err
}

View File

@ -0,0 +1,222 @@
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package websocket
import (
"errors"
"net/http"
"reflect"
"testing"
"k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/authentication/user"
)
func TestAuthenticateRequest(t *testing.T) {
auth := NewProtocolAuthenticator(authenticator.TokenFunc(func(token string) (user.Info, bool, error) {
if token != "token" {
t.Errorf("unexpected token: %s", token)
}
return &user.DefaultInfo{Name: "user"}, true, nil
}))
user, ok, err := auth.AuthenticateRequest(&http.Request{
Header: http.Header{
"Connection": []string{"upgrade"},
"Upgrade": []string{"websocket"},
"Sec-Websocket-Protocol": []string{"base64url.bearer.authorization.k8s.io.dG9rZW4,dummy"},
},
})
if !ok || user == nil || err != nil {
t.Errorf("expected valid user")
}
}
func TestAuthenticateRequestTokenInvalid(t *testing.T) {
auth := NewProtocolAuthenticator(authenticator.TokenFunc(func(token string) (user.Info, bool, error) {
return nil, false, nil
}))
user, ok, err := auth.AuthenticateRequest(&http.Request{
Header: http.Header{
"Connection": []string{"upgrade"},
"Upgrade": []string{"websocket"},
"Sec-Websocket-Protocol": []string{"base64url.bearer.authorization.k8s.io.dG9rZW4,dummy"},
},
})
if ok || user != nil {
t.Errorf("expected not authenticated user")
}
if err != invalidToken {
t.Errorf("expected invalidToken error, got %v", err)
}
}
func TestAuthenticateRequestTokenInvalidCustomError(t *testing.T) {
customError := errors.New("custom")
auth := NewProtocolAuthenticator(authenticator.TokenFunc(func(token string) (user.Info, bool, error) {
return nil, false, customError
}))
user, ok, err := auth.AuthenticateRequest(&http.Request{
Header: http.Header{
"Connection": []string{"upgrade"},
"Upgrade": []string{"websocket"},
"Sec-Websocket-Protocol": []string{"base64url.bearer.authorization.k8s.io.dG9rZW4,dummy"},
},
})
if ok || user != nil {
t.Errorf("expected not authenticated user")
}
if err != customError {
t.Errorf("expected custom error, got %v", err)
}
}
func TestAuthenticateRequestTokenError(t *testing.T) {
auth := NewProtocolAuthenticator(authenticator.TokenFunc(func(token string) (user.Info, bool, error) {
return nil, false, errors.New("error")
}))
user, ok, err := auth.AuthenticateRequest(&http.Request{
Header: http.Header{
"Connection": []string{"upgrade"},
"Upgrade": []string{"websocket"},
"Sec-Websocket-Protocol": []string{"base64url.bearer.authorization.k8s.io.dG9rZW4,dummy"},
},
})
if ok || user != nil || err == nil {
t.Errorf("expected error")
}
}
func TestAuthenticateRequestBadValue(t *testing.T) {
testCases := []struct {
Req *http.Request
}{
{Req: &http.Request{}},
{Req: &http.Request{Header: http.Header{
"Connection": []string{"upgrade"},
"Upgrade": []string{"websocket"},
"Sec-Websocket-Protocol": []string{"other-protocol"}}},
},
{Req: &http.Request{Header: http.Header{
"Connection": []string{"upgrade"},
"Upgrade": []string{"websocket"},
"Sec-Websocket-Protocol": []string{"base64url.bearer.authorization.k8s.io."}}},
},
}
for i, testCase := range testCases {
auth := NewProtocolAuthenticator(authenticator.TokenFunc(func(token string) (user.Info, bool, error) {
t.Errorf("authentication should not have been called")
return nil, false, nil
}))
user, ok, err := auth.AuthenticateRequest(testCase.Req)
if ok || user != nil || err != nil {
t.Errorf("%d: expected not authenticated (no token)", i)
}
}
}
func TestBearerToken(t *testing.T) {
tests := map[string]struct {
ProtocolHeaders []string
TokenAuth authenticator.Token
ExpectedUserName string
ExpectedOK bool
ExpectedErr bool
ExpectedProtocolHeaders []string
}{
"no header": {
ProtocolHeaders: nil,
ExpectedUserName: "",
ExpectedOK: false,
ExpectedErr: false,
ExpectedProtocolHeaders: nil,
},
"empty header": {
ProtocolHeaders: []string{""},
ExpectedUserName: "",
ExpectedOK: false,
ExpectedErr: false,
ExpectedProtocolHeaders: []string{""},
},
"non-bearer header": {
ProtocolHeaders: []string{"undefined"},
ExpectedUserName: "",
ExpectedOK: false,
ExpectedErr: false,
ExpectedProtocolHeaders: []string{"undefined"},
},
"empty bearer token": {
ProtocolHeaders: []string{"base64url.bearer.authorization.k8s.io."},
ExpectedUserName: "",
ExpectedOK: false,
ExpectedErr: false,
ExpectedProtocolHeaders: []string{"base64url.bearer.authorization.k8s.io."},
},
"valid bearer token removing header": {
ProtocolHeaders: []string{"base64url.bearer.authorization.k8s.io.dG9rZW4", "dummy, dummy2"},
TokenAuth: authenticator.TokenFunc(func(t string) (user.Info, bool, error) { return &user.DefaultInfo{Name: "myuser"}, true, nil }),
ExpectedUserName: "myuser",
ExpectedOK: true,
ExpectedErr: false,
ExpectedProtocolHeaders: []string{"dummy,dummy2"},
},
"invalid bearer token": {
ProtocolHeaders: []string{"base64url.bearer.authorization.k8s.io.dG9rZW4,dummy"},
TokenAuth: authenticator.TokenFunc(func(t string) (user.Info, bool, error) { return nil, false, nil }),
ExpectedUserName: "",
ExpectedOK: false,
ExpectedErr: true,
ExpectedProtocolHeaders: []string{"base64url.bearer.authorization.k8s.io.dG9rZW4,dummy"},
},
"error bearer token": {
ProtocolHeaders: []string{"base64url.bearer.authorization.k8s.io.dG9rZW4,dummy"},
TokenAuth: authenticator.TokenFunc(func(t string) (user.Info, bool, error) { return nil, false, errors.New("error") }),
ExpectedUserName: "",
ExpectedOK: false,
ExpectedErr: true,
ExpectedProtocolHeaders: []string{"base64url.bearer.authorization.k8s.io.dG9rZW4,dummy"},
},
}
for k, tc := range tests {
req, _ := http.NewRequest("GET", "/", nil)
req.Header.Set("Connection", "upgrade")
req.Header.Set("Upgrade", "websocket")
for _, h := range tc.ProtocolHeaders {
req.Header.Add("Sec-Websocket-Protocol", h)
}
bearerAuth := NewProtocolAuthenticator(tc.TokenAuth)
u, ok, err := bearerAuth.AuthenticateRequest(req)
if tc.ExpectedErr != (err != nil) {
t.Errorf("%s: Expected err=%v, got %v", k, tc.ExpectedErr, err)
continue
}
if ok != tc.ExpectedOK {
t.Errorf("%s: Expected ok=%v, got %v", k, tc.ExpectedOK, ok)
continue
}
if ok && u.GetName() != tc.ExpectedUserName {
t.Errorf("%s: Expected username=%v, got %v", k, tc.ExpectedUserName, u.GetName())
continue
}
if !reflect.DeepEqual(req.Header["Sec-Websocket-Protocol"], tc.ExpectedProtocolHeaders) {
t.Errorf("%s: Expected headers=%#v, got %#v", k, tc.ExpectedProtocolHeaders, req.Header["Sec-Websocket-Protocol"])
continue
}
}
}

View File

@ -87,7 +87,10 @@ var (
// IsWebSocketRequest returns true if the incoming request contains connection upgrade headers
// for WebSockets.
func IsWebSocketRequest(req *http.Request) bool {
return connectionUpgradeRegex.MatchString(strings.ToLower(req.Header.Get("Connection"))) && strings.ToLower(req.Header.Get("Upgrade")) == "websocket"
if !strings.EqualFold(req.Header.Get("Upgrade"), "websocket") {
return false
}
return connectionUpgradeRegex.MatchString(strings.ToLower(req.Header.Get("Connection")))
}
// IgnoreReceives reads from a WebSocket until it is closed, then returns. If timeout is set, the