diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 76ef19ae4..f759285da 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -20,7 +20,6 @@ import ( "github.com/portainer/portainer/jwt" "github.com/portainer/portainer/ldap" "github.com/portainer/portainer/libcompose" - "github.com/portainer/portainer/oauth" "log" ) @@ -101,10 +100,6 @@ func initLDAPService() portainer.LDAPService { return &ldap.Service{} } -func initOAuthService() portainer.OAuthService { - return &oauth.Service{} -} - func initGitService() portainer.GitService { return &git.Service{} } @@ -529,8 +524,6 @@ func main() { ldapService := initLDAPService() - oauthService := initOAuthService() - gitService := initGitService() cryptoService := initCryptoService() @@ -676,7 +669,6 @@ func main() { JWTService: jwtService, FileService: fileService, LDAPService: ldapService, - OAuthService: oauthService, GitService: gitService, SignatureService: digitalSignatureService, JobScheduler: jobScheduler, diff --git a/api/exec/extension.go b/api/exec/extension.go index cb58ecad6..ab7880a1b 100644 --- a/api/exec/extension.go +++ b/api/exec/extension.go @@ -18,7 +18,8 @@ import ( var extensionDownloadBaseURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com/extensions/" var extensionBinaryMap = map[portainer.ExtensionID]string{ - portainer.RegistryManagementExtension: "extension-registry-management", + portainer.RegistryManagementExtension: "extension-registry-management", + portainer.OAuthAuthenticationExtension: "extension-oauth-authentication", } // ExtensionManager represents a service used to diff --git a/api/http/handler/auth/authenticate_oauth.go b/api/http/handler/auth/authenticate_oauth.go index 03031d6f0..5eafe0d1b 100644 --- a/api/http/handler/auth/authenticate_oauth.go +++ b/api/http/handler/auth/authenticate_oauth.go @@ -1,7 +1,8 @@ package auth import ( - "log" + "encoding/json" + "io/ioutil" "net/http" "github.com/asaskevich/govalidator" @@ -21,6 +22,54 @@ 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) + + encodedConfiguration, err := json.Marshal(settings) + if err != nil { + 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 "", portainer.Error(extResp.Err + ":" + extResp.Details) + } + + return extResp.Username, nil +} + func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { var payload oauthPayload err := request.DecodeAndValidateJSONPayload(r, &payload) @@ -37,16 +86,16 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h return &httperror.HandlerError{http.StatusForbidden, "OAuth authentication is not enabled", err} } - token, err := handler.OAuthService.GetAccessToken(payload.Code, &settings.OAuthSettings) - if err != nil { - log.Printf("[DEBUG] - Failed retrieving access token: %v", err) - return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid access token", portainer.ErrUnauthorized} + extension, err := handler.ExtensionService.Extension(portainer.OAuthAuthenticationExtension) + if err == portainer.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.OAuthService.GetUsername(token, &settings.OAuthSettings) + username, err := handler.authenticateThroughExtension(payload.Code, extension.License.LicenseKey, &settings.OAuthSettings) if err != nil { - log.Printf("[DEBUG] - Failed acquiring username: %v", err) - return &httperror.HandlerError{http.StatusForbidden, "Unable to acquire username", portainer.ErrUnauthorized} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate through OAuth", portainer.ErrUnauthorized} } user, err := handler.UserService.UserByUsername(username) @@ -85,18 +134,3 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h return handler.writeToken(w, user) } - -func (handler *Handler) loginOAuth(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - settings, err := handler.SettingsService.Settings() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} - } - - if settings.AuthenticationMethod != 3 { - return &httperror.HandlerError{http.StatusForbidden, "OAuth authentication is disabled", err} - } - - url := handler.OAuthService.BuildLoginURL(&settings.OAuthSettings) - http.Redirect(w, r, url, http.StatusTemporaryRedirect) - return nil -} diff --git a/api/http/handler/auth/handler.go b/api/http/handler/auth/handler.go index 50e956e76..75e343f23 100644 --- a/api/http/handler/auth/handler.go +++ b/api/http/handler/auth/handler.go @@ -6,6 +6,7 @@ import ( "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" "github.com/portainer/portainer" + "github.com/portainer/portainer/http/proxy" "github.com/portainer/portainer/http/security" ) @@ -25,10 +26,11 @@ type Handler struct { CryptoService portainer.CryptoService JWTService portainer.JWTService LDAPService portainer.LDAPService - OAuthService portainer.OAuthService SettingsService portainer.SettingsService TeamService portainer.TeamService TeamMembershipService portainer.TeamMembershipService + ExtensionService portainer.ExtensionService + ProxyManager *proxy.Manager } // NewHandler creates a handler to manage authentication operations. @@ -38,8 +40,6 @@ func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimi authDisabled: authDisabled, } - h.Handle("/auth/oauth/login", - rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.loginOAuth)))).Methods(http.MethodGet) h.Handle("/auth/oauth/validate", rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.validateOAuth)))).Methods(http.MethodPost) h.Handle("/auth", diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go index ee277d676..a9033c701 100644 --- a/api/http/handler/settings/handler.go +++ b/api/http/handler/settings/handler.go @@ -19,7 +19,6 @@ type Handler struct { *mux.Router SettingsService portainer.SettingsService LDAPService portainer.LDAPService - OAuthService portainer.OAuthService FileService portainer.FileService JobScheduler portainer.JobScheduler ScheduleService portainer.ScheduleService diff --git a/api/http/handler/settings/settings_public.go b/api/http/handler/settings/settings_public.go index 1cb59f8c6..e5fabd6d8 100644 --- a/api/http/handler/settings/settings_public.go +++ b/api/http/handler/settings/settings_public.go @@ -33,6 +33,7 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) * AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers, EnableHostManagementFeatures: settings.EnableHostManagementFeatures, ExternalTemplates: false, + // TODO: check if state=portainer useful or not OAuthLoginURI: fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s&state=portainer", settings.OAuthSettings.AuthorizationURI, settings.OAuthSettings.ClientID, diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index b1e2aa6f4..f7cbbd5dd 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -12,7 +12,8 @@ import ( // TODO: contain code related to legacy extension management var extensionPorts = map[portainer.ExtensionID]string{ - portainer.RegistryManagementExtension: "7001", + portainer.RegistryManagementExtension: "7001", + portainer.OAuthAuthenticationExtension: "7002", } type ( @@ -103,6 +104,10 @@ func (manager *Manager) CreateExtensionProxy(extensionID portainer.ExtensionID) return proxy, nil } +func (manager *Manager) GetExtensionURL(extensionID portainer.ExtensionID) string { + return "http://localhost:" + extensionPorts[extensionID] +} + // DeleteExtensionProxy deletes the extension proxy associated to an extension identifier func (manager *Manager) DeleteExtensionProxy(extensionID portainer.ExtensionID) { manager.extensionProxies.Remove(strconv.Itoa(int(extensionID))) diff --git a/api/http/server.go b/api/http/server.go index cf3c9ef2b..7f3368f25 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -55,7 +55,6 @@ type Server struct { GitService portainer.GitService JWTService portainer.JWTService LDAPService portainer.LDAPService - OAuthService portainer.OAuthService ExtensionService portainer.ExtensionService RegistryService portainer.RegistryService ResourceControlService portainer.ResourceControlService @@ -105,10 +104,11 @@ func (server *Server) Start() error { authHandler.CryptoService = server.CryptoService authHandler.JWTService = server.JWTService authHandler.LDAPService = server.LDAPService - authHandler.OAuthService = server.OAuthService authHandler.SettingsService = server.SettingsService authHandler.TeamService = server.TeamService authHandler.TeamMembershipService = server.TeamMembershipService + authHandler.ExtensionService = server.ExtensionService + authHandler.ProxyManager = proxyManager var dockerHubHandler = dockerhub.NewHandler(requestBouncer) dockerHubHandler.DockerHubService = server.DockerHubService @@ -157,7 +157,6 @@ func (server *Server) Start() error { var settingsHandler = settings.NewHandler(requestBouncer) settingsHandler.SettingsService = server.SettingsService settingsHandler.LDAPService = server.LDAPService - settingsHandler.OAuthService = server.OAuthService settingsHandler.FileService = server.FileService settingsHandler.JobScheduler = server.JobScheduler settingsHandler.ScheduleService = server.ScheduleService diff --git a/api/oauth/oauth.go b/api/oauth/oauth.go deleted file mode 100644 index b7de6792f..000000000 --- a/api/oauth/oauth.go +++ /dev/null @@ -1,123 +0,0 @@ -package oauth - -import ( - "context" - "encoding/json" - "fmt" - "io/ioutil" - "mime" - "net/http" - "net/url" - - "github.com/portainer/portainer" - "golang.org/x/oauth2" -) - -const ( - // ErrInvalidCode defines an error raised when the user authorization code is invalid - ErrInvalidCode = portainer.Error("Invalid OAuth authorization code") -) - -// Service represents a service used to authenticate users against an authorization server -type Service struct{} - -// GetAccessToken takes an access code and exchanges it for an access token from portainer OAuthSettings token endpoint -func (*Service) GetAccessToken(code string, settings *portainer.OAuthSettings) (string, error) { - unescapedCode, err := url.QueryUnescape(code) - if err != nil { - return "", err - } - - config := buildConfig(settings) - token, err := config.Exchange(context.Background(), unescapedCode) - if err != nil { - return "", err - } - - return token.AccessToken, nil -} - -// GetUsername takes a token and retrieves the portainer OAuthSettings user identifier from resource server. -func (*Service) GetUsername(token string, settings *portainer.OAuthSettings) (string, error) { - req, err := http.NewRequest("GET", settings.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(settings.UserIdentifier) - return username, nil - } - - var datamap map[string]interface{} - if err = json.Unmarshal(body, &datamap); err != nil { - return "", err - } - - username, ok := datamap[settings.UserIdentifier].(string) - if ok && username != "" { - return username, nil - } - - if !ok { - username, ok := datamap[settings.UserIdentifier].(float64) - if ok && username != 0 { - return fmt.Sprint(int(username)), nil - } - } - return "", &oauth2.RetrieveError{ - Response: resp, - Body: body, - } -} - -// BuildLoginURL creates a login url for the oauth provider -func (*Service) BuildLoginURL(oauthSettings *portainer.OAuthSettings) string { - oauthConfig := buildConfig(oauthSettings) - return oauthConfig.AuthCodeURL("portainer") -} - -func buildConfig(oauthSettings *portainer.OAuthSettings) *oauth2.Config { - endpoint := oauth2.Endpoint{ - AuthURL: oauthSettings.AuthorizationURI, - TokenURL: oauthSettings.AccessTokenURI, - } - - return &oauth2.Config{ - ClientID: oauthSettings.ClientID, - ClientSecret: oauthSettings.ClientSecret, - Endpoint: endpoint, - RedirectURL: oauthSettings.RedirectURI, - Scopes: []string{oauthSettings.Scopes}, - } -} diff --git a/api/portainer.go b/api/portainer.go index de2511b37..1123e3b16 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -67,7 +67,7 @@ type ( UserIdentifier string `json:"UserIdentifier"` Scopes string `json:"Scopes"` OAuthAutoCreateUsers bool `json:"OAuthAutoCreateUsers"` - DefaultTeamID TeamID `json:"DefaultTeamID"` + DefaultTeamID TeamID `json:"DefaultTeamID"` } // TLSConfiguration represents a TLS configuration @@ -764,13 +764,6 @@ type ( GetUserGroups(username string, settings *LDAPSettings) ([]string, error) } - // OAuthService represents a service used to authenticate users against an authorization server - OAuthService interface { - GetAccessToken(code string, settings *OAuthSettings) (string, error) - GetUsername(token string, settings *OAuthSettings) (string, error) - BuildLoginURL(oauthSettings *OAuthSettings) string - } - // SwarmStackManager represents a service to manage Swarm stacks SwarmStackManager interface { Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint) @@ -809,7 +802,8 @@ const ( // MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved MessageOfTheDayURL = AssetsServerURL + "/motd.html" // ExtensionDefinitionsURL represents the URL where Portainer extension definitions can be retrieved - ExtensionDefinitionsURL = AssetsServerURL + "/extensions.json" + // TODO: UPDATE URL to production URL + ExtensionDefinitionsURL = AssetsServerURL + "/extensions-dev.json" // PortainerAgentHeader represents the name of the header available in any agent response PortainerAgentHeader = "Portainer-Agent" // PortainerAgentTargetHeader represent the name of the header containing the target node name @@ -936,6 +930,8 @@ const ( _ ExtensionID = iota // RegistryManagementExtension represents the registry management extension RegistryManagementExtension + // OAuthAuthenticationExtension represents the OAuth authentication extension + OAuthAuthenticationExtension ) const ( diff --git a/app/portainer/services/api/extensionService.js b/app/portainer/services/api/extensionService.js index b7087f3fc..a715effea 100644 --- a/app/portainer/services/api/extensionService.js +++ b/app/portainer/services/api/extensionService.js @@ -62,5 +62,20 @@ angular.module('portainer.app') return deferred.promise; }; + service.OAuthAuthenticationEnabled = function() { + var deferred = $q.defer(); + + service.extensions(false) + .then(function onSuccess(extensions) { + var extensionAvailable = _.find(extensions, { Id: 2, Enabled: true }) ? true : false; + deferred.resolve(extensionAvailable); + }) + .catch(function onError(err) { + deferred.reject(err); + }); + + return deferred.promise; + }; + return service; }]); diff --git a/app/portainer/views/settings/authentication/settingsAuthentication.html b/app/portainer/views/settings/authentication/settingsAuthentication.html index 1458dc076..cddc54848 100644 --- a/app/portainer/views/settings/authentication/settingsAuthentication.html +++ b/app/portainer/views/settings/authentication/settingsAuthentication.html @@ -37,7 +37,7 @@

LDAP authentication

-
+
diff --git a/app/portainer/views/settings/authentication/settingsAuthenticationController.js b/app/portainer/views/settings/authentication/settingsAuthenticationController.js index 8a22d1783..f6f28b3be 100644 --- a/app/portainer/views/settings/authentication/settingsAuthenticationController.js +++ b/app/portainer/views/settings/authentication/settingsAuthenticationController.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.controller('SettingsAuthenticationController', ['$q', '$scope', 'Notifications', 'SettingsService', 'FileUploadService', 'TeamService', -function ($q, $scope, Notifications, SettingsService, FileUploadService, TeamService) { +.controller('SettingsAuthenticationController', ['$q', '$scope', '$state', 'Notifications', 'SettingsService', 'FileUploadService', 'TeamService', 'ExtensionService', +function($q, $scope, $state, Notifications, SettingsService, FileUploadService, TeamService, ExtensionService) { $scope.state = { successfulConnectivityCheck: false, @@ -14,6 +14,10 @@ function ($q, $scope, Notifications, SettingsService, FileUploadService, TeamSer TLSCACert: '' }; + $scope.goToOAuthExtensionView = function() { + $state.go('portainer.extensions.extension', { id: 2 }); + }; + $scope.isOauthEnabled = function isOauthEnabled() { return $scope.settings && $scope.settings.AuthenticationMethod === 3; }; @@ -25,7 +29,7 @@ function ($q, $scope, Notifications, SettingsService, FileUploadService, TeamSer $scope.removeSearchConfiguration = function(index) { $scope.LDAPSettings.SearchSettings.splice(index, 1); }; - + $scope.addGroupSearchConfiguration = function() { $scope.LDAPSettings.GroupSearchSettings.push({ GroupBaseDN: '', GroupAttribute: '', GroupFilter: '' }); }; @@ -98,14 +102,17 @@ function ($q, $scope, Notifications, SettingsService, FileUploadService, TeamSer function initView() { $q.all({ settings: SettingsService.settings(), - teams: TeamService.teams() - }).then(function success(data) { + teams: TeamService.teams(), + oauthAuthentication: ExtensionService.OAuthAuthenticationEnabled() + }) + .then(function success(data) { var settings = data.settings; $scope.teams = data.teams; $scope.settings = settings; $scope.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');