diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 10d83c51e..f759285da 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -260,6 +260,7 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL portainer.LDAPGroupSearchSettings{}, }, }, + OAuthSettings: portainer.OAuthSettings{}, AllowBindMountsForRegularUsers: true, AllowPrivilegedModeForRegularUsers: true, EnableHostManagementFeatures: false, diff --git a/api/exec/extension.go b/api/exec/extension.go index cb58ecad6..ab7880a1b 100644 --- a/api/exec/extension.go +++ b/api/exec/extension.go @@ -18,7 +18,8 @@ import ( var extensionDownloadBaseURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com/extensions/" var extensionBinaryMap = map[portainer.ExtensionID]string{ - portainer.RegistryManagementExtension: "extension-registry-management", + portainer.RegistryManagementExtension: "extension-registry-management", + portainer.OAuthAuthenticationExtension: "extension-oauth-authentication", } // ExtensionManager represents a service used to diff --git a/api/http/handler/auth/authenticate_oauth.go b/api/http/handler/auth/authenticate_oauth.go new file mode 100644 index 000000000..d8559e999 --- /dev/null +++ b/api/http/handler/auth/authenticate_oauth.go @@ -0,0 +1,138 @@ +package auth + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "log" + + "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) authenticateThroughExtension(code, licenseKey string, settings *portainer.OAuthSettings) (string, error) { + extensionURL := handler.ProxyManager.GetExtensionURL(portainer.OAuthAuthenticationExtension) + + encodedConfiguration, err := json.Marshal(settings) + if err != nil { + return "", nil + } + + req, err := http.NewRequest("GET", extensionURL+"/validate", nil) + if err != nil { + return "", err + } + + client := &http.Client{} + req.Header.Set("X-OAuth-Config", string(encodedConfiguration)) + req.Header.Set("X-OAuth-Code", code) + req.Header.Set("X-PortainerExtension-License", licenseKey) + + resp, err := client.Do(req) + if err != nil { + return "", err + } + + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + + type extensionResponse struct { + Username string `json:"Username,omitempty"` + Err string `json:"err,omitempty"` + Details string `json:"details,omitempty"` + } + + var extResp extensionResponse + err = json.Unmarshal(body, &extResp) + if err != nil { + return "", err + } + + if resp.StatusCode != http.StatusOK { + return "", portainer.Error(extResp.Err + ":" + extResp.Details) + } + + return extResp.Username, nil +} + +func (handler *Handler) validateOAuth(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload oauthPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + 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 enabled", portainer.Error("OAuth authentication is not enabled")} + } + + extension, err := handler.ExtensionService.Extension(portainer.OAuthAuthenticationExtension) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Oauth authentication extension is not enabled", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err} + } + + username, err := handler.authenticateThroughExtension(payload.Code, extension.License.LicenseKey, &settings.OAuthSettings) + if err != nil { + log.Printf("[DEBUG] - OAuth authentication error: %s", err) + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate through OAuth", portainer.ErrUnauthorized} + } + + 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 user == nil && !settings.OAuthSettings.OAuthAutoCreateUsers { + return &httperror.HandlerError{http.StatusForbidden, "Account not created beforehand in Portainer and automatic user provisioning not enabled", portainer.ErrUnauthorized} + } + + if user == 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} + } + + if settings.OAuthSettings.DefaultTeamID != 0 { + membership := &portainer.TeamMembership{ + UserID: user.ID, + TeamID: settings.OAuthSettings.DefaultTeamID, + Role: portainer.TeamMember, + } + + err = handler.TeamMembershipService.CreateTeamMembership(membership) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist team membership inside the database", err} + } + } + } + + return handler.writeToken(w, user) +} diff --git a/api/http/handler/auth/handler.go b/api/http/handler/auth/handler.go index 1f0769e08..75e343f23 100644 --- a/api/http/handler/auth/handler.go +++ b/api/http/handler/auth/handler.go @@ -6,6 +6,7 @@ import ( "github.com/gorilla/mux" httperror "github.com/portainer/libhttp/error" "github.com/portainer/portainer" + "github.com/portainer/portainer/http/proxy" "github.com/portainer/portainer/http/security" ) @@ -28,6 +29,8 @@ type Handler struct { SettingsService portainer.SettingsService TeamService portainer.TeamService TeamMembershipService portainer.TeamMembershipService + ExtensionService portainer.ExtensionService + ProxyManager *proxy.Manager } // NewHandler creates a handler to manage authentication operations. @@ -36,6 +39,9 @@ func NewHandler(bouncer *security.RequestBouncer, rateLimiter *security.RateLimi Router: mux.NewRouter(), authDisabled: authDisabled, } + + h.Handle("/auth/oauth/validate", + 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) diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go index 0acbd2ca6..a9033c701 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. diff --git a/api/http/handler/settings/settings_public.go b/api/http/handler/settings/settings_public.go index 9744b319a..cc1e07854 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,6 +16,7 @@ type publicSettingsResponse struct { AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"` ExternalTemplates bool `json:"ExternalTemplates"` + OAuthLoginURI string `json:"OAuthLoginURI"` } // GET request on /api/settings/public @@ -31,6 +33,11 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) * AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers, EnableHostManagementFeatures: settings.EnableHostManagementFeatures, ExternalTemplates: false, + OAuthLoginURI: fmt.Sprintf("%s?response_type=code&client_id=%s&redirect_uri=%s&scope=%s&prompt=login", + settings.OAuthSettings.AuthorizationURI, + settings.OAuthSettings.ClientID, + settings.OAuthSettings.RedirectURI, + 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 5c68fca94..21b9daa99 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,8 +25,8 @@ type settingsUpdatePayload struct { } func (payload *settingsUpdatePayload) Validate(r *http.Request) error { - if *payload.AuthenticationMethod != 1 && *payload.AuthenticationMethod != 2 { - return portainer.Error("Invalid authentication method value. Value must be one of: 1 (internal) or 2 (LDAP/AD)") + if *payload.AuthenticationMethod != 1 && *payload.AuthenticationMethod != 2 && *payload.AuthenticationMethod != 3 { + 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") @@ -74,6 +75,15 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * settings.LDAPSettings.Password = ldapPassword } + if payload.OAuthSettings != nil { + clientSecret := payload.OAuthSettings.ClientSecret + if clientSecret == "" { + clientSecret = settings.OAuthSettings.ClientSecret + } + settings.OAuthSettings = *payload.OAuthSettings + settings.OAuthSettings.ClientSecret = clientSecret + } + if payload.AllowBindMountsForRegularUsers != nil { settings.AllowBindMountsForRegularUsers = *payload.AllowBindMountsForRegularUsers } diff --git a/api/http/handler/users/user_delete.go b/api/http/handler/users/user_delete.go index 1c500bfc1..78f83fe57 100644 --- a/api/http/handler/users/user_delete.go +++ b/api/http/handler/users/user_delete.go @@ -41,6 +41,10 @@ func (handler *Handler) userDelete(w http.ResponseWriter, r *http.Request) *http } func (handler *Handler) deleteAdminUser(w http.ResponseWriter, user *portainer.User) *httperror.HandlerError { + if user.Password == "" { + return handler.deleteUser(w, user) + } + users, err := handler.UserService.Users() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err} diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index b1e2aa6f4..f7cbbd5dd 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -12,7 +12,8 @@ import ( // TODO: contain code related to legacy extension management var extensionPorts = map[portainer.ExtensionID]string{ - portainer.RegistryManagementExtension: "7001", + portainer.RegistryManagementExtension: "7001", + portainer.OAuthAuthenticationExtension: "7002", } type ( @@ -103,6 +104,10 @@ func (manager *Manager) CreateExtensionProxy(extensionID portainer.ExtensionID) return proxy, nil } +func (manager *Manager) GetExtensionURL(extensionID portainer.ExtensionID) string { + return "http://localhost:" + extensionPorts[extensionID] +} + // DeleteExtensionProxy deletes the extension proxy associated to an extension identifier func (manager *Manager) DeleteExtensionProxy(extensionID portainer.ExtensionID) { manager.extensionProxies.Remove(strconv.Itoa(int(extensionID))) diff --git a/api/http/server.go b/api/http/server.go index 1220d94d1..7f3368f25 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -107,6 +107,8 @@ func (server *Server) Start() error { authHandler.SettingsService = server.SettingsService authHandler.TeamService = server.TeamService authHandler.TeamMembershipService = server.TeamMembershipService + authHandler.ExtensionService = server.ExtensionService + authHandler.ProxyManager = proxyManager var dockerHubHandler = dockerhub.NewHandler(requestBouncer) dockerHubHandler.DockerHubService = server.DockerHubService diff --git a/api/portainer.go b/api/portainer.go index 9ff82bb37..cba90a55e 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -56,6 +56,20 @@ 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"` + DefaultTeamID TeamID `json:"DefaultTeamID"` + } + // TLSConfiguration represents a TLS configuration TLSConfiguration struct { TLS bool `json:"TLS"` @@ -85,6 +99,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"` @@ -834,6 +849,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 ( @@ -912,6 +929,8 @@ const ( _ ExtensionID = iota // RegistryManagementExtension represents the registry management extension RegistryManagementExtension + // OAuthAuthenticationExtension represents the OAuth authentication extension + OAuthAuthenticationExtension ) const ( diff --git a/app/extensions/_module.js b/app/extensions/_module.js index 5a936d2cf..7ad877aa6 100644 --- a/app/extensions/_module.js +++ b/app/extensions/_module.js @@ -1,3 +1,4 @@ angular.module('portainer.extensions', [ - 'portainer.extensions.registrymanagement' + 'portainer.extensions.registrymanagement', + 'portainer.extensions.oauth' ]); diff --git a/app/extensions/oauth/__module.js b/app/extensions/oauth/__module.js new file mode 100644 index 000000000..8292353a5 --- /dev/null +++ b/app/extensions/oauth/__module.js @@ -0,0 +1,2 @@ +angular.module('portainer.extensions.oauth', ['ngResource']) + .constant('API_ENDPOINT_OAUTH', 'api/auth/oauth'); diff --git a/app/extensions/oauth/components/oauth-providers-selector/oauth-provider-selector-controller.js b/app/extensions/oauth/components/oauth-providers-selector/oauth-provider-selector-controller.js new file mode 100644 index 000000000..818fef20a --- /dev/null +++ b/app/extensions/oauth/components/oauth-providers-selector/oauth-provider-selector-controller.js @@ -0,0 +1,63 @@ +angular.module('portainer.extensions.oauth') + .controller('OAuthProviderSelectorController', function OAuthProviderSelectorController() { + var ctrl = this; + + this.providers = [ + { + authUrl: 'https://login.microsoftonline.com/TENANT_ID/oauth2/authorize', + accessTokenUrl: 'https://login.microsoftonline.com/TENANT_ID/oauth2/token', + resourceUrl: 'https://graph.windows.net/TENANT_ID/me?api-version=2013-11-08', + userIdentifier: 'userPrincipalName', + scopes: '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' + }, + { + authUrl: '', + accessTokenUrl: '', + resourceUrl: '', + userIdentifier: '', + scopes: '', + name: 'custom' + } + ]; + + this.$onInit = onInit; + + function onInit() { + if (ctrl.provider.authUrl) { + ctrl.provider = getProviderByURL(ctrl.provider.authUrl); + } else { + ctrl.provider = ctrl.providers[0]; + } + ctrl.onSelect(ctrl.provider, false); + } + + 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.providers[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 new file mode 100644 index 000000000..56023908e --- /dev/null +++ b/app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.html @@ -0,0 +1,49 @@ +
+ 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 new file mode 100644 index 000000000..1376671fe --- /dev/null +++ b/app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.js @@ -0,0 +1,8 @@ +angular.module('portainer.extensions.oauth').component('oauthProvidersSelector', { + templateUrl: 'app/extensions/oauth/components/oauth-providers-selector/oauth-providers-selector.html', + bindings: { + onSelect: '<', + provider: '=' + }, + 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 new file mode 100644 index 000000000..07d39e6cc --- /dev/null +++ b/app/extensions/oauth/components/oauth-settings/oauth-settings-controller.js @@ -0,0 +1,74 @@ +angular.module('portainer.extensions.oauth') + .controller('OAuthSettingsController', function OAuthSettingsController() { + var ctrl = this; + + this.state = { + provider: {}, + overrideConfiguration: false, + microsoftTenantID: '' + }; + + this.$onInit = onInit; + this.onSelectProvider = onSelectProvider; + this.onMicrosoftTenantIDChange = onMicrosoftTenantIDChange; + this.useDefaultProviderConfiguration = useDefaultProviderConfiguration; + + 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 useDefaultProviderConfiguration() { + ctrl.settings.AuthorizationURI = ctrl.state.provider.authUrl; + ctrl.settings.AccessTokenURI = ctrl.state.provider.accessTokenUrl; + ctrl.settings.ResourceURI = ctrl.state.provider.resourceUrl; + ctrl.settings.UserIdentifier = ctrl.state.provider.userIdentifier; + ctrl.settings.Scopes = ctrl.state.provider.scopes; + + if (ctrl.state.provider.name === 'microsoft' && ctrl.state.microsoftTenantID !== '') { + onMicrosoftTenantIDChange(); + } + } + + function useExistingConfiguration() { + var provider = ctrl.state.provider; + ctrl.settings.AuthorizationURI = ctrl.settings.AuthorizationURI === '' ? provider.authUrl : ctrl.settings.AuthorizationURI; + ctrl.settings.AccessTokenURI = ctrl.settings.AccessTokenURI === '' ? provider.accessTokenUrl : ctrl.settings.AccessTokenURI; + ctrl.settings.ResourceURI = ctrl.settings.ResourceURI === '' ? provider.resourceUrl : ctrl.settings.ResourceURI; + ctrl.settings.UserIdentifier = ctrl.settings.UserIdentifier === '' ? provider.userIdentifier : ctrl.settings.UserIdentifier; + ctrl.settings.Scopes = ctrl.settings.Scopes === '' ? provider.scopes : ctrl.settings.Scopes; + + if (provider.name === 'microsoft' && ctrl.state.microsoftTenantID !== '') { + onMicrosoftTenantIDChange(); + } + } + + function onSelectProvider(provider, overrideConfiguration) { + ctrl.state.provider = provider; + + if (overrideConfiguration) { + useDefaultProviderConfiguration(); + } else { + useExistingConfiguration(); + } + } + + function onInit() { + if (ctrl.settings.RedirectURI === '') { + ctrl.settings.RedirectURI = window.location.origin; + } + + if (ctrl.settings.AuthorizationURI !== '') { + ctrl.state.provider.authUrl = ctrl.settings.AuthorizationURI; + + if (ctrl.settings.AuthorizationURI.indexOf('login.microsoftonline.com') > -1) { + var tenantID = ctrl.settings.AuthorizationURI.match(/login.microsoftonline.com\/(.*?)\//)[1]; + ctrl.state.microsoftTenantID = tenantID; + onMicrosoftTenantIDChange(); + } + } + } + }); diff --git a/app/extensions/oauth/components/oauth-settings/oauth-settings.html b/app/extensions/oauth/components/oauth-settings/oauth-settings.html new file mode 100644 index 000000000..955ad0665 --- /dev/null +++ b/app/extensions/oauth/components/oauth-settings/oauth-settings.html @@ -0,0 +1,215 @@ +
+
+ Automatic user provisioning +
+
+ + With automatic user provisioning enabled, Portainer will create user(s) automatically with standard user role. If + disabled, users must be created beforehand in Portainer in order to login. + +
+
+ + +
+ +
+
+ +

The users created by the automatic provisioning feature can be added to a default team on creation.

+

By assigning newly created users to a team they will be able to access the environments associated to that team. This setting is optional and if not set newly created users won't be able to access any environments.

+
+
+
+ + + You have not yet created any team. Head over the teams view to manage user teams. + + +
+ +
+
+
+ + + +
OAuth Configuration
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+
diff --git a/app/extensions/oauth/components/oauth-settings/oauth-settings.js b/app/extensions/oauth/components/oauth-settings/oauth-settings.js new file mode 100644 index 000000000..f7a30e1c2 --- /dev/null +++ b/app/extensions/oauth/components/oauth-settings/oauth-settings.js @@ -0,0 +1,8 @@ +angular.module('portainer.extensions.oauth').component('oauthSettings', { + templateUrl: 'app/extensions/oauth/components/oauth-settings/oauth-settings.html', + bindings: { + settings: '=', + teams: '<' + }, + controller: 'OAuthSettingsController' +}); diff --git a/app/extensions/oauth/services/rest/oauth.js b/app/extensions/oauth/services/rest/oauth.js new file mode 100644 index 000000000..f33e7b30f --- /dev/null +++ b/app/extensions/oauth/services/rest/oauth.js @@ -0,0 +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 + '/:action', {}, { + validate: { + method: 'POST', + ignoreLoadingBar: true, + params: { + action: 'validate' + } + } + }); +}]); \ No newline at end of file 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/helpers/urlHelper.js b/app/portainer/helpers/urlHelper.js new file mode 100644 index 000000000..2b40c7274 --- /dev/null +++ b/app/portainer/helpers/urlHelper.js @@ -0,0 +1,30 @@ +angular.module('portainer.app') +.factory('URLHelper', ['$window', function URLHelperFactory($window) { + 'use strict'; + var helper = {}; + + helper.getParameter = getParameter; + helper.cleanParameters = cleanParameters; + + function getParameter(param) { + 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 = ''; + } + + return helper; +}]); diff --git a/app/portainer/models/settings.js b/app/portainer/models/settings.js index a4f2e8bf6..bdb8b8713 100644 --- a/app/portainer/models/settings.js +++ b/app/portainer/models/settings.js @@ -3,6 +3,7 @@ function SettingsViewModel(data) { this.BlackListedLabels = data.BlackListedLabels; this.AuthenticationMethod = data.AuthenticationMethod; this.LDAPSettings = data.LDAPSettings; + this.OAuthSettings = new OAuthSettingsViewModel(data.OAuthSettings); this.AllowBindMountsForRegularUsers = data.AllowBindMountsForRegularUsers; this.AllowPrivilegedModeForRegularUsers = data.AllowPrivilegedModeForRegularUsers; this.SnapshotInterval = data.SnapshotInterval; @@ -11,6 +12,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; @@ -31,3 +42,16 @@ 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; + this.DefaultTeamID = data.DefaultTeamID; +} \ No newline at end of file diff --git a/app/portainer/services/api/extensionService.js b/app/portainer/services/api/extensionService.js index b7087f3fc..a715effea 100644 --- a/app/portainer/services/api/extensionService.js +++ b/app/portainer/services/api/extensionService.js @@ -62,5 +62,20 @@ angular.module('portainer.app') return deferred.promise; }; + service.OAuthAuthenticationEnabled = function() { + var deferred = $q.defer(); + + service.extensions(false) + .then(function onSuccess(extensions) { + var extensionAvailable = _.find(extensions, { Id: 2, Enabled: true }) ? true : false; + deferred.resolve(extensionAvailable); + }) + .catch(function onError(err) { + deferred.reject(err); + }); + + return deferred.promise; + }; + return service; }]); diff --git a/app/portainer/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) { diff --git a/app/portainer/services/authentication.js b/app/portainer/services/authentication.js index 0e9f19a93..3396e3155 100644 --- a/app/portainer/services/authentication.js +++ b/app/portainer/services/authentication.js @@ -1,11 +1,14 @@ angular.module('portainer.app') -.factory('Authentication', ['$q', 'Auth', 'jwtHelper', 'LocalStorage', 'StateManager', 'EndpointProvider', function AuthenticationFactory($q, Auth, jwtHelper, LocalStorage, StateManager, EndpointProvider) { +.factory('Authentication', [ +'Auth', 'OAuth', 'jwtHelper', 'LocalStorage', 'StateManager', 'EndpointProvider', +function AuthenticationFactory(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; @@ -15,30 +18,22 @@ angular.module('portainer.app') 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.validate({ code: code }).$promise + .then(function onLoginSuccess(response) { + return setUser(response.jwt); + }); + } + function login(username, password) { - var deferred = $q.defer(); - - 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; - deferred.resolve(); - }) - .catch(function error() { - deferred.reject(); - }); - - return deferred.promise; + return Auth.login({ username: username, password: password }).$promise + .then(function onLoginSuccess(response) { + return setUser(response.jwt); + }); } function logout() { @@ -56,5 +51,13 @@ angular.module('portainer.app') 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; }]); diff --git a/app/portainer/views/account/account.html b/app/portainer/views/account/account.html index d8c6fe955..c289771cc 100644 --- a/app/portainer/views/account/account.html +++ b/app/portainer/views/account/account.html @@ -56,6 +56,10 @@ You cannot change your password when using LDAP authentication. + + + You cannot change your password when using OAuth authentication. + diff --git a/app/portainer/views/auth/auth.html b/app/portainer/views/auth/auth.html index 6cde5c633..28ff6a645 100644 --- a/app/portainer/views/auth/auth.html +++ b/app/portainer/views/auth/auth.html @@ -9,7 +9,7 @@ -
+
@@ -27,20 +27,44 @@
+
+
+
+ OAuth authentication in progress... +
+
+
diff --git a/app/portainer/views/auth/authController.js b/app/portainer/views/auth/authController.js index 055d359be..d8a13fcfd 100644 --- a/app/portainer/views/auth/authController.js +++ b/app/portainer/views/auth/authController.js @@ -1,7 +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', '$stateParams', '$sanitize', 'Authentication', 'UserService', 'EndpointService', 'StateManager', 'Notifications', 'SettingsService', 'URLHelper', +function($q, $scope, $state, $stateParams, $sanitize, Authentication, UserService, EndpointService, StateManager, Notifications, SettingsService, URLHelper) { $scope.logo = StateManager.getState().application.logo; $scope.formValues = { @@ -10,7 +9,9 @@ function ($q, $scope, $state, $transition$, $sanitize, Authentication, UserServi }; $scope.state = { - AuthenticationError: '' + AuthenticationError: '', + isInOAuthProcess: true, + OAuthProvider: '' }; $scope.authenticateUser = function() { @@ -81,10 +82,31 @@ function ($q, $scope, $state, $transition$, $sanitize, Authentication, UserServi }); } + function determineOauthProvider(LoginURI) { + if (LoginURI.indexOf('login.microsoftonline.com') !== -1) { + return 'Microsoft'; + } + else if (LoginURI.indexOf('accounts.google.com') !== -1) { + return 'Google'; + } + else if (LoginURI.indexOf('github.com') !== -1) { + return 'Github'; + } + return 'OAuth'; + } + function initView() { - if ($transition$.params().logout || $transition$.params().error) { + SettingsService.publicSettings() + .then(function success(settings) { + $scope.AuthenticationMethod = settings.AuthenticationMethod; + $scope.OAuthLoginURI = settings.OAuthLoginURI; + $scope.state.OAuthProvider = determineOauthProvider(settings.OAuthLoginURI); + }); + + if ($stateParams.logout || $stateParams.error) { Authentication.logout(); - $scope.state.AuthenticationError = $transition$.params().error; + $scope.state.AuthenticationError = $stateParams.error; + $scope.state.isInOAuthProcess = false; return; } @@ -98,7 +120,26 @@ function ($q, $scope, $state, $transition$, $sanitize, Authentication, UserServi } else { authenticatedFlow(); } + + var code = URLHelper.getParameter('code'); + if (code) { + oAuthLogin(code); + } else { + $scope.state.isInOAuthProcess = false; + } } + function oAuthLogin(code) { + return Authentication.OAuthLogin(code) + .then(function success() { + URLHelper.cleanParameters(); + }) + .catch(function error() { + $scope.state.AuthenticationError = 'Unable to login via OAuth'; + $scope.state.isInOAuthProcess = false; + }); + } + + initView(); }]); diff --git a/app/portainer/views/settings/authentication/settingsAuthentication.html b/app/portainer/views/settings/authentication/settingsAuthentication.html index 72db0edef..cddc54848 100644 --- a/app/portainer/views/settings/authentication/settingsAuthentication.html +++ b/app/portainer/views/settings/authentication/settingsAuthentication.html @@ -37,23 +37,49 @@

LDAP authentication

+
+ + +
+
+ + +
-
- 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
@@ -306,7 +332,12 @@
- + + + +
+ Actions +