diff --git a/api/config.go b/api/config.go index 6b95a26..7804b9e 100644 --- a/api/config.go +++ b/api/config.go @@ -15,6 +15,7 @@ type Configuration struct { CAFile string `mapstructure:"ca_file"` CAPath string `mapstructure:"ca_path"` User string `mapstructure:"user"` + AccessToken string `mapstructure:"access_token"` HttpClient *http.Client } diff --git a/api/http_client.go b/api/http_client.go index d419b50..b1cd313 100644 --- a/api/http_client.go +++ b/api/http_client.go @@ -99,6 +99,8 @@ func GetClient(cc Configuration) (HTTPClient, error) { client.basicAuthUserPass = url.UserPassword(parts[0], parts[1]) } + client.accessToken = strings.TrimSpace(cc.AccessToken) + client.workflows = &workflowsService{client: client} client.executions = &executionsService{client: client} client.sshKeys = &sshKeysService{client: client} @@ -114,6 +116,7 @@ type client struct { sshKeys SSHKeysService basicAuthUserPass *url.Userinfo + accessToken string } func (c *client) Workflows() WorkflowsService { @@ -123,6 +126,7 @@ func (c *client) Workflows() WorkflowsService { func (c *client) Executions() ExecutionsService { return c.executions } + func (c *client) SSHKeys() SSHKeysService { return c.sshKeys } @@ -133,6 +137,9 @@ func (c *client) NewRequest(ctx context.Context, method, path string, body io.Re if err != nil { return nil, errors.Wrap(err, "Cannot create request") } + if c.accessToken != "" { + req.Header.Add("Authorization", "Bearer "+c.accessToken) + } req.URL.User = c.basicAuthUserPass return req, nil diff --git a/api/workflows.go b/api/workflows.go index 8fc63f5..414265f 100644 --- a/api/workflows.go +++ b/api/workflows.go @@ -48,7 +48,6 @@ func (s *workflowsService) Trigger(ctx context.Context, workflowID string, input return "", errors.Wrap(err, "failed to create http request") } request.Header.Add("Accept", "application/json") - request.Header.Add("Content-Type", "application/json") response, err := s.client.Do(request) if err != nil { diff --git a/cmd/waas/root.go b/cmd/waas/root.go index cde9468..92c4775 100644 --- a/cmd/waas/root.go +++ b/cmd/waas/root.go @@ -110,6 +110,7 @@ const certFileFlagName = "cert_file" const caFileFlagName = "ca_file" const caPathFlagName = "ca_path" const userFlagName = "user" +const accessTokenFlagName = "access_token" func init() { cobra.OnInitialize(initConfig) @@ -120,7 +121,7 @@ func init() { // Global flags rootCmd.PersistentFlags().StringVarP(&output, "output", "o", DefaultDisplayOutput, "Output format either \"text\" or \"json\".") - rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.hpcwaas-api.yaml)") + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.waas)") rootCmd.PersistentFlags().String(apiURLFlagName, api.DefaultAPIAddress, "The default URL used to connect to the API") rootCmd.PersistentFlags().Bool(skipTLSVerifyFlagName, false, "Either or not to skip SSL certificates validation") rootCmd.PersistentFlags().String(caFileFlagName, "", "CA File to use to validate SSL certificates") @@ -136,6 +137,7 @@ If the password is not specified, it will be prompted for. The user name and passwords are split up on the first colon, which makes it impossible to use a colon in the user name. However, the password can contains colons. `) + rootCmd.PersistentFlags().StringP(accessTokenFlagName, "t", "", "Access token for authentication") // Global flags/config binding viper.BindPFlag(apiURLFlagName, rootCmd.PersistentFlags().Lookup(apiURLFlagName)) @@ -145,6 +147,7 @@ However, the password can contains colons. viper.BindPFlag(keyFileFlagName, rootCmd.PersistentFlags().Lookup(keyFileFlagName)) viper.BindPFlag(certFileFlagName, rootCmd.PersistentFlags().Lookup(certFileFlagName)) viper.BindPFlag(userFlagName, rootCmd.PersistentFlags().Lookup(userFlagName)) + viper.BindPFlag(accessTokenFlagName, rootCmd.PersistentFlags().Lookup(accessTokenFlagName)) //Environment Variables viper.SetEnvPrefix("HW") // HW == HpcWaas @@ -158,6 +161,7 @@ However, the password can contains colons. viper.BindEnv(keyFileFlagName) viper.BindEnv(certFileFlagName) viper.BindEnv(userFlagName) + viper.BindEnv(accessTokenFlagName) // Global defaults viper.SetDefault(apiURLFlagName, api.DefaultAPIAddress) @@ -180,6 +184,7 @@ func initConfig() { // Search config in home directory with name ".waas" (without extension). viper.AddConfigPath(home) viper.SetConfigName(".waas") + viper.SetConfigType("yaml") } viper.AutomaticEnv() // read in environment variables that match diff --git a/docs/rest-api.md b/docs/rest-api.md index 2b7923a..7a865b2 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -15,10 +15,49 @@ The design of workflows themself is out of the scope of this API and is done by ### Authentication / Authorization -It is using the HTTP Basic authentication. It is also in the process of integrating with Unity Single Sign-On service +HPCWaaS handles authentication with the OAuth 2 protocol. Identities are managed by the Unity identity provider. Authentication in HPCWaaS is a two-ste process: + +* Step 1: Retrieve an **access token** by visiting the `/auth/login` endpoint in your browser. +* Step 2: Use the token for sending requests to the API. + +#### Authenticating with the REST API + +For accessing the REST API with a general utility like `curl`, you need to pass the token in the header, e.g. +`curl -H "Authorization: Bearer " ...` + +#### Authenticating with the CLI utility + +For the `waas` CLI utility, you can pass the token in three different places: + +* In the WaaS config file with the `access_token` key, e.g. + `access_token: ` +* In the `HW_ACCESS_TOKEN` environment variable, e.g. + `export HW_ACCESS_TOKEN=` +* In the command-line options, e.g. + `waas workflows list -t=` + or + `waas workflows list --access_token=` + +The parameters take precendence in the following order: command-line option > environment variable > config file. + +#### Authorization + +To make a workflow visible by the `waas` CLI, you need to add the `hpcwaas-workfows` tag to your workflow, in _alien4cloud_. The tag value will be the name of your workflow. +By default, workflows are public. This means that they are displayed when any user uses the `wass workflows list` command. To restrict access to a workflow to a user (or a group of users), you need to add the `hpcwaas-authorized-users` tag to your workflow. The tag value is a list of comma-seperated UUID of the users you want to allow access to your workflow. +Users can get their Unity UUID by logging in at the `/auth/login` endpoint in their browser. + +**Important note:** Without the `hpcwaas-workflows` tag, your workflow won't be visible from the `waas` CLI, even if the `hpcwaas-authorized-users` tag is defined. So, to make a workflow visible to a user (or a group of users), both tags need to be present. ### API Endpoints +#### Request authentication token + +This API endpoint is to be used in a browser. It allows, after logging in to a Unity server, to retrieve an **access token** that is needed to authenticate when accessing other endpoints. It also displays your Unity UUID, which can be used to restrict the access to your workflow. + +##### Endpoint + +`/auth/login` + #### List available workflows This API endpoint provides the workflows that could be triggered by an *end-user* diff --git a/go.mod b/go.mod index 46463a6..fd2d9a7 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/pterm/pterm v0.12.39 golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064 - golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 + golang.org/x/term v0.8.0 gotest.tools/v3 v3.1.0 ) @@ -35,14 +35,19 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect github.com/fatih/color v1.13.0 // indirect github.com/fsnotify/fsnotify v1.5.1 // indirect + github.com/gin-contrib/sessions v0.0.5 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect github.com/go-playground/validator/v10 v10.10.1 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/golang/snappy v0.0.4 // indirect - github.com/google/go-cmp v0.5.7 // indirect + github.com/google/go-cmp v0.5.8 // indirect github.com/gookit/color v1.5.0 // indirect + github.com/gorilla/context v1.1.1 // indirect + github.com/gorilla/securecookie v1.1.1 // indirect + github.com/gorilla/sessions v1.2.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-hclog v1.2.0 // indirect @@ -87,10 +92,12 @@ require ( github.com/ugorji/go/codec v1.2.7 // indirect github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect go.uber.org/atomic v1.9.0 // indirect - golang.org/x/net v0.0.0-20220225172249-27dd8689420f // indirect - golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 // indirect - golang.org/x/text v0.3.7 // indirect + golang.org/x/net v0.10.0 // indirect + golang.org/x/oauth2 v0.8.0 // indirect + golang.org/x/sys v0.8.0 // indirect + golang.org/x/text v0.9.0 // indirect golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 // indirect + google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb // indirect google.golang.org/grpc v1.45.0 // indirect google.golang.org/protobuf v1.28.0 // indirect diff --git a/go.sum b/go.sum index ef56e9e..c26b4a2 100644 --- a/go.sum +++ b/go.sum @@ -141,6 +141,8 @@ github.com/frankban/quicktest v1.13.0/go.mod h1:qLE0fzW0VuyUAJgPU19zByoIr0HtCHN/ github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/gin-contrib/sessions v0.0.5 h1:CATtfHmLMQrMNpJRgzjWXD7worTh7g7ritsQfmF+0jE= +github.com/gin-contrib/sessions v0.0.5/go.mod h1:vYAuaUPqie3WUSsft6HUlCjlwwoJQs97miaG2+7neKY= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.7.7 h1:3DoBmSbJbZAWqXJC3SLjAPfutPJJRN1U5pALB7EeTTs= @@ -171,6 +173,8 @@ github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3a github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -222,6 +226,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -254,6 +260,12 @@ github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8 github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= github.com/gookit/color v1.5.0 h1:1Opow3+BWDwqor78DcJkJCIwnkviFi+rrOANki9BUFw= github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= +github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/goware/urlx v0.3.1 h1:BbvKl8oiXtJAzOzMqAQ0GfIhf96fKeNEZfm9ocNSUBI= github.com/goware/urlx v0.3.1/go.mod h1:h8uwbJy68o+tQXCGZNa9D73WN8n0r9OBae5bUnLcgjw= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= @@ -640,6 +652,8 @@ golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220225172249-27dd8689420f h1:oA4XRj0qtSt8Yo1Zms0CUlsT3KG69V2UGQWPBxujDmc= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -657,6 +671,8 @@ golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8= +golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -738,11 +754,15 @@ golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 h1:OH54vjqzRWmbJ62fjuhxy7AxFFgoHN0/DPc/UrL8cAs= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -753,6 +773,8 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -858,6 +880,7 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20170818010345-ee236bd376b0/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= diff --git a/pkg/rest/authorize.go b/pkg/rest/authorize.go new file mode 100644 index 0000000..d50f9a5 --- /dev/null +++ b/pkg/rest/authorize.go @@ -0,0 +1,84 @@ +package rest + +import ( + "context" + "encoding/base64" + "fmt" + "net/http" + + "github.com/eflows4hpc/hpcwaas-api/api" + "github.com/eflows4hpc/hpcwaas-api/pkg/store" + "github.com/gin-gonic/gin" + "github.com/pkg/errors" +) + +func (s *Server) authorize(gc *gin.Context) { + // Check state + requestState := gc.Request.FormValue("state") + if requestState != s.Config.Auth.State { + writeError(gc, newBadRequestMessage("request state doesn't match session state")) + return + } + + // Exchange code + authorizationCode := gc.Request.FormValue("code") + token, err := s.Config.Auth.OAuth2.Exchange(context.Background(), authorizationCode) + if err != nil { + writeError(gc, newInternalServerError(err)) + return + } + + // Get user info from endpoint + userInfo, err := s.getUserInfo(gc, token.AccessToken) + if err != nil { + writeError(gc, newInternalServerError(err)) + return + } + + // Start a new session with user info and token + err = s.store.CreateSession(gc, userInfo, token.AccessToken) + if err != nil { + writeError(gc, newInternalServerError(err)) + return + } + + // Set user ID in context + userAccount := AuthAccount{Username: userInfo.Sub} + gc.Set(gin.AuthUserKey, userAccount) + + encodedToken := base64.StdEncoding.EncodeToString([]byte(token.AccessToken)) + msg := fmt.Sprintf(` Log in successful + +Welcome %s %s +User ID: %s +You can now use HPCWaaS + +For using the CLI, please use the following token: + %s +`, userInfo.FirstName, userInfo.Surname, userInfo.Sub, encodedToken) + + gc.String(http.StatusOK, msg) +} + +func (s *Server) getUserInfo(ctx context.Context, accessToken string) (*store.UserInfo, error) { + var res store.UserInfo + url := s.Config.Auth.UserInfoURL + request, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, errors.Wrap(err, "failed to create http request") + } + request.Header.Set("Authorization", "Bearer "+accessToken) + + client := http.Client{} + response, err := client.Do(request) + if err != nil { + return nil, errors.Wrap(err, "failed to send http request to get user info") + } + + err = api.ReadResponse(response, &res) + if err != nil { + return nil, err + } + + return &res, nil +} diff --git a/pkg/rest/config.go b/pkg/rest/config.go index e17c745..1aeb215 100644 --- a/pkg/rest/config.go +++ b/pkg/rest/config.go @@ -3,6 +3,7 @@ package rest import ( "github.com/eflows4hpc/hpcwaas-api/pkg/managers/a4c" "github.com/eflows4hpc/hpcwaas-api/pkg/managers/vault" + "golang.org/x/oauth2" ) const DefaultListenAddress = "0.0.0.0:9090" @@ -15,7 +16,23 @@ type Config struct { } type AuthConfig struct { - BasicAuth *BasicAuthConfig `mapstructure:"basic_auth,omitempty"` + AuthType string `mapstructure:"auth_type,omitempty"` + BasicAuth *BasicAuthConfig `mapstructure:"basic_auth,omitempty"` + AuthURL string `mapstructure:"auth_url,omitempty"` + TokenURL string `mapstructure:"token_url,omitempty"` + UserInfoURL string `mapstructure:"user_info_url,omitempty"` + RedirectURL string `mapstructure:"redirect_url,omitempty"` + Scopes []string `mapstructure:"scopes,omitempty"` + SessionDuration int64 `mapstructure:"session_duration,omitempty"` + ClientID string `mapstructure:"client_id,omitempty"` + ClientSecret string `mapstructure:"client_secret,omitempty"` + + // Authentication parameters, to be setup at server start + + // OAuth2 confiuguration + OAuth2 *oauth2.Config + // Random state to protect against Cross-Site Request Forgery (CSRF) + State string } type BasicAuthConfig struct { diff --git a/pkg/rest/login.go b/pkg/rest/login.go new file mode 100644 index 0000000..3578560 --- /dev/null +++ b/pkg/rest/login.go @@ -0,0 +1,12 @@ +package rest + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +func (s *Server) login(gc *gin.Context) { + url := s.Config.Auth.OAuth2.AuthCodeURL(s.Config.Auth.State) + gc.Redirect(http.StatusSeeOther, url) +} diff --git a/pkg/rest/routes.go b/pkg/rest/routes.go index 604f318..9e184d6 100644 --- a/pkg/rest/routes.go +++ b/pkg/rest/routes.go @@ -1,9 +1,18 @@ package rest -import "github.com/gin-gonic/gin" +import ( + "log" -func (s *Server) setupRoutes() { + "github.com/eflows4hpc/hpcwaas-api/pkg/util" + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" +) + +const SessionName = "hpcwaas-session" +func (s *Server) setupRoutes() { + s.setupStore() rootGrp := s.router.Group("/") s.setupAuth(rootGrp) { @@ -14,10 +23,32 @@ func (s *Server) setupRoutes() { rootGrp.DELETE("/executions/:execution_id", s.cancelExecution) rootGrp.POST("/ssh_keys", s.createKey) } + + authGrp := s.router.Group("/auth") + { + authGrp.GET("/login", s.login) + authGrp.GET("/authorize", s.authorize) + } } -func (s *Server) setupAuth(rootGrp *gin.RouterGroup) { - if s.Config.Auth.BasicAuth != nil { - rootGrp.Use(basicAuth(s.Config.Auth.BasicAuth)) +func (s *Server) setupAuth(group *gin.RouterGroup) { + auth := s.Config.Auth + switch auth.AuthType { + case "basic": + log.Println("Using basic authentication") + group.Use(basicAuth(auth.BasicAuth)) + case "sso": + log.Println("Using SSO authentication") + s.initSsoConf() + group.Use(s.ssoAuth(s.Config.Auth.OAuth2)) + default: + log.Printf("Invalid authentication type*: '%s'", auth.AuthType) } } + +func (s *Server) setupStore() { + storeSecret := util.SecureRandomBytes(64) + store := cookie.NewStore(storeSecret) + session := sessions.Sessions(SessionName, store) + s.router.Use(session) +} diff --git a/pkg/rest/server.go b/pkg/rest/server.go index ce40180..741cb38 100644 --- a/pkg/rest/server.go +++ b/pkg/rest/server.go @@ -5,6 +5,7 @@ import ( "github.com/eflows4hpc/hpcwaas-api/pkg/managers/a4c" "github.com/eflows4hpc/hpcwaas-api/pkg/managers/vault" + "github.com/eflows4hpc/hpcwaas-api/pkg/store" "github.com/gin-gonic/gin" ) @@ -14,6 +15,7 @@ type Server struct { router *gin.Engine a4cManager a4c.Manager vaultManager vault.Manager + store store.Store } func (s *Server) StartServer() error { @@ -29,6 +31,8 @@ func (s *Server) StartServer() error { } defer vault.CloseRenewers() + s.store = store.NewStore(s.Config.Auth.SessionDuration) + s.router = gin.Default() s.setupRoutes() diff --git a/pkg/rest/sso_auth.go b/pkg/rest/sso_auth.go new file mode 100644 index 0000000..4a89529 --- /dev/null +++ b/pkg/rest/sso_auth.go @@ -0,0 +1,72 @@ +package rest + +import ( + "encoding/base64" + "log" + "regexp" + + "github.com/eflows4hpc/hpcwaas-api/pkg/util" + "github.com/gin-gonic/gin" + "golang.org/x/oauth2" +) + +var ( + bearerPattern = regexp.MustCompile("^Bearer *([^ ]+) *$") +) + +// getRandomState returns a number of random bytes, encoded in base64 +func getRandomState(length int) string { + return util.SecureRandomSecret(length) +} + +func (s *Server) initSsoConf() { + auth := s.Config.Auth + s.Config.Auth.OAuth2 = &oauth2.Config{ + ClientID: auth.ClientID, + ClientSecret: auth.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: auth.AuthURL, + TokenURL: auth.TokenURL, + }, + Scopes: auth.Scopes, + RedirectURL: auth.RedirectURL, + } + s.Config.Auth.State = getRandomState(64) +} + +func (s *Server) ssoAuth(oauthConf *oauth2.Config) gin.HandlerFunc { + if oauthConf == nil { + log.Fatal("Empty oauth2 config") + } + + return func(gc *gin.Context) { + authorization := gc.Request.Header.Get("Authorization") + if authorization == "" { + writeError(gc, newUnauthorizedRequest(gc, "Authorization Required")) + return + } + if !bearerPattern.MatchString(authorization) { + writeError(gc, newUnauthorizedRequest(gc, "Invalid authorization format")) + return + } + base64AccessToken := bearerPattern.FindStringSubmatch(authorization)[1] + bytesAccessToken, err := base64.StdEncoding.DecodeString(base64AccessToken) + if err != nil { + writeError(gc, newUnauthorizedRequest(gc, "Invalid authorization token")) + return + } + accessToken := string(bytesAccessToken) + + userSession, err := s.store.GetSession(gc, accessToken) + if err != nil || userSession == nil || userSession.IsExpired() { + writeError(gc, newUnauthorizedRequest(gc, "You are not logged in or your session has expired")) + return + } + + // Set user ID in context + userAccount := AuthAccount{Username: userSession.UserInfo.Sub} + gc.Set(gin.AuthUserKey, userAccount) + + gc.Next() + } +} diff --git a/pkg/store/store.go b/pkg/store/store.go new file mode 100644 index 0000000..fba8c2e --- /dev/null +++ b/pkg/store/store.go @@ -0,0 +1,91 @@ +package store + +import ( + "errors" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +type Store interface { + CreateSession(gc *gin.Context, userInfo *UserInfo, accessToken string) error + GetSession(gc *gin.Context, accessToken string) (*UserSession, error) + DeleteSession(gc *gin.Context, accessToken string) error +} + +// A class to store user sessions +type store struct { + sessionDuration time.Duration + userSessions map[string]UserSession +} + +// Instanciate a store +// +// sessionMaxSeconds is the maximum length of a user session, in seconds +func NewStore(sessionMaxSeconds int64) Store { + sessionDuration := time.Duration(sessionMaxSeconds) * time.Second + return &store{ + sessionDuration: sessionDuration, + userSessions: make(map[string]UserSession, 0), + } +} + +// Create and store a new user session +func (s *store) CreateSession(gc *gin.Context, userInfo *UserInfo, accessToken string) error { + if gc == nil { + return errors.New("empty Gin context") + } + if userInfo == nil { + return errors.New("empty user info") + } + if accessToken == "" { + return errors.New("empty access token") + } + + // Save user ID in store + session := sessions.Default(gc) + session.Set(gin.AuthUserKey, userInfo.Sub) + err := session.Save() + if err != nil { + return err + } + + // Store session in map + userSession := NewUserSession(*userInfo, s.sessionDuration) + s.userSessions[accessToken] = *userSession + return nil +} + +// Get the session associated to the specified token +// +// Return nil if the token is not present in the store +func (s *store) GetSession(gc *gin.Context, accessToken string) (*UserSession, error) { + if gc == nil { + return nil, errors.New("empty Gin context") + } + if accessToken == "" { + return nil, errors.New("empty access token") + } + + // Get session for specified token + userSession, present := s.userSessions[accessToken] + if !present { + return nil, nil + } + + return &userSession, nil +} + +// Delete the session associated to the specified token +func (s *store) DeleteSession(gc *gin.Context, accessToken string) error { + if gc == nil { + return errors.New("empty Gin context") + } + if accessToken == "" { + return errors.New("empty access token") + } + + delete(s.userSessions, accessToken) + return nil +} diff --git a/pkg/store/user_info.go b/pkg/store/user_info.go new file mode 100644 index 0000000..768767a --- /dev/null +++ b/pkg/store/user_info.go @@ -0,0 +1,9 @@ +package store + +// UserInfo is the response structure of a GetUserInfo operation +type UserInfo struct { + Sub string `json:"sub"` + FirstName string `json:"firstname"` + Surname string `json:"surname"` + Email string `json:"email"` +} diff --git a/pkg/store/user_sesssion.go b/pkg/store/user_sesssion.go new file mode 100644 index 0000000..a6fc7e1 --- /dev/null +++ b/pkg/store/user_sesssion.go @@ -0,0 +1,26 @@ +package store + +import ( + "time" +) + +type UserSession struct { + UserInfo UserInfo `json:"user_info"` + StartedAt time.Time `json:"started_at"` + ExpireAt time.Time `json:"expire_at"` +} + +func NewUserSession(userInfo UserInfo, validity time.Duration) *UserSession { + startTime := time.Now() + userSession := UserSession{ + UserInfo: userInfo, + StartedAt: startTime, + ExpireAt: startTime.Add(validity), + } + return &userSession +} + +func (us *UserSession) IsExpired() bool { + now := time.Now() + return now.After(us.ExpireAt) +} diff --git a/pkg/util/util.go b/pkg/util/util.go new file mode 100644 index 0000000..81ed20d --- /dev/null +++ b/pkg/util/util.go @@ -0,0 +1,27 @@ +package util + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "log" +) + +// SecureRandomBytes returns the requested number of bytes using crypto/rand +func SecureRandomBytes(length int) []byte { + var randomBytes = make([]byte, length) + _, err := rand.Read(randomBytes) + if err != nil { + log.Fatal("Unable to generate random bytes") + } + return randomBytes +} + +// SecureRandomSecret returns Base64-encoded random string of the specified leangth +func SecureRandomSecret(length int) string { + randomBytes := SecureRandomBytes(length) + h := sha256.New() + h.Write(randomBytes) + randomString := base64.StdEncoding.EncodeToString(h.Sum(nil)) + return randomString +} diff --git a/templates/.waas.template b/templates/.waas.template new file mode 100644 index 0000000..129052f --- /dev/null +++ b/templates/.waas.template @@ -0,0 +1,8 @@ +# waas CLI configuration file + +# The URL of the HPCWaaS server API +#api_url: "https://www.your-server.com/waas" + +# The access token used for authentication with Unity +# To get or renew the token, open in a browser the /auth/login endpoint of your HPCWaaS server +#access_token: "U1Y3TzBTbjQ0TkJlXzk2WmQzUFRyRzZ0bXZQX093a3FLb3Nha3haSFlFaw==" \ No newline at end of file diff --git a/templates/README.md b/templates/README.md new file mode 100644 index 0000000..a3958cc --- /dev/null +++ b/templates/README.md @@ -0,0 +1,6 @@ +# Configuration template files + +This folder contains two template config files, both in YAML format: + +- `hpcwaas-api.yml` is the config file for the HPCWaaS server. It is mandatory in order to allow SSO authentication. It is managed by the sysadmin and the server needs to be restarted after each modification. Its default location is `/etc/hpcwaas-api/hpcwaas-api.yml`. +- `.waas` is an optional config file for the `waas` command-line utility. It is managed by the users. Its default locatizon is `$HOME/.waas`. \ No newline at end of file diff --git a/templates/hpcwaas-api.yml.template b/templates/hpcwaas-api.yml.template new file mode 100644 index 0000000..baa42b4 --- /dev/null +++ b/templates/hpcwaas-api.yml.template @@ -0,0 +1,22 @@ +auth: + # Supported authentication types: basic, sso + auth_type: sso + # The endpoint to authenticate users + auth_url: /oauth2-as/oauth2-authz + # The endpoint to retrieve tokens + token_url: /oauth2/token + # The endpoint to get user info + user_info_url: /oauth2/userinfo + # The URL where the server should redirect the token + redirect_url: /auth/authorize + # The authentication scopes + scopes: + - profile + - email + - eflows + # Maximum length of a user session, in seconds + session_duration: 86400 + # The client ID attributed by the SSO provider + client_id: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX + # The client secret attributed by the SSO provider + client_secret: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX \ No newline at end of file