diff --git a/README.md b/README.md index 65117ef76..6a6a41c22 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

- +

[![Docker Pulls](https://img.shields.io/docker/pulls/portainer/portainer.svg)](https://hub.docker.com/r/portainer/portainer/) @@ -8,7 +8,6 @@ [![Documentation Status](https://readthedocs.org/projects/portainer/badge/?version=stable)](http://portainer.readthedocs.io/en/stable/?badge=stable) [![Build Status](https://portainer.visualstudio.com/Portainer%20CI/_apis/build/status/Portainer%20CI?branchName=develop)](https://portainer.visualstudio.com/Portainer%20CI/_build/latest?definitionId=3&branchName=develop) [![Code Climate](https://codeclimate.com/github/portainer/portainer/badges/gpa.svg)](https://codeclimate.com/github/portainer/portainer) -[![Gitter](https://badges.gitter.im/portainer/Lobby.svg)](https://gitter.im/portainer/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) [![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=YHXZJQNJQ36H6) **_Portainer_** is a lightweight management UI which allows you to **easily** manage your different Docker environments (Docker hosts or Swarm clusters). @@ -41,7 +40,6 @@ Unlike the public demo, the playground sessions are deleted after 4 hours. Apart * Issues: https://github.com/portainer/portainer/issues * FAQ: https://portainer.readthedocs.io/en/latest/faq.html * Slack (chat): https://portainer.io/slack/ -* Gitter (chat): https://gitter.im/portainer/Lobby ## Reporting bugs and contributing diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 09e223593..f759285da 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -175,7 +175,7 @@ func loadEndpointSyncSystemSchedule(jobScheduler portainer.JobScheduler, schedul endpointSyncJob := &portainer.EndpointSyncJob{} - endointSyncSchedule := &portainer.Schedule{ + endpointSyncSchedule := &portainer.Schedule{ ID: portainer.ScheduleID(scheduleService.GetNextIdentifier()), Name: "system_endpointsync", CronExpression: "@every " + *flags.SyncInterval, @@ -186,14 +186,14 @@ func loadEndpointSyncSystemSchedule(jobScheduler portainer.JobScheduler, schedul } endpointSyncJobContext := cron.NewEndpointSyncJobContext(endpointService, *flags.ExternalEndpoints) - endpointSyncJobRunner := cron.NewEndpointSyncJobRunner(endointSyncSchedule, endpointSyncJobContext) + endpointSyncJobRunner := cron.NewEndpointSyncJobRunner(endpointSyncSchedule, endpointSyncJobContext) err = jobScheduler.ScheduleJob(endpointSyncJobRunner) if err != nil { return err } - return scheduleService.CreateSchedule(endointSyncSchedule) + return scheduleService.CreateSchedule(endpointSyncSchedule) } func loadSchedulesFromDatabase(jobScheduler portainer.JobScheduler, jobService portainer.JobService, scheduleService portainer.ScheduleService, endpointService portainer.EndpointService, fileService portainer.FileService) error { @@ -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/extensions/handler.go b/api/http/handler/extensions/handler.go index b7ebbd06b..bba32f389 100644 --- a/api/http/handler/extensions/handler.go +++ b/api/http/handler/extensions/handler.go @@ -23,7 +23,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { } h.Handle("/extensions", - bouncer.AdministratorAccess(httperror.LoggerHandler(h.extensionList))).Methods(http.MethodGet) + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.extensionList))).Methods(http.MethodGet) h.Handle("/extensions", bouncer.AdministratorAccess(httperror.LoggerHandler(h.extensionCreate))).Methods(http.MethodPost) h.Handle("/extensions/{id}", diff --git a/api/http/handler/motd/motd.go b/api/http/handler/motd/motd.go index 53246fec1..9d4f18004 100644 --- a/api/http/handler/motd/motd.go +++ b/api/http/handler/motd/motd.go @@ -10,6 +10,7 @@ import ( ) type motdResponse struct { + Title string `json:"Title"` Message string `json:"Message"` Hash []byte `json:"Hash"` } @@ -22,6 +23,12 @@ func (handler *Handler) motd(w http.ResponseWriter, r *http.Request) { return } + title, err := client.Get(portainer.MessageOfTheDayTitleURL, 0) + if err != nil { + response.JSON(w, &motdResponse{Message: ""}) + return + } + hash := crypto.HashFromBytes(motd) - response.JSON(w, &motdResponse{Message: string(motd), Hash: hash}) + response.JSON(w, &motdResponse{Title: string(title), Message: string(motd), Hash: hash}) } diff --git a/api/http/handler/registries/handler.go b/api/http/handler/registries/handler.go index a8f3ae24c..26edebd15 100644 --- a/api/http/handler/registries/handler.go +++ b/api/http/handler/registries/handler.go @@ -18,6 +18,7 @@ func hideFields(registry *portainer.Registry) { // Handler is the HTTP handler used to handle registry operations. type Handler struct { *mux.Router + requestBouncer *security.RequestBouncer RegistryService portainer.RegistryService ExtensionService portainer.ExtensionService FileService portainer.FileService @@ -27,7 +28,8 @@ type Handler struct { // NewHandler creates a handler to manage registry operations. func NewHandler(bouncer *security.RequestBouncer) *Handler { h := &Handler{ - Router: mux.NewRouter(), + Router: mux.NewRouter(), + requestBouncer: bouncer, } h.Handle("/registries", @@ -35,7 +37,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { h.Handle("/registries", bouncer.RestrictedAccess(httperror.LoggerHandler(h.registryList))).Methods(http.MethodGet) h.Handle("/registries/{id}", - bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryInspect))).Methods(http.MethodGet) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.registryInspect))).Methods(http.MethodGet) h.Handle("/registries/{id}", bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryUpdate))).Methods(http.MethodPut) h.Handle("/registries/{id}/access", @@ -45,7 +47,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { h.Handle("/registries/{id}", bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryDelete))).Methods(http.MethodDelete) h.PathPrefix("/registries/{id}/v2").Handler( - bouncer.AdministratorAccess(httperror.LoggerHandler(h.proxyRequestsToRegistryAPI))) + bouncer.RestrictedAccess(httperror.LoggerHandler(h.proxyRequestsToRegistryAPI))) return h } diff --git a/api/http/handler/registries/proxy.go b/api/http/handler/registries/proxy.go index b83bcc549..844dc7e79 100644 --- a/api/http/handler/registries/proxy.go +++ b/api/http/handler/registries/proxy.go @@ -24,6 +24,11 @@ func (handler *Handler) proxyRequestsToRegistryAPI(w http.ResponseWriter, r *htt return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} } + err = handler.requestBouncer.RegistryAccess(r, registry) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access registry", portainer.ErrEndpointAccessDenied} + } + extension, err := handler.ExtensionService.Extension(portainer.RegistryManagementExtension) if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Registry management extension is not enabled", err} diff --git a/api/http/handler/registries/registry_inspect.go b/api/http/handler/registries/registry_inspect.go index 96cdf84ac..1dd9a3ffb 100644 --- a/api/http/handler/registries/registry_inspect.go +++ b/api/http/handler/registries/registry_inspect.go @@ -23,6 +23,11 @@ func (handler *Handler) registryInspect(w http.ResponseWriter, r *http.Request) return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} } + err = handler.requestBouncer.RegistryAccess(r, registry) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access registry", portainer.ErrEndpointAccessDenied} + } + hideFields(registry) return response.JSON(w, registry) } 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..7572ff658 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,11 @@ func (manager *Manager) CreateExtensionProxy(extensionID portainer.ExtensionID) return proxy, nil } +// GetExtensionURL retrieves the URL of an extension running locally based on the extension port table +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/security/authorization.go b/api/http/security/authorization.go index 6ffa3a0e4..4a0bac587 100644 --- a/api/http/security/authorization.go +++ b/api/http/security/authorization.go @@ -153,10 +153,10 @@ func authorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *porta return true } -// AuthorizedEndpointGroupAccess ensure that the user can access the specified endpoint group. +// authorizedEndpointGroupAccess ensure that the user can access the specified endpoint group. // It will check if the user is part of the authorized users or part of a team that is // listed in the authorized teams. -func AuthorizedEndpointGroupAccess(endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool { +func authorizedEndpointGroupAccess(endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool { return authorizedAccess(userID, memberships, endpointGroup.AuthorizedUsers, endpointGroup.AuthorizedTeams) } diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index de0c75523..fa88612a8 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -111,6 +111,31 @@ func (bouncer *RequestBouncer) EndpointAccess(r *http.Request, endpoint *portain return nil } +// RegistryAccess retrieves the JWT token from the request context and verifies +// that the user can access the specified registry. +// An error is returned when access is denied. +func (bouncer *RequestBouncer) RegistryAccess(r *http.Request, registry *portainer.Registry) error { + tokenData, err := RetrieveTokenData(r) + if err != nil { + return err + } + + if tokenData.Role == portainer.AdministratorRole { + return nil + } + + memberships, err := bouncer.teamMembershipService.TeamMembershipsByUserID(tokenData.ID) + if err != nil { + return err + } + + if !AuthorizedRegistryAccess(registry, tokenData.ID, memberships) { + return portainer.ErrEndpointAccessDenied + } + + return nil +} + // mwSecureHeaders provides secure headers middleware for handlers. func mwSecureHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/api/http/security/filter.go b/api/http/security/filter.go index 878a66689..4d44043b3 100644 --- a/api/http/security/filter.go +++ b/api/http/security/filter.go @@ -124,7 +124,7 @@ func FilterEndpointGroups(endpointGroups []portainer.EndpointGroup, context *Res filteredEndpointGroups = make([]portainer.EndpointGroup, 0) for _, group := range endpointGroups { - if AuthorizedEndpointGroupAccess(&group, context.UserID, context.UserMemberships) { + if authorizedEndpointGroupAccess(&group, context.UserID, context.UserMemberships) { filteredEndpointGroups = append(filteredEndpointGroups, group) } } 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..0b2eff4f4 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"` @@ -779,15 +794,17 @@ type ( const ( // APIVersion is the version number of the Portainer API - APIVersion = "1.20.1" + APIVersion = "1.20.2" // DBVersion is the version number of the Portainer database DBVersion = 17 // AssetsServerURL represents the URL of the Portainer asset server AssetsServerURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com" // MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved MessageOfTheDayURL = AssetsServerURL + "/motd.html" + // MessageOfTheDayTitleURL represents the URL where Portainer MOTD title can be retrieved + MessageOfTheDayTitleURL = AssetsServerURL + "/motd-title.txt" // ExtensionDefinitionsURL represents the URL where Portainer extension definitions can be retrieved - ExtensionDefinitionsURL = AssetsServerURL + "/extensions.json" + ExtensionDefinitionsURL = AssetsServerURL + "/extensions-1.20.2.json" // PortainerAgentHeader represents the name of the header available in any agent response PortainerAgentHeader = "Portainer-Agent" // PortainerAgentTargetHeader represent the name of the header containing the target node name @@ -834,6 +851,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 +931,8 @@ const ( _ ExtensionID = iota // RegistryManagementExtension represents the registry management extension RegistryManagementExtension + // OAuthAuthenticationExtension represents the OAuth authentication extension + OAuthAuthenticationExtension ) const ( diff --git a/api/swagger.yaml b/api/swagger.yaml index 7f5339d2e..a6d50a8b5 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -54,7 +54,7 @@ info: **NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8). - version: "1.20.1" + version: "1.20.2" title: "Portainer API" contact: email: "info@portainer.io" @@ -3018,7 +3018,7 @@ definitions: description: "Is analytics enabled" Version: type: "string" - example: "1.20.1" + example: "1.20.2" description: "Portainer API version" PublicSettingsInspectResponse: type: "object" diff --git a/api/swagger_config.json b/api/swagger_config.json index cdd9e1115..1da6f8728 100644 --- a/api/swagger_config.json +++ b/api/swagger_config.json @@ -1,5 +1,5 @@ { "packageName": "portainer", - "packageVersion": "1.20.1", + "packageVersion": "1.20.2", "projectName": "portainer" } diff --git a/app/app.js b/app/app.js index c07789282..38f0151e7 100644 --- a/app/app.js +++ b/app/app.js @@ -47,7 +47,7 @@ function initAuthentication(authManager, Authentication, $rootScope, $state) { // to have more controls on which URL should trigger the unauthenticated state. $rootScope.$on('unauthenticated', function (event, data) { if (!_.includes(data.config.url, '/v2/')) { - $state.go('portainer.auth', {error: 'Your session has expired', redirect: $state.current.name}); + $state.go('portainer.auth', { error: 'Your session has expired' }); } }); } diff --git a/app/constants.js b/app/constants.js index 464089158..d741e312d 100644 --- a/app/constants.js +++ b/app/constants.js @@ -20,4 +20,5 @@ angular.module('portainer') .constant('DEFAULT_TEMPLATES_URL', 'https://raw.githubusercontent.com/portainer/templates/master/templates.json') .constant('PAGINATION_MAX_ITEMS', 10) .constant('APPLICATION_CACHE_VALIDITY', 3600) -.constant('CONSOLE_COMMANDS_LABEL_PREFIX', 'io.portainer.commands.'); +.constant('CONSOLE_COMMANDS_LABEL_PREFIX', 'io.portainer.commands.') +.constant('PREDEFINED_NETWORKS', ['host', 'bridge', 'none']); diff --git a/app/docker/components/container-quick-actions/containerQuickActions.html b/app/docker/components/container-quick-actions/containerQuickActions.html index 0befd7e8d..5475dd9c5 100644 --- a/app/docker/components/container-quick-actions/containerQuickActions.html +++ b/app/docker/components/container-quick-actions/containerQuickActions.html @@ -28,14 +28,14 @@ diff --git a/app/docker/components/datatables/networks-datatable/networksDatatable.html b/app/docker/components/datatables/networks-datatable/networksDatatable.html index 29b82b6a7..032de038b 100644 --- a/app/docker/components/datatables/networks-datatable/networksDatatable.html +++ b/app/docker/components/datatables/networks-datatable/networksDatatable.html @@ -110,7 +110,7 @@ - + {{ item.Name | truncate:40 }} diff --git a/app/docker/components/datatables/networks-datatable/networksDatatable.js b/app/docker/components/datatables/networks-datatable/networksDatatable.js index e996f9afa..448a8b5a8 100644 --- a/app/docker/components/datatables/networks-datatable/networksDatatable.js +++ b/app/docker/components/datatables/networks-datatable/networksDatatable.js @@ -1,6 +1,6 @@ angular.module('portainer.docker').component('networksDatatable', { templateUrl: './networksDatatable.html', - controller: 'GenericDatatableController', + controller: 'NetworksDatatableController', bindings: { titleText: '@', titleIcon: '@', diff --git a/app/docker/components/datatables/networks-datatable/networksDatatableController.js b/app/docker/components/datatables/networks-datatable/networksDatatableController.js new file mode 100644 index 000000000..8c9afd635 --- /dev/null +++ b/app/docker/components/datatables/networks-datatable/networksDatatableController.js @@ -0,0 +1,20 @@ +angular.module('portainer.docker') + .controller('NetworksDatatableController', ['$scope', '$controller', 'PREDEFINED_NETWORKS', + function ($scope, $controller, PREDEFINED_NETWORKS) { + angular.extend(this, $controller('GenericDatatableController', {$scope: $scope})); + + this.disableRemove = function(item) { + return PREDEFINED_NETWORKS.includes(item.Name); + }; + + this.selectAll = function() { + for (var i = 0; i < this.state.filteredDataSet.length; i++) { + var item = this.state.filteredDataSet[i]; + if (!this.disableRemove(item) && item.Checked !== this.state.selectAll) { + item.Checked = this.state.selectAll; + this.selectItem(item); + } + } + }; + } +]); \ No newline at end of file diff --git a/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html index 40f255ae0..ebabb9b4c 100644 --- a/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html +++ b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html @@ -60,8 +60,8 @@ {{ item.Status.State }} - {{ item.Id }}Roz - {{ item.Id }}Doz + {{ item.Id }} + {{ item.Id }} diff --git a/app/docker/components/datatables/volumes-datatable/volumesDatatable.html b/app/docker/components/datatables/volumes-datatable/volumesDatatable.html index ca92dad87..475290f84 100644 --- a/app/docker/components/datatables/volumes-datatable/volumesDatatable.html +++ b/app/docker/components/datatables/volumes-datatable/volumesDatatable.html @@ -79,6 +79,13 @@ + + + Created + + + + Host @@ -112,6 +119,7 @@ {{ item.StackName ? item.StackName : '-' }} {{ item.Driver }} {{ item.Mountpoint | truncatelr }} + {{ item.CreatedAt | getisodate }} {{ item.NodeName ? item.NodeName : '-' }} diff --git a/app/docker/models/volume.js b/app/docker/models/volume.js index 0860031b2..ae2ac1904 100644 --- a/app/docker/models/volume.js +++ b/app/docker/models/volume.js @@ -2,6 +2,7 @@ import { ResourceControlViewModel } from "../../portainer/models/resourceControl export function VolumeViewModel(data) { this.Id = data.Name; + this.CreatedAt = data.CreatedAt; this.Driver = data.Driver; this.Options = data.Options; this.Labels = data.Labels; diff --git a/app/docker/views/containers/create/createContainerController.js b/app/docker/views/containers/create/createContainerController.js index e588144e2..7c3a74f2a 100644 --- a/app/docker/views/containers/create/createContainerController.js +++ b/app/docker/views/containers/create/createContainerController.js @@ -56,6 +56,7 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai PortBindings: [], PublishAllPorts: false, Binds: [], + AutoRemove: false, NetworkMode: 'bridge', Privileged: false, Runtime: '', diff --git a/app/docker/views/containers/create/createcontainer.html b/app/docker/views/containers/create/createcontainer.html index da8521a1c..83091a978 100644 --- a/app/docker/views/containers/create/createcontainer.html +++ b/app/docker/views/containers/create/createcontainer.html @@ -124,6 +124,19 @@
Actions
+ +
+
+ + +
+
+
+ diff --git a/app/docker/views/networks/edit/networkController.js b/app/docker/views/networks/edit/networkController.js index 9ca04092e..c26800ec1 100644 --- a/app/docker/views/networks/edit/networkController.js +++ b/app/docker/views/networks/edit/networkController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('NetworkController', ['$scope', '$state', '$transition$', '$filter', 'NetworkService', 'Container', 'Notifications', 'HttpRequestHelper', -function ($scope, $state, $transition$, $filter, NetworkService, Container, Notifications, HttpRequestHelper) { +.controller('NetworkController', ['$scope', '$state', '$transition$', '$filter', 'NetworkService', 'Container', 'Notifications', 'HttpRequestHelper', 'PREDEFINED_NETWORKS', +function ($scope, $state, $transition$, $filter, NetworkService, Container, Notifications, HttpRequestHelper, PREDEFINED_NETWORKS) { $scope.removeNetwork = function removeNetwork() { NetworkService.remove($transition$.params().id, $transition$.params().id) @@ -25,6 +25,10 @@ function ($scope, $state, $transition$, $filter, NetworkService, Container, Noti }); }; + $scope.allowRemove = function allowRemove(item) { + return !PREDEFINED_NETWORKS.includes(item.Name); + }; + function filterContainersInNetwork(network, containers) { var containersInNetwork = []; containers.forEach(function(container) { diff --git a/app/docker/views/volumes/edit/volume.html b/app/docker/views/volumes/edit/volume.html index 9d191de5b..6b9cebbdf 100644 --- a/app/docker/views/volumes/edit/volume.html +++ b/app/docker/views/volumes/edit/volume.html @@ -19,6 +19,10 @@ + + Created + {{ volume.CreatedAt | getisodate }} + Mount path {{ volume.Mountpoint }} 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..76d8c789b --- /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: './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..dcf36cd1c --- /dev/null +++ b/app/extensions/oauth/components/oauth-settings/oauth-settings-controller.js @@ -0,0 +1,76 @@ +import _ from 'lodash-es'; + +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.

+
+
+
+
+ + + +
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..f3770c45b --- /dev/null +++ b/app/extensions/oauth/components/oauth-settings/oauth-settings.js @@ -0,0 +1,8 @@ +angular.module('portainer.extensions.oauth').component('oauthSettings', { + templateUrl: './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/extensions/registry-management/views/repositories/registryRepositories.html b/app/extensions/registry-management/views/repositories/registryRepositories.html index 6ca3664c7..5e3210ca7 100644 --- a/app/extensions/registry-management/views/repositories/registryRepositories.html +++ b/app/extensions/registry-management/views/repositories/registryRepositories.html @@ -5,7 +5,7 @@ - Registries > {{ registry.Name }} > Repositories + Registries > {{ registry.Name }}{{ registry.Name}} > Repositories diff --git a/app/extensions/registry-management/views/repositories/registryRepositoriesController.js b/app/extensions/registry-management/views/repositories/registryRepositoriesController.js index ccf4fb3de..5d463e722 100644 --- a/app/extensions/registry-management/views/repositories/registryRepositoriesController.js +++ b/app/extensions/registry-management/views/repositories/registryRepositoriesController.js @@ -1,6 +1,6 @@ angular.module('portainer.extensions.registrymanagement') -.controller('RegistryRepositoriesController', ['$transition$', '$scope', 'RegistryService', 'RegistryV2Service', 'Notifications', -function ($transition$, $scope, RegistryService, RegistryV2Service, Notifications) { +.controller('RegistryRepositoriesController', ['$transition$', '$scope', 'RegistryService', 'RegistryV2Service', 'Notifications', 'Authentication', +function ($transition$, $scope, RegistryService, RegistryV2Service, Notifications, Authentication) { $scope.state = { displayInvalidConfigurationMessage: false @@ -9,6 +9,13 @@ function ($transition$, $scope, RegistryService, RegistryV2Service, Notification function initView() { var registryId = $transition$.params().id; + var authenticationEnabled = $scope.applicationState.application.authentication; + if (authenticationEnabled) { + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1; + $scope.isAdmin = isAdmin; + } + RegistryService.registry(registryId) .then(function success(data) { $scope.registry = data; diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 6251d0216..2db2f1ccc 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -48,7 +48,7 @@ angular.module('portainer.app', []) var authentication = { name: 'portainer.auth', - url: '/auth?redirect', + url: '/auth', params: { logout: false, error: '' @@ -329,7 +329,7 @@ angular.module('portainer.app', []) } }, resolve: { - endpointID: ['EndpointProvider', '$state', + endpointID: ['EndpointProvider', '$state', function (EndpointProvider, $state) { var id = EndpointProvider.endpointID(); if (!id) { @@ -457,6 +457,16 @@ angular.module('portainer.app', []) var templates = { name: 'portainer.templates', url: '/templates', + resolve: { + endpointID: ['EndpointProvider', '$state', + function (EndpointProvider, $state) { + var id = EndpointProvider.endpointID(); + if (!id) { + return $state.go('portainer.home'); + } + } + ] + }, views: { 'content@': { templateUrl: './views/templates/templates.html', diff --git a/app/portainer/components/datatables/registries-datatable/registriesDatatable.html b/app/portainer/components/datatables/registries-datatable/registriesDatatable.html index 9bc262c40..58377e2c3 100644 --- a/app/portainer/components/datatables/registries-datatable/registriesDatatable.html +++ b/app/portainer/components/datatables/registries-datatable/registriesDatatable.html @@ -6,7 +6,7 @@ {{ $ctrl.titleText }}
-
+
diff --git a/app/portainer/views/auth/auth.html b/app/portainer/views/auth/auth.html index 0cedbc5b4..ceba5e6d0 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 2e2b729bf..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', '$stateParams', -function ($q, $scope, $state, $transition$, $sanitize, Authentication, UserService, EndpointService, StateManager, Notifications, SettingsService, $stateParams) { - +.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() { @@ -44,7 +45,7 @@ function ($q, $scope, $state, $transition$, $sanitize, Authentication, UserServi if (endpoints.length === 0) { $state.go('portainer.init.endpoint'); } else { - $state.go($stateParams.redirect ||'portainer.home'); + $state.go('portainer.home'); } }) .catch(function error(err) { @@ -73,7 +74,7 @@ function ($q, $scope, $state, $transition$, $sanitize, Authentication, UserServi if (endpoints.length === 0 && userDetails.role === 1) { $state.go('portainer.init.endpoint'); } else { - $state.go($stateParams.redirect || 'portainer.home'); + $state.go('portainer.home'); } }) .catch(function error(err) { @@ -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/endpoints/create/createEndpointController.js b/app/portainer/views/endpoints/create/createEndpointController.js index 49efcb9c2..2572b70e2 100644 --- a/app/portainer/views/endpoints/create/createEndpointController.js +++ b/app/portainer/views/endpoints/create/createEndpointController.js @@ -22,7 +22,7 @@ function ($q, $scope, $state, $filter, clipboard, EndpointService, GroupService, }; $scope.copyAgentCommand = function() { - clipboard.copyText('curl -L https://portainer.io/download/agent-stack.yml -o agent-stack.yml && docker stack deploy --compose-file=agent-stack.yml portainer-agent'); + clipboard.copyText('curl -L https://downloads.portainer.io/agent-stack.yml -o agent-stack.yml && docker stack deploy --compose-file=agent-stack.yml portainer-agent'); $('#copyNotification').show(); $('#copyNotification').fadeOut(2000); }; diff --git a/app/portainer/views/extensions/extensions.html b/app/portainer/views/extensions/extensions.html index 360915d3c..d8a14c840 100644 --- a/app/portainer/views/extensions/extensions.html +++ b/app/portainer/views/extensions/extensions.html @@ -7,8 +7,8 @@

Portainer CE is a great way of managing clusters, provisioning containers and services and - managing container environment lifecycles. To extend the benefit of Portainer CE even - more, and to address the needs of larger, complex or critical environments, the Portainer + managing container environment lifecycles. To extend the benefit of Portainer CE even more, + and to address the needs of larger, complex or critical environments, the Portainer team provides a growing range of low-cost Extensions.

@@ -39,19 +39,18 @@

The advantage of an extensible design is clear: While a range of capability is available, only necessary functionality is added as and when needed. + To ensure that Portainer remains the best choice for managing production container platforms, + the Portainer team have chosen a modular, extensible design approach, where additional capability + can be added to the Portainer CE core as needed, and at very low cost.

- Our first extension is Registry Manager, available now. Others (such as - Single Sign On and Operations Management) are scheduled for the early part of 2019. + Available through a simple subscription process from the list below, Portainer Extensions + provide a simple way to enhance Portainer CE’s core functionality through incremental capability in important areas.

- Portainer CE is the core of the Portainer management environments. Portainer CE will - continue to be developed and made freely available as part of our deep commitment to our - Open Source heritage and our user community. Portainer CE will always deliver great - functionality and remain the industry standard toolset for managing container-based - platforms. + For additional information on Portainer Extensions, see our website here.

diff --git a/app/portainer/views/home/home.html b/app/portainer/views/home/home.html index b12d8e749..06eecd3e4 100644 --- a/app/portainer/views/home/home.html +++ b/app/portainer/views/home/home.html @@ -9,7 +9,7 @@

diff --git a/app/portainer/views/home/homeController.js b/app/portainer/views/home/homeController.js index f25e5b51f..aef6f23d7 100644 --- a/app/portainer/views/home/homeController.js +++ b/app/portainer/views/home/homeController.js @@ -1,139 +1,139 @@ angular.module('portainer.app') -.controller('HomeController', ['$q', '$scope', '$state', 'Authentication', 'EndpointService', 'EndpointHelper', 'GroupService', 'Notifications', 'EndpointProvider', 'StateManager', 'LegacyExtensionManager', 'ModalService', 'MotdService', 'SystemService', -function ($q, $scope, $state, Authentication, EndpointService, EndpointHelper, GroupService, Notifications, EndpointProvider, StateManager, LegacyExtensionManager, ModalService, MotdService, SystemService) { + .controller('HomeController', ['$q', '$scope', '$state', 'Authentication', 'EndpointService', 'EndpointHelper', 'GroupService', 'Notifications', 'EndpointProvider', 'StateManager', 'LegacyExtensionManager', 'ModalService', 'MotdService', 'SystemService', + function($q, $scope, $state, Authentication, EndpointService, EndpointHelper, GroupService, Notifications, EndpointProvider, StateManager, LegacyExtensionManager, ModalService, MotdService, SystemService) { - $scope.goToEdit = function(id) { - $state.go('portainer.endpoints.endpoint', { id: id }); - }; + $scope.goToEdit = function(id) { + $state.go('portainer.endpoints.endpoint', { id: id }); + }; - $scope.goToDashboard = function (endpoint) { - if (endpoint.Type === 3) { - return switchToAzureEndpoint(endpoint); - } + $scope.goToDashboard = function(endpoint) { + if (endpoint.Type === 3) { + return switchToAzureEndpoint(endpoint); + } - checkEndpointStatus(endpoint) - .then(function sucess() { - return switchToDockerEndpoint(endpoint); - }).catch(function error(err) { - Notifications.error('Failure', err, 'Unable to verify endpoint status'); - }); - }; + checkEndpointStatus(endpoint) + .then(function sucess() { + return switchToDockerEndpoint(endpoint); + }).catch(function error(err) { + Notifications.error('Failure', err, 'Unable to verify endpoint status'); + }); + }; - $scope.dismissImportantInformation = function (hash) { - StateManager.dismissImportantInformation(hash); - }; + $scope.dismissImportantInformation = function(hash) { + StateManager.dismissImportantInformation(hash); + }; - $scope.dismissInformationPanel = function (id) { - StateManager.dismissInformationPanel(id); - }; + $scope.dismissInformationPanel = function(id) { + StateManager.dismissInformationPanel(id); + }; - $scope.triggerSnapshot = function () { - ModalService.confirmEndpointSnapshot(function (result) { - if (!result) { - return; - } - triggerSnapshot(); - }); - }; + $scope.triggerSnapshot = function() { + ModalService.confirmEndpointSnapshot(function(result) { + if (!result) { + return; + } + triggerSnapshot(); + }); + }; - function checkEndpointStatus(endpoint) { - var deferred = $q.defer(); + function checkEndpointStatus(endpoint) { + var deferred = $q.defer(); + + var status = 1; + SystemService.ping(endpoint.Id) + .then(function sucess() { + status = 1; + }).catch(function error() { + status = 2; + }).finally(function() { + if (endpoint.Status === status) { + deferred.resolve(endpoint); + return deferred.promise; + } + + EndpointService.updateEndpoint(endpoint.Id, { Status: status }) + .then(function sucess() { + deferred.resolve(endpoint); + }).catch(function error(err) { + deferred.reject({ msg: 'Unable to update endpoint status', err: err }); + }); + }); - var status = 1; - SystemService.ping(endpoint.Id) - .then(function sucess() { - status = 1; - }).catch(function error() { - status = 2; - }).finally(function () { - if (endpoint.Status === status) { - deferred.resolve(endpoint); return deferred.promise; } - EndpointService.updateEndpoint(endpoint.Id, { Status: status }) - .then(function sucess() { - deferred.resolve(endpoint); - }).catch(function error(err) { - deferred.reject({msg: 'Unable to update endpoint status', err: err}); - }); - }); + function switchToAzureEndpoint(endpoint) { + EndpointProvider.setEndpointID(endpoint.Id); + EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); + EndpointProvider.setOfflineModeFromStatus(endpoint.Status); + StateManager.updateEndpointState(endpoint, []) + .then(function success() { + $state.go('azure.dashboard'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to connect to the Azure endpoint'); + }); + } - return deferred.promise; - } + function switchToDockerEndpoint(endpoint) { + if (endpoint.Status === 2 && endpoint.Snapshots[0] && endpoint.Snapshots[0].Swarm === true) { + Notifications.error('Failure', '', 'Endpoint is unreachable. Connect to another swarm manager.'); + return; + } else if (endpoint.Status === 2 && !endpoint.Snapshots[0]) { + Notifications.error('Failure', '', 'Endpoint is unreachable and there is no snapshot available for offline browsing.'); + return; + } - function switchToAzureEndpoint(endpoint) { - EndpointProvider.setEndpointID(endpoint.Id); - EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); - EndpointProvider.setOfflineModeFromStatus(endpoint.Status); - StateManager.updateEndpointState(endpoint.Name, endpoint.Type, []) - .then(function success() { - $state.go('azure.dashboard'); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to connect to the Azure endpoint'); - }); - } + EndpointProvider.setEndpointID(endpoint.Id); + EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); + EndpointProvider.setOfflineModeFromStatus(endpoint.Status); + LegacyExtensionManager.initEndpointExtensions(endpoint) + .then(function success(data) { + var extensions = data; + return StateManager.updateEndpointState(endpoint, extensions); + }) + .then(function success() { + $state.go('docker.dashboard'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint'); + }); + } - function switchToDockerEndpoint(endpoint) { - if (endpoint.Status === 2 && endpoint.Snapshots[0] && endpoint.Snapshots[0].Swarm === true) { - Notifications.error('Failure', '', 'Endpoint is unreachable. Connect to another swarm manager.'); - return; - } else if (endpoint.Status === 2 && !endpoint.Snapshots[0]) { - Notifications.error('Failure', '', 'Endpoint is unreachable and there is no snapshot available for offline browsing.'); - return; - } + function triggerSnapshot() { + EndpointService.snapshotEndpoints() + .then(function success() { + Notifications.success('Success', 'Endpoints updated'); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'An error occured during endpoint snapshot'); + }); + } - EndpointProvider.setEndpointID(endpoint.Id); - EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); - EndpointProvider.setOfflineModeFromStatus(endpoint.Status); - LegacyExtensionManager.initEndpointExtensions(endpoint) - .then(function success(data) { - var extensions = data; - return StateManager.updateEndpointState(endpoint, extensions); - }) - .then(function success() { - $state.go('docker.dashboard'); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint'); - }); - } + function initView() { + $scope.isAdmin = Authentication.getUserDetails().role === 1; - function triggerSnapshot() { - EndpointService.snapshotEndpoints() - .then(function success() { - Notifications.success('Success', 'Endpoints updated'); - $state.reload(); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'An error occured during endpoint snapshot'); - }); - } + MotdService.motd() + .then(function success(data) { + $scope.motd = data; + }); - function initView() { - $scope.isAdmin = Authentication.getUserDetails().role === 1; + $q.all({ + endpoints: EndpointService.endpoints(), + groups: GroupService.groups() + }) + .then(function success(data) { + var endpoints = data.endpoints; + var groups = data.groups; + EndpointHelper.mapGroupNameToEndpoint(endpoints, groups); + $scope.endpoints = endpoints; + EndpointProvider.setEndpoints(endpoints); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve endpoint information'); + }); + } - MotdService.motd() - .then(function success(data) { - $scope.motd = data; - }); - - $q.all({ - endpoints: EndpointService.endpoints(), - groups: GroupService.groups() - }) - .then(function success(data) { - var endpoints = data.endpoints; - var groups = data.groups; - EndpointHelper.mapGroupNameToEndpoint(endpoints, groups); - $scope.endpoints = endpoints; - EndpointProvider.setEndpoints(endpoints); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve endpoint information'); - }); - } - - initView(); -}]); + initView(); + }]); diff --git a/app/portainer/views/registries/registries.html b/app/portainer/views/registries/registries.html index 77ef9b773..670baba7e 100644 --- a/app/portainer/views/registries/registries.html +++ b/app/portainer/views/registries/registries.html @@ -7,7 +7,7 @@ Registry management -
+
@@ -74,7 +74,7 @@ title-text="Registries" title-icon="fa-database" dataset="registries" table-key="registries" order-by="Name" - access-management="applicationState.application.authentication" + access-management="applicationState.application.authentication && isAdmin" remove-action="removeAction" registry-management="registryManagementAvailable" > diff --git a/app/portainer/views/registries/registriesController.js b/app/portainer/views/registries/registriesController.js index 373a01bdf..538d9117d 100644 --- a/app/portainer/views/registries/registriesController.js +++ b/app/portainer/views/registries/registriesController.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.controller('RegistriesController', ['$q', '$scope', '$state', 'RegistryService', 'DockerHubService', 'ModalService', 'Notifications', 'ExtensionService', -function ($q, $scope, $state, RegistryService, DockerHubService, ModalService, Notifications, ExtensionService) { +.controller('RegistriesController', ['$q', '$scope', '$state', 'RegistryService', 'DockerHubService', 'ModalService', 'Notifications', 'ExtensionService', 'Authentication', +function ($q, $scope, $state, RegistryService, DockerHubService, ModalService, Notifications, ExtensionService, Authentication) { $scope.state = { actionInProgress: false @@ -67,6 +67,12 @@ function ($q, $scope, $state, RegistryService, DockerHubService, ModalService, N $scope.registries = data.registries; $scope.dockerhub = data.dockerhub; $scope.registryManagementAvailable = data.registryManagement; + var authenticationEnabled = $scope.applicationState.application.authentication; + if (authenticationEnabled) { + var userDetails = Authentication.getUserDetails(); + var isAdmin = userDetails.role === 1; + $scope.isAdmin = isAdmin; + } }) .catch(function error(err) { $scope.registries = []; 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 +
-