Allow requesting a join token with IAM method from the web api (#11339)

Co-authored-by: Jim Bishopp <jamesbishopp@gmail.com>
Co-authored-by: Nic Klaassen <nic@goteleport.com>
This commit is contained in:
Matheus
2022-04-19 12:59:08 +00:00
committed by GitHub
co-authored by Jim Bishopp Nic Klaassen
parent 2eca7c70f4
commit 57cc2ed355
6 changed files with 510 additions and 29 deletions
+1 -1
View File
@@ -87,7 +87,7 @@ func NewProvisionTokenFromSpec(token string, expires time.Time, spec ProvisionTo
}
// MustCreateProvisionToken returns a new valid provision token
// or panics, used in testes
// or panics, used in tests
func MustCreateProvisionToken(token string, roles SystemRoles, expires time.Time) ProvisionToken {
t, err := NewProvisionToken(token, roles, expires)
if err != nil {
+1 -1
View File
@@ -45,7 +45,7 @@ type Provisioner interface {
}
// MustCreateProvisionToken returns a new valid provision token
// or panics, used in testes
// or panics, used in tests
func MustCreateProvisionToken(token string, roles types.SystemRoles, expires time.Time) types.ProvisionToken {
t, err := types.NewProvisionToken(token, roles, expires)
if err != nil {
+29 -5
View File
@@ -2008,9 +2008,11 @@ func (s *WebSuite) TestGetClusterDetails(c *C) {
func TestTokenGeneration(t *testing.T) {
tt := []struct {
name string
roles types.SystemRoles
shouldErr bool
name string
roles types.SystemRoles
shouldErr bool
joinMethod types.JoinMethod
allow []*types.TokenRule
}{
{
name: "single node role",
@@ -2037,6 +2039,19 @@ func TestTokenGeneration(t *testing.T) {
roles: types.SystemRoles{},
shouldErr: true,
},
{
name: "cannot request token with IAM join method without allow field",
roles: types.SystemRoles{types.RoleNode},
joinMethod: types.JoinMethodIAM,
shouldErr: true,
},
{
name: "can request token with IAM join method",
roles: types.SystemRoles{types.RoleNode},
joinMethod: types.JoinMethodIAM,
allow: []*types.TokenRule{{AWSAccount: "1234"}},
shouldErr: false,
},
}
for _, tc := range tt {
@@ -2047,8 +2062,10 @@ func TestTokenGeneration(t *testing.T) {
pack := proxy.authPack(t, "test-user@example.com")
endpoint := pack.clt.Endpoint("webapi", "token")
re, err := pack.clt.PostJSON(context.Background(), endpoint, createTokenRequest{
Roles: tc.roles,
re, err := pack.clt.PostJSON(context.Background(), endpoint, types.ProvisionTokenSpecV2{
Roles: tc.roles,
JoinMethod: tc.joinMethod,
Allow: tc.allow,
})
if tc.shouldErr {
@@ -2066,6 +2083,13 @@ func TestTokenGeneration(t *testing.T) {
generatedToken, err := proxy.auth.Auth().GetToken(context.Background(), responseToken.ID)
require.NoError(t, err)
require.Equal(t, tc.roles, generatedToken.GetRoles())
expectedJoinMethod := tc.joinMethod
if tc.joinMethod == "" {
expectedJoinMethod = types.JoinMethodToken
}
// if no joinMethod is provided, expect token method
require.Equal(t, expectedJoinMethod, generatedToken.GetJoinMethod())
})
}
}
+120 -20
View File
@@ -21,9 +21,12 @@ import (
"context"
"encoding/hex"
"fmt"
"hash/fnv"
"net/http"
"net/url"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
"time"
@@ -46,6 +49,8 @@ type nodeJoinToken struct {
ID string `json:"id"`
// Expiry is token expiration time.
Expiry time.Time `json:"expiry,omitempty"`
// Method is the join method that the token supports
Method types.JoinMethod `json:"method"`
}
// scriptSettings is used to hold values which are passed into the function that
@@ -55,28 +60,75 @@ type scriptSettings struct {
appInstallMode bool
appName string
appURI string
}
// createTokenRequest is the expected request body of
// the endpoint to create token
type createTokenRequest struct {
Roles types.SystemRoles `json:"roles"`
joinMethod string
}
func (h *Handler) createTokenHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) {
var req createTokenRequest
var req types.ProvisionTokenSpecV2
if err := httplib.ReadJSON(r, &req); err != nil {
log.WithError(err).Error("error reading body")
return nil, trace.Wrap(err)
}
clt, err := ctx.GetClient()
if err != nil {
log.WithError(err).Error("error getting client")
return nil, trace.Wrap(err)
}
return createJoinToken(r.Context(), clt, req.Roles)
var expires time.Time
var tokenName string
switch req.JoinMethod {
case types.JoinMethodIAM:
// to prevent generation of redundant IAM tokens
// we generate a deterministic name for them
tokenName, err = generateIAMTokenName(req.Allow)
if err != nil {
return nil, trace.Wrap(err)
}
// if a token with this name is found and it has indeed the same rule set,
// return it. Otherwise, go ahead and create it
t, err := clt.GetToken(r.Context(), tokenName)
if err != nil && !trace.IsNotFound(err) {
return nil, trace.Wrap(err)
}
if err == nil {
// check if the token found has the right rules
if t.GetJoinMethod() != types.JoinMethodIAM || !isSameRuleSet(req.Allow, t.GetAllowRules()) {
return nil, trace.BadParameter("failed to create token: token with name %q already exists and does not have the expected allow rules", tokenName)
}
return &nodeJoinToken{
ID: t.GetName(),
Expiry: *t.GetMetadata().Expires,
Method: t.GetJoinMethod(),
}, nil
}
// IAM tokens should 'never' expire
expires = time.Now().UTC().AddDate(1000, 0, 0)
default:
tokenName, err = utils.CryptoRandomHex(auth.TokenLenBytes)
if err != nil {
return nil, trace.Wrap(err)
}
expires = time.Now().UTC().Add(defaults.NodeJoinTokenTTL)
}
provisionToken, err := types.NewProvisionTokenFromSpec(tokenName, expires, req)
if err != nil {
return nil, trace.Wrap(err)
}
err = clt.UpsertToken(r.Context(), provisionToken)
if err != nil {
return nil, trace.Wrap(err)
}
return &nodeJoinToken{
ID: tokenName,
Expiry: expires,
Method: provisionToken.GetJoinMethod(),
}, nil
}
func (h *Handler) createNodeTokenHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) {
@@ -99,6 +151,7 @@ func (h *Handler) getNodeJoinScriptHandle(w http.ResponseWriter, r *http.Request
settings := scriptSettings{
token: params.ByName("token"),
appInstallMode: false,
joinMethod: r.URL.Query().Get("method"),
}
script, err := getJoinScript(settings, h.GetProxyClient())
@@ -176,17 +229,20 @@ func createJoinToken(ctx context.Context, m nodeAPIGetter, roles types.SystemRol
}
func getJoinScript(settings scriptSettings, m nodeAPIGetter) (string, error) {
// This token does not need to be validated against the backend because it's not used to
// reveal any sensitive information. However, we still need to perform a simple input
// validation check by verifying that the token was auto-generated.
// Auto-generated tokens must be encoded and must have an expected length.
decodedToken, err := hex.DecodeString(settings.token)
if err != nil {
return "", trace.Wrap(err)
}
// Skip decoding validation for IAM tokens since they are generated with a different method
if settings.joinMethod != string(types.JoinMethodIAM) {
// This token does not need to be validated against the backend because it's not used to
// reveal any sensitive information. However, we still need to perform a simple input
// validation check by verifying that the token was auto-generated.
// Auto-generated tokens must be encoded and must have an expected length.
decodedToken, err := hex.DecodeString(settings.token)
if err != nil {
return "", trace.Wrap(err)
}
if len(decodedToken) != auth.TokenLenBytes {
return "", trace.BadParameter("invalid token length")
if len(decodedToken) != auth.TokenLenBytes {
return "", trace.BadParameter("invalid token length")
}
}
// Get hostname and port from proxy server address.
@@ -237,6 +293,7 @@ func getJoinScript(settings scriptSettings, m nodeAPIGetter) (string, error) {
"appInstallMode": strconv.FormatBool(settings.appInstallMode),
"appName": settings.appName,
"appURI": settings.appURI,
"joinMethod": settings.joinMethod,
})
if err != nil {
return "", trace.Wrap(err)
@@ -245,6 +302,49 @@ func getJoinScript(settings scriptSettings, m nodeAPIGetter) (string, error) {
return buf.String(), nil
}
// generateIAMTokenName makes a deterministic name for a iam join token
// based on its rule set
func generateIAMTokenName(rules []*types.TokenRule) (string, error) {
// sort the rules by (account ID, arn)
// to make sure a set of rules will produce the same hash,
// no matter the order they are in the slice
orderedRules := make([]*types.TokenRule, len(rules))
copy(orderedRules, rules)
sortRules(orderedRules)
h := fnv.New32a()
for _, r := range orderedRules {
s := fmt.Sprintf("%s%s", r.AWSAccount, r.AWSARN)
_, err := h.Write([]byte(s))
if err != nil {
return "", trace.Wrap(err)
}
}
return fmt.Sprintf("teleport-ui-iam-%d", h.Sum32()), nil
}
// sortRules sorts a slice of rules based on their AWS Account ID and ARN
func sortRules(rules []*types.TokenRule) {
sort.Slice(rules, func(i, j int) bool {
iAcct, jAcct := rules[i].AWSAccount, rules[j].AWSAccount
// if accountID is the same, sort based on arn
if iAcct == jAcct {
arn1, arn2 := rules[i].AWSARN, rules[j].AWSARN
return arn1 < arn2
}
return iAcct < jAcct
})
}
// isSameRuleSet check if r1 and r2 are the same rules, ignoring the order
func isSameRuleSet(r1 []*types.TokenRule, r2 []*types.TokenRule) bool {
sortRules(r1)
sortRules(r2)
return reflect.DeepEqual(r1, r2)
}
type nodeAPIGetter interface {
// GenerateToken creates a special provisioning token for a new SSH server
// that is valid for ttl period seconds.
+346
View File
@@ -46,6 +46,237 @@ func TestCreateNodeJoinToken(t *testing.T) {
require.Equal(t, "some-token-id", token.ID)
}
func TestGenerateIAMTokenName(t *testing.T) {
rule1 := types.TokenRule{
AWSAccount: "100000000000",
AWSARN: "arn:aws:iam:1",
}
rule1Name := "teleport-ui-iam-2218897454"
// make sure the hash algorithm don't change accidentally
hash1, err := generateIAMTokenName([]*types.TokenRule{&rule1})
require.NoError(t, err)
require.Equal(t, rule1Name, hash1)
rule2 := types.TokenRule{
AWSAccount: "200000000000",
AWSARN: "arn:aws:iam:b",
}
// make sure the order doesn't matter
hash1, err = generateIAMTokenName([]*types.TokenRule{&rule1, &rule2})
require.NoError(t, err)
hash2, err := generateIAMTokenName([]*types.TokenRule{&rule2, &rule1})
require.NoError(t, err)
require.Equal(t, hash1, hash2)
// generate different hashes for different rules
hash1, err = generateIAMTokenName([]*types.TokenRule{&rule1})
require.NoError(t, err)
hash2, err = generateIAMTokenName([]*types.TokenRule{&rule2})
require.NoError(t, err)
require.NotEqual(t, hash1, hash2)
}
func TestSortRules(t *testing.T) {
tt := []struct {
name string
rules []*types.TokenRule
expected []*types.TokenRule
}{
{
name: "different account ID, no ARN",
rules: []*types.TokenRule{
{AWSAccount: "200000000000"},
{AWSAccount: "100000000000"},
},
expected: []*types.TokenRule{
{AWSAccount: "100000000000"},
{AWSAccount: "200000000000"},
},
},
{
name: "different account ID, no ARN, already ordered",
rules: []*types.TokenRule{
{AWSAccount: "100000000000"},
{AWSAccount: "200000000000"},
},
expected: []*types.TokenRule{
{AWSAccount: "100000000000"},
{AWSAccount: "200000000000"},
},
},
{
name: "different account ID, with ARN",
rules: []*types.TokenRule{
{
AWSAccount: "200000000000",
AWSARN: "arn:aws:iam:b",
},
{
AWSAccount: "100000000000",
AWSARN: "arn:aws:iam:b",
},
},
expected: []*types.TokenRule{
{
AWSAccount: "100000000000",
AWSARN: "arn:aws:iam:b",
},
{
AWSAccount: "200000000000",
AWSARN: "arn:aws:iam:b",
},
},
},
{
name: "different account ID, with ARN, already ordered",
rules: []*types.TokenRule{
{
AWSAccount: "100000000000",
AWSARN: "arn:aws:iam:b",
},
{
AWSAccount: "200000000000",
AWSARN: "arn:aws:iam:b",
},
},
expected: []*types.TokenRule{
{
AWSAccount: "100000000000",
AWSARN: "arn:aws:iam:b",
},
{
AWSAccount: "200000000000",
AWSARN: "arn:aws:iam:b",
},
},
},
{
name: "same account ID, different ARN, already ordered",
rules: []*types.TokenRule{
{
AWSAccount: "100000000000",
AWSARN: "arn:aws:iam:a",
},
{
AWSAccount: "100000000000",
AWSARN: "arn:aws:iam:b",
},
},
expected: []*types.TokenRule{
{
AWSAccount: "100000000000",
AWSARN: "arn:aws:iam:a",
},
{
AWSAccount: "100000000000",
AWSARN: "arn:aws:iam:b",
},
},
},
{
name: "same account ID, different ARN",
rules: []*types.TokenRule{
{
AWSAccount: "100000000000",
AWSARN: "arn:aws:iam:b",
},
{
AWSAccount: "100000000000",
AWSARN: "arn:aws:iam:a",
},
},
expected: []*types.TokenRule{
{
AWSAccount: "100000000000",
AWSARN: "arn:aws:iam:a",
},
{
AWSAccount: "100000000000",
AWSARN: "arn:aws:iam:b",
},
},
},
{
name: "multiple account ID and ARNs",
rules: []*types.TokenRule{
{
AWSAccount: "100000000000",
AWSARN: "arn:aws:iam:b",
},
{
AWSAccount: "200000000001",
AWSARN: "arn:aws:iam:b",
},
{
AWSAccount: "200000000000",
AWSARN: "arn:aws:iam:a",
},
{
AWSAccount: "200000000000",
AWSARN: "arn:aws:iam:b",
},
{
AWSAccount: "200000000001",
AWSARN: "arn:aws:iam:z",
},
{
AWSAccount: "100000000000",
AWSARN: "arn:aws:iam:a",
},
{
AWSAccount: "300000000000",
AWSARN: "arn:aws:iam:a",
},
},
expected: []*types.TokenRule{
{
AWSAccount: "100000000000",
AWSARN: "arn:aws:iam:a",
},
{
AWSAccount: "100000000000",
AWSARN: "arn:aws:iam:b",
},
{
AWSAccount: "200000000000",
AWSARN: "arn:aws:iam:a",
},
{
AWSAccount: "200000000000",
AWSARN: "arn:aws:iam:b",
},
{
AWSAccount: "200000000001",
AWSARN: "arn:aws:iam:b",
},
{
AWSAccount: "200000000001",
AWSARN: "arn:aws:iam:z",
},
{
AWSAccount: "300000000000",
AWSARN: "arn:aws:iam:a",
},
},
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
sortRules(tc.rules)
require.Equal(t, tc.expected, tc.rules)
})
}
}
func TestGetNodeJoinScript(t *testing.T) {
m := &mockedNodeAPIGetter{}
m.mockGetProxyServers = func() ([]types.Server, error) {
@@ -94,6 +325,17 @@ func TestGetNodeJoinScript(t *testing.T) {
require.Contains(t, script, "test-host")
require.Contains(t, script, "12345678")
require.Contains(t, script, "sha256:")
require.NotContains(t, script, "JOIN_METHOD=\"iam\"")
// Test iam method script
iamToken := scriptSettings{
token: "token length doesnt matter in this case",
joinMethod: string(types.JoinMethodIAM),
}
script, err = getJoinScript(iamToken, m)
require.NoError(t, err)
require.Contains(t, script, "JOIN_METHOD=\"iam\"")
}
func TestGetAppJoinScript(t *testing.T) {
@@ -272,6 +514,110 @@ func TestGetAppJoinScript(t *testing.T) {
}
}
func TestIsSameRuleSet(t *testing.T) {
tt := []struct {
name string
r1 []*types.TokenRule
r2 []*types.TokenRule
expected bool
}{
{
name: "empty slice",
expected: true,
},
{
name: "simple identical rules",
r1: []*types.TokenRule{
{
AWSAccount: "123123123123",
},
},
r2: []*types.TokenRule{
{
AWSAccount: "123123123123",
},
},
expected: true,
},
{
name: "different rules",
r1: []*types.TokenRule{
{
AWSAccount: "123123123123",
},
},
r2: []*types.TokenRule{
{
AWSAccount: "111111111111",
},
},
expected: false,
},
{
name: "same rules in different order",
r1: []*types.TokenRule{
{
AWSAccount: "123123123123",
},
{
AWSAccount: "222222222222",
},
{
AWSAccount: "111111111111",
AWSARN: "arn:*",
},
},
r2: []*types.TokenRule{
{
AWSAccount: "222222222222",
},
{
AWSAccount: "111111111111",
AWSARN: "arn:*",
},
{
AWSAccount: "123123123123",
},
},
expected: true,
},
{
name: "almost the same rules",
r1: []*types.TokenRule{
{
AWSAccount: "123123123123",
},
{
AWSAccount: "222222222222",
},
{
AWSAccount: "111111111111",
AWSARN: "arn:*",
},
},
r2: []*types.TokenRule{
{
AWSAccount: "123123123123",
},
{
AWSAccount: "222222222222",
},
{
AWSAccount: "111111111111",
AWSARN: "arn:",
},
},
expected: false,
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.expected, isSameRuleSet(tc.r1, tc.r2))
})
}
}
type mockedNodeAPIGetter struct {
mockGenerateToken func(ctx context.Context, req auth.GenerateTokenRequest) (string, error)
mockGetProxyServers func() ([]types.Server, error)
+13 -2
View File
@@ -1,5 +1,4 @@
#!/bin/bash
# Any changes made to this script must also be made to e/lib/web/scripts/install_node.go
set -euo pipefail
SCRIPT_NAME="teleport-installer"
@@ -40,6 +39,7 @@ TELEPORT_VERSION="{{.version}}"
TARGET_HOSTNAME="{{.hostname}}"
TARGET_PORT="{{.port}}"
JOIN_TOKEN="{{.token}}"
JOIN_METHOD="{{.joinMethod}}"
CA_PIN_HASHES="{{.caPins}}"
ARG_CA_PIN_HASHES=""
APP_INSTALL_MODE="{{.appInstallMode}}"
@@ -431,10 +431,11 @@ EOF
install_teleport_node_config() {
log "Writing Teleport node service config to ${TELEPORT_CONFIG_PATH}"
CA_PINS_CONFIG=$(get_yaml_list "ca_pin" "${CA_PIN_HASHES}" " ")
AUTH_CONFIG=$(get_node_auth_config)
cat << EOF > ${TELEPORT_CONFIG_PATH}
teleport:
nodename: ${NODENAME}
auth_token: ${JOIN_TOKEN}
${AUTH_CONFIG}
${CA_PINS_CONFIG}
auth_servers:
- ${TARGET_HOSTNAME}:${TARGET_PORT}
@@ -449,6 +450,16 @@ proxy_service:
enabled: no
EOF
}
# get the auth section of a node config
get_node_auth_config() {
if [[ ${JOIN_METHOD} == "iam" ]]; then
echo "join_params:
token_name: ${JOIN_TOKEN}
method: iam
";
else
echo "auth_token: ${JOIN_TOKEN}"; fi
}
# checks whether the given host is running MacOS
is_macos_host() { if [[ ${OSTYPE} == "darwin"* ]]; then return 0; else return 1; fi }
# checks whether teleport is already running on the host