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

pull/2060/head^2
Olli Janatuinen 6 years ago committed by Anthony Lapenna
parent ea7615d71c
commit cec878b01d

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

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

@ -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,

@ -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.

@ -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}
}
settings, err := handler.SettingsService.Settings()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
}
u, err := handler.UserService.UserByUsername(payload.Username)
if err == portainer.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid credentials", ErrInvalidCredentials}
} else if err != nil {
if err != nil && err != portainer.ErrObjectNotFound {
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 err == portainer.ErrObjectNotFound && settings.AuthenticationMethod == portainer.AuthenticationInternal {
return &httperror.HandlerError{http.StatusUnprocessableEntity, "Invalid credentials", portainer.ErrUnauthorized}
}
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}
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
}

@ -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.

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

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

@ -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 {

@ -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.

@ -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"

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

@ -49,10 +49,10 @@
</div>
<div class="form-group" ng-if="settings.AuthenticationMethod === 2">
<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;">
<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>
</span>
</div>
@ -229,12 +229,66 @@
<div class="form-group">
<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>
</div>
</div>
<!-- !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>
<!-- actions -->

@ -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;

Loading…
Cancel
Save