diff --git a/api/bolt/migrator/migrate_dbversion12.go b/api/bolt/migrator/migrate_dbversion12.go new file mode 100644 index 000000000..465c10682 --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion12.go @@ -0,0 +1,16 @@ +package migrator + +import "github.com/portainer/portainer" + +func (m *Migrator) updateSettingsToVersion13() error { + legacySettings, err := m.settingsService.Settings() + if err != nil { + return err + } + + legacySettings.LDAPSettings.GroupSearchSettings = []portainer.LDAPGroupSearchSettings{ + portainer.LDAPGroupSearchSettings{}, + } + + return m.settingsService.UpdateSettings(legacySettings) +} diff --git a/api/bolt/migrator/migrator.go b/api/bolt/migrator/migrator.go index 5e32366ff..efae952d8 100644 --- a/api/bolt/migrator/migrator.go +++ b/api/bolt/migrator/migrator.go @@ -170,5 +170,12 @@ func (m *Migrator) Migrate() error { } } + if m.currentDBVersion < 13 { + err := m.updateSettingsToVersion13() + if err != nil { + return err + } + } + return m.versionService.StoreDBVersion(portainer.DBVersion) } diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index b1a41e8a1..4c9ddfea6 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -164,6 +164,9 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL SearchSettings: []portainer.LDAPSearchSettings{ portainer.LDAPSearchSettings{}, }, + GroupSearchSettings: []portainer.LDAPGroupSearchSettings{ + portainer.LDAPGroupSearchSettings{}, + }, }, AllowBindMountsForRegularUsers: true, AllowPrivilegedModeForRegularUsers: true, diff --git a/api/errors.go b/api/errors.go index 9e4eb70e7..37552f104 100644 --- a/api/errors.go +++ b/api/errors.go @@ -10,10 +10,11 @@ const ( // User errors. const ( - ErrUserAlreadyExists = Error("User already exists") - ErrInvalidUsername = Error("Invalid username. White spaces are not allowed") - ErrAdminAlreadyInitialized = Error("An administrator user already exists") - ErrAdminCannotRemoveSelf = Error("Cannot remove your own user account. Contact another administrator") + ErrUserAlreadyExists = Error("User already exists") + ErrInvalidUsername = Error("Invalid username. White spaces are not allowed") + ErrAdminAlreadyInitialized = Error("An administrator user already exists") + ErrAdminCannotRemoveSelf = Error("Cannot remove your own user account. Contact another administrator") + ErrCannotRemoveLastLocalAdmin = Error("Cannot remove the last local administrator account") ) // Team errors. diff --git a/api/http/handler/auth/authenticate.go b/api/http/handler/auth/authenticate.go index b146352a8..e473e60fc 100644 --- a/api/http/handler/auth/authenticate.go +++ b/api/http/handler/auth/authenticate.go @@ -1,6 +1,7 @@ package auth import ( + "log" "net/http" "github.com/asaskevich/govalidator" @@ -40,34 +41,82 @@ func (handler *Handler) authenticate(w http.ResponseWriter, r *http.Request) *ht return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - u, err := handler.UserService.UserByUsername(payload.Username) - if err == portainer.ErrObjectNotFound { - return &httperror.HandlerError{http.StatusBadRequest, "Invalid credentials", ErrInvalidCredentials} - } else if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", 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 == portainer.AuthenticationLDAP && u.ID != 1 { - err = handler.LDAPService.AuthenticateUser(payload.Username, payload.Password, &settings.LDAPSettings) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to authenticate user via LDAP/AD", err} - } - } else { - err = handler.CryptoService.CompareHashAndData(u.Password, payload.Password) - if err != nil { - return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", ErrInvalidCredentials} - } + u, err := handler.UserService.UserByUsername(payload.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 err == portainer.ErrObjectNotFound && settings.AuthenticationMethod == portainer.AuthenticationInternal { + return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", portainer.ErrUnauthorized} + } + + if settings.AuthenticationMethod == portainer.AuthenticationLDAP { + if u == nil { + return handler.authenticateLDAPAndCreateUser(w, payload.Username, payload.Password, &settings.LDAPSettings) + } + return handler.authenticateLDAP(w, u, payload.Password, &settings.LDAPSettings) + } + + return handler.authenticateInternal(w, u, payload.Password) +} + +func (handler *Handler) authenticateLDAP(w http.ResponseWriter, user *portainer.User, password string, ldapSettings *portainer.LDAPSettings) *httperror.HandlerError { + err := handler.LDAPService.AuthenticateUser(user.Username, password, ldapSettings) + if err != nil { + return handler.authenticateInternal(w, user, password) + } + + err = handler.addUserIntoTeams(user, ldapSettings) + if err != nil { + log.Printf("Warning: unable to automatically add user into teams: %s\n", err.Error()) + } + + return handler.writeToken(w, user) +} + +func (handler *Handler) authenticateInternal(w http.ResponseWriter, user *portainer.User, password string) *httperror.HandlerError { + err := handler.CryptoService.CompareHashAndData(user.Password, password) + if err != nil { + return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", portainer.ErrUnauthorized} + } + + return handler.writeToken(w, user) +} + +func (handler *Handler) authenticateLDAPAndCreateUser(w http.ResponseWriter, username, password string, ldapSettings *portainer.LDAPSettings) *httperror.HandlerError { + err := handler.LDAPService.AuthenticateUser(username, password, ldapSettings) + if err != nil { + return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", err} + } + + 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} + } + + err = handler.addUserIntoTeams(user, ldapSettings) + if err != nil { + log.Printf("Warning: unable to automatically add user into teams: %s\n", err.Error()) + } + + return handler.writeToken(w, user) +} + +func (handler *Handler) writeToken(w http.ResponseWriter, user *portainer.User) *httperror.HandlerError { tokenData := &portainer.TokenData{ - ID: u.ID, - Username: u.Username, - Role: u.Role, + ID: user.ID, + Username: user.Username, + Role: user.Role, } token, err := handler.JWTService.GenerateToken(tokenData) @@ -77,3 +126,59 @@ func (handler *Handler) authenticate(w http.ResponseWriter, r *http.Request) *ht return response.JSON(w, &authenticateResponse{JWT: token}) } + +func (handler *Handler) addUserIntoTeams(user *portainer.User, settings *portainer.LDAPSettings) error { + teams, err := handler.TeamService.Teams() + if err != nil { + return err + } + + userGroups, err := handler.LDAPService.GetUserGroups(user.Username, settings) + if err != nil { + return err + } + + userMemberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(user.ID) + if err != nil { + return err + } + + for _, team := range teams { + if teamExists(team.Name, userGroups) { + + if teamMembershipExists(team.ID, userMemberships) { + continue + } + + membership := &portainer.TeamMembership{ + UserID: user.ID, + TeamID: team.ID, + Role: portainer.TeamMember, + } + + err := handler.TeamMembershipService.CreateTeamMembership(membership) + if err != nil { + return err + } + } + } + return nil +} + +func teamExists(teamName string, ldapGroups []string) bool { + for _, group := range ldapGroups { + if group == teamName { + return true + } + } + return false +} + +func teamMembershipExists(teamID portainer.TeamID, memberships []portainer.TeamMembership) bool { + for _, membership := range memberships { + if membership.TeamID == teamID { + return true + } + } + return false +} diff --git a/api/http/handler/auth/handler.go b/api/http/handler/auth/handler.go index db47b82e2..87be85429 100644 --- a/api/http/handler/auth/handler.go +++ b/api/http/handler/auth/handler.go @@ -20,12 +20,14 @@ const ( // Handler is the HTTP handler used to handle authentication operations. type Handler struct { *mux.Router - authDisabled bool - UserService portainer.UserService - CryptoService portainer.CryptoService - JWTService portainer.JWTService - LDAPService portainer.LDAPService - SettingsService portainer.SettingsService + authDisabled bool + UserService portainer.UserService + CryptoService portainer.CryptoService + JWTService portainer.JWTService + LDAPService portainer.LDAPService + SettingsService portainer.SettingsService + TeamService portainer.TeamService + TeamMembershipService portainer.TeamMembershipService } // NewHandler creates a handler to manage authentication operations. diff --git a/api/http/handler/users/user_delete.go b/api/http/handler/users/user_delete.go index c183df7a7..90a5b52cc 100644 --- a/api/http/handler/users/user_delete.go +++ b/api/http/handler/users/user_delete.go @@ -26,19 +26,47 @@ func (handler *Handler) userDelete(w http.ResponseWriter, r *http.Request) *http return &httperror.HandlerError{http.StatusForbidden, "Cannot remove your own user account. Contact another administrator", portainer.ErrAdminCannotRemoveSelf} } - _, err = handler.UserService.User(portainer.UserID(userID)) + user, err := handler.UserService.User(portainer.UserID(userID)) if err == portainer.ErrObjectNotFound { return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} } else if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} } - err = handler.UserService.DeleteUser(portainer.UserID(userID)) + if user.Role == portainer.AdministratorRole { + return handler.deleteAdminUser(w, user) + } + + return handler.deleteUser(w, user) +} + +func (handler *Handler) deleteAdminUser(w http.ResponseWriter, user *portainer.User) *httperror.HandlerError { + users, err := handler.UserService.Users() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve users from the database", err} + } + + localAdminCount := 0 + for _, u := range users { + if u.Role == portainer.AdministratorRole && u.Password != "" { + localAdminCount++ + } + } + + if localAdminCount < 2 { + return &httperror.HandlerError{http.StatusInternalServerError, "Cannot remove local administrator user", portainer.ErrCannotRemoveLastLocalAdmin} + } + + return handler.deleteUser(w, user) +} + +func (handler *Handler) deleteUser(w http.ResponseWriter, user *portainer.User) *httperror.HandlerError { + err := handler.UserService.DeleteUser(portainer.UserID(user.ID)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user from the database", err} } - err = handler.TeamMembershipService.DeleteTeamMembershipByUserID(portainer.UserID(userID)) + err = handler.TeamMembershipService.DeleteTeamMembershipByUserID(portainer.UserID(user.ID)) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user memberships from the database", err} } diff --git a/api/http/server.go b/api/http/server.go index a6941d1f3..564e24e3b 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -91,6 +91,8 @@ func (server *Server) Start() error { authHandler.JWTService = server.JWTService authHandler.LDAPService = server.LDAPService authHandler.SettingsService = server.SettingsService + authHandler.TeamService = server.TeamService + authHandler.TeamMembershipService = server.TeamMembershipService var dockerHubHandler = dockerhub.NewHandler(requestBouncer) dockerHubHandler.DockerHubService = server.DockerHubService diff --git a/api/ldap/ldap.go b/api/ldap/ldap.go index 7b72b8930..528a92e7f 100644 --- a/api/ldap/ldap.go +++ b/api/ldap/ldap.go @@ -102,12 +102,65 @@ func (*Service) AuthenticateUser(username, password string, settings *portainer. err = connection.Bind(userDN, password) if err != nil { - return err + return portainer.ErrUnauthorized } return nil } +// GetUserGroups is used to retrieve user groups from LDAP/AD. +func (*Service) GetUserGroups(username string, settings *portainer.LDAPSettings) ([]string, error) { + connection, err := createConnection(settings) + if err != nil { + return nil, err + } + defer connection.Close() + + err = connection.Bind(settings.ReaderDN, settings.Password) + if err != nil { + return nil, err + } + + userDN, err := searchUser(username, connection, settings.SearchSettings) + if err != nil { + return nil, err + } + + userGroups := getGroups(userDN, connection, settings.GroupSearchSettings) + + return userGroups, nil +} + +// Get a list of group names for specified user from LDAP/AD +func getGroups(userDN string, conn *ldap.Conn, settings []portainer.LDAPGroupSearchSettings) []string { + groups := make([]string, 0) + + for _, searchSettings := range settings { + searchRequest := ldap.NewSearchRequest( + searchSettings.GroupBaseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(&%s(%s=%s))", searchSettings.GroupFilter, searchSettings.GroupAttribute, userDN), + []string{"cn"}, + nil, + ) + + // Deliberately skip errors on the search request so that we can jump to other search settings + // if any issue arise with the current one. + sr, err := conn.Search(searchRequest) + if err != nil { + continue + } + + for _, entry := range sr.Entries { + for _, attr := range entry.Attributes { + groups = append(groups, attr.Values[0]) + } + } + } + + return groups +} + // TestConnectivity is used to test a connection against the LDAP server using the credentials // specified in the LDAPSettings. func (*Service) TestConnectivity(settings *portainer.LDAPSettings) error { diff --git a/api/portainer.go b/api/portainer.go index b4a9b4e5f..610e0bdac 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -46,12 +46,13 @@ type ( // LDAPSettings represents the settings used to connect to a LDAP server. LDAPSettings struct { - ReaderDN string `json:"ReaderDN"` - Password string `json:"Password"` - URL string `json:"URL"` - TLSConfig TLSConfiguration `json:"TLSConfig"` - StartTLS bool `json:"StartTLS"` - SearchSettings []LDAPSearchSettings `json:"SearchSettings"` + ReaderDN string `json:"ReaderDN"` + Password string `json:"Password"` + URL string `json:"URL"` + TLSConfig TLSConfiguration `json:"TLSConfig"` + StartTLS bool `json:"StartTLS"` + SearchSettings []LDAPSearchSettings `json:"SearchSettings"` + GroupSearchSettings []LDAPGroupSearchSettings `json:"GroupSearchSettings"` } // TLSConfiguration represents a TLS configuration. @@ -70,6 +71,13 @@ type ( UserNameAttribute string `json:"UserNameAttribute"` } + // LDAPGroupSearchSettings represents settings used to search for groups in a LDAP server. + LDAPGroupSearchSettings struct { + GroupBaseDN string `json:"GroupBaseDN"` + GroupFilter string `json:"GroupFilter"` + GroupAttribute string `json:"GroupAttribute"` + } + // Settings represents the application settings. Settings struct { LogoURL string `json:"LogoURL"` @@ -581,6 +589,7 @@ type ( LDAPService interface { AuthenticateUser(username, password string, settings *LDAPSettings) error TestConnectivity(settings *LDAPSettings) error + GetUserGroups(username string, settings *LDAPSettings) ([]string, error) } // SwarmStackManager represents a service to manage Swarm stacks. @@ -602,7 +611,7 @@ const ( // APIVersion is the version number of the Portainer API. APIVersion = "1.18.2-dev" // DBVersion is the version number of the Portainer database. - DBVersion = 12 + DBVersion = 13 // 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. diff --git a/api/swagger.yaml b/api/swagger.yaml index d936a710c..fefd61d33 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -2897,6 +2897,21 @@ definitions: type: "string" example: "uid" description: "LDAP attribute which denotes the username" + LDAPGroupSearchSettings: + type: "object" + properties: + GroupBaseDN: + type: "string" + example: "dc=ldap,dc=domain,dc=tld" + description: "The distinguished name of the element from which the LDAP server will search for groups." + GroupFilter: + type: "string" + example: "(objectClass=account)" + description: "The LDAP search filter used to select group elements, optional." + GroupAttribute: + type: "string" + example: "member" + description: "LDAP attribute which denotes the group membership." LDAPSettings: type: "object" @@ -2923,6 +2938,10 @@ definitions: type: "array" items: $ref: "#/definitions/LDAPSearchSettings" + GroupSearchSettings: + type: "array" + items: + $ref: "#/definitions/LDAPGroupSearchSettings" Settings: type: "object" diff --git a/app/portainer/models/settings/ldapSettings.js b/app/portainer/models/settings/ldapSettings.js index 2da574598..3bd02225c 100644 --- a/app/portainer/models/settings/ldapSettings.js +++ b/app/portainer/models/settings/ldapSettings.js @@ -3,6 +3,7 @@ function LDAPSettingsViewModel(data) { this.Password = data.Password; this.URL = data.URL; this.SearchSettings = data.SearchSettings; + this.GroupSearchSettings = data.GroupSearchSettings; } function LDAPSearchSettings(BaseDN, UsernameAttribute, Filter) { @@ -10,3 +11,9 @@ function LDAPSearchSettings(BaseDN, UsernameAttribute, Filter) { this.UsernameAttribute = UsernameAttribute; this.Filter = Filter; } + +function LDAPGroupSearchSettings(GroupBaseDN, GroupAttribute, GroupFilter) { + this.GroupBaseDN = GroupBaseDN; + this.GroupAttribute = GroupAttribute; + this.GroupFilter = GroupFilter; +} diff --git a/app/portainer/views/settings/authentication/settingsAuthentication.html b/app/portainer/views/settings/authentication/settingsAuthentication.html index 9017b2470..fad5137d3 100644 --- a/app/portainer/views/settings/authentication/settingsAuthentication.html +++ b/app/portainer/views/settings/authentication/settingsAuthentication.html @@ -49,10 +49,10 @@
- When using LDAP authentication, Portainer will delegate user authentication to a LDAP server (exception for the admin user that always uses internal authentication). + When using LDAP authentication, Portainer will delegate user authentication to a LDAP server and fallback to internal authentication if LDAP authentication fails.

- Users still need to be created in Portainer beforehand. + Portainer will create user(s) automatically with standard user role and assign them to team(s) which matches to LDAP group name(s).

@@ -229,12 +229,66 @@
- add search configuration + add user search configuration
+ +
+ Group search configurations +
+ + +
+ +
+ + Extra search configuration + +
+ +
+ +
+ +
+ + +
+ +
+
+
+ +
+ +
+
+ +
+
+ +
+ + add group search configuration + +
+ +
+ diff --git a/app/portainer/views/settings/authentication/settingsAuthenticationController.js b/app/portainer/views/settings/authentication/settingsAuthenticationController.js index f47d01a8f..8c617678b 100644 --- a/app/portainer/views/settings/authentication/settingsAuthenticationController.js +++ b/app/portainer/views/settings/authentication/settingsAuthenticationController.js @@ -21,6 +21,14 @@ function ($q, $scope, Notifications, SettingsService, FileUploadService) { $scope.removeSearchConfiguration = function(index) { $scope.LDAPSettings.SearchSettings.splice(index, 1); }; + + $scope.addGroupSearchConfiguration = function() { + $scope.LDAPSettings.GroupSearchSettings.push({ GroupBaseDN: '', GroupAttribute: '', GroupFilter: '' }); + }; + + $scope.removeGroupSearchConfiguration = function(index) { + $scope.LDAPSettings.GroupSearchSettings.splice(index, 1); + }; $scope.LDAPConnectivityCheck = function() { var settings = $scope.settings;