diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 830a8aede..e3b2d0da9 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -6,7 +6,7 @@ import ( "strings" "time" - portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/bolt" "github.com/portainer/portainer/api/chisel" "github.com/portainer/portainer/api/cli" @@ -24,6 +24,7 @@ import ( kubecli "github.com/portainer/portainer/api/kubernetes/cli" "github.com/portainer/portainer/api/ldap" "github.com/portainer/portainer/api/libcompose" + "github.com/portainer/portainer/api/oauth" ) func initCLI() *portainer.CLIFlags { @@ -108,6 +109,10 @@ func initLDAPService() portainer.LDAPService { return &ldap.Service{} } +func initOAuthService() portainer.OAuthService { + return oauth.NewService() +} + func initGitService() portainer.GitService { return git.NewService() } @@ -354,6 +359,8 @@ func main() { ldapService := initLDAPService() + oauthService := initOAuthService() + gitService := initGitService() cryptoService := initCryptoService() @@ -467,6 +474,7 @@ func main() { JWTService: jwtService, FileService: fileService, LDAPService: ldapService, + OAuthService: oauthService, GitService: gitService, SignatureService: digitalSignatureService, SnapshotService: snapshotService, diff --git a/api/go.mod b/api/go.mod index 650af2de9..fcee3b6a8 100644 --- a/api/go.mod +++ b/api/go.mod @@ -30,6 +30,7 @@ require ( github.com/portainer/libhttp v0.0.0-20190806161843-ba068f58be33 golang.org/x/crypto v0.0.0-20191128160524-b544559bb6d1 golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933 // indirect + golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 gopkg.in/alecthomas/kingpin.v2 v2.2.6 gopkg.in/src-d/go-git.v4 v4.13.1 k8s.io/api v0.17.2 diff --git a/api/http/handler/auth/authenticate_oauth.go b/api/http/handler/auth/authenticate_oauth.go index f0d6d59a2..c0e3e8bda 100644 --- a/api/http/handler/auth/authenticate_oauth.go +++ b/api/http/handler/auth/authenticate_oauth.go @@ -1,9 +1,7 @@ package auth import ( - "encoding/json" "errors" - "io/ioutil" "log" "net/http" @@ -27,52 +25,22 @@ func (payload *oauthPayload) Validate(r *http.Request) error { return nil } -func (handler *Handler) authenticateThroughExtension(code, licenseKey string, settings *portainer.OAuthSettings) (string, error) { - extensionURL := handler.ProxyManager.GetExtensionURL(portainer.OAuthAuthenticationExtension) +func (handler *Handler) authenticateOAuth(code string, settings *portainer.OAuthSettings) (string, error) { + if code == "" { + return "", errors.New("Invalid OAuth authorization code") + } - encodedConfiguration, err := json.Marshal(settings) + if settings == nil { + return "", errors.New("Invalid OAuth configuration") + } + + username, err := handler.OAuthService.Authenticate(code, settings) if err != nil { + log.Printf("[DEBUG] - Unable to authenticate user via OAuth: %v", err) return "", nil } - req, err := http.NewRequest("GET", extensionURL+"/validate", nil) - if err != nil { - return "", err - } - - client := &http.Client{} - req.Header.Set("X-OAuth-Config", string(encodedConfiguration)) - req.Header.Set("X-OAuth-Code", code) - req.Header.Set("X-PortainerExtension-License", licenseKey) - - resp, err := client.Do(req) - if err != nil { - return "", err - } - - defer resp.Body.Close() - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return "", err - } - - type extensionResponse struct { - Username string `json:"Username,omitempty"` - Err string `json:"err,omitempty"` - Details string `json:"details,omitempty"` - } - - var extResp extensionResponse - err = json.Unmarshal(body, &extResp) - if err != nil { - return "", err - } - - if resp.StatusCode != http.StatusOK { - return "", errors.New(extResp.Err + ":" + extResp.Details) - } - - return extResp.Username, nil + return username, nil } func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { @@ -91,14 +59,7 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h return &httperror.HandlerError{http.StatusForbidden, "OAuth authentication is not enabled", errors.New("OAuth authentication is not enabled")} } - extension, err := handler.DataStore.Extension().Extension(portainer.OAuthAuthenticationExtension) - if err == bolterrors.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusNotFound, "Oauth authentication extension is not enabled", err} - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err} - } - - username, err := handler.authenticateThroughExtension(payload.Code, extension.License.LicenseKey, &settings.OAuthSettings) + username, err := handler.authenticateOAuth(payload.Code, &settings.OAuthSettings) if err != nil { log.Printf("[DEBUG] - OAuth authentication error: %s", err) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate through OAuth", httperrors.ErrUnauthorized} diff --git a/api/http/handler/auth/handler.go b/api/http/handler/auth/handler.go index 9bc98f834..120e5980e 100644 --- a/api/http/handler/auth/handler.go +++ b/api/http/handler/auth/handler.go @@ -19,6 +19,7 @@ type Handler struct { CryptoService portainer.CryptoService JWTService portainer.JWTService LDAPService portainer.LDAPService + OAuthService portainer.OAuthService ProxyManager *proxy.Manager AuthorizationService *authorization.Service KubernetesTokenCacheManager *kubernetes.TokenCacheManager diff --git a/api/http/server.go b/api/http/server.go index fb62c842e..f75aaf86e 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -61,6 +61,7 @@ type Server struct { GitService portainer.GitService JWTService portainer.JWTService LDAPService portainer.LDAPService + OAuthService portainer.OAuthService SwarmStackManager portainer.SwarmStackManager Handler *handler.Handler SSL bool @@ -90,6 +91,7 @@ func (server *Server) Start() error { authHandler.ProxyManager = proxyManager authHandler.AuthorizationService = authorizationService authHandler.KubernetesTokenCacheManager = kubernetesTokenCacheManager + authHandler.OAuthService = server.OAuthService var roleHandler = roles.NewHandler(requestBouncer) roleHandler.DataStore = server.DataStore diff --git a/api/oauth/oauth.go b/api/oauth/oauth.go new file mode 100644 index 000000000..7b6866c90 --- /dev/null +++ b/api/oauth/oauth.go @@ -0,0 +1,130 @@ +package oauth + +import ( + "context" + "encoding/json" + "fmt" + "golang.org/x/oauth2" + "io/ioutil" + "log" + "mime" + "net/http" + "net/url" + + "github.com/portainer/portainer/api" +) + +// Service represents a service used to authenticate users against an authorization server +type Service struct{} + +// NewService returns a pointer to a new instance of this service +func NewService() *Service { + return &Service{} +} + +// Authenticate takes an access code and exchanges it for an access token from portainer OAuthSettings token endpoint. +// On success, it will then return the username associated to authenticated user by fetching this information +// from the resource server and matching it with the user identifier setting. +func (*Service) Authenticate(code string, configuration *portainer.OAuthSettings) (string, error) { + token, err := getAccessToken(code, configuration) + if err != nil { + log.Printf("[DEBUG] - Failed retrieving access token: %v", err) + return "", err + } + + return getUsername(token, configuration) +} + +func getAccessToken(code string, configuration *portainer.OAuthSettings) (string, error) { + unescapedCode, err := url.QueryUnescape(code) + if err != nil { + return "", err + } + + config := buildConfig(configuration) + token, err := config.Exchange(context.Background(), unescapedCode) + if err != nil { + return "", err + } + + return token.AccessToken, nil +} + +func getUsername(token string, configuration *portainer.OAuthSettings) (string, error) { + req, err := http.NewRequest("GET", configuration.ResourceURI, nil) + if err != nil { + return "", err + } + + client := &http.Client{} + req.Header.Set("Authorization", "Bearer "+token) + resp, err := client.Do(req) + if err != nil { + return "", err + } + + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode != http.StatusOK { + return "", &oauth2.RetrieveError{ + Response: resp, + Body: body, + } + } + + content, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return "", err + } + + if content == "application/x-www-form-urlencoded" || content == "text/plain" { + values, err := url.ParseQuery(string(body)) + if err != nil { + return "", err + } + + username := values.Get(configuration.UserIdentifier) + return username, nil + } + + var datamap map[string]interface{} + if err = json.Unmarshal(body, &datamap); err != nil { + return "", err + } + + username, ok := datamap[configuration.UserIdentifier].(string) + if ok && username != "" { + return username, nil + } + + if !ok { + username, ok := datamap[configuration.UserIdentifier].(float64) + if ok && username != 0 { + return fmt.Sprint(int(username)), nil + } + } + + return "", &oauth2.RetrieveError{ + Response: resp, + Body: body, + } +} + +func buildConfig(configuration *portainer.OAuthSettings) *oauth2.Config { + endpoint := oauth2.Endpoint{ + AuthURL: configuration.AuthorizationURI, + TokenURL: configuration.AccessTokenURI, + } + + return &oauth2.Config{ + ClientID: configuration.ClientID, + ClientSecret: configuration.ClientSecret, + Endpoint: endpoint, + RedirectURL: configuration.RedirectURI, + Scopes: []string{configuration.Scopes}, + } +} diff --git a/api/portainer.go b/api/portainer.go index 4cc3947fb..4226bb778 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -984,6 +984,11 @@ type ( GetUserGroups(username string, settings *LDAPSettings) ([]string, error) } + // OAuthService represents a service used to authenticate users using OAuth + OAuthService interface { + Authenticate(code string, configuration *OAuthSettings) (string, error) + } + // RegistryService represents a service for managing registry data RegistryService interface { Registry(ID RegistryID) (*Registry, error) diff --git a/app/extensions/_module.js b/app/extensions/_module.js index ebf2143cd..47dfa09a3 100644 --- a/app/extensions/_module.js +++ b/app/extensions/_module.js @@ -1 +1 @@ -angular.module('portainer.extensions', ['portainer.extensions.registrymanagement', 'portainer.extensions.oauth', 'portainer.extensions.rbac']); +angular.module('portainer.extensions', ['portainer.extensions.registrymanagement', 'portainer.extensions.rbac']); diff --git a/app/extensions/oauth/__module.js b/app/extensions/oauth/__module.js deleted file mode 100644 index d9e386521..000000000 --- a/app/extensions/oauth/__module.js +++ /dev/null @@ -1 +0,0 @@ -angular.module('portainer.extensions.oauth', ['ngResource']).constant('API_ENDPOINT_OAUTH', 'api/auth/oauth'); diff --git a/app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.html b/app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.html deleted file mode 100644 index a01ec3d41..000000000 --- a/app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.html +++ /dev/null @@ -1,49 +0,0 @@ -
LDAP authentication
-