mirror of https://github.com/hashicorp/boundary
Tools for auth token interception and authentication (#152)
parent
f69d6b9952
commit
c44a4c4795
@ -1,11 +1,13 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/hashicorp/watchtower/internal/authtoken"
|
||||
"github.com/hashicorp/watchtower/internal/host/static"
|
||||
"github.com/hashicorp/watchtower/internal/iam"
|
||||
)
|
||||
|
||||
type (
|
||||
IamRepoFactory func() (*iam.Repository, error)
|
||||
StaticRepoFactory func() (*static.Repository, error)
|
||||
IamRepoFactory func() (*iam.Repository, error)
|
||||
StaticRepoFactory func() (*static.Repository, error)
|
||||
AuthTokenRepoFactory func() (*authtoken.Repository, error)
|
||||
)
|
||||
|
||||
@ -0,0 +1,182 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/hashicorp/go-hclog"
|
||||
"github.com/hashicorp/watchtower/internal/servers/controller/common"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
const (
|
||||
headerAuthMethod = "Authorization"
|
||||
httpOnlyCookieName = "wt-http-token-cookie"
|
||||
jsVisibleCookieName = "wt-js-token-cookie"
|
||||
)
|
||||
|
||||
// TokenAuthenticator returns a function that can be used in grpc-gateway's runtime.WithMetadata ServerOption.
|
||||
// It looks at the cookies and headers of the incoming request and returns metadata that can later be
|
||||
// used by handlers to build a TokenMetadata using the ToTokenMetadata function.
|
||||
func TokenAuthenticator(l hclog.Logger, tokenRepo common.AuthTokenRepoFactory) func(context.Context, *http.Request) metadata.MD {
|
||||
return func(ctx context.Context, req *http.Request) metadata.MD {
|
||||
tMD := TokenMetadata{}
|
||||
if authHeader := req.Header.Get(headerAuthMethod); authHeader != "" {
|
||||
headerSplit := strings.SplitN(strings.TrimSpace(authHeader), " ", 2)
|
||||
if len(headerSplit) == 2 && strings.EqualFold(strings.TrimSpace(headerSplit[0]), "bearer") {
|
||||
tMD.recievedTokenType = authTokenTypeBearer
|
||||
tMD.bearerPayload = strings.TrimSpace(headerSplit[1])
|
||||
}
|
||||
}
|
||||
if tMD.recievedTokenType != authTokenTypeBearer {
|
||||
if hc, err := req.Cookie(httpOnlyCookieName); err == nil {
|
||||
tMD.httpCookiePayload = hc.Value
|
||||
}
|
||||
if jc, err := req.Cookie(jsVisibleCookieName); err == nil {
|
||||
tMD.jsCookiePayload = jc.Value
|
||||
}
|
||||
if tMD.httpCookiePayload != "" && tMD.jsCookiePayload != "" {
|
||||
tMD.recievedTokenType = authTokenTypeSplitCookie
|
||||
}
|
||||
}
|
||||
|
||||
repo, err := tokenRepo()
|
||||
if err != nil {
|
||||
l.Error("failed to get authtoken repo", "error", err)
|
||||
return tMD.toMetadata()
|
||||
}
|
||||
at, err := repo.ValidateToken(ctx, tMD.publicId(), tMD.token())
|
||||
if err != nil {
|
||||
l.Error("failed to validate token", "error", err)
|
||||
}
|
||||
if at != nil {
|
||||
tMD.UserId = at.GetIamUserId()
|
||||
}
|
||||
|
||||
return tMD.toMetadata()
|
||||
}
|
||||
}
|
||||
|
||||
type tokenFormat int
|
||||
|
||||
const (
|
||||
authTokenTypeUnknown tokenFormat = iota
|
||||
authTokenTypeBearer
|
||||
authTokenTypeSplitCookie
|
||||
)
|
||||
|
||||
// TokenMetadata allows easy writing/reading of tokens to clients and authenticating the provided token.
|
||||
// Expected usage for authorization is
|
||||
// func (s *Service) GetResource(ctx context.Context, req GetResourceRequest) (GetResourceResponse, error) {
|
||||
// amd := handlers.ToTokenMetadata(ctx)
|
||||
// if !authorizer.isAuthorized(amd.UserId, "ReadResource", req.GetId()) { return nil, UnauthorizedError }
|
||||
// ...
|
||||
//
|
||||
// A new token will be created by the Authenticate method on an Organization. The token value will be returned
|
||||
// through json and not be intercepted by these tools.
|
||||
// TODO: Intercept the outgoing Authenticate/Deauthenticate response and manipulate
|
||||
// the response if the token type was cookie.
|
||||
type TokenMetadata struct {
|
||||
// Only set the UserId if the token was found and was not expired.
|
||||
UserId string
|
||||
|
||||
recievedTokenType tokenFormat
|
||||
bearerPayload string
|
||||
|
||||
jsCookiePayload string
|
||||
httpCookiePayload string
|
||||
}
|
||||
|
||||
const (
|
||||
mdAuthTokenUserKey = "wt-authtoken-user-key"
|
||||
mdAuthTokenBearerTokenKey = "wt-authtoken-bearer-token-key"
|
||||
mdAuthTokenHttpTokenKey = "wt-authtoken-http-token-key"
|
||||
mdAuthTokenJsTokenKey = "wt-authtoken-js-token-key"
|
||||
mdAuthTokenTypeKey = "wt-authtoken-type-key"
|
||||
)
|
||||
|
||||
// ToTokenMetadata takes an incoming context and builds a TokenMetadata based on the metadata attached to it.
|
||||
// If the context has no TokenMetadata attached to it an empty TokenMetadata is returned.
|
||||
func ToTokenMetadata(ctx context.Context) TokenMetadata {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return TokenMetadata{}
|
||||
}
|
||||
tMD := TokenMetadata{}
|
||||
if uid := md.Get(mdAuthTokenUserKey); len(uid) > 0 {
|
||||
tMD.UserId = uid[0]
|
||||
}
|
||||
if token := md.Get(mdAuthTokenBearerTokenKey); len(token) > 0 {
|
||||
tMD.bearerPayload = token[0]
|
||||
}
|
||||
if token := md.Get(mdAuthTokenHttpTokenKey); len(token) > 0 {
|
||||
tMD.httpCookiePayload = token[0]
|
||||
}
|
||||
if token := md.Get(mdAuthTokenJsTokenKey); len(token) > 0 {
|
||||
tMD.jsCookiePayload = token[0]
|
||||
}
|
||||
if sType := md.Get(mdAuthTokenTypeKey); len(sType) > 0 {
|
||||
if st, err := strconv.Atoi(sType[0]); err == nil {
|
||||
tMD.recievedTokenType = tokenFormat(st)
|
||||
}
|
||||
}
|
||||
|
||||
return tMD
|
||||
}
|
||||
|
||||
func (s TokenMetadata) toMetadata() metadata.MD {
|
||||
md := metadata.MD{}
|
||||
if s.UserId != "" {
|
||||
md.Set(mdAuthTokenUserKey, s.UserId)
|
||||
}
|
||||
if s.bearerPayload != "" {
|
||||
md.Set(mdAuthTokenBearerTokenKey, s.bearerPayload)
|
||||
}
|
||||
if s.httpCookiePayload != "" {
|
||||
md.Set(mdAuthTokenHttpTokenKey, s.httpCookiePayload)
|
||||
}
|
||||
if s.jsCookiePayload != "" {
|
||||
md.Set(mdAuthTokenJsTokenKey, s.jsCookiePayload)
|
||||
}
|
||||
if s.recievedTokenType != authTokenTypeUnknown {
|
||||
md.Set(mdAuthTokenTypeKey, fmt.Sprint(s.recievedTokenType))
|
||||
}
|
||||
return md
|
||||
}
|
||||
|
||||
// publicId returns the public id parsed out of the provided auth token. If the provided auth token
|
||||
// is malformed then this returns an empty string.
|
||||
func (s TokenMetadata) publicId() string {
|
||||
tok := ""
|
||||
switch s.recievedTokenType {
|
||||
case authTokenTypeBearer:
|
||||
tok = s.bearerPayload
|
||||
case authTokenTypeSplitCookie:
|
||||
tok = s.jsCookiePayload + s.httpCookiePayload
|
||||
}
|
||||
l := strings.Split(tok, "_")[:strings.Count(tok, "_")]
|
||||
if len(l) != 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(l, "_")
|
||||
}
|
||||
|
||||
// token returns the token value parsed out of the provided auth token. If the provided auth token
|
||||
// is malformed then this returns an empty string.
|
||||
func (s TokenMetadata) token() string {
|
||||
var tok string
|
||||
switch s.recievedTokenType {
|
||||
case authTokenTypeBearer:
|
||||
tok = s.bearerPayload
|
||||
case authTokenTypeSplitCookie:
|
||||
tok = s.jsCookiePayload + s.httpCookiePayload
|
||||
}
|
||||
l := strings.Split(tok, "_")
|
||||
if len(l) != 3 {
|
||||
return ""
|
||||
}
|
||||
return l[2]
|
||||
}
|
||||
@ -0,0 +1,194 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/grpc-ecosystem/grpc-gateway/runtime"
|
||||
"github.com/hashicorp/go-hclog"
|
||||
"github.com/hashicorp/watchtower/internal/authtoken"
|
||||
"github.com/hashicorp/watchtower/internal/db"
|
||||
"github.com/hashicorp/watchtower/internal/gen/controller/api/services"
|
||||
pbs "github.com/hashicorp/watchtower/internal/gen/controller/api/services"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Any generated service would do, but using organization since the path is the shortest for testing.
|
||||
type fakeHandler struct {
|
||||
pbs.UnimplementedOrganizationServiceServer
|
||||
validateFn func(context.Context)
|
||||
}
|
||||
|
||||
func (s *fakeHandler) GetOrganization(ctx context.Context, _ *pbs.GetOrganizationRequest) (*pbs.GetOrganizationResponse, error) {
|
||||
s.validateFn(ctx)
|
||||
return nil, errors.New("Doesn't matter this is just for testing input.")
|
||||
}
|
||||
|
||||
func TestAuthTokenPublicIdTokenValue(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in TokenMetadata
|
||||
wantId string
|
||||
wantToken string
|
||||
}{
|
||||
{
|
||||
name: "no delimeter",
|
||||
in: TokenMetadata{
|
||||
recievedTokenType: authTokenTypeBearer,
|
||||
bearerPayload: "prefix_publicid_token",
|
||||
jsCookiePayload: "this_is_just_junk",
|
||||
httpCookiePayload: "this_can_be_ignored",
|
||||
},
|
||||
wantId: "prefix_publicid",
|
||||
wantToken: "token",
|
||||
},
|
||||
{
|
||||
name: "no delimeter",
|
||||
in: TokenMetadata{
|
||||
recievedTokenType: authTokenTypeSplitCookie,
|
||||
bearerPayload: "this_is_just_junk_that_should_be_ignored",
|
||||
jsCookiePayload: "prefix_publicid_token",
|
||||
httpCookiePayload: "cookiepayload",
|
||||
},
|
||||
wantId: "prefix_publicid",
|
||||
wantToken: "tokencookiepayload",
|
||||
},
|
||||
{
|
||||
name: "no delimeter",
|
||||
in: TokenMetadata{
|
||||
recievedTokenType: authTokenTypeBearer,
|
||||
bearerPayload: "this-doesnt-have-the-expected-delimiter",
|
||||
},
|
||||
wantId: "",
|
||||
wantToken: "",
|
||||
},
|
||||
{
|
||||
name: "to many delimeters",
|
||||
in: TokenMetadata{
|
||||
recievedTokenType: authTokenTypeBearer,
|
||||
bearerPayload: "this_has_to_many_delimiters",
|
||||
},
|
||||
wantId: "",
|
||||
wantToken: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.wantId, tc.in.publicId(), "got wrong public id")
|
||||
assert.Equal(t, tc.wantToken, tc.in.token(), "got wrong token value")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthTokenAuthenticator(t *testing.T) {
|
||||
conn, _ := db.TestSetup(t, "postgres")
|
||||
rw := db.New(conn)
|
||||
wrapper := db.TestWrapper(t)
|
||||
repo, err := authtoken.NewRepository(rw, rw, wrapper)
|
||||
require.NoError(t, err)
|
||||
repoFn := func() (*authtoken.Repository, error) {
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
at := authtoken.TestAuthToken(t, conn, wrapper)
|
||||
|
||||
tokValue := at.GetPublicId() + "_" + at.GetToken()
|
||||
jsCookieVal, httpCookieVal := tokValue[:len(tokValue)/2], tokValue[len(tokValue)/2:]
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
headers map[string]string
|
||||
cookies []http.Cookie
|
||||
wantAuthTokMd TokenMetadata
|
||||
}{
|
||||
{
|
||||
name: "Empty headers",
|
||||
headers: map[string]string{},
|
||||
wantAuthTokMd: TokenMetadata{recievedTokenType: authTokenTypeUnknown},
|
||||
},
|
||||
{
|
||||
name: "Bear token",
|
||||
headers: map[string]string{"Authorization": fmt.Sprintf("Bearer %s", tokValue)},
|
||||
wantAuthTokMd: TokenMetadata{
|
||||
recievedTokenType: authTokenTypeBearer,
|
||||
bearerPayload: tokValue,
|
||||
UserId: at.GetIamUserId(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Split cookie token",
|
||||
cookies: []http.Cookie{
|
||||
{Name: httpOnlyCookieName, Value: httpCookieVal},
|
||||
{Name: jsVisibleCookieName, Value: jsCookieVal},
|
||||
},
|
||||
wantAuthTokMd: TokenMetadata{
|
||||
recievedTokenType: authTokenTypeSplitCookie,
|
||||
httpCookiePayload: httpCookieVal,
|
||||
jsCookiePayload: jsCookieVal,
|
||||
UserId: at.GetIamUserId(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Split cookie token only http cookie",
|
||||
cookies: []http.Cookie{
|
||||
{Name: httpOnlyCookieName, Value: httpCookieVal},
|
||||
},
|
||||
wantAuthTokMd: TokenMetadata{
|
||||
recievedTokenType: authTokenTypeUnknown,
|
||||
httpCookiePayload: httpCookieVal,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Split cookie token only js cookie",
|
||||
cookies: []http.Cookie{
|
||||
{Name: jsVisibleCookieName, Value: jsCookieVal},
|
||||
},
|
||||
wantAuthTokMd: TokenMetadata{
|
||||
recievedTokenType: authTokenTypeUnknown,
|
||||
jsCookiePayload: jsCookieVal,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Cookie and auth header",
|
||||
headers: map[string]string{"Authorization": fmt.Sprintf("Bearer %s", tokValue)},
|
||||
cookies: []http.Cookie{
|
||||
{Name: httpOnlyCookieName, Value: httpCookieVal},
|
||||
{Name: jsVisibleCookieName, Value: jsCookieVal},
|
||||
},
|
||||
// We prioritize the auth header over the cookie and if the header is set we ignore the cookies completely.
|
||||
wantAuthTokMd: TokenMetadata{
|
||||
recievedTokenType: authTokenTypeBearer,
|
||||
bearerPayload: tokValue,
|
||||
UserId: at.GetIamUserId(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
hook := &fakeHandler{validateFn: func(ctx context.Context) {
|
||||
tMD := ToTokenMetadata(ctx)
|
||||
assert.Equal(t, tc.wantAuthTokMd, tMD)
|
||||
}}
|
||||
mux := runtime.NewServeMux(runtime.WithMetadata(TokenAuthenticator(hclog.L(), repoFn)))
|
||||
require.NoError(t, services.RegisterOrganizationServiceHandlerServer(context.Background(), mux, hook))
|
||||
|
||||
req := httptest.NewRequest("GET", "http://127.0.0.1/v1/orgs/1", nil)
|
||||
for k, v := range tc.headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
for _, c := range tc.cookies {
|
||||
req.AddCookie(&c)
|
||||
}
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
mux.ServeHTTP(resp, req)
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue