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 @@ -
- Provider -
- -
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 5f3907272..9bb8fb74b 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -25,7 +25,7 @@ function initAnalytics(Analytics, $rootScope) { }); } -angular.module('portainer.app', []).config([ +angular.module('portainer.app', ['portainer.oauth']).config([ '$stateRegistryProvider', function ($stateRegistryProvider) { 'use strict'; diff --git a/app/portainer/oauth/__module.js b/app/portainer/oauth/__module.js new file mode 100644 index 000000000..f6b119235 --- /dev/null +++ b/app/portainer/oauth/__module.js @@ -0,0 +1 @@ +angular.module('portainer.oauth', ['ngResource']).constant('API_ENDPOINT_OAUTH', 'api/auth/oauth'); diff --git a/app/extensions/oauth/components/oauth-providers-selector/oauth-provider-selector-controller.js b/app/portainer/oauth/components/oauth-providers-selector/oauth-provider-selector-controller.js similarity index 77% rename from app/extensions/oauth/components/oauth-providers-selector/oauth-provider-selector-controller.js rename to app/portainer/oauth/components/oauth-providers-selector/oauth-provider-selector-controller.js index 3adc8d39a..6d7afc099 100644 --- a/app/extensions/oauth/components/oauth-providers-selector/oauth-provider-selector-controller.js +++ b/app/portainer/oauth/components/oauth-providers-selector/oauth-provider-selector-controller.js @@ -1,4 +1,4 @@ -angular.module('portainer.extensions.oauth').controller('OAuthProviderSelectorController', function OAuthProviderSelectorController() { +angular.module('portainer.oauth').controller('OAuthProviderSelectorController', function OAuthProviderSelectorController() { var ctrl = this; this.providers = [ @@ -9,6 +9,9 @@ angular.module('portainer.extensions.oauth').controller('OAuthProviderSelectorCo userIdentifier: 'userPrincipalName', scopes: 'id,email,name', name: 'microsoft', + label: 'Microsoft', + description: 'Microsoft OAuth provider', + icon: 'fab fa-microsoft', }, { authUrl: 'https://accounts.google.com/o/oauth2/auth', @@ -17,6 +20,9 @@ angular.module('portainer.extensions.oauth').controller('OAuthProviderSelectorCo userIdentifier: 'email', scopes: 'profile email', name: 'google', + label: 'Google', + description: 'Google OAuth provider', + icon: 'fab fa-google', }, { authUrl: 'https://github.com/login/oauth/authorize', @@ -25,6 +31,9 @@ angular.module('portainer.extensions.oauth').controller('OAuthProviderSelectorCo userIdentifier: 'login', scopes: 'id email name', name: 'github', + label: 'Github', + description: 'Github OAuth provider', + icon: 'fab fa-github', }, { authUrl: '', @@ -33,6 +42,9 @@ angular.module('portainer.extensions.oauth').controller('OAuthProviderSelectorCo userIdentifier: '', scopes: '', name: 'custom', + label: 'Custom', + description: 'Custom OAuth provider', + icon: 'fa fa-user-check', }, ]; diff --git a/app/portainer/oauth/components/oauth-providers-selector/oauth-providers-selector.html b/app/portainer/oauth/components/oauth-providers-selector/oauth-providers-selector.html new file mode 100644 index 000000000..17dffd726 --- /dev/null +++ b/app/portainer/oauth/components/oauth-providers-selector/oauth-providers-selector.html @@ -0,0 +1,19 @@ +
+ Provider +
+ +
+
+
+
+ + +
+
+
diff --git a/app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.js b/app/portainer/oauth/components/oauth-providers-selector/oauth-providers-selector.js similarity index 65% rename from app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.js rename to app/portainer/oauth/components/oauth-providers-selector/oauth-providers-selector.js index 2ce2941e2..d0a1dca08 100644 --- a/app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.js +++ b/app/portainer/oauth/components/oauth-providers-selector/oauth-providers-selector.js @@ -1,4 +1,4 @@ -angular.module('portainer.extensions.oauth').component('oauthProvidersSelector', { +angular.module('portainer.oauth').component('oauthProvidersSelector', { templateUrl: './oauth-providers-selector.html', bindings: { onSelect: '<', diff --git a/app/extensions/oauth/components/oauth-settings/oauth-settings-controller.js b/app/portainer/oauth/components/oauth-settings/oauth-settings-controller.js similarity index 96% rename from app/extensions/oauth/components/oauth-settings/oauth-settings-controller.js rename to app/portainer/oauth/components/oauth-settings/oauth-settings-controller.js index 085715982..ba1424956 100644 --- a/app/extensions/oauth/components/oauth-settings/oauth-settings-controller.js +++ b/app/portainer/oauth/components/oauth-settings/oauth-settings-controller.js @@ -1,6 +1,6 @@ import _ from 'lodash-es'; -angular.module('portainer.extensions.oauth').controller('OAuthSettingsController', function OAuthSettingsController() { +angular.module('portainer.oauth').controller('OAuthSettingsController', function OAuthSettingsController() { var ctrl = this; this.state = { diff --git a/app/extensions/oauth/components/oauth-settings/oauth-settings.html b/app/portainer/oauth/components/oauth-settings/oauth-settings.html similarity index 100% rename from app/extensions/oauth/components/oauth-settings/oauth-settings.html rename to app/portainer/oauth/components/oauth-settings/oauth-settings.html diff --git a/app/extensions/oauth/components/oauth-settings/oauth-settings.js b/app/portainer/oauth/components/oauth-settings/oauth-settings.js similarity index 65% rename from app/extensions/oauth/components/oauth-settings/oauth-settings.js rename to app/portainer/oauth/components/oauth-settings/oauth-settings.js index 818fc69c9..ffe6b1739 100644 --- a/app/extensions/oauth/components/oauth-settings/oauth-settings.js +++ b/app/portainer/oauth/components/oauth-settings/oauth-settings.js @@ -1,4 +1,4 @@ -angular.module('portainer.extensions.oauth').component('oauthSettings', { +angular.module('portainer.oauth').component('oauthSettings', { templateUrl: './oauth-settings.html', bindings: { settings: '=', diff --git a/app/extensions/oauth/services/rest/oauth.js b/app/portainer/oauth/services/rest/oauth.js similarity index 85% rename from app/extensions/oauth/services/rest/oauth.js rename to app/portainer/oauth/services/rest/oauth.js index d3db66b2f..b221f7d23 100644 --- a/app/extensions/oauth/services/rest/oauth.js +++ b/app/portainer/oauth/services/rest/oauth.js @@ -1,4 +1,4 @@ -angular.module('portainer.extensions.oauth').factory('OAuth', [ +angular.module('portainer.oauth').factory('OAuth', [ '$resource', 'API_ENDPOINT_OAUTH', function OAuthFactory($resource, API_ENDPOINT_OAUTH) { diff --git a/app/portainer/views/settings/authentication/settingsAuthentication.html b/app/portainer/views/settings/authentication/settingsAuthentication.html index be58b78da..0ecab33e1 100644 --- a/app/portainer/views/settings/authentication/settingsAuthentication.html +++ b/app/portainer/views/settings/authentication/settingsAuthentication.html @@ -57,7 +57,7 @@

LDAP authentication

-
+
diff --git a/app/portainer/views/settings/authentication/settingsAuthenticationController.js b/app/portainer/views/settings/authentication/settingsAuthenticationController.js index 97eb2a5dc..f03b88f46 100644 --- a/app/portainer/views/settings/authentication/settingsAuthenticationController.js +++ b/app/portainer/views/settings/authentication/settingsAuthenticationController.js @@ -6,8 +6,7 @@ angular.module('portainer.app').controller('SettingsAuthenticationController', [ 'SettingsService', 'FileUploadService', 'TeamService', - 'ExtensionService', - function ($q, $scope, $state, Notifications, SettingsService, FileUploadService, TeamService, ExtensionService) { + function ($q, $scope, $state, Notifications, SettingsService, FileUploadService, TeamService) { $scope.state = { successfulConnectivityCheck: false, failedConnectivityCheck: false, @@ -68,10 +67,6 @@ angular.module('portainer.app').controller('SettingsAuthenticationController', [ }, }; - $scope.goToOAuthExtensionView = function () { - $state.go('portainer.extensions.extension', { id: 2 }); - }; - $scope.isOauthEnabled = function isOauthEnabled() { return $scope.settings && $scope.settings.AuthenticationMethod === 3; }; @@ -167,7 +162,6 @@ angular.module('portainer.app').controller('SettingsAuthenticationController', [ $q.all({ settings: SettingsService.settings(), teams: TeamService.teams(), - oauthAuthentication: ExtensionService.extensionEnabled(ExtensionService.EXTENSIONS.OAUTH_AUTHENTICATION), }) .then(function success(data) { var settings = data.settings; @@ -176,7 +170,6 @@ angular.module('portainer.app').controller('SettingsAuthenticationController', [ $scope.formValues.LDAPSettings = settings.LDAPSettings; $scope.OAuthSettings = settings.OAuthSettings; $scope.formValues.TLSCACert = settings.LDAPSettings.TLSConfig.TLSCACert; - $scope.oauthAuthenticationAvailable = data.oauthAuthentication; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve application settings');