HTTP API support for 'nomad ui -login'

Endpoints for requesting and exchanging one-time tokens via the HTTP
API. Includes documentation updates.
This commit is contained in:
Tim Gross 2021-02-25 16:41:00 -05:00
parent 7010a344d6
commit 75878f978e
7 changed files with 329 additions and 0 deletions

View File

@ -154,6 +154,36 @@ func (a *ACLTokens) Self(q *QueryOptions) (*ACLToken, *QueryMeta, error) {
return &resp, wm, nil
}
// UpsertOneTimeToken is used to create a one-time token
func (a *ACLTokens) UpsertOneTimeToken(q *WriteOptions) (*OneTimeToken, *WriteMeta, error) {
var resp *OneTimeTokenUpsertResponse
wm, err := a.client.write("/v1/acl/token/onetime", nil, &resp, q)
if err != nil {
return nil, nil, err
}
if resp == nil {
return nil, nil, fmt.Errorf("no one-time token returned")
}
return resp.OneTimeToken, wm, nil
}
// ExchangeOneTimeToken is used to create a one-time token
func (a *ACLTokens) ExchangeOneTimeToken(secret string, q *WriteOptions) (*ACLToken, *WriteMeta, error) {
if secret == "" {
return nil, nil, fmt.Errorf("missing secret ID")
}
req := &OneTimeTokenExchangeRequest{OneTimeSecretID: secret}
var resp *OneTimeTokenExchangeResponse
wm, err := a.client.write("/v1/acl/token/onetime/exchange", req, &resp, q)
if err != nil {
return nil, nil, err
}
if resp == nil {
return nil, nil, fmt.Errorf("no ACL token returned")
}
return resp.Token, wm, nil
}
// ACLPolicyListStub is used to for listing ACL policies
type ACLPolicyListStub struct {
Name string
@ -194,3 +224,23 @@ type ACLTokenListStub struct {
CreateIndex uint64
ModifyIndex uint64
}
type OneTimeToken struct {
OneTimeSecretID string
AccessorID string
ExpiresAt time.Time
CreateIndex uint64
ModifyIndex uint64
}
type OneTimeTokenUpsertResponse struct {
OneTimeToken *OneTimeToken
}
type OneTimeTokenExchangeRequest struct {
OneTimeSecretID string
}
type OneTimeTokenExchangeResponse struct {
Token *ACLToken
}

View File

@ -235,3 +235,36 @@ func TestACLTokens_Delete(t *testing.T) {
assert.Nil(t, err)
assertWriteMeta(t, wm)
}
func TestACL_OneTimeToken(t *testing.T) {
t.Parallel()
c, s, _ := makeACLClient(t, nil, nil)
defer s.Stop()
at := c.ACLTokens()
token := &ACLToken{
Name: "foo",
Type: "client",
Policies: []string{"foo1"},
}
// Create the ACL token
out, wm, err := at.Create(token, nil)
assert.Nil(t, err)
assertWriteMeta(t, wm)
assert.NotNil(t, out)
// Get a one-time token
c.SetSecretID(out.SecretID)
out2, wm, err := at.UpsertOneTimeToken(nil)
assert.Nil(t, err)
assertWriteMeta(t, wm)
assert.NotNil(t, out2)
// Exchange the one-time token
out3, wm, err := at.ExchangeOneTimeToken(out2.OneTimeSecretID, nil)
assert.Nil(t, err)
assertWriteMeta(t, wm)
assert.NotNil(t, out3)
assert.Equal(t, out3.AccessorID, out.AccessorID)
}

View File

@ -277,3 +277,42 @@ func (s *HTTPServer) aclTokenDelete(resp http.ResponseWriter, req *http.Request,
setIndex(resp, out.Index)
return nil, nil
}
func (s *HTTPServer) UpsertOneTimeToken(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
// Ensure this is a PUT or POST
if !(req.Method == "PUT" || req.Method == "POST") {
return nil, CodedError(405, ErrInvalidMethod)
}
// the request body is empty but we need to parse to get the auth token
args := structs.OneTimeTokenUpsertRequest{}
s.parseWriteRequest(req, &args.WriteRequest)
var out structs.OneTimeTokenUpsertResponse
if err := s.agent.RPC("ACL.UpsertOneTimeToken", &args, &out); err != nil {
return nil, err
}
setIndex(resp, out.Index)
return out, nil
}
func (s *HTTPServer) ExchangeOneTimeToken(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
// Ensure this is a PUT or POST
if !(req.Method == "PUT" || req.Method == "POST") {
return nil, CodedError(405, ErrInvalidMethod)
}
var args structs.OneTimeTokenExchangeRequest
if err := decodeBody(req, &args); err != nil {
return nil, CodedError(500, err.Error())
}
s.parseWriteRequest(req, &args.WriteRequest)
var out structs.OneTimeTokenExchangeResponse
if err := s.agent.RPC("ACL.ExchangeOneTimeToken", &args, &out); err != nil {
return nil, err
}
setIndex(resp, out.Index)
return out, nil
}

View File

@ -8,6 +8,7 @@ import (
"github.com/hashicorp/nomad/nomad/mock"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHTTP_ACLPolicyList(t *testing.T) {
@ -448,3 +449,66 @@ func TestHTTP_ACLTokenDelete(t *testing.T) {
assert.Nil(t, out)
})
}
func TestHTTP_OneTimeToken(t *testing.T) {
t.Parallel()
httpACLTest(t, nil, func(s *TestAgent) {
// Setup the ACL token
p1 := mock.ACLToken()
p1.AccessorID = ""
args := structs.ACLTokenUpsertRequest{
Tokens: []*structs.ACLToken{p1},
WriteRequest: structs.WriteRequest{
Region: "global",
AuthToken: s.RootToken.SecretID,
},
}
var resp structs.ACLTokenUpsertResponse
err := s.Agent.RPC("ACL.UpsertTokens", &args, &resp)
require.NoError(t, err)
aclID := resp.Tokens[0].AccessorID
aclSecret := resp.Tokens[0].SecretID
// Make a HTTP request to get a one-time token
req, err := http.NewRequest("POST", "/v1/acl/token/onetime", nil)
require.NoError(t, err)
req.Header.Set("X-Nomad-Token", aclSecret)
respW := httptest.NewRecorder()
obj, err := s.Server.UpsertOneTimeToken(respW, req)
require.NoError(t, err)
require.NotNil(t, obj)
ott := obj.(structs.OneTimeTokenUpsertResponse)
require.Equal(t, aclID, ott.OneTimeToken.AccessorID)
require.NotEqual(t, "", ott.OneTimeToken.OneTimeSecretID)
// Make a HTTP request to exchange that token
buf := encodeReq(structs.OneTimeTokenExchangeRequest{
OneTimeSecretID: ott.OneTimeToken.OneTimeSecretID})
req, err = http.NewRequest("POST", "/v1/acl/token/onetime/exchange", buf)
respW = httptest.NewRecorder()
obj, err = s.Server.ExchangeOneTimeToken(respW, req)
require.NoError(t, err)
require.NotNil(t, obj)
token := obj.(structs.OneTimeTokenExchangeResponse)
require.Equal(t, aclID, token.Token.AccessorID)
require.Equal(t, aclSecret, token.Token.SecretID)
// Making the same request a second time should return an error
buf = encodeReq(structs.OneTimeTokenExchangeRequest{
OneTimeSecretID: ott.OneTimeToken.OneTimeSecretID})
req, err = http.NewRequest("POST", "/v1/acl/token/onetime/exchange", buf)
respW = httptest.NewRecorder()
obj, err = s.Server.ExchangeOneTimeToken(respW, req)
require.EqualError(t, err, structs.ErrPermissionDenied.Error())
})
}

View File

@ -272,6 +272,8 @@ func (s *HTTPServer) registerHandlers(enableDebug bool) {
s.mux.HandleFunc("/v1/acl/policies", s.wrap(s.ACLPoliciesRequest))
s.mux.HandleFunc("/v1/acl/policy/", s.wrap(s.ACLPolicySpecificRequest))
s.mux.HandleFunc("/v1/acl/token/onetime", s.wrap(s.UpsertOneTimeToken))
s.mux.HandleFunc("/v1/acl/token/onetime/exchange", s.wrap(s.ExchangeOneTimeToken))
s.mux.HandleFunc("/v1/acl/bootstrap", s.wrap(s.ACLTokenBootstrap))
s.mux.HandleFunc("/v1/acl/tokens", s.wrap(s.ACLTokensRequest))
s.mux.HandleFunc("/v1/acl/token", s.wrap(s.ACLTokenSpecificRequest))

View File

@ -154,6 +154,36 @@ func (a *ACLTokens) Self(q *QueryOptions) (*ACLToken, *QueryMeta, error) {
return &resp, wm, nil
}
// UpsertOneTimeToken is used to create a one-time token
func (a *ACLTokens) UpsertOneTimeToken(q *WriteOptions) (*OneTimeToken, *WriteMeta, error) {
var resp *OneTimeTokenUpsertResponse
wm, err := a.client.write("/v1/acl/token/onetime", nil, &resp, q)
if err != nil {
return nil, nil, err
}
if resp == nil {
return nil, nil, fmt.Errorf("no one-time token returned")
}
return resp.OneTimeToken, wm, nil
}
// ExchangeOneTimeToken is used to create a one-time token
func (a *ACLTokens) ExchangeOneTimeToken(secret string, q *WriteOptions) (*ACLToken, *WriteMeta, error) {
if secret == "" {
return nil, nil, fmt.Errorf("missing secret ID")
}
req := &OneTimeTokenExchangeRequest{OneTimeSecretID: secret}
var resp *OneTimeTokenExchangeResponse
wm, err := a.client.write("/v1/acl/token/onetime/exchange", req, &resp, q)
if err != nil {
return nil, nil, err
}
if resp == nil {
return nil, nil, fmt.Errorf("no ACL token returned")
}
return resp.Token, wm, nil
}
// ACLPolicyListStub is used to for listing ACL policies
type ACLPolicyListStub struct {
Name string
@ -194,3 +224,23 @@ type ACLTokenListStub struct {
CreateIndex uint64
ModifyIndex uint64
}
type OneTimeToken struct {
OneTimeSecretID string
AccessorID string
ExpiresAt time.Time
CreateIndex uint64
ModifyIndex uint64
}
type OneTimeTokenUpsertResponse struct {
OneTimeToken *OneTimeToken
}
type OneTimeTokenExchangeRequest struct {
OneTimeSecretID string
}
type OneTimeTokenExchangeResponse struct {
Token *ACLToken
}

View File

@ -342,3 +342,94 @@ $ curl \
--request DELETE \
https://localhost:4646/v1/acl/token/aa534e09-6a07-0a45-2295-a7f77063d429
```
## Upsert One-Time Token
This endpoint creates a one-time token for the ACL token provided in the
`X-Nomad-Token` header. Returns 403 if the token header is not set.
| Method | Path | Produces |
| -------- | ------------------------- | -------------- |
| `POST` | `/acl/token/onetime` | `application/json` |
The table below shows this endpoint's support for
[blocking queries](/api-docs#blocking-queries) and
[required ACLs](/api-docs#acls).
| Blocking Queries | ACL Required |
| ---------------- | ------------ |
| `NO` | `any` |
### Sample Request
```shell-session
$ curl \
--request POST \
-H "X-Nomad-Token: aa534e09-6a07-0a45-2295-a7f77063d429" \
https://localhost:4646/v1/acl/token/onetime
```
### Sample Response
```json
{
"Index": 15,
"OneTimeToken": {
"AccessorID": "b780e702-98ce-521f-2e5f-c6b87de05b24",
"CreateIndex": 7,
"ExpiresAt": "2017-08-23T22:47:14.695408057Z",
"ModifyIndex": 7,
"OneTimeSecretID": "3f4a0fcd-7c42-773c-25db-2d31ba0c05fe"
}
}
```
## Exchange One-Time Token
This endpoint exchanges a one-time token for the original ACL token used to
create it.
| Method | Path | Produces |
| -------- | ------------------------- | -------------- |
| `POST` | `/acl/token/onetime/exchange` | `application/json` |
The table below shows this endpoint's support for
[blocking queries](/api-docs#blocking-queries) and
[required ACLs](/api-docs#acls).
| Blocking Queries | ACL Required |
| ---------------- | ------------ |
| `NO` | `any` |
### Sample Request
```shell-session
$ curl \
--request POST \
-d '{ "OneTimeSecretID": "aa534e09-6a07-0a45-2295-a7f77063d429" } \
https://localhost:4646/v1/acl/token/onetime/exchange
```
### Sample Response
```json
{
"Index": 17,
"Token": {
"AccessorID": "b780e702-98ce-521f-2e5f-c6b87de05b24",
"CreateIndex": 7,
"CreateTime": "2017-08-23T22:47:14.695408057Z",
"Global": true,
"Hash": "UhZESkSFGFfX7eBgq5Uwph30OctbUbpe8+dlH2i4whA=",
"ModifyIndex": 7,
"Name": "Developer token",
"Policies": [
"developer"
],
"SecretID": "3f4a0fcd-7c42-773c-25db-2d31ba0c05fe",
"Type": "client"
}
}
```