From e66e97e021e2581124b6549d2eac9d34a6343f21 Mon Sep 17 00:00:00 2001 From: Nikolay Stupak Date: Tue, 15 Oct 2019 19:09:39 +0300 Subject: [PATCH 1/3] web: add an HTTP interface for executing login and logout operations --- README.md | 92 +++++++++- cmd/tokget/main.go | 127 ++++++++++++-- go.mod | 5 + go.sum | 48 ++++++ internal/chrome/chrome.go | 35 +--- internal/log/log.go | 68 ++++---- internal/oidc/login.go | 63 +++---- internal/oidc/login_test.go | 41 +---- internal/oidc/logout.go | 10 +- internal/oidc/logout_test.go | 5 +- internal/stat/stat.go | 67 ++++++++ internal/stat/stat_test.go | 64 +++++++ internal/web/web.go | 179 ++++++++++++++++++++ internal/web/web_test.go | 319 +++++++++++++++++++++++++++++++++++ 14 files changed, 953 insertions(+), 170 deletions(-) create mode 100644 internal/stat/stat.go create mode 100644 internal/stat/stat_test.go create mode 100644 internal/web/web.go create mode 100644 internal/web/web_test.go diff --git a/README.md b/README.md index 0d39c71..17d5318 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,11 @@ [![GoDoc][doc-img]][doc] [![Build Status][build-img]][build] [![codecov][codecov-img]][codecov] [![Go Report Card][goreport-img]][goreport] -`tokget` is a CLI tool that allows to get a user's access token and ID token by [the OpenID Connect protocol][oidc-spec-core]. +`tokget` is a tool that allows getting a user's access token and ID token by [the OpenID Connect protocol][oidc-spec-core]. **Features** +- works as a CLI tool or a web server; - authenticates a user without interaction between the browser and user; - supports arbitrary structure of the login page; - logs a user out by canceling an ID token. @@ -31,18 +32,18 @@ go install ./... From Docker: ```bash -docker pull icoreru/tokget:v1.1.0 +docker pull icoreru/tokget:v1.2.0 ``` Download binary: ```bash -curl -Lo /tmp/tokget_linux_amd64.tar.gz 'https://github.com/i-core/tokget/releases/download/v1.1.0/tokget_linux_amd64.tar.gz' +curl -Lo /tmp/tokget_linux_amd64.tar.gz 'https://github.com/i-core/tokget/releases/download/v1.2.0/tokget_linux_amd64.tar.gz' tar -xzf /tmp/tokget_linux_amd64.tar.gz -C /usr/local/bin # In alpine linux (as it does not come with curl by default) -wget -P /tmp 'https://github.com/i-core/tokget/releases/download/v1.1.0/tokget_linux_amd64.tar.gz' +wget -P /tmp 'https://github.com/i-core/tokget/releases/download/v1.2.0/tokget_linux_amd64.tar.gz' tar -xzf /tmp/tokget_linux_amd64.tar.gz -C /usr/local/bin ``` @@ -70,7 +71,7 @@ Via Docker: ```bash -docker run --name tokget --rm -it icoreru/tokget:v1.1.0 login -e https://openid-connect-provider -c -r -s openid,profile,email -u username -pwd-stdin +docker run --name tokget --rm -it icoreru/tokget:v1.2.0 login -e https://openid-connect-provider -c -r -s openid,profile,email -u username -pwd-stdin ``` **Note** Image `icoreru/tokget` already contains Google Chrome so you don't need to run Google Chrome manually. @@ -108,13 +109,90 @@ Via Docker: ```bash -docker run --name tokget --rm -it icoreru/tokget:v1.1.0 logout -e https://openid-connect-provider -t id_token +docker run --name tokget --rm -it icoreru/tokget:v1.2.0 logout -e https://openid-connect-provider -t id_token +``` + +### Serve + +The command `serve` starts a web server. The web server has endpoints for logging in and out: + +In the terminal: + +```bash +tokget serve +``` + +Via Docker: + + +```bash +docker run --name tokget --rm -it -p 8080:8080 icoreru/tokget:v1.2.0 serve +``` + +After the web server started you can get a user's access token and ID token by sending a request to endpoint `/login`: + +```bash +curl -X POST -H "Content-Type: application/json" -d '{"endpoint":"https://openid-connect-provider","clientId":"client ID","redirectUri":"redirect uri","scopes":"openid profile email","username":"user name","password":"user password"}' http://localhost:8080/login +``` + +A request's body must conforms the next JSON schema: + +```yaml +type: object +properties: + endpoint: + type: string + clientId: + type: string + redirectUri: + type: string + scopes: + type: string + username: + type: string + password: + type: string + usernameField: + type: string + passwordField: + type: string + submitButton: + type: string + errorMessage: + type: string +required: + - endpoint + - clientId + - username + - password +} +``` + +And you can log a user out by sending a request to endpoint `/logout`: + +```bash +curl -X POST -H "Content-Type: application/json" -d '{"endpoint": "https://openid-connect-provider","idToken": "ID token"}' http://localhost:8080/logout +``` + +A request's body must conforms the next JSON schema: + +```yaml +type: object +properties: + endpoint: + type: string + idToken: + type: string +required: + - endpoint + - idToken +} ``` ### Remote Google Chrome By default `tokget` starts a new Google Chrome process. But you can use an existed Google Chrome process. -This Google Chrome process should be run with enabled debugger, for example: +This Google Chrome process should be run with an enabled debugger, for example: ```bash chrome --no-sandbox --remote-debugging-address=0.0.0.0 --remote-debugging-port=9222 diff --git a/cmd/tokget/main.go b/cmd/tokget/main.go index e86508e..87acc97 100644 --- a/cmd/tokget/main.go +++ b/cmd/tokget/main.go @@ -11,37 +11,63 @@ import ( "encoding/json" "flag" "fmt" + "net/http" "os" + "os/signal" "strings" + "github.com/i-core/rlog" + "github.com/i-core/routegroup" "github.com/i-core/tokget/internal/errors" "github.com/i-core/tokget/internal/log" "github.com/i-core/tokget/internal/oidc" + "github.com/i-core/tokget/internal/stat" + "github.com/i-core/tokget/internal/web" + "github.com/kelseyhightower/envconfig" + "go.uber.org/zap" + "golang.org/x/crypto/ssh/terminal" ) // version will be filled at compile time. var version = "" +// serveConfig is a server's configuration. +type serveConfig struct { + Listen string `default:":8080" desc:"a host and port to listen on (:)"` + Verbose bool `default:"false" desc:"a development mode"` +} + +// The default values of the login operation's parameters. +const ( + defaultRedirectURI = "http://localhost:3000" + defaultScopes = "openid,profile,email" + defaultUsernameField = "input[name=username]" + defaultPasswordField = "input[name=password]" + defaultSubmitButton = "button[type=submit]" + defaultErrorMessage = "p.message" +) + func main() { var ( verboseLogin bool verboseLogout bool scopes string + passwordStdin bool ) loginCnf := &oidc.LoginConfig{} loginCmd := flag.NewFlagSet("login", flag.ExitOnError) loginCmd.StringVar(&loginCnf.Endpoint, "e", "", "an OpenID Connect endpoint") loginCmd.StringVar(&loginCnf.ClientID, "c", "", "an OpenID Connect client ID") - loginCmd.StringVar(&loginCnf.RedirectURI, "r", "http://localhost:3000", "an OpenID Connect client's redirect uri") - loginCmd.StringVar(&scopes, "s", "openid,profile,email", "OpenID Connect scopes") + loginCmd.StringVar(&loginCnf.RedirectURI, "r", defaultRedirectURI, "an OpenID Connect client's redirect uri") + loginCmd.StringVar(&scopes, "s", defaultScopes, "OpenID Connect scopes") loginCmd.StringVar(&loginCnf.Username, "u", "", "a user's name") loginCmd.StringVar(&loginCnf.Password, "p", "", "a user's password") - loginCmd.BoolVar(&loginCnf.PasswordStdin, "pwd-stdin", false, "a user's password from stdin") - loginCmd.StringVar(&loginCnf.UsernameField, "username-field", "input[name=username]", "a CSS selector of the username field on the login form") - loginCmd.StringVar(&loginCnf.PasswordField, "password-field", "input[name=password]", "a CSS selector of the password field on the login form") - loginCmd.StringVar(&loginCnf.SubmitButton, "submit-button", "button[type=submit]", "a CSS selector of the submit button on the login form") - loginCmd.StringVar(&loginCnf.ErrorMessage, "error-message", "p.message", "a CSS selector of an error message on the login form") + loginCmd.StringVar(&loginCnf.UsernameField, "username-field", defaultUsernameField, "a CSS selector of the username field on the login form") + loginCmd.StringVar(&loginCnf.PasswordField, "password-field", defaultPasswordField, "a CSS selector of the password field on the login form") + loginCmd.StringVar(&loginCnf.SubmitButton, "submit-button", defaultSubmitButton, "a CSS selector of the submit button on the login form") + loginCmd.StringVar(&loginCnf.ErrorMessage, "error-message", defaultErrorMessage, "a CSS selector of an error message on the login form") + loginCmd.BoolVar(&passwordStdin, "pwd-stdin", false, "a user's password from stdin") loginCmd.BoolVar(&verboseLogin, "v", false, "verbose mode") logoutCnf := &oidc.LogoutConfig{} @@ -50,6 +76,11 @@ func main() { logoutCmd.StringVar(&logoutCnf.IDToken, "t", "", "an ID token") logoutCmd.BoolVar(&verboseLogout, "v", false, "verbose mode") + serveCnf := &serveConfig{} + serveCmd := flag.NewFlagSet("serve", flag.ExitOnError) + serveCmd.StringVar(&serveCnf.Listen, "l", "", "a host and port to listen on (:)") + serveCmd.BoolVar(&serveCnf.Verbose, "v", false, "verbose mode") + flag.Usage = func() { fmt.Fprintln(os.Stderr, usage) } @@ -76,15 +107,66 @@ func main() { } chromeURL = args[1] args = args[2:] + case serveCmd.Name(): + if err := envconfig.Process("tokget", serveCnf); err != nil { + fmt.Fprintf(os.Stderr, "Invalid configuration: %s\n", err) + os.Exit(1) + } + + serveCmd.Parse(args[1:]) + + logFunc := zap.NewProduction + if serveCnf.Verbose { + logFunc = zap.NewDevelopment + } + log, err := logFunc() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create logger: %s\n", err) + os.Exit(1) + } + + router := routegroup.NewRouter(rlog.NewMiddleware(log)) + loginFn := func(ctx context.Context, cnf *oidc.LoginConfig) (*oidc.LoginData, error) { + return oidc.Login(ctx, chromeURL, cnf) + } + logoutFn := func(ctx context.Context, cnf *oidc.LogoutConfig) error { + return oidc.Logout(ctx, chromeURL, cnf) + } + defaults := web.Defaults{ + RedirectURI: defaultRedirectURI, + Scopes: strings.ReplaceAll(defaultScopes, ",", " "), + UsernameField: defaultUsernameField, + PasswordField: defaultPasswordField, + SubmitButton: defaultSubmitButton, + ErrorMessage: defaultErrorMessage, + } + router.AddRoutes(web.NewHandler(defaults, loginFn, logoutFn, rlog.FromContext), "") + router.AddRoutes(stat.NewHandler(version), "/stat") + + log = log.Named("main") + log.Info("Tokget started", zap.Any("config", serveCnf), zap.String("version", version)) + log.Fatal("Tokget finished", zap.Error(http.ListenAndServe(serveCnf.Listen, router))) + os.Exit(0) case loginCmd.Name(): loginCmd.Parse(args[1:]) loginCnf.Scopes = strings.ReplaceAll(scopes, ",", " ") - ctx := context.Background() - if verboseLogin { - ctx = log.WithDebugger(ctx, log.VerboseDebugger) + if passwordStdin { + fmt.Println("Enter password: ") + b, err := terminal.ReadPassword(0) + if err != nil { + if errors.Cause(err) != context.Canceled { + fmt.Fprintf(os.Stderr, "Error: %s\n", err) + } + os.Exit(1) + } + loginCnf.Password = string(b) } + + ctx := withInterrupt(context.Background()) + ctx = log.WithLogger(ctx, verboseLogin) + v, err := oidc.Login(ctx, chromeURL, loginCnf) if err != nil { if errors.Cause(err) != context.Canceled { @@ -102,10 +184,9 @@ func main() { case logoutCmd.Name(): logoutCmd.Parse(args[1:]) - ctx := context.Background() - if verboseLogout { - ctx = log.WithDebugger(ctx, log.VerboseDebugger) - } + ctx := withInterrupt(context.Background()) + ctx = log.WithLogger(ctx, verboseLogout) + err := oidc.Logout(ctx, chromeURL, logoutCnf) if err != nil { if errors.Cause(err) != context.Canceled { @@ -124,15 +205,31 @@ func main() { os.Exit(1) } +// withInterrupt returns a new context that will be canceled when the program receives an interruption signal (SIGINT). +func withInterrupt(parent context.Context) context.Context { + ctx, cancel := context.WithCancel(parent) + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt) + go func() { + select { + case <-c: + cancel() + case <-ctx.Done(): + } + }() + return ctx +} + const usage = ` usage: tokget [options] [options] Options: - --remote-chrome A remote Google Chrome's url + --remote-chrome A remote Google Chrome's URL Commands: login Logs a user in and returns its access token and ID token. logout Logs a user out. + serve Starts a web server that allows logging in and out. version Prints version of the tool. help Prints help about the tool. ` diff --git a/go.mod b/go.mod index 2a9e631..9095ad3 100644 --- a/go.mod +++ b/go.mod @@ -5,5 +5,10 @@ go 1.12 require ( github.com/chromedp/cdproto v0.0.0-20190429085128-1aa4f57ff2a9 github.com/chromedp/chromedp v0.3.0 + github.com/i-core/rlog v1.0.0 + github.com/i-core/routegroup v1.0.0 + github.com/justinas/nosurf v0.0.0-20190416172904-05988550ea18 + github.com/kelseyhightower/envconfig v1.4.0 + go.uber.org/zap v1.10.0 golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5 ) diff --git a/go.sum b/go.sum index 68f115e..bc68551 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,62 @@ +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/cespare/xxhash v1.0.0 h1:naDmySfoNg0nKS62/ujM6e71ZgM2AoVdaqGwMG0w18A= +github.com/cespare/xxhash v1.0.0/go.mod h1:fX/lfQBkSCDXZSUgv6jVIu/EVA3/JNseAX5asI4c4T4= github.com/chromedp/cdproto v0.0.0-20190429085128-1aa4f57ff2a9 h1:ARnDd2vEk91rLNra8yk1hF40H8z+1HrD6juNpe7FsI0= github.com/chromedp/cdproto v0.0.0-20190429085128-1aa4f57ff2a9/go.mod h1:xquOK9dIGFlLaIGI4c6IyfLI/Gz0LiYYuJtzhsUODgI= github.com/chromedp/chromedp v0.3.0 h1:7/pwrXFRq6/ym3sxCykm90DMoyw6VKXY48DgGRgUURA= github.com/chromedp/chromedp v0.3.0/go.mod h1:EktsZcC2iycVrRhC9fDmshBpCK9lNnZYi6x2q9uE7zI= +github.com/coocood/freecache v1.0.1 h1:oFyo4msX2c0QIKU+kuMJUwsKamJ+AKc2JJrKcMszJ5M= +github.com/coocood/freecache v1.0.1/go.mod h1:ePwxCDzOYvARfHdr1pByNct1at3CoKnsipOHwKlNbzI= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/elazarl/go-bindata-assetfs v1.0.0 h1:G/bYguwHIzWq9ZoyUQqrjTmJbbYn3j3CKKpKinvZLFk= +github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4= github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0= github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.0.0 h1:1WdyfgUcImUfVBvYbsW2krIsnko+1QU2t45soaF8v1M= github.com/gobwas/ws v1.0.0/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE= +github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/i-core/rlog v1.0.0 h1:8CY2rsqvm3Z9cfl3hroppn8LTBwbtL45+ho79JTz8Jg= +github.com/i-core/rlog v1.0.0/go.mod h1:wTQKCF9IKx2HlNQ2M7dUpP3zIOD5ayqF4X3uQFbwY3g= +github.com/i-core/routegroup v1.0.0 h1:kTFVBWTWoT2vbhpk0PDemW3GEKV/DwAkQ3qjKnTNygI= +github.com/i-core/routegroup v1.0.0/go.mod h1:wXq5xEjOOs8xuM2olbaAlxgUbP/u8mVaW0tM/09cmKU= +github.com/julienschmidt/httprouter v1.2.0 h1:TDTW5Yz1mjftljbcKqRcrYhd4XeOoI98t+9HbQbYf7g= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da h1:5y58+OCjoHCYB8182mpf/dEsq0vwTKPOo4zGfH0xW9A= +github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da/go.mod h1:oLH0CmIaxCGXD67VKGR5AacGXZSMznlmeqM8RzPrcY8= +github.com/justinas/nosurf v0.0.0-20171023064657-7182011986c4/go.mod h1:Aucr5I5chr4OCuuVB4LTuHVrKHBuyRSo7vM2hqrcb7E= +github.com/justinas/nosurf v0.0.0-20190416172904-05988550ea18 h1:ci3v0mUqcCewO25ntt7hprt2ZMNA0AWI6s6qV0rSpc0= +github.com/justinas/nosurf v0.0.0-20190416172904-05988550ea18/go.mod h1:Aucr5I5chr4OCuuVB4LTuHVrKHBuyRSo7vM2hqrcb7E= +github.com/kelseyhightower/envconfig v1.3.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/kevinburke/go-bindata v3.13.0+incompatible/go.mod h1:/pEEZ72flUW2p0yi30bslSp9YqD9pysLxunQDdb2CPM= github.com/knq/sysutil v0.0.0-20181215143952-f05b59f0f307 h1:vl4eIlySbjertFaNwiMjXsGrFVK25aOWLq7n+3gh2ls= github.com/knq/sysutil v0.0.0-20181215143952-f05b59f0f307/go.mod h1:BjPj+aVjl9FW/cCGiF3nGh5v+9Gd3VCgBQbod/GlMaQ= github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983 h1:wL11wNW7dhKIcRCHSm4sHKPWz0tt4mwBsVodG7+Xyqg= github.com/mailru/easyjson v0.0.0-20190403194419-1ea4449da983/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +go.uber.org/atomic v1.4.0 h1:cxzIVoETapQEqDhQu3QfnvXAV4AlzcvUCxkVUFw3+EU= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v1.1.0 h1:HoEmRHQPVSqub6w2z2d2EOVs2fjyFRGyofhKuyDq0QI= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v1.10.0 h1:ORx85nbTijNz8ljznvCMR1ZBIPKFn3jQrag10X2AsuM= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5 h1:58fnuSXlxZmFdJyvtTFVmVhcMLU6v5fEb/ok4wyqtNU= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -22,3 +66,7 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190509141414-a5b02f93d862 h1:rM0ROo5vb9AdYJi1110yjWGMej9ITfKddS89P3Fkhug= golang.org/x/sys v0.0.0-20190509141414-a5b02f93d862/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gopkg.in/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225 h1:JBwmEvLfCqgPcIq8MjVMQxsF3LVL4XG/HH0qiG0+IFY= +gopkg.in/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= +gopkg.in/ldap.v2 v2.5.1 h1:wiu0okdNfjlBzg6UWvd1Hn8Y+Ux17/u/4nlk4CQr6tU= +gopkg.in/ldap.v2 v2.5.1/go.mod h1:oI0cpe/D7HRtBQl8aTg+ZmzFUAvu4lsv3eLXMLGFxWk= diff --git a/internal/chrome/chrome.go b/internal/chrome/chrome.go index 594fb06..9dc127a 100644 --- a/internal/chrome/chrome.go +++ b/internal/chrome/chrome.go @@ -13,8 +13,6 @@ import ( "io/ioutil" "net/http" "net/url" - "os" - "os/signal" "regexp" "strconv" "strings" @@ -52,8 +50,6 @@ const ( // In the case when the connection established with a new Chrome process // the function finishes the Chrome process. func ConnectWithContext(parent context.Context, chromeURL string, domains ...Domain) (context.Context, context.CancelFunc, error) { - parent = withInterrupt(parent) - var ( ctx context.Context cancel context.CancelFunc @@ -62,12 +58,12 @@ func ConnectWithContext(parent context.Context, chromeURL string, domains ...Dom // // Establish a connection with a Chrome process. // - debugger := log.DebuggerFromContext(parent) + logger := log.LoggerFromContext(parent).Sugar() if chromeURL == "" { - debugger.Debugln("Start a new chrome process") + logger.Debug("Start a new chrome process") ctx, cancel = chromedp.NewContext(parent) } else { - debugger.Debugf("Connect to a chrome process at %q\n", chromeURL) + logger.Debugf("Connect to a chrome process at %q\n", chromeURL) var err error if ctx, cancel, err = connectToRemoteChrome(parent, chromeURL); err != nil { return nil, nil, errors.Wrap(err, "connect to remote Chrome process") @@ -89,7 +85,7 @@ func ConnectWithContext(parent context.Context, chromeURL string, domains ...Dom if err := chromedp.Run(ctx, versionAction); err != nil { return errors.Wrap(err, "get Chrome version") } - debugger.Debugf("Chrome info:\n\tprotocolVersion: %s\n\tproduct: %s\n", cdpVersion, product) + logger.Debugf("Chrome info:\n\tprotocolVersion: %s\n\tproduct: %s\n", cdpVersion, product) major, err := majorVersion(product) if err != nil { return errors.New("invalid Chrome version %q", product) @@ -175,8 +171,8 @@ func connectToRemoteChrome(parent context.Context, chromeURL string) (context.Co } return nil, nil, errors.New("unexpected chrome config:\n%s", string(b)) } - debugger := log.DebuggerFromContext(parent) - debugger.Debugf("Remote Chrome Config:\n%s\n", string(b)) + logger := log.LoggerFromContext(parent).Sugar() + logger.Debugf("Remote Chrome Config:\n%s\n", string(b)) debuggerURL := cnfs[0].DebuggerURL // Connect to a remote Chrome process. @@ -188,21 +184,6 @@ func connectToRemoteChrome(parent context.Context, chromeURL string) (context.Co }, nil } -// withInterrupt returns a new context that will be canceled when the program receives an interruption signal (SIGINT). -func withInterrupt(ctx context.Context) context.Context { - ctx, cancel := context.WithCancel(ctx) - c := make(chan os.Signal, 1) - signal.Notify(c, os.Interrupt) - go func() { - select { - case <-c: - cancel() - case <-ctx.Done(): - } - }() - return ctx -} - // majorVersion extracts the major version from a Chrome's product name. // // Example: HeadlessChrome/70.0.3538.16 -> 70. @@ -253,8 +234,8 @@ func NewNavHistory(ctx context.Context) (*NavHistory, error) { rurl.Fragment = v.Request.URLFragment[1:] } navHistory.entries = append(navHistory.entries, rurl.String()) - debugger := log.DebuggerFromContext(ctx) - debugger.Debugf("request %s %s\n", v.Request.Method, rurl.String()) + logger := log.LoggerFromContext(ctx).Sugar() + logger.Debugf("request %s %s\n", v.Request.Method, rurl.String()) } go func(interceptionID network.InterceptionID) { if err := chromedp.Run(ctx, network.ContinueInterceptedRequest(interceptionID)); err != nil { diff --git a/internal/log/log.go b/internal/log/log.go index a16729f..f78536e 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -9,48 +9,58 @@ package log import ( "context" "fmt" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" ) type contextKey struct{} var ctxKey = contextKey{} -// Debugger is a logger that prints debug information. -type Debugger struct { - enable bool -} - -// Debugln formats using the default formats for its operands and print to standard output if enabled. -func (d *Debugger) Debugln(args ...interface{}) { - if d.enable { - fmt.Println(args...) +// WithLogger returns a new context with a zap.Logger. +func WithLogger(ctx context.Context, verbose bool) context.Context { + logger := zap.NewNop() + if verbose { + logger = newConsoleLogger() } + return context.WithValue(ctx, ctxKey, logger) } -// Debugf formats according to a format specifier and writes to standard output if enabled. -func (d *Debugger) Debugf(format string, args ...interface{}) { - if d.enable { - fmt.Printf(format, args...) +func newConsoleLogger() *zap.Logger { + cnf := zap.Config{ + Level: zap.NewAtomicLevelAt(zap.DebugLevel), + Development: true, + Encoding: "console", + EncoderConfig: zapcore.EncoderConfig{ + TimeKey: "", + LevelKey: "", + NameKey: "", + CallerKey: "", + MessageKey: "M", + StacktraceKey: "", + LineEnding: zapcore.DefaultLineEnding, + EncodeLevel: zapcore.CapitalLevelEncoder, + EncodeTime: zapcore.ISO8601TimeEncoder, + EncodeDuration: zapcore.StringDurationEncoder, + EncodeCaller: zapcore.ShortCallerEncoder, + }, + OutputPaths: []string{"stderr"}, + ErrorOutputPaths: []string{"stderr"}, } + logger, err := cnf.Build() + if err != nil { + panic(fmt.Sprintf("failed to create logger: %s", err)) + } + return logger } -var ( - silentDebugger = &Debugger{enable: false} - // VerboseDebugger is a Debugger that allowed prints messages to standard output. - VerboseDebugger = &Debugger{enable: true} -) - -// WithDebugger returns a new context with a Debugger. -func WithDebugger(ctx context.Context, l *Debugger) context.Context { - return context.WithValue(ctx, ctxKey, l) -} - -// DebuggerFromContext returns a Debugger stored in a context. -// If the context does not contain a Debugger the function returns a silent Debugger. -func DebuggerFromContext(ctx context.Context) *Debugger { +// LoggerFromContext returns a zap.Logger stored in a context. +// If the context does not contain a zap.Logger the function returns a silent zap.Logger. +func LoggerFromContext(ctx context.Context) *zap.Logger { l := ctx.Value(ctxKey) if l == nil { - return silentDebugger + return zap.NewNop() } - return l.(*Debugger) + return l.(*zap.Logger) } diff --git a/internal/oidc/login.go b/internal/oidc/login.go index 9578e11..62e3e43 100644 --- a/internal/oidc/login.go +++ b/internal/oidc/login.go @@ -8,7 +8,6 @@ package oidc import ( "context" - "fmt" "net/url" "strings" "time" @@ -17,22 +16,20 @@ import ( "github.com/i-core/tokget/internal/chrome" "github.com/i-core/tokget/internal/errors" "github.com/i-core/tokget/internal/log" - "golang.org/x/crypto/ssh/terminal" ) // LoginConfig is a configuration of the login process. type LoginConfig struct { - Endpoint string // an OpenID Connect endpoint - ClientID string // a client's ID - RedirectURI string // a client's redirect uri - Scopes string // OpenID Connect scopes - Username string // a user's name - Password string // a user's password - PasswordStdin bool // a user's password from stdin - UsernameField string // a CSS selector of the username field on the login form - PasswordField string // a CSS selector of the password field on the login form - SubmitButton string // a CSS selector of the submit button on the login form - ErrorMessage string // a CSS selector of an error message on the login form + Endpoint string `json:"endpoint"` // an OpenID Connect endpoint + ClientID string `json:"clientId"` // a client's ID + RedirectURI string `json:"redirectUri"` // a client's redirect uri + Scopes string `json:"scopes"` // OpenID Connect scopes + Username string `json:"username"` // a user's name + Password string `json:"password"` // a user's password + UsernameField string `json:"usernameField"` // a CSS selector of the username field on the login form + PasswordField string `json:"passwordField"` // a CSS selector of the password field on the login form + SubmitButton string `json:"submitButton"` // a CSS selector of the submit button on the login form + ErrorMessage string `json:"errorMessage"` // a CSS selector of an error message on the login form } // LoginData is a successful result of the login process. @@ -41,23 +38,11 @@ type LoginData struct { IDToken string `json:"id_token"` } -var pwdFromStdin = defaultPwdFromStdin - -// defaultPwdFromStdin reads a password, without echo, from the stdin. -func defaultPwdFromStdin() (string, error) { - fmt.Println("Enter password: ") - b, err := terminal.ReadPassword(0) - if err != nil { - return "", err - } - return string(b), nil -} - // Login authenticates a user by opening the login page of an OpenID Connect Provider, // and emulating user's actions to fill the authentication parameters and clicking the login button. // The function returns a struct that contains an access token an ID token of the authenticated user. func Login(ctx context.Context, chromeURL string, cnf *LoginConfig) (*LoginData, error) { - debugger := log.DebuggerFromContext(ctx) + logger := log.LoggerFromContext(ctx).Sugar() // // Step 1. Validate input parameters, and request a user for a password if it is not defined. @@ -124,14 +109,6 @@ func Login(ctx context.Context, chromeURL string, cnf *LoginConfig) (*LoginData, return nil, errors.New(errors.KindEndpointInvalid, "OpenID Connect endpoint has an invalid value") } - password := cnf.Password - if cnf.PasswordStdin { - password, err = pwdFromStdin() - if err != nil { - return nil, err - } - } - // // Step 2. Initialize Chrome connection and open a new tab. // @@ -140,7 +117,7 @@ func Login(ctx context.Context, chromeURL string, cnf *LoginConfig) (*LoginData, return nil, errors.Wrap(err, "connect to chrome") } defer func() { - debugger.Debugln("Disconnect Chrome") + logger.Debug("Disconnect Chrome") cancelBrowser() }() @@ -153,7 +130,7 @@ func Login(ctx context.Context, chromeURL string, cnf *LoginConfig) (*LoginData, // Step 3. Navigate to the OpenID Connect Provider's login page. // loginStartURL := buildLoginURL(endpoint, cnf.ClientID, cnf.RedirectURI, cnf.Scopes) - debugger.Debugf("Navigate to the login page %q\n", loginStartURL) + logger.Debugf("Navigate to the login page %q\n", loginStartURL) if err = chrome.Navigate(ctx, loginStartURL); err != nil { return nil, errors.Wrap(err, "navigate to the login page") } @@ -164,13 +141,13 @@ func Login(ctx context.Context, chromeURL string, cnf *LoginConfig) (*LoginData, if err = chromedp.Run(ctx, chromedp.OuterHTML("html", &loginPageContent)); err != nil { return nil, errors.Wrap(err, "get the login page's content") } - debugger.Debugf("The login page is loaded:\n\n%s\n\n", loginPageContent) + logger.Debugf("The login page is loaded:\n\n%s\n\n", loginPageContent) // // Step 4. Validate the login form. // // We expect that the login form contains the username field, password field and submit button. - debugger.Debugln("Fill the login form") + logger.Debug("Fill the login form") formHasElement := func(name, sel string, kind errors.Kind) error { has, hasErr := chrome.HasElement(ctx, sel) if hasErr != nil { @@ -197,8 +174,8 @@ func Login(ctx context.Context, chromeURL string, cnf *LoginConfig) (*LoginData, if err = chromedp.Run(ctx, chromedp.SendKeys(cnf.UsernameField, cnf.Username)); err != nil { return nil, errors.Wrap(err, "fill the username field") } - if password != "" { - if err = chromedp.Run(ctx, chromedp.SendKeys(cnf.PasswordField, password)); err != nil { + if cnf.Password != "" { + if err = chromedp.Run(ctx, chromedp.SendKeys(cnf.PasswordField, cnf.Password)); err != nil { return nil, errors.Wrap(err, "fill the password field") } } @@ -206,7 +183,7 @@ func Login(ctx context.Context, chromeURL string, cnf *LoginConfig) (*LoginData, // // Step 6. Submit the login form. // - debugger.Debugln("Submit the login form") + logger.Debug("Submit the login form") // We submit the login form by clicking on the submit button instead of calling chromedp.Submit() // because of the tool emulates a user's actions. wait := chrome.PageLoadWaiterFunc(ctx, false, 5*time.Second) @@ -224,7 +201,7 @@ func Login(ctx context.Context, chromeURL string, cnf *LoginConfig) (*LoginData, // 1. The OpenID Connect Provider redirects a user to the client's redirect URI with tokens in the URL's fragment. // 2. The OpenID Connect Provider redirects a user to an OpenID Connect error's page. // 3. The OpenID Connect Provider shows a user the login page that contains authentication error's message. - debugger.Debugln("Submiting is finished") + logger.Debug("Submiting is finished") postLoginURL := navHistory.Last() loginData, err := extractOIDCTokens(postLoginURL) if err != nil { @@ -234,7 +211,7 @@ func Login(ctx context.Context, chromeURL string, cnf *LoginConfig) (*LoginData, return loginData, nil } - debugger.Debugln("Failed to authenticate the user") + logger.Debug("Failed to authenticate the user") if err = extractOIDCError(postLoginURL); err != nil { return nil, err } diff --git a/internal/oidc/login_test.go b/internal/oidc/login_test.go index 95e4108..25bf8df 100644 --- a/internal/oidc/login_test.go +++ b/internal/oidc/login_test.go @@ -371,37 +371,6 @@ func TestLogin(t *testing.T) { wantAccToken: "access_token_value", wantIDToken: "id_token_value", }, - { - name: "password from stdin", - endpoints: []endpoint{ - { - path: "/oauth2/auth", - wantQuery: testQuery, - status: http.StatusOK, - html: htmlForm("/handle-auth"), - }, - { - path: "/handle-auth", - status: http.StatusPermanentRedirect, - redirect: "http://localhost:3000#access_token=access_token_value&id_token=id_token_value", - wantBody: map[string]interface{}{"user": "foo", "pass": "bar"}, - }, - }, - cnf: &LoginConfig{ - ClientID: "test-client", - RedirectURI: "http://localhost:9000/auth-callback", - Scopes: "openid profile email", - Username: "foo", - Password: "bar", - PasswordStdin: true, - UsernameField: "#user", - PasswordField: "#pass", - SubmitButton: "#submit", - ErrorMessage: "#error", - }, - wantAccToken: "access_token_value", - wantIDToken: "id_token_value", - }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { @@ -478,16 +447,8 @@ func TestLogin(t *testing.T) { cnf.Endpoint = u.String() } } - if cnf.PasswordStdin { - v := pwdFromStdin - pwdFromStdin = func() (string, error) { return cnf.Password, nil } - defer func() { pwdFromStdin = v }() - } - ctx := context.Background() - if verbose { - ctx = log.WithDebugger(ctx, log.VerboseDebugger) - } + ctx := log.WithLogger(context.Background(), verbose) got, err := Login(ctx, remoteChromeURL, cnf) if tc.wantErr != nil { diff --git a/internal/oidc/logout.go b/internal/oidc/logout.go index 481d3e0..3797415 100644 --- a/internal/oidc/logout.go +++ b/internal/oidc/logout.go @@ -17,8 +17,8 @@ import ( // LogoutConfig is a configuration of the logout process. type LogoutConfig struct { - Endpoint string // an OpenID Connect endpoint - IDToken string // an ID token + Endpoint string `json:"endpoint"` // an OpenID Connect endpoint + IDToken string `json:"idToken"` // an ID token } // Logout logs a user out and revoke the specified ID token. @@ -55,15 +55,15 @@ func Logout(ctx context.Context, chromeURL string, cnf *LogoutConfig) error { // Step 3. Navigate to the OpenID Connect Provider's logout page, and process result. // logoutURL := buildLogoutURL(endpoint, cnf.IDToken) - debugger := log.DebuggerFromContext(ctx) - debugger.Debugf("Navigate to the logout page %q\n", logoutURL) + logger := log.LoggerFromContext(ctx).Sugar() + logger.Debugf("Navigate to the logout page %q\n", logoutURL) if err = chrome.Navigate(ctx, logoutURL); err != nil { return errors.Wrap(err, "navigate to the logout page") } if err = extractOIDCError(navHistory.Last()); err != nil { return err } - debugger.Debugln(`Logged out`) + logger.Debug(`Logged out`) return nil } diff --git a/internal/oidc/logout_test.go b/internal/oidc/logout_test.go index a5d8cc0..ca801bb 100644 --- a/internal/oidc/logout_test.go +++ b/internal/oidc/logout_test.go @@ -149,10 +149,7 @@ func TestLogout(t *testing.T) { cnf.Endpoint = u.String() } } - ctx := context.Background() - if verbose { - ctx = log.WithDebugger(ctx, log.VerboseDebugger) - } + ctx := log.WithLogger(context.Background(), verbose) err := Logout(ctx, remoteChromeURL, cnf) if tc.wantErr != nil { diff --git a/internal/stat/stat.go b/internal/stat/stat.go new file mode 100644 index 0000000..4dc9f80 --- /dev/null +++ b/internal/stat/stat.go @@ -0,0 +1,67 @@ +/* +Copyright (c) JSC iCore. + +This source code is licensed under the MIT license found in the +LICENSE file in the root directory of this source tree. +*/ + +package stat + +import ( + "encoding/json" + "net/http" + + "github.com/i-core/rlog" + "go.uber.org/zap" +) + +// Handler provides HTTP handlers for health checking and versioning. +type Handler struct { + version string +} + +// NewHandler creates a new Handler. +func NewHandler(version string) *Handler { + return &Handler{version: version} +} + +// AddRoutes registers all required routes for the package stat. +func (h *Handler) AddRoutes(apply func(m, p string, h http.Handler, mws ...func(http.Handler) http.Handler)) { + apply(http.MethodGet, "/health/alive", newHealthHandler()) + apply(http.MethodGet, "/health/ready", newHealthHandler()) + apply(http.MethodGet, "/version", newVersionHandler(h.version)) +} + +func newHealthHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + log := rlog.FromContext(r.Context()) + resp := struct { + Status string `json:"status"` + }{ + Status: "ok", + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Info("Failed to marshal health liveness and readiness status", zap.Error(err)) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + } +} + +func newVersionHandler(version string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + log := rlog.FromContext(r.Context()) + resp := struct { + Version string `json:"version"` + }{ + Version: version, + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Info("Failed to marshal version", zap.Error(err)) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + } +} diff --git a/internal/stat/stat_test.go b/internal/stat/stat_test.go new file mode 100644 index 0000000..ddadf7e --- /dev/null +++ b/internal/stat/stat_test.go @@ -0,0 +1,64 @@ +package stat + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/i-core/routegroup" +) + +func TestHealthHandler(t *testing.T) { + rr := httptest.NewRecorder() + h := newHealthHandler() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "http://example.org", nil)) + testResp(t, rr, http.StatusOK, "application/json", map[string]interface{}{"status": "ok"}) +} + +func TestVersionHandler(t *testing.T) { + rr := httptest.NewRecorder() + h := newVersionHandler("test-version") + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "http://example.org", nil)) + testResp(t, rr, http.StatusOK, "application/json", map[string]interface{}{"version": "test-version"}) +} + +func TestStatHandler(t *testing.T) { + var ( + rr *httptest.ResponseRecorder + router = routegroup.NewRouter() + ) + router.AddRoutes(NewHandler("test-version"), "/stat") + + rr = httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/stat/health/alive", nil)) + testResp(t, rr, http.StatusOK, "application/json", map[string]interface{}{"status": "ok"}) + + rr = httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/stat/health/ready", nil)) + testResp(t, rr, http.StatusOK, "application/json", map[string]interface{}{"status": "ok"}) + + rr = httptest.NewRecorder() + router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/stat/version", nil)) + testResp(t, rr, http.StatusOK, "application/json", map[string]interface{}{"version": "test-version"}) +} + +func testResp(t *testing.T, rr *httptest.ResponseRecorder, wantStatus int, wantMime string, wantBody interface{}) { + if rr.Code != wantStatus { + t.Errorf("got status %d, want status %d", rr.Code, wantStatus) + } + + if gotMime := rr.Header().Get("Content-Type"); gotMime != wantMime { + t.Errorf("got content type %q, want content type %q", gotMime, wantMime) + } + + var gotBody interface{} + if err := json.NewDecoder(rr.Body).Decode(&gotBody); err != nil { + t.Fatalf("failed to decode the request body: %s", err) + } + + if !reflect.DeepEqual(gotBody, wantBody) { + t.Errorf("got body %#v, want body %#v", gotBody, wantBody) + } +} diff --git a/internal/web/web.go b/internal/web/web.go new file mode 100644 index 0000000..a14de56 --- /dev/null +++ b/internal/web/web.go @@ -0,0 +1,179 @@ +/* +Copyright (c) JSC iCore. + +This source code is licensed under the MIT license found in the +LICENSE file in the root directory of this source tree. +*/ + +package web + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/i-core/tokget/internal/errors" + "github.com/i-core/tokget/internal/oidc" + "go.uber.org/zap" +) + +const errMsgInvalidBody = "invalid body" + +// LogFn is a function that creates a logging function for HTTP request. +type LogFn func(ctx context.Context) *zap.Logger + +// Defaults is the default parameter of a login config. +type Defaults struct { + RedirectURI string + Scopes string + UsernameField string + PasswordField string + SubmitButton string + ErrorMessage string +} + +// Handler provides HTTP handlers for login and logout operations. +type Handler struct { + defaults Defaults + loginFn LoginFn + logoutFn LogoutFn + logFn LogFn +} + +// NewHandler creates a new Handler. +func NewHandler(defaults Defaults, loginFn LoginFn, logoutFn LogoutFn, logFn LogFn) *Handler { + return &Handler{defaults: defaults, loginFn: loginFn, logoutFn: logoutFn, logFn: logFn} +} + +// AddRoutes registers all required routes for login and logout operations. +func (h *Handler) AddRoutes(apply func(m, p string, h http.Handler, mws ...func(http.Handler) http.Handler)) { + apply(http.MethodPost, "/login", newLoginHandler(h.defaults, h.loginFn, h.logFn)) + apply(http.MethodPost, "/logout", newLogoutHandler(h.logoutFn, h.logFn)) +} + +// LoginFn is an interface to execute the login operation. +type LoginFn func(ctx context.Context, cnf *oidc.LoginConfig) (*oidc.LoginData, error) + +func newLoginHandler(defaults Defaults, loginFn LoginFn, logFn LogFn) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + log := logFn(r.Context()).Sugar() + + loginCnf := &oidc.LoginConfig{ + RedirectURI: defaults.RedirectURI, + Scopes: defaults.Scopes, + UsernameField: defaults.UsernameField, + PasswordField: defaults.PasswordField, + SubmitButton: defaults.SubmitButton, + ErrorMessage: defaults.ErrorMessage, + } + if r.Body != http.NoBody { + if err := json.NewDecoder(r.Body).Decode(loginCnf); err != nil { + httpError(w, http.StatusBadRequest, errMsgInvalidBody) + log.Debug("A payload is invalid", zap.Error(err)) + return + } + } + + v, err := loginFn(r.Context(), loginCnf) + if err != nil { + if isValidationError(err) { + httpError(w, http.StatusBadRequest, err.Error()) + log.Debug("A payload is invalid", zap.Error(err)) + return + } + httpError(w, http.StatusInternalServerError, "") + log.Debug("A payload is invalid", zap.Error(err)) + return + } + + response(w, http.StatusOK, v) + } +} + +// LogoutFn is an interface to execute the logout operation. +type LogoutFn func(ctx context.Context, cnf *oidc.LogoutConfig) error + +func newLogoutHandler(logoutFn LogoutFn, logFn LogFn) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + log := logFn(r.Context()).Sugar() + + logoutCnf := &oidc.LogoutConfig{} + if r.Body != http.NoBody { + if err := json.NewDecoder(r.Body).Decode(logoutCnf); err != nil { + httpError(w, http.StatusBadRequest, errMsgInvalidBody) + log.Debug("A payload is invalid", zap.Error(err)) + return + } + } + + err := logoutFn(r.Context(), logoutCnf) + if err != nil { + if isValidationError(err) { + httpError(w, http.StatusBadRequest, err.Error()) + log.Debug("A payload is invalid", zap.Error(err)) + return + } + httpError(w, http.StatusInternalServerError, "") + log.Debug("A payload is invalid", zap.Error(err)) + return + } + + response(w, http.StatusOK, nil) + } +} + +func isValidationError(err error) bool { + v, ok := err.(*errors.Error) + if !ok { + return false + } + validationErrors := []errors.Kind{ + errors.KindEndpointMissed, + errors.KindClientIDMissed, + errors.KindRedirectURIMissed, + errors.KindScopesMissed, + errors.KindUsernameMissed, + errors.KindUsernameFieldMissed, + errors.KindPasswordFieldMissed, + errors.KindSubmitButtonMissed, + errors.KindErrorMessageMissed, + errors.KindEndpointInvalid, + } + for _, kind := range validationErrors { + if kind == v.Kind { + return true + } + } + return false +} + +// ErrorData describes an error's format that all HTTP handlers must use to sends errors to a client. +type ErrorData struct { + Message string `json:"message"` +} + +// httpError writes an error to a request in a standard form. +func httpError(w http.ResponseWriter, code int, message string, params ...interface{}) { + var msg string + if code >= 500 || message == "" { + msg = http.StatusText(code) + } + msg = fmt.Sprintf(msg, params...) + + response(w, code, ErrorData{Message: msg}) +} + +// response writes data to a request in a standard form. +func response(w http.ResponseWriter, code int, data interface{}) { + w.Header().Set("X-Content-Type-Options", "nosniff") + if data != nil { + w.Header().Set("Content-Type", "application/json") + } + w.WriteHeader(code) + if data != nil { + if err := json.NewEncoder(w).Encode(data); err != nil { + panic(err) + } + } +} diff --git a/internal/web/web_test.go b/internal/web/web_test.go new file mode 100644 index 0000000..3e468b0 --- /dev/null +++ b/internal/web/web_test.go @@ -0,0 +1,319 @@ +package web + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/i-core/tokget/internal/errors" + "github.com/i-core/tokget/internal/oidc" + "go.uber.org/zap" +) + +var testLogFn = func(ctx context.Context) *zap.Logger { return zap.NewNop() } + +func TestLoginHandler(t *testing.T) { + testCases := []struct { + name string + defaults Defaults + body io.Reader + loginErr error + tokens *oidc.LoginData + wantConfig *oidc.LoginConfig + wantStatus int + wantTokens *oidc.LoginData + }{ + { + name: "substitute the default RedirectURI", + defaults: Defaults{RedirectURI: "test"}, + tokens: &oidc.LoginData{AccessToken: "test", IDToken: "test"}, + wantConfig: &oidc.LoginConfig{RedirectURI: "test"}, + wantStatus: http.StatusOK, + wantTokens: &oidc.LoginData{AccessToken: "test", IDToken: "test"}, + }, + { + name: "substitute the default Scopes", + defaults: Defaults{Scopes: "test"}, + tokens: &oidc.LoginData{AccessToken: "test", IDToken: "test"}, + wantConfig: &oidc.LoginConfig{Scopes: "test"}, + wantStatus: http.StatusOK, + wantTokens: &oidc.LoginData{AccessToken: "test", IDToken: "test"}, + }, + { + name: "substitute the default UsernameField", + defaults: Defaults{UsernameField: "test"}, + tokens: &oidc.LoginData{AccessToken: "test", IDToken: "test"}, + wantConfig: &oidc.LoginConfig{UsernameField: "test"}, + wantStatus: http.StatusOK, + wantTokens: &oidc.LoginData{AccessToken: "test", IDToken: "test"}, + }, + { + name: "substitute the default PasswordField", + defaults: Defaults{PasswordField: "test"}, + tokens: &oidc.LoginData{AccessToken: "test", IDToken: "test"}, + wantConfig: &oidc.LoginConfig{PasswordField: "test"}, + wantStatus: http.StatusOK, + wantTokens: &oidc.LoginData{AccessToken: "test", IDToken: "test"}, + }, + { + name: "substitute the default SubmitButton", + defaults: Defaults{SubmitButton: "test"}, + tokens: &oidc.LoginData{AccessToken: "test", IDToken: "test"}, + wantConfig: &oidc.LoginConfig{SubmitButton: "test"}, + wantStatus: http.StatusOK, + wantTokens: &oidc.LoginData{AccessToken: "test", IDToken: "test"}, + }, + { + name: "substitute the default ErrorMessage", + defaults: Defaults{ErrorMessage: "test"}, + tokens: &oidc.LoginData{AccessToken: "test", IDToken: "test"}, + wantConfig: &oidc.LoginConfig{ErrorMessage: "test"}, + wantStatus: http.StatusOK, + wantTokens: &oidc.LoginData{AccessToken: "test", IDToken: "test"}, + }, + { + name: "login parameters are transfered to the login function", + body: toJSON(oidc.LoginConfig{ + Endpoint: "test-endpoint", + ClientID: "test-client-id", + RedirectURI: "test-redirect-uri", + Scopes: "test-scopes", + Username: "test-username", + Password: "test-password", + UsernameField: "test-username-field", + PasswordField: "test-password-field", + SubmitButton: "test-submit-button", + ErrorMessage: "test-error-message", + }), + tokens: &oidc.LoginData{AccessToken: "test", IDToken: "test"}, + wantConfig: &oidc.LoginConfig{ + Endpoint: "test-endpoint", + ClientID: "test-client-id", + RedirectURI: "test-redirect-uri", + Scopes: "test-scopes", + Username: "test-username", + Password: "test-password", + UsernameField: "test-username-field", + PasswordField: "test-password-field", + SubmitButton: "test-submit-button", + ErrorMessage: "test-error-message", + }, + wantStatus: http.StatusOK, + wantTokens: &oidc.LoginData{AccessToken: "test", IDToken: "test"}, + }, + { + name: "bad request when endpoint is missed", + loginErr: errors.New(errors.KindEndpointMissed), + wantConfig: &oidc.LoginConfig{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "bad request when client ID is missed", + loginErr: errors.New(errors.KindClientIDMissed), + wantConfig: &oidc.LoginConfig{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "bad request when redirect URI is missed", + loginErr: errors.New(errors.KindRedirectURIMissed), + wantConfig: &oidc.LoginConfig{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "bad request when scopes are missed", + loginErr: errors.New(errors.KindScopesMissed), + wantConfig: &oidc.LoginConfig{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "bad request when username is missed", + loginErr: errors.New(errors.KindUsernameMissed), + wantConfig: &oidc.LoginConfig{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "bad request when username field is missed", + loginErr: errors.New(errors.KindUsernameFieldMissed), + wantConfig: &oidc.LoginConfig{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "bad request when password field is missed", + loginErr: errors.New(errors.KindPasswordFieldMissed), + wantConfig: &oidc.LoginConfig{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "bad request when submit button is missed", + loginErr: errors.New(errors.KindSubmitButtonMissed), + wantConfig: &oidc.LoginConfig{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "bad request when error message is missed", + loginErr: errors.New(errors.KindErrorMessageMissed), + wantConfig: &oidc.LoginConfig{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "bad request when endpoint is invalid", + loginErr: errors.New(errors.KindEndpointInvalid), + wantConfig: &oidc.LoginConfig{}, + wantStatus: http.StatusBadRequest, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var gotConfig *oidc.LoginConfig + testLoginFn := func(ctx context.Context, cnf *oidc.LoginConfig) (*oidc.LoginData, error) { + gotConfig = cnf + if tc.loginErr != nil { + return nil, tc.loginErr + } + return tc.tokens, nil + } + h := newLoginHandler(tc.defaults, testLoginFn, testLogFn) + + req := httptest.NewRequest(http.MethodPost, "/", tc.body) + res := httptest.NewRecorder() + h.ServeHTTP(res, req) + + var gotTokens *oidc.LoginData + if res.Body.Len() > 0 { + gotTokens = &oidc.LoginData{} + if err := json.Unmarshal(res.Body.Bytes(), gotTokens); err != nil { + t.Fatalf("failed to read the login handler's response: %s", err) + } + } + + if !reflect.DeepEqual(gotConfig, tc.wantConfig) { + t.Errorf("got config %#v; want config: %#v", gotConfig, tc.wantConfig) + } + if res.Code != tc.wantStatus { + t.Errorf("got status code %d; want status code: %#v", res.Code, tc.wantStatus) + } + if tc.wantStatus == http.StatusOK { + if !reflect.DeepEqual(gotTokens, tc.wantTokens) { + t.Errorf("got tokens %#v; want tokens: %#v", gotTokens, tc.wantTokens) + } + } + }) + } +} + +func TestLogoutHandler(t *testing.T) { + testCases := []struct { + name string + body io.Reader + loginErr error + wantConfig *oidc.LogoutConfig + wantStatus int + }{ + { + name: "logout parameters are transfered to the login function", + body: toJSON(oidc.LogoutConfig{ + Endpoint: "test-endpoint", + IDToken: "test-id-token", + }), + wantConfig: &oidc.LogoutConfig{ + Endpoint: "test-endpoint", + IDToken: "test-id-token", + }, + wantStatus: http.StatusOK, + }, + { + name: "bad request when endpoint is missed", + loginErr: errors.New(errors.KindEndpointMissed), + wantConfig: &oidc.LogoutConfig{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "bad request when client ID is missed", + loginErr: errors.New(errors.KindClientIDMissed), + wantConfig: &oidc.LogoutConfig{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "bad request when redirect URI is missed", + loginErr: errors.New(errors.KindRedirectURIMissed), + wantConfig: &oidc.LogoutConfig{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "bad request when scopes are missed", + loginErr: errors.New(errors.KindScopesMissed), + wantConfig: &oidc.LogoutConfig{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "bad request when username is missed", + loginErr: errors.New(errors.KindUsernameMissed), + wantConfig: &oidc.LogoutConfig{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "bad request when username field is missed", + loginErr: errors.New(errors.KindUsernameFieldMissed), + wantConfig: &oidc.LogoutConfig{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "bad request when password field is missed", + loginErr: errors.New(errors.KindPasswordFieldMissed), + wantConfig: &oidc.LogoutConfig{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "bad request when submit button is missed", + loginErr: errors.New(errors.KindSubmitButtonMissed), + wantConfig: &oidc.LogoutConfig{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "bad request when error message is missed", + loginErr: errors.New(errors.KindErrorMessageMissed), + wantConfig: &oidc.LogoutConfig{}, + wantStatus: http.StatusBadRequest, + }, + { + name: "bad request when endpoint is invalid", + loginErr: errors.New(errors.KindEndpointInvalid), + wantConfig: &oidc.LogoutConfig{}, + wantStatus: http.StatusBadRequest, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var gotConfig *oidc.LogoutConfig + testLogoutFn := func(ctx context.Context, cnf *oidc.LogoutConfig) error { + gotConfig = cnf + return tc.loginErr + } + h := newLogoutHandler(testLogoutFn, testLogFn) + + req := httptest.NewRequest(http.MethodPost, "/", tc.body) + res := httptest.NewRecorder() + h.ServeHTTP(res, req) + + if !reflect.DeepEqual(gotConfig, tc.wantConfig) { + t.Errorf("got config %#v; want config: %#v", gotConfig, tc.wantConfig) + } + if res.Code != tc.wantStatus { + t.Errorf("got status code %d; want status code: %#v", res.Code, tc.wantStatus) + } + }) + } +} + +func toJSON(data interface{}) io.Reader { + b, err := json.Marshal(data) + if err != nil { + panic(err) + } + return bytes.NewReader(b) +} From 4aa2af25b120b86b6570460878103f46dd5a6602 Mon Sep 17 00:00:00 2001 From: Nikolay Stupak Date: Wed, 16 Oct 2019 10:30:26 +0300 Subject: [PATCH 2/3] log: remove excess new lines --- internal/chrome/chrome.go | 8 ++++---- internal/oidc/login.go | 4 ++-- internal/oidc/logout.go | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/chrome/chrome.go b/internal/chrome/chrome.go index 9dc127a..c75c0ab 100644 --- a/internal/chrome/chrome.go +++ b/internal/chrome/chrome.go @@ -63,7 +63,7 @@ func ConnectWithContext(parent context.Context, chromeURL string, domains ...Dom logger.Debug("Start a new chrome process") ctx, cancel = chromedp.NewContext(parent) } else { - logger.Debugf("Connect to a chrome process at %q\n", chromeURL) + logger.Debugf("Connect to a chrome process at %q", chromeURL) var err error if ctx, cancel, err = connectToRemoteChrome(parent, chromeURL); err != nil { return nil, nil, errors.Wrap(err, "connect to remote Chrome process") @@ -85,7 +85,7 @@ func ConnectWithContext(parent context.Context, chromeURL string, domains ...Dom if err := chromedp.Run(ctx, versionAction); err != nil { return errors.Wrap(err, "get Chrome version") } - logger.Debugf("Chrome info:\n\tprotocolVersion: %s\n\tproduct: %s\n", cdpVersion, product) + logger.Debugf("Chrome info:\n\tprotocolVersion: %s\n\tproduct: %s", cdpVersion, product) major, err := majorVersion(product) if err != nil { return errors.New("invalid Chrome version %q", product) @@ -172,7 +172,7 @@ func connectToRemoteChrome(parent context.Context, chromeURL string) (context.Co return nil, nil, errors.New("unexpected chrome config:\n%s", string(b)) } logger := log.LoggerFromContext(parent).Sugar() - logger.Debugf("Remote Chrome Config:\n%s\n", string(b)) + logger.Debugf("Remote Chrome Config:\n%s", string(b)) debuggerURL := cnfs[0].DebuggerURL // Connect to a remote Chrome process. @@ -235,7 +235,7 @@ func NewNavHistory(ctx context.Context) (*NavHistory, error) { } navHistory.entries = append(navHistory.entries, rurl.String()) logger := log.LoggerFromContext(ctx).Sugar() - logger.Debugf("request %s %s\n", v.Request.Method, rurl.String()) + logger.Debugf("request %s %s", v.Request.Method, rurl.String()) } go func(interceptionID network.InterceptionID) { if err := chromedp.Run(ctx, network.ContinueInterceptedRequest(interceptionID)); err != nil { diff --git a/internal/oidc/login.go b/internal/oidc/login.go index 62e3e43..f66dcfb 100644 --- a/internal/oidc/login.go +++ b/internal/oidc/login.go @@ -130,7 +130,7 @@ func Login(ctx context.Context, chromeURL string, cnf *LoginConfig) (*LoginData, // Step 3. Navigate to the OpenID Connect Provider's login page. // loginStartURL := buildLoginURL(endpoint, cnf.ClientID, cnf.RedirectURI, cnf.Scopes) - logger.Debugf("Navigate to the login page %q\n", loginStartURL) + logger.Debugf("Navigate to the login page %q", loginStartURL) if err = chrome.Navigate(ctx, loginStartURL); err != nil { return nil, errors.Wrap(err, "navigate to the login page") } @@ -141,7 +141,7 @@ func Login(ctx context.Context, chromeURL string, cnf *LoginConfig) (*LoginData, if err = chromedp.Run(ctx, chromedp.OuterHTML("html", &loginPageContent)); err != nil { return nil, errors.Wrap(err, "get the login page's content") } - logger.Debugf("The login page is loaded:\n\n%s\n\n", loginPageContent) + logger.Debugf("The login page is loaded:\n\n%s\n", loginPageContent) // // Step 4. Validate the login form. diff --git a/internal/oidc/logout.go b/internal/oidc/logout.go index 3797415..b1dd0fb 100644 --- a/internal/oidc/logout.go +++ b/internal/oidc/logout.go @@ -56,7 +56,7 @@ func Logout(ctx context.Context, chromeURL string, cnf *LogoutConfig) error { // logoutURL := buildLogoutURL(endpoint, cnf.IDToken) logger := log.LoggerFromContext(ctx).Sugar() - logger.Debugf("Navigate to the logout page %q\n", logoutURL) + logger.Debugf("Navigate to the logout page %q", logoutURL) if err = chrome.Navigate(ctx, logoutURL); err != nil { return errors.Wrap(err, "navigate to the logout page") } From b9bbe82b10bdbaa585230b1b2e2bdbd3f2697ae0 Mon Sep 17 00:00:00 2001 From: Nikolay Stupak Date: Tue, 22 Oct 2019 16:59:20 +0300 Subject: [PATCH 3/3] login: remove terminal option pwd-stdin --- README.md | 4 ++-- cmd/tokget/main.go | 15 --------------- go.mod | 1 - go.sum | 3 --- 4 files changed, 2 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 17d5318..f5e517c 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Run `tokget -h` to see a list of available commands. In terminal: ```bash -tokget login -e https://openid-connect-provider -c -r -s openid,profile,email -u username --pwd-std +tokget login -e https://openid-connect-provider -c -r -s openid,profile,email -u username -p password ``` **Note** Google Chrome must be in `$PATH`. @@ -71,7 +71,7 @@ Via Docker: ```bash -docker run --name tokget --rm -it icoreru/tokget:v1.2.0 login -e https://openid-connect-provider -c -r -s openid,profile,email -u username -pwd-stdin +docker run --name tokget --rm -it icoreru/tokget:v1.2.0 login -e https://openid-connect-provider -c -r -s openid,profile,email -u username -p password ``` **Note** Image `icoreru/tokget` already contains Google Chrome so you don't need to run Google Chrome manually. diff --git a/cmd/tokget/main.go b/cmd/tokget/main.go index 87acc97..9d9ac3c 100644 --- a/cmd/tokget/main.go +++ b/cmd/tokget/main.go @@ -25,7 +25,6 @@ import ( "github.com/i-core/tokget/internal/web" "github.com/kelseyhightower/envconfig" "go.uber.org/zap" - "golang.org/x/crypto/ssh/terminal" ) // version will be filled at compile time. @@ -52,7 +51,6 @@ func main() { verboseLogin bool verboseLogout bool scopes string - passwordStdin bool ) loginCnf := &oidc.LoginConfig{} @@ -67,7 +65,6 @@ func main() { loginCmd.StringVar(&loginCnf.PasswordField, "password-field", defaultPasswordField, "a CSS selector of the password field on the login form") loginCmd.StringVar(&loginCnf.SubmitButton, "submit-button", defaultSubmitButton, "a CSS selector of the submit button on the login form") loginCmd.StringVar(&loginCnf.ErrorMessage, "error-message", defaultErrorMessage, "a CSS selector of an error message on the login form") - loginCmd.BoolVar(&passwordStdin, "pwd-stdin", false, "a user's password from stdin") loginCmd.BoolVar(&verboseLogin, "v", false, "verbose mode") logoutCnf := &oidc.LogoutConfig{} @@ -152,18 +149,6 @@ func main() { loginCnf.Scopes = strings.ReplaceAll(scopes, ",", " ") - if passwordStdin { - fmt.Println("Enter password: ") - b, err := terminal.ReadPassword(0) - if err != nil { - if errors.Cause(err) != context.Canceled { - fmt.Fprintf(os.Stderr, "Error: %s\n", err) - } - os.Exit(1) - } - loginCnf.Password = string(b) - } - ctx := withInterrupt(context.Background()) ctx = log.WithLogger(ctx, verboseLogin) diff --git a/go.mod b/go.mod index 9095ad3..87f22f2 100644 --- a/go.mod +++ b/go.mod @@ -10,5 +10,4 @@ require ( github.com/justinas/nosurf v0.0.0-20190416172904-05988550ea18 github.com/kelseyhightower/envconfig v1.4.0 go.uber.org/zap v1.10.0 - golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5 ) diff --git a/go.sum b/go.sum index bc68551..35fa2b2 100644 --- a/go.sum +++ b/go.sum @@ -57,9 +57,6 @@ go.uber.org/multierr v1.1.0 h1:HoEmRHQPVSqub6w2z2d2EOVs2fjyFRGyofhKuyDq0QI= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/zap v1.10.0 h1:ORx85nbTijNz8ljznvCMR1ZBIPKFn3jQrag10X2AsuM= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5 h1:58fnuSXlxZmFdJyvtTFVmVhcMLU6v5fEb/ok4wyqtNU= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=