feat(authentication/ldap): Auto create and assign LDAP users (#2042)

pull/2060/head^2
Olli Janatuinen 2018-07-23 07:57:38 +03:00 committed by Anthony Lapenna
parent ea7615d71c
commit cec878b01d
14 changed files with 358 additions and 44 deletions

View File

@ -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)
}

View File

@ -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) return m.versionService.StoreDBVersion(portainer.DBVersion)
} }

View File

@ -164,6 +164,9 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL
SearchSettings: []portainer.LDAPSearchSettings{ SearchSettings: []portainer.LDAPSearchSettings{
portainer.LDAPSearchSettings{}, portainer.LDAPSearchSettings{},
}, },
GroupSearchSettings: []portainer.LDAPGroupSearchSettings{
portainer.LDAPGroupSearchSettings{},
},
}, },
AllowBindMountsForRegularUsers: true, AllowBindMountsForRegularUsers: true,
AllowPrivilegedModeForRegularUsers: true, AllowPrivilegedModeForRegularUsers: true,

View File

@ -14,6 +14,7 @@ const (
ErrInvalidUsername = Error("Invalid username. White spaces are not allowed") ErrInvalidUsername = Error("Invalid username. White spaces are not allowed")
ErrAdminAlreadyInitialized = Error("An administrator user already exists") ErrAdminAlreadyInitialized = Error("An administrator user already exists")
ErrAdminCannotRemoveSelf = Error("Cannot remove your own user account. Contact another administrator") ErrAdminCannotRemoveSelf = Error("Cannot remove your own user account. Contact another administrator")
ErrCannotRemoveLastLocalAdmin = Error("Cannot remove the last local administrator account")
) )
// Team errors. // Team errors.

View File

@ -1,6 +1,7 @@
package auth package auth
import ( import (
"log"
"net/http" "net/http"
"github.com/asaskevich/govalidator" "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} 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() settings, err := handler.SettingsService.Settings()
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
} }
if settings.AuthenticationMethod == portainer.AuthenticationLDAP && u.ID != 1 { u, err := handler.UserService.UserByUsername(payload.Username)
err = handler.LDAPService.AuthenticateUser(payload.Username, payload.Password, &settings.LDAPSettings) if err != nil && err != portainer.ErrObjectNotFound {
if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a user with the specified username from the database", err}
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}
}
} }
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{ tokenData := &portainer.TokenData{
ID: u.ID, ID: user.ID,
Username: u.Username, Username: user.Username,
Role: u.Role, Role: user.Role,
} }
token, err := handler.JWTService.GenerateToken(tokenData) 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}) 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
}

View File

@ -26,6 +26,8 @@ type Handler struct {
JWTService portainer.JWTService JWTService portainer.JWTService
LDAPService portainer.LDAPService LDAPService portainer.LDAPService
SettingsService portainer.SettingsService SettingsService portainer.SettingsService
TeamService portainer.TeamService
TeamMembershipService portainer.TeamMembershipService
} }
// NewHandler creates a handler to manage authentication operations. // NewHandler creates a handler to manage authentication operations.

View File

@ -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} 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 { if err == portainer.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err} return &httperror.HandlerError{http.StatusNotFound, "Unable to find a user with the specified identifier inside the database", err}
} else if err != nil { } else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a user with the specified identifier inside the database", err} 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 { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user from the database", err} 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 { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user memberships from the database", err} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove user memberships from the database", err}
} }

View File

@ -91,6 +91,8 @@ func (server *Server) Start() error {
authHandler.JWTService = server.JWTService authHandler.JWTService = server.JWTService
authHandler.LDAPService = server.LDAPService authHandler.LDAPService = server.LDAPService
authHandler.SettingsService = server.SettingsService authHandler.SettingsService = server.SettingsService
authHandler.TeamService = server.TeamService
authHandler.TeamMembershipService = server.TeamMembershipService
var dockerHubHandler = dockerhub.NewHandler(requestBouncer) var dockerHubHandler = dockerhub.NewHandler(requestBouncer)
dockerHubHandler.DockerHubService = server.DockerHubService dockerHubHandler.DockerHubService = server.DockerHubService

View File

@ -102,12 +102,65 @@ func (*Service) AuthenticateUser(username, password string, settings *portainer.
err = connection.Bind(userDN, password) err = connection.Bind(userDN, password)
if err != nil { if err != nil {
return err return portainer.ErrUnauthorized
} }
return nil 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 // TestConnectivity is used to test a connection against the LDAP server using the credentials
// specified in the LDAPSettings. // specified in the LDAPSettings.
func (*Service) TestConnectivity(settings *portainer.LDAPSettings) error { func (*Service) TestConnectivity(settings *portainer.LDAPSettings) error {

View File

@ -52,6 +52,7 @@ type (
TLSConfig TLSConfiguration `json:"TLSConfig"` TLSConfig TLSConfiguration `json:"TLSConfig"`
StartTLS bool `json:"StartTLS"` StartTLS bool `json:"StartTLS"`
SearchSettings []LDAPSearchSettings `json:"SearchSettings"` SearchSettings []LDAPSearchSettings `json:"SearchSettings"`
GroupSearchSettings []LDAPGroupSearchSettings `json:"GroupSearchSettings"`
} }
// TLSConfiguration represents a TLS configuration. // TLSConfiguration represents a TLS configuration.
@ -70,6 +71,13 @@ type (
UserNameAttribute string `json:"UserNameAttribute"` 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 represents the application settings.
Settings struct { Settings struct {
LogoURL string `json:"LogoURL"` LogoURL string `json:"LogoURL"`
@ -581,6 +589,7 @@ type (
LDAPService interface { LDAPService interface {
AuthenticateUser(username, password string, settings *LDAPSettings) error AuthenticateUser(username, password string, settings *LDAPSettings) error
TestConnectivity(settings *LDAPSettings) error TestConnectivity(settings *LDAPSettings) error
GetUserGroups(username string, settings *LDAPSettings) ([]string, error)
} }
// SwarmStackManager represents a service to manage Swarm stacks. // SwarmStackManager represents a service to manage Swarm stacks.
@ -602,7 +611,7 @@ const (
// APIVersion is the version number of the Portainer API. // APIVersion is the version number of the Portainer API.
APIVersion = "1.18.2-dev" APIVersion = "1.18.2-dev"
// DBVersion is the version number of the Portainer database. // 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 represents the name of the header available in any agent response
PortainerAgentHeader = "Portainer-Agent" PortainerAgentHeader = "Portainer-Agent"
// PortainerAgentTargetHeader represent the name of the header containing the target node name. // PortainerAgentTargetHeader represent the name of the header containing the target node name.

View File

@ -2897,6 +2897,21 @@ definitions:
type: "string" type: "string"
example: "uid" example: "uid"
description: "LDAP attribute which denotes the username" 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: LDAPSettings:
type: "object" type: "object"
@ -2923,6 +2938,10 @@ definitions:
type: "array" type: "array"
items: items:
$ref: "#/definitions/LDAPSearchSettings" $ref: "#/definitions/LDAPSearchSettings"
GroupSearchSettings:
type: "array"
items:
$ref: "#/definitions/LDAPGroupSearchSettings"
Settings: Settings:
type: "object" type: "object"

View File

@ -3,6 +3,7 @@ function LDAPSettingsViewModel(data) {
this.Password = data.Password; this.Password = data.Password;
this.URL = data.URL; this.URL = data.URL;
this.SearchSettings = data.SearchSettings; this.SearchSettings = data.SearchSettings;
this.GroupSearchSettings = data.GroupSearchSettings;
} }
function LDAPSearchSettings(BaseDN, UsernameAttribute, Filter) { function LDAPSearchSettings(BaseDN, UsernameAttribute, Filter) {
@ -10,3 +11,9 @@ function LDAPSearchSettings(BaseDN, UsernameAttribute, Filter) {
this.UsernameAttribute = UsernameAttribute; this.UsernameAttribute = UsernameAttribute;
this.Filter = Filter; this.Filter = Filter;
} }
function LDAPGroupSearchSettings(GroupBaseDN, GroupAttribute, GroupFilter) {
this.GroupBaseDN = GroupBaseDN;
this.GroupAttribute = GroupAttribute;
this.GroupFilter = GroupFilter;
}

View File

@ -49,10 +49,10 @@
</div> </div>
<div class="form-group" ng-if="settings.AuthenticationMethod === 2"> <div class="form-group" ng-if="settings.AuthenticationMethod === 2">
<span class="col-sm-12 text-muted small"> <span class="col-sm-12 text-muted small">
When using LDAP authentication, Portainer will delegate user authentication to a LDAP server (exception for the <b>admin</b> 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.
<p style="margin-top:5px;"> <p style="margin-top:5px;">
<i class="fa fa-exclamation-triangle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i> <i class="fa fa-exclamation-triangle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
<u>Users still need to be created in Portainer beforehand.</u> <u>Portainer will create user(s) automatically with standard user role and assign them to team(s) which matches to LDAP group name(s).</u>
</p> </p>
</span> </span>
</div> </div>
@ -229,12 +229,66 @@
<div class="form-group"> <div class="form-group">
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addSearchConfiguration()"> <span class="label label-default interactive" style="margin-left: 10px;" ng-click="addSearchConfiguration()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add search configuration <i class="fa fa-plus-circle" aria-hidden="true"></i> add user search configuration
</span> </span>
</div> </div>
</div> </div>
<!-- !search-settings --> <!-- !search-settings -->
<div class="col-sm-12 form-section-title">
Group search configurations
</div>
<!-- group-search-settings -->
<div ng-repeat="groupConfig in LDAPSettings.GroupSearchSettings | limitTo: (1 - LDAPSettings.GroupSearchSettings)" style="margin-top: 5px;">
<div class="form-group" ng-if="$index > 0">
<span class="col-sm-12 text-muted small">
Extra search configuration
</span>
</div>
<div class="form-group">
<label for="ldap_group_basedn_{{$index}}" class="col-sm-4 col-md-2 control-label text-left">
Group Base DN
<portainer-tooltip position="bottom" message="The distinguished name of the element from which the LDAP server will search for groups."></portainer-tooltip>
</label>
<div class="col-sm-8 col-md-4">
<input type="text" class="form-control" id="ldap_group_basedn_{{$index}}" ng-model="groupConfig.GroupBaseDN" placeholder="dc=ldap,dc=domain,dc=tld">
</div>
<label for="ldap_group_att_{{$index}}" class="col-sm-4 col-md-3 col-lg-2 margin-sm-top control-label text-left">
Group Membership Attribute
<portainer-tooltip position="bottom" message="LDAP attribute which denotes the group membership."></portainer-tooltip>
</label>
<div class="col-sm-8 col-md-3 col-lg-4 margin-sm-top">
<input type="text" class="form-control" id="ldap_group_att_{{$index}}" ng-model="groupConfig.GroupAttribute" placeholder="member">
</div>
</div>
<div class="form-group">
<label for="ldap_group_filter_{{$index}}" class="col-sm-4 col-md-2 control-label text-left">
Group Filter
<portainer-tooltip position="bottom" message="The LDAP search filter used to select group elements, optional."></portainer-tooltip>
</label>
<div class="col-sm-7 col-md-9">
<input type="text" class="form-control" id="ldap_group_filter_{{$index}}" ng-model="groupConfig.GroupFilter" placeholder="(objectClass=account)">
</div>
<div class="col-sm-1" ng-if="$index > 0">
<button class="btn btn-sm btn-danger" type="button" ng-click="removeGroupSearchConfiguration($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="form-group">
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addGroupSearchConfiguration()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add group search configuration
</span>
</div>
</div>
<!-- !group-search-settings -->
</div> </div>
<!-- actions --> <!-- actions -->

View File

@ -22,6 +22,14 @@ function ($q, $scope, Notifications, SettingsService, FileUploadService) {
$scope.LDAPSettings.SearchSettings.splice(index, 1); $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() { $scope.LDAPConnectivityCheck = function() {
var settings = $scope.settings; var settings = $scope.settings;
var TLSCAFile = $scope.formValues.TLSCACert !== settings.LDAPSettings.TLSConfig.TLSCACert ? $scope.formValues.TLSCACert : null; var TLSCAFile = $scope.formValues.TLSCACert !== settings.LDAPSettings.TLSConfig.TLSCACert ? $scope.formValues.TLSCACert : null;