From c431b10397ab9a53ccf3b5f3328d6ecf33e87da4 Mon Sep 17 00:00:00 2001 From: Jordan Singer Date: Fri, 27 Jan 2023 15:21:33 -0600 Subject: [PATCH 01/13] start of auth --- cmd/klotho/main.go | 1 + go.mod | 14 ++- go.sum | 25 +++-- pkg/auth/auth.go | 208 ++++++++++++++++++++++++++++++++++++++++ pkg/auth/credentials.go | 47 +++++++++ pkg/cli/klothomain.go | 36 +++++-- 6 files changed, 315 insertions(+), 16 deletions(-) create mode 100644 pkg/auth/auth.go create mode 100644 pkg/auth/credentials.go diff --git a/cmd/klotho/main.go b/cmd/klotho/main.go index c114910c4..c4c88478b 100644 --- a/cmd/klotho/main.go +++ b/cmd/klotho/main.go @@ -12,5 +12,6 @@ func main() { return psb.AddAll() }, } + km.Main() } diff --git a/go.mod b/go.mod index 65dab23ac..cd163dae6 100644 --- a/go.mod +++ b/go.mod @@ -9,11 +9,12 @@ require ( github.com/davecgh/go-spew v1.1.1 github.com/fatih/color v1.13.0 github.com/gojek/heimdall/v7 v7.0.2 + github.com/golang-jwt/jwt/v4 v4.4.3 github.com/google/uuid v1.3.0 github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf github.com/kopoli/go-terminal-size v0.0.0-20170219200355-5c97524c8b54 github.com/pborman/ansi v1.0.0 - github.com/pelletier/go-toml/v2 v2.0.0-beta.6 + github.com/pelletier/go-toml/v2 v2.0.6 github.com/pkg/errors v0.9.1 github.com/smacker/go-tree-sitter v0.0.0-20220209044044-0d3022e933c3 github.com/spf13/cobra v1.6.1 @@ -29,6 +30,13 @@ require ( sigs.k8s.io/yaml v1.3.0 ) +require ( + github.com/gomodule/redigo v2.0.0+incompatible // indirect + github.com/kr/pretty v0.3.0 // indirect + github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect + golang.org/x/oauth2 v0.4.0 // indirect +) + replace github.com/smacker/go-tree-sitter => github.com/klothoplatform/go-tree-sitter v0.1.1 require ( @@ -85,7 +93,7 @@ require ( github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.12 // indirect - github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mattn/go-isatty v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.9 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect @@ -102,6 +110,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0-rc2 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect + github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_golang v1.14.0 // indirect github.com/prometheus/client_model v0.3.0 // indirect @@ -122,7 +131,6 @@ require ( go.uber.org/multierr v1.7.0 // indirect golang.org/x/crypto v0.5.0 // indirect golang.org/x/net v0.7.0 // indirect - golang.org/x/oauth2 v0.4.0 // indirect golang.org/x/sync v0.1.0 // indirect golang.org/x/sys v0.5.0 // indirect golang.org/x/term v0.5.0 // indirect diff --git a/go.sum b/go.sum index 500852d24..fb1cc40d3 100644 --- a/go.sum +++ b/go.sum @@ -259,6 +259,8 @@ github.com/gojek/heimdall/v7 v7.0.2 h1:+YutGXZ8oEWbCJIwjRnkKmoTl+Oxt1Urs3hc/FR0s github.com/gojek/heimdall/v7 v7.0.2/go.mod h1:Z43HtMid7ysSjmsedPTXAki6jcdcNVnjn5pmsTyiMic= github.com/gojek/valkyrie v0.0.0-20180215180059-6aee720afcdf h1:5xRGbUdOmZKoDXkGx5evVLehuCMpuO1hl701bEQqXOM= github.com/gojek/valkyrie v0.0.0-20180215180059-6aee720afcdf/go.mod h1:QzhUKaYKJmcbTnCYCAVQrroCOY7vOOI8cSQ4NbuhYf0= +github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU= +github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -293,7 +295,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/gomodule/redigo v1.8.2 h1:H5XSIre1MB5NbPYFp+i1NBbb5qN1W8Y8YAQoAYbkm8k= +github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0= +github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= @@ -436,8 +439,9 @@ github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= @@ -473,16 +477,18 @@ github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-oci8 v0.1.1/go.mod h1:wjDx6Xm9q7dFtHJvIlrI99JytznLw5wQ4R+9mNXJwGI= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= -github.com/mattn/go-sqlite3 v1.14.14 h1:qZgc/Rwetq+MtyE18WhzjokPD93dNqLGNT3QJuLvBGw= github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= +github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= @@ -560,11 +566,13 @@ github.com/pborman/ansi v1.0.0 h1:OqjHMhvlSuCCV5JT07yqPuJPQzQl+WXsiZ14gZsqOrQ= github.com/pborman/ansi v1.0.0/go.mod h1:SgWzwMAx1X/Ez7i90VqF8LRiQtx52pWDiQP+x3iGnzw= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml/v2 v2.0.0-beta.6 h1:JFNqj2afbbhCqTiyN16D7Tudc/aaDzE2FBDk+VlBQnE= -github.com/pelletier/go-toml/v2 v2.0.0-beta.6/go.mod h1:ke6xncR3W76Ba8xnVxkrZG0js6Rd2BsQEAYrfgJ6eQA= +github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU= +github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -613,6 +621,8 @@ github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqn github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rubenv/sql-migrate v1.2.0 h1:fOXMPLMd41sK7Tg75SXDec15k3zg5WNV6SjuDRiNfcU= github.com/rubenv/sql-migrate v1.2.0/go.mod h1:Z5uVnq7vrIrPmHbVFfR4YLHRZquxeHpckCnRq0P/K9Y= @@ -671,7 +681,6 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1-0.20210427113832-6241f9ab9942/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -933,6 +942,7 @@ golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -941,6 +951,7 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go new file mode 100644 index 000000000..b071f6342 --- /dev/null +++ b/pkg/auth/auth.go @@ -0,0 +1,208 @@ +package auth + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/klothoplatform/klotho/pkg/cli_config" + "github.com/pkg/browser" + "go.uber.org/zap" +) + +type LoginResponse struct { + Url string + State string +} + +func Login() error { + state, err := CallLoginEndpoint() + if err != nil { + return err + } + err = retry(20, time.Duration(5)*time.Second, CallGetTokenEndpoint, state) + return err +} + +func CallLoginEndpoint() (string, error) { + res, err := http.Get("http://localhost:3000/login") + if err != nil { + return "", err + } + body, err := io.ReadAll(res.Body) + if err != nil { + return "", err + } + result := LoginResponse{} + err = json.Unmarshal(body, &result) + if err != nil { + return "", err + } + err = browser.OpenURL(result.Url) + if err != nil { + return "", err + } + defer res.Body.Close() + return result.State, nil +} + +func CallGetTokenEndpoint(state string) error { + values := map[string]string{"state": state} + jsonData, err := json.Marshal(values) + if err != nil { + log.Fatal(err) + } + res, err := http.Post("http://localhost:3000/logintoken", "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + if res.StatusCode != 200 { + return fmt.Errorf("recieved invalid status code %d", res.StatusCode) + } + body, err := io.ReadAll(res.Body) + if err != nil { + return err + } + err = WriteIDToken(string(body)) + if err != nil { + return err + } + defer res.Body.Close() + return nil +} + +func CallLogoutEndpoint() error { + res, _ := http.Get("http://localhost:3000/logout") + body, _ := io.ReadAll(res.Body) + _ = browser.OpenURL(string(body)) + defer res.Body.Close() + + configPath, err := cli_config.KlothoConfigPath("credentials.json") + if err != nil { + return err + } + err = os.Remove(configPath) + if err != nil { + return err + } + return nil +} + +func CallRefreshToken(token string) error { + values := map[string]string{"refresh_token": token} + jsonData, err := json.Marshal(values) + if err != nil { + return err + } + res, err := http.Post("http://localhost:3000/refresh", "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + body, err := io.ReadAll(res.Body) + if err != nil { + return err + } + err = WriteIDToken(string(body)) + if err != nil { + return err + } + defer res.Body.Close() + return nil +} + +type MyCustomClaims struct { + ProEnabled bool + ProTier int + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + jwt.StandardClaims +} + +func Authorize(tokenRefreshed bool) error { + creds, err := GetIDToken() + if err != nil { + return errors.New("failed to get credentials for user, please login") + } + + token, err := jwt.ParseWithClaims(creds.IdToken, &MyCustomClaims{}, func(token *jwt.Token) (interface{}, error) { + return nil, nil + }) + if err != nil { + zap.S().Debug(err) + } + if claims, ok := token.Claims.(*MyCustomClaims); ok { + if !claims.EmailVerified { + if tokenRefreshed { + return fmt.Errorf("user %s, has not verified their email", claims.Email) + } + err := CallRefreshToken(creds.RefreshToken) + if err != nil { + return err + } + err = Authorize(true) + if err != nil { + return err + } + } else if !claims.ProEnabled { + return fmt.Errorf("user %s is not authorized to use KlothoPro", claims.Email) + } else if claims.IssuedAt < time.Now().Unix() { + if tokenRefreshed { + return fmt.Errorf("user %s, does not have a valid token", claims.Email) + } + err := CallRefreshToken(creds.RefreshToken) + if err != nil { + return err + } + err = Authorize(true) + if err != nil { + return err + } + } + } else { + return errors.New("failed to authorize user") + } + return nil +} + +func GetUserEmail() (string, error) { + creds, err := GetIDToken() + if err != nil { + return "", errors.New("failed to get credentials for user, please login") + } + token, err := jwt.ParseWithClaims(creds.IdToken, &MyCustomClaims{}, func(token *jwt.Token) (interface{}, error) { + return nil, nil + }) + if err != nil { + zap.S().Debug(err) + } + if claims, ok := token.Claims.(*MyCustomClaims); ok { + return claims.Email, nil + } else { + return "", errors.New("failed to authorize user") + } +} + +func retry(attempts int, sleep time.Duration, f func(state string) error, state string) (err error) { + for i := 0; ; i++ { + err = f(state) + if err == nil { + return + } + + if i >= (attempts - 1) { + break + } + + time.Sleep(sleep) + sleep *= 2 + } + return fmt.Errorf("after %d attempts, last error: %s", attempts, err) +} diff --git a/pkg/auth/credentials.go b/pkg/auth/credentials.go new file mode 100644 index 000000000..27d2a85f1 --- /dev/null +++ b/pkg/auth/credentials.go @@ -0,0 +1,47 @@ +package auth + +import ( + "encoding/json" + "os" + + "github.com/klothoplatform/klotho/pkg/cli_config" +) + +type Credentials struct { + IdToken string + RefreshToken string +} + +func WriteIDToken(token string) error { + + configPath, err := cli_config.KlothoConfigPath("credentials.json") + if err != nil { + return err + } + err = cli_config.CreateKlothoConfigPath() + if err != nil { + return err + } + err = os.WriteFile(configPath, []byte(token), 0644) + if err != nil { + return err + } + return nil +} + +func GetIDToken() (*Credentials, error) { + configPath, err := cli_config.KlothoConfigPath("credentials.json") + result := Credentials{} + + if err != nil { + return &result, err + } + + content, err := os.ReadFile(configPath) + if err != nil { + return &result, err + } + err = json.Unmarshal(content, &result) + + return &result, err +} diff --git a/pkg/cli/klothomain.go b/pkg/cli/klothomain.go index 3adc3bf95..d8cf6e15a 100644 --- a/pkg/cli/klothomain.go +++ b/pkg/cli/klothomain.go @@ -5,6 +5,7 @@ import ( "os" "regexp" + "github.com/klothoplatform/klotho/pkg/auth" "github.com/klothoplatform/klotho/pkg/cli_config" "github.com/klothoplatform/klotho/pkg/updater" @@ -45,8 +46,9 @@ var cfg struct { uploadSource bool update bool cfgFormat string - login string setOption map[string]string + login bool + logout bool } const defaultDisableLogo = false @@ -91,8 +93,9 @@ func (km KlothoMain) Main() { flags.BoolVar(&cfg.internalDebug, "internalDebug", false, "Enable debugging for compiler") flags.BoolVar(&cfg.version, "version", false, "Print the version") flags.BoolVar(&cfg.update, "update", false, "update the cli to the latest version") - flags.StringVar(&cfg.login, "login", "", "Login to Klotho with email. For anonymous login, use 'local'") flags.StringToStringVar(&cfg.setOption, "set-option", nil, "Sets a CLI option") + flags.BoolVar(&cfg.login, "login", false, "Login to Klotho with email. For anonymous login, use 'local'") + flags.BoolVar(&cfg.logout, "logout", false, "Logout of current klotho account.") _ = flags.MarkHidden("internalDebug") err := root.Execute() @@ -108,6 +111,7 @@ func (km KlothoMain) Main() { if hadWarnings.Load() && cfg.strict { os.Exit(1) } + //finished <- true } func setupLogger(analyticsClient *analytics.Client) (*zap.Logger, error) { @@ -169,7 +173,6 @@ func (km KlothoMain) run(cmd *cobra.Command, args []string) (err error) { // supports color if !color.NoColor && showLogo { color.New(color.FgHiGreen).Println(Logo) - fmt.Println() } // create config directory if necessary, must run @@ -179,13 +182,34 @@ func (km KlothoMain) run(cmd *cobra.Command, args []string) (err error) { } // Set up user if login is specified - if cfg.login != "" { - if err := analytics.CreateUser(cfg.login); err != nil { - return errors.Wrapf(err, "could not configure user '%s'", cfg.login) + if cfg.login { + err := auth.Login() + if err != nil { + return err + } + email, err := auth.GetUserEmail() + if err != nil { + return err + } + if err := analytics.CreateUser(email); err != nil { + return errors.Wrapf(err, "could not configure user '%s'", email) + } + return nil + } + // Set up user if login is specified + if cfg.logout { + err := auth.CallLogoutEndpoint() + if err != nil { + return err } return nil } + err = auth.Authorize(false) + if err != nil { + return err + + } // Set up analytics analyticsClient, err := analytics.NewClient(map[string]interface{}{ "version": km.Version, From 94946a65e4fb1f2f63cde1c1b274461859fa4920 Mon Sep 17 00:00:00 2001 From: Yuval Shavit <110620369+yuval-klotho@users.noreply.github.com> Date: Wed, 1 Feb 2023 18:44:34 -0500 Subject: [PATCH 02/13] add `--local` auth This introduces a new Authorizer, which is hookable per KlothoMain. The default one just calls auth.Authorize(), but KlothoMains can provide an alternative, including one that adds flags. resolves #164 --- cmd/klotho/main.go | 18 ++++++++++++++++++ pkg/auth/auth.go | 27 ++++++++++++++++++++++++--- pkg/cli/klothomain.go | 26 ++++++++++++++++++++------ 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/cmd/klotho/main.go b/cmd/klotho/main.go index c4c88478b..0ad5a4e26 100644 --- a/cmd/klotho/main.go +++ b/cmd/klotho/main.go @@ -1,17 +1,35 @@ package main import ( + "github.com/klothoplatform/klotho/pkg/auth" "github.com/klothoplatform/klotho/pkg/cli" + "github.com/spf13/pflag" ) func main() { + authRequirement := LocalAuth(false) km := cli.KlothoMain{ DefaultUpdateStream: "open:latest", Version: Version, PluginSetup: func(psb *cli.PluginSetBuilder) error { return psb.AddAll() }, + Authorizer: &authRequirement, } km.Main() } + +// LocalAuth is an auth.Authorizer that requires login unless its value is true. +type LocalAuth bool + +func (local *LocalAuth) SetUpCliFlags(flags *pflag.FlagSet) { + flags.BoolVar((*bool)(local), "local", bool(*local), "If provided, runs Klotho with a local login (that is, not requiring an authenticated login)") +} + +func (local *LocalAuth) Authorize() error { + if !*local { + return auth.Authorize() + } + return nil +} diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index b071f6342..cb80ef67a 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -22,6 +22,23 @@ type LoginResponse struct { State string } +type Authorizer interface { + Authorize() error +} + +func DefaultIfNil(auth Authorizer) Authorizer { + if auth == nil { + return standardAuthorizer{} + } + return auth +} + +type standardAuthorizer struct{} + +func (s standardAuthorizer) Authorize() error { + return Authorize() +} + func Login() error { state, err := CallLoginEndpoint() if err != nil { @@ -126,7 +143,11 @@ type MyCustomClaims struct { jwt.StandardClaims } -func Authorize(tokenRefreshed bool) error { +func Authorize() error { + return authorize(false) +} + +func authorize(tokenRefreshed bool) error { creds, err := GetIDToken() if err != nil { return errors.New("failed to get credentials for user, please login") @@ -147,7 +168,7 @@ func Authorize(tokenRefreshed bool) error { if err != nil { return err } - err = Authorize(true) + err = authorize(true) if err != nil { return err } @@ -161,7 +182,7 @@ func Authorize(tokenRefreshed bool) error { if err != nil { return err } - err = Authorize(true) + err = authorize(true) if err != nil { return err } diff --git a/pkg/cli/klothomain.go b/pkg/cli/klothomain.go index d8cf6e15a..c00c24dc0 100644 --- a/pkg/cli/klothomain.go +++ b/pkg/cli/klothomain.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "github.com/spf13/pflag" "os" "regexp" @@ -29,6 +30,12 @@ type KlothoMain struct { Version string VersionQualifier string PluginSetup func(*PluginSetBuilder) error + // Authorizer is an optional authorizer override. If this also conforms to FlagsProvider, those flags will be added. + Authorizer auth.Authorizer +} + +type FlagsProvider interface { + SetUpCliFlags(flags *pflag.FlagSet) } var cfg struct { @@ -71,6 +78,7 @@ const ( ) func (km KlothoMain) Main() { + km.Authorizer = auth.DefaultIfNil(km.Authorizer) var root = &cobra.Command{ Use: "klotho [path to source]", @@ -94,8 +102,13 @@ func (km KlothoMain) Main() { flags.BoolVar(&cfg.version, "version", false, "Print the version") flags.BoolVar(&cfg.update, "update", false, "update the cli to the latest version") flags.StringToStringVar(&cfg.setOption, "set-option", nil, "Sets a CLI option") - flags.BoolVar(&cfg.login, "login", false, "Login to Klotho with email. For anonymous login, use 'local'") + flags.BoolVar(&cfg.login, "login", false, "Login to Klotho with email.") flags.BoolVar(&cfg.logout, "logout", false, "Logout of current klotho account.") + + if authFlags, hasFlags := km.Authorizer.(FlagsProvider); hasFlags { + authFlags.SetUpCliFlags(flags) + } + _ = flags.MarkHidden("internalDebug") err := root.Execute() @@ -205,11 +218,6 @@ func (km KlothoMain) run(cmd *cobra.Command, args []string) (err error) { return nil } - err = auth.Authorize(false) - if err != nil { - return err - - } // Set up analytics analyticsClient, err := analytics.NewClient(map[string]interface{}{ "version": km.Version, @@ -253,6 +261,12 @@ func (km KlothoMain) run(cmd *cobra.Command, args []string) (err error) { analyticsClient.Properties[km.VersionQualifier] = true } + // Needs to go after the --version and --update checks + err = km.Authorizer.Authorize() + if err != nil { + return err + } + // if update is specified do the update in place var klothoUpdater = updater.Updater{ ServerURL: updater.DefaultServer, From 60d553e2b36d5445f9770b730a6559611379d158 Mon Sep 17 00:00:00 2001 From: Yuval Shavit <110620369+yuval-klotho@users.noreply.github.com> Date: Thu, 2 Feb 2023 18:48:40 -0500 Subject: [PATCH 03/13] remove client-side login retry Before this, the server's /logintoken API would return instantly, with either the token from auth0 or 404 if it hadn't been set yet from the auth0.com callback. So, we needed a callback not just for intermittent errors, but for the happy path functionality. Now, the /logintoken call hangs until it gets that token (or times out), so the happy path doesn't need a retry. We could still need a retry for intermittent failurs, but that's a more general problem that we should resolve across all http calls, not just for auth. I'm also moving the Login error handler to a callback, just as an organizational tool. We want to send the analytics for any error manually (since the client hasn't been set up yet), but the Login method shouldn't have to know about that intricacy. klothomain.go does, so that's where we should put that logic --- pkg/auth/auth.go | 26 ++++++-------------------- pkg/cli/klothomain.go | 20 ++++++++++++++------ 2 files changed, 20 insertions(+), 26 deletions(-) diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index cb80ef67a..c2b6e9c5c 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -39,13 +39,16 @@ func (s standardAuthorizer) Authorize() error { return Authorize() } -func Login() error { +func Login(onError func(error)) error { state, err := CallLoginEndpoint() if err != nil { return err } - err = retry(20, time.Duration(5)*time.Second, CallGetTokenEndpoint, state) - return err + err = CallGetTokenEndpoint(state) + if err != nil { + onError(err) + } + return nil } func CallLoginEndpoint() (string, error) { @@ -210,20 +213,3 @@ func GetUserEmail() (string, error) { return "", errors.New("failed to authorize user") } } - -func retry(attempts int, sleep time.Duration, f func(state string) error, state string) (err error) { - for i := 0; ; i++ { - err = f(state) - if err == nil { - return - } - - if i >= (attempts - 1) { - break - } - - time.Sleep(sleep) - sleep *= 2 - } - return fmt.Errorf("after %d attempts, last error: %s", attempts, err) -} diff --git a/pkg/cli/klothomain.go b/pkg/cli/klothomain.go index c00c24dc0..f8ae15a86 100644 --- a/pkg/cli/klothomain.go +++ b/pkg/cli/klothomain.go @@ -194,9 +194,21 @@ func (km KlothoMain) run(cmd *cobra.Command, args []string) (err error) { zap.S().Warnf("failed to create .klotho directory: %v", err) } + analyticsClientProperties := map[string]interface{}{ + "version": km.Version, + "strict": cfg.strict, + "edition": km.DefaultUpdateStream, + } + // Set up user if login is specified if cfg.login { - err := auth.Login() + err := auth.Login(func(err error) { + // We don't have the analytics client set up to the logger yet, so the warn message won't send any + //analytics. Manually create a client and send the tracking. + zap.L().Warn(`Couldn't log in. You may be able to continue using klotho without logging in for now, but this may break in the future. Please contact us if this continues.'`) + client := &analytics.Client{Properties: analyticsClientProperties} + client.Warn("login failed") + }) if err != nil { return err } @@ -219,11 +231,7 @@ func (km KlothoMain) run(cmd *cobra.Command, args []string) (err error) { } // Set up analytics - analyticsClient, err := analytics.NewClient(map[string]interface{}{ - "version": km.Version, - "strict": cfg.strict, - "edition": km.DefaultUpdateStream, - }) + analyticsClient, err := analytics.NewClient(analyticsClientProperties) if err != nil { return errors.New(fmt.Sprintf("Issue retrieving user info: %s. \nYou may need to run: klotho --login ", err)) } From b6f93b66153d5e622adb2c0b575abf6339eb8593 Mon Sep 17 00:00:00 2001 From: Yuval Shavit <110620369+yuval-klotho@users.noreply.github.com> Date: Thu, 2 Feb 2023 18:56:37 -0500 Subject: [PATCH 04/13] make the auth endpoint configurable I'm doing it as a base (instead of just hostname) so that people can specify http and port (especially for localhost testing). --- pkg/auth/auth.go | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index c2b6e9c5c..8bc6a4a1b 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -17,6 +17,8 @@ import ( "go.uber.org/zap" ) +var authUrlBase = getAuthUrlBase() + type LoginResponse struct { Url string State string @@ -52,7 +54,7 @@ func Login(onError func(error)) error { } func CallLoginEndpoint() (string, error) { - res, err := http.Get("http://localhost:3000/login") + res, err := http.Get(authUrlBase + "/login") if err != nil { return "", err } @@ -79,7 +81,7 @@ func CallGetTokenEndpoint(state string) error { if err != nil { log.Fatal(err) } - res, err := http.Post("http://localhost:3000/logintoken", "application/json", bytes.NewBuffer(jsonData)) + res, err := http.Post(authUrlBase+"/logintoken", "application/json", bytes.NewBuffer(jsonData)) if err != nil { return err } @@ -99,7 +101,7 @@ func CallGetTokenEndpoint(state string) error { } func CallLogoutEndpoint() error { - res, _ := http.Get("http://localhost:3000/logout") + res, _ := http.Get(authUrlBase + "/logout") body, _ := io.ReadAll(res.Body) _ = browser.OpenURL(string(body)) defer res.Body.Close() @@ -121,7 +123,7 @@ func CallRefreshToken(token string) error { if err != nil { return err } - res, err := http.Post("http://localhost:3000/refresh", "application/json", bytes.NewBuffer(jsonData)) + res, err := http.Post(authUrlBase+"/refresh", "application/json", bytes.NewBuffer(jsonData)) if err != nil { return err } @@ -213,3 +215,11 @@ func GetUserEmail() (string, error) { return "", errors.New("failed to authorize user") } } + +func getAuthUrlBase() string { + host := os.Getenv("KLOTHO_AUTH_BASE") + if host == "" { + host = "http://klotho-auth-service-alb-e22c092-466389525.us-east-1.elb.amazonaws.com" + } + return host +} From 485a7a1a1e5a7b85122395d04bae83681ff42640 Mon Sep 17 00:00:00 2001 From: Jordan Singer Date: Thu, 2 Feb 2023 18:05:59 -0600 Subject: [PATCH 05/13] check against expiry not issuance --- pkg/auth/auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 8bc6a4a1b..2dbae675d 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -179,7 +179,7 @@ func authorize(tokenRefreshed bool) error { } } else if !claims.ProEnabled { return fmt.Errorf("user %s is not authorized to use KlothoPro", claims.Email) - } else if claims.IssuedAt < time.Now().Unix() { + } else if claims.ExpiresAt < time.Now().Unix() { if tokenRefreshed { return fmt.Errorf("user %s, does not have a valid token", claims.Email) } From 9031440327c236521ecb2b6edd1bb0d348f063e9 Mon Sep 17 00:00:00 2001 From: Yuval Shavit <110620369+yuval-klotho@users.noreply.github.com> Date: Fri, 3 Feb 2023 10:11:51 -0500 Subject: [PATCH 06/13] fix some Close() calls In some cases, were creating the deferred close after the method would have already returned. Also, we were dropping some errors on the ground; this will now handle them. --- pkg/auth/auth.go | 27 +++++++++++++++++++-------- pkg/cli/klothomain.go | 1 - pkg/closenicely/closeutil.go | 12 ++++++++++++ 3 files changed, 31 insertions(+), 9 deletions(-) create mode 100644 pkg/closenicely/closeutil.go diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 2dbae675d..1941bd2f7 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -3,8 +3,9 @@ package auth import ( "bytes" "encoding/json" - "errors" "fmt" + "github.com/klothoplatform/klotho/pkg/closenicely" + "github.com/pkg/errors" "io" "log" "net/http" @@ -58,6 +59,7 @@ func CallLoginEndpoint() (string, error) { if err != nil { return "", err } + defer closenicely.OrDebug(res.Body) body, err := io.ReadAll(res.Body) if err != nil { return "", err @@ -71,7 +73,6 @@ func CallLoginEndpoint() (string, error) { if err != nil { return "", err } - defer res.Body.Close() return result.State, nil } @@ -85,6 +86,7 @@ func CallGetTokenEndpoint(state string) error { if err != nil { return err } + defer closenicely.OrDebug(res.Body) if res.StatusCode != 200 { return fmt.Errorf("recieved invalid status code %d", res.StatusCode) } @@ -96,15 +98,24 @@ func CallGetTokenEndpoint(state string) error { if err != nil { return err } - defer res.Body.Close() return nil } func CallLogoutEndpoint() error { - res, _ := http.Get(authUrlBase + "/logout") - body, _ := io.ReadAll(res.Body) - _ = browser.OpenURL(string(body)) - defer res.Body.Close() + res, err := http.Get(authUrlBase + "/logout") + if err != nil { + return errors.Wrap(err, "couldn't invoke logout URL") + } + defer closenicely.OrDebug(res.Body) + body, err := io.ReadAll(res.Body) + if err != nil { + return errors.Wrap(err, "couldn't read logout redirect URL") + } + err = browser.OpenURL(string(body)) + if err != nil { + zap.S().Debug("couldn't open logout URL: %s", string(body)) + zap.L().Warn("couldn't open logout URL. If this persists, run with --verbose to see it. Will still clear local credentials.") + } configPath, err := cli_config.KlothoConfigPath("credentials.json") if err != nil { @@ -127,6 +138,7 @@ func CallRefreshToken(token string) error { if err != nil { return err } + defer closenicely.OrDebug(res.Body) body, err := io.ReadAll(res.Body) if err != nil { return err @@ -135,7 +147,6 @@ func CallRefreshToken(token string) error { if err != nil { return err } - defer res.Body.Close() return nil } diff --git a/pkg/cli/klothomain.go b/pkg/cli/klothomain.go index f8ae15a86..fc74c392a 100644 --- a/pkg/cli/klothomain.go +++ b/pkg/cli/klothomain.go @@ -124,7 +124,6 @@ func (km KlothoMain) Main() { if hadWarnings.Load() && cfg.strict { os.Exit(1) } - //finished <- true } func setupLogger(analyticsClient *analytics.Client) (*zap.Logger, error) { diff --git a/pkg/closenicely/closeutil.go b/pkg/closenicely/closeutil.go new file mode 100644 index 000000000..a6f8ed5f7 --- /dev/null +++ b/pkg/closenicely/closeutil.go @@ -0,0 +1,12 @@ +package closenicely + +import ( + "go.uber.org/zap" + "io" +) + +func OrDebug(closer io.Closer) { + if err := closer.Close(); err != nil { + zap.L().Debug("Failed to close resource", zap.Error(err)) + } +} From eca24643fadfdbf1770b0e7341c5ae90d0394def Mon Sep 17 00:00:00 2001 From: jhsinger-klotho <111291520+jhsinger-klotho@users.noreply.github.com> Date: Fri, 3 Feb 2023 09:26:54 -0600 Subject: [PATCH 07/13] allow env var for id token --- pkg/auth/auth.go | 9 ++++++--- pkg/auth/credentials.go | 8 ++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 1941bd2f7..c43914d17 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -121,9 +121,11 @@ func CallLogoutEndpoint() error { if err != nil { return err } - err = os.Remove(configPath) - if err != nil { - return err + if _, err := os.Stat(configPath); err == nil { + err = os.Remove(configPath) + if err != nil { + return err + } } return nil } @@ -175,6 +177,7 @@ func authorize(tokenRefreshed bool) error { if err != nil { zap.S().Debug(err) } + if claims, ok := token.Claims.(*MyCustomClaims); ok { if !claims.EmailVerified { if tokenRefreshed { diff --git a/pkg/auth/credentials.go b/pkg/auth/credentials.go index 27d2a85f1..9f5dcb176 100644 --- a/pkg/auth/credentials.go +++ b/pkg/auth/credentials.go @@ -30,6 +30,14 @@ func WriteIDToken(token string) error { } func GetIDToken() (*Credentials, error) { + + idToken := os.Getenv("KLOTHO_ID_TOKEN") + if idToken != "" { + return &Credentials{ + IdToken: idToken, + }, nil + } + configPath, err := cli_config.KlothoConfigPath("credentials.json") result := Credentials{} From a0b275911f9626a872ac463544c7277165d70d78 Mon Sep 17 00:00:00 2001 From: Yuval Shavit <110620369+yuval-klotho@users.noreply.github.com> Date: Tue, 7 Feb 2023 10:08:49 -0500 Subject: [PATCH 08/13] replace local login with auth0 - split analytics/client.go:NewClient up, such that its email stuff is in a new method, AttachAuthorizations(). - rm most of analytics/user.go, and replace it with a tiny method GetOrCreateAnalyticsFile(). This just upserts the ~/.klotho/analytics.json file. All of the logic around retrieving the user and validating emails and all that is just gone. - auth/auth.go: - Authorize() now returns its claims. We pass these to AttachAuthorizations(), mentioned above. - We download our auth0 PEM (and cache it), and use it to verify the auth0 token. Without this, we can't actually get the claims. - cli/klothomain.go: - construct analytics.Client just once, and then later attach the email from the claims (this resolves #180) - also move the log hooks up, to follow the client - use RegisteredClaims instead of StandardClaims. Per the [jwt docs][1]: > Deprecated: Use RegisteredClaims instead for a forward-compatible way > to access registered claims in a struct. [1]: https://pkg.go.dev/github.com/golang-jwt/jwt/v4#StandardClaims --- cmd/klotho/main.go | 4 +- pkg/analytics/client.go | 40 +++---- pkg/analytics/tracking_utils.go | 22 +--- pkg/analytics/user.go | 188 ++++---------------------------- pkg/auth/auth.go | 175 +++++++++++++++++++---------- pkg/auth/credentials.go | 1 - pkg/cli/klothomain.go | 39 +++---- pkg/closenicely/closeutil.go | 6 +- 8 files changed, 176 insertions(+), 299 deletions(-) diff --git a/cmd/klotho/main.go b/cmd/klotho/main.go index 0ad5a4e26..9042cd21d 100644 --- a/cmd/klotho/main.go +++ b/cmd/klotho/main.go @@ -27,9 +27,9 @@ func (local *LocalAuth) SetUpCliFlags(flags *pflag.FlagSet) { flags.BoolVar((*bool)(local), "local", bool(*local), "If provided, runs Klotho with a local login (that is, not requiring an authenticated login)") } -func (local *LocalAuth) Authorize() error { +func (local *LocalAuth) Authorize() (*auth.KlothoClaims, error) { if !*local { return auth.Authorize() } - return nil + return nil, nil } diff --git a/pkg/analytics/client.go b/pkg/analytics/client.go index 1eba3e730..c5c1d417e 100644 --- a/pkg/analytics/client.go +++ b/pkg/analytics/client.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/json" "fmt" + "github.com/klothoplatform/klotho/pkg/auth" "github.com/google/uuid" "github.com/klothoplatform/klotho/pkg/core" @@ -36,38 +37,29 @@ var ( var datadogLogLevel = "_logLevel" var datadogStatus = "status" -func NewClient(properties map[string]interface{}) (*Client, error) { - result, err := getTrackingFileContents(analyticsFile) - if err != nil { - return nil, err - } - user := RetrieveUser(result) - if user == nil { - return nil, errors.New("required user info not set") - } - - err = user.RegisterUser() - if err != nil { - return nil, err - } +func NewClient(properties map[string]interface{}) *Client { + local := GetOrCreateAnalyticsFile() client := &Client{ Properties: properties, } - if user.Email != "" { - client.UserId = user.Email - client.Properties["validated"] = user.Validated - if user.Id != "" { - client.Properties["localId"] = user.Id - } - } else { - client.UserId = user.Id - } + + // These will get validated in AttachAuthorizations + client.UserId = local.Id + client.Properties["validated"] = false + + client.Properties["localId"] = local.Id if runUuid, err := uuid.NewRandom(); err == nil { client.Properties["runId"] = runUuid.String() } - return client, nil + return client +} + +func (t *Client) AttachAuthorizations(claims *auth.KlothoClaims) { + t.Properties["localId"] = t.UserId + t.UserId = claims.Email + t.Properties["validated"] = claims.EmailVerified } func (t *Client) Info(event string) { diff --git a/pkg/analytics/tracking_utils.go b/pkg/analytics/tracking_utils.go index fb4e03033..41c06d8fe 100644 --- a/pkg/analytics/tracking_utils.go +++ b/pkg/analytics/tracking_utils.go @@ -5,10 +5,8 @@ import ( "bytes" "encoding/json" "fmt" - "github.com/klothoplatform/klotho/pkg/cli_config" "math" "net/http" - "os" "time" "github.com/klothoplatform/klotho/pkg/core" @@ -17,8 +15,7 @@ import ( var kloServerUrl = "http://srv.klo.dev" type AnalyticsFile struct { - Email string - Id string + Id string } func SendTrackingToServer(bundle *Client) error { @@ -72,20 +69,3 @@ func CompressFiles(input *core.InputFiles) ([]byte, error) { return buf.Bytes(), err } - -func getTrackingFileContents(file string) (AnalyticsFile, error) { - configPath, err := cli_config.KlothoConfigPath(file) - result := AnalyticsFile{} - - if err != nil { - return result, err - } - - content, err := os.ReadFile(configPath) - if err != nil { - return result, err - } - err = json.Unmarshal(content, &result) - - return result, err -} diff --git a/pkg/analytics/user.go b/pkg/analytics/user.go index e81a5b062..75c73ec22 100644 --- a/pkg/analytics/user.go +++ b/pkg/analytics/user.go @@ -1,18 +1,11 @@ package analytics import ( - "bytes" "encoding/json" - "errors" - "fmt" - "github.com/klothoplatform/klotho/pkg/cli_config" - "net/http" - "net/mail" - "os" - - "github.com/fatih/color" "github.com/google/uuid" + "github.com/klothoplatform/klotho/pkg/cli_config" "go.uber.org/zap" + "os" ) type User struct { @@ -30,184 +23,49 @@ type Validated struct { // located in ~/.klotho/ var analyticsFile = "analytics.json" -func CreateUser(email string) error { - +func GetOrCreateAnalyticsFile() AnalyticsFile { // Check if the analytics file exists. If it does, try retrieving the user. // If it doesn't or we error because the data is invalid, it's fine. // We will create the new user and override the invalid or non-existent file - result, err := getTrackingFileContents(analyticsFile) - var existUser *User - if err == nil { - existUser = RetrieveUser(result) - } - - user := User{} - if email == "local" { - // login local will wipe an existing set email, but we want to preserve any set uuid - if existUser != nil { - user.Id = existUser.Id - } else { - user.Id = uuid.New().String() - } - printLocalLoginMessage() - } else { - addr, err := mail.ParseAddress(email) - if err != nil { - return err - } - - if existUser == nil { - user.Email = addr.Address - if err := user.SendUserEmailValidation(); err != nil { - return err - } - printEmailLoginMessage(user.Email) - } else { - // preserve the uuid if it was set before - user.Id = existUser.Id - user.Email = addr.Address - validated := false - // Determine if the address provided is new or the same and if we need to do any validation - if existUser.Email == addr.Address { - validated = existUser.Validated - } else { - if v, err := user.CheckUserEmailValidation(); err != nil { - zap.L().Warn("Failed to validate email with server") - } else { - user.Validated = v.Validated - } - validated = user.Validated - } - - if validated { - printEmailLoginMessage(user.Email) - } else { - if err := user.SendUserEmailValidation(); err != nil { - return err - } - printEmailLoginMessage(user.Email) - } - } - } - - configPath, err := cli_config.KlothoConfigPath(analyticsFile) - if err != nil { - return err - } - return user.writeConfig(configPath) -} - -func RetrieveUser(result AnalyticsFile) *User { - user := User{} - - if result.Email != "" { - user.Email = result.Email - if v, err := user.CheckUserEmailValidation(); err != nil { - zap.L().Warn("Failed to validate email with server") - } else { - user.Validated = v.Validated - } - } - if result.Id != "" { - user.Id = result.Id - } - if (User{} == user) { - return nil - } - return &user -} - -func (u *User) CheckUserEmailValidation() (*Validated, error) { - postBody, err := json.Marshal(u) - if err != nil { - return nil, err - } - data := bytes.NewBuffer(postBody) - resp, err := http.Post(fmt.Sprintf("%v/user/check-validation", kloServerUrl), "application/json", data) - if err != nil { - return nil, err - } - - if resp.StatusCode != http.StatusOK { - return nil, errors.New("failed to check user validation") + localLogin, err := getTrackingFileContents(analyticsFile) + if err == nil { + return localLogin } + login := AnalyticsFile{Id: uuid.New().String()} - defer resp.Body.Close() - - validated := Validated{} - - err = json.NewDecoder(resp.Body).Decode(&validated) - + // Try to write the file, but don't let any errors stop us + err = writeTrackingFileContents(analyticsFile, AnalyticsFile{Id: login.Id}) if err != nil { - return nil, err + zap.L().Debug("Couldn't write local analytics state", zap.Error(err)) } - - return &validated, nil + return login } - -func (u *User) SendUserEmailValidation() error { - postBody, _ := json.Marshal(u) - data := bytes.NewBuffer(postBody) - resp, err := http.Post(fmt.Sprintf("%v/user/send-validation", kloServerUrl), "application/json", data) +func getTrackingFileContents(file string) (AnalyticsFile, error) { + configPath, err := cli_config.KlothoConfigPath(file) + result := AnalyticsFile{} if err != nil { - return err + return result, err } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return errors.New("failed to send user validation email") - } - - return nil -} - -func (user *User) writeConfig(configPath string) error { - content, err := json.Marshal(user) + content, err := os.ReadFile(configPath) if err != nil { - return err + return result, err } + err = json.Unmarshal(content, &result) - return os.WriteFile(configPath, content, 0660) + return result, err } -func (u *User) RegisterUser() error { - - postBody, err := json.Marshal(u) +func writeTrackingFileContents(file string, contents AnalyticsFile) error { + configPath, err := cli_config.KlothoConfigPath(file) if err != nil { return err } - - data := bytes.NewBuffer(postBody) - resp, err := http.Post(fmt.Sprintf("%v/analytics/user", kloServerUrl), "application/json", data) + loginJson, err := json.Marshal(contents) if err != nil { return err } - - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("non 200 status code: %v", resp.StatusCode) - } - - return nil -} - -func printLocalLoginMessage() { - color.New(color.FgHiGreen).Println("Success: Logged in as local user") - color.New(color.FgYellow).Println( - "If you would like to \n", - " \u2022 Receive support with klotho issues\n", - " \u2022 Help shape the future of the product\n", - " \u2022 Access features like the developer console", - ) - color.New(color.FgHiBlue).Println( - "run:\n", - " $ klotho --login ", - ) -} - -func printEmailLoginMessage(email string) { - color.New(color.FgHiGreen).Printf("Success: Logged in as %s\n\n", email) + return os.WriteFile(configPath, loginJson, 0660) } diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index c43914d17..a27a5bfa5 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -2,14 +2,19 @@ package auth import ( "bytes" + "crypto/rsa" + "crypto/x509" "encoding/json" + "encoding/pem" "fmt" "github.com/klothoplatform/klotho/pkg/closenicely" "github.com/pkg/errors" "io" "log" "net/http" + "net/url" "os" + "path" "time" "github.com/golang-jwt/jwt/v4" @@ -18,7 +23,9 @@ import ( "go.uber.org/zap" ) -var authUrlBase = getAuthUrlBase() +var authUrlBase = EnvVar("KLOTHO_AUTH_BASE").GetOr(`http://klotho-auth-service-alb-e22c092-466389525.us-east-1.elb.amazonaws.com`) + +var pemUrl = EnvVar("KLOTHO_AUTH_PEM").GetOr(`https://klotho.us.auth0.com/pem`) type LoginResponse struct { Url string @@ -26,7 +33,7 @@ type LoginResponse struct { } type Authorizer interface { - Authorize() error + Authorize() (*KlothoClaims, error) } func DefaultIfNil(auth Authorizer) Authorizer { @@ -38,7 +45,7 @@ func DefaultIfNil(auth Authorizer) Authorizer { type standardAuthorizer struct{} -func (s standardAuthorizer) Authorize() error { +func (s standardAuthorizer) Authorize() (*KlothoClaims, error) { return Authorize() } @@ -152,88 +159,138 @@ func CallRefreshToken(token string) error { return nil } -type MyCustomClaims struct { +type KlothoClaims struct { ProEnabled bool ProTier int Email string `json:"email"` EmailVerified bool `json:"email_verified"` Name string `json:"name"` - jwt.StandardClaims + jwt.RegisteredClaims } -func Authorize() error { +func Authorize() (*KlothoClaims, error) { return authorize(false) } -func authorize(tokenRefreshed bool) error { - creds, err := GetIDToken() +func authorize(tokenRefreshed bool) (*KlothoClaims, error) { + creds, claims, err := getClaims() if err != nil { - return errors.New("failed to get credentials for user, please login") + return nil, err } - token, err := jwt.ParseWithClaims(creds.IdToken, &MyCustomClaims{}, func(token *jwt.Token) (interface{}, error) { - return nil, nil - }) - if err != nil { - zap.S().Debug(err) - } - - if claims, ok := token.Claims.(*MyCustomClaims); ok { - if !claims.EmailVerified { - if tokenRefreshed { - return fmt.Errorf("user %s, has not verified their email", claims.Email) - } - err := CallRefreshToken(creds.RefreshToken) - if err != nil { - return err - } - err = authorize(true) - if err != nil { - return err - } - } else if !claims.ProEnabled { - return fmt.Errorf("user %s is not authorized to use KlothoPro", claims.Email) - } else if claims.ExpiresAt < time.Now().Unix() { - if tokenRefreshed { - return fmt.Errorf("user %s, does not have a valid token", claims.Email) - } - err := CallRefreshToken(creds.RefreshToken) - if err != nil { - return err - } - err = authorize(true) - if err != nil { - return err - } + if !claims.EmailVerified { + if tokenRefreshed { + return nil, fmt.Errorf("user %s, has not verified their email", claims.Email) + } + err := CallRefreshToken(creds.RefreshToken) + if err != nil { + return nil, err + } + claims, err = authorize(true) + if err != nil { + return nil, err + } + } else if !claims.ProEnabled { + return nil, fmt.Errorf("user %s is not authorized to use KlothoPro", claims.Email) + } else if claims.ExpiresAt.Before(time.Now()) { + if tokenRefreshed { + return nil, fmt.Errorf("user %s, does not have a valid token", claims.Email) + } + err := CallRefreshToken(creds.RefreshToken) + if err != nil { + return nil, err + } + claims, err = authorize(true) + if err != nil { + return nil, err } - } else { - return errors.New("failed to authorize user") } - return nil + return claims, nil } -func GetUserEmail() (string, error) { +func getClaims() (*Credentials, *KlothoClaims, error) { + errMsg := `Failed to get credentials for user. Please run "klotho --login"` creds, err := GetIDToken() if err != nil { - return "", errors.New("failed to get credentials for user, please login") + return nil, nil, errors.New(errMsg) } - token, err := jwt.ParseWithClaims(creds.IdToken, &MyCustomClaims{}, func(token *jwt.Token) (interface{}, error) { - return nil, nil + token, err := jwt.ParseWithClaims(creds.IdToken, &KlothoClaims{}, func(token *jwt.Token) (interface{}, error) { + return getPem() }) if err != nil { - zap.S().Debug(err) + return nil, nil, errors.Wrap(err, errMsg) } - if claims, ok := token.Claims.(*MyCustomClaims); ok { - return claims.Email, nil + if claims, ok := token.Claims.(*KlothoClaims); ok { + return creds, claims, nil } else { - return "", errors.New("failed to authorize user") + return nil, nil, errors.Wrap(err, errMsg) } } -func getAuthUrlBase() string { - host := os.Getenv("KLOTHO_AUTH_BASE") - if host == "" { - host = "http://klotho-auth-service-alb-e22c092-466389525.us-east-1.elb.amazonaws.com" +func getPem() (*rsa.PublicKey, error) { + var authServerPemCacheFile = path.Join("pem", url.PathEscape(pemUrl)) + + writePemCache := false + // Try to read the PEM from local cache + configPath, err := cli_config.KlothoConfigPath(authServerPemCacheFile) + if err != nil { + return nil, err + } + bs, err := os.ReadFile(configPath) + // Couldn't read it from cache, so (a) try to fetch it from URL and (b) mark down that we should write it on success + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + zap.L().Debug("Couldn't read PEM cache file. Will download it.", zap.Error(err)) + } + pemResp, err := http.Get(pemUrl) + if err != nil { + return nil, err + } + defer closenicely.OrDebug(pemResp.Body) + bs, err = io.ReadAll(pemResp.Body) + if err != nil { + return nil, err + } + writePemCache = true + } + // okay, we have the PEM bytes. Try to decode them into a PublicKey. + block, _ := pem.Decode(bs) + if block == nil { + return nil, errors.New("Couldn't parse PEM certificate") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, err + } + pub, ok := cert.PublicKey.(*rsa.PublicKey) + if !ok { + return nil, errors.New("Couldn't parse PEM certificate block") + } + // Finally, if we'd fetched the PEM bytes from URL, save them now. + if writePemCache { + configPath, err := cli_config.KlothoConfigPath(authServerPemCacheFile) + if err == nil { + _ = os.MkdirAll(path.Dir(configPath), 0777) + err = os.WriteFile(configPath, bs, 0644) + } + if err != nil { + zap.L().Debug("Couldn't write PEM to local cache", zap.Error(err)) + } + } + return pub, nil +} + +// EnvVar represents an environment variable, specified by its key name. This is a +// wrapper around os.Getenv. This string's value is the env var key. Use GetOr to get its value. +type EnvVar string + +// GetOr uses os.Getenv to get the env var specified by the target EnvVar. If that env var's value is unset or empty, +// it returns the defaultValue. +func (s EnvVar) GetOr(defaultValue string) string { + value := os.Getenv(string(s)) + if value == "" { + return defaultValue + } else { + return value } - return host } diff --git a/pkg/auth/credentials.go b/pkg/auth/credentials.go index 9f5dcb176..96ff2c17e 100644 --- a/pkg/auth/credentials.go +++ b/pkg/auth/credentials.go @@ -30,7 +30,6 @@ func WriteIDToken(token string) error { } func GetIDToken() (*Credentials, error) { - idToken := os.Getenv("KLOTHO_ID_TOKEN") if idToken != "" { return &Credentials{ diff --git a/pkg/cli/klothomain.go b/pkg/cli/klothomain.go index fc74c392a..92f07cc6b 100644 --- a/pkg/cli/klothomain.go +++ b/pkg/cli/klothomain.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "github.com/klothoplatform/klotho/pkg/closenicely" "github.com/spf13/pflag" "os" "regexp" @@ -193,31 +194,27 @@ func (km KlothoMain) run(cmd *cobra.Command, args []string) (err error) { zap.S().Warnf("failed to create .klotho directory: %v", err) } - analyticsClientProperties := map[string]interface{}{ + // Set up analytics, and hook them up to the logs + analyticsClient := analytics.NewClient(map[string]interface{}{ "version": km.Version, "strict": cfg.strict, "edition": km.DefaultUpdateStream, + }) + z, err := setupLogger(analyticsClient) + if err != nil { + return err } + defer closenicely.FuncOrDebug(z.Sync) + zap.ReplaceGlobals(z) // Set up user if login is specified if cfg.login { err := auth.Login(func(err error) { - // We don't have the analytics client set up to the logger yet, so the warn message won't send any - //analytics. Manually create a client and send the tracking. zap.L().Warn(`Couldn't log in. You may be able to continue using klotho without logging in for now, but this may break in the future. Please contact us if this continues.'`) - client := &analytics.Client{Properties: analyticsClientProperties} - client.Warn("login failed") }) if err != nil { return err } - email, err := auth.GetUserEmail() - if err != nil { - return err - } - if err := analytics.CreateUser(email); err != nil { - return errors.Wrapf(err, "could not configure user '%s'", email) - } return nil } // Set up user if login is specified @@ -229,19 +226,6 @@ func (km KlothoMain) run(cmd *cobra.Command, args []string) (err error) { return nil } - // Set up analytics - analyticsClient, err := analytics.NewClient(analyticsClientProperties) - if err != nil { - return errors.New(fmt.Sprintf("Issue retrieving user info: %s. \nYou may need to run: klotho --login ", err)) - } - - z, err := setupLogger(analyticsClient) - if err != nil { - return err - } - defer z.Sync() // nolint:errcheck - zap.ReplaceGlobals(z) - errHandler := ErrorHandler{ InternalDebug: cfg.internalDebug, Verbose: cfg.verbose, @@ -269,7 +253,10 @@ func (km KlothoMain) run(cmd *cobra.Command, args []string) (err error) { } // Needs to go after the --version and --update checks - err = km.Authorizer.Authorize() + claims, err := km.Authorizer.Authorize() + if claims != nil { + analyticsClient.AttachAuthorizations(claims) + } if err != nil { return err } diff --git a/pkg/closenicely/closeutil.go b/pkg/closenicely/closeutil.go index a6f8ed5f7..0a1bd8564 100644 --- a/pkg/closenicely/closeutil.go +++ b/pkg/closenicely/closeutil.go @@ -6,7 +6,11 @@ import ( ) func OrDebug(closer io.Closer) { - if err := closer.Close(); err != nil { + FuncOrDebug(closer.Close) +} + +func FuncOrDebug(closer func() error) { + if err := closer(); err != nil { zap.L().Debug("Failed to close resource", zap.Error(err)) } } From 40573eb6e9f8a64ca44e330e9a647f747b9e086c Mon Sep 17 00:00:00 2001 From: Yuval Shavit <110620369+yuval-klotho@users.noreply.github.com> Date: Fri, 10 Feb 2023 10:25:18 -0500 Subject: [PATCH 09/13] fail-open on auth failures If the `credentials.json` file isn't there at all, then require a login. But if it's there but the login doesn't work for whatever reason, just issue a warning and move on. Meanwhile, in the login path, handle errors by writing an empty `credentials.json` and then ignoring the error. resolves #194 Relatedly, add a workaround for #195. It's very easy to hit it with this new path, so I felt like a quick workaround is in order. --- pkg/auth/auth.go | 28 ++++++++++++++++++---------- pkg/auth/credentials.go | 11 ++++++++--- pkg/cli/klothomain.go | 31 ++++++++++++++++++++----------- pkg/logging/console.go | 3 +++ 4 files changed, 49 insertions(+), 24 deletions(-) diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index a27a5bfa5..9566336ab 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -23,9 +23,11 @@ import ( "go.uber.org/zap" ) -var authUrlBase = EnvVar("KLOTHO_AUTH_BASE").GetOr(`http://klotho-auth-service-alb-e22c092-466389525.us-east-1.elb.amazonaws.com`) - -var pemUrl = EnvVar("KLOTHO_AUTH_PEM").GetOr(`https://klotho.us.auth0.com/pem`) +var ( + authUrlBase = EnvVar("KLOTHO_AUTH_BASE").GetOr(`http://klotho-auth-service-alb-e22c092-466389525.us-east-1.elb.amazonaws.com`) + pemUrl = EnvVar("KLOTHO_AUTH_PEM").GetOr(`https://klotho.us.auth0.com/pem`) + ErrNoCredentialsFile = errors.New("no local credentials file") +) type LoginResponse struct { Url string @@ -33,6 +35,10 @@ type LoginResponse struct { } type Authorizer interface { + // Authorize tries to authorize the user. The KlothoClaims it returns may be nil, even if the authentication + // succeeds. Conversely, if the KlothoClaims is non-nil, it is valid even if the error is also non-nil; you can use + // those claims provisionally (and specifically, in analytics) even if the error is non-nil, indicating failed + // authentication. Authorize() (*KlothoClaims, error) } @@ -49,14 +55,14 @@ func (s standardAuthorizer) Authorize() (*KlothoClaims, error) { return Authorize() } -func Login(onError func(error)) error { +func Login(onError func(error) error) error { state, err := CallLoginEndpoint() if err != nil { - return err + return onError(err) } err = CallGetTokenEndpoint(state) if err != nil { - onError(err) + return onError(err) } return nil } @@ -67,6 +73,9 @@ func CallLoginEndpoint() (string, error) { return "", err } defer closenicely.OrDebug(res.Body) + if res.StatusCode < 200 || res.StatusCode >= 300 { + return "", errors.Errorf(`received %v from auth server`, res.StatusCode) + } body, err := io.ReadAll(res.Body) if err != nil { return "", err @@ -209,21 +218,20 @@ func authorize(tokenRefreshed bool) (*KlothoClaims, error) { } func getClaims() (*Credentials, *KlothoClaims, error) { - errMsg := `Failed to get credentials for user. Please run "klotho --login"` creds, err := GetIDToken() if err != nil { - return nil, nil, errors.New(errMsg) + return nil, nil, err } token, err := jwt.ParseWithClaims(creds.IdToken, &KlothoClaims{}, func(token *jwt.Token) (interface{}, error) { return getPem() }) if err != nil { - return nil, nil, errors.Wrap(err, errMsg) + return nil, nil, err } if claims, ok := token.Claims.(*KlothoClaims); ok { return creds, claims, nil } else { - return nil, nil, errors.Wrap(err, errMsg) + return nil, nil, err } } diff --git a/pkg/auth/credentials.go b/pkg/auth/credentials.go index 96ff2c17e..08477cd7f 100644 --- a/pkg/auth/credentials.go +++ b/pkg/auth/credentials.go @@ -2,6 +2,7 @@ package auth import ( "encoding/json" + "github.com/pkg/errors" "os" "github.com/klothoplatform/klotho/pkg/cli_config" @@ -13,7 +14,6 @@ type Credentials struct { } func WriteIDToken(token string) error { - configPath, err := cli_config.KlothoConfigPath("credentials.json") if err != nil { return err @@ -46,9 +46,14 @@ func GetIDToken() (*Credentials, error) { content, err := os.ReadFile(configPath) if err != nil { - return &result, err + if errors.Is(err, os.ErrNotExist) { + err = ErrNoCredentialsFile + } + return nil, err + } + if len(content) > 0 { + err = json.Unmarshal(content, &result) } - err = json.Unmarshal(content, &result) return &result, err } diff --git a/pkg/cli/klothomain.go b/pkg/cli/klothomain.go index 92f07cc6b..773fbdc3f 100644 --- a/pkg/cli/klothomain.go +++ b/pkg/cli/klothomain.go @@ -209,8 +209,12 @@ func (km KlothoMain) run(cmd *cobra.Command, args []string) (err error) { // Set up user if login is specified if cfg.login { - err := auth.Login(func(err error) { - zap.L().Warn(`Couldn't log in. You may be able to continue using klotho without logging in for now, but this may break in the future. Please contact us if this continues.'`) + err := auth.Login(func(err error) error { + zap.L().Warn(`Couldn't log in. You may be able to continue using klotho without logging in for now, but this may break in the future. Please contact us if this continues.`) + // Set an empty token. This will mean that the user doesn't get prompted to log in. The login token is still + // invalid, but it'll fail-open (at least for now). + _ = auth.WriteIDToken("") + return nil }) if err != nil { return err @@ -252,15 +256,6 @@ func (km KlothoMain) run(cmd *cobra.Command, args []string) (err error) { analyticsClient.Properties[km.VersionQualifier] = true } - // Needs to go after the --version and --update checks - claims, err := km.Authorizer.Authorize() - if claims != nil { - analyticsClient.AttachAuthorizations(claims) - } - if err != nil { - return err - } - // if update is specified do the update in place var klothoUpdater = updater.Updater{ ServerURL: updater.DefaultServer, @@ -297,6 +292,20 @@ func (km KlothoMain) run(cmd *cobra.Command, args []string) (err error) { return nil } + // Needs to go after the --version and --update checks + claims, err := km.Authorizer.Authorize() + if claims != nil { + analyticsClient.AttachAuthorizations(claims) + } + if err != nil { + if errors.Is(err, auth.ErrNoCredentialsFile) { + return errors.New(`Failed to get credentials for user. Please run "klotho --login"`) + } + // Fail-open. See also the error handler at auth.Login(...) above (you should change that to not write the + // empty token, if this fail-open ever changes). + zap.L().Warn(`Not logged in. You may be able to continue using klotho without logging in for now, but this may break in the future. Please contact us if this continues.`, zap.Error(err)) + } + appCfg, err := readConfig(args) if err != nil { return errors.Wrapf(err, "could not read config '%s'", cfg.config) diff --git a/pkg/logging/console.go b/pkg/logging/console.go index 94dfaa6ab..ab02a518c 100644 --- a/pkg/logging/console.go +++ b/pkg/logging/console.go @@ -146,6 +146,9 @@ func (enc *ConsoleEncoder) EncodeEntry(ent zapcore.Entry, fieldList []zapcore.Fi case postLogMessage: postMessage = v.Message continue + case error: + // hacky workaround to #195: just don't print errors, since they can be long strings of multi-line trace + continue } if fieldCount > 0 { fields.AppendString(", ") From 1b66aa171f68edbcb70ba55aeb98eb95cb5bcb63 Mon Sep 17 00:00:00 2001 From: Yuval Shavit Date: Tue, 14 Feb 2023 19:56:00 -0500 Subject: [PATCH 10/13] send login failures to analytics In service of #218 --- pkg/auth/auth.go | 15 ++++++++------- pkg/cli/klothomain.go | 16 +++++++++++++--- pkg/logging/fields.go | 4 ++-- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 9566336ab..c9da972db 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -27,6 +27,7 @@ var ( authUrlBase = EnvVar("KLOTHO_AUTH_BASE").GetOr(`http://klotho-auth-service-alb-e22c092-466389525.us-east-1.elb.amazonaws.com`) pemUrl = EnvVar("KLOTHO_AUTH_PEM").GetOr(`https://klotho.us.auth0.com/pem`) ErrNoCredentialsFile = errors.New("no local credentials file") + ErrEmailUnverified = errors.New("login email hasn't been verified") ) type LoginResponse struct { @@ -189,29 +190,29 @@ func authorize(tokenRefreshed bool) (*KlothoClaims, error) { if !claims.EmailVerified { if tokenRefreshed { - return nil, fmt.Errorf("user %s, has not verified their email", claims.Email) + return claims, ErrEmailUnverified } err := CallRefreshToken(creds.RefreshToken) if err != nil { - return nil, err + return claims, err } claims, err = authorize(true) if err != nil { - return nil, err + return claims, err } } else if !claims.ProEnabled { - return nil, fmt.Errorf("user %s is not authorized to use KlothoPro", claims.Email) + return claims, fmt.Errorf("user %s is not authorized to use KlothoPro", claims.Email) } else if claims.ExpiresAt.Before(time.Now()) { if tokenRefreshed { - return nil, fmt.Errorf("user %s, does not have a valid token", claims.Email) + return claims, fmt.Errorf("user %s, does not have a valid token", claims.Email) } err := CallRefreshToken(creds.RefreshToken) if err != nil { - return nil, err + return claims, err } claims, err = authorize(true) if err != nil { - return nil, err + return claims, err } } return claims, nil diff --git a/pkg/cli/klothomain.go b/pkg/cli/klothomain.go index 773fbdc3f..cdf60b7dc 100644 --- a/pkg/cli/klothomain.go +++ b/pkg/cli/klothomain.go @@ -301,9 +301,19 @@ func (km KlothoMain) run(cmd *cobra.Command, args []string) (err error) { if errors.Is(err, auth.ErrNoCredentialsFile) { return errors.New(`Failed to get credentials for user. Please run "klotho --login"`) } - // Fail-open. See also the error handler at auth.Login(...) above (you should change that to not write the - // empty token, if this fail-open ever changes). - zap.L().Warn(`Not logged in. You may be able to continue using klotho without logging in for now, but this may break in the future. Please contact us if this continues.`, zap.Error(err)) + if errors.Is(err, auth.ErrEmailUnverified) { + zap.L().Warn( + `You have not verified your email. You may continue using klotho for now, but this may break in the future. Please check your email to complete registration.`, + zap.Error(err), + logging.SendEntryMessage) + } else { + // Fail-open. See also the error handler at auth.Login(...) above (you should change that to not write the + // empty token, if this fail-open ever changes). + zap.L().Warn( + `Not logged in. You may be able to continue using klotho without logging in for now, but this may break in the future. Please contact us if this continues.`, + zap.Error(err), + logging.SendEntryMessage) + } } appCfg, err := readConfig(args) diff --git a/pkg/logging/fields.go b/pkg/logging/fields.go index 41efe3cfb..a2fd079c2 100644 --- a/pkg/logging/fields.go +++ b/pkg/logging/fields.go @@ -10,7 +10,7 @@ import ( "go.uber.org/zap/zapcore" ) -var EntryMessageField = "entryMessage" +const EntryMessageField = "entryMessage" type fileField struct { f core.File @@ -75,7 +75,7 @@ func (field entryMessage) MarshalLogObject(enc zapcore.ObjectEncoder) error { } // SendEntryMessage adds the entryMessage field to the logger in order to bypass sanitization and allow for the raw message to be logged. -var SendEntryMessage = zap.Object("entryMessage", entryMessage{}) +var SendEntryMessage = zap.Object(EntryMessageField, entryMessage{}) // DescribeKlothoFields is intended for unit testing expected log lines. // From 76017f6a22a9db5e970051abadcc929a36e3981a Mon Sep 17 00:00:00 2001 From: Yuval Shavit Date: Thu, 16 Feb 2023 16:15:05 -0500 Subject: [PATCH 11/13] move Authorizer to klothomain --- pkg/auth/auth.go | 19 ++----------------- pkg/cli/klothomain.go | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index c9da972db..910d721bd 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -35,24 +35,9 @@ type LoginResponse struct { State string } -type Authorizer interface { - // Authorize tries to authorize the user. The KlothoClaims it returns may be nil, even if the authentication - // succeeds. Conversely, if the KlothoClaims is non-nil, it is valid even if the error is also non-nil; you can use - // those claims provisionally (and specifically, in analytics) even if the error is non-nil, indicating failed - // authentication. - Authorize() (*KlothoClaims, error) -} - -func DefaultIfNil(auth Authorizer) Authorizer { - if auth == nil { - return standardAuthorizer{} - } - return auth -} - -type standardAuthorizer struct{} +type Auth0Authorizer struct{} -func (s standardAuthorizer) Authorize() (*KlothoClaims, error) { +func (s Auth0Authorizer) Authorize() (*KlothoClaims, error) { return Authorize() } diff --git a/pkg/cli/klothomain.go b/pkg/cli/klothomain.go index cdf60b7dc..d52357b3c 100644 --- a/pkg/cli/klothomain.go +++ b/pkg/cli/klothomain.go @@ -32,7 +32,15 @@ type KlothoMain struct { VersionQualifier string PluginSetup func(*PluginSetBuilder) error // Authorizer is an optional authorizer override. If this also conforms to FlagsProvider, those flags will be added. - Authorizer auth.Authorizer + Authorizer Authorizer +} +type Authorizer interface { + + // Authorize tries to authorize the user. The KlothoClaims it returns may be nil, even if the authentication + // succeeds. Conversely, if the KlothoClaims is non-nil, it is valid even if the error is also non-nil; you can use + // those claims provisionally (and specifically, in analytics) even if the error is non-nil, indicating failed + // authentication. + Authorize() (*auth.KlothoClaims, error) } type FlagsProvider interface { @@ -79,7 +87,9 @@ const ( ) func (km KlothoMain) Main() { - km.Authorizer = auth.DefaultIfNil(km.Authorizer) + if km.Authorizer == nil { + km.Authorizer = auth.Auth0Authorizer{} + } var root = &cobra.Command{ Use: "klotho [path to source]", From 8d625089da799e4aaeb2c765551b9517e5a2520d Mon Sep 17 00:00:00 2001 From: Yuval Shavit Date: Thu, 16 Feb 2023 16:21:33 -0500 Subject: [PATCH 12/13] move EnvVar to cli_config --- pkg/auth/auth.go | 19 ++----------------- pkg/cli_config/envvar.go | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 17 deletions(-) create mode 100644 pkg/cli_config/envvar.go diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 910d721bd..25fb5eedd 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -24,8 +24,8 @@ import ( ) var ( - authUrlBase = EnvVar("KLOTHO_AUTH_BASE").GetOr(`http://klotho-auth-service-alb-e22c092-466389525.us-east-1.elb.amazonaws.com`) - pemUrl = EnvVar("KLOTHO_AUTH_PEM").GetOr(`https://klotho.us.auth0.com/pem`) + authUrlBase = cli_config.EnvVar("KLOTHO_AUTH_BASE").GetOr(`http://klotho-auth-service-alb-e22c092-466389525.us-east-1.elb.amazonaws.com`) + pemUrl = cli_config.EnvVar("KLOTHO_AUTH_PEM").GetOr(`https://klotho.us.auth0.com/pem`) ErrNoCredentialsFile = errors.New("no local credentials file") ErrEmailUnverified = errors.New("login email hasn't been verified") ) @@ -273,18 +273,3 @@ func getPem() (*rsa.PublicKey, error) { } return pub, nil } - -// EnvVar represents an environment variable, specified by its key name. This is a -// wrapper around os.Getenv. This string's value is the env var key. Use GetOr to get its value. -type EnvVar string - -// GetOr uses os.Getenv to get the env var specified by the target EnvVar. If that env var's value is unset or empty, -// it returns the defaultValue. -func (s EnvVar) GetOr(defaultValue string) string { - value := os.Getenv(string(s)) - if value == "" { - return defaultValue - } else { - return value - } -} diff --git a/pkg/cli_config/envvar.go b/pkg/cli_config/envvar.go new file mode 100644 index 000000000..07dd3af51 --- /dev/null +++ b/pkg/cli_config/envvar.go @@ -0,0 +1,19 @@ +package cli_config + +import "os" + +// EnvVar represents an environment variable, specified by its key name. +// wrapper around os.Getenv. This string's value is the env var key. Use GetOr to get its value, or a +// default if the value isn't set. +type EnvVar string + +// GetOr uses os.Getenv to get the env var specified by the target EnvVar. If that env var's value is unset or empty, +// it returns the defaultValue. +func (s EnvVar) GetOr(defaultValue string) string { + value := os.Getenv(string(s)) + if value == "" { + return defaultValue + } else { + return value + } +} From 73ffeec8565f38d1338242b9b52e48afef762bd8 Mon Sep 17 00:00:00 2001 From: Yuval Shavit Date: Thu, 16 Feb 2023 19:44:48 -0500 Subject: [PATCH 13/13] update integ test runner --- .github/workflows/run-integ-tests.yaml | 20 +++++--------------- README.md | 1 + 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/.github/workflows/run-integ-tests.yaml b/.github/workflows/run-integ-tests.yaml index bf040c86c..e31a4797e 100644 --- a/.github/workflows/run-integ-tests.yaml +++ b/.github/workflows/run-integ-tests.yaml @@ -15,11 +15,6 @@ on: description: comma-delimited list of dirs within test-app-repo to run (if empty, runs all) required: false type: string - klotho-login: - description: email address to log into Klotho - required: true - type: string - default: klotho-engineering@klo.dev region: description: the AWS region to deploy to, other than redis tests required: false @@ -46,10 +41,6 @@ on: description: comma-delimited list of dirs within test-app-repo required: false type: string - klotho-login: - description: email address to log into Klotho - required: true - type: string region: description: the AWS region to deploy to required: false @@ -166,9 +157,7 @@ jobs: run: | curl -fsSL http://srv.klo.dev/update/latest/linux/amd64 -o "$RUNNER_TEMP/klotho-old" chmod +x "$RUNNER_TEMP/klotho-old" - "$RUNNER_TEMP/klotho-old" --login "$KLOTHO_LOGIN" - env: - KLOTHO_LOGIN: ${{ inputs.klotho-login }} + "$RUNNER_TEMP/klotho-old" --login 'klotho-engineering@klo.dev' - name: download klotho uses: actions/download-artifact@v3 with: @@ -184,9 +173,6 @@ jobs: - name: install klotho run: | chmod +x $RUNNER_TEMP/klotho - klotho --login $KLOTHO_LOGIN - env: - KLOTHO_LOGIN: ${{ inputs.klotho-login }} - name: typescript compilation if: steps.get_language.outputs.language == 'ts' working-directory: ${{ matrix.app_to_test }} @@ -205,6 +191,8 @@ jobs: else klotho . --app $STACK_NAME -p aws fi + env: + KLOTHO_ID_TOKEN: ${{ secrets.KLOTHO_CREDS_ID_TOKEN }} - name: pulumi npm install working-directory: ${{ matrix.app_to_test }} run: | @@ -282,6 +270,8 @@ jobs: else klotho . --app $STACK_NAME -p aws fi + env: + KLOTHO_ID_TOKEN: ${{ secrets.KLOTHO_CREDS_ID_TOKEN }} - name: pulumi npm install (upgrade path) if: matrix.mode == 'upgrade' working-directory: ${{ matrix.app_to_test }} diff --git a/README.md b/README.md index 581f06258..c4d4e2aa5 100644 --- a/README.md +++ b/README.md @@ -214,3 +214,4 @@ These providers are not yet supported but are in design and development * to run integration tests against a branch, navigate to the [run-integ-tests.yaml](https://github.com/klothoplatform/klotho/actions/workflows/run-integ-tests.yaml) action and click the "run workflow ▾" button. Select your branch, optionally fill in or change any of the parameters, and then click the "run workflow" button. * For security reasons, only authorized members of the team may do this. You can run integration tests on your own fork, providing your own AWS and Pulumi credentials. * Note that the nightly integration tests are [a different workflow](https://github.com/klothoplatform/klotho/actions/workflows/nightly-integ-tests.yaml). Authorized members of the team can manually kick off a run of that workflow, but it doesn't take any inputs. The nightly integration tests workflow simply invokes the run-integ-tests.yaml workflow, so they effectively do the same thing. + * The tests use a Klothoh login token that's stored as a GH Action secret within the `integ-test` environment. The login credentials are in BitWarden, under the "GitHub CI/CD login" entry.