Skip to content

Commit

Permalink
feat!: terraform login protocol (#424)
Browse files Browse the repository at this point in the history
Breaking change: secret must be a hex-encoded 16 byte array. Tokens will very likely therefore need to be re-created, and users will need to re-authenticate.
  • Loading branch information
leg100 authored May 9, 2023
1 parent b4b9ff3 commit 2e627ca
Show file tree
Hide file tree
Showing 39 changed files with 810 additions and 161 deletions.
10 changes: 3 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,12 @@ Setup local credentials:
terraform login demo.otf.ninja
```

Confirm with `yes` to proceed and it'll open a browser window where you can create a token:
Confirm with `yes` to proceed and you'll be asked to give consent to allow `terraform` to access your account on OTF. After you give consent, you should be notified that you can close the browser and return to the terminal.

Click `New token` and give it a description and click `Create token`. The token will be revealed. Click on the token to copy it to your clipboard.

Return to your terminal and paste the token into the prompt.

You should then receive successful confirmation:
In the terminal `terraform login` should have printed out confirmation of success:

```
Success! Logged in to Terraform Enterprise (demo.otf.ninja)
Success! Terraform has obtained and saved an API token.
```

Write some terraform configuration to a file, setting the organization to your username:
Expand Down
13 changes: 10 additions & 3 deletions cmd/environment_variables.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,19 @@ const (
)

// SetFlagsFromEnvVariables overrides flag values with environment variables.
func SetFlagsFromEnvVariables(fs *pflag.FlagSet) error {
func SetFlagsFromEnvVariables(fs *pflag.FlagSet) (err error) {
defer func() {
if p := recover(); p != nil {
err = p.(error)
}
}()
fileEnvs := make(map[string]*pflag.Flag, fs.NFlag())
fs.VisitAll(func(f *pflag.Flag) {
envVar := flagToEnvVarName(f)
if val, present := os.LookupEnv(envVar); present {
fs.Set(f.Name, val)
if err := fs.Set(f.Name, val); err != nil {
panic(err)
}
return
}

Expand All @@ -43,7 +50,7 @@ func SetFlagsFromEnvVariables(fs *pflag.FlagSet) error {
fs.Set(f.Name, string(value))
}

return nil
return err
}

func flagToEnvVarName(f *pflag.Flag) string {
Expand Down
11 changes: 11 additions & 0 deletions cmd/environment_variables_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,15 @@ func TestSetFlagsFromEnvVariables(t *testing.T) {
t.Setenv("OTF_FOO_FILE", "./does-not-exist")
assert.Error(t, SetFlagsFromEnvVariables(fs))
})
t.Run("override flag with env var containing invalid value", func(t *testing.T) {
fs := pflag.NewFlagSet("testing", pflag.ContinueOnError)
_ = fs.BytesHex("foo", nil, "")
t.Setenv("OTF_FOO", "not-hex")

err := SetFlagsFromEnvVariables(fs)
if assert.Error(t, err) {
want := "invalid argument \"not-hex\" for \"--foo\" flag: encoding/hex: invalid byte: U+006E 'n'"
assert.Equal(t, want, err.Error())
}
})
}
2 changes: 1 addition & 1 deletion cmd/otfd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func parseFlags(ctx context.Context, args []string, out io.Writer) error {
cmd.Flags().StringVar(&cfg.Host, "hostname", "", "User-facing hostname for otf")
cmd.Flags().StringVar(&cfg.SiteToken, "site-token", "", "API token with site-wide unlimited permissions. Use with care.")
cmd.Flags().StringSliceVar(&cfg.SiteAdmins, "site-admins", nil, "Promote a list of users to site admin.")
cmd.Flags().StringVar(&cfg.Secret, "secret", "", "Secret string for signing short-lived URLs. Required.")
cmd.Flags().BytesHexVar(&cfg.Secret, "secret", nil, "Hex-encoded 16 byte secret for cryptographic work. Required.")
cmd.Flags().Int64Var(&cfg.MaxConfigSize, "max-config-size", cfg.MaxConfigSize, "Maximum permitted configuration size in bytes.")

cmd.Flags().IntVar(&cfg.CacheConfig.Size, "cache-size", 0, "Maximum cache size in MB. 0 means unlimited size.")
Expand Down
10 changes: 10 additions & 0 deletions cmd/otfd/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"bytes"
"context"
"io"
"regexp"
"testing"

Expand Down Expand Up @@ -50,3 +51,12 @@ func TestHelp(t *testing.T) {
})
}
}

func TestInvalidSecret(t *testing.T) {
ctx := context.Background()

err := parseFlags(ctx, []string{"--secret", "not-hex"}, io.Discard)
assert.Error(t, err)
want := "invalid argument \"not-hex\" for \"--secret\" flag: encoding/hex: invalid byte: U+006E 'n'"
assert.Equal(t, want, err.Error())
}
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ services:
retries: 3
environment:
- OTF_DATABASE=postgres://postgres:postgres@postgres:5432/postgres?sslmode=disable
- OTF_SECRET=ce6bf87f25118c87c8ca3d3066010c5ee56643c01ba5cab605642b0d83271e6e
- OTF_SECRET=6b07b57377755b07cf61709780ee7484
- OTF_SITE_TOKEN=site-token
- OTF_SSL=true
- OTF_CERT_FILE=/fixtures/cert.pem
Expand Down
8 changes: 0 additions & 8 deletions docs/auth/site_admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,5 @@ The `site-admin` user allows for exceptional access to OTF. The user possesses u

You can sign into the web UI using the token. Use the link found in the bottom right corner of the login page.

You can also configure the `otf` client CLI and the `terraform` CLI to use this token:

```bash
terraform login <otf hostname>
```

And enter the token when prompted. It'll be persisted to a local credentials file.

!!! note
Use of the site admin token is recommended only for one-off administrative and testing purposes. You should use an [identity provider](/auth/providers) in most cases.
3 changes: 0 additions & 3 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,3 @@ Credentials are sourced from the same file the terraform CLI uses (`~/.terraform
```bash
terraform login <otfd_hostname>
```

!!! note
`terraform login` has a bug wherein it ignores the port when opening a browser. If you have included a port, e.g. `localhost:8080`, then you'll need to fix the URL in the browser address bar accordingly.
9 changes: 6 additions & 3 deletions docs/config/flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,13 +159,16 @@ Restricts the ability to create organizations to users possessing the site admin
* System: `otfd`
* Default: ""

A secret string for performing cryptographic work. It must be no longer than 64 characters and you should use a cryptographically secure random number generator, e.g. `openssl`:
Hex-encoded 16-byte secret for performing cryptographic work. You should use a cryptographically secure random number generator, e.g. `openssl`:

```bash
> openssl rand -hex 32
56789f6076a66323643f57a1016cdde7e7e39914785d36d61fdd8b9a30081f14
> openssl rand -hex 16
6b07b57377755b07cf61709780ee7484
```

!!! note
The secret is required. It must be exactly 16 bytes in size, and it must be hex-encoded.

## `--site-admins`

* System: `otfd`
Expand Down
16 changes: 7 additions & 9 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,17 @@ createdb otf
At a minimum, `otfd` requires a [secret](/config/flags#-secret) and a means of authentication. For the purposes of this quickstart we'll use a [site token](/config/flags#-site-token):

```bash
> ./otfd --secret=my-secret --site-token=my-token
> ./otfd --secret=6b07b57377755b07cf61709780ee7484 --site-token=my-token
2022-10-30T20:06:10Z INF started cache max_size=0 ttl=10m0s
2022-10-30T20:06:10Z INF successfully connected component=database path=postgres:///otf?host=/var/run/postgresql
2022-10-30T20:06:10Z INF goose: no migrations to run. current version: 20221017170815 compone
nt=database
2022-10-30T20:06:10Z INF started server address=[::]:8080 ssl=false
```

!!! note
The secret must be a hex-encoded 16-byte array

You have now successfully installed `otfd` and confirmed you can start `otfd` with minimal configuration. Proceed to create your first organization.

#### Create organization
Expand Down Expand Up @@ -54,7 +57,7 @@ sudo update-ca-certificates
Now return to the terminal in which `otfd` is running. You'll need to kill it and start it again, this time with SSL enabled:

```bash
> ./otfd --secret=my-secret --site-token=my-token --ssl --cert-file=cert.crt --key-file=key.pem
> ./otfd --secret=6b07b57377755b07cf61709780ee7484 --site-token=my-token --ssl --cert-file=cert.crt --key-file=key.pem
```

Terraform needs to use your token to authenticate with `otfd`:
Expand All @@ -63,15 +66,10 @@ Terraform needs to use your token to authenticate with `otfd`:
terraform login localhost:8080
```

Enter `yes` to proceed.

!!! bug
You'll notice `terraform login` opens a browser window. However it ignores the port, thereby failing to open the correct page on the server. Once you properly deploy the server on a non-custom port this won't be a problem.

Ignore the browser window it has opened and enter your token at the terminal prompt. You should receive confirmation of success:
Enter `yes` to proceed. A browser window is opened where you give consent to `terraform` to access your OTF account. Once you've done that you should be notified you can close the browser and return to the terminal. You should see the confirmation of success:

```
Success! Logged in to Terraform Enterprise (localhost:8080)
Success! Terraform has obtained and saved an API token.
```

Now we'll write some terraform configuration. Configure the terraform backend and define a resource:
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,9 @@ require (
github.com/prometheus/common v0.37.0 // indirect
github.com/prometheus/procfs v0.8.0 // indirect
github.com/rogpeppe/go-internal v1.8.1 // indirect
github.com/sergi/go-diff v1.1.0 // indirect
github.com/shopspring/decimal v1.2.0 // indirect
github.com/spf13/cast v1.3.1 // indirect
github.com/spf13/cast v1.3.2-0.20200723214538-8d17101741c8 // indirect
github.com/zclconf/go-cty v1.8.0 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/crypto v0.7.0 // indirect
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -572,8 +572,9 @@ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
Expand All @@ -590,8 +591,9 @@ github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4k
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.3.2-0.20200723214538-8d17101741c8 h1:GbJaXkBXPYlxE45H4g2wo0Hb4TGzv/YbHVA1OGqx+mo=
github.com/spf13/cast v1.3.2-0.20200723214538-8d17101741c8/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
github.com/spf13/cobra v1.1.3 h1:xghbfqPkxzxP3C/f3n5DdpAbdKLj4ZE4BWQI362l53M=
github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo=
Expand Down
63 changes: 63 additions & 0 deletions internal/crypto.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package internal

import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
)

// Encrypt plaintext using secret key. The returned string is
// base64-url-encoded.
func Encrypt(plaintext, secret []byte) (string, error) {
block, err := aes.NewCipher(secret)
if err != nil {
return "", err
}

// Never use more than 2^32 random nonces with a given key because of the risk of a repeat.
nonce := make([]byte, 12)
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}

aesgcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}

ciphertext := aesgcm.Seal(nil, nonce, plaintext, nil)

// Prefix string with nonce
return base64.URLEncoding.EncodeToString(append(nonce, ciphertext...)), nil
}

// Decrypt encrypted string using secret key. The encrypted string must be
// base64-url-encoded.
func Decrypt(encrypted string, secret []byte) ([]byte, error) {
block, err := aes.NewCipher(secret)
if err != nil {
return nil, err
}

aesgcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}

decoded, err := base64.URLEncoding.DecodeString(encrypted)
if err != nil {
return nil, err
}

// Nonce is first 12 bytes, so decoding should at least be that length (plus
// a multiple of 32 bytes for the ciphertext, but we'll let aesgcm.Open
// check that).
if len(decoded) < 12 {
return nil, fmt.Errorf("size of decoded encrypted string is incorrect: %d", len(decoded))
}

return aesgcm.Open(nil, decoded[:12], decoded[12:], nil)
}
18 changes: 18 additions & 0 deletions internal/crypto_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package internal

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestCrypto(t *testing.T) {
secret := []byte(GenerateRandomString(32))
encrypted, err := Encrypt([]byte("exampleplaintext"), secret)
require.NoError(t, err)

decrypted, err := Decrypt(encrypted, secret)
require.NoError(t, err, encrypted)
assert.Equal(t, "exampleplaintext", string(decrypted))
}
16 changes: 15 additions & 1 deletion internal/daemon/config.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package daemon

import (
"errors"
"reflect"

"github.com/leg100/otf/internal"
"github.com/leg100/otf/internal/agent"
"github.com/leg100/otf/internal/cloud"
"github.com/leg100/otf/internal/configversion"
Expand All @@ -12,6 +14,8 @@ import (
"github.com/leg100/otf/internal/tokens"
)

var ErrInvalidSecretLength = errors.New("secret must be 16 bytes in size")

// Config configures the otfd daemon. Descriptions of each field can be found in
// the flag definitions in ./cmd/otfd
type Config struct {
Expand All @@ -20,7 +24,7 @@ type Config struct {
Github cloud.CloudOAuthConfig
Gitlab cloud.CloudOAuthConfig
OIDC cloud.OIDCConfig
Secret string // secret for signing URLs
Secret []byte // 16-byte secret for signing URLs and encrypting payloads
SiteToken string
Host string
Address string
Expand Down Expand Up @@ -62,3 +66,13 @@ func ApplyDefaults(cfg *Config) {
}
}
}

func (cfg *Config) Valid() error {
if cfg.Secret == nil {
return &internal.MissingParameterError{Parameter: "secret"}
}
if len(cfg.Secret) != 16 {
return ErrInvalidSecretLength
}
return nil
}
17 changes: 15 additions & 2 deletions internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ import (
"github.com/leg100/otf/internal/client"
"github.com/leg100/otf/internal/cloud"
"github.com/leg100/otf/internal/configversion"
"github.com/leg100/otf/internal/disco"
"github.com/leg100/otf/internal/http"
"github.com/leg100/otf/internal/http/html"
"github.com/leg100/otf/internal/inmem"
"github.com/leg100/otf/internal/loginserver"
"github.com/leg100/otf/internal/logs"
"github.com/leg100/otf/internal/module"
"github.com/leg100/otf/internal/organization"
Expand Down Expand Up @@ -77,8 +79,8 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) {
if cfg.DevMode {
logger.Info("enabled developer mode")
}
if cfg.Secret == "" {
return nil, &internal.MissingParameterError{Parameter: "secret"}
if err := cfg.Valid(); err != nil {
return nil, err
}

hostnameService := internal.NewHostnameService(cfg.Host)
Expand Down Expand Up @@ -260,6 +262,15 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) {
return nil, err
}

loginServer, err := loginserver.NewServer(loginserver.Options{
Secret: cfg.Secret,
Renderer: renderer,
TokensService: tokensService,
})
if err != nil {
return nil, err
}

api := api.New(api.Options{
WorkspaceService: workspaceService,
OrganizationService: orgService,
Expand Down Expand Up @@ -290,6 +301,8 @@ func New(ctx context.Context, logger logr.Logger, cfg Config) (*Daemon, error) {
logsService,
repoService,
authenticatorService,
loginServer,
disco.Service{},
api,
}

Expand Down
Loading

0 comments on commit 2e627ca

Please sign in to comment.