Index: app/models/auth_source_ldap.rb =================================================================== --- app/models/auth_source_ldap.rb (Revision 10) +++ app/models/auth_source_ldap.rb (Arbeitskopie) @@ -33,34 +33,67 @@ def authenticate(login, password) return nil if login.blank? || password.blank? + attrs = get_attributes(login) + dn = attrs.first.delete(:dn) unless attrs.nil? + # authenticate user + ldap_con = initialize_ldap_con(dn, password) # add ", login" for fake LDAP tests + return nil unless ldap_con.bind + # return user's attributes + logger.debug "Authentication successful for '#{login}'" if logger && logger.debug? + attrs + rescue Net::LDAP::LdapError => text + raise "LdapError: " + text + end + + def get_attributes(login) + return nil if login.blank? attrs = [] # get user's DN - ldap_con = initialize_ldap_con(self.account, self.account_password) + ldap_con = initialize_ldap_con(self.account, self.account_password) # add ", login" for fake LDAP tests login_filter = Net::LDAP::Filter.eq( self.attr_login, login ) object_filter = Net::LDAP::Filter.eq( "objectClass", "*" ) + # Only ask for attributes that will be used + query_attrs = ['dn'] + query_attrs << self.attr_firstname << self.attr_lastname << self.attr_mail if onthefly_register? + query_attrs << self.attr_groups unless self.attr_groups.blank? + query_attrs << self.attr_groups2 unless self.attr_groups2.blank? dn = String.new ldap_con.search( :base => self.base_dn, :filter => object_filter & login_filter, - # only ask for the DN if on-the-fly registration is disabled - :attributes=> (onthefly_register? ? ['dn', self.attr_firstname, self.attr_lastname, self.attr_mail] : ['dn'])) do |entry| + :attributes=> query_attrs) do |entry| + logger.debug "yielded" dn = entry.dn attrs = [:firstname => AuthSourceLdap.get_attr(entry, self.attr_firstname), :lastname => AuthSourceLdap.get_attr(entry, self.attr_lastname), :mail => AuthSourceLdap.get_attr(entry, self.attr_mail), - :auth_source_id => self.id ] if onthefly_register? + :group_names => build_names(AuthSourceLdap.get_attr_list(entry, self.attr_groups), + AuthSourceLdap.get_attr_list(entry, self.attr_groups2)), + :auth_source_id => self.id ] if onthefly_register? end return nil if dn.empty? logger.debug "DN found for #{login}: #{dn}" if logger && logger.debug? - # authenticate user - ldap_con = initialize_ldap_con(dn, password) - return nil unless ldap_con.bind - # return user's attributes - logger.debug "Authentication successful for '#{login}'" if logger && logger.debug? - attrs + attrs.first[:dn] = dn + attrs rescue Net::LDAP::LdapError => text raise "LdapError: " + text end + def build_names(names, names2) + all = [] + names.each do |n| + all << Group.shorten_lastname(self.group_prefix, n) + end + names2.each do |n| + all << Group.shorten_lastname(self.group_prefix, n) + end + names.each do |n| + names2.each do |n2| + all << Group.shorten_lastname(self.group_prefix, n + self.group_separator + n2) + end + end if self.cross_product + all + end + # test the connection to the LDAP def test_connection ldap_con = initialize_ldap_con(self.account, self.account_password) @@ -81,13 +114,21 @@ end end - def initialize_ldap_con(ldap_user, ldap_password) - options = { :host => self.host, - :port => self.port, - :encryption => (self.tls ? :simple_tls : nil) - } - options.merge!(:auth => { :method => :simple, :username => ldap_user, :password => ldap_password }) unless ldap_user.blank? && ldap_password.blank? - Net::LDAP.new options + # By passing an optional third parameter (the login name), the fake classes + # below will be used (more comfortable for debugging). Anyone logging in + # with a login matching "firstname.lastname" is considered to be in this + # fake LDAP, any password matches. Some groups will also be set + def initialize_ldap_con(ldap_user, ldap_password, fake = nil) + if (fake != nil) + FakeLdapCon.new fake + else + options = { :host => self.host, + :port => self.port, + :encryption => (self.tls ? :simple_tls : nil) + } + options.merge!(:auth => { :method => :simple, :username => ldap_user, :password => ldap_password }) unless ldap_user.blank? && ldap_password.blank? + Net::LDAP.new options + end end def self.get_attr(entry, attr_name) @@ -95,4 +136,73 @@ entry[attr_name].is_a?(Array) ? entry[attr_name].first : entry[attr_name] end end + + def self.get_attr_list(entry, attr_name) + if !attr_name.blank? + entry[attr_name].is_a?(Array) ? entry[attr_name] : [entry[attr_name]] + end + end end + +class FakeLdapCon + def initialize(first_dot_last) + parts = first_dot_last.split('.') + @first = parts[0] + @last = parts[1] + end + + def first + @first + end + + def last + @last + end + + def to_s + "FakeLdap(#{@first}.#{last})" + end + + def search(query, &block) + yield FakeLdapEntry.new(@first, @last) unless @last.blank? + end + + def bind + !@last.blank? + end +end + +class FakeLdapEntry + def initialize(first, last) + @first = first + @last = last + end + + def dn + @first + "." + @last + end + + def givenName + @first + end + + def sn + @last + end + + def mail + self.dn + "@example.org" + end + + def ou + [@first, @last, "everyone"] + end + + def businessCategory + @first.length.odd? ? "S" : "A" + end + + def [](entry) + self.send(entry) + end +end Index: app/models/user.rb =================================================================== --- app/models/user.rb (Revision 10) +++ app/models/user.rb (Arbeitskopie) @@ -49,6 +49,7 @@ attr_accessor :password, :password_confirmation attr_accessor :last_before_login_on + attr_accessor :group_names # Prevents unauthorized assignments attr_protected :login, :admin, :password, :password_confirmation, :hashed_password, :group_ids @@ -102,7 +103,11 @@ return nil if !user.active? if user.auth_source # user has an external authentication method - return nil unless user.auth_source.authenticate(login, password) + attrs = user.auth_source.authenticate(login, password) + logger.debug attrs.inspect + return nil unless attrs = user.auth_source.authenticate(login, password) + user.group_names = attrs.first[:group_names] + user.refresh_group_memberships else # authentication with local password return nil unless User.hash_password(password) == user.hashed_password @@ -117,6 +122,7 @@ if user.save user.reload logger.info("User '#{user.login}' created from the LDAP") if logger + user.refresh_group_memberships end end end @@ -134,6 +140,7 @@ token = tokens.first if (token.created_on > Setting.autologin.to_i.day.ago) && token.user && token.user.active? token.user.update_attribute(:last_login_on, Time.now) + token.user.refresh_group_memberships if token.user.auth_source token.user end end @@ -302,7 +309,7 @@ false end end - + def self.current=(user) @current_user = user end @@ -321,6 +328,36 @@ end anonymous_user end + + def refresh_group_memberships + return nil if !self.auth_source + # (re-)import the information from LDAP if necessary + if group_names.nil? + attrs = self.auth_source.get_attributes(self.login) + if attrs.nil? + logger.error("Refreshing group memberships not possible for #{self.login}") + return nil + end + self.group_names = attrs.first[:group_names] + end + # Add user to groups she is not in yet + prefixed_names = {} + self.group_names.each do |name| + prefixed_names[name] = true + group = Group.find_or_create_by_lastname(name) + if !group.users.exists?(self) + group.auth_source_id = self.auth_source_id # Mark as LDAP-maintained + group.users << self + group.save + end + end + # Remove user from groups she is no longer in yet; remove empty groups + gg = Group.find(:all, :conditions => ["auth_source_id = ?", self.auth_source_id]).each do |g| + g.users.delete(self) if g.users.exists?(self) && !prefixed_names.key?(g.lastname) + Group.destroy(g.id) if g.users.count == 0 + end + true + end protected Index: app/models/auth_source.rb =================================================================== --- app/models/auth_source.rb (Revision 10) +++ app/models/auth_source.rb (Arbeitskopie) @@ -17,6 +17,7 @@ class AuthSource < ActiveRecord::Base has_many :users + has_many :groups validates_presence_of :name validates_uniqueness_of :name @@ -46,4 +47,47 @@ end return nil end + + def self.import(login) + auth = get_data(login) + logger.debug("auth is #{auth.class.to_s}") + if auth && auth.size == 1 + a = auth.first + a.each { |key, value| logger.debug("#{key} => #{value}") } + a.delete(:dn) + user = User.new(a) + user.login = login + user.language = Setting.default_language + user.admin = false # Just to be sure + if user.save + logger.debug("successful created") + user.refresh_group_memberships + return user + else + logger.debug("failed to create") + return nil + end + else + logger.debug("User not found among those sources available for on-the-fly creation") + return nil + end + end + + private + + # Try to import a user not yet registered against available sources + def self.get_data(login) + AuthSource.find(:all, :conditions => ["onthefly_register=?", true]).each do |source| + begin + logger.debug "Importing '#{login}' from '#{source.name}'" if logger && logger.debug? + logger.debug "Using class #{source.class.to_s}" if logger && logger.debug? + attrs = source.get_attributes(login) + rescue => e + logger.error "Error during import: #{e.message}" + attrs = nil + end + return attrs if attrs + end + return nil + end end Index: app/models/group.rb =================================================================== --- app/models/group.rb (Revision 10) +++ app/models/group.rb (Arbeitskopie) @@ -24,7 +24,18 @@ validates_presence_of :lastname validates_uniqueness_of :lastname, :case_sensitive => false validates_length_of :lastname, :maximum => 30 - + + # Remove slash- or space-separated entities until it fits into the maxlength + # If the last part is too long, just take the ending maxlength chars + def self.shorten_lastname(prefix, lastname) + save = lastname + while !lastname.nil? && lastname.length+prefix.length > 30 + dummy, lastname = lastname.split(/[ \/]/, 2) + end + lastname = save[-30+prefix.length,30] if lastname.nil? || lastname.empty? + prefix + lastname + end + def to_s lastname.to_s end @@ -45,4 +56,27 @@ :conditions => ["#{Member.table_name}.user_id = ? AND #{MemberRole.table_name}.inherited_from IN (?)", user.id, member.member_role_ids]).each(&:destroy) end end + + def size_and_updated_by_string + updated_by = updated_by_string + if updated_by == nil + return users.size + else + return "#{users.size} (#{updated_by_string})" + end + end + + def updated_by_string + source_id = auth_source_id.to_i + if source_id == 0 + return nil + else + source = AuthSource.find_by_id(source_id) + if source == nil + return l(:text_group_updated_by_unknown, :id => source_id) + else + return l(:text_group_updated_by, :name => source.name, :type => source.auth_method_name) + end + end + end end Index: app/controllers/members_controller.rb =================================================================== --- app/controllers/members_controller.rb (Revision 10) +++ app/controllers/members_controller.rb (Arbeitskopie) @@ -24,6 +24,24 @@ members = [] if params[:member] && request.post? attrs = params[:member].dup + # When no user is selected but the name does match a user + # in LDAP, which has not yet been imported, then go and import the + # user from LDAP and add it to the project. Multiple names may be + # separated by whitespace. + if (! attrs.has_key?(:user_ids) && ! params[:principal_search].empty?) + attrs[:user_ids] = [] + newUser = nil + params[:principal_search].split.each do |login| + newUser = AuthSource.import(login) + if newUser + logger.info("Imported AuthSource as #{newUser}") + else + newUser = User.first(:conditions => ["login = ?", login]) + end + attrs[:user_ids] << newUser.id if newUser + logger.debug("Would join entries #{attrs[:user_ids].inspect}") + end + end if (user_ids = attrs.delete(:user_ids)) user_ids.each do |user_id| members << Member.new(attrs.merge(:user_id => user_id)) Index: app/controllers/auth_sources_controller.rb =================================================================== --- app/controllers/auth_sources_controller.rb (Revision 10) +++ app/controllers/auth_sources_controller.rb (Arbeitskopie) @@ -72,6 +72,24 @@ end redirect_to :action => 'list' end + + def refresh_groups + success_count = 0 + error_users = [] + User.active.find(:all, :conditions => ['auth_source_id = ?', params[:id]]).each do |u| + if u.refresh_group_memberships.nil? + error_users << u.login + else + success_count = success_count + 1 + end + end + if (error_users.size == 0) + flash[:notice] = l(:text_group_refreshed, :count => success_count) + else + flash[:error] = l(:text_group_refresh_failed, :errors => error_users.size, :failures => error_users.join(", "), :success => success_count) + end + redirect_to :action => 'list' + end def destroy @auth_source = AuthSource.find(params[:id]) Index: app/views/auth_sources/list.rhtml =================================================================== --- app/views/auth_sources/list.rhtml (Revision 10) +++ app/views/auth_sources/list.rhtml (Arbeitskopie) @@ -20,6 +20,7 @@ <%= source.host %> <%= source.users.count %> + <%= link_to l(:button_refresh), :action => 'refresh_groups', :id => source unless source.attr_groups.blank? %> <%= link_to l(:button_test), :action => 'test_connection', :id => source %> <%= link_to l(:button_delete), { :action => 'destroy', :id => source }, :method => :post, Index: app/views/auth_sources/_form.rhtml =================================================================== --- app/views/auth_sources/_form.rhtml (Revision 10) +++ app/views/auth_sources/_form.rhtml (Arbeitskopie) @@ -39,6 +39,25 @@

