From 241a701eca7fe1516f04861823c889d91bd7523d Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Sun, 30 Dec 2018 18:02:22 +0200 Subject: [PATCH 01/61] feat(oauth): merge pr from https://github.com/portainer/portainer/pull/2515 --- api/cmd/portainer/main.go | 9 + api/http/handler/auth/authenticate.go | 63 +++++++ api/http/handler/auth/handler.go | 3 + api/http/handler/handler.go | 2 + api/http/handler/settings/handler.go | 2 + api/http/handler/settings/settings_public.go | 8 + api/http/handler/settings/settings_update.go | 7 +- api/http/server.go | 3 + api/oauth/oauth.go | 168 ++++++++++++++++++ api/portainer.go | 22 +++ app/constants.js | 1 + .../users-datatable/usersDatatable.html | 3 +- app/portainer/models/settings.js | 17 ++ app/portainer/rest/oauth.js | 9 + app/portainer/services/authentication.js | 21 ++- app/portainer/views/auth/auth.html | 4 +- app/portainer/views/auth/authController.js | 45 ++++- .../settingsAuthentication.html | 120 ++++++++++++- .../settingsAuthenticationController.js | 1 + 19 files changed, 501 insertions(+), 7 deletions(-) create mode 100644 api/oauth/oauth.go create mode 100644 app/portainer/rest/oauth.js diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index e0a9dd2f9..83f85aa8f 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -20,6 +20,7 @@ import ( "github.com/portainer/portainer/jwt" "github.com/portainer/portainer/ldap" "github.com/portainer/portainer/libcompose" + "github.com/portainer/portainer/oauth" "log" ) @@ -100,6 +101,10 @@ func initLDAPService() portainer.LDAPService { return &ldap.Service{} } +func initOAuthService() portainer.OAuthService { + return &oauth.Service{} +} + func initGitService() portainer.GitService { return &git.Service{} } @@ -260,6 +265,7 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL portainer.LDAPGroupSearchSettings{}, }, }, + OAuthSettings: portainer.OAuthSettings{}, AllowBindMountsForRegularUsers: true, AllowPrivilegedModeForRegularUsers: true, EnableHostManagementFeatures: false, @@ -520,6 +526,8 @@ func main() { ldapService := initLDAPService() + oauthService := initOAuthService() + gitService := initGitService() cryptoService := initCryptoService() @@ -665,6 +673,7 @@ func main() { JWTService: jwtService, FileService: fileService, LDAPService: ldapService, + OAuthService: oauthService, GitService: gitService, SignatureService: digitalSignatureService, JobScheduler: jobScheduler, diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go index d5562edd3..163023d0e 100644 --- a/api/http/handler/auth/authenticate.go +++ b/api/http/handler/auth/authenticate.go @@ -17,6 +17,10 @@ type authenticatePayload struct { Password string } +type oauthPayload struct { + Code string +} + type authenticateResponse struct { JWT string `json:"jwt"` } @@ -31,6 +35,13 @@ func (payload *authenticatePayload) Validate(r *http.Request) error { return nil } +func (payload *oauthPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Code) { + return portainer.Error("Invalid OAuth authorization code") + } + return nil +} + func (handler *Handler) authenticate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { if handler.authDisabled { return &httperror.HandlerError{http.StatusServiceUnavailable, "Cannot authenticate user. Portainer was started with the --no-auth flag", ErrAuthDisabled} @@ -82,6 +93,58 @@ func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer. return handler.writeToken(w, user) } +func (handler *Handler) authenticateOAuth(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload oauthPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + 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 not being used", err} + } + + token, err := handler.OAuthService.GetAccessToken(payload.Code, &settings.OAuthSettings) + if err != nil { + return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid access token", portainer.ErrUnauthorized} + } + + username, err := handler.OAuthService.GetUsername(token, &settings.OAuthSettings) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Unable to acquire username", portainer.ErrUnauthorized} + } + + u, err := handler.UserService.UserByUsername(username) + if err != nil && err != portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", err} + } + + if u == nil && !settings.OAuthSettings.OAuthAutoCreateUsers { + return &httperror.HandlerError{http.StatusForbidden, "Unregistered account", portainer.ErrUnauthorized} + } + + if u == nil { + user := &portainer.User{ + Username: username, + Role: portainer.StandardUserRole, + } + + err = handler.UserService.CreateUser(user) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err} + } + + return handler.writeToken(w, user) + } + + return handler.writeToken(w, u) +} + func (handler *Handler) authenticateInternal(w http.ResponseWriter, user *portainer.User, password string) *httperror.HandlerError { err := handler.CryptoService.CompareHashAndData(user.Password, password) if err != nil { diff --git a/api/http/handler/auth/handler.go b/api/http/handler/auth/handler.go index 1f0769e08..073da3a69 100644 --- a/api/http/handler/auth/handler.go +++ b/api/http/handler/auth/handler.go @@ -25,6 +25,7 @@ type Handler struct { CryptoService portainer.CryptoService JWTService portainer.JWTService LDAPService portainer.LDAPService + OAuthService portainer.OAuthService SettingsService portainer.SettingsService TeamService portainer.TeamService TeamMembershipService portainer.TeamMembershipService @@ -38,6 +39,8 @@ func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimi } h.Handle("/auth", rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticate)))).Methods(http.MethodPost) + h.Handle("/oauth", + rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticateOAuth)))).Methods(http.MethodPost) return h } diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 9cb3059df..f818edf4c 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -60,6 +60,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch { case strings.HasPrefix(r.URL.Path, "/api/auth"): http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/oauth"): + http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/dockerhub"): http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/endpoint_groups"): diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go index 0acbd2ca6..ee277d676 100644 --- a/api/http/handler/settings/handler.go +++ b/api/http/handler/settings/handler.go @@ -11,6 +11,7 @@ import ( func hideFields(settings *portainer.Settings) { settings.LDAPSettings.Password = "" + settings.OAuthSettings.ClientSecret = "" } // Handler is the HTTP handler used to handle settings operations. @@ -18,6 +19,7 @@ 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 9744b319a..a803c22a5 100644 --- a/api/http/handler/settings/settings_public.go +++ b/api/http/handler/settings/settings_public.go @@ -15,6 +15,10 @@ type publicSettingsResponse struct { AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"` ExternalTemplates bool `json:"ExternalTemplates"` + AuthorizationURI string `json:"AuthorizationURI"` + ClientID string `json:"ClientID"` + RedirectURI string `json:"RedirectURI"` + Scopes string `json:"Scopes"` } // GET request on /api/settings/public @@ -31,6 +35,10 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) * AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers, EnableHostManagementFeatures: settings.EnableHostManagementFeatures, ExternalTemplates: false, + AuthorizationURI: settings.OAuthSettings.AuthorizationURI, + ClientID: settings.OAuthSettings.ClientID, + RedirectURI: settings.OAuthSettings.RedirectURI, + Scopes: settings.OAuthSettings.Scopes, } if settings.TemplatesURL != "" { diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 5b4d33ae3..57c6d1d0a 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -16,6 +16,7 @@ type settingsUpdatePayload struct { BlackListedLabels []portainer.Pair AuthenticationMethod *int LDAPSettings *portainer.LDAPSettings + OAuthSettings *portainer.OAuthSettings AllowBindMountsForRegularUsers *bool AllowPrivilegedModeForRegularUsers *bool EnableHostManagementFeatures *bool @@ -24,7 +25,7 @@ type settingsUpdatePayload struct { } func (payload *settingsUpdatePayload) Validate(r *http.Request) error { - if *payload.AuthenticationMethod != 1 && *payload.AuthenticationMethod != 2 { + if *payload.AuthenticationMethod != 1 && *payload.AuthenticationMethod != 2 && *payload.AuthenticationMethod != 3 { return portainer.Error("Invalid authentication method value. Value must be one of: 1 (internal) or 2 (LDAP/AD)") } if payload.LogoURL != nil && *payload.LogoURL != "" && !govalidator.IsURL(*payload.LogoURL) { @@ -69,6 +70,10 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * settings.LDAPSettings = *payload.LDAPSettings } + if payload.OAuthSettings != nil { + settings.OAuthSettings = *payload.OAuthSettings + } + if payload.AllowBindMountsForRegularUsers != nil { settings.AllowBindMountsForRegularUsers = *payload.AllowBindMountsForRegularUsers } diff --git a/api/http/server.go b/api/http/server.go index 1220d94d1..cf3c9ef2b 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -55,6 +55,7 @@ type Server struct { GitService portainer.GitService JWTService portainer.JWTService LDAPService portainer.LDAPService + OAuthService portainer.OAuthService ExtensionService portainer.ExtensionService RegistryService portainer.RegistryService ResourceControlService portainer.ResourceControlService @@ -104,6 +105,7 @@ 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 @@ -155,6 +157,7 @@ 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 new file mode 100644 index 000000000..bc175d46c --- /dev/null +++ b/api/oauth/oauth.go @@ -0,0 +1,168 @@ +package oauth + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "log" + "mime" + "net/http" + "net/url" + "strings" + + "github.com/portainer/portainer" + "golang.org/x/oauth2" +) + +const ( + // ErrInvalidCode defines an error raised when the user authorization code is invalid + ErrInvalidCode = portainer.Error("Authorization code is invalid") +) + +// 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) { + v := url.Values{} + v.Set("client_id", settings.ClientID) + v.Set("client_secret", settings.ClientSecret) + v.Set("redirect_uri", settings.RedirectURI) + v.Set("code", code) + v.Set("grant_type", "authorization_code") + + req, err := http.NewRequest("POST", settings.AccessTokenURI, strings.NewReader(v.Encode())) + if err != nil { + return "", err + } + + client := &http.Client{} + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r, err := client.Do(req) + if err != nil { + return "", err + } + + body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + return "", fmt.Errorf("oauth2: cannot fetch token: %v", err) + } + + if r.StatusCode != http.StatusOK { + log.Printf("[Error] - request returned with bad status code %v, body is %v", r.StatusCode, string(body)) + type ErrorMessage struct { + Message string + Type string + Code int + } + type ErrorResponse struct { + Error ErrorMessage + } + + var response ErrorResponse + if err = json.Unmarshal(body, &response); err != nil { + // report also error + log.Printf("[Error] - Failed parsing error body: %v", err) + return "", errors.New("oauth2: cannot fetch token") + } + + return "", errors.New(response.Error.Message) + } + + content, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) + if content == "application/x-www-form-urlencoded" || content == "text/plain" { + values, err := url.ParseQuery(string(body)) + if err != nil { + return "", err + } + + token := values.Get("access_token") + log.Printf("[DEBUG] - returned body %v", values) + + if token == "" { + log.Printf("[DEBUG] - access token returned empty - %v", values) + return "", errors.New("oauth2: cannot fetch token") + } + + return token, nil + } + + type tokenJSON struct { + AccessToken string `json:"access_token"` + } + + var tj tokenJSON + if err = json.Unmarshal(body, &tj); err != nil { + return "", err + } + + token := tj.AccessToken + + if token == "" { + log.Printf("[DEBUG] - access token returned empty - %v with status code", string(body), r.StatusCode) + return "", errors.New("oauth2: cannot fetch token") + } + return token, 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, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")) + 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 { + return fmt.Sprint(int(username)), nil + } + } + return "", &oauth2.RetrieveError{ + Response: resp, + Body: body, + } +} diff --git a/api/portainer.go b/api/portainer.go index c8062ed7a..fe099769e 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -56,6 +56,19 @@ type ( AutoCreateUsers bool `json:"AutoCreateUsers"` } + // OAuthSettings represents the settings used to authorize with an authorization server + OAuthSettings struct { + ClientID string `json:"ClientID"` + ClientSecret string `json:"ClientSecret,omitempty"` + AccessTokenURI string `json:"AccessTokenURI"` + AuthorizationURI string `json:"AuthorizationURI"` + ResourceURI string `json:"ResourceURI"` + RedirectURI string `json:"RedirectURI"` + UserIdentifier string `json:"UserIdentifier"` + Scopes string `json:"Scopes"` + OAuthAutoCreateUsers bool `json:"OAuthAutoCreateUsers"` + } + // TLSConfiguration represents a TLS configuration TLSConfiguration struct { TLS bool `json:"TLS"` @@ -85,6 +98,7 @@ type ( BlackListedLabels []Pair `json:"BlackListedLabels"` AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"` LDAPSettings LDAPSettings `json:"LDAPSettings"` + OAuthSettings OAuthSettings `json:"OAuthSettings"` AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"` AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` SnapshotInterval string `json:"SnapshotInterval"` @@ -748,6 +762,12 @@ 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) + } + // SwarmStackManager represents a service to manage Swarm stacks SwarmStackManager interface { Login(dockerhub *DockerHub, registries []Registry, endpoint *Endpoint) @@ -833,6 +853,8 @@ const ( AuthenticationInternal // AuthenticationLDAP represents the LDAP authentication method (authentication against a LDAP server) AuthenticationLDAP + //AuthenticationOAuth represents the OAuth authentication method (authentication against a authorization server) + AuthenticationOAuth ) const ( diff --git a/app/constants.js b/app/constants.js index 464089158..f1b941de6 100644 --- a/app/constants.js +++ b/app/constants.js @@ -4,6 +4,7 @@ angular.module('portainer') .constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints') .constant('API_ENDPOINT_ENDPOINT_GROUPS', 'api/endpoint_groups') .constant('API_ENDPOINT_MOTD', 'api/motd') +.constant('API_ENDPOINT_OAUTH', 'api/oauth') .constant('API_ENDPOINT_EXTENSIONS', 'api/extensions') .constant('API_ENDPOINT_REGISTRIES', 'api/registries') .constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls') diff --git a/app/portainer/components/datatables/users-datatable/usersDatatable.html b/app/portainer/components/datatables/users-datatable/usersDatatable.html index 11692974d..e99fd16f5 100644 --- a/app/portainer/components/datatables/users-datatable/usersDatatable.html +++ b/app/portainer/components/datatables/users-datatable/usersDatatable.html @@ -65,8 +65,9 @@ - Internal + Internal LDAP + OAuth diff --git a/app/portainer/models/settings.js b/app/portainer/models/settings.js index a4f2e8bf6..cbf0c958a 100644 --- a/app/portainer/models/settings.js +++ b/app/portainer/models/settings.js @@ -3,6 +3,11 @@ function SettingsViewModel(data) { this.BlackListedLabels = data.BlackListedLabels; this.AuthenticationMethod = data.AuthenticationMethod; this.LDAPSettings = data.LDAPSettings; + this.OAuthSettings = data.OAuthSettings; + this.ClientID = data.ClientID; + this.RedirectURI = data.RedirectURI; + this.Scopes = data.Scopes; + this.AuthorizationURI = data.AuthorizationURI; this.AllowBindMountsForRegularUsers = data.AllowBindMountsForRegularUsers; this.AllowPrivilegedModeForRegularUsers = data.AllowPrivilegedModeForRegularUsers; this.SnapshotInterval = data.SnapshotInterval; @@ -31,3 +36,15 @@ function LDAPGroupSearchSettings(GroupBaseDN, GroupAttribute, GroupFilter) { this.GroupAttribute = GroupAttribute; this.GroupFilter = GroupFilter; } + +function OAuthSettingsViewModel(data) { + this.ClientID = data.ClientID; + this.ClientSecret = data.ClientSecret; + this.AccessTokenURI = data.AccessTokenURI; + this.AuthorizationURI = data.AuthorizationURI; + this.ResourceURI = data.ResourceURI; + this.RedirectURI = data.RedirectURI; + this.UserIdentifier = data.UserIdentifier; + this.Scopes = data.Scopes; + this.OAuthAutoCreateUsers = data.OAuthAutoCreateUsers; +} \ No newline at end of file diff --git a/app/portainer/rest/oauth.js b/app/portainer/rest/oauth.js new file mode 100644 index 000000000..c1d2a68ad --- /dev/null +++ b/app/portainer/rest/oauth.js @@ -0,0 +1,9 @@ +angular.module('portainer.app') +.factory('OAuth', ['$resource', 'API_ENDPOINT_OAUTH', function OAuthFactory($resource, API_ENDPOINT_OAUTH) { + 'use strict'; + return $resource(API_ENDPOINT_OAUTH, {}, { + login: { + method: 'POST', ignoreLoadingBar: true + } + }); +}]); \ No newline at end of file diff --git a/app/portainer/services/authentication.js b/app/portainer/services/authentication.js index 0e9f19a93..e3ac610e9 100644 --- a/app/portainer/services/authentication.js +++ b/app/portainer/services/authentication.js @@ -1,11 +1,12 @@ angular.module('portainer.app') -.factory('Authentication', ['$q', 'Auth', 'jwtHelper', 'LocalStorage', 'StateManager', 'EndpointProvider', function AuthenticationFactory($q, Auth, jwtHelper, LocalStorage, StateManager, EndpointProvider) { +.factory('Authentication', ['$q', 'Auth', 'OAuth', 'jwtHelper', 'LocalStorage', 'StateManager', 'EndpointProvider', function AuthenticationFactory($q, Auth, OAuth, jwtHelper, LocalStorage, StateManager, EndpointProvider) { 'use strict'; var service = {}; var user = {}; service.init = init; + service.oAuthLogin = oAuthLogin; service.login = login; service.logout = logout; service.isAuthenticated = isAuthenticated; @@ -22,6 +23,24 @@ angular.module('portainer.app') } } + function oAuthLogin(code) { + var deferred = $q.defer(); + + OAuth.login({code: code}).$promise + .then(function success(data) { + LocalStorage.storeJWT(data.jwt); + var tokenPayload = jwtHelper.decodeToken(data.jwt); + user.username = tokenPayload.username; + user.ID = tokenPayload.id; + user.role = tokenPayload.role; + deferred.resolve(); + }) + .catch(function error() { + deferred.reject(); + }); + return deferred.promise; + } + function login(username, password) { var deferred = $q.defer(); diff --git a/app/portainer/views/auth/auth.html b/app/portainer/views/auth/auth.html index 6cde5c633..835303ff2 100644 --- a/app/portainer/views/auth/auth.html +++ b/app/portainer/views/auth/auth.html @@ -28,13 +28,15 @@
+
OAuth Login
- + {{ state.AuthenticationError }}
+ diff --git a/app/portainer/views/auth/authController.js b/app/portainer/views/auth/authController.js index 055d359be..6a04ecf36 100644 --- a/app/portainer/views/auth/authController.js +++ b/app/portainer/views/auth/authController.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.controller('AuthenticationController', ['$q', '$scope', '$state', '$transition$', '$sanitize', 'Authentication', 'UserService', 'EndpointService', 'StateManager', 'Notifications', 'SettingsService', -function ($q, $scope, $state, $transition$, $sanitize, Authentication, UserService, EndpointService, StateManager, Notifications, SettingsService) { +.controller('AuthenticationController', ['$q', '$scope', '$state', '$transition$', '$sanitize', '$location', '$window', 'Authentication', 'UserService', 'EndpointService', 'StateManager', 'Notifications', 'SettingsService', +function ($q, $scope, $state, $transition$, $sanitize, $location, $window, Authentication, UserService, EndpointService, StateManager, Notifications, SettingsService) { $scope.logo = StateManager.getState().application.logo; @@ -12,6 +12,16 @@ function ($q, $scope, $state, $transition$, $sanitize, Authentication, UserServi $scope.state = { AuthenticationError: '' }; + + SettingsService.publicSettings() + .then(function success(settings) { + $scope.AuthenticationMethod = settings.AuthenticationMethod; + $scope.ClientID = settings.ClientID; + $scope.RedirectURI = settings.RedirectURI; + $scope.Scopes = settings.Scopes; + $scope.AuthorizationURI = settings.AuthorizationURI; + }); + $scope.authenticateUser = function() { var username = $scope.formValues.Username; @@ -74,6 +84,7 @@ function ($q, $scope, $state, $transition$, $sanitize, Authentication, UserServi $state.go('portainer.init.endpoint'); } else { $state.go('portainer.home'); + $window.location.search = ''; } }) .catch(function error(err) { @@ -100,5 +111,35 @@ function ($q, $scope, $state, $transition$, $sanitize, Authentication, UserServi } } + function oAuthLogin(code) { + Authentication.oAuthLogin(code) + .then(function success() { + $state.go('portainer.home'); + $window.location.search = ''; + }) + .catch(function error() { + $scope.state.AuthenticationError = 'Failed to authenticate with OAuth2 Provider'; + }); + } + + function getParameter(param) { + var URL = $location.absUrl(); + var params = URL.split('?')[1]; + if (params === undefined) { + return null; + } + params = params.split('&'); + for (var i = 0; i < params.length; i++) { + var parameter = params[i].split('='); + if (parameter[0] === param) { + return parameter[1].split('#')[0]; + } + } + return null; + } + initView(); + if (getParameter('code') !== null) { + oAuthLogin(getParameter('code')); + } }]); diff --git a/app/portainer/views/settings/authentication/settingsAuthentication.html b/app/portainer/views/settings/authentication/settingsAuthentication.html index 72db0edef..1cef110f7 100644 --- a/app/portainer/views/settings/authentication/settingsAuthentication.html +++ b/app/portainer/views/settings/authentication/settingsAuthentication.html @@ -37,6 +37,16 @@

LDAP authentication

+
+ + +
@@ -52,6 +62,11 @@ When using LDAP authentication, Portainer will delegate user authentication to a LDAP server and fallback to internal authentication if LDAP authentication fails.
+
+ + When using OAuth authentication, Portainer will allow users to optionally authenticate with an OAuth authorization server. + +
@@ -306,7 +321,110 @@
- +
+ +
+ OAuth Configuration +
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ + With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role. If disabled, users must be created in Portainer in order to login. + +
+
+
+ + +
+
+
+ +
-
- -
- OAuth Configuration -
- -
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- -
- -
- -
-
- -
- - With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role. If disabled, users must be created in Portainer in order to login. - -
-
-
- - -
-
-
+
diff --git a/app/portainer/views/settings/authentication/settingsAuthenticationController.js b/app/portainer/views/settings/authentication/settingsAuthenticationController.js index 81f096942..a94dfd658 100644 --- a/app/portainer/views/settings/authentication/settingsAuthenticationController.js +++ b/app/portainer/views/settings/authentication/settingsAuthenticationController.js @@ -14,6 +14,10 @@ function ($q, $scope, Notifications, SettingsService, FileUploadService) { TLSCACert: '' }; + $scope.isOauthEnabled = function isOauthEnabled() { + return $scope.settings.AuthenticationMethod === 3; + }; + $scope.addSearchConfiguration = function() { $scope.LDAPSettings.SearchSettings.push({ BaseDN: '', UserNameAttribute: '', Filter: '' }); }; From 7aaa9e58e90b5f37274f54d0d59d2b24af9ab61d Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Wed, 2 Jan 2019 16:24:10 +0200 Subject: [PATCH 08/61] refactor(auth): move oauth info to component --- .../oauth/components/oauth-settings/oauth-settings.html | 6 ++++++ .../settings/authentication/settingsAuthentication.html | 6 +----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/extensions/oauth/components/oauth-settings/oauth-settings.html b/app/extensions/oauth/components/oauth-settings/oauth-settings.html index 49f923e12..f73415e25 100644 --- a/app/extensions/oauth/components/oauth-settings/oauth-settings.html +++ b/app/extensions/oauth/components/oauth-settings/oauth-settings.html @@ -1,4 +1,10 @@ +
+ + When using OAuth authentication, Portainer will allow users to optionally authenticate with an OAuth authorization server. + +
+
OAuth Configuration
diff --git a/app/portainer/views/settings/authentication/settingsAuthentication.html b/app/portainer/views/settings/authentication/settingsAuthentication.html index 8c46a5aca..ebab9dcf2 100644 --- a/app/portainer/views/settings/authentication/settingsAuthentication.html +++ b/app/portainer/views/settings/authentication/settingsAuthentication.html @@ -62,11 +62,7 @@ When using LDAP authentication, Portainer will delegate user authentication to a LDAP server and fallback to internal authentication if LDAP authentication fails.
-
- - When using OAuth authentication, Portainer will allow users to optionally authenticate with an OAuth authorization server. - -
+
From 15b6941872b4ca160719ba057b819d3505f405ad Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Wed, 2 Jan 2019 20:00:41 +0200 Subject: [PATCH 09/61] refactor(oauth): move oauth rest service to extension --- app/extensions/oauth/__module.js | 4 +++- app/{portainer => extensions/oauth/services}/rest/oauth.js | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) rename app/{portainer => extensions/oauth/services}/rest/oauth.js (83%) diff --git a/app/extensions/oauth/__module.js b/app/extensions/oauth/__module.js index 80b17e495..564c659f7 100644 --- a/app/extensions/oauth/__module.js +++ b/app/extensions/oauth/__module.js @@ -1 +1,3 @@ -angular.module('portainer.extensions.oauth', []); \ No newline at end of file +angular.module('portainer.extensions.oauth', [ + 'ngResource' +]); \ No newline at end of file diff --git a/app/portainer/rest/oauth.js b/app/extensions/oauth/services/rest/oauth.js similarity index 83% rename from app/portainer/rest/oauth.js rename to app/extensions/oauth/services/rest/oauth.js index c1d2a68ad..a6eff3bf7 100644 --- a/app/portainer/rest/oauth.js +++ b/app/extensions/oauth/services/rest/oauth.js @@ -1,4 +1,4 @@ -angular.module('portainer.app') +angular.module('portainer.extensions.oauth') .factory('OAuth', ['$resource', 'API_ENDPOINT_OAUTH', function OAuthFactory($resource, API_ENDPOINT_OAUTH) { 'use strict'; return $resource(API_ENDPOINT_OAUTH, {}, { From 81e3ace232600be26b589e789a645402a0e07d1d Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Wed, 2 Jan 2019 20:01:06 +0200 Subject: [PATCH 10/61] fix(auth): fix oauh enabled function --- .../views/settings/authentication/settingsAuthentication.html | 2 +- .../settings/authentication/settingsAuthenticationController.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/portainer/views/settings/authentication/settingsAuthentication.html b/app/portainer/views/settings/authentication/settingsAuthentication.html index ebab9dcf2..8a1ac2e19 100644 --- a/app/portainer/views/settings/authentication/settingsAuthentication.html +++ b/app/portainer/views/settings/authentication/settingsAuthentication.html @@ -317,7 +317,7 @@
- +
diff --git a/app/portainer/views/settings/authentication/settingsAuthenticationController.js b/app/portainer/views/settings/authentication/settingsAuthenticationController.js index a94dfd658..dc74a3b54 100644 --- a/app/portainer/views/settings/authentication/settingsAuthenticationController.js +++ b/app/portainer/views/settings/authentication/settingsAuthenticationController.js @@ -15,7 +15,7 @@ function ($q, $scope, Notifications, SettingsService, FileUploadService) { }; $scope.isOauthEnabled = function isOauthEnabled() { - return $scope.settings.AuthenticationMethod === 3; + return $scope.settings && $scope.settings.AuthenticationMethod === 3; }; $scope.addSearchConfiguration = function() { From 9bebe9dee775d802c5b31fd390bd735d34399e0a Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Wed, 2 Jan 2019 20:01:23 +0200 Subject: [PATCH 11/61] refactor(auth): move user setter into function --- app/portainer/services/authentication.js | 37 +++++++++++------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/app/portainer/services/authentication.js b/app/portainer/services/authentication.js index b6b9c8071..7b334e10e 100644 --- a/app/portainer/services/authentication.js +++ b/app/portainer/services/authentication.js @@ -18,33 +18,22 @@ function AuthenticationFactory(Auth, OAuth, jwtHelper, LocalStorage, StateManage var jwt = LocalStorage.getJWT(); if (jwt) { - var tokenPayload = jwtHelper.decodeToken(jwt); - user.username = tokenPayload.username; - user.ID = tokenPayload.id; - user.role = tokenPayload.role; + setUser(jwt); } } function oAuthLogin(code) { - return OAuth.login({code: code}).$promise - .then(function success(data) { - LocalStorage.storeJWT(data.jwt); - var tokenPayload = jwtHelper.decodeToken(data.jwt); - user.username = tokenPayload.username; - user.ID = tokenPayload.id; - user.role = tokenPayload.role; - }); + return OAuth.login({ code: code }).$promise + .then(function onLoginSuccess(response) { + return setUser(response.jwt); + }); } function login(username, password) { - return Auth.login({username: username, password: password}).$promise - .then(function success(data) { - LocalStorage.storeJWT(data.jwt); - var tokenPayload = jwtHelper.decodeToken(data.jwt); - user.username = username; - user.ID = tokenPayload.id; - user.role = tokenPayload.role; - }); + return Auth.login({ username: username, password: password }).$promise + .then(function onLoginSuccess(response) { + return setUser(response.jwt); + }); } function logout() { @@ -62,5 +51,13 @@ function AuthenticationFactory(Auth, OAuth, jwtHelper, LocalStorage, StateManage return user; } + function setUser(jwt) { + LocalStorage.storeJWT(jwt); + var tokenPayload = jwtHelper.decodeToken(jwt); + user.username = tokenPayload.username; + user.ID = tokenPayload.id; + user.role = tokenPayload.role; + } + return service; }]); From 25620c50083f499ab6b1c5ae4f62797647ec0223 Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Wed, 2 Jan 2019 20:49:25 +0200 Subject: [PATCH 12/61] refactor(auth): refactor get url params --- app/portainer/helpers/urlHelper.js | 35 ++++++++++++---------- app/portainer/views/auth/authController.js | 3 +- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/app/portainer/helpers/urlHelper.js b/app/portainer/helpers/urlHelper.js index 977e22751..3125492a5 100644 --- a/app/portainer/helpers/urlHelper.js +++ b/app/portainer/helpers/urlHelper.js @@ -1,21 +1,24 @@ -angular.module('portainer.app').service('urlHelper', function urlHelper($location) { - +angular.module('portainer.app').service('urlHelper', function urlHelper($window) { this.getParameter = getParameter; + this.cleanParameters = cleanParameters; function getParameter(param) { - var url = $location.absUrl(); - var index = url.indexOf('?'); - if (index < 0) { - return; - } - var params = url.substring(index + 1); - params = params.split('&'); - for (var i = 0; i < params.length; i++) { - var parameter = params[i].split('='); - if (parameter[0] === param) { - return parameter[1].split('#')[0]; - } - } - return; + var parameters = extractParameters(); + return parameters[param]; + } + + function extractParameters() { + var queryString = $window.location.search.replace(/.*?\?/,'').split('&'); + return queryString.reduce(function(acc, keyValStr) { + var keyVal = keyValStr.split('='); + var key = keyVal[0]; + var val = keyVal[1]; + acc[key] = val; + return acc; + }, {}); + } + + function cleanParameters() { + $window.location.search = ''; } }); diff --git a/app/portainer/views/auth/authController.js b/app/portainer/views/auth/authController.js index 9c80ba721..aab5905de 100644 --- a/app/portainer/views/auth/authController.js +++ b/app/portainer/views/auth/authController.js @@ -114,8 +114,9 @@ function (urlHelper, $q, $scope, $state, $stateParams, $sanitize, Authentication } function oAuthLogin(code) { - Authentication.oAuthLogin(code) + return Authentication.oAuthLogin(code) .then(function success() { + urlHelper.cleanParameters(); $state.go('portainer.home'); }) .catch(function error() { From 17ac3e5ed13a194323e7c7172187892d1b8daadf Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Thu, 3 Jan 2019 13:36:17 +0200 Subject: [PATCH 13/61] refactor(oauth): move enpoint constant to extension --- app/constants.js | 1 - app/extensions/oauth/__module.js | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/constants.js b/app/constants.js index f1b941de6..464089158 100644 --- a/app/constants.js +++ b/app/constants.js @@ -4,7 +4,6 @@ angular.module('portainer') .constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints') .constant('API_ENDPOINT_ENDPOINT_GROUPS', 'api/endpoint_groups') .constant('API_ENDPOINT_MOTD', 'api/motd') -.constant('API_ENDPOINT_OAUTH', 'api/oauth') .constant('API_ENDPOINT_EXTENSIONS', 'api/extensions') .constant('API_ENDPOINT_REGISTRIES', 'api/registries') .constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls') diff --git a/app/extensions/oauth/__module.js b/app/extensions/oauth/__module.js index 564c659f7..413d9d4f6 100644 --- a/app/extensions/oauth/__module.js +++ b/app/extensions/oauth/__module.js @@ -1,3 +1,2 @@ -angular.module('portainer.extensions.oauth', [ - 'ngResource' -]); \ No newline at end of file +angular.module('portainer.extensions.oauth', ['ngResource']) + .constant('API_ENDPOINT_OAUTH', 'api/oauth'); From 44b7e0fdca5c421cd0e9c6fa81e93e1ffad587ca Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 16 Jan 2019 16:49:33 +0200 Subject: [PATCH 14/61] fix(auth): change error type Co-Authored-By: chiptus --- api/http/handler/auth/authenticate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go index a0fbf7c51..eca24a902 100644 --- a/api/http/handler/auth/authenticate.go +++ b/api/http/handler/auth/authenticate.go @@ -106,7 +106,7 @@ func (handler *Handler) authenticateOAuth(w http.ResponseWriter, r *http.Request } if settings.AuthenticationMethod != 3 { - return &httperror.HandlerError{http.StatusForbidden, "OAuth authentication is not being used", err} + return &httperror.HandlerError{http.StatusServiceUnavailable, "Authentication is not configured to use OAuth", err} } token, err := handler.OAuthService.GetAccessToken(payload.Code, &settings.OAuthSettings) From fc8938e8719fdc16031b110c754287c7dc83368e Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 16 Jan 2019 16:50:19 +0200 Subject: [PATCH 15/61] fix(auth): change oauth error type Co-Authored-By: chiptus --- api/http/handler/auth/authenticate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go index eca24a902..eb1b5128d 100644 --- a/api/http/handler/auth/authenticate.go +++ b/api/http/handler/auth/authenticate.go @@ -127,7 +127,7 @@ func (handler *Handler) authenticateOAuth(w http.ResponseWriter, r *http.Request } if u == nil && !settings.OAuthSettings.OAuthAutoCreateUsers { - return &httperror.HandlerError{http.StatusForbidden, "Unregistered account", portainer.ErrUnauthorized} + return &httperror.HandlerError{http.StatusForbidden, "Account must be created inside Portainer beforehand", portainer.ErrUnauthorized} } if u == nil { From c650fe56c29c0a1aeb7066533fd58aee66b1026c Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 16 Jan 2019 16:53:24 +0200 Subject: [PATCH 16/61] fix(auth): fix typos Co-Authored-By: chiptus --- app/portainer/services/authentication.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/portainer/services/authentication.js b/app/portainer/services/authentication.js index 7b334e10e..39b7ada24 100644 --- a/app/portainer/services/authentication.js +++ b/app/portainer/services/authentication.js @@ -8,7 +8,7 @@ function AuthenticationFactory(Auth, OAuth, jwtHelper, LocalStorage, StateManage var user = {}; service.init = init; - service.oAuthLogin = oAuthLogin; + service.OAuthLogin = OAuthLogin; service.login = login; service.logout = logout; service.isAuthenticated = isAuthenticated; @@ -22,7 +22,7 @@ function AuthenticationFactory(Auth, OAuth, jwtHelper, LocalStorage, StateManage } } - function oAuthLogin(code) { + function OAuthLogin(code) { return OAuth.login({ code: code }).$promise .then(function onLoginSuccess(response) { return setUser(response.jwt); From f6bdc5c2b362db99bd9517f42795bdaba1049ce4 Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Wed, 16 Jan 2019 17:01:38 +0200 Subject: [PATCH 17/61] refactor(auth): move oauth handler code to its own file --- api/http/handler/auth/authenticate.go | 54 ----------------- api/http/handler/auth/authenticate_oauth.go | 64 +++++++++++++++++++++ 2 files changed, 64 insertions(+), 54 deletions(-) create mode 100644 api/http/handler/auth/authenticate_oauth.go diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go index a0fbf7c51..8183c2f7b 100644 --- a/api/http/handler/auth/authenticate.go +++ b/api/http/handler/auth/authenticate.go @@ -93,60 +93,6 @@ func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer. return handler.writeToken(w, user) } -func (handler *Handler) authenticateOAuth(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { - var payload oauthPayload - err := request.DecodeAndValidateJSONPayload(r, &payload) - if err != nil { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} - } - - 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 not being used", 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} - } - - username, err := handler.OAuthService.GetUsername(token, &settings.OAuthSettings) - if err != nil { - log.Printf("[DEBUG] - Failed acquiring username: %v", err) - return &httperror.HandlerError{http.StatusForbidden, "Unable to acquire username", portainer.ErrUnauthorized} - } - - u, err := handler.UserService.UserByUsername(username) - if err != nil && err != portainer.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", err} - } - - if u == nil && !settings.OAuthSettings.OAuthAutoCreateUsers { - return &httperror.HandlerError{http.StatusForbidden, "Unregistered account", portainer.ErrUnauthorized} - } - - if u == nil { - user := &portainer.User{ - Username: username, - Role: portainer.StandardUserRole, - } - - err = handler.UserService.CreateUser(user) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err} - } - - return handler.writeToken(w, user) - } - - return handler.writeToken(w, u) -} - func (handler *Handler) authenticateInternal(w http.ResponseWriter, user *portainer.User, password string) *httperror.HandlerError { err := handler.CryptoService.CompareHashAndData(user.Password, password) if err != nil { diff --git a/api/http/handler/auth/authenticate_oauth.go b/api/http/handler/auth/authenticate_oauth.go new file mode 100644 index 000000000..58319b9df --- /dev/null +++ b/api/http/handler/auth/authenticate_oauth.go @@ -0,0 +1,64 @@ +package auth + +import ( + "log" + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/portainer" +) + +func (handler *Handler) authenticateOAuth(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload oauthPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + 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 not being used", 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} + } + + username, err := handler.OAuthService.GetUsername(token, &settings.OAuthSettings) + if err != nil { + log.Printf("[DEBUG] - Failed acquiring username: %v", err) + return &httperror.HandlerError{http.StatusForbidden, "Unable to acquire username", portainer.ErrUnauthorized} + } + + u, err := handler.UserService.UserByUsername(username) + if err != nil && err != portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", err} + } + + if u == nil && !settings.OAuthSettings.OAuthAutoCreateUsers { + return &httperror.HandlerError{http.StatusForbidden, "Unregistered account", portainer.ErrUnauthorized} + } + + if u == nil { + user := &portainer.User{ + Username: username, + Role: portainer.StandardUserRole, + } + + err = handler.UserService.CreateUser(user) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err} + } + + return handler.writeToken(w, user) + } + + return handler.writeToken(w, u) +} From 4cbde7bb0d3b19bf57f5ee39d9244645fc873316 Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Wed, 16 Jan 2019 17:24:58 +0200 Subject: [PATCH 18/61] refactor(auth): move oauth handler under auth --- api/http/handler/auth/handler.go | 5 +++-- app/extensions/oauth/__module.js | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/api/http/handler/auth/handler.go b/api/http/handler/auth/handler.go index 073da3a69..a97928150 100644 --- a/api/http/handler/auth/handler.go +++ b/api/http/handler/auth/handler.go @@ -37,10 +37,11 @@ func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimi Router: mux.NewRouter(), authDisabled: authDisabled, } + + h.Handle("/auth/oauth", + rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticateOAuth)))).Methods(http.MethodPost) h.Handle("/auth", rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticate)))).Methods(http.MethodPost) - h.Handle("/oauth", - rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticateOAuth)))).Methods(http.MethodPost) return h } diff --git a/app/extensions/oauth/__module.js b/app/extensions/oauth/__module.js index 413d9d4f6..8292353a5 100644 --- a/app/extensions/oauth/__module.js +++ b/app/extensions/oauth/__module.js @@ -1,2 +1,2 @@ angular.module('portainer.extensions.oauth', ['ngResource']) - .constant('API_ENDPOINT_OAUTH', 'api/oauth'); + .constant('API_ENDPOINT_OAUTH', 'api/auth/oauth'); From 24f066716be39f989f2f87dcb990c9e4e9293959 Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Wed, 16 Jan 2019 17:25:16 +0200 Subject: [PATCH 19/61] refactor(auth): expose only the login url --- api/http/handler/settings/settings_public.go | 15 +++++++-------- app/portainer/views/auth/auth.html | 2 +- app/portainer/views/auth/authController.js | 5 +---- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/api/http/handler/settings/settings_public.go b/api/http/handler/settings/settings_public.go index a803c22a5..1cb59f8c6 100644 --- a/api/http/handler/settings/settings_public.go +++ b/api/http/handler/settings/settings_public.go @@ -1,6 +1,7 @@ package settings import ( + "fmt" "net/http" httperror "github.com/portainer/libhttp/error" @@ -15,10 +16,7 @@ type publicSettingsResponse struct { AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"` ExternalTemplates bool `json:"ExternalTemplates"` - AuthorizationURI string `json:"AuthorizationURI"` - ClientID string `json:"ClientID"` - RedirectURI string `json:"RedirectURI"` - Scopes string `json:"Scopes"` + OAuthLoginURI string `json:"OAuthLoginURI"` } // GET request on /api/settings/public @@ -35,10 +33,11 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) * AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers, EnableHostManagementFeatures: settings.EnableHostManagementFeatures, ExternalTemplates: false, - AuthorizationURI: settings.OAuthSettings.AuthorizationURI, - ClientID: settings.OAuthSettings.ClientID, - RedirectURI: settings.OAuthSettings.RedirectURI, - Scopes: settings.OAuthSettings.Scopes, + OAuthLoginURI: fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s&state=portainer", + settings.OAuthSettings.AuthorizationURI, + settings.OAuthSettings.ClientID, + settings.OAuthSettings.RedirectURI, + settings.OAuthSettings.Scopes), } if settings.TemplatesURL != "" { diff --git a/app/portainer/views/auth/auth.html b/app/portainer/views/auth/auth.html index 835303ff2..05024059f 100644 --- a/app/portainer/views/auth/auth.html +++ b/app/portainer/views/auth/auth.html @@ -28,7 +28,7 @@
-
OAuth Login
+
OAuth Login
diff --git a/app/portainer/views/auth/authController.js b/app/portainer/views/auth/authController.js index aab5905de..30ca6b42c 100644 --- a/app/portainer/views/auth/authController.js +++ b/app/portainer/views/auth/authController.js @@ -84,10 +84,7 @@ function (urlHelper, $q, $scope, $state, $stateParams, $sanitize, Authentication SettingsService.publicSettings() .then(function success(settings) { $scope.AuthenticationMethod = settings.AuthenticationMethod; - $scope.ClientID = settings.ClientID; - $scope.RedirectURI = settings.RedirectURI; - $scope.Scopes = settings.Scopes; - $scope.AuthorizationURI = settings.AuthorizationURI; + $scope.OAuthLoginURI = settings.OAuthLoginURI; }); if ($stateParams.logout || $stateParams.error) { From 80d570861dbafef1a661cb5efa9984a6cf3c1b4f Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Wed, 16 Jan 2019 17:34:12 +0200 Subject: [PATCH 20/61] refactor(auth): move public settings into view model --- app/portainer/models/settings.js | 10 ++++++++++ app/portainer/services/api/settingsService.js | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/portainer/models/settings.js b/app/portainer/models/settings.js index cbf0c958a..6da0e8883 100644 --- a/app/portainer/models/settings.js +++ b/app/portainer/models/settings.js @@ -16,6 +16,16 @@ function SettingsViewModel(data) { this.EnableHostManagementFeatures = data.EnableHostManagementFeatures; } +function PublicSettingsViewModel(settings) { + this.AllowBindMountsForRegularUsers = settings.AllowBindMountsForRegularUsers; + this.AllowPrivilegedModeForRegularUsers = settings.AllowPrivilegedModeForRegularUsers; + this.AuthenticationMethod = settings.AuthenticationMethod; + this.EnableHostManagementFeatures = settings.EnableHostManagementFeatures; + this.ExternalTemplates = settings.ExternalTemplates; + this.LogoURL = settings.LogoURL; + this.OAuthLoginURI = settings.OAuthLoginURI; +} + function LDAPSettingsViewModel(data) { this.ReaderDN = data.ReaderDN; this.Password = data.Password; diff --git a/app/portainer/services/api/settingsService.js b/app/portainer/services/api/settingsService.js index a725ce09c..c228e47bf 100644 --- a/app/portainer/services/api/settingsService.js +++ b/app/portainer/services/api/settingsService.js @@ -27,7 +27,7 @@ angular.module('portainer.app') Settings.publicSettings().$promise .then(function success(data) { - var settings = new SettingsViewModel(data); + var settings = new PublicSettingsViewModel(data); deferred.resolve(settings); }) .catch(function error(err) { From 3f44925d7e438ef42e3cdb7bc8cad3bd1ee545a7 Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Wed, 16 Jan 2019 17:37:50 +0200 Subject: [PATCH 21/61] fix(auth): fix typo - missing function --- app/portainer/views/auth/authController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/portainer/views/auth/authController.js b/app/portainer/views/auth/authController.js index 30ca6b42c..fb8968e48 100644 --- a/app/portainer/views/auth/authController.js +++ b/app/portainer/views/auth/authController.js @@ -111,7 +111,7 @@ function (urlHelper, $q, $scope, $state, $stateParams, $sanitize, Authentication } function oAuthLogin(code) { - return Authentication.oAuthLogin(code) + return Authentication.OAuthLogin(code) .then(function success() { urlHelper.cleanParameters(); $state.go('portainer.home'); From b121f975fabde5c1b0576103605d6504c0ad7b2f Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Wed, 16 Jan 2019 17:38:07 +0200 Subject: [PATCH 22/61] refactor(settings): remove duplicate settings --- app/portainer/models/settings.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/portainer/models/settings.js b/app/portainer/models/settings.js index 6da0e8883..930ae4da5 100644 --- a/app/portainer/models/settings.js +++ b/app/portainer/models/settings.js @@ -3,11 +3,7 @@ function SettingsViewModel(data) { this.BlackListedLabels = data.BlackListedLabels; this.AuthenticationMethod = data.AuthenticationMethod; this.LDAPSettings = data.LDAPSettings; - this.OAuthSettings = data.OAuthSettings; - this.ClientID = data.ClientID; - this.RedirectURI = data.RedirectURI; - this.Scopes = data.Scopes; - this.AuthorizationURI = data.AuthorizationURI; + this.OAuthSettings = new OAuthSettingsViewModel(data.OAuthSettings); this.AllowBindMountsForRegularUsers = data.AllowBindMountsForRegularUsers; this.AllowPrivilegedModeForRegularUsers = data.AllowPrivilegedModeForRegularUsers; this.SnapshotInterval = data.SnapshotInterval; From dc067b3308e8469f2bd7a98180c5f40fe788d4f4 Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Wed, 16 Jan 2019 17:41:56 +0200 Subject: [PATCH 23/61] refactor(http): remove old oauth handler --- api/http/handler/handler.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index f818edf4c..9cb3059df 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -60,8 +60,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch { case strings.HasPrefix(r.URL.Path, "/api/auth"): http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r) - case strings.HasPrefix(r.URL.Path, "/api/oauth"): - http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/dockerhub"): http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/endpoint_groups"): From b09f491f6206fcd34b03387b948ae1c9436c6f73 Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Wed, 16 Jan 2019 17:53:10 +0200 Subject: [PATCH 24/61] style(auth): remove comments and change error --- api/http/handler/settings/settings_update.go | 2 +- api/oauth/oauth.go | 2 +- .../components/oauth-settings/oauth-settings-controller.js | 0 .../oauth/components/oauth-settings/oauth-settings.js | 1 - 4 files changed, 2 insertions(+), 3 deletions(-) delete mode 100644 app/extensions/oauth/components/oauth-settings/oauth-settings-controller.js diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 919d8a075..b4ddb9e96 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -26,7 +26,7 @@ type settingsUpdatePayload struct { func (payload *settingsUpdatePayload) Validate(r *http.Request) error { if *payload.AuthenticationMethod != 1 && *payload.AuthenticationMethod != 2 && *payload.AuthenticationMethod != 3 { - return portainer.Error("Invalid authentication method value. Value must be one of: 1 (internal) or 2 (LDAP/AD)") + return portainer.Error("Invalid authentication method value. Value must be one of: 1 (internal), 2 (LDAP/AD) or 3 (OAuth)") } if payload.LogoURL != nil && *payload.LogoURL != "" && !govalidator.IsURL(*payload.LogoURL) { return portainer.Error("Invalid logo URL. Must correspond to a valid URL format") diff --git a/api/oauth/oauth.go b/api/oauth/oauth.go index d2ceffa55..bafd99443 100644 --- a/api/oauth/oauth.go +++ b/api/oauth/oauth.go @@ -18,7 +18,7 @@ import ( const ( // ErrInvalidCode defines an error raised when the user authorization code is invalid - ErrInvalidCode = portainer.Error("Authorization code is invalid") + ErrInvalidCode = portainer.Error("Invalid OAuth authorization code") ) // Service represents a service used to authenticate users against an authorization server diff --git a/app/extensions/oauth/components/oauth-settings/oauth-settings-controller.js b/app/extensions/oauth/components/oauth-settings/oauth-settings-controller.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/extensions/oauth/components/oauth-settings/oauth-settings.js b/app/extensions/oauth/components/oauth-settings/oauth-settings.js index 9301a0e12..d5031d187 100644 --- a/app/extensions/oauth/components/oauth-settings/oauth-settings.js +++ b/app/extensions/oauth/components/oauth-settings/oauth-settings.js @@ -3,5 +3,4 @@ angular.module('portainer.extensions.oauth').component('oauthSettings', { bindings: { settings: '<' } - // controller: 'oauthSettingsController' }); From 0d4e1d00f06b95bec340ea7c87d673481578f553 Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Wed, 16 Jan 2019 18:00:01 +0200 Subject: [PATCH 25/61] refactor(login): move oauth button to right --- app/portainer/views/auth/auth.html | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/portainer/views/auth/auth.html b/app/portainer/views/auth/auth.html index 05024059f..62c26d8b6 100644 --- a/app/portainer/views/auth/auth.html +++ b/app/portainer/views/auth/auth.html @@ -27,10 +27,16 @@
-
-
OAuth Login
+
+ +
+ Login with OAuth +
+
+ - + + {{ state.AuthenticationError }} From 0a439b3893808ee603d478762ff89b9b2b94b87b Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Wed, 16 Jan 2019 18:57:15 +0200 Subject: [PATCH 26/61] refactor(auth): extract oauth login mechanism to service --- .../oauth/services/oauth-service.js | 53 +++++++++++++++++++ app/portainer/helpers/urlHelper.js | 8 +-- app/portainer/services/authentication.js | 14 ++--- app/portainer/views/auth/auth.html | 8 ++- app/portainer/views/auth/authController.js | 32 ++++++----- 5 files changed, 83 insertions(+), 32 deletions(-) create mode 100644 app/extensions/oauth/services/oauth-service.js diff --git a/app/extensions/oauth/services/oauth-service.js b/app/extensions/oauth/services/oauth-service.js new file mode 100644 index 000000000..d1985c693 --- /dev/null +++ b/app/extensions/oauth/services/oauth-service.js @@ -0,0 +1,53 @@ +angular.module('portainer.extensions.oauth').service('OAuthService', [ + 'SettingsService', 'OAuth', 'urlHelper', + function OAuthService(SettingsService, OAuth, urlHelper) { + this.login = login; + + function login() { + return getLoginURI() + .then(function openPopup(loginUrl) { + var popup = window.open(loginUrl, 'login-popup', 'width=800, height=600'); + if (!popup) { + throw new Error('Please enable popups for this page'); + } + return waitForCode(popup); + }) + .then(function onCodeReady(code) { + return OAuth.login({ code: code }).$promise; + }); + } + + function getLoginURI() { + return SettingsService.publicSettings().then(function onLoadSettings(settings) { + if (settings.AuthenticationMethod !== 3) { + throw new Error('OAuth is disabled'); + } + return settings.OAuthLoginURI; + }); + } + + function waitForCode(popup) { + return waitFor(function checkIfCodeIsAvailable() { + if (popup.document.URL.indexOf('code') !== -1) { + var queryParams = popup.location.search; + popup.close(); + return urlHelper.getParameter(queryParams, 'code'); + } + }); + } + + function waitFor(clbk, interval) { + interval = interval || 100; + var intervalId; + return new Promise(function executor(resolve) { + intervalId = setInterval(function intervalFunction() { + var callbackReturn = clbk(); + if (callbackReturn) { + clearInterval(intervalId); + resolve(callbackReturn); + } + }, interval); + }); + } + } +]); diff --git a/app/portainer/helpers/urlHelper.js b/app/portainer/helpers/urlHelper.js index 3125492a5..daacf0131 100644 --- a/app/portainer/helpers/urlHelper.js +++ b/app/portainer/helpers/urlHelper.js @@ -2,13 +2,13 @@ angular.module('portainer.app').service('urlHelper', function urlHelper($window) this.getParameter = getParameter; this.cleanParameters = cleanParameters; - function getParameter(param) { - var parameters = extractParameters(); + function getParameter(queryParams, param) { + var parameters = extractParameters(queryParams); return parameters[param]; } - function extractParameters() { - var queryString = $window.location.search.replace(/.*?\?/,'').split('&'); + function extractParameters(queryParams) { + var queryString = queryParams.replace(/.*?\?/,'').split('&'); return queryString.reduce(function(acc, keyValStr) { var keyVal = keyValStr.split('='); var key = keyVal[0]; diff --git a/app/portainer/services/authentication.js b/app/portainer/services/authentication.js index 39b7ada24..ac03e1fde 100644 --- a/app/portainer/services/authentication.js +++ b/app/portainer/services/authentication.js @@ -1,7 +1,7 @@ angular.module('portainer.app') .factory('Authentication', [ -'Auth', 'OAuth', 'jwtHelper', 'LocalStorage', 'StateManager', 'EndpointProvider', -function AuthenticationFactory(Auth, OAuth, jwtHelper, LocalStorage, StateManager, EndpointProvider) { +'Auth', 'OAuthService', 'jwtHelper', 'LocalStorage', 'StateManager', 'EndpointProvider', +function AuthenticationFactory(Auth, OAuthService, jwtHelper, LocalStorage, StateManager, EndpointProvider) { 'use strict'; var service = {}; @@ -13,6 +13,8 @@ function AuthenticationFactory(Auth, OAuth, jwtHelper, LocalStorage, StateManage service.logout = logout; service.isAuthenticated = isAuthenticated; service.getUserDetails = getUserDetails; + + function init() { var jwt = LocalStorage.getJWT(); @@ -22,10 +24,10 @@ function AuthenticationFactory(Auth, OAuth, jwtHelper, LocalStorage, StateManage } } - function OAuthLogin(code) { - return OAuth.login({ code: code }).$promise - .then(function onLoginSuccess(response) { - return setUser(response.jwt); + function OAuthLogin() { + return OAuthService.login() + .then(function onLoginSuccess(loginResponse) { + setUser(loginResponse.jwt); }); } diff --git a/app/portainer/views/auth/auth.html b/app/portainer/views/auth/auth.html index 62c26d8b6..e32b7633a 100644 --- a/app/portainer/views/auth/auth.html +++ b/app/portainer/views/auth/auth.html @@ -28,11 +28,9 @@
- -
- Login with OAuth -
-
+ diff --git a/app/portainer/views/auth/authController.js b/app/portainer/views/auth/authController.js index fb8968e48..575ecdc6a 100644 --- a/app/portainer/views/auth/authController.js +++ b/app/portainer/views/auth/authController.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.controller('AuthenticationController', ['urlHelper','$q', '$scope', '$state', '$stateParams', '$sanitize', 'Authentication', 'UserService', 'EndpointService', 'StateManager', 'Notifications', 'SettingsService', -function (urlHelper, $q, $scope, $state, $stateParams, $sanitize, Authentication, UserService, EndpointService, StateManager, Notifications, SettingsService) { +.controller('AuthenticationController', ['$q', '$scope', '$state', '$stateParams', '$sanitize', 'Authentication', 'UserService', 'EndpointService', 'StateManager', 'Notifications', 'SettingsService', +function ($q, $scope, $state, $stateParams, $sanitize, Authentication, UserService, EndpointService, StateManager, Notifications, SettingsService) { $scope.logo = StateManager.getState().application.logo; $scope.formValues = { @@ -37,6 +37,17 @@ function (urlHelper, $q, $scope, $state, $stateParams, $sanitize, Authentication }); }; + $scope.oauthLogin = function oauthLogin() { + return Authentication.OAuthLogin() + .then(function onLoginSuccess() { + return $state.go('portainer.home'); + }) + .catch(function onError(error) { + $scope.state.AuthenticationError = error.message; + }); + }; + + function unauthenticatedFlow() { EndpointService.endpoints() .then(function success(endpoints) { @@ -104,23 +115,10 @@ function (urlHelper, $q, $scope, $state, $stateParams, $sanitize, Authentication authenticatedFlow(); } - var code = urlHelper.getParameter('code'); - if (code) { - oAuthLogin(code); - } - } - - function oAuthLogin(code) { - return Authentication.OAuthLogin(code) - .then(function success() { - urlHelper.cleanParameters(); - $state.go('portainer.home'); - }) - .catch(function error() { - $scope.state.AuthenticationError = 'Failed to authenticate with OAuth2 Provider'; - }); + } + initView(); }]); From c28274667d643d3a34a0f01dc78fc5f993d85e27 Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Fri, 18 Jan 2019 10:13:33 +0200 Subject: [PATCH 27/61] refactor(oauth): use oauth2 to generate login url --- api/http/handler/auth/authenticate.go | 11 ----- api/http/handler/auth/authenticate_oauth.go | 43 +++++++++++++++++++ api/http/handler/auth/handler.go | 4 +- .../oauth/services/oauth-service.js | 32 +++++--------- app/extensions/oauth/services/rest/oauth.js | 10 +++-- 5 files changed, 63 insertions(+), 37 deletions(-) diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go index 8183c2f7b..d5562edd3 100644 --- a/api/http/handler/auth/authenticate.go +++ b/api/http/handler/auth/authenticate.go @@ -17,10 +17,6 @@ type authenticatePayload struct { Password string } -type oauthPayload struct { - Code string -} - type authenticateResponse struct { JWT string `json:"jwt"` } @@ -35,13 +31,6 @@ func (payload *authenticatePayload) Validate(r *http.Request) error { return nil } -func (payload *oauthPayload) Validate(r *http.Request) error { - if govalidator.IsNull(payload.Code) { - return portainer.Error("Invalid OAuth authorization code") - } - return nil -} - func (handler *Handler) authenticate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { if handler.authDisabled { return &httperror.HandlerError{http.StatusServiceUnavailable, "Cannot authenticate user. Portainer was started with the --no-auth flag", ErrAuthDisabled} diff --git a/api/http/handler/auth/authenticate_oauth.go b/api/http/handler/auth/authenticate_oauth.go index 58319b9df..2e311c8b8 100644 --- a/api/http/handler/auth/authenticate_oauth.go +++ b/api/http/handler/auth/authenticate_oauth.go @@ -3,12 +3,27 @@ package auth import ( "log" "net/http" + "strings" + "golang.org/x/oauth2" + + "github.com/asaskevich/govalidator" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/portainer" ) +type oauthPayload struct { + Code string +} + +func (payload *oauthPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Code) { + return portainer.Error("Invalid OAuth authorization code") + } + return nil +} + func (handler *Handler) authenticateOAuth(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { var payload oauthPayload err := request.DecodeAndValidateJSONPayload(r, &payload) @@ -62,3 +77,31 @@ func (handler *Handler) authenticateOAuth(w http.ResponseWriter, r *http.Request return handler.writeToken(w, u) } + +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} + } + + endpoint := oauth2.Endpoint{ + AuthURL: settings.OAuthSettings.AuthorizationURI, + TokenURL: settings.OAuthSettings.AccessTokenURI, + } + + oauthConfig := &oauth2.Config{ + ClientID: settings.OAuthSettings.ClientID, + ClientSecret: settings.OAuthSettings.ClientSecret, + Endpoint: endpoint, + RedirectURL: settings.OAuthSettings.RedirectURI, + Scopes: strings.Split(settings.OAuthSettings.Scopes, ","), + } + + url := oauthConfig.AuthCodeURL("portainer") + 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 a97928150..4bc440a63 100644 --- a/api/http/handler/auth/handler.go +++ b/api/http/handler/auth/handler.go @@ -38,7 +38,9 @@ func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimi authDisabled: authDisabled, } - h.Handle("/auth/oauth", + 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.authenticateOAuth)))).Methods(http.MethodPost) h.Handle("/auth", rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticate)))).Methods(http.MethodPost) diff --git a/app/extensions/oauth/services/oauth-service.js b/app/extensions/oauth/services/oauth-service.js index d1985c693..88221dd5b 100644 --- a/app/extensions/oauth/services/oauth-service.js +++ b/app/extensions/oauth/services/oauth-service.js @@ -1,28 +1,16 @@ angular.module('portainer.extensions.oauth').service('OAuthService', [ - 'SettingsService', 'OAuth', 'urlHelper', - function OAuthService(SettingsService, OAuth, urlHelper) { + 'API_ENDPOINT_OAUTH', 'OAuth', 'urlHelper', 'Notifications', + function OAuthService(API_ENDPOINT_OAUTH, OAuth, urlHelper, Notifications) { this.login = login; function login() { - return getLoginURI() - .then(function openPopup(loginUrl) { - var popup = window.open(loginUrl, 'login-popup', 'width=800, height=600'); - if (!popup) { - throw new Error('Please enable popups for this page'); - } - return waitForCode(popup); - }) - .then(function onCodeReady(code) { - return OAuth.login({ code: code }).$promise; - }); - } - - function getLoginURI() { - return SettingsService.publicSettings().then(function onLoadSettings(settings) { - if (settings.AuthenticationMethod !== 3) { - throw new Error('OAuth is disabled'); - } - return settings.OAuthLoginURI; + var loginUrl = API_ENDPOINT_OAUTH + '/login'; + var popup = window.open(loginUrl, 'login-popup', 'width=800, height=600'); + if (!popup) { + Notifications.warn('Please enable popups for this page'); + } + return waitForCode(popup).then(function onCodeReady(code) { + return OAuth.validate({ code: code }).$promise; }); } @@ -49,5 +37,5 @@ angular.module('portainer.extensions.oauth').service('OAuthService', [ }, interval); }); } - } + }, ]); diff --git a/app/extensions/oauth/services/rest/oauth.js b/app/extensions/oauth/services/rest/oauth.js index a6eff3bf7..f33e7b30f 100644 --- a/app/extensions/oauth/services/rest/oauth.js +++ b/app/extensions/oauth/services/rest/oauth.js @@ -1,9 +1,13 @@ angular.module('portainer.extensions.oauth') .factory('OAuth', ['$resource', 'API_ENDPOINT_OAUTH', function OAuthFactory($resource, API_ENDPOINT_OAUTH) { 'use strict'; - return $resource(API_ENDPOINT_OAUTH, {}, { - login: { - method: 'POST', ignoreLoadingBar: true + return $resource(API_ENDPOINT_OAUTH + '/:action', {}, { + validate: { + method: 'POST', + ignoreLoadingBar: true, + params: { + action: 'validate' + } } }); }]); \ No newline at end of file From c5c06b307af5884dcb7950b5e18c7c724edf1385 Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Fri, 18 Jan 2019 10:15:02 +0200 Subject: [PATCH 28/61] refactor(oauth): rename authenticate function --- api/http/handler/auth/authenticate_oauth.go | 2 +- api/http/handler/auth/handler.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api/http/handler/auth/authenticate_oauth.go b/api/http/handler/auth/authenticate_oauth.go index 2e311c8b8..8e7542cb2 100644 --- a/api/http/handler/auth/authenticate_oauth.go +++ b/api/http/handler/auth/authenticate_oauth.go @@ -24,7 +24,7 @@ func (payload *oauthPayload) Validate(r *http.Request) error { return nil } -func (handler *Handler) authenticateOAuth(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { +func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { var payload oauthPayload err := request.DecodeAndValidateJSONPayload(r, &payload) if err != nil { diff --git a/api/http/handler/auth/handler.go b/api/http/handler/auth/handler.go index 4bc440a63..50e956e76 100644 --- a/api/http/handler/auth/handler.go +++ b/api/http/handler/auth/handler.go @@ -41,7 +41,7 @@ func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimi 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.authenticateOAuth)))).Methods(http.MethodPost) + rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.validateOAuth)))).Methods(http.MethodPost) h.Handle("/auth", rateLimiter.LimitAccess(bouncer.PublicAccess(httperror.LoggerHandler(h.authenticate)))).Methods(http.MethodPost) From 60040e90d01a61a73ecc20b2c53c9e1a980466a9 Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Fri, 18 Jan 2019 10:24:42 +0200 Subject: [PATCH 29/61] refactor(oauth): move build url logic to service --- api/http/handler/auth/authenticate_oauth.go | 18 +----------------- api/oauth/oauth.go | 18 ++++++++++++++++++ api/portainer.go | 1 + 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/api/http/handler/auth/authenticate_oauth.go b/api/http/handler/auth/authenticate_oauth.go index 8e7542cb2..43dcdc33c 100644 --- a/api/http/handler/auth/authenticate_oauth.go +++ b/api/http/handler/auth/authenticate_oauth.go @@ -3,9 +3,6 @@ package auth import ( "log" "net/http" - "strings" - - "golang.org/x/oauth2" "github.com/asaskevich/govalidator" httperror "github.com/portainer/libhttp/error" @@ -88,20 +85,7 @@ func (handler *Handler) loginOAuth(w http.ResponseWriter, r *http.Request) *http return &httperror.HandlerError{http.StatusForbidden, "OAuth authentication is disabled", err} } - endpoint := oauth2.Endpoint{ - AuthURL: settings.OAuthSettings.AuthorizationURI, - TokenURL: settings.OAuthSettings.AccessTokenURI, - } - - oauthConfig := &oauth2.Config{ - ClientID: settings.OAuthSettings.ClientID, - ClientSecret: settings.OAuthSettings.ClientSecret, - Endpoint: endpoint, - RedirectURL: settings.OAuthSettings.RedirectURI, - Scopes: strings.Split(settings.OAuthSettings.Scopes, ","), - } - - url := oauthConfig.AuthCodeURL("portainer") + url := handler.OAuthService.BuildLoginURL(settings.OAuthSettings) http.Redirect(w, r, url, http.StatusTemporaryRedirect) return nil } diff --git a/api/oauth/oauth.go b/api/oauth/oauth.go index bafd99443..87439a636 100644 --- a/api/oauth/oauth.go +++ b/api/oauth/oauth.go @@ -165,3 +165,21 @@ func (*Service) GetUsername(token string, settings *portainer.OAuthSettings) (st Body: body, } } + +// BuildLoginURL creates a login url for the oauth provider +func (*Service) BuildLoginURL(oauthSettings portainer.OAuthSettings) string { + endpoint := oauth2.Endpoint{ + AuthURL: oauthSettings.AuthorizationURI, + TokenURL: oauthSettings.AccessTokenURI, + } + + oauthConfig := &oauth2.Config{ + ClientID: oauthSettings.ClientID, + ClientSecret: oauthSettings.ClientSecret, + Endpoint: endpoint, + RedirectURL: oauthSettings.RedirectURI, + Scopes: strings.Split(oauthSettings.Scopes, ","), + } + + return oauthConfig.AuthCodeURL("portainer") +} diff --git a/api/portainer.go b/api/portainer.go index fe099769e..b5682c344 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -766,6 +766,7 @@ type ( 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 From 46e8f10aead6e12988219da488d3f6567888da21 Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Fri, 18 Jan 2019 10:56:16 +0200 Subject: [PATCH 30/61] refactor(ouath): use oauth2 library to get token --- api/http/handler/auth/authenticate_oauth.go | 13 ++- api/oauth/oauth.go | 96 +++------------------ api/portainer.go | 2 +- 3 files changed, 18 insertions(+), 93 deletions(-) diff --git a/api/http/handler/auth/authenticate_oauth.go b/api/http/handler/auth/authenticate_oauth.go index 43dcdc33c..d8a446d3e 100644 --- a/api/http/handler/auth/authenticate_oauth.go +++ b/api/http/handler/auth/authenticate_oauth.go @@ -49,17 +49,17 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h return &httperror.HandlerError{http.StatusForbidden, "Unable to acquire username", portainer.ErrUnauthorized} } - u, err := handler.UserService.UserByUsername(username) + user, err := handler.UserService.UserByUsername(username) if err != nil && err != portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", err} } - if u == nil && !settings.OAuthSettings.OAuthAutoCreateUsers { + if user == nil && !settings.OAuthSettings.OAuthAutoCreateUsers { return &httperror.HandlerError{http.StatusForbidden, "Unregistered account", portainer.ErrUnauthorized} } - if u == nil { - user := &portainer.User{ + if user == nil { + user = &portainer.User{ Username: username, Role: portainer.StandardUserRole, } @@ -69,10 +69,9 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist user inside the database", err} } - return handler.writeToken(w, user) } - return handler.writeToken(w, u) + return handler.writeToken(w, user) } func (handler *Handler) loginOAuth(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { @@ -85,7 +84,7 @@ func (handler *Handler) loginOAuth(w http.ResponseWriter, r *http.Request) *http return &httperror.HandlerError{http.StatusForbidden, "OAuth authentication is disabled", err} } - url := handler.OAuthService.BuildLoginURL(settings.OAuthSettings) + url := handler.OAuthService.BuildLoginURL(&settings.OAuthSettings) http.Redirect(w, r, url, http.StatusTemporaryRedirect) return nil } diff --git a/api/oauth/oauth.go b/api/oauth/oauth.go index 87439a636..c716ef90a 100644 --- a/api/oauth/oauth.go +++ b/api/oauth/oauth.go @@ -1,12 +1,10 @@ package oauth import ( + "context" "encoding/json" - "errors" "fmt" - "io" "io/ioutil" - "log" "mime" "net/http" "net/url" @@ -26,84 +24,9 @@ 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) { - v := url.Values{} - v.Set("client_id", settings.ClientID) - v.Set("client_secret", settings.ClientSecret) - v.Set("redirect_uri", settings.RedirectURI) - v.Set("code", code) - v.Set("grant_type", "authorization_code") - - req, err := http.NewRequest("POST", settings.AccessTokenURI, strings.NewReader(v.Encode())) - if err != nil { - return "", err - } - - client := &http.Client{} - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - r, err := client.Do(req) - if err != nil { - return "", err - } - - body, err := ioutil.ReadAll(io.LimitReader(r.Body, 1<<20)) - if err != nil { - return "", fmt.Errorf("oauth2: cannot fetch token: %v", err) - } - - if r.StatusCode != http.StatusOK { - type ErrorMessage struct { - Message string - Type string - Code int - } - type ErrorResponse struct { - Error ErrorMessage - } - - var response ErrorResponse - if err = json.Unmarshal(body, &response); err != nil { - // report also error - log.Printf("[Error] - Failed parsing error body: %v", err) - return "", errors.New("oauth2: cannot fetch token") - } - - return "", errors.New(response.Error.Message) - } - - content, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")) - if content == "application/x-www-form-urlencoded" || content == "text/plain" { - values, err := url.ParseQuery(string(body)) - if err != nil { - return "", err - } - - token := values.Get("access_token") - log.Printf("[DEBUG] - returned body %v", values) - - if token == "" { - log.Printf("[DEBUG] - access token returned empty - %v", values) - return "", errors.New("oauth2: cannot fetch token") - } - - return token, nil - } - - type tokenJSON struct { - AccessToken string `json:"access_token"` - } - - var tj tokenJSON - if err = json.Unmarshal(body, &tj); err != nil { - return "", err - } - - token := tj.AccessToken - - if token == "" { - log.Printf("[DEBUG] - access token returned empty - %v with status code", string(body), r.StatusCode) - return "", errors.New("oauth2: cannot fetch token") - } - return token, nil + config := buildConfig(settings) + token, err := config.Exchange(context.Background(), code) + return token.AccessToken, err } // GetUsername takes a token and retrieves the portainer OAuthSettings user identifier from resource server. @@ -167,19 +90,22 @@ func (*Service) GetUsername(token string, settings *portainer.OAuthSettings) (st } // BuildLoginURL creates a login url for the oauth provider -func (*Service) BuildLoginURL(oauthSettings portainer.OAuthSettings) string { +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, } - oauthConfig := &oauth2.Config{ + return &oauth2.Config{ ClientID: oauthSettings.ClientID, ClientSecret: oauthSettings.ClientSecret, Endpoint: endpoint, RedirectURL: oauthSettings.RedirectURI, Scopes: strings.Split(oauthSettings.Scopes, ","), } - - return oauthConfig.AuthCodeURL("portainer") } diff --git a/api/portainer.go b/api/portainer.go index b5682c344..63f886f0f 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -766,7 +766,7 @@ type ( OAuthService interface { GetAccessToken(code string, settings *OAuthSettings) (string, error) GetUsername(token string, settings *OAuthSettings) (string, error) - BuildLoginURL(oauthSettings OAuthSettings) string + BuildLoginURL(oauthSettings *OAuthSettings) string } // SwarmStackManager represents a service to manage Swarm stacks From de5f6086d067034c938c272f70d11fd90cfe4fc5 Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Fri, 18 Jan 2019 11:51:41 +0200 Subject: [PATCH 31/61] refactor(oauth): return parse content error --- api/oauth/oauth.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/oauth/oauth.go b/api/oauth/oauth.go index c716ef90a..12d0d440c 100644 --- a/api/oauth/oauth.go +++ b/api/oauth/oauth.go @@ -56,7 +56,11 @@ func (*Service) GetUsername(token string, settings *portainer.OAuthSettings) (st } } - content, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")) + 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 { From 193e7eb3f84ca62ebdeded3bbd29f5d52450c029 Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Fri, 18 Jan 2019 11:53:44 +0200 Subject: [PATCH 32/61] refactor(oauth): remove separation of strings --- api/oauth/oauth.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/oauth/oauth.go b/api/oauth/oauth.go index 12d0d440c..3b3d210bc 100644 --- a/api/oauth/oauth.go +++ b/api/oauth/oauth.go @@ -8,7 +8,6 @@ import ( "mime" "net/http" "net/url" - "strings" "github.com/portainer/portainer" "golang.org/x/oauth2" @@ -110,6 +109,7 @@ func buildConfig(oauthSettings *portainer.OAuthSettings) *oauth2.Config { ClientSecret: oauthSettings.ClientSecret, Endpoint: endpoint, RedirectURL: oauthSettings.RedirectURI, - Scopes: strings.Split(oauthSettings.Scopes, ","), + // TODO figure out how to handle different providers, see https://github.com/golang/oauth2/issues/119 + Scopes: []string{oauthSettings.Scopes}, } } From 69252a8377bad49368767109218c5ec242b55906 Mon Sep 17 00:00:00 2001 From: Chaim Lev Ari Date: Fri, 18 Jan 2019 12:08:18 +0200 Subject: [PATCH 33/61] refactour(auth): move information body to each setting --- .../settingsAuthentication.html | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/app/portainer/views/settings/authentication/settingsAuthentication.html b/app/portainer/views/settings/authentication/settingsAuthentication.html index 8a1ac2e19..d7427269c 100644 --- a/app/portainer/views/settings/authentication/settingsAuthentication.html +++ b/app/portainer/views/settings/authentication/settingsAuthentication.html @@ -49,26 +49,31 @@
-
- Information -
-
- + +
+
+ Information +
+
When using internal authentication, Portainer will encrypt user passwords and store credentials locally. - -
-
- - When using LDAP authentication, Portainer will delegate user authentication to a LDAP server and fallback to internal authentication if LDAP authentication fails. - +
+
+
+ Information +
+
+ When using LDAP authentication, Portainer will delegate user authentication to a LDAP server and fallback to internal authentication if LDAP authentication fails. +
+
+
LDAP configuration
- +
diff --git a/app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.js b/app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.js index 9efd413b1..fa1cdb1e1 100644 --- a/app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.js +++ b/app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.js @@ -1,4 +1,20 @@ -angular.module('portainer.extensions.oauth') - .component('oauthProvidersSelector', { - templateUrl: 'app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.html' - }); \ No newline at end of file +angular.module('portainer.extensions.oauth').component('oauthProvidersSelector', { + templateUrl: 'app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.html', + bindings: { + onSelect: '<' + }, + controller: function oauthProvidersSelectorController() { + this.providers = [ + { + name: 'Facebook', + authUrl: 'https://www.facebook.com/v3.2/dialog/oauth', + accessTokenUrl: 'https://graph.facebook.com/v3.2/oauth/access_token', + resourceUrl: 'https://graph.facebook.com/v3.2/me?fields=email', + userIdentifier: 'email' + }, + { + name: 'Custom' + } + ]; + } +}); diff --git a/app/extensions/oauth/components/oauth-settings/oauth-settings-controller.js b/app/extensions/oauth/components/oauth-settings/oauth-settings-controller.js index b91aabc6a..ba324c49b 100644 --- a/app/extensions/oauth/components/oauth-settings/oauth-settings-controller.js +++ b/app/extensions/oauth/components/oauth-settings/oauth-settings-controller.js @@ -1,6 +1,11 @@ angular.module('portainer.extensions.oauth') - .controller('OAuthSettingsController', function OAuthSettingsController() { - this.providers = [ - 'facebook' - ] - }); \ No newline at end of file +.controller('OAuthSettingsController', function OAuthSettingsController() { + this.onSelectProvider = onSelectProvider; + + function onSelectProvider(provider) { + this.settings.AuthorizationURI = provider.authUrl; + this.settings.AccessTokenURI = provider.accessTokenUrl; + this.settings.ResourceURI = provider.resourceUrl; + this.settings.UserIdentifier = provider.userIdentifier; + } +}); diff --git a/app/extensions/oauth/components/oauth-settings/oauth-settings.html b/app/extensions/oauth/components/oauth-settings/oauth-settings.html index f3307321d..e2a5217e0 100644 --- a/app/extensions/oauth/components/oauth-settings/oauth-settings.html +++ b/app/extensions/oauth/components/oauth-settings/oauth-settings.html @@ -1,5 +1,5 @@
- +
OAuth Configuration
From 4d8133f696e221c0de6899a7bb1575e179d9717a Mon Sep 17 00:00:00 2001 From: baron_l Date: Thu, 7 Feb 2019 15:07:10 +0100 Subject: [PATCH 39/61] feat(oauth): spinner on code evaluation after sucessfull oauth --- app/portainer/views/auth/auth.html | 9 ++++++++- app/portainer/views/auth/authController.js | 7 ++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/portainer/views/auth/auth.html b/app/portainer/views/auth/auth.html index 62c26d8b6..9c0ecfc2e 100644 --- a/app/portainer/views/auth/auth.html +++ b/app/portainer/views/auth/auth.html @@ -9,7 +9,7 @@
-
+
@@ -49,6 +49,13 @@
+
+
+
+ Connecting with OAuth +
+
+
diff --git a/app/portainer/views/auth/authController.js b/app/portainer/views/auth/authController.js index 8f281a170..43aaebb08 100644 --- a/app/portainer/views/auth/authController.js +++ b/app/portainer/views/auth/authController.js @@ -8,7 +8,8 @@ angular.module('portainer.app').controller('AuthenticationController', ['$q', '$ }; $scope.state = { - AuthenticationError: '' + AuthenticationError: '', + isInOAuthProcess: true }; $scope.authenticateUser = function() { @@ -89,6 +90,7 @@ angular.module('portainer.app').controller('AuthenticationController', ['$q', '$ if ($stateParams.logout || $stateParams.error) { Authentication.logout(); $scope.state.AuthenticationError = $stateParams.error; + $scope.state.isInOAuthProcess = false; return; } @@ -106,6 +108,8 @@ angular.module('portainer.app').controller('AuthenticationController', ['$q', '$ var code = urlHelper.getParameter('code'); if (code) { oAuthLogin(code); + } else { + $scope.state.isInOAuthProcess = false; } } @@ -117,6 +121,7 @@ angular.module('portainer.app').controller('AuthenticationController', ['$q', '$ }) .catch(function error() { $scope.state.AuthenticationError = 'Failed to authenticate with OAuth2 Provider'; + $scope.state.isInOAuthProcess = false; }); } From 2755527d28d4e4d3dd3670c9b59fcf6383bb221a Mon Sep 17 00:00:00 2001 From: baron_l Date: Thu, 7 Feb 2019 19:32:02 +0100 Subject: [PATCH 40/61] feat(oauth): default team for user on oauth settings --- api/portainer.go | 1 + .../oauth-settings/oauth-settings.html | 24 +++++++++++++++++++ .../oauth-settings/oauth-settings.js | 3 ++- app/portainer/models/settings.js | 1 + .../settingsAuthentication.html | 2 +- .../settingsAuthenticationController.js | 13 ++++++---- 6 files changed, 37 insertions(+), 7 deletions(-) diff --git a/api/portainer.go b/api/portainer.go index 6f71dc176..de2511b37 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -67,6 +67,7 @@ type ( UserIdentifier string `json:"UserIdentifier"` Scopes string `json:"Scopes"` OAuthAutoCreateUsers bool `json:"OAuthAutoCreateUsers"` + DefaultTeamID TeamID `json:"DefaultTeamID"` } // TLSConfiguration represents a TLS configuration diff --git a/app/extensions/oauth/components/oauth-settings/oauth-settings.html b/app/extensions/oauth/components/oauth-settings/oauth-settings.html index e2a5217e0..1a51eac59 100644 --- a/app/extensions/oauth/components/oauth-settings/oauth-settings.html +++ b/app/extensions/oauth/components/oauth-settings/oauth-settings.html @@ -149,6 +149,9 @@
+
+ Automatic user provisioning +
With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role. If @@ -163,4 +166,25 @@
+ +
+
+ + The users created by the automatic provisioning feature can be added to a default team on creation. This setting is optional. + +
+
+ +
+ +
+
+ +
+
+
diff --git a/app/extensions/oauth/components/oauth-settings/oauth-settings.js b/app/extensions/oauth/components/oauth-settings/oauth-settings.js index d5031d187..51a9ec553 100644 --- a/app/extensions/oauth/components/oauth-settings/oauth-settings.js +++ b/app/extensions/oauth/components/oauth-settings/oauth-settings.js @@ -1,6 +1,7 @@ angular.module('portainer.extensions.oauth').component('oauthSettings', { templateUrl: 'app/extensions/oauth/components/oauth-settings/oauth-settings.html', bindings: { - settings: '<' + settings: '<', + teams: '<' } }); diff --git a/app/portainer/models/settings.js b/app/portainer/models/settings.js index 930ae4da5..bdb8b8713 100644 --- a/app/portainer/models/settings.js +++ b/app/portainer/models/settings.js @@ -53,4 +53,5 @@ function OAuthSettingsViewModel(data) { this.UserIdentifier = data.UserIdentifier; this.Scopes = data.Scopes; this.OAuthAutoCreateUsers = data.OAuthAutoCreateUsers; + this.DefaultTeamID = data.DefaultTeamID; } \ No newline at end of file diff --git a/app/portainer/views/settings/authentication/settingsAuthentication.html b/app/portainer/views/settings/authentication/settingsAuthentication.html index d7427269c..f47d02032 100644 --- a/app/portainer/views/settings/authentication/settingsAuthentication.html +++ b/app/portainer/views/settings/authentication/settingsAuthentication.html @@ -322,7 +322,7 @@
- +
diff --git a/app/portainer/views/settings/authentication/settingsAuthenticationController.js b/app/portainer/views/settings/authentication/settingsAuthenticationController.js index dc74a3b54..8a22d1783 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', -function ($q, $scope, Notifications, SettingsService, FileUploadService) { +.controller('SettingsAuthenticationController', ['$q', '$scope', 'Notifications', 'SettingsService', 'FileUploadService', 'TeamService', +function ($q, $scope, Notifications, SettingsService, FileUploadService, TeamService) { $scope.state = { successfulConnectivityCheck: false, @@ -96,9 +96,12 @@ function ($q, $scope, Notifications, SettingsService, FileUploadService) { } function initView() { - SettingsService.settings() - .then(function success(data) { - var settings = data; + $q.all({ + settings: SettingsService.settings(), + teams: TeamService.teams() + }).then(function success(data) { + var settings = data.settings; + $scope.teams = data.teams; $scope.settings = settings; $scope.LDAPSettings = settings.LDAPSettings; $scope.OAuthSettings = settings.OAuthSettings; From 8f568c8699f3ae5103614cc65172e4dc0df64498 Mon Sep 17 00:00:00 2001 From: baron_l Date: Fri, 8 Feb 2019 16:07:16 +0100 Subject: [PATCH 41/61] style(oauth): oauth loading + oauth config rework --- .../oauth-settings/oauth-settings.html | 79 ++++++++++--------- app/portainer/views/auth/auth.html | 4 +- 2 files changed, 42 insertions(+), 41 deletions(-) diff --git a/app/extensions/oauth/components/oauth-settings/oauth-settings.html b/app/extensions/oauth/components/oauth-settings/oauth-settings.html index 1a51eac59..dc13135f1 100644 --- a/app/extensions/oauth/components/oauth-settings/oauth-settings.html +++ b/app/extensions/oauth/components/oauth-settings/oauth-settings.html @@ -1,5 +1,45 @@
+
+ Automatic user provisioning +
+
+ + With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role. If + disabled, users must be created in Portainer in order to login. + +
+
+
+ + +
+
+ +
+
+ + The users created by the automatic provisioning feature can be added to a default team on creation. This setting is optional. + +
+
+ + + You have not yet created any team. Head over the teams view to manage user teams. + + +
+ +
+
+
+
OAuth Configuration
@@ -148,43 +188,4 @@ />
- -
- Automatic user provisioning -
-
- - With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role. If - disabled, users must be created in Portainer in order to login. - -
-
-
- - -
-
- -
-
- - The users created by the automatic provisioning feature can be added to a default team on creation. This setting is optional. - -
-
- -
- -
-
- -
-
-
diff --git a/app/portainer/views/auth/auth.html b/app/portainer/views/auth/auth.html index 9c0ecfc2e..8787121ba 100644 --- a/app/portainer/views/auth/auth.html +++ b/app/portainer/views/auth/auth.html @@ -51,8 +51,8 @@
-
- Connecting with OAuth +
+ Connecting with OAuth
From de76ba4e676dc79cadf0842f556745144f008808 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Thu, 14 Feb 2019 15:58:45 +1300 Subject: [PATCH 42/61] feat(oauth): update OAuth UX --- api/http/handler/auth/authenticate_oauth.go | 2 +- api/oauth/oauth.go | 16 +- .../oauth-provider-selector-controller.js | 56 +++++ .../oauth-providers-selector.html | 58 +++-- .../oauth-providers-selector.js | 18 +- .../oauth-settings-controller.js | 45 +++- .../oauth-settings/oauth-settings.html | 206 ++++++++++-------- .../oauth-settings/oauth-settings.js | 5 +- app/portainer/views/auth/authController.js | 2 +- .../settingsAuthentication.html | 9 +- 10 files changed, 278 insertions(+), 139 deletions(-) create mode 100644 app/extensions/oauth/components/oauth-providers-selector/oauth-provider-selector-controller.js diff --git a/api/http/handler/auth/authenticate_oauth.go b/api/http/handler/auth/authenticate_oauth.go index d8a446d3e..89b828383 100644 --- a/api/http/handler/auth/authenticate_oauth.go +++ b/api/http/handler/auth/authenticate_oauth.go @@ -55,7 +55,7 @@ func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *h } if user == nil && !settings.OAuthSettings.OAuthAutoCreateUsers { - return &httperror.HandlerError{http.StatusForbidden, "Unregistered account", portainer.ErrUnauthorized} + return &httperror.HandlerError{http.StatusForbidden, "Account not created beforehand in Portainer and automatic user provisioning not enabled", portainer.ErrUnauthorized} } if user == nil { diff --git a/api/oauth/oauth.go b/api/oauth/oauth.go index 3b3d210bc..b7de6792f 100644 --- a/api/oauth/oauth.go +++ b/api/oauth/oauth.go @@ -23,9 +23,18 @@ 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(), code) - return token.AccessToken, err + 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. @@ -109,7 +118,6 @@ func buildConfig(oauthSettings *portainer.OAuthSettings) *oauth2.Config { ClientSecret: oauthSettings.ClientSecret, Endpoint: endpoint, RedirectURL: oauthSettings.RedirectURI, - // TODO figure out how to handle different providers, see https://github.com/golang/oauth2/issues/119 - Scopes: []string{oauthSettings.Scopes}, + Scopes: []string{oauthSettings.Scopes}, } } diff --git a/app/extensions/oauth/components/oauth-providers-selector/oauth-provider-selector-controller.js b/app/extensions/oauth/components/oauth-providers-selector/oauth-provider-selector-controller.js new file mode 100644 index 000000000..2f5be632a --- /dev/null +++ b/app/extensions/oauth/components/oauth-providers-selector/oauth-provider-selector-controller.js @@ -0,0 +1,56 @@ +angular.module('portainer.extensions.oauth') + .controller('OAuthProviderSelectorController', function OAuthProviderSelectorController() { + var ctrl = this; + + this.providers = [ + { + userIdentifier: 'mail', + scope: 'id,email,name', + name: 'microsoft' + }, + { + authUrl: 'https://accounts.google.com/o/oauth2/auth', + accessTokenUrl: 'https://accounts.google.com/o/oauth2/token', + resourceUrl: 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json', + userIdentifier: 'email', + scopes: 'profile email', + name: 'google' + }, + { + authUrl: 'https://github.com/login/oauth/authorize', + accessTokenUrl: 'https://github.com/login/oauth/access_token', + resourceUrl: 'https://api.github.com/user', + userIdentifier: 'login', + scopes: 'id email name', + name: 'github' + }, + { + name: 'custom' + } + ]; + + this.$onInit = onInit; + + function onInit() { + console.log(ctrl.provider.authUrl); + if (ctrl.provider.authUrl) { + ctrl.provider = getProviderByURL(ctrl.provider.authUrl); + } else { + ctrl.provider = ctrl.providers[0]; + } + ctrl.onSelect(ctrl.provider); + } + + function getProviderByURL(providerAuthURL) { + if (providerAuthURL.indexOf('login.microsoftonline.com') !== -1) { + return ctrl.providers[0]; + } + else if (providerAuthURL.indexOf('accounts.google.com') !== -1) { + return ctrl.providers[1]; + } + else if (providerAuthURL.indexOf('github.com') !== -1) { + return ctrl.providers[2]; + } + return ctrl.provider[3]; + } + }); 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 index f451768fe..8422f0900 100644 --- a/app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.html +++ b/app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.html @@ -1,17 +1,49 @@
- Provider -
+ Provider +
- -
-
- +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
diff --git a/app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.js b/app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.js index fa1cdb1e1..1376671fe 100644 --- a/app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.js +++ b/app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.js @@ -1,20 +1,8 @@ angular.module('portainer.extensions.oauth').component('oauthProvidersSelector', { templateUrl: 'app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.html', bindings: { - onSelect: '<' + onSelect: '<', + provider: '=' }, - controller: function oauthProvidersSelectorController() { - this.providers = [ - { - name: 'Facebook', - authUrl: 'https://www.facebook.com/v3.2/dialog/oauth', - accessTokenUrl: 'https://graph.facebook.com/v3.2/oauth/access_token', - resourceUrl: 'https://graph.facebook.com/v3.2/me?fields=email', - userIdentifier: 'email' - }, - { - name: 'Custom' - } - ]; - } + controller: 'OAuthProviderSelectorController' }); diff --git a/app/extensions/oauth/components/oauth-settings/oauth-settings-controller.js b/app/extensions/oauth/components/oauth-settings/oauth-settings-controller.js index ba324c49b..58690b511 100644 --- a/app/extensions/oauth/components/oauth-settings/oauth-settings-controller.js +++ b/app/extensions/oauth/components/oauth-settings/oauth-settings-controller.js @@ -1,11 +1,38 @@ angular.module('portainer.extensions.oauth') -.controller('OAuthSettingsController', function OAuthSettingsController() { - this.onSelectProvider = onSelectProvider; + .controller('OAuthSettingsController', function OAuthSettingsController() { + var ctrl = this; - function onSelectProvider(provider) { - this.settings.AuthorizationURI = provider.authUrl; - this.settings.AccessTokenURI = provider.accessTokenUrl; - this.settings.ResourceURI = provider.resourceUrl; - this.settings.UserIdentifier = provider.userIdentifier; - } -}); + this.state = { + provider: {}, + overrideConfiguration: false, + microsoftTenantID: '' + }; + + this.$onInit = onInit; + this.onSelectProvider = onSelectProvider; + this.onMicrosoftTenantIDChange = onMicrosoftTenantIDChange; + + function onMicrosoftTenantIDChange() { + var tenantID = ctrl.state.microsoftTenantID; + + ctrl.settings.AuthorizationURI = _.replace('https://login.microsoftonline.com/TENANT_ID/oauth2/authorize', 'TENANT_ID', tenantID); + ctrl.settings.AccessTokenURI = _.replace('https://login.microsoftonline.com/TENANT_ID/oauth2/token', 'TENANT_ID', tenantID); + ctrl.settings.ResourceURI = _.replace('https://graph.windows.net/TENANT_ID/me?api-version=2013-11-08', 'TENANT_ID', tenantID); + } + + function onSelectProvider(provider) { + ctrl.state.provider = provider; + ctrl.settings.AuthorizationURI = provider.authUrl; + ctrl.settings.AccessTokenURI = provider.accessTokenUrl; + ctrl.settings.ResourceURI = provider.resourceUrl; + ctrl.settings.UserIdentifier = provider.userIdentifier; + ctrl.settings.Scopes = provider.scopes; + } + + function onInit() { + if (ctrl.settings.RedirectURI === '') { + ctrl.settings.RedirectURI = window.location.origin; + } + ctrl.state.provider.authUrl = ctrl.settings.AuthorizationURI; + } + }); diff --git a/app/extensions/oauth/components/oauth-settings/oauth-settings.html b/app/extensions/oauth/components/oauth-settings/oauth-settings.html index dc13135f1..d71effc69 100644 --- a/app/extensions/oauth/components/oauth-settings/oauth-settings.html +++ b/app/extensions/oauth/components/oauth-settings/oauth-settings.html @@ -1,48 +1,61 @@
- -
- Automatic user provisioning -
-
- - With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role. If - disabled, users must be created in Portainer in order to login. - -
-
-
- +
+ Automatic user provisioning +
+
+ + With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role. If + disabled, users must be created in Portainer in order to login. + +
+
+
-
-
-
- - The users created by the automatic provisioning feature can be added to a default team on creation. This setting is optional. - -
-
- - - You have not yet created any team. Head over the teams view to manage user teams. - - -
- +
+
+ + The users created by the automatic provisioning feature can be added to a default team on creation. This setting is optional. + +
+
+ + + You have not yet created any team. Head over the teams view to manage user teams. + + +
+ +
-
+
OAuth Configuration
+
+ +
+ +
+
+
@@ -63,129 +76,140 @@
-
+
-
+
-
+
-
+
-
+
- diff --git a/app/extensions/oauth/components/oauth-settings/oauth-settings.js b/app/extensions/oauth/components/oauth-settings/oauth-settings.js index 51a9ec553..f7a30e1c2 100644 --- a/app/extensions/oauth/components/oauth-settings/oauth-settings.js +++ b/app/extensions/oauth/components/oauth-settings/oauth-settings.js @@ -1,7 +1,8 @@ angular.module('portainer.extensions.oauth').component('oauthSettings', { templateUrl: 'app/extensions/oauth/components/oauth-settings/oauth-settings.html', bindings: { - settings: '<', + settings: '=', teams: '<' - } + }, + controller: 'OAuthSettingsController' }); diff --git a/app/portainer/views/auth/authController.js b/app/portainer/views/auth/authController.js index 43aaebb08..98de83580 100644 --- a/app/portainer/views/auth/authController.js +++ b/app/portainer/views/auth/authController.js @@ -120,7 +120,7 @@ angular.module('portainer.app').controller('AuthenticationController', ['$q', '$ $state.go('portainer.home'); }) .catch(function error() { - $scope.state.AuthenticationError = 'Failed to authenticate with OAuth2 Provider'; + $scope.state.AuthenticationError = 'Unable to login via OAuth'; $scope.state.isInOAuthProcess = false; }); } diff --git a/app/portainer/views/settings/authentication/settingsAuthentication.html b/app/portainer/views/settings/authentication/settingsAuthentication.html index f47d02032..1458dc076 100644 --- a/app/portainer/views/settings/authentication/settingsAuthentication.html +++ b/app/portainer/views/settings/authentication/settingsAuthentication.html @@ -49,7 +49,7 @@
- +
Information @@ -58,7 +58,7 @@ When using internal authentication, Portainer will encrypt user passwords and store credentials locally.
- +
@@ -73,7 +73,7 @@
LDAP configuration
- +