<%= text_field 'auth_source', 'attr_mail', :size => 20 %>

+ +

+<%= text_field 'auth_source', 'attr_groups', :size => 20 %>

+ +

+<%= text_field 'auth_source', 'attr_groups2', :size => 20 %>

+ + +
<%=l(:label_group_option_plural)%> +

+<%= text_field 'auth_source', 'group_prefix', :size => 20 %>

+ +

+<%= check_box 'auth_source', 'cross_product' %>

+ +

+<%= text_field 'auth_source', 'group_separator', :size => 20 %>

+
+ Index: app/views/groups/index.html.erb =================================================================== --- app/views/groups/index.html.erb (Revision 10) +++ app/views/groups/index.html.erb (Arbeitskopie) @@ -13,9 +13,9 @@ <% @groups.each do |group| %> - + <%= link_to h(group), :action => 'edit', :id => group %> - <%= group.users.size %> + <%= group.size_and_updated_by_string %> <%= link_to l(:button_delete), group, :confirm => l(:text_are_you_sure), :method => :delete, :class => 'icon icon-del' %> <% end %> Index: app/views/groups/_form.html.erb =================================================================== --- app/views/groups/_form.html.erb (Revision 10) +++ app/views/groups/_form.html.erb (Arbeitskopie) @@ -1,7 +1,7 @@ <%= error_messages_for :group %>
-

<%= f.text_field :lastname, :label => :field_name %>

+

<%= f.text_field :lastname, :label => :field_name, :disabled => (@group.auth_source_id.to_i > 0) %> <%= @group.updated_by_string || "" %>

<% @group.custom_field_values.each do |value| %>

<%= custom_field_tag_with_label :group, value %>

<% end %> Index: config/locales/en.yml =================================================================== --- config/locales/en.yml (Revision 10) +++ config/locales/en.yml (Arbeitskopie) @@ -276,6 +276,11 @@ field_content: Content field_group_by: Group results by field_sharing: Sharing + field_groups: Organisation group + field_prefix: Group name prefix + field_groups2: Function group + field_cross_product: Include combined function/organisation groups + field_group_separator: Function/group separator setting_app_title: Application title setting_app_subtitle: Application subtitle @@ -743,6 +748,7 @@ label_api_access_key: API access key label_missing_api_access_key: Missing an API access key label_api_access_key_created_on: "API access key created {{value}} ago" + label_group_option_plural: Grouping options button_login: Login button_submit: Submit @@ -787,6 +793,7 @@ button_quote: Quote button_duplicate: Duplicate button_show: Show + button_refresh: Refresh groups status_active: active status_registered: registered @@ -853,7 +860,10 @@ text_wiki_page_destroy_children: "Delete child pages and all their descendants" text_wiki_page_reassign_children: "Reassign child pages to this parent page" text_own_membership_delete_confirmation: "You are about to remove some or all of your permissions and may no longer be able to edit this project after that.\nAre you sure you want to continue?" - + text_group_updated_by: "maintained by {{type}} authentication source {{name}}" + text_group_refreshed: "Groups for {{count}} active users refreshed" + text_group_refresh_failed: "Group refresh for {{errors}} failed: {{failures}} (updating for {{success}} active users successful)" + default_role_manager: Manager default_role_developper: Developer default_role_reporter: Reporter Index: config/locales/de.yml =================================================================== --- config/locales/de.yml (Revision 10) +++ config/locales/de.yml (Arbeitskopie) @@ -279,6 +279,11 @@ field_default_value: Standardwert field_comments_sorting: Kommentare anzeigen field_parent_title: Übergeordnete Seite + field_groups: Organisation für Gruppen + field_prefix: Präfix für Gruppennamen + field_groups2: Funktion für Gruppen + field_cross_product: Erzeuge kombinierte Organisations-/Funktionsgruppen + field_group_separator: Trenner zwischen Organisation und Funktion setting_app_title: Applikations-Titel setting_app_subtitle: Applikations-Untertitel @@ -693,6 +698,7 @@ label_generate_key: Generieren label_issue_watchers: Beobachter label_example: Beispiel + label_group_option_plural: Gruppenoptionen button_login: Anmelden button_submit: OK @@ -732,6 +738,7 @@ button_update: Aktualisieren button_configure: Konfigurieren button_quote: Zitieren + button_refresh: Gruppenzugehörigkeit aktualisieren status_active: aktiv status_registered: angemeldet @@ -781,6 +788,9 @@ text_email_delivery_not_configured: "Der SMTP-Server ist nicht konfiguriert und Mailbenachrichtigungen sind ausgeschaltet.\nNehmen Sie die Einstellungen für Ihren SMTP-Server in config/email.yml vor und starten Sie die Applikation neu." text_repository_usernames_mapping: "Bitte legen Sie die Zuordnung der Redmine-Benutzer zu den Benutzernamen der Commit-Log-Meldungen des Projektarchivs fest.\nBenutzer mit identischen Redmine- und Projektarchiv-Benutzernamen oder -E-Mail-Adressen werden automatisch zugeordnet." text_diff_truncated: '... Dieser Diff wurde abgeschnitten, weil er die maximale Anzahl anzuzeigender Zeilen überschreitet.' + text_group_updated_by: "verwaltet durch {{type}}-Anbindung {{name}}" + text_group_refreshed: "Gruppenzugehörigkeit von {{count}} aktiven Benutzern aktualisiert" + text_group_refresh_failed: "Gruppenzugehörigkeit der folgenden {{errors}} Benutzer fehlgeschlagen: {{failures}} (Aktualisierung für {{success}} aktive Benutzer erfolgreich)" default_role_manager: Manager default_role_developper: Entwickler Index: db/schema.rb =================================================================== --- db/schema.rb (Revision 10) +++ db/schema.rb (Arbeitskopie) @@ -9,7 +9,7 @@ # # It's strongly recommended to check this file into your version control system. -ActiveRecord::Schema.define(:version => 20091227112908) do +ActiveRecord::Schema.define(:version => 20100207220329) do create_table "attachments", :force => true do |t| t.integer "container_id", :default => 0, :null => false @@ -43,6 +43,11 @@ t.string "attr_mail", :limit => 30 t.boolean "onthefly_register", :default => false, :null => false t.boolean "tls", :default => false, :null => false + t.string "attr_groups", :limit => 30, :default => "", :null => false + t.string "group_prefix", :limit => 30, :default => "_", :null => false + t.string "attr_groups2", :limit => 30, :default => "", :null => false + t.string "group_separator", :limit => 30, :default => ":", :null => false + t.boolean "cross_product", :default => false, :null => false end add_index "auth_sources", ["id", "type"], :name => "index_auth_sources_on_id_and_type" @@ -473,6 +478,7 @@ add_index "users", ["auth_source_id"], :name => "index_users_on_auth_source_id" add_index "users", ["id", "type"], :name => "index_users_on_id_and_type" + add_index "users", [nil], :name => "index_users_on_lower_login" create_table "versions", :force => true do |t| t.integer "project_id", :default => 0, :null => false Index: db/migrate/20100207220329_extend_ldap_groups.rb =================================================================== --- db/migrate/20100207220329_extend_ldap_groups.rb (Revision 0) +++ db/migrate/20100207220329_extend_ldap_groups.rb (Revision 67) @@ -0,0 +1,13 @@ +class ExtendLdapGroups < ActiveRecord::Migration + def self.up + add_column :auth_sources, :attr_groups2, :string, :limit => 30, :default => "", :null => false + add_column :auth_sources, :group_separator, :string, :limit => 30, :default => ":", :null => false + add_column :auth_sources, :cross_product, :boolean, :default => false, :null => false + end + + def self.down + remove_column :auth_sources, :attr_groups2 + remove_column :auth_sources, :group_separator + remove_column :auth_sources, :cross_product + end +end Index: db/migrate/20100204211355_add_ldap_group_support.rb =================================================================== --- db/migrate/20100204211355_add_ldap_group_support.rb (Revision 0) +++ db/migrate/20100204211355_add_ldap_group_support.rb (Revision 67) @@ -0,0 +1,11 @@ +class AddLdapGroupSupport < ActiveRecord::Migration + def self.up + add_column :auth_sources, :attr_groups, :string, :limit => 30, :default => "", :null => false + add_column :auth_sources, :group_prefix, :string, :limit => 30, :default => "_", :null => false + end + + def self.down + remove_column :auth_sources, :attr_groups + remove_column :auth_sources, :group_prefix + end +